Archived
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>
452 lines
14 KiB
Nix
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 ];
|
|
}
|