diff --git a/.claude/tasks/0007-guest-secrets-placement.md b/.claude/tasks/0007-guest-secrets-placement.md new file mode 100644 index 0000000..71cef90 --- /dev/null +++ b/.claude/tasks/0007-guest-secrets-placement.md @@ -0,0 +1,40 @@ +--- +spec: guests +blocked-by: 0002-guest-walking-skeleton +--- + +## What to build + +The Host-side placement that gives a Guest only the decrypted secrets it names, while no Guest ever holds a decryption key. +A Host sets `secrets`, the names of the secret files the Guest needs. +The Host is the sole decryptor, consistent with the host identity and the existing sops-nix setup: it decrypts, and the Guest receives only the specific named secret files, read-only bind-mounted in, with ownership aligned by the identity mapping. +A Guest holds no age key. + +## Acceptance criteria + +- [x] A Host setting `guests..secrets` gives the Guest exactly the named secret files and no others. +- [x] The named secrets are decrypted by the Host and bind-mounted into the Guest read-only, with ownership aligned by the identity mapping. +- [x] The Guest holds no age key and performs no decryption of its own. +- [x] A Host with a Guest that names secrets builds via `nix flake check`, and the resolved secret mounts are verifiable by `nix eval`. + +## Implementation Notes + +`secrets` is a placement option on the `guest` builder: a list of secret names. +For each name the builder declares `sops.secrets.` on the host, so the host is the sole decryptor from the sops files it already holds, and bind-mounts the decrypted file read-only into the guest at the same `/run/secrets/` path it occupies on the host. +A service inside the guest therefore reads its credentials at the location it would on a host, keeping a module placement-agnostic. + +The guest holds no age key and declares no secrets of its own. +This is inherited, not added: `guest.nix` stands on `base.nix`, not on the host base `system.nix` that sets `sops.age.keyFile`. +Verified through `nixosConfigurations.neogaia.extendModules`: with `guests.sample.secrets = [ "alexion-password" ]`, the interior's `sops.age.keyFile` is null and its `sops.secrets` is empty, while the host declares the secret and the container's `bindMounts` carries exactly the one entry, read-only, keyed and sourced at the secret's path. + +Ownership alignment needs no new code. +The container already runs with `privateUsers = "no"` (task 0006), so the host and guest share one uid and gid space, and the decrypted file's host owner is its owner inside the guest. +A secret defaults to `root:root` mode `0400`, so a guest service running as a non-root user needs the operator to set `sops.secrets..owner` on the host, which merges cleanly with the builder's stub declaration. +The `secrets` field stays a list of names per the spec, which treats the owner as host-set data. + +A build-time assertion rejects an in-guest path claimed by both a `mounts` entry and a secret, since the two attribute sets merge and the collision would otherwise resolve silently in the secret's favour. + +No host commits a `secrets` placement, mirroring the mounts, networking, and storage foundations (tasks 0003–0006): the only host, `neogaia`, is a laptop carrying no service that names one. +`nix flake check` passes on the committed tree, where the sample guest declares no secrets. +The full host toplevel was built with the sample guest naming `alexion-password` (a real key in `secrets/shared.yaml`); sops-nix validates at build time that each named key exists in the host's sops files, so a build with a name absent from those files fails clearly rather than at activation. +Real identity-mapped reads on the target host are verified manually, per the spec's testing decisions. diff --git a/lib.nix b/lib.nix index fd8160a..f43e32d 100644 --- a/lib.nix +++ b/lib.nix @@ -96,6 +96,33 @@ let networked = cfg.vlan != null; + # Host paths the operator maps into the guest, keyed by their in-guest path. + userMounts = lib.mapAttrs (_guestPath: m: { + inherit (m) hostPath; + isReadOnly = m.readOnly; + }) cfg.mounts; + + # Each named secret bind-mounted read-only at the same `/run/secrets/` + # path it holds on the host. + # No ownership is set here, since the container's one-to-one identity map + # carries the host file's owner through unchanged. + secretMounts = lib.listToAttrs ( + map ( + name: + let + path = config.sops.secrets.${name}.path; + in + lib.nameValuePair path { + hostPath = path; + isReadOnly = true; + } + ) cfg.secrets + ); + + # An in-guest path claimed by both a mount and a secret, which the merge + # below would otherwise resolve silently in the secret's favour. + mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts); + # A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container. # The interface is eth0, the name a nested container gives its bridged veth. # It takes the placement MAC, and the static address or DHCP when that is unset. @@ -197,10 +224,36 @@ let read-write unless `readOnly` is set. ''; }; + secrets = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "jellyfin-api-key" ]; + description = '' + Names of the secrets this guest needs. The host is the sole + decryptor: it decrypts each named secret from its own sops files and + bind-mounts the plaintext file into the guest read-only at + `/run/secrets/`, the same path it would occupy on a host, so a + service reads its credentials at a predictable location. The guest + names the files it wants and receives exactly those. It holds no age + key and decrypts nothing itself. Ownership carries across unchanged, + since the container maps ids one to one, so a secret owned by a uid on + the host is owned by that same uid inside the guest. + ''; + }; }; config = lib.mkIf cfg.enable { + # Declared here so the host is the one that decrypts each named secret. + # The guest carries no age key and decrypts nothing of its own. + sops.secrets = lib.genAttrs cfg.secrets (_: { }); + assertions = [ + { + assertion = mountCollisions == [ ]; + message = '' + guests.${name} maps a mount at ${lib.concatStringsSep ", " mountCollisions}, colliding with a secret bind-mounted at the same path. Rename the mount or the secret so each in-guest path is used once. + ''; + } { assertion = cfg.backend == "container"; message = '' @@ -231,10 +284,7 @@ let # A private-user mapping would shift the ids and reintroduce those errors, so it stays off. privateUsers = lib.mkDefault "no"; - bindMounts = lib.mapAttrs (_guestPath: m: { - inherit (m) hostPath; - isReadOnly = m.readOnly; - }) cfg.mounts; + bindMounts = userMounts // secretMounts; inherit specialArgs;