{ config, lib, pkgs, inputs, vars, ... }: let pxeRoot = "/srv/pxe"; httpRoot = "${pxeRoot}/http"; tftpRoot = "${pxeRoot}/tftp"; pxeBaseUrl = "http://${vars.pxeServerIp}"; bootIpxe = pkgs.writeText "boot.ipxe" '' #!ipxe dhcp echo Booting from PXE server... chain ${pxeBaseUrl}/menu.ipxe ''; autoexecIpxe = pkgs.writeText "autoexec.ipxe" '' #!ipxe dhcp chain ${pxeBaseUrl}/boot.ipxe ''; debianRelease = "bookworm"; debianMirror = "https://deb.debian.org/debian"; debianNetbootBase = "${debianMirror}/dists/${debianRelease}/main/installer-amd64/current/images/netboot/debian-installer/amd64"; rockyRelease = "9"; rockyArch = "x86_64"; rockyMirror = "https://dl.rockylinux.org/pub/rocky/${rockyRelease}"; rockyPxebootBase = "${rockyMirror}/BaseOS/${rockyArch}/os/images/pxeboot"; debianIpxe = pkgs.writeText "debian.ipxe" '' #!ipxe set base ${pxeBaseUrl} kernel ''${base}/debian/linux initrd ''${base}/debian/initrd.gz boot ''; fetchDebianNetboot = pkgs.writeShellScript "fetch-debian-netboot" '' set -eu dir="${httpRoot}/debian" mirror="${debianNetbootBase}" if [ -f "$dir/linux" ] && [ -f "$dir/initrd.gz" ]; then echo "Debian ${debianRelease} netboot files already present; skipping download." exit 0 fi echo "Downloading Debian ${debianRelease} netboot kernel and initrd from $mirror ..." ${pkgs.curl}/bin/curl -fsSL -o "$dir/linux.tmp" "$mirror/linux" ${pkgs.curl}/bin/curl -fsSL -o "$dir/initrd.gz.tmp" "$mirror/initrd.gz" mv "$dir/linux.tmp" "$dir/linux" mv "$dir/initrd.gz.tmp" "$dir/initrd.gz" echo "Debian ${debianRelease} netboot files staged." ''; # Rocky Linux 9 iPXE script — boots vmlinuz+initrd.img from the staged # /rocky/ directory and hands Anaconda the hosted Kickstart URL. # net.ifnames=0 biosdevname=0 ensures the NIC is eth0 in both the # installer and the installed system (matches the Kickstart NM config). rockyFreeIpaIpxe = pkgs.writeText "rocky-freeipa.ipxe" '' #!ipxe set base ${pxeBaseUrl} kernel ''${base}/rocky/vmlinuz inst.ks=''${base}/rocky-freeipa.ks inst.repo=${rockyMirror}/BaseOS/${rockyArch}/os/ net.ifnames=0 biosdevname=0 ip=dhcp quiet initrd ''${base}/rocky/initrd.img boot ''; # Kickstart file for domain-controller.sweet.home. # Installs Rocky Linux 9, sets a static IP, creates wayne with the # admin SSH key, then on first reboot runs ipa-server-install via a # systemd oneshot service. Passwords are generated at %post time, # written to /root/ipa-credentials.txt (chmod 600), and read back by # the first-boot script — never hardcoded here or in the repo. rockyFreeIpaKs = pkgs.writeText "rocky-freeipa.ks" '' #version=RHEL9 # Unattended Rocky Linux 9 + FreeIPA install # Target: domain-controller.sweet.home 192.168.2.138 url --url=${rockyMirror}/BaseOS/${rockyArch}/os/ repo --name=appstream --baseurl=${rockyMirror}/AppStream/${rockyArch}/os/ lang en_US.UTF-8 keyboard us timezone UTC --utc # DHCP during install; static IP configured in %post via NM config file network --bootproto=dhcp --device=link --activate network --hostname=domain-controller.sweet.home selinux --enforcing firewall --enabled --service=ssh rootpw --lock user --name=wayne --groups=wheel --shell=/bin/bash sshkey --username=wayne "${vars.adminSshKey}" zerombr clearpart --all --initlabel --drives=sda # Keep net.ifnames=0 biosdevname=0 in the installed GRUB so the NIC # stays eth0 after reboot (matches the NM connection file below). bootloader --location=mbr --boot-drive=sda --append="net.ifnames=0 biosdevname=0" part /boot --fstype=xfs --size=1024 --ondisk=sda part swap --fstype=swap --size=2048 --ondisk=sda part / --fstype=xfs --grow --size=1 --ondisk=sda --asprimary %packages @^minimal-environment ipa-server ipa-server-dns %end reboot %post --log=/root/ks-post.log set -euo pipefail # -- Static IP: write NM connection file directly (NM not running in chroot) -- mkdir -p /etc/NetworkManager/system-connections cat > /etc/NetworkManager/system-connections/eth0.nmconnection << 'NMCONN' [connection] id=eth0 type=ethernet interface-name=eth0 autoconnect=true [ethernet] [ipv4] method=manual addresses=192.168.2.138/24 gateway=192.168.2.254 dns=192.168.2.253; dns-search=sweet.home; [ipv6] method=auto NMCONN chmod 600 /etc/NetworkManager/system-connections/eth0.nmconnection # -- /etc/hosts: FQDN must resolve to the real IP (not loopback) for IPA -- sed -i '/domain-controller/d' /etc/hosts echo '192.168.2.138 domain-controller.sweet.home domain-controller' >> /etc/hosts # -- Generate IPA passwords and store securely -- DM_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24) ADMIN_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24) printf 'Directory Manager: %s\nIPA Admin: %s\n' "$DM_PASS" "$ADMIN_PASS" \ > /root/ipa-credentials.txt chmod 600 /root/ipa-credentials.txt # -- First-boot script: reads passwords back, runs ipa-server-install -- cat > /usr/local/sbin/freeipa-first-boot.sh << 'FIRSTBOOT' #!/bin/bash set -euo pipefail exec >> /root/freeipa-install.log 2>&1 echo "=== FreeIPA first-boot install started at $(date) ===" DM_PASS=$(grep '^Directory Manager:' /root/ipa-credentials.txt | awk '{print $NF}') ADMIN_PASS=$(grep '^IPA Admin:' /root/ipa-credentials.txt | awk '{print $NF}') ipa-server-install \ --realm=SWEET.HOME \ --domain=sweet.home \ --hostname=domain-controller.sweet.home \ --ds-password="$DM_PASS" \ --admin-password="$ADMIN_PASS" \ --setup-dns \ --forwarder=192.168.2.253 \ --no-dnssec-validation \ --no-ntp \ --unattended echo "=== FreeIPA install complete at $(date) ===" echo "Credentials: /root/ipa-credentials.txt (save to password manager)" echo "CA backup: /root/cacert.p12 (encrypted with Directory Manager password)" systemctl disable freeipa-first-boot.service FIRSTBOOT chmod 700 /usr/local/sbin/freeipa-first-boot.sh # -- Systemd oneshot service: runs freeipa-first-boot.sh on first real boot -- cat > /etc/systemd/system/freeipa-first-boot.service << 'UNIT' [Unit] Description=FreeIPA first-boot installation After=network-online.target Wants=network-online.target ConditionPathExists=/root/ipa-credentials.txt [Service] Type=oneshot ExecStart=/usr/local/sbin/freeipa-first-boot.sh TimeoutStartSec=1800 RemainAfterExit=yes [Install] WantedBy=multi-user.target UNIT mkdir -p /etc/systemd/system/multi-user.target.wants ln -sf /etc/systemd/system/freeipa-first-boot.service \ /etc/systemd/system/multi-user.target.wants/freeipa-first-boot.service echo "Kickstart %post complete. FreeIPA installs on first reboot (~20 min)." %end ''; fetchRockyPxeboot = pkgs.writeShellScript "fetch-rocky-pxeboot" '' set -eu dir="${httpRoot}/rocky" base="${rockyPxebootBase}" if [ -f "$dir/vmlinuz" ] && [ -f "$dir/initrd.img" ]; then echo "Rocky Linux ${rockyRelease} pxeboot files already present; skipping download." exit 0 fi echo "Downloading Rocky Linux ${rockyRelease} pxeboot kernel and initrd from $base ..." ${pkgs.curl}/bin/curl -fsSL -o "$dir/vmlinuz.tmp" "$base/vmlinuz" ${pkgs.curl}/bin/curl -fsSL -o "$dir/initrd.img.tmp" "$base/initrd.img" mv "$dir/vmlinuz.tmp" "$dir/vmlinuz" mv "$dir/initrd.img.tmp" "$dir/initrd.img" echo "Rocky Linux ${rockyRelease} pxeboot files staged." ''; systemRescueIpxe = pkgs.writeText "systemrescue.ipxe" '' #!ipxe set base ${pxeBaseUrl} kernel ''${base}/systemrescue/sysresccd/boot/x86_64/vmlinuz initrd=sysresccd.img archisobasedir=sysresccd archiso_http_srv=''${base}/systemrescue/ ip=dhcp checksum initrd ''${base}/systemrescue/sysresccd/boot/x86_64/sysresccd.img sysresccd.img boot ''; stageSystemRescue = pkgs.writeShellScript "stage-systemrescue" '' set -eu iso="${httpRoot}/images/systemrescue.iso" staged="${httpRoot}/systemrescue" tmp="${httpRoot}/.systemrescue.tmp" previous="${httpRoot}/.systemrescue.previous" if [ ! -e "$iso" ]; then echo "SystemRescue ISO not found at $iso; skipping staging." exit 0 fi rm -rf "$tmp" mkdir -p "$tmp" ${pkgs.libarchive}/bin/bsdtar -C "$tmp" -xf "$iso" test -f "$tmp/sysresccd/boot/x86_64/vmlinuz" test -f "$tmp/sysresccd/boot/x86_64/sysresccd.img" chmod -R a+rX "$tmp" rm -rf "$previous" if [ -e "$staged" ]; then mv "$staged" "$previous" fi mv "$tmp" "$staged" rm -rf "$previous" ''; menuIpxe = pkgs.writeText "menu.ipxe" '' #!ipxe set base ${pxeBaseUrl} menu PXE Boot Menu item auto-installer NixOS Auto-Installer item nixos-minimal NixOS Minimal item debian Debian Minimal item rocky-freeipa FreeIPA Server (Rocky Linux 9) item rescue Rescue Environment item shell iPXE Shell item reboot Reboot choose target && goto ''${target} :auto-installer chain ''${base}/auto-installer/netboot.ipxe :nixos-minimal chain ''${base}/nixos-minimal/netboot.ipxe :debian chain ''${base}/debian.ipxe :rocky-freeipa chain ''${base}/rocky-freeipa.ipxe :rescue chain ''${base}/systemrescue.ipxe :shell shell :reboot reboot ''; in { imports = [ ../pxe-boot/stage-installer-artifacts.nix ../pxe-boot/mount-pxe-images.nix ]; environment.systemPackages = with pkgs; [ ipxe ]; services = { nginx = { enable = true; virtualHosts."pxe-boot" = { default = true; root = httpRoot; locations."/" = { extraConfig = '' autoindex on; ''; }; }; }; # TFTP is only used to deliver the initial iPXE bootloader. After iPXE # starts, all further assets are fetched via nginx over HTTP. atftpd = { enable = true; root = tftpRoot; extraOptions = [ "--verbose=5" ]; }; openssh.settings.PermitRootLogin = "yes"; }; systemd = { tmpfiles.rules = [ "d ${pxeRoot} 0755 root root -" "d ${httpRoot} 0755 root root -" "L+ ${httpRoot}/images - - - - ${vars.nfsShares.pxebootImages.mountpoint}" "d ${httpRoot}/auto-installer 0755 root root -" "d ${httpRoot}/nixos-minimal 0755 root root -" "d ${httpRoot}/systemrescue 0755 root root -" "d ${httpRoot}/debian 0755 root root -" "d ${httpRoot}/ubuntu 0755 root root -" "d ${httpRoot}/rescue 0755 root root -" "d ${httpRoot}/rocky 0755 root root -" "d ${tftpRoot} 0755 root root -" "C+ ${httpRoot}/boot.ipxe 0644 root root - ${bootIpxe}" "C+ ${httpRoot}/menu.ipxe 0644 root root - ${menuIpxe}" "C+ ${httpRoot}/debian.ipxe 0644 root root - ${debianIpxe}" "C+ ${httpRoot}/rocky-freeipa.ipxe 0644 root root - ${rockyFreeIpaIpxe}" "C+ ${httpRoot}/rocky-freeipa.ks 0644 root root - ${rockyFreeIpaKs}" "C+ ${httpRoot}/systemrescue.ipxe 0644 root root - ${systemRescueIpxe}" "C+ ${tftpRoot}/autoexec.ipxe 0644 root root - ${autoexecIpxe}" "C+ ${tftpRoot}/ipxe.efi 0644 root root - ${pkgs.ipxe}/ipxe.efi" "C+ ${tftpRoot}/undionly.kpxe 0644 root root - ${pkgs.ipxe}/undionly.kpxe" ]; services = { fetch-debian-netboot = { description = "Download Debian ${debianRelease} netboot kernel and initrd for HTTP PXE boot"; after = [ "local-fs.target" "systemd-tmpfiles-setup.service" "network-online.target" ]; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; ExecStart = fetchDebianNetboot; RemainAfterExit = true; }; }; fetch-rocky-pxeboot = { description = "Download Rocky Linux ${rockyRelease} pxeboot kernel and initrd for HTTP PXE boot"; after = [ "local-fs.target" "systemd-tmpfiles-setup.service" "network-online.target" ]; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; ExecStart = fetchRockyPxeboot; RemainAfterExit = true; }; }; stage-systemrescue = { description = "Stage SystemRescue ISO contents for HTTP PXE boot"; after = [ "local-fs.target" "systemd-tmpfiles-setup.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; ExecStart = stageSystemRescue; }; }; }; }; services.dnsmasq = { enable = true; settings = { # Disable DNS listener — only proxy DHCP is needed here. # Without this dnsmasq tries to bind port 53 which systemd-resolved # already owns, causing startup failure. port = 0; dhcp-range = "192.168.2.0,proxy"; # Detect iPXE clients on the second DHCP round (after loading the # iPXE binary) so we can serve the HTTP menu instead of TFTP. dhcp-match = "set:ipxe,175"; dhcp-userclass = "set:ipxe,iPXE"; # iPXE second boot: chain to HTTP boot menu. dhcp-boot = "tag:ipxe,http://${vars.pxeServerIp}/boot.ipxe"; # Initial boot: use pxe-service so dnsmasq sends proxy DHCP offers. # dhcp-boot alone does not trigger proxy offers in dnsmasq 2.93. # Arch 0 = BIOS, 7 = EFI BC (OVMF), 9 = EFI x86-64. pxe-service = [ "0,\"Network Boot\",undionly.kpxe,${vars.pxeServerIp}" "7,\"Network Boot\",ipxe.efi,${vars.pxeServerIp}" "9,\"Network Boot\",ipxe.efi,${vars.pxeServerIp}" ]; }; }; networking.firewall.allowedTCPPorts = [ vars.ports.pxeBootHttp ]; networking.firewall.allowedUDPPorts = [ vars.ports.pxeBootTftp 67 4011 ]; }