diff --git a/bin/secret-translator.jl b/bin/secret-translator.jl new file mode 100644 index 0000000..ce50af8 --- /dev/null +++ b/bin/secret-translator.jl @@ -0,0 +1,30 @@ +#!/usr/bin/env julia +using TOML: TOML + +function remove_podman_secrets() + run(`podman secret rm --all`) + return nothing +end #function + +function create_podman_secret(name, secret) + run( + Cmd( + `podman secret create --env=true --replace $name SECRET`; + env = Dict("SECRET" => secret), + ), + ) + return nothing +end #function + +function parse_toml_secrets(file) + foreach(f -> create_podman_secret(f[1], f[2]), TOML.parsefile(file)) + return nothing +end #function + +function main() + remove_podman_secrets() + foreach(parse_toml_secrets, ARGS) + return 0 +end #function + +main() diff --git a/flake.nix b/flake.nix index a703651..204941e 100644 --- a/flake.nix +++ b/flake.nix @@ -138,9 +138,13 @@ "mcentire" = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; + specialArgs = { + home-manager-quadlet-nix = quadlet-nix.homeManagerModules.quadlet; + }; modules = [ ./systems/linux/mcentire.nix agenix.nixosModules.default + home-manager.nixosModules.home-manager quadlet-nix.nixosModules.quadlet crowdsec.nixosModules.crowdsec crowdsec.nixosModules.crowdsec-firewall-bouncer diff --git a/modules/podman-secrets.nix b/modules/podman-secrets.nix new file mode 100644 index 0000000..41a01c5 --- /dev/null +++ b/modules/podman-secrets.nix @@ -0,0 +1,154 @@ +# Warning to my future self: This module was "vibe coded" by Claude Sonnet 4.5. +# I originally had a hand-written module in here that did the secrets +# translation as the root user. I knew that I would want to support multiple +# secrets files and multiple users, and thought I should be able to create an +# arbitrary number of them, similar to how you can have an arbitrary number +# of `programs.firefox.profiles`. Unfortunately, even after looking at lots of +# example modules, I could not figure out the syntax (Nix has a serious lack +# of minimum working examples), so I broke down and asked Claude to rewrite it +# for me. Based on everything that I read, this seems to be exactly what I asked +# for. +# +# Here is the prompt I used to get here: +# [@Per service isolation on NixOS with Traefik](zed:///agent/thread/94cb8a22-ff0c-4772-a1a1-018b0107f334?name=Per+service+isolation+on+NixOS+with+Traefik) +# +# [@podman-secrets.nix](file:///home/millironx/.config/home-manager/modules/podman-secrets.nix) +# +# I originally wrote this module assuming that I would use Podman as root for +# all containers. I would like to fix the module to have as many secrets files +# processed as needed with services setup for each secret that is run as the +# appropriate user. Ideally, the syntax for working with secrets to be +# translated would be +# +# ```nix +# { config, ... }: { +# age.secrets = { +# "caddy.toml" = { +# file = ./../secrets/caddy.toml.age; +# owner = "caddy"; +# group = "caddy"; +# }; +# +# "authentik.toml" = { +# file = ./../secrets/authentik.toml.age; +# owner = "authentik"; +# group = "authentik"; +# }; +# +# "freshrss.toml" = { +# file = ./../secrets/freshrss.toml.age; +# owner = "freshrss"; +# group = "freshrss"; +# }; +# }; +# +# millironx.podman-secrets = with config; { +# caddy = { +# user = "caddy"; +# secrets-files = [ +# ./../not-really-secret.toml +# age.secrets."caddy.toml".path +# ]; +# }; +# authentik = { +# user = "authentik"; +# secrets-files = [ +# age.secrets."authentik.toml".path +# ]; +# }; +# freshrss = { +# user = "freshrss"; +# secrets-files = [ +# age.secrets."freshrss.toml".path +# ]; +# }; +# }; +# } +# ``` +# +# Can you help me rewrite the module to accomplish this? +# +{ config, lib, pkgs, ... }: +with lib; +let + cfg = config.millironx.podman-secrets; + + secret-translator = pkgs.writeScriptBin "secret-translator" + (builtins.readFile ./../bin/secret-translator.jl); + + # Submodule type for each service's secrets configuration + serviceSecretsType = types.submodule { + options = { + user = mkOption { + type = types.str; + description = "User account to run the secrets translation service as"; + example = "caddy"; + }; + + secrets-files = mkOption { + type = types.listOf (types.either types.path types.string); + description = + "List of TOML files containing secrets to translate to Podman secrets"; + example = literalExpression '' + [ + "/run/agenix/caddy.toml" + ./../not-really-secret.toml + ] + ''; + default = [ ]; + }; + }; + }; + + # Generate a systemd service for each configured service + mkSecretsService = name: serviceCfg: + nameValuePair "podman-secrets-${name}" { + description = "Podman secrets converter service for ${name}"; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "oneshot"; + User = serviceCfg.user; + ExecStart = "${secret-translator}/bin/secret-translator ${ + lib.concatStringsSep " " serviceCfg.secrets-files + }"; + Path = [ "${pkgs.podman}/bin" "${pkgs.julia-lts-bin}/bin" ]; + }; + }; + + # Filter out services with no secrets-files + enabledServices = + lib.filterAttrs (name: serviceCfg: serviceCfg.secrets-files != [ ]) cfg; + +in { + options.millironx.podman-secrets = mkOption { + type = types.attrsOf serviceSecretsType; + description = '' + Per-service Podman secrets configuration. + Each attribute creates a separate systemd service that translates TOML secrets + files into Podman secrets for the specified user. + ''; + example = literalExpression '' + { + caddy = { + user = "caddy"; + secrets-files = [ + ./../not-really-secret.toml + config.age.secrets."caddy.toml".path + ]; + }; + authentik = { + user = "authentik"; + secrets-files = [ + config.age.secrets."authentik.toml".path + ]; + }; + } + ''; + default = { }; + }; + + config = mkIf (enabledServices != { }) { + systemd.services = lib.mapAttrs' mkSecretsService enabledServices; + }; +} diff --git a/secrets.nix b/secrets.nix index 1de9bc8..8042bf6 100644 --- a/secrets.nix +++ b/secrets.nix @@ -26,6 +26,8 @@ let in { "secrets/ansible-vault-password.age".publicKeys = system-administrators; + "secrets/authentik.toml.age".publicKeys = system-administrators + ++ [ mcentire-host ]; "secrets/borgmatic-passphrase.age".publicKeys = system-administrators ++ [ mcentire-host ]; "secrets/borgmatic-ssh-config.age".publicKeys = system-administrators diff --git a/secrets/authentik.toml.age b/secrets/authentik.toml.age new file mode 100644 index 0000000..1ef8f6e Binary files /dev/null and b/secrets/authentik.toml.age differ diff --git a/services/authentik.nix b/services/authentik.nix new file mode 100644 index 0000000..30ee781 --- /dev/null +++ b/services/authentik.nix @@ -0,0 +1,128 @@ +{ config, home-manager-quadlet-nix, ... }: +let + user = "authentik"; + state-directory = "/var/lib/authentik"; + port = "9000"; + +in { + # Secrets are translated in the system scope, but performed by the user, + # so are available in the user's Podman secrets + age.secrets = { + "authentik.toml" = { + file = ./../secrets/authentik.toml.age; + owner = "${user}"; + }; + }; + + millironx.podman-secrets.authentik = { + user = "${user}"; + secrets-files = [ config.age.secrets."authentik.toml".path ]; + }; + + systemd.tmpfiles.rules = [ "d ${state-directory} 1775 ${user} ${user} -" ]; + + services.caddy.virtualHosts."auth.millironx.com".extraConfig = '' + reverse_proxy http://127.0.0.1:${port} + ''; + + # Create a dedicated user for this service + users.users."${user}" = { + group = "${user}"; + isSystemUser = true; + linger = true; + autoSubUidGidRange = true; + }; + users.groups."${user}" = {}; + + home-manager.users."${user}" = { config, ... }: { + imports = [ home-manager-quadlet-nix ]; + + home.stateVersion = "25.05"; + + virtualisation.quadlet.containers = { + authentik-db = { + autoStart = true; + containerConfig = { + image = "docker.io/library/postgres:16"; + environments = { + POSTGRES_DB = "${user}"; + POSTGRES_USER = "${user}"; + }; + secrets = [ + "AUTHENTIK_POSTGRESQL__PASSWORD,type=env,target=POSTGRES_PASSWORD" + ]; + healthCmd = "pg_isready -d \${POSTGRES_DB} -U \${POSTGRES_USER}"; + healthInterval = "30s"; + healthRetries = 5; + healthStartPeriod = "20s"; + volumes = [ "${state-directory}/database:/var/lib/postgresql/data" ]; + }; + }; + + authentik-worker = { + autoStart = true; + containerConfig = { + image = "ghcr.io/goauthentik/server:2025.10.2"; + environments = { + AUTHENTIK_POSTGRESQL__HOST = "authentik-db"; + AUTHENTIK_POSTGRESQL__NAME = "${user}"; + AUTHENTIK_POSTGRESQL__USER = "${user}"; + }; + exec = "worker"; + secrets = [ + "AUTHENTIK_POSTGRESQL__PASSWORD,type=env" + "AUTHENTIK_SECRET_KEY,type=env" + ]; + volumes = [ + "${state-directory}/media:/media" + "${state-directory}/custom-templates:/templates" + "${state-directory}/certs:/certs" + ]; + }; + unitConfig.Requires = [ + config.virtualisation.quadlet.containers.authentik-db.ref + "network-online.target" + ]; + unitConfig.After = [ + config.virtualisation.quadlet.containers.authentik-db.ref + "network-online.target" + ]; + }; + + authentik = { + autoStart = true; + containerConfig = { + image = "ghcr.io/goauthentik/server:2025.10.2"; + environments = { + AUTHENTIK_POSTGRESQL__HOST = "authentik-db"; + AUTHENTIK_POSTGRESQL__NAME = "${user}"; + AUTHENTIK_POSTGRESQL__USER = "${user}"; + }; + secrets = [ + "AUTHENTIK_POSTGRESQL__PASSWORD,type=env" + "AUTHENTIK_SECRET_KEY,type=env" + "AUTHENTIK_EMAIL__HOST,type=env" + "AUTHENTIK_EMAIL__PORT,type=env" + "AUTHENTIK_EMAIL__USERNAME,type=env" + "AUTHENTIK_EMAIL__PASSWORD,type=env" + "AUTHENTIK_EMAIL__USE_SSL,type=env" + "AUTHENTIK_EMAIL__FROM,type=env" + ]; + publishPorts = [ "127.0.0.1:${port}:${port}" ]; + volumes = [ + "${state-directory}/media:/media" + "${state-directory}/custom-templates:/templates" + ]; + }; + unitConfig.Requires = [ + config.virtualisation.quadlet.containers.authentik-db.ref + "network-online.target" + ]; + unitConfig.After = [ + config.virtualisation.quadlet.containers.authentik-db.ref + "network-online.target" + ]; + }; + }; + }; +} diff --git a/systems/linux/mcentire.nix b/systems/linux/mcentire.nix index 60fd158..4a1b09a 100644 --- a/systems/linux/mcentire.nix +++ b/systems/linux/mcentire.nix @@ -3,9 +3,11 @@ { imports = [ # Include the results of the hardware scan. ./hardware-configuration/mcentire.nix + ./../../modules/podman-secrets.nix ./../../services/nixos-update.nix ./../../services/borgmatic.nix ./../../services/crowdsec.nix + ./../../services/authentik.nix ]; # Use the GRUB 2 boot loader. @@ -16,6 +18,7 @@ useDHCP = false; interfaces.eth0.useDHCP = true; hostName = "mcentire"; # Define your hostname. + firewall.allowedTCPPorts = [ 80 443 ]; }; # Set your time zone. @@ -55,8 +58,11 @@ services = { openssh.enable = true; tailscale.enable = true; + caddy.enable = true; }; + virtualisation.quadlet.enable = true; + system.stateVersion = "25.05"; # Did you read the comment? nix = { extraOptions = "experimental-features = nix-command flakes"; }; }