Compare commits

..

5 Commits

  1. 69
      flake.nix
  2. 25
      machines/core.domain.com/configuration.nix
  3. 2
      machines/core.domain.com/hardware-configuration.nix
  4. 23
      machines/deploy.domain.com/configuration.nix
  5. 28
      machines/deploy.domain.com/hardware-configuration.nix
  6. 61
      modules/acme.nix
  7. 37
      modules/gatus_service.nix
  8. 138
      modules/gitea.nix
  9. 7
      modules/keycloak.nix
  10. 31
      modules/nginx.nix

@ -37,16 +37,63 @@
sharedModules = [ sharedModules = [
./modules/users.nix ./modules/users.nix
./modules/auto-update.nix ./modules/auto-update.nix
./modules/nginx.nix
./modules/acme.nix
]; ];
sharedArgs = { # Central subdomain -> service mapping.
inherit domain git_url key_mappings; # Each entry can set:
# description human-readable label (used as the gatus monitor name)
# ports.http local port nginx proxies to
# websockets enable WebSocket proxying
# gatus_health health-check path monitored by gatus (e.g. "/health").
# Leave empty/unset to hide this host from the status page.
vhosts = {
"${domain}" = {
description = "Website";
};
"auth.${domain}" = {
description = "Keycloak";
ports = {"http" = 8080; };
gatus_health = "/health";
};
"status.${domain}" = {
description = "Gatus status page";
ports = {"http" = 8081; };
gatus_health = "/";
};
"pad.${domain}" = {
# TODO: not implemented (hidden from gatus until gatus_health is set)
description = "HedgeDoc (Pad)";
ports = {"http" = 3001; "httpws" = 3001; };
websockets = true;
};
"git.${domain}" = {
description = "Gitea";
ports = { "http" = 3000; "ssh" = 22; };
gatus_health = "/";
};
"tickets.${domain}" = {
description = "Pretix";
};
"cfp.${domain}" = {
description = "Pretalx";
};
"matrix.${domain}" = {
description = "Matrix";
};
};
baseArgs = {
inherit domain git_url key_mappings vhosts;
}; };
in { in {
nixosConfigurations = { nixosConfigurations = {
auth.domain.com = nixpkgs.lib.nixosSystem { core.domain.com = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; system = "x86_64-linux";
specialArgs = sharedArgs; specialArgs = baseArgs // {
domains = [ "auth.${domain}" "status.${domain}" ];
};
modules = [ modules = [
./machines/auth.domain.com/configuration.nix ./machines/auth.domain.com/configuration.nix
./machines/auth.domain.com/hardware-configuration.nix ./machines/auth.domain.com/hardware-configuration.nix
@ -55,12 +102,22 @@
] ++ sharedModules; ] ++ sharedModules;
}; };
status.domain.com = nixpkgs.lib.nixosSystem { deploy.domain.com = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; system = "x86_64-linux";
specialArgs = sharedArgs; specialArgs = baseArgs // {
domains = [
"${domain}"
"pad.${domain}"
"git.${domain}"
"tickets.${domain}"
"cfp.${domain}"
"matrix.${domain}"
];
};
modules = [ modules = [
./machines/status.domain.com/configuration.nix ./machines/status.domain.com/configuration.nix
./machines/status.domain.com/hardware-configuration.nix ./machines/status.domain.com/hardware-configuration.nix
./modules/gitea.nix
] ++ sharedModules; ] ++ sharedModules;
}; };
}; };

@ -3,13 +3,12 @@
{ {
imports = [ imports = [
./hardware-configuration.nix ./hardware-configuration.nix
../../modules/keycloak.nix
]; ];
boot.loader.grub.enable = true; boot.loader.grub.enable = true;
boot.loader.grub.device = "/dev/sda"; boot.loader.grub.device = "/dev/sda";
networking.hostName = "auth"; networking.hostName = "core";
networking.domain = "domain.com"; networking.domain = "domain.com";
time.timeZone = "UTC"; time.timeZone = "UTC";
@ -29,6 +28,11 @@
bash bash
]; ];
infra.acme = {
enable = true;
email = "admin@${config.networking.domain}";
};
services.keycloak = { services.keycloak = {
enable = true; enable = true;
hostname = "auth.domain.com"; hostname = "auth.domain.com";
@ -43,25 +47,14 @@
enable = true; enable = true;
hostname = "status.domain.com"; hostname = "status.domain.com";
title = "Status"; title = "Status";
endpoints = [
{
name = "Keycloak";
url = "https://auth.domain.com/health";
interval = "1m";
conditions = [
"[STATUS] == 200"
"[RESPONSE_TIME] < 500"
];
}
];
}; };
infra.autoUpdate = { infra.autoUpdate = {
enable = true; enable = true;
# selects the nixosConfigurations.auth.domain.com output from the flake # selects the nixosConfigurations.core.domain.com output from the flake
flake = "${git_url}#auth.domain.com"; flake = "${git_url}#core.domain.com";
allowReboot = true; allowReboot = true;
}; };
system.stateVersion = "26.05"; system.stateVersion = "26.05";
} }

@ -25,4 +25,4 @@
networking.useDHCP = lib.mkDefault true; networking.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
} }

@ -8,7 +8,7 @@
boot.loader.grub.enable = true; boot.loader.grub.enable = true;
boot.loader.grub.device = "/dev/sda"; boot.loader.grub.device = "/dev/sda";
networking.hostName = "status"; networking.hostName = "deploy";
networking.domain = "domain.com"; networking.domain = "domain.com";
time.timeZone = "UTC"; time.timeZone = "UTC";
@ -28,12 +28,27 @@
bash bash
]; ];
infra.acme = {
enable = true;
email = "admin@${config.networking.domain}";
};
infra.gitea = {
enable = true;
hostname = "git.${config.networking.domain}";
oidc = {
enable = true;
clientId = "gitea";
clientSecretFile = "/etc/gitea-oidc-secret";
};
};
infra.autoUpdate = { infra.autoUpdate = {
enable = true; enable = true;
# selects the nixosConfigurations.status.domain.com output from the flake # selects the nixosConfigurations.deploy.domain.com output from the flake
flake = "${git_url}#status.domain.com"; flake = "${git_url}#deploy.domain.com";
allowReboot = true; allowReboot = true;
}; };
system.stateVersion = "26.05"; system.stateVersion = "26.05";
} }

@ -0,0 +1,28 @@
{ config, lib, pkgs, modulesPath, ... }:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [ "xhci_pci" "ehci_pci" "ahci" "usb_storage" "sd_mod" "sr_mod" ];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
fileSystems."/boot" = {
device = "/dev/disk/by-label/boot";
fsType = "vfat";
};
swapDevices = [ { device = "/dev/disk/by-label/swap"; } ];
networking.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}

@ -0,0 +1,61 @@
# Let's Encrypt (ACME) certificate issuance, per-host HTTP-01.
#
# Termination & deployment scheme
# -------------------------------
# Each NixOS host terminates TLS itself for the subdomains it serves.
# There is no central certificate store and no distribution step.
#
# 1. A host declares which subdomains it serves via `domains = [ ... ]`
# in flake.nix (consumed by modules/nginx.nix).
# 2. With `infra.acme.enable = true`, the nginx module sets
# `enableACME = true; forceSSL = true;` on every served vhost.
# 3. security.acme requests one cert per vhost via the HTTP-01
# challenge; nginx answers /.well-known/acme-challenge/ on :80.
# 4. On issue/renewal, nginx is reloaded automatically
# (`reloadServices`), so the new certificate goes live at once.
# 5. Renewals run on a systemd timer; every host (core, deploy, ...)
# keeps its own certificates fresh — nothing is deployed by hand.
#
# Adding a new endpoint (e.g. deploy.domain.com):
# - add the host (or add the subdomain to an existing host's `domains`),
# - enable `infra.acme` on that host,
# - point the subdomain's DNS at the host and leave :80 reachable so
# the HTTP-01 challenge can complete.
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.infra.acme;
in {
options.infra.acme = {
enable = mkEnableOption "Let's Encrypt (ACME) TLS certificates";
email = mkOption {
type = types.str;
description = "Account email used for Let's Encrypt";
};
staging = mkOption {
type = types.bool;
default = false;
description = ''
Use the Let's Encrypt staging environment
(untrusted certificates, no rate limiting) for testing.
'';
};
};
config = mkIf cfg.enable {
security.acme = {
acceptTerms = true;
defaults = {
inherit (cfg) email;
reloadServices = [ "nginx.service" ];
} // optionalAttrs cfg.staging {
server = "https://acme-staging-v02.api.letsencrypt.org/directory";
};
};
};
}

@ -1,9 +1,26 @@
{ config, lib, pkgs, domain, ... }: { config, lib, pkgs, domain, vhosts ? { }, ... }:
with lib; with lib;
let let
cfg = config.infra.gatus; cfg = config.infra.gatus;
vhost = vhosts.${cfg.hostname} or { };
port = vhost.ports.http or 8081;
# Auto-infer monitored hosts from the central vhosts map.
# Only entries with a non-empty `gatus_health` path are monitored.
monitored = lib.filterAttrs (_: v: (v.gatus_health or "") != "") vhosts;
autoEndpoints = lib.mapAttrsToList (name: v:
{
name = v.description or name;
url = "https://${name}${v.gatus_health}";
interval = "1m";
conditions = [
"[STATUS] == 200"
"[RESPONSE_TIME] < 1000"
];
}
) monitored;
in { in {
options.infra.gatus = { options.infra.gatus = {
enable = mkEnableOption "Gatus status page behind nginx"; enable = mkEnableOption "Gatus status page behind nginx";
@ -14,12 +31,6 @@ in {
description = "Virtual host name serving the Gatus status page"; description = "Virtual host name serving the Gatus status page";
}; };
port = mkOption {
type = types.port;
default = 8081;
description = "Port Gatus listens on locally";
};
title = mkOption { title = mkOption {
type = types.str; type = types.str;
default = "Status"; default = "Status";
@ -47,24 +58,16 @@ in {
enable = true; enable = true;
settings = mkMerge [ settings = mkMerge [
{ {
web.port = cfg.port; web.port = port;
storage = { storage = {
type = "sqlite"; type = "sqlite";
path = "/var/lib/gatus/data.db"; path = "/var/lib/gatus/data.db";
}; };
ui.title = cfg.title; ui.title = cfg.title;
endpoints = cfg.endpoints; endpoints = autoEndpoints ++ cfg.endpoints;
} }
cfg.extraSettings cfg.extraSettings
]; ];
}; };
services.nginx = {
enable = true;
virtualHosts.${cfg.hostname}.locations."/".proxyPass =
"http://127.0.0.1:${toString cfg.port}";
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
}; };
} }

@ -0,0 +1,138 @@
{ config, lib, pkgs, domain, vhosts ? { }, ... }:
with lib;
let
cfg = config.infra.gitea;
vhost = vhosts.${cfg.hostname} or { };
httpPort = vhost.ports.http or 3000;
sshPort = vhost.ports.ssh or 2222;
giteaBin = "${config.services.gitea.package}/bin/gitea";
discoveryUrl = "${cfg.oidc.issuerUrl}/.well-known/openid-configuration";
in {
options.infra.gitea = {
enable = mkEnableOption "Gitea with Keycloak OIDC";
hostname = mkOption {
type = types.str;
default = "git.${domain}";
description = "Public hostname for Gitea";
};
oidc = {
enable = mkOption {
type = types.bool;
default = true;
description = "Register Keycloak as an OIDC authentication source";
};
name = mkOption {
type = types.str;
default = "Keycloak";
description = "Display name for the OIDC auth source";
};
clientId = mkOption {
type = types.str;
description = "Keycloak OIDC client id";
};
clientSecretFile = mkOption {
type = types.path;
description = ''
Path to a file (readable by the gitea user) containing the
Keycloak OIDC client secret.
'';
};
issuerUrl = mkOption {
type = types.str;
default = "https://auth.${domain}/realms/master";
description = "Keycloak realm issuer URL";
};
};
};
config = mkIf cfg.enable {
services.gitea = {
enable = true;
settings = {
server = {
DOMAIN = cfg.hostname;
ROOT_URL = "https://${cfg.hostname}/";
PROTOCOL = "http";
HTTP_ADDR = "127.0.0.1";
HTTP_PORT = httpPort;
SSH_DOMAIN = cfg.hostname;
SSH_PORT = sshPort;
START_SSH_SERVER = true;
SSH_LISTEN_PORT = sshPort;
LANDING_PAGE = "explore";
};
service = {
DISABLE_REGISTRATION = false;
SHOW_REGISTRATION_BUTTON = false;
};
openid = {
ENABLE_OPENID_SIGNIN = true;
ENABLE_OPENID_SIGNUP = true;
};
oauth2_client = {
ENABLE_AUTO_REGISTRATION = true;
ACCOUNT_LINKING = "login";
UPDATE_AVATAR = true;
};
};
};
networking.firewall.allowedTCPPorts = [ sshPort ];
systemd.services.gitea-oidc = mkIf cfg.oidc.enable {
description = "Register Keycloak OIDC authentication source in Gitea";
after = [ "gitea.service" ];
bindsTo = [ "gitea.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
User = "gitea";
Group = "gitea";
RemainAfterExit = true;
};
environment = {
GITEA_CUSTOM = "/var/lib/gitea/custom";
GITEA_WORK_DIR = "/var/lib/gitea";
};
script = ''
secret="$(cat ${cfg.oidc.clientSecretFile})"
# Wait until Gitea has finished initialising its database
for i in $(seq 1 60); do
if ${giteaBin} admin auth list > /tmp/gitea-auth-list 2>/dev/null; then
break
fi
sleep 2
done
${giteaBin} admin auth list > /tmp/gitea-auth-list || true
existing=$(grep -w '${cfg.oidc.name}' /tmp/gitea-auth-list | awk '{print $1}' | head -n1)
if [ -n "$existing" ]; then
${giteaBin} admin auth update-oauth --id "$existing" \
--name '${cfg.oidc.name}' \
--provider openidConnect \
--key '${cfg.oidc.clientId}' \
--secret "$secret" \
--auto-discover-url '${discoveryUrl}'
else
${giteaBin} admin auth add-oauth \
--name '${cfg.oidc.name}' \
--provider openidConnect \
--key '${cfg.oidc.clientId}' \
--secret "$secret" \
--auto-discover-url '${discoveryUrl}'
fi
'';
};
};
}

@ -1,9 +1,11 @@
{ config, lib, pkgs, ... }: { config, lib, pkgs, vhosts ? { }, ... }:
with lib; with lib;
let let
cfg = config.services.keycloak; cfg = config.services.keycloak;
vhost = vhosts.${cfg.hostname} or { };
port = vhost.ports.http or 8080;
in { in {
options.services.keycloak = { options.services.keycloak = {
enable = mkEnableOption "Keycloak identity provider"; enable = mkEnableOption "Keycloak identity provider";
@ -61,6 +63,7 @@ in {
enable = true; enable = true;
settings = { settings = {
hostname = cfg.hostname; hostname = cfg.hostname;
http-port = port;
hostname-strict-backchannel = true; hostname-strict-backchannel = true;
proxy = "edge"; proxy = "edge";
db = mkDefault "postgres"; db = mkDefault "postgres";
@ -71,7 +74,5 @@ in {
admin-password-file = cfg.adminPasswordFile; admin-password-file = cfg.adminPasswordFile;
} // cfg.settings; } // cfg.settings;
}; };
networking.firewall.allowedTCPPorts = [ 80 443 ];
}; };
} }

@ -0,0 +1,31 @@
{ config, lib, pkgs, vhosts ? { }, domains ? [ ], ... }:
let
served = lib.filterAttrs (k: _: builtins.elem k domains) vhosts;
enabled = served != { };
acme = config.infra.acme.enable or false;
in {
config = lib.mkIf enabled {
services.nginx = {
enable = true;
virtualHosts = lib.mapAttrs (name: vhost:
let
port = vhost.ports.http or null;
hasProxy = port != null;
root = vhost.root or null;
in {
enableACME = acme;
forceSSL = acme;
root = lib.mkIf (root != null) root;
extraConfig = vhost.extraConfig or "";
locations."/" = lib.mkIf hasProxy {
proxyPass = "http://localhost:${toString port}";
proxyWebsockets = vhost.websockets or false;
};
}
) served;
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
};
}
Loading…
Cancel
Save