/secrets is the authoritative location for every secret, incl. the ssh host keys

Operator rule: secrets live in /secrets and consumers reach them from there.
Three subdirectories with the ownership each consumer needs — files/ (loops),
host/ (root: ssh host keys + sops age identity), nginx/ (root:nginx: the
opencode UI htpasswd) — under a 0711 /secrets so nginx can traverse to its own
without the directory being listable.

sshd's hostKeys and sops-nix's sshKeyPaths/keyFile are pointed at /secrets
DIRECTLY rather than through symlinks: the ed25519 host key is load-bearing
beyond ssh, since its age identity (age1tmvg…) is a recipient of cc-ci-secrets,
and a dangling symlink would let sshd write a NEW key and silently make every
cc-ci secret undecryptable. The /etc/ssh symlinks are added for discoverability
only, so nothing depends on activation ordering.

nginx's htpasswd path becomes an option (opencodeUiHtpasswdFile) defaulting
under /secrets, rather than a hard-coded /etc/nginx path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz
This commit is contained in:
2026-09-08 17:23:28 +00:00
co-authored by Claude Opus 5
parent 360999d623
commit e7aa055784
2 changed files with 50 additions and 1 deletions
+37
View File
@@ -38,10 +38,47 @@
# Everything is reached over the public IP: ssh (keys only), the CI front doors via traefik,
# and the opencode UI on 443 (traefik → nginx basic auth). fail2ban below guards the two logins.
# ---- /secrets is THE authoritative location for this host's secret material ---------------
# Operator rule (2026-09-08): every secret lives under /secrets; anything that needs one either
# reads it from there directly (where we own the path) or reaches it by a symlink (where the
# consuming software fixes the path). One directory to audit, back up, and reason about.
#
# /secrets/files loops:users 0700 the agent's secrets (testenv, opencode auth, its ssh keys)
# /secrets/host root:root 0700 host identity: ssh host keys + the sops age identity
# /secrets/nginx root:nginx 0750 the opencode UI htpasswd (nginx must read it)
#
# /secrets itself is 0711: traversable so nginx can reach its own subdirectory, not listable.
systemd.tmpfiles.rules = [
"d /secrets 0711 root root -"
"d /secrets/host 0700 root root -"
"d /secrets/nginx 0750 root nginx -"
# Convenience symlinks at the conventional paths, so an operator (or a tool that assumes the
# usual location) still finds the host keys. NOT load-bearing: sshd and sops-nix below are
# pointed at /secrets directly, precisely so nothing depends on symlink/activation ordering.
"L+ /etc/ssh/ssh_host_ed25519_key - - - - /secrets/host/ssh_host_ed25519_key"
"L+ /etc/ssh/ssh_host_ed25519_key.pub - - - - /secrets/host/ssh_host_ed25519_key.pub"
"L+ /etc/ssh/ssh_host_rsa_key - - - - /secrets/host/ssh_host_rsa_key"
"L+ /etc/ssh/ssh_host_rsa_key.pub - - - - /secrets/host/ssh_host_rsa_key.pub"
];
# sops-nix: the cc-ci server module hard-codes /etc/ssh/... and /var/lib/sops-nix/key.txt.
# Override both to the authoritative copies. THE ED25519 HOST KEY IS LOAD-BEARING BEYOND SSH:
# its age identity (age1tmvg…) is a recipient of cc-ci-secrets, so replacing or regenerating it
# makes every cc-ci secret undecryptable. Move it, never re-create it.
sops.age.sshKeyPaths = lib.mkForce [ "/secrets/host/ssh_host_ed25519_key" ];
sops.age.keyFile = lib.mkForce "/secrets/host/sops-age-key.txt";
# ---- ssh ----------------------------------------------------------------------------------
services.openssh = {
enable = true;
settings.PermitRootLogin = "yes";
# Host keys live in /secrets (above). sshd is pointed here directly rather than through the
# /etc/ssh symlinks, so it can never write a NEW key through a dangling link — that would
# silently rotate the age identity that decrypts cc-ci-secrets.
hostKeys = [
{ path = "/secrets/host/ssh_host_ed25519_key"; type = "ed25519"; }
{ path = "/secrets/host/ssh_host_rsa_key"; type = "rsa"; bits = 4096; }
];
};
# Root keys: PUBLIC keys, tracked deliberately in ./ssh-keys (one per line, blank lines ok).
users.users.root.openssh.authorizedKeys.keys =
+13 -1
View File
@@ -62,6 +62,18 @@ in
description = "nginx server_name for the opencode web UI (TLS + basic auth).";
};
opencodeUiHtpasswdFile = lib.mkOption {
type = lib.types.str;
default = "/secrets/nginx/oc-htpasswd";
description = ''
htpasswd file for the opencode UI (`oc:<bcrypt>`), created out of band a store path
would be world-readable. Default is under /secrets, the authoritative location for this
host's secrets; it must be readable by the `nginx` user (root:nginx 0640 in a directory
nginx can traverse). **nginx refuses to start if it is missing**, and its config check
runs as the nginx user, so a root-only file fails the check even though the path exists.
'';
};
};
config = {
@@ -204,7 +216,7 @@ SSHCFG
recommendedProxySettings = true;
virtualHosts.${cfg.opencodeUiHost} = {
listen = [ { addr = "0.0.0.0"; port = cfg.opencodeUiBackendPort; } ];
basicAuthFile = "/etc/nginx/oc-htpasswd";
basicAuthFile = cfg.opencodeUiHtpasswdFile;
extraConfig = ''
# traefik sits on the docker networks (ingress 10.0.0.0/24, gwbridge 172.18.0.0/16)
set_real_ip_from 172.16.0.0/12;