From 7055bcdb97f88ee4d2f50f021dd987eb95fce860 Mon Sep 17 00:00:00 2001 From: beatzaplenty Date: Mon, 20 Jul 2026 13:38:21 +1000 Subject: [PATCH] Fix lxc-* hosts never completing first-boot user/secrets activation virtualisation/proxmox-lxc.nix registers the Nix store DB via a systemd service, never an activation script -- so neededForUsers sops secrets (password hashes) and the user-creation step that consumes them never ran on a real first boot, leaving /etc/shadow stuck with build-time placeholder entries. boot.postBootCommands looked like the right hook (stage-2-init.sh does invoke it) but switch-to-configuration behaves unreliably that early, before systemd itself is up. Fixed with a genuine oneshot systemd service, gated by ConditionPathExists so it only ever runs once. Confirmed live via a from-scratch destroy+rebuild+redeploy of the lxc-nix-cache test container: real password hashes applied automatically, systemctl is-system-running -> running, zero failed units. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01La55Nsss8jZ7ZuzUV9mfot --- modules/platforms/lxc.nix | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/modules/platforms/lxc.nix b/modules/platforms/lxc.nix index dca5389..0c14bd2 100644 --- a/modules/platforms/lxc.nix +++ b/modules/platforms/lxc.nix @@ -85,4 +85,64 @@ in mode = "0644"; }; }; + + # virtualisation/proxmox-lxc.nix (imported above) registers the Nix + # store DB via a systemd service (register-nix-paths) -- it never runs + # an activation script at all. Confirmed live this means neither + # sops-nix's "for users" secrets (password hashes -- installed by the + # activation script itself, not a systemd service, since they need to + # exist *before* user creation) nor the user-creation step that + # consumes them ever run on a real lxc-* boot. Regular secrets + # (nix-serve's key, beszel's token, etc.) work anyway because sops-nix + # provides its own systemd service for those. + # + # A systemd service, not boot.postBootCommands: tried that first (it's + # a genuine, generally-invoked hook -- nixos/modules/system/boot/stage-2-init.sh, + # which becomes this container's actual /sbin/init, unconditionally + # runs it) but switch-to-configuration behaves differently that early in + # boot (raw stage-2-init.sh, before systemd itself has even started) -- + # confirmed live it silently failed to rewrite /etc/shadow from there + # even in "test" mode, despite the exact same command working reliably + # every time when run post-boot (i.e. as a normal systemd service, which + # is what this is). Not fully root-caused why the early context + # specifically breaks it; a real systemd service sidesteps needing to. + # + # /etc/shadow already has PLACEHOLDER entries for every declared user + # baked in at build time (part of constructing the system closure). + # update-users-groups.pl deliberately never overwrites an *existing* + # shadow entry -- a correct safety property in general (don't clobber a + # real user's real password on a config rebuild) -- but on a genuine + # first boot that only means the real hashedPasswordFile-derived hash + # never gets the chance to be applied either, since the placeholder is + # already "seen". Safe to clear here specifically: there is no real + # password yet to protect on a first boot. + # + # "test" mode, not "boot": confirmed live "boot" mode aborts partway + # through (before rewriting /etc/shadow) on a warning that "/boot" is on + # a different filesystem -- a real check for a host with a bootloader to + # update, meaningless for a container that has none + # (boot.loader.{grub,systemd-boot}.enable are both false above), but it + # still aborts the script. "test" runs every activation step without + # touching boot-loader state at all. + # + # ConditionPathExists (systemd-native, not a bash-level check) means + # this only ever runs once, on the genuine first boot -- systemd itself + # skips even starting it on every later boot once the marker exists. + # switch-to-configuration is otherwise the operator's call per this + # repo's own safety rules, not something to run on every boot. + systemd.services.nixos-lxc-first-boot-activate = { + description = "Complete first-boot NixOS activation (users, secrets) for this LXC container"; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = "!/var/lib/nixos-lxc-first-boot-activated"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + rm -f /etc/shadow + /run/current-system/bin/switch-to-configuration test + mkdir -p /var/lib + touch /var/lib/nixos-lxc-first-boot-activated + ''; + }; }