···11+#!/usr/bin/env bash
22+set -euo pipefail
33+44+PROG="pxe-serve"
55+66+# Defaults
77+HTTP_PORT="8079"
88+IFACE=""
99+TARGET=""
1010+NO_DHCP=""
1111+1212+usage() {
1313+ cat >&2 <<EOF
1414+Usage: $PROG [OPTIONS] <flake>#<machine> [interface]
1515+1616+Netboot a machine straight from its flake: builds the machine's netboot image
1717+variant, serves it, and points PXE clients at it.
1818+1919+ $PROG .#atlas
2020+2121+The machine needs a \`netboot\` entry in image.modules (see machines/atlas).
2222+Everything is derived, so nothing has to be kept in sync by hand:
2323+2424+ * kernel, initrd and kernel command line come from the machine's netboot
2525+ variant (\`system.build.images.netboot\`)
2626+ * the iPXE script is generated against the serving interface's own address,
2727+ which is the bit that is wrong in any pre-baked image the moment you move
2828+ to a different network
2929+ * kernel and initrd go over HTTP, because an initrd carrying a whole nix
3030+ store is far too big for TFTP
3131+3232+By default this acts as a proxy DHCP server: it answers only the PXE boot
3333+question and never hands out addresses, so it works on a network whose DHCP
3434+server belongs to somebody else. The client must be on the same L2 segment.
3535+3636+Runs in the foreground. Press Ctrl+C to stop; the built payload stays in the
3737+nix store, the served directory is temporary.
3838+3939+Options:
4040+ --no-dhcp Don't answer DHCP; only serve the files. Use when
4141+ another DHCP server already points clients at us.
4242+ --http-port <port> Port to serve kernel/initrd on (default: ${HTTP_PORT})
4343+ -i, --interface <iface> Interface to serve on
4444+ (default: the one carrying the default route)
4545+ -h, --help Show this help
4646+EOF
4747+}
4848+4949+while [[ $# -gt 0 ]]; do
5050+ case $1 in
5151+ --no-dhcp)
5252+ NO_DHCP=1
5353+ shift
5454+ ;;
5555+ --http-port)
5656+ HTTP_PORT="$2"
5757+ shift 2
5858+ ;;
5959+ -i | --interface)
6060+ IFACE="$2"
6161+ shift 2
6262+ ;;
6363+ -h | --help)
6464+ usage
6565+ exit 0
6666+ ;;
6767+ -*)
6868+ echo "$PROG: unknown option: $1" >&2
6969+ usage
7070+ exit 1
7171+ ;;
7272+ *)
7373+ if [[ -z $TARGET ]]; then
7474+ TARGET="$1"
7575+ elif [[ -z $IFACE ]]; then
7676+ IFACE="$1"
7777+ else
7878+ echo "$PROG: unexpected argument: $1" >&2
7979+ usage
8080+ exit 1
8181+ fi
8282+ shift
8383+ ;;
8484+ esac
8585+done
8686+8787+if [[ -z $TARGET ]]; then
8888+ echo "$PROG: need a machine, e.g. $PROG .#atlas" >&2
8989+ usage
9090+ exit 1
9191+fi
9292+9393+# "<flake>#<machine>", or a bare machine name in the current flake.
9494+if [[ $TARGET == *#* ]]; then
9595+ FLAKE="${TARGET%%#*}"
9696+ MACHINE="${TARGET##*#}"
9797+else
9898+ FLAKE="."
9999+ MACHINE="$TARGET"
100100+fi
101101+[[ -n $FLAKE ]] || FLAKE="."
102102+103103+# builtins.getFlake (used for the iPXE override below) needs an absolute path
104104+# or a URL, not a relative one like ".".
105105+case $FLAKE in
106106+ . | ./* | /* | ../*) FLAKE_REF=$(cd "$FLAKE" && pwd -P) ;;
107107+ *) FLAKE_REF="$FLAKE" ;;
108108+esac
109109+110110+if [[ -z $IFACE ]]; then
111111+ IFACE=$(ip -4 route show default | awk '{print $5; exit}')
112112+ if [[ -z $IFACE ]]; then
113113+ echo "$PROG: no default route to pick an interface from; pass --interface" >&2
114114+ exit 1
115115+ fi
116116+fi
117117+118118+ADDR=$(ip -4 -brief addr show dev "$IFACE" 2>/dev/null | awk '{print $3}' | cut -d/ -f1 | head -n1)
119119+if [[ -z $ADDR ]]; then
120120+ echo "$PROG: $IFACE has no IPv4 address to serve from" >&2
121121+ exit 1
122122+fi
123123+124124+CFG="${FLAKE}#nixosConfigurations.${MACHINE}.config.system.build.images.netboot.passthru.config"
125125+126126+echo "$PROG: building netboot payload for ${MACHINE} (this pulls in the whole store, so it takes a while)"
127127+KERNEL=$(nix build --no-link --print-out-paths "${CFG}.system.build.kernel")
128128+KERNEL_FILE=$(nix eval --raw "${CFG}.system.boot.loader.kernelFile")
129129+INITRD=$(nix build --no-link --print-out-paths "${CFG}.system.build.netbootRamdisk")
130130+TOPLEVEL=$(nix eval --raw "${CFG}.system.build.toplevel")
131131+PARAMS=$(nix eval --raw --apply 'ps: builtins.concatStringsSep " " ps' "${CFG}.boot.kernelParams")
132132+133133+# iPXE gets the chain script baked in rather than asking DHCP for a filename.
134134+# In proxy-DHCP mode dnsmasq answers through pxe-service, so a --dhcp-boot
135135+# aimed at the second-stage (tag:ipxe) request never reaches iPXE and it dies
136136+# with "Nothing to boot: No such file or directory" (ipxe.org/2d03e13b) right
137137+# after loading. With the script embedded it only needs an address, which the
138138+# network's own DHCP server hands out.
139139+#
140140+# Written to a file instead of passed inline because the expression embeds a
141141+# URL and a flake path, and quoting that through the shell is a trap.
142142+IPXE_EXPR=$(mktemp -t "${PROG}-ipxe-XXXXXX.nix")
143143+cat >"$IPXE_EXPR" <<EOF
144144+let
145145+ flake = builtins.getFlake "${FLAKE_REF}";
146146+in
147147+flake.nixosConfigurations."${MACHINE}".pkgs.ipxe.override {
148148+ embedScript = builtins.toFile "chain.ipxe" ''
149149+ #!ipxe
150150+ dhcp
151151+ chain http://${ADDR}:${HTTP_PORT}/netboot.ipxe
152152+ '';
153153+}
154154+EOF
155155+IPXE=$(nix build --no-link --print-out-paths --impure --file "$IPXE_EXPR")
156156+rm -f "$IPXE_EXPR"
157157+158158+# A stable directory rather than a mktemp one with a cleanup trap: the trap
159159+# was the reason Ctrl+C did nothing useful. A bash INT handler does not exit
160160+# after running, so it deleted the served directory out from under a still
161161+# running dnsmasq and then went back to waiting. Only symlinks live here, so
162162+# leaving them behind between runs costs nothing.
163163+ROOT="${XDG_RUNTIME_DIR:-/tmp}/${PROG}/${MACHINE}"
164164+rm -rf "$ROOT"
165165+mkdir -p "$ROOT"
166166+chmod 755 "$ROOT"
167167+168168+ln -s "${KERNEL}/${KERNEL_FILE}" "$ROOT/bzImage"
169169+ln -s "${INITRD}/initrd" "$ROOT/initrd"
170170+ln -s "${IPXE}/ipxe.efi" "$ROOT/ipxe.efi"
171171+ln -s "${IPXE}/undionly.kpxe" "$ROOT/undionly.kpxe"
172172+173173+cat >"$ROOT/netboot.ipxe" <<EOF
174174+#!ipxe
175175+kernel http://${ADDR}:${HTTP_PORT}/bzImage init=${TOPLEVEL}/init initrd=initrd ${PARAMS}
176176+initrd http://${ADDR}:${HTTP_PORT}/initrd
177177+boot
178178+EOF
179179+180180+echo "$PROG: serving ${MACHINE} on ${IFACE} (${ADDR}), kernel+initrd over http://${ADDR}:${HTTP_PORT}"
181181+echo "$PROG: initrd is $(stat -Lc %s "$ROOT/initrd" | awk '{printf "%.1f GB", $1/1073741824}') — expect that much to cross the wire on every boot"
182182+183183+# exec, so there is no wrapper process between the terminal and dnsmasq: Ctrl+C
184184+# reaches it directly and pxe-share's own trap does the privileged teardown
185185+# (firewall rules, darkhttpd). pxe-share re-execs itself through sudo; the nix
186186+# builds above deliberately ran as the calling user.
187187+exec pxe-share ${NO_DHCP:+--no-dhcp} --http --http-port "$HTTP_PORT" "$ROOT" "$IFACE"
···11+bin "pxe-serve"
22+about "Netboot a machine from its flake: builds its netboot image variant and serves it over PXE"
33+44+flag "--no-dhcp" help="Don't answer DHCP; only serve the files (another DHCP server points clients at us)"
55+flag "--http-port <port>" help="Port to serve kernel/initrd on (default: 8079)"
66+flag "-i --interface <iface>" help="Interface to serve on (default: the one carrying the default route)"
77+flag "-h --help" help="Show help message"
88+99+arg "<flake#machine>" help="Machine to netboot, e.g. .#atlas"
1010+arg "[interface]" help="Interface to serve on"