Files
pinpox b9ca37a597
PR Size Review Check / pr-size-review-gate (pull_request) Successful in 14s
sizelint on each commit / sizelint (pull_request) Successful in 21s
buildbot/nix-eval evaluation succeeded in 1m 3s (3 warnings)
buildbot/nix-build 150 attributes built
PR Size Review Check / pr-size-review-gate (pull_request_review) Successful in 15s
gitea-mq/buildbot/nix-eval evaluation succeeded in 1m 11s (3 warnings)
gitea-mq/buildbot/nix-build gitea:clan/clan-core#checks.aarch64-linux.clan-pytest-with-core From a previous merge queue attempt
gitea-mq/buildbot/nix-build 150 attributes built
gitea-mq Merge queue passed
vars/age: support external store via vars.settings.age.externalStore
The age secret store lives in <flake>/secrets and is committed to the
flake repository. For public flake repos this permanently publishes the
encrypted secrets with their history.

Setting `vars.settings.age.externalStore = true` keeps the entire store
(machine keys, secrets, recipients sidecars) outside the flake. The
flag only commits the intent; each admin points CLAN_AGE_STORE_DIR at
their local copy of the store, so admins of shared infra can keep it in
different places. If that directory is a git repository, clan commits
store changes there with the same messages; nothing store-related
touches the flake repo.

External secrets are not part of the system closure: age.nix decrypts
them from a runtime path under secretLocation (pinned against
populate_dir's upload layout by the age-source-path eval test) and
populate_dir uploads the encrypted files next to the machine key. The
internal store keeps its strict eval-time semantics.

Misconfigurations fail loudly: the flag without the env var, the env
var without the flag, a store dir inside the flake repo, or a non-empty
in-repo store.
2026-09-20 22:54:04 +02:00

346 lines
11 KiB
Nix

{
_class,
config,
options,
lib,
pkgs,
...
}:
let
secretLocation = config.clan.core.vars.age.secretLocation;
externalStore = config.clan.core.vars.settings.age.externalStore;
clanDir = config.clan.core.settings.directory;
isNixOS = _class == "nixos";
isDarwin = _class == "darwin";
# Path layouts: ./age-source-path.nix, ./age-runtime-path.nix.
encryptedSourcePath = import ./age-source-path.nix;
encryptedRuntimePath = import ./age-runtime-path.nix;
# Internal store (default): the .age file is part of the flake and thus of
# the system closure; secrets missing from the flake are skipped at eval
# time (src = null).
# External store (vars.settings.age.externalStore): the store is not in the
# flake; populate_dir uploads the .age files next to the machine key and we
# decrypt them from under secretLocation at runtime.
encryptedSecretSource =
rel_dir: fileName:
if externalStore then
encryptedRuntimePath secretLocation rel_dir fileName
else
let
storePath = encryptedSourcePath clanDir rel_dir fileName;
in
if builtins.pathExists storePath then storePath else null;
# Collect all secret files with their permissions, grouped by phase
secretFiles = lib.concatLists (
lib.mapAttrsToList (
genName: gen:
lib.concatLists (
lib.mapAttrsToList (
fileName: file:
let
src = encryptedSecretSource file.rel_dir fileName;
in
lib.optional (file.secret && file.deploy && src != null) {
inherit genName fileName src;
inherit (file)
rel_dir
owner
group
mode
neededFor
;
}
) gen.files
)
) config.clan.core.vars.generators
);
# Generate a bash snippet that decrypts and sets permissions for files of a given phase.
# Internal store: sources come from the nix store, existence is checked at
# eval time. External store: sources come from the uploaded copy under
# secretLocation, which may be missing at runtime, so guard and warn.
phaseDecryptSnippet =
phase: targetDir:
let
phaseFiles = lib.filter (f: f.neededFor == phase) secretFiles;
in
lib.concatMapStringsSep "\n" (
f:
let
destFile = "${targetDir}/${f.rel_dir}/${f.fileName}";
destDir = "${targetDir}/${f.rel_dir}";
in
''
mkdir -p "${destDir}"
''
+ (
if externalStore then
''
if [ -e "${f.src}" ]; then
age --decrypt -i "$key" -o "${destFile}" "${f.src}"
chmod ${f.mode} "${destFile}"
chown ${f.owner}:${f.group} "${destFile}"
else
echo "WARNING: encrypted secret ${f.src} not found, skipping" >&2
fi
''
else
''
age --decrypt -i "$key" -o "${destFile}" "${f.src}"
chmod ${f.mode} "${destFile}"
chown ${f.owner}:${f.group} "${destFile}"
''
)
) phaseFiles;
# Cross-platform secret filesystem setup.
# Linux: ramfs (secrets never touch disk, no swap)
# macOS: hdiutil RAM disk with HFS (closest equivalent)
mountSecretFs = pkgs.writeShellApplication {
name = "mount-secret-fs";
runtimeInputs = [ pkgs.coreutils ];
text = ''
set -efu -o pipefail
target="$1"
mkdir -p "$target"
case "$(uname)" in
Linux)
if ! mountpoint -q "$target"; then
mount -t ramfs -o mode=0751,nodev,nosuid,noexec ramfs "$target"
fi
;;
Darwin)
# Check if already mounted (marker file)
if [ -f "$target/.age-secretfs" ]; then
exit 0
fi
# 64MB RAM disk: size in 512-byte sectors
numsectors=$(( 64 * 1024 * 1024 / 512 ))
diskpath=$(hdiutil attach -nomount "ram://$numsectors" | tr -d '[:space:]')
newfs_hfs -s "$diskpath"
mount -t hfs -o nobrowse,nodev,nosuid,-m=0751 "$diskpath" "$target"
# Marker so we know it's already set up
touch "$target/.age-secretfs"
;;
*)
echo "Unsupported OS: $(uname)" >&2
exit 1
;;
esac
chmod 751 "$target"
'';
};
decryptAgeSecrets = pkgs.writeShellApplication {
name = "decrypt-age-secrets";
# $key and $effective_target may appear unused when no secrets exist
# for a given phase, since phaseDecryptSnippet generates empty content.
excludeShellChecks = [ "SC2034" ];
runtimeInputs = [
pkgs.age
pkgs.coreutils
mountSecretFs
]
++ lib.optionals isNixOS [
pkgs.util-linux
];
text = ''
set -efu -o pipefail
key="$1" # path to age key file (key.txt)
target="$2" # target mount point (e.g. /run/secrets)
phase="$3" # phase name (e.g. "services", "users")
''
+ (
if isNixOS then
''
# NixOS: atomic mount replacement via `mount --bind --beneath`
mkdir -p "$target".tmp "$target"
if mountpoint -q "$target"; then
mount-secret-fs "$target".tmp
else
mount-secret-fs "$target"
ln -sfn "$target" "$target".tmp || true
fi
if mountpoint -q "$target".tmp && [ "$target".tmp != "$target" ]; then
effective_target="$target".tmp
else
effective_target="$target"
fi
''
else
''
# Darwin: simple RAM disk mount
mount-secret-fs "$target"
effective_target="$target"
''
)
+ ''
# Decrypt and set permissions (generated from Nix, sources from nix store)
case "$phase" in
users)
${phaseDecryptSnippet "users" "$effective_target"}
;;
services)
${phaseDecryptSnippet "services" "$effective_target"}
;;
esac
''
+ lib.optionalString isNixOS ''
# If we used .tmp, atomically bind it beneath the old mount, then
# unmount the old top. `mount --bind --beneath` requires util-linux
# >= 2.40 and avoids the stacked-mount accumulation that a plain
# `--move` would produce on shared subtrees.
if mountpoint -q "$target".tmp && [ "$(stat -c %d "$target".tmp)" != "$(stat -c %d "$target")" ]; then
mount --bind --beneath "$target".tmp "$target"
umount --lazy "$target"
umount "$target".tmp
rmdir "$target".tmp 2>/dev/null || true
fi
'';
};
useSystemdActivation =
isNixOS
&& (
(options.systemd ? sysusers && config.systemd.sysusers.enable)
|| (options.services ? userborn && config.services.userborn.enable)
);
normalSecrets = lib.any (
gen: lib.any (file: file.neededFor == "services") (lib.attrValues gen.files)
) (lib.attrValues config.clan.core.vars.generators);
userSecrets = lib.any (gen: lib.any (file: file.neededFor == "users") (lib.attrValues gen.files)) (
lib.attrValues config.clan.core.vars.generators
);
keyFile = "${secretLocation}/key.txt";
in
{
options.clan.core.vars.age = {
secretLocation = lib.mkOption {
type = lib.types.path;
default = "/etc/secret-vars";
description = ''
Location where the age machine key is uploaded to.
'';
};
};
config = {
clan.core.vars.settings.fileModule =
lib.mkIf (config.clan.core.vars.settings.secretStore == "age")
(
file:
lib.mkIf file.config.secret {
path =
if file.config.neededFor == "users" then
"/run/user-secrets/${file.config.rel_dir}/${file.config.name}"
else if file.config.neededFor == "services" then
"/run/secrets/${file.config.rel_dir}/${file.config.name}"
else if file.config.neededFor == "activation" then
"${secretLocation}/activation/${file.config.rel_dir}/${file.config.name}"
else if file.config.neededFor == "partitioning" then
"/run/partitioning-secrets/${file.config.rel_dir}/${file.config.name}"
else
throw "unknown neededFor ${file.config.neededFor}";
}
);
}
# ── NixOS: activation scripts ──────────────────────────────────────
// lib.optionalAttrs isNixOS {
system.activationScripts =
lib.mkIf ((config.clan.core.vars.settings.secretStore == "age") && !useSystemdActivation)
{
setupUserSecrets = lib.mkIf userSecrets (
lib.stringAfter
[
"specialfs"
]
''
[ -e /run/current-system ] || echo setting up age user secrets...
${decryptAgeSecrets}/bin/decrypt-age-secrets \
"${keyFile}" \
/run/user-secrets \
users
''
// lib.optionalAttrs (config.system ? dryActivationScript) {
supportsDryActivation = true;
}
);
users.deps = lib.mkIf userSecrets [ "setupUserSecrets" ];
setupSecrets = lib.mkIf normalSecrets (
lib.stringAfter
[
"specialfs"
"users"
"groups"
]
''
[ -e /run/current-system ] || echo setting up age secrets...
${decryptAgeSecrets}/bin/decrypt-age-secrets \
"${keyFile}" \
/run/secrets \
services
''
// lib.optionalAttrs (config.system ? dryActivationScript) {
supportsDryActivation = true;
}
);
};
# ── NixOS: systemd services (sysusers/userborn) ────────────────────
systemd.services =
lib.mkIf ((config.clan.core.vars.settings.secretStore == "age") && useSystemdActivation)
{
age-decrypt-user-secrets = lib.mkIf userSecrets {
wantedBy = [ "systemd-sysusers.service" ];
before = [ "systemd-sysusers.service" ];
unitConfig.DefaultDependencies = "no";
serviceConfig = {
Type = "oneshot";
ExecStart = [
"${decryptAgeSecrets}/bin/decrypt-age-secrets ${keyFile} /run/user-secrets users"
];
RemainAfterExit = true;
};
};
age-decrypt-secrets = lib.mkIf normalSecrets {
wantedBy = [ "sysinit.target" ];
after = [ "systemd-sysusers.service" ];
unitConfig.DefaultDependencies = "no";
serviceConfig = {
Type = "oneshot";
ExecStart = [
"${decryptAgeSecrets}/bin/decrypt-age-secrets ${keyFile} /run/secrets services"
];
RemainAfterExit = true;
};
};
};
}
# ── Darwin: launchd + activation ───────────────────────────────────
// lib.optionalAttrs isDarwin {
launchd.daemons = lib.mkIf (config.clan.core.vars.settings.secretStore == "age") {
age-decrypt-secrets = lib.mkIf normalSecrets {
command = "${decryptAgeSecrets}/bin/decrypt-age-secrets ${keyFile} /run/secrets services";
serviceConfig = {
RunAtLoad = true;
KeepAlive = false;
};
};
};
};
}