{ description = "Homelab NixOS configuration"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; # Second nixpkgs, used for ONE package: immich. 26.05 pins 2.7.5, but # jupiter's imported database was written by 3.0.0 and immich never # migrates a schema backwards. NOT `follows` — the point is a different # package set. See services/media/immich.nix. 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.nixpkgs.follows = "nixpkgs"; }; 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"; }; # Deliberately NOT `inputs.nixpkgs.follows` — upstream states overriding it # breaks their pinned python dependency set. Costs a second nixpkgs in the # lock; builds come prebuilt from nix-community's Cachix. authentik-nix.url = "github:nix-community/authentik-nix"; # Unofficial packaging of Proton's pass-cli (not in nixpkgs) — used by # ./scripts/deploy to pull sudo/ssh passwords from the "HomeLab" vault. proton-pass-cli = { url = "github:tomsch/proton-pass-cli-nix"; 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. tome = { url = "git+ssh://gitea@git.mgaction.town:2222/darman/TOME.git"; flake = false; }; }; outputs = { self, nixpkgs, disko, sops-nix, nixos-images, home-manager, mediamanager-nix, authentik-nix, ... }@inputs: let system = "x86_64-linux"; in { packages.${system}.tome = nixpkgs.legacyPackages.${system}.callPackage ./pkgs/tome.nix { src = inputs.tome; }; 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 ./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 ./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. # Build the ISO: # nix build .#nixosConfigurations.installer-iso.config.system.build.isoImage # dd it to a USB stick, boot the ZimaBlade from it, SSH in, ./deploy install. installer-iso = nixpkgs.lib.nixosSystem { inherit system; modules = [ (nixpkgs + "/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix") ({ ... }: { services.openssh.enable = true; services.openssh.settings.PermitRootLogin = "prohibit-password"; users.users.root.openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZpkPVhzi1zG5JI9hWyUgdyvNIQbp4ts4jw3idpMhhN erik@laptop" ]; networking.hostName = "jupiter-installer"; }) ]; }; }; # 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() ''; }; }; }