refactor(secrets): move nushell-secrets.nu out of agenix (#423)

This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-09 22:40:50 +08:00
1 parent 92740ec968
commit 0e12440148
10 files changed
+76 -38

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ bump, and the commands that destroy rollback points are in the `nix-config-updat
| remote host | anything on a Colmena host | `ssh root@<host> journalctl -b -p err`, same commands over SSH | | remote host | anything on a Colmena host | `ssh root@<host> journalctl -b -p err`, same commands over SSH |
| MicroVM guest | guest down or unreachable | on the VM host: `systemctl status microvm@<guest>` and `microvm-tap-interfaces@<guest>`; then `br0` | | MicroVM guest | guest down or unreachable | on the VM host: `systemctl status microvm@<guest>` and `microvm-tap-interfaces@<guest>`; then `br0` |
| secrets | missing or unreadable `/etc/agenix/*` | the `nix-config-secrets` skill | | secrets | missing or unreadable `/etc/agenix/*` | the `nix-config-secrets` skill |
| shell | nushell exits 1 with `File not found` | whether `~/.secrets/` exists on the host; the `nix-config-secrets` skill |
## 2. Read the evaluated value ## 2. Read the evaluated value
+2 -1
View File
@@ -17,7 +17,8 @@ in [hosts/README.md](../../../hosts/README.md).
the configuration name; Niri desktops append `-niri`, because `just niri` deploys the configuration name; Niri desktops append `-niri`, because `just niri` deploys
`$(hostname)-niri`. The `hostname` eval test encodes this. `$(hostname)-niri`. The `hostname` eval test encodes this.
2. **Secrets come from another repository.** The new host decrypts nothing until its host key is a 2. **Secrets come from another repository.** The new host decrypts nothing until its host key is a
recipient in `nix-secrets`; see the `nix-config-secrets` skill. recipient in `nix-secrets`; see the `nix-config-secrets` skill. A new desktop also needs its
synced dotfiles restored before the first switch, or the shell starts without them.
3. **The shared policy modules are not optional.** Several eval tests assert a policy for every 3. **The shared policy modules are not optional.** Several eval tests assert a policy for every
configuration, so an under-wired host fails `just test` instead of failing in production. configuration, so an under-wired host fails `just test` instead of failing in production.
4. **Build before you install.** `just test`, `just eval-host <name>`, and `just build-host <name>` 4. **Build before you install.** `just test`, `just eval-host <name>`, and `just build-host <name>`
+46 -4
View File
@@ -1,8 +1,9 @@
--- ---
name: nix-config-secrets name: nix-config-secrets
description: description:
Use when adding, changing, renaming, or removing an agenix secret, wiring one into a host, or Use when adding, changing, renaming, or removing an agenix secret, deciding whether a secret
fixing a decryption or activation failure in this repo. belongs to agenix or to the encrypted dotfiles sync, wiring a secret into a host, or fixing a
decryption or activation failure in this repo.
--- ---
# Working with secrets # Working with secrets
@@ -14,10 +15,35 @@ in this public repository.
Read [secrets/README.md](../../../secrets/README.md) for the concepts and the recipient rule. Read [secrets/README.md](../../../secrets/README.md) for the concepts and the recipient rule.
## Which flow a file belongs to
Two age flows live in `~/codes/nix-secrets`, and a file belongs to exactly one.
| Flow | Shape | Consumed as | `flake.lock` bump |
| ------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------- | ----------------- |
| **agenix** (`secrets.nix`) | static and one-way: repository → host, decrypted at activation | `/run/agenix/<name>` or an `environment.etc` copy | yes |
| **dotfiles sync** (`dotfiles/`) | changes on the host; `save`/`restore` are manual, outside Nix | a real file under `$HOME`, edited by hand or by its tool | **no** |
agenix is for the secrets that barely change and that a Nix module or service reads from the
decrypted path. Everything else is a synced dotfile: Nix does not read it, it changes often, or it
is a config file its tool keeps rewriting that happens to carry a few secrets inside. The pin
decides it, not the encryption: `flake.lock` fixes the agenix revision, so every edit costs a
rebuild and switch on every desktop.
Nothing in this flake evaluates the sync flow: no `secrets.nix` entry, no `agenix -r`, no lock bump,
so the three-step order in §2 does not apply to it. Its manifest and file list live in
`~/codes/nix-secrets/dotfiles/`; this repository carries the pointer, never the inventory. Do not
list synced paths here or copy one into a Nix module.
`nushell-secrets.nu` already moved to `~/.secrets/nushell-secrets.nu`. It has no agenix entry and
must not get one back.
## Core rules ## Core rules
1. **Never read a decrypted secret.** Reference its path; do not `cat`, copy, or print it. Prove a 1. **Never read a decrypted secret.** Reference its path; do not `cat`, copy, or print it. Prove a
change with metadata (mode, owner, timestamps), never with content. change with metadata (mode, owner, timestamps), never with content. The same holds for a file the
sync flow owns: it is a live credential under `$HOME`, and its path is listed in the private
repository, not here.
2. **Three steps, in order.** The private repository changes first, then this repository's 2. **Three steps, in order.** The private repository changes first, then this repository's
`flake.lock` moves `mysecrets` to that commit, then the declaration and consumer change here. `flake.lock` moves `mysecrets` to that commit, then the declaration and consumer change here.
`file = "${mysecrets}/x.age"` resolves against the locked revision, so a declaration that lands `file = "${mysecrets}/x.age"` resolves against the locked revision, so a declaration that lands
@@ -43,6 +69,7 @@ Read [secrets/README.md](../../../secrets/README.md) for the concepts and the re
| Which host gets which group | `modules.secrets.<group>.enable` in `outputs/<system>/src/<name>.nix` or a host module | | Which host gets which group | `modules.secrets.<group>.enable` in `outputs/<system>/src/<name>.nix` or a host module |
| Consumers | modules reading `config.age.secrets."<name>".path` (default `/run/agenix/<name>`) | | Consumers | modules reading `config.age.secrets."<name>".path` (default `/run/agenix/<name>`) |
| Decryption key | `age.identityPaths`: the host's SSH host key; `/persistent/etc/ssh/...` on a preservation host | | Decryption key | `age.identityPaths`: the host's SSH host key; `/persistent/etc/ssh/...` on a preservation host |
| Secret-bearing dotfiles edited in place | `~/codes/nix-secrets/dotfiles/` — private, not a flake input, no entry in `secrets.nix` |
The private repository groups the `.age` files: `desktop/` (only desktops decrypt them), `server/` The private repository groups the `.age` files: `desktop/` (only desktops decrypt them), `server/`
(any server), `certs/`, and `public/`. (any server), `certs/`, and `public/`.
@@ -124,9 +151,16 @@ you set, an old copy gone) instead of assuming activation did it.
Check `secrets/`, `home/`, `modules/`, `hosts/`, outputs, tests, and the private repository. Check `secrets/`, `home/`, `modules/`, `hosts/`, outputs, tests, and the private repository.
- Delete the `age.secrets` entry, its `environment.etc` placement, and every consumer in one change; - Delete the `age.secrets` entry, its `environment.etc` placement, and every consumer in one change;
a declaration whose file no longer exists breaks activation. a declaration whose file no longer exists breaks activation. On darwin also drop the path from the
`chown`/`chmod` list in `system.activationScripts.postActivation`.
- Remove it from `secrets.nix` and delete the file in the private repository, then bump the lock. - Remove it from `secrets.nix` and delete the file in the private repository, then bump the lock.
- A rename changes the attribute name and every consumer. The `.age` filename is separate. - A rename changes the attribute name and every consumer. The `.age` filename is separate.
- Moving a secret **out** of agenix into the sync flow is that deletion plus what agenix used to
guarantee: the consumer points at a `$HOME` path, and on a preservation host the directory joins
`preservation.preserveAt`, which also sets its mode. A sourced file that may be missing is left to
fail: nushell has no conditional `source`
([nushell#8214](https://github.com/nushell/nushell/issues/8214)), and a host without the file is
misconfigured, not half-working.
## 7. Failure modes ## 7. Failure modes
@@ -140,6 +174,12 @@ you set, an old copy gone) instead of assuming activation did it.
`/persistent/etc/ssh/...` path, which exists before preservation mounts `/etc`. `/persistent/etc/ssh/...` path, which exists before preservation mounts `/etc`.
- **The first macOS activation fails** on the `/etc/agenix` copies, because they run before - **The first macOS activation fails** on the `/etc/agenix` copies, because they run before
`activate-agenix` has decrypted anything. Run it again. `activate-agenix` has decrypted anything. Run it again.
- **Nushell exits 1 with `File not found` and the shell has no aliases:** the sourced secret file is
missing. agenix guaranteed the path; the sync flow does not, and the failure is left loud on
purpose. Restore the file on that host. Never silence it with an empty placeholder: the sync tool
reads that as a local edit and reports a conflict.
- **A secret is gone after a reboot on a desktop:** it moved out of agenix and its `$HOME` directory
is not in `preservation.preserveAt`, so the tmpfs root drops it.
## Why these rules exist ## Why these rules exist
@@ -151,3 +191,5 @@ you set, an old copy gone) instead of assuming activation did it.
host had; the reference moved to `modules/nixos/desktop/nix.nix` (core rule 4). host had; the reference moved to `modules/nixos/desktop/nix.nix` (core rule 4).
- `260da1ee chore: rename the nushell secret, and forbid reading decrypted secrets` - the no-reading - `260da1ee chore: rename the nushell secret, and forbid reading decrypted secrets` - the no-reading
rule. rule.
- The move of `nushell-secrets.nu` out of agenix - the `flake.lock` pin, not the encryption, made
each edit of a hand-edited secret cost a rebuild and switch on every desktop.
+2 -1
View File
@@ -131,7 +131,8 @@ For an impactful change, follow these steps in order, scaled to its risk:
dependencies; ask before creating a flake or installing another way. dependencies; ask before creating a flake or installing another way.
- The user's interactive shell is **nushell**; bash is only started from within nushell, - The user's interactive shell is **nushell**; bash is only started from within nushell,
occasionally, for tasks nushell can't do. Put shell env, aliases, and per-session secrets in the occasionally, for tasks nushell can't do. Put shell env, aliases, and per-session secrets in the
Nushell config (or `nushell-secrets.nu`), not in `bashrc`/`zshrc`. Nushell config, not in `bashrc`/`zshrc`. The secret block is `~/.secrets/nushell-secrets.nu`:
hand-edited, sourced as code by the shell, not Nix-managed, and agents MUST NOT read or edit it.
- Use `gh` for authorized GitHub operations; keep SSH for GitHub Git remotes and make sure the - Use `gh` for authorized GitHub operations; keep SSH for GitHub Git remotes and make sure the
Nix-managed `~/.ssh/config` is used; rerun outside the sandbox if it is rejected. Nix-managed `~/.ssh/config` is used; rerun outside the sandbox if it is rejected.
- Code layout: `~/codes` = personal, `~/work` = work code, `~/src/<repo>` = source checkouts. - Code layout: `~/codes` = personal, `~/work` = work code, `~/src/<repo>` = source checkouts.
+2 -2
View File
@@ -1,17 +1,17 @@
{ {
config,
nu_scripts, nu_scripts,
... ...
}: }:
{ {
programs.nushell = { programs.nushell = {
# load the alias file for work
# the file must exist, otherwise nushell will complain about it! # the file must exist, otherwise nushell will complain about it!
# #
# currently, nushell does not support conditional sourcing of files # currently, nushell does not support conditional sourcing of files
# https://github.com/nushell/nushell/issues/8214 # https://github.com/nushell/nushell/issues/8214
extraConfig = '' extraConfig = ''
# decrypted secret: AI agents must not read it # decrypted secret: AI agents must not read it
source /etc/agenix/nushell-secrets.nu source ${config.home.homeDirectory}/.secrets/nushell-secrets.nu
# Directories in this constant are searched by the # Directories in this constant are searched by the
# `use` and `source` commands. # `use` and `source` commands.
+5
View File
@@ -260,6 +260,11 @@ in
directory = ".gnupg"; directory = ".gnupg";
mode = "0700"; mode = "0700";
} }
# hand-edited secret config; Nix does not deploy it
{
directory = ".secrets";
mode = "0700";
}
{ {
directory = ".ssh"; directory = ".ssh";
mode = "0700"; mode = "0700";
+2 -2
View File
@@ -3,8 +3,8 @@
// cost context here and nowhere else. // cost context here and nowhere else.
// //
// mcp-grafana talks to the homelab Grafana. The service-account token stays in // mcp-grafana talks to the homelab Grafana. The service-account token stays in
// the environment (GRAFANA_SERVICE_ACCOUNT_TOKEN, set by nushell-secrets.nu), // the environment (GRAFANA_SERVICE_ACCOUNT_TOKEN, exported by the hand-edited
// never in git. // ~/.secrets/nushell-secrets.nu), never in git.
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"mcp": { "mcp": {
"servers": { "servers": {
+16
View File
@@ -21,6 +21,18 @@ This directory contains this `README.md`, the `Justfile` with the agenix recipes
`nixos.nix`/`darwin.nix` files that decrypt all my secrets via `agenix`. Then, I can use them in `nixos.nix`/`darwin.nix` files that decrypt all my secrets via `agenix`. Then, I can use them in
this flake. this flake.
## Two Flows, and Which One a File Belongs To
agenix holds the secrets that barely change and that a Nix module or service reads from the
decrypted path. `flake.lock` pins `mysecrets`, so an agenix secret is a frozen revision: each edit
costs a lock bump plus a rebuild and switch on every host that reads it.
Anything else is a synced dotfile: Nix does not read it, it changes often, or it is a config file
its tool keeps rewriting that happens to carry a few secrets inside. The private repository syncs
those as age ciphertext under `dotfiles/`, by hand and outside Nix, so there is no `secrets.nix`
entry and no lock bump. `nushell-secrets.nu` moved that way and is `~/.secrets/nushell-secrets.nu`
now; do not re-add it to `age.secrets`. The file list stays in the private repository.
## Which Keys Go on a Secret ## Which Keys Go on a Secret
**Every secret is decryptable by the desktops and by the offline `recovery_key`, and those two have **Every secret is decryptable by the desktops and by the offline `recovery_key`, and those two have
@@ -229,6 +241,10 @@ the `age.secrets.<name>.path` argument, which defaults to `/run/agenix/<name>`.
3. On the new host: 3. On the new host:
1. Clone this repo and run `nixos-rebuild switch` to deploy it, all the secrets will be decrypted 1. Clone this repo and run `nixos-rebuild switch` to deploy it, all the secrets will be decrypted
automatically via the host private key. automatically via the host private key.
2. A desktop also needs the synced dotfiles: regenerate their recipient list from `desktop_keys`
and run the sync's `restore` there before the first switch, so the shell's secret block is
present when the config sources it. See
[Two Flows](#two-flows-and-which-one-a-file-belongs-to).
## Troubleshooting ## Troubleshooting
-12
View File
@@ -77,12 +77,6 @@
} }
// user_readable; // user_readable;
# decrypted secret: AI agents must not read it, only reference the path
"nushell-secrets.nu" = {
file = "${mysecrets}/desktop/nushell-secrets.nu.age";
}
// user_readable;
# work git identity, symlinked to ~/work/.gitconfig by home-manager # work git identity, symlinked to ~/work/.gitconfig by home-manager
"work-gitconfig" = { "work-gitconfig" = {
file = "${mysecrets}/desktop/work-gitconfig.age"; file = "${mysecrets}/desktop/work-gitconfig.age";
@@ -108,10 +102,6 @@
# The following secrets are used by home-manager modules # The following secrets are used by home-manager modules
# But nix-darwin doesn't support environment.etc.<name>.mode # But nix-darwin doesn't support environment.etc.<name>.mode
# So we need to change its mode manually # So we need to change its mode manually
"agenix/nushell-secrets.nu" = {
source = config.age.secrets."nushell-secrets.nu".path;
};
"agenix/work-gitconfig" = { "agenix/work-gitconfig" = {
source = config.age.secrets."work-gitconfig".path; source = config.age.secrets."work-gitconfig".path;
}; };
@@ -124,11 +114,9 @@
if [ -d /etc/agenix ]; then if [ -d /etc/agenix ]; then
${pkgs.coreutils}/bin/chown ${myvars.username} \ ${pkgs.coreutils}/bin/chown ${myvars.username} \
/etc/agenix/ssh-key-romantic \ /etc/agenix/ssh-key-romantic \
/etc/agenix/nushell-secrets.nu \
/etc/agenix/work-gitconfig /etc/agenix/work-gitconfig
${pkgs.coreutils}/bin/chmod 0500 \ ${pkgs.coreutils}/bin/chmod 0500 \
/etc/agenix/ssh-key-romantic \ /etc/agenix/ssh-key-romantic \
/etc/agenix/nushell-secrets.nu \
/etc/agenix/work-gitconfig /etc/agenix/work-gitconfig
${pkgs.coreutils}/bin/chown root /etc/agenix/rclone.conf ${pkgs.coreutils}/bin/chown root /etc/agenix/rclone.conf
-16
View File
@@ -115,12 +115,6 @@ in
} }
// user_readable; // user_readable;
# decrypted secret: AI agents must not read it, only reference the path
"nushell-secrets.nu" = {
file = "${mysecrets}/desktop/nushell-secrets.nu.age";
}
// user_readable;
# work git identity, symlinked to ~/work/.gitconfig by home-manager # work git identity, symlinked to ~/work/.gitconfig by home-manager
"work-gitconfig" = { "work-gitconfig" = {
file = "${mysecrets}/desktop/work-gitconfig.age"; file = "${mysecrets}/desktop/work-gitconfig.age";
@@ -145,16 +139,6 @@ in
mode = "0000"; mode = "0000";
}; };
# Used by a home-manager module, so it must be readable by the user.
# Setting `mode` makes environment.etc copy the file instead of
# symlinking the runtime secret, so the owner must be set as well —
# otherwise it becomes a world-readable, root-owned copy of the secret.
"agenix/nushell-secrets.nu" = {
source = config.age.secrets."nushell-secrets.nu".path;
mode = "0400";
user = myvars.username;
};
# Used by a home-manager module, so it must be readable by the user. # Used by a home-manager module, so it must be readable by the user.
# Setting `mode` makes environment.etc copy the file instead of # Setting `mode` makes environment.etc copy the file instead of
# symlinking the runtime secret, so the owner must be set as well — # symlinking the runtime secret, so the owner must be set as well —