This repository has been archived on 2026-07-30. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
nixos/modules/build-types/pxe-boot.nix
T
beatzaplentyandClaude Sonnet 4.6 7e4b2d33fb fix(pxe-boot): open UDP 4011 for PXE boot service discovery
After the proxy DHCP handshake, the UEFI client queries the PXE boot
server on UDP port 4011 to get the actual boot filename. This port was
not open in the NixOS firewall, so all four PXE discovery attempts from
the test VM silently timed out. dnsmasq was bound to 0.0.0.0:4011 but
never received the packets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 15:12:12 +10:00

452 lines
14 KiB
Nix

{ 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;
log-dhcp = true;
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 ];
}