{ description = "Homelab NixOS configuration"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; disko = { url = "github:nix-community/disko"; inputs.nixpkgs.follows = "nixpkgs"; }; sops-nix = { url = "github:Mic92/sops-nix"; inputs.nixpkgs.follows = "nixpkgs"; }; nixos-images = { url = "github:nix-community/nixos-images"; inputs.nixos-stable.follows = "nixpkgs"; }; nixos-anywhere = { url = "github:nix-community/nixos-anywhere"; inputs.nixpkgs.follows = "nixpkgs"; inputs.nixos-stable.follows = "nixpkgs"; # 26.05 already IS stable inputs.disko.follows = "disko"; inputs.nixos-images.follows = "nixos-images"; }; home-manager = { url = "github:nix-community/home-manager/release-26.05"; inputs.nixpkgs.follows = "nixpkgs"; }; mediamanager-nix = { url = "github:strangeglyph/mediamanager-nix"; inputs.nixpkgs.follows = "nixpkgs"; }; authentik-nix.url = "github:nix-community/authentik-nix"; nix-flatpak.url = "github:gmodena/nix-flatpak"; # Hermes Agent (see services/desktop/hermes-agent.nix) — Tier 2 platform # per its own docs (best-effort Nix support, can break on any upstream # commit), so pinned like everything else via flake.lock rather than # followed loosely. hermes-agent.url = "github:NousResearch/hermes-agent"; # Own Hyprland plugin (border + title bar), public repo, fetched over # https (no credentials needed, unlike tome below). `nixpkgs.follows` is # what makes its packaged build ABI-correct — Hyprland plugins are # ABI-locked to the exact Hyprland build they load into, so it has to be # built against THIS flake's own nixpkgs, not whatever hypr-chrome's own # flake.lock happens to pin standalone. hypr-chrome = { url = "git+https://git.mgaction.town/darman/hypr-chrome.git"; inputs.nixpkgs.follows = "nixpkgs"; }; # Tome (formerly AudibleLibrary) — darman's own .NET/Photino desktop app. # Private repo on our own gitea; fetched over ssh with darman's ambient key, # same as any other git flake input. `flake = false`: it's a plain source # tree, not itself a flake. See pkgs/tome.nix. # # NOTE: the credential-less installer-iso can't fetch this (git+ssh needs # darman's key), so `./scripts/deploy install terra localhost` will fail # at nixos-install (post-disko) while this input is present. Known # tradeoff — re-removed this once before (4f79ec7) for the same reason. tome = { url = "git+ssh://gitea@git.mgaction.town:2222/darman/TOME.git"; flake = false; }; }; outputs = { self, nixpkgs, disko, nixos-anywhere, sops-nix, nixos-images, home-manager, mediamanager-nix, authentik-nix, ... }@inputs: let system = "x86_64-linux"; in { packages.${system} = { # Re-exported so `./scripts/deploy` can run them as `nix run .#disko` / # `nix run .#nixos-anywhere`, at the revision flake.lock pins. See the # nixos-anywhere input above for why that matters. disko = disko.packages.${system}.disko; nixos-anywhere = nixos-anywhere.packages.${system}.nixos-anywhere; }; nixosConfigurations = { # Real host — install on the ZimaBlade. # disko owns the OS-disk partitioning + filesystems (see disk-config.nix). jupiter = nixpkgs.lib.nixosSystem { inherit system; specialArgs = { inherit inputs; }; modules = [ disko.nixosModules.disko sops-nix.nixosModules.sops inputs.hermes-agent.nixosModules.default ./hosts/jupiter/configuration.nix ]; }; # netcup VPS — public reverse proxy + tailnet node. neptun = nixpkgs.lib.nixosSystem { inherit system; specialArgs = { inherit inputs; }; modules = [ disko.nixosModules.disko sops-nix.nixosModules.sops ./hosts/neptun/configuration.nix ]; }; # terra — Ryzen 9 5900X desktop (MSI MS-7A32). Replaces CachyOS on the # OS SSD; Hyprland desktop + tailnet node. See hosts/terra/*. terra = nixpkgs.lib.nixosSystem { inherit system; specialArgs = { inherit inputs; }; modules = [ disko.nixosModules.disko sops-nix.nixosModules.sops home-manager.nixosModules.home-manager inputs.nix-flatpak.nixosModules.nix-flatpak inputs.hermes-agent.nixosModules.default ./hosts/terra/configuration.nix ]; }; # mercury — Raspberry Pi 3B+ (aarch64), DNS/DHCP. Boots from an SD image: # nix build .#nixosConfigurations.mercury.config.system.build.sdImage # (aarch64 build — needs binfmt/qemu on this x86 host, or a remote/aarch64 # builder; substitutes most paths from cache.nixos.org.) mercury = nixpkgs.lib.nixosSystem { system = "aarch64-linux"; specialArgs = { inherit inputs; }; modules = [ (nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix") sops-nix.nixosModules.sops ./hosts/mercury/configuration.nix ]; }; # x86_64 QEMU VM to runtime-test mercury's DNS/DHCP stack (pihole + # unbound) before flashing the aarch64 SD. Build + run: # nix build .#nixosConfigurations.mercury-vm.config.system.build.vm # ./result/bin/run-mercury-vm-vm mercury-vm = nixpkgs.lib.nixosSystem { inherit system; # x86_64-linux, fast to build/boot with KVM modules = [ (nixpkgs + "/nixos/modules/virtualisation/qemu-vm.nix") ./common.nix ./services/network/unbound.nix ./services/network/pihole.nix ({ lib, ... }: { networking.hostName = "mercury-vm"; networking.nameservers = [ "1.1.1.1" "9.9.9.9" ]; # host resolver (not pihole) users.users.darman.initialPassword = "test"; users.users.root.initialPassword = "test"; services.openssh.settings.PasswordAuthentication = lib.mkForce true; virtualisation.graphics = false; virtualisation.memorySize = 2048; virtualisation.forwardPorts = [ { from = "host"; host.port = 2223; guest.port = 22; } { from = "host"; host.port = 8081; guest.port = 80; } ]; system.stateVersion = "26.05"; }) ]; }; # VirtualBox test image. Build the OVA with: # nix build .#nixosConfigurations.jupiter-vbox.config.system.build.virtualBoxOVA # NOTE: no disko here — the virtualbox-image module supplies the disk. jupiter-vbox = nixpkgs.lib.nixosSystem { inherit system; specialArgs = { inherit inputs; }; modules = [ ./hosts/jupiter/vm.nix ]; }; # Custom kexec installer with our SSH key baked in, for headless install # onto a box with a read-only root (ZimaOS) where nixos-anywhere can't # ssh-copy-id. Build the tarball: # nix build .#nixosConfigurations.kexec.config.system.build.kexecInstallerTarball # then scp it to the target's writable /tmp and run kexec/run (see README). kexec = nixpkgs.lib.nixosSystem { inherit system; modules = [ nixos-images.nixosModules.kexec-installer ({ ... }: { users.users.root.openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZpkPVhzi1zG5JI9hWyUgdyvNIQbp4ts4jw3idpMhhN erik@laptop" ]; }) ]; }; # Bootable USB recovery installer with our SSH key + sshd + DHCP. Clones # the (now public) homelab repo fresh at every boot to /root/homelab — # always current master, so the same USB stick stays useful across # install/rescue occasions without ever needing a rebuild. No # rsync/copy-the-repo-over step: boot it, ssh in, # `cd /root/homelab && ./scripts/deploy install ...`. # Reusable for any host's manual-USB install path (jupiter, terra, ...). # Build the ISO: # nix build .#nixosConfigurations.installer-iso.config.system.build.isoImage # dd it to a USB stick, boot the target from it, SSH in, ./deploy install. installer-iso = nixpkgs.lib.nixosSystem { inherit system; modules = [ (nixpkgs + "/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix") ({ pkgs, lib, ... }: { services.openssh.enable = true; services.openssh.settings.PermitRootLogin = "prohibit-password"; users.users.root.openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZpkPVhzi1zG5JI9hWyUgdyvNIQbp4ts4jw3idpMhhN erik@laptop" ]; networking.hostName = "homelab-installer"; environment.systemPackages = [ pkgs.git ]; # findiso= is a SCRIPT-stage-1 feature (stage-1-init.sh) only. The # systemd initrd — the default since 26.05 — has no findiso path # at all: it mounts /iso straight from # /dev/disk/by-label/ (iso-image.nix), which only exists # when the ISO is the physical boot medium. Booted as a kernel + # initrd off the ESP with the iso as a plain file elsewhere, that # label never appears and stage 1 times out into an emergency # shell (mounts /sysroot fine, then fails /sysroot/nix/.ro-store). # Script stage 1 instead loop-mounts the file findiso= points at # and symlinks it to /dev/root — which is the whole mechanism this # install path relies on. So force it off here. boot.initrd.systemd.enable = false; # installation-cd-minimal leaves experimental-features unset, so # the ISO's nix.conf has no `nix-command`/`flakes` at all (unlike # the nixos-images kexec installer, which sets # extra-experimental-features itself — which is why the same # `install localhost` branch works after kexec-local but # not here). Without this, both `nix run .#disko` and # `nixos-install --flake` die with "experimental Nix feature # 'nix-command' is disabled". nix.settings.experimental-features = [ "nix-command" "flakes" ]; # Fresh clone of a PUBLIC repo — no credentials baked into the # ISO. require_tracked() in scripts/deploy still works fine here # (this IS a real git checkout, unlike the old baked-`self` # approach), but retry manually with `systemctl restart # homelab-checkout` if DHCP was still coming up at boot. systemd.services.homelab-checkout = { description = "Clone the homelab repo to /root/homelab"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; path = [ pkgs.git ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; script = '' rm -rf /root/homelab git clone --depth 1 https://git.mgaction.town/darman/homelab.git /root/homelab ''; }; # Finishes a local_install_prepare_and_reboot() run (scripts/deploy) # unattended: that function stages this ISO, points a systemd-boot # one-shot entry at it with `homelab.install=` on the kernel # cmdline, and reboots. Once booted here, this re-runs the exact same # `./scripts/deploy install localhost` command — now genuinely # inside the installer (hostname homelab-installer), so is_live_installer # takes the disko+nixos-install branch instead of preparing again. # A manual boot of this ISO with no such cmdline param is a no-op. systemd.services.homelab-auto-install = { description = "Auto-run the homelab install if homelab.install= was passed on the kernel cmdline"; after = [ "homelab-checkout.service" ]; requires = [ "homelab-checkout.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig.Type = "oneshot"; # Full system PATH, not the restricted default a `path = [...]` # produces: this unit execs `./scripts/deploy`, whose # `#!/usr/bin/env bash` needs bash, and which then reaches for # nix / nixos-install / git / sudo / efibootmgr. The default # service PATH gave "env: 'bash': No such file or directory" # (status 127) before the script even started. # /run/current-system/sw/bin carries all of it on the installer; # /run/wrappers/bin for sudo. mkForce because NixOS otherwise # derives environment.PATH from `path` and that line would win. # # HOME too: systemd sets no $HOME for a service without User= # (systemd.exec(5): SetLoginEnvironment= defaults false), and # scripts/deploy runs under `set -u`, so a bare $HOME aborted the # whole run with an "unbound variable" that read like a bug. environment = { HOME = "/root"; PATH = lib.mkForce "/run/current-system/sw/bin:/run/wrappers/bin"; }; script = '' cfg=$(grep -o 'homelab\.install=[^ ]*' /proc/cmdline | cut -d= -f2 || true) if [ -z "$cfg" ]; then echo "no homelab.install= on the kernel cmdline — nothing to auto-install" exit 0 fi # Persist this whole run to a file that OUTLIVES the install. # The systemd journal is on the installer's tmpfs and dies with # the reboot, and by the time anything interesting fails disko # has already wiped the OS disk — so a failed attempt used to # leave nothing to debug. local_install_prepare_and_reboot() # (scripts/deploy) passes the STAGING partition's PARTUUID as # homelab.logpart=; that partition holds the iso and is on a # different disk from the one disko wipes, so it survives. The # actual install runs inside do_install() below so one tee at # the end captures all of it. Every step here is best-effort: # logging must never be the thing that breaks an install. logfile="" logpart=$(grep -o 'homelab\.logpart=[^ ]*' /proc/cmdline | cut -d= -f2 || true) if [ -n "$logpart" ]; then dev="/dev/disk/by-partuuid/$logpart" logdir="" mkdir -p /run/homelab-log if mount -o rw "$dev" /run/homelab-log 2>/dev/null; then logdir=/run/homelab-log elif where=$(findmnt -fno TARGET "$dev" 2>/dev/null) && [ -n "$where" ]; then # stage-1's findiso already holds this partition mounted # (that is how it reached the iso) — write into the existing # mount rather than trying to stack a second one on it. mount -o remount,rw "$where" 2>/dev/null || true logdir="$where" fi if [ -n "$logdir" ]; then # Next to the iso: findiso= is its path on this partition. iso=$(grep -o 'findiso=[^ ]*' /proc/cmdline | cut -d= -f2 || true) dest="$logdir/$(dirname "$iso" 2>/dev/null || echo /)" if mkdir -p "$dest" 2>/dev/null; then logfile="$dest/homelab-install-$cfg.log" else logfile="$logdir/homelab-install-$cfg.log" fi echo "logging this install to $logfile (on the staging disk — survives the wipe)" else echo "warning: could not mount PARTUUID=$logpart to log to — continuing without a persistent log" >&2 fi fi do_install() { # The host key scripts/deploy seeds /etc/ssh with (so sops can # decrypt on boot #1) cannot live in this ISO: it is built from # a PUBLIC repo and the private keys are deliberately off-repo. # local_install_prepare_and_reboot() therefore drops it on the # boot partition and passes that partition's PARTUUID here. # That copy dies with the disko wipe a few minutes later. keypart=$(grep -o 'homelab\.keypart=[^ ]*' /proc/cmdline | cut -d= -f2 || true) if [ -n "$keypart" ]; then mkdir -p /run/homelab-key if mount -o ro "/dev/disk/by-partuuid/$keypart" /run/homelab-key; then src=/run/homelab-key/homelab-installer if [ -f "$src/ssh_host_ed25519_key" ]; then echo "picking up $cfg's host key from PARTUUID=$keypart" install -Dm600 "$src/ssh_host_ed25519_key" \ "/root/.config/homelab/$cfg/ssh_host_ed25519_key" install -Dm644 "$src/ssh_host_ed25519_key.pub" \ "/root/.config/homelab/$cfg/ssh_host_ed25519_key.pub" else echo "warning: no host key at $src — the install will refuse" >&2 fi umount /run/homelab-key else echo "warning: could not mount PARTUUID=$keypart for the host key" >&2 fi fi # On a box whose old bootloader had no one-shot (Limine on # terra), scripts/deploy got us here via a temporary UEFI # entry + BootNext (arm_efi_bootnext). BootNext is already # spent, but the entry itself would linger in NVRAM pointing # at a partition disko is about to reformat. Drop it now, so # even an install that fails later leaves NVRAM clean. for n in $(efibootmgr 2>/dev/null \ | sed -n 's/^Boot\([0-9A-Fa-f]\{4\}\)\*\?[[:space:]]Homelab Installer[[:space:]].*/\1/p'); do echo "removing temporary UEFI entry Boot$n" efibootmgr -q -B -b "$n" || true done echo "auto-installing $cfg (homelab.install= on the kernel cmdline)" cd /root/homelab ./scripts/deploy install "$cfg" localhost --yes } # tee, not exec: we need the exit status back to sync the log # to the platter before the box possibly drops to a shell. if [ -n "$logfile" ]; then { echo "=== homelab auto-install: $cfg ($(date -u 2>/dev/null || true)) ==="; do_install; } 2>&1 | tee -a "$logfile" status=''${PIPESTATUS[0]} else do_install status=$? fi sync 2>/dev/null || true exit "$status" ''; }; }) ]; }; }; # VM test for `./scripts/deploy kexec-local`. Run: # nix build .#checks.x86_64-linux.kexec-local -L # # Worth having because kexec-local is the one command that cannot be # rehearsed on real hardware: it jumps the machine you are typing at, and # a failure looks exactly like a slow boot. It regression-tests the # subtle one — kexec-run.sh backgrounds `sleep 6 && kexec -e` and returns, # so anything that cleans up the staging dir on exit deletes the binary # that performs the jump and the box silently never leaves the old kernel. # # After the jump the test driver's backdoor is gone with the old kernel, # so the installer is driven over a forwarded ssh port instead (the same # approach nixos-images uses in its own kexec test). checks.${system}.kexec-local = let pkgs = nixpkgs.legacyPackages.${system}; tarball = self.nixosConfigurations.kexec.config.system.build.kexecInstallerTarball; sshKey = nixos-images + "/nix/kexec-installer/ssh-keys/id_ed25519"; in pkgs.testers.runNixOSTest { name = "deploy-kexec-local"; nodes.machine = { modulesPath, ... }: { imports = [ (modulesPath + "/profiles/minimal.nix") ]; virtualisation.vlans = [ ]; # kexec-local refuses to run if RAM < 3x the installer image, and # the staging dir needs ~3x the tarball on /var/tmp. virtualisation.memorySize = 4 * 1024; virtualisation.diskSize = 12 * 1024; virtualisation.forwardPorts = [{ host.port = 2222; guest.port = 22; }]; services.openssh.enable = true; users.users.root.openssh.authorizedKeys.keyFiles = [ "${sshKey}.pub" ]; # Everything the script shells out to, minus nix — the test uses the # HOMELAB_KEXEC_* hook so no build happens inside the VM. environment.systemPackages = with pkgs; [ bash gnutar coreutils findutils util-linux cpio gzip ]; system.extraDependencies = [ tarball pkgs.cpio pkgs.gzip ]; environment.etc."deploy".source = ./scripts/deploy; }; testScript = /* python */ '' import os, shutil, subprocess, tempfile, time start_all() machine.wait_for_unit("sshd.service") # ssh refuses a private key that is group/world readable, and nix # store paths are 0444 — copy it out and tighten the mode. keydir = tempfile.mkdtemp() key = os.path.join(keydir, "id_ed25519") shutil.copyfile("${sshKey}", key) os.chmod(key, 0o600) def ssh(cmd, check=True, stdout=None): return subprocess.run( [ "${pkgs.openssh}/bin/ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=1", "-i", key, "-p", "2222", "root@127.0.0.1", "--" ] + cmd, text=True, check=check, stdout=stdout) machine.succeed("install -Dm755 /etc/deploy /root/deploy") # systemd-run starts units with a bare PATH that lacks # /run/current-system/sw/bin, so `#!/usr/bin/env bash` cannot even # resolve bash, let alone tar/findmnt/nohup. Set it explicitly. env = ( " --setenv=PATH=/run/wrappers/bin:/run/current-system/sw/bin" " --setenv=HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz" " --setenv=HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio" " --setenv=HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip" ) # Same values for the foreground (non-systemd-run) invocation below. envsh = ( "HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz" " HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio" " HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip" ) # Marker on a tmpfs: it must NOT survive the jump, proving we really # booted a new kernel rather than just restarting a service. machine.succeed("touch /run/pre-kexec-marker") host_key_before = machine.succeed("cat /etc/ssh/ssh_host_ed25519_key.pub").strip() while ssh(["true"], check=False).returncode != 0: time.sleep(1) # Refuses without --yes when stdin is not a tty (read gets EOF). # Must reach the confirmation prompt, so it needs the same env — # otherwise it just dies early on the nix build and proves nothing. out = machine.fail(f"{envsh} /root/deploy kexec-local &1") assert "using prebuilt kexec installer" in out, \ f"never reached the prompt, so the refusal proves nothing:\n{out}" # systemd-run so the call returns immediately: the script stays # alive ~60s on purpose, outliving kexec-run.sh's `sleep 6`. machine.succeed(f"systemd-run --collect --unit=kexec-local{env} /root/deploy kexec-local --yes") print("waiting for the jump...") deadline = time.time() + 300 while ssh(["true"], check=False).returncode == 0: # Surface a dead unit immediately instead of stalling until the # deadline and blaming "never left the old kernel". st = ssh(["systemctl", "is-active", "kexec-local"], check=False, stdout=subprocess.PIPE).stdout or "" if st.strip() in ("failed", "inactive"): # NB: not `log` — the driver already binds that name to its # AbstractLogger and the type check rejects the shadowing. unit_log = ssh(["journalctl", "-u", "kexec-local", "--no-pager"], check=False, stdout=subprocess.PIPE).stdout or "" raise AssertionError( f"kexec-local.service ended ({st.strip()}) without jumping:\n{unit_log}") assert time.time() < deadline, "machine never left the old kernel" time.sleep(1) print("waiting for the installer...") deadline = time.time() + 300 while ssh(["true"], check=False).returncode != 0: assert time.time() < deadline, "installer never came up" time.sleep(1) # It really is the RAM installer, not the old system. host = ssh(["hostname"], stdout=subprocess.PIPE).stdout.strip() assert host == "nixos-installer", f"hostname is {host}, not nixos-installer" assert ssh(["ls", "/run/pre-kexec-marker"], check=False).returncode != 0, \ "old /run survived — this was not a fresh kernel" # The host key is carried across (kexec-run.sh copies /etc/ssh into # the appended initrd), which is why `kexec` does no ssh-keygen -R. host_key_after = ssh( ["cat", "/etc/ssh/ssh_host_ed25519_key.pub"], stdout=subprocess.PIPE ).stdout.strip() assert host_key_before == host_key_after, \ f"host key changed: {host_key_before} != {host_key_after}" machine.crash() ''; }; }; }