Skip to content

Runbook — secrets

Everything in the repository’s secrets/ directory is sops ciphertext (safe to commit). Plaintext never enters git or the Nix store; sops-nix decrypts at activation into /run/secrets. Recipients are listed in .sops.yaml at the repository root.

Host key (once, on the workstation, BEFORE building the installer image)

Section titled “Host key (once, on the workstation, BEFORE building the installer image)”
Terminal window
age-keygen -o host-age.txt # prints "Public key: age1…"; keep the file off the repo and off the image
chmod 600 host-age.txt

Add the printed public key to .sops.yaml as &cobalt, then (re-)encrypt the files so the host can read them: sops updatekeys secrets/*.yaml. The image is built from the final configuration, so the secrets must exist — encrypted to this key — before nix build path:.#images.installer. On the laptop, install.sh asks for the path of the prepared key (another medium) and installs it as /persist/var/lib/sops-nix/key.txt; sops-nix decrypts into /run/secrets at every activation.

Accounts are declarative (users.mutableUsers = false), so the hashes must exist before the first boot or nobody can log in. Keys are fixed names, independent of the actual usernames:

Terminal window
# One hash per account: mkpasswd (yescrypt) on Linux; on macOS `openssl passwd -6` (sha512-crypt), which
# NixOS accepts as well. Both prompt for the password without echoing it.
h1=$(mkpasswd -m yescrypt) # the operator's password
h2=$(mkpasswd -m yescrypt) # the second account's password
# Encrypted straight from stdin, so the hashes never land in a plaintext file. --filename-override: sops
# matches the creation rule against the DESTINATION path (sops ≥ 3.9); stdin alone matches no rule.
printf 'password-operator: "%s"\npassword-secondary: "%s"\n' "$h1" "$h2" |
sops --encrypt --filename-override secrets/users.yaml --input-type yaml --output-type yaml /dev/stdin > secrets/users.yaml
unset h1 h2

Source: your own Stash/Clash Meta profile, or example.CM.min.yaml from the repository root, a skeleton that already contains every setting in the table below. Work on a copy named CM.min.yaml, which git ignores, and do not commit the plaintext. A Stash profile is already in mihomo dialect; apply these Linux/NixOS adjustments:

KeyChangeWhy
tun.deviceadd device: Metathe firewall exemption in network.nix is keyed on this name
tun.stackgvisor → system (or mixed)better throughput on Linux; the firewall trusts the TUN for it
tun.auto-redirremoveLinux-only nftables redirect; redundant with auto-route and fights our nftables firewall
dns.listen127.0.0.1:53 → 127.0.0.1:1053 (or remove)the service has no CAP_NET_BIND_SERVICE; resolution still works via dns-hijack
allow-lantrue → false (recommended)the laptop is not a relay; inbound 7890/7891/7894 are firewalled anyway
external-controller, secretkeep (loopback + secret)dashboard comes from services.mihomo.webui, drop any external-ui key
everything else (geodata, rule-providers, profile.store-*, proxies, groups, rules)keepstate lands in /var/lib/private/mihomo, which is persisted

Notes

  • DOMAIN-KEYWORD,nixos,DIRECT already sends cache.nixos.org direct — keep it, the binary cache should not ride the proxy.
  • First start needs connectivity to download GeoIP/GeoSite (geox-url); bootstrap resolvers are literal IPs, so no deadlock.
  • The relative paths in rule-providers.*.path resolve inside the state directory.

Encrypt:

Terminal window
cp example.CM.min.yaml CM.min.yaml # or copy your own profile here; CM.min.yaml is git-ignored
$EDITOR CM.min.yaml # servers, credentials, rules; keep the settings from the table above
sops --encrypt --filename-override secrets/mihomo.yaml --input-type yaml --output-type yaml CM.min.yaml > secrets/mihomo.yaml

Keep the plaintext CM.min.yaml only as long as you need it: it holds the proxy credentials, and on an SSD overwriting a file does not reliably erase it, so the workstation’s disk encryption is what protects it.

modules/nixos/hardening/network.nix enables services.mihomo automatically once secrets/mihomo.yaml exists; until then the module is inert and the flake still evaluates.