feat(security): harden homelab SSH, services and audit trail

The homelab hosts accept root SSH with long-lived file keys, run several
exposed services with almost no systemd sandboxing, and keep no audit trail of
privileged actions. Close those three gaps, weighing each measure against its
cost instead of adopting a policy wholesale.

SSH
- Turn off KbdInteractiveAuthentication: nixpkgs defaults it to true and PAM
  then still accepts the Unix password despite PasswordAuthentication = false.
- authorizedKeysInHomedir = false: only keys declared in Nix take effect;
  youko keeps Gitea's forced-command file through `Match User gitea`.
- Pin aquamarine's passphrase-free remote-builder keys to
  `restrict,command="nix-daemon --stdio --store daemon"`, so builds run in the
  system daemon instead of an audited root login session.

systemd sandboxing
- Add the mylib.systemdHardening preset and apply it to the exposed, weakly
  sandboxed services: caddy 7.8 -> 1.5, vmalert 8.2 -> 1.4, rustfs 6.6 -> 1.4,
  whisper-asr 7.7 -> 1.6. An eval test pins the key restrictions.
- Rejected: a global all-keys policy (<=0.2 score gains on services nixpkgs
  already sandboxes) and per-service confinement (bind paths to maintain).

auditd on all four hosts, reusing nixpkgs' loader and unit
- Audit login sessions only: a first `-a never,task -F auid=unset` rule keeps
  services out of the per-syscall audit path. A compromised service is
  therefore unrecorded until it opens a session.
- Record root commands, credential and host-key access, writes to SSH, account,
  PAM, Nix-trust and audit configuration, module loads and session denials.
- Rejected: global denial logging, service-UID watches and
  --loginuid-immutable (the kernel already records loginuid changes).

Verified on all four hosts after deployment: zero failed units, k3s 6/6 Ready,
and the exposed services healthy.

Assisted-by: Claude Code (claude-opus-5-5)
This commit is contained in:
Ryan Yin committed 2026-10-31 01:09:15 +08:00
1 parent 0376697cef
commit 7dc4e7a39f
28 files changed
+578 -29

No files matched your search

+1
View File
@@ -27,6 +27,7 @@ modules/
│ └── users.nix # macOS user management
└── nixos/ # NixOS-specific modules
├── base/ # Base NixOS configuration
│ ├── auditd.nix # Audit trail for root sessions and credentials
│ ├── btop.nix # System resource monitor
│ ├── btrbk.nix # Local btrfs snapshots
│ ├── core.nix # Core system settings
+128
View File
@@ -0,0 +1,128 @@
{
config,
lib,
options,
pkgs,
...
}:
let
cfg = config.modules.security.audit;
inherit (config.services) openssh;
# Audit trail for login sessions, so that a stolen deployment key leaves a
# record of what each session did; see SECURITY.md ("Audit trail").
# `auditctl -R` aborts at the first watch whose path is missing, leaving
# auditing off, so every watched path must exist when the rules load.
# Secrets are symlinks into a per-deploy generation under the agenix ramfs,
# which a per-file watch would lose on the next deploy; watch the ramfs tree.
agenixSecrets = lib.optionals (options ? age) (lib.attrValues config.age.secrets);
hasAgenix = agenixSecrets != [ ];
credentialDirs = lib.optional hasAgenix config.age.secretsMountPoint;
credentialFiles = lib.unique (
map (secret: secret.path) (lib.filter (secret: !secret.symlink) agenixSecrets)
# The host key is also agenix's identity: reading it decrypts every secret.
++ lib.optionals openssh.enable (map (key: key.path) openssh.hostKeys)
++ lib.optionals hasAgenix config.age.identityPaths
);
identityFiles = map (name: "/etc/${name}") [
"passwd"
"shadow"
"group"
"gshadow"
"sudoers"
];
watchedDirs = credentialDirs ++ [
"/etc/ssh"
"/etc/pam.d"
"/etc/nix"
"/etc/audit"
"/var/log/audit"
];
watchedFiles = credentialFiles ++ identityFiles ++ [ "/nix/var/nix/profiles/system" ];
watchFile =
key: perm: path:
"-a always,exit -F path=${path} -F perm=${perm} -k ${key}";
watchDir =
key: perm: dir:
"-a always,exit -F dir=${dir} -F perm=${perm} -k ${key}";
# A b64-only syscall rule misses 32-bit binaries.
arches = [ "b64" ] ++ lib.optional pkgs.stdenv.hostPlatform.isx86_64 "b32";
perArch = rule: map (arch: "-a always,exit -F arch=${arch} ${rule}") arches;
in
{
options.modules.security.audit.enable = lib.mkEnableOption ''
the Linux audit trail (auditd) for login sessions: every command run as
root, access to decrypted secrets and SSH host keys, and changes to SSH,
account, PAM, Nix trust and audit configuration. Logs stay in
/var/log/audit on the host
'';
config = lib.mkIf cfg.enable {
assertions = [
{
# auditctl -R splits fields on whitespace; it does not shell-unquote paths.
assertion = lib.all (path: builtins.match ".*[[:space:]].*" path == null) (
watchedDirs ++ watchedFiles
);
message = "modules.security.audit: audited paths cannot contain whitespace in the native auditctl rule file.";
}
];
security.auditd.enable = true;
security.audit = {
enable = true;
backlogLimit = lib.mkDefault 8192;
# The first matching exit rule sets the key: specific watches go first.
rules = lib.mkBefore (
# Evaluated at fork: tasks without a login uid (everything systemd
# starts) get no audit context, so no per-syscall cost.
[ "-a never,task -F auid=unset" ]
++ map (watchFile "credential-access" "rwa") credentialFiles
++ map (watchDir "credential-access" "rwa") credentialDirs
++ [ (watchDir "ssh-config" "wa" "/etc/ssh") ]
++ map (watchFile "identity" "wa") identityFiles
++ [
(watchDir "identity" "wa" "/etc/pam.d")
(watchDir "nix-trust" "wa" "/etc/nix")
(watchFile "nix-trust" "wa" "/nix/var/nix/profiles/system")
(watchDir "audit-config" "wa" "/etc/audit")
(watchDir "audit-config" "wa" "/var/log/audit")
]
++ perArch "-S init_module,finit_module,delete_module -k kernel-module"
++ perArch "-S execve,execveat -F euid=0 -F auid!=unset -k root-session"
++ [
"-a always,exit -S all -F exit=-EACCES -F auid!=unset -k access-denied"
"-a always,exit -S all -F exit=-EPERM -F auid!=unset -k access-denied"
]
);
};
security.auditd.settings = {
log_file = "/var/log/audit/audit.log";
max_log_file_action = "rotate";
max_log_file = 50; # MiB
num_logs = 5;
};
# Parents of watches that may not exist yet on a fresh system.
systemd.tmpfiles.rules = [
"d /var/log/audit 0700 root root -"
"d /nix/var/nix/profiles 0755 root root -"
];
# A watch binds to the inode present at load time, so load after the
# bind mounts (preservation), tmpfiles and the agenix ramfs.
systemd.services.audit-rules-nixos = {
after = [
"local-fs.target"
"systemd-tmpfiles-setup.service"
]
++ lib.optional hasAgenix "agenix-install-secrets.service";
unitConfig.RequiresMountsFor = lib.unique (watchedDirs ++ map dirOf watchedFiles);
};
};
}
+4
View File
@@ -14,8 +14,12 @@
# root login stays available for Colmena/MicroVM deployment, keys only
PermitRootLogin = lib.mkDefault "prohibit-password";
PasswordAuthentication = false; # disable password login
# nixpkgs defaults it to true, and PAM then still accepts the Unix password
KbdInteractiveAuthentication = false;
};
openFirewall = true;
# Only keys declared in Nix log in; a hand- or intruder-added key is ignored
authorizedKeysInHomedir = false;
};
# NixOS appends an `Include` of systemd's ssh_config.d drop-in to