Compare commits

...

2 commits

7 changed files with 324 additions and 0 deletions

30
bin/secret-translator.jl Normal file
View file

@ -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()

View file

@ -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

154
modules/podman-secrets.nix Normal file
View file

@ -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;
};
}

View file

@ -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

BIN
secrets/authentik.toml.age Normal file

Binary file not shown.

128
services/authentik.nix Normal file
View file

@ -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"
];
};
};
};
}

View file

@ -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"; };
}