This repository has no description
7.3 kB
200 lines
1#!/usr/bin/env bash
2set -euo pipefail
3
4PROG="pxe-serve"
5
6# Defaults
7HTTP_PORT="8079"
8IFACE=""
9TARGET=""
10NO_DHCP=""
11
12usage() {
13 cat >&2 <<EOF
14Usage: $PROG [OPTIONS] <flake>#<machine> [interface]
15
16Netboot a machine straight from its flake: builds the machine's netboot image
17variant, serves it, and points PXE clients at it.
18
19 $PROG .#atlas
20
21The machine needs a \`netboot\` entry in image.modules (see machines/atlas).
22Everything is derived, so nothing has to be kept in sync by hand:
23
24 * kernel, initrd and kernel command line come from the machine's netboot
25 variant (\`system.build.images.netboot\`)
26 * the iPXE script is generated against the serving interface's own address,
27 which is the bit that is wrong in any pre-baked image the moment you move
28 to a different network
29 * kernel and initrd go over HTTP, because an initrd carrying a whole nix
30 store is far too big for TFTP
31
32By default this acts as a proxy DHCP server: it answers only the PXE boot
33question and never hands out addresses, so it works on a network whose DHCP
34server belongs to somebody else. The client must be on the same L2 segment.
35
36Runs in the foreground. Press Ctrl+C to stop; the built payload stays in the
37nix store, the served directory is temporary.
38
39Options:
40 --no-dhcp Don't answer DHCP; only serve the files. Use when
41 another DHCP server already points clients at us.
42 --http-port <port> Port to serve kernel/initrd on (default: ${HTTP_PORT})
43 -i, --interface <iface> Interface to serve on
44 (default: the one carrying the default route)
45 -h, --help Show this help
46EOF
47}
48
49while [[ $# -gt 0 ]]; do
50 case $1 in
51 --no-dhcp)
52 NO_DHCP=1
53 shift
54 ;;
55 --http-port)
56 HTTP_PORT="$2"
57 shift 2
58 ;;
59 -i | --interface)
60 IFACE="$2"
61 shift 2
62 ;;
63 -h | --help)
64 usage
65 exit 0
66 ;;
67 -*)
68 echo "$PROG: unknown option: $1" >&2
69 usage
70 exit 1
71 ;;
72 *)
73 if [[ -z $TARGET ]]; then
74 TARGET="$1"
75 elif [[ -z $IFACE ]]; then
76 IFACE="$1"
77 else
78 echo "$PROG: unexpected argument: $1" >&2
79 usage
80 exit 1
81 fi
82 shift
83 ;;
84 esac
85done
86
87if [[ -z $TARGET ]]; then
88 echo "$PROG: need a machine, e.g. $PROG .#atlas" >&2
89 usage
90 exit 1
91fi
92
93# "<flake>#<machine>", or a bare machine name in the current flake.
94if [[ $TARGET == *#* ]]; then
95 FLAKE="${TARGET%%#*}"
96 MACHINE="${TARGET##*#}"
97else
98 FLAKE="."
99 MACHINE="$TARGET"
100fi
101[[ -n $FLAKE ]] || FLAKE="."
102
103# builtins.getFlake (used for the iPXE override below) needs an absolute path
104# or a URL, not a relative one like ".".
105case $FLAKE in
106 . | ./* | /* | ../*) FLAKE_REF=$(cd "$FLAKE" && pwd -P) ;;
107 *) FLAKE_REF="$FLAKE" ;;
108esac
109
110if [[ -z $IFACE ]]; then
111 IFACE=$(ip -4 route show default | awk '{print $5; exit}')
112 if [[ -z $IFACE ]]; then
113 echo "$PROG: no default route to pick an interface from; pass --interface" >&2
114 exit 1
115 fi
116fi
117
118ADDR=$(ip -4 -brief addr show dev "$IFACE" 2>/dev/null | awk '{print $3}' | cut -d/ -f1 | head -n1)
119if [[ -z $ADDR ]]; then
120 echo "$PROG: $IFACE has no IPv4 address to serve from" >&2
121 exit 1
122fi
123
124CFG="${FLAKE}#nixosConfigurations.${MACHINE}.config.system.build.images.netboot.passthru.config"
125
126echo "$PROG: building netboot payload for ${MACHINE} (this pulls in the whole store, so it takes a while)"
127KERNEL=$(nix build --no-link --print-out-paths "${CFG}.system.build.kernel")
128KERNEL_FILE=$(nix eval --raw "${CFG}.system.boot.loader.kernelFile")
129INITRD=$(nix build --no-link --print-out-paths "${CFG}.system.build.netbootRamdisk")
130TOPLEVEL=$(nix eval --raw "${CFG}.system.build.toplevel")
131PARAMS=$(nix eval --raw --apply 'ps: builtins.concatStringsSep " " ps' "${CFG}.boot.kernelParams")
132# Served separately from the initrd: iPXE can only hold so much in
133# EfiBootServicesData, so stage 1 fetches this itself over HTTP.
134SQUASHFS=$(nix build --no-link --print-out-paths "${CFG}.system.build.squashfsStore")
135
136# iPXE gets the chain script baked in rather than asking DHCP for a filename.
137# In proxy-DHCP mode dnsmasq answers through pxe-service, so a --dhcp-boot
138# aimed at the second-stage (tag:ipxe) request never reaches iPXE and it dies
139# with "Nothing to boot: No such file or directory" (ipxe.org/2d03e13b) right
140# after loading. With the script embedded it only needs an address, which the
141# network's own DHCP server hands out.
142#
143# Written to a file instead of passed inline because the expression embeds a
144# URL and a flake path, and quoting that through the shell is a trap.
145IPXE_EXPR=$(mktemp -t "${PROG}-ipxe-XXXXXX.nix")
146cat >"$IPXE_EXPR" <<EOF
147let
148 flake = builtins.getFlake "${FLAKE_REF}";
149in
150flake.nixosConfigurations."${MACHINE}".pkgs.ipxe.override {
151 embedScript = builtins.toFile "chain.ipxe" ''
152 #!ipxe
153 dhcp
154 chain http://${ADDR}:${HTTP_PORT}/netboot.ipxe
155 '';
156}
157EOF
158IPXE=$(nix build --no-link --print-out-paths --impure --file "$IPXE_EXPR")
159rm -f "$IPXE_EXPR"
160
161# A stable directory rather than a mktemp one with a cleanup trap: the trap
162# was the reason Ctrl+C did nothing useful. A bash INT handler does not exit
163# after running, so it deleted the served directory out from under a still
164# running dnsmasq and then went back to waiting. Only symlinks live here, so
165# leaving them behind between runs costs nothing.
166#
167# Not under XDG_RUNTIME_DIR: dnsmasq drops privileges after startup, and
168# /run/user/$UID is mode 0700, so the unprivileged dnsmasq cannot traverse it
169# ("TFTP directory ... inaccessible: Permission denied", then it refuses to
170# start). Everything served is a symlink into the world-readable nix store,
171# so a world-traversable directory costs nothing.
172ROOT="/var/tmp/${PROG}/${MACHINE}"
173rm -rf "$ROOT"
174mkdir -p "$ROOT"
175chmod 755 "/var/tmp/${PROG}" "$ROOT"
176
177ln -s "${KERNEL}/${KERNEL_FILE}" "$ROOT/bzImage"
178ln -s "${INITRD}/initrd" "$ROOT/initrd"
179ln -s "${SQUASHFS}" "$ROOT/nix-store.squashfs"
180ln -s "${IPXE}/ipxe.efi" "$ROOT/ipxe.efi"
181ln -s "${IPXE}/undionly.kpxe" "$ROOT/undionly.kpxe"
182
183# store.url= is what stage 1 fetches the squashfs from; passing it on the
184# command line instead of baking it into the image keeps the image valid when
185# the serving address changes.
186cat >"$ROOT/netboot.ipxe" <<EOF
187#!ipxe
188kernel http://${ADDR}:${HTTP_PORT}/bzImage init=${TOPLEVEL}/init initrd=initrd ${PARAMS} store.url=http://${ADDR}:${HTTP_PORT}/nix-store.squashfs
189initrd http://${ADDR}:${HTTP_PORT}/initrd
190boot
191EOF
192
193echo "$PROG: serving ${MACHINE} on ${IFACE} (${ADDR}) over http://${ADDR}:${HTTP_PORT}"
194echo "$PROG: iPXE fetches $(stat -Lc %s "$ROOT/bzImage" | awk '{printf "%.0f MB", $1/1048576}') kernel + $(stat -Lc %s "$ROOT/initrd" | awk '{printf "%.0f MB", $1/1048576}') initrd; stage 1 then fetches $(stat -Lc %s "$ROOT/nix-store.squashfs" | awk '{printf "%.2f GB", $1/1073741824}') of store itself"
195
196# exec, so there is no wrapper process between the terminal and dnsmasq: Ctrl+C
197# reaches it directly and pxe-share's own trap does the privileged teardown
198# (firewall rules, darkhttpd). pxe-share re-execs itself through sudo; the nix
199# builds above deliberately ran as the calling user.
200exec pxe-share ${NO_DHCP:+--no-dhcp} --http --http-port "$HTTP_PORT" "$ROOT" "$IFACE"