···11+#!/usr/bin/env bash
22+set -euo pipefail
33+44+PROG="pxe-share"
55+66+# Defaults
77+MODE="proxy"
88+HTTP_WANTED=""
99+HTTP_ROOT=""
1010+HTTP_PORT="8079"
1111+PXE_SCRIPT="netboot.ipxe"
1212+BACKEND="auto"
1313+IFACE=""
1414+ROOT=""
1515+1616+usage() {
1717+ cat >&2 <<EOF
1818+Usage: $PROG [OPTIONS] <dir> <interface>
1919+2020+Serve PXE network boot from <dir> on <interface>.
2121+2222+By default this is a *proxy* DHCP server: it answers only the PXE boot
2323+questions (which boot file to fetch) and never hands out addresses or DNS, so
2424+it can run on a network whose DHCP server belongs to somebody else — a router,
2525+an office LAN — without fighting it. The existing server keeps assigning IPs.
2626+2727+Nothing about the interface is reconfigured; only firewall rules are added,
2828+and they are removed again on exit.
2929+3030+Runs in the foreground. Press Ctrl+C to stop and restore.
3131+3232+Options:
3333+ --no-dhcp Do not answer DHCP at all; only serve the files
3434+ (TFTP, and HTTP with --http). Use when another DHCP
3535+ server on the link already points clients at us,
3636+ e.g. \`nat-share --pxe\`, which owns port 67 itself.
3737+ --http Also serve <dir> over HTTP, on --http-port. Needed
3838+ for netboot payloads: a kernel+initrd carrying a
3939+ nix store is far too big for TFTP.
4040+ --http-port <port> Port for --http (default: ${HTTP_PORT})
4141+ --script <name> iPXE script handed to iPXE (default: ${PXE_SCRIPT})
4242+ -b, --backend <name> Firewall backend: auto | iptables | nft
4343+ (default: auto-detect)
4444+ -h, --help Show this help
4545+4646+<dir> should hold ipxe.efi (UEFI x64), optionally ipxe32.efi / undionly.kpxe
4747+for other client architectures, and ${PXE_SCRIPT}.
4848+EOF
4949+}
5050+5151+while [[ $# -gt 0 ]]; do
5252+ case $1 in
5353+ --no-dhcp)
5454+ MODE="serve-only"
5555+ shift
5656+ ;;
5757+ --http)
5858+ # A plain flag on purpose: an optional argument here is ambiguous next
5959+ # to the positional <dir> <interface> and silently eats the interface.
6060+ HTTP_WANTED=1
6161+ shift
6262+ ;;
6363+ --http-port)
6464+ HTTP_PORT="$2"
6565+ shift 2
6666+ ;;
6767+ --script)
6868+ PXE_SCRIPT="$2"
6969+ shift 2
7070+ ;;
7171+ -b | --backend)
7272+ BACKEND="$2"
7373+ shift 2
7474+ ;;
7575+ -h | --help)
7676+ usage
7777+ exit 0
7878+ ;;
7979+ -*)
8080+ echo "$PROG: unknown option: $1" >&2
8181+ usage
8282+ exit 1
8383+ ;;
8484+ *)
8585+ if [[ -z $ROOT ]]; then
8686+ ROOT="$1"
8787+ elif [[ -z $IFACE ]]; then
8888+ IFACE="$1"
8989+ else
9090+ echo "$PROG: unexpected argument: $1" >&2
9191+ usage
9292+ exit 1
9393+ fi
9494+ shift
9595+ ;;
9696+ esac
9797+done
9898+9999+if [[ -z $ROOT || -z $IFACE ]]; then
100100+ echo "$PROG: need both <dir> and <interface>" >&2
101101+ usage
102102+ exit 1
103103+fi
104104+105105+case $BACKEND in
106106+ auto | iptables | nft) ;;
107107+ *)
108108+ echo "$PROG: invalid backend '$BACKEND' (expected auto|iptables|nft)" >&2
109109+ exit 1
110110+ ;;
111111+esac
112112+113113+if [[ ! -d $ROOT ]]; then
114114+ echo "$PROG: '$ROOT' is not a directory" >&2
115115+ exit 1
116116+fi
117117+# Absolute, because we re-exec through sudo from an unknown cwd.
118118+ROOT=$(cd "$ROOT" && pwd -P)
119119+120120+if [[ -n $HTTP_WANTED ]]; then
121121+ HTTP_ROOT="$ROOT"
122122+fi
123123+124124+if ! ip link show "$IFACE" >/dev/null 2>&1; then
125125+ echo "$PROG: interface '$IFACE' does not exist" >&2
126126+ exit 1
127127+fi
128128+129129+# Re-exec as root, preserving arguments.
130130+if [[ $EUID -ne 0 ]]; then
131131+ reexec=()
132132+ if [[ $MODE == serve-only ]]; then
133133+ reexec+=(--no-dhcp)
134134+ fi
135135+ if [[ -n $HTTP_WANTED ]]; then
136136+ reexec+=(--http --http-port "$HTTP_PORT")
137137+ fi
138138+ reexec+=(--script "$PXE_SCRIPT" --backend "$BACKEND" "$ROOT" "$IFACE")
139139+ exec sudo -- "$0" "${reexec[@]}"
140140+fi
141141+142142+if [[ $BACKEND == auto ]]; then
143143+ if command -v nft >/dev/null 2>&1 && nft list table inet nixos-fw >/dev/null 2>&1; then
144144+ BACKEND=nft
145145+ else
146146+ BACKEND=iptables
147147+ fi
148148+fi
149149+150150+# Proxy-DHCP has to answer from the address the clients can reach us on, and
151151+# dnsmasq wants that subnet in --dhcp-range to scope the proxy offer.
152152+ADDR=""
153153+if [[ $MODE == proxy ]]; then
154154+ ADDR=$(ip -4 -brief addr show dev "$IFACE" | awk '{print $3}' | cut -d/ -f1 | head -n1)
155155+ if [[ -z $ADDR ]]; then
156156+ echo "$PROG: $IFACE has no IPv4 address; proxy DHCP needs one (or use --no-dhcp)" >&2
157157+ exit 1
158158+ fi
159159+fi
160160+161161+# Track what we changed so cleanup only reverts our own state.
162162+IPT_RULES=()
163163+FW_HANDLES=() # "chain handle" pairs inserted into inet nixos-fw
164164+HTTP_PID=""
165165+166166+# Name of the "inet nixos-fw" base chain registered to the given hook, if any.
167167+nft_base_chain() {
168168+ nft list table inet nixos-fw 2>/dev/null | awk -v h="hook $1 " '
169169+ /chain [A-Za-z0-9_.-]+ \{/ { name = $2 }
170170+ index($0, h) { print name; exit }
171171+ '
172172+}
173173+174174+# Insert a rule at the top of an "inet nixos-fw" chain, remembering its handle.
175175+fw_insert() {
176176+ local chain=$1
177177+ shift
178178+ local out handle
179179+ out=$(nft --handle --echo insert rule inet nixos-fw "$chain" "$@" 2>/dev/null) || return 1
180180+ handle=$(printf '%s\n' "$out" | grep -oE 'handle [0-9]+' | grep -oE '[0-9]+' | tail -n1)
181181+ [[ -n $handle ]] && FW_HANDLES+=("$chain $handle")
182182+}
183183+184184+# Open a port on the served interface in whichever backend is active.
185185+open_port() {
186186+ local proto=$1 port=$2
187187+ if [[ $BACKEND == iptables ]]; then
188188+ iptables -I INPUT -i "$IFACE" -p "$proto" --dport "$port" -j ACCEPT
189189+ IPT_RULES+=("$proto $port")
190190+ else
191191+ local chain
192192+ chain=$(nft_base_chain input)
193193+ [[ -n $chain ]] && fw_insert "$chain" iifname "$IFACE" "$proto" dport "$port" accept
194194+ fi
195195+}
196196+197197+cleanup() {
198198+ set +e
199199+ echo ""
200200+ echo "$PROG: restoring..."
201201+202202+ if [[ -n $HTTP_PID ]]; then
203203+ kill "$HTTP_PID" 2>/dev/null
204204+ wait "$HTTP_PID" 2>/dev/null
205205+ fi
206206+207207+ local entry
208208+ for entry in "${IPT_RULES[@]}"; do
209209+ # shellcheck disable=SC2086
210210+ set -- $entry
211211+ iptables -D INPUT -i "$IFACE" -p "$1" --dport "$2" -j ACCEPT 2>/dev/null
212212+ done
213213+ for entry in "${FW_HANDLES[@]}"; do
214214+ # shellcheck disable=SC2086
215215+ set -- $entry
216216+ nft delete rule inet nixos-fw "$1" handle "$2" 2>/dev/null
217217+ done
218218+219219+ echo "$PROG: done"
220220+}
221221+trap cleanup EXIT INT TERM
222222+223223+# TFTP is always ours. Only the initial request hits UDP/69; dnsmasq then
224224+# transfers from an ephemeral port, whose return traffic conntrack already
225225+# accepts as ESTABLISHED.
226226+open_port udp 69
227227+if [[ $MODE == proxy ]]; then
228228+ # 67 for the proxy offer, 4011 for the PXE client's follow-up request.
229229+ open_port udp 67
230230+ open_port udp 4011
231231+fi
232232+if [[ -n $HTTP_ROOT ]]; then
233233+ open_port tcp "$HTTP_PORT"
234234+fi
235235+236236+DNSMASQ_OPTS=(
237237+ --keep-in-foreground
238238+ --log-facility=-
239239+ --interface="$IFACE"
240240+ --bind-interfaces
241241+ --except-interface=lo
242242+ # No DNS: somebody else's network already has one.
243243+ --port=0
244244+ --enable-tftp
245245+ "--tftp-root=${ROOT}"
246246+)
247247+248248+if [[ $MODE == proxy ]]; then
249249+ # Chainloaded in two steps: bare firmware is matched on its client
250250+ # architecture and handed an iPXE binary; iPXE itself (which sets DHCP
251251+ # option 175) is then handed the boot script. Without that split the
252252+ # firmware would be pointed back at iPXE forever.
253253+ #
254254+ # ",proxy" is what makes this coexist with the network's real DHCP server:
255255+ # dnsmasq replies with boot information only, never with a lease.
256256+ DNSMASQ_OPTS+=(
257257+ "--dhcp-range=${ADDR},proxy"
258258+ "--dhcp-match=set:ipxe,175"
259259+ "--dhcp-match=set:efi64,option:client-arch,7"
260260+ "--dhcp-match=set:efi64,option:client-arch,9"
261261+ "--dhcp-match=set:efi32,option:client-arch,6"
262262+ "--pxe-service=tag:!ipxe,x86-64_EFI,Network boot,ipxe.efi"
263263+ "--pxe-service=tag:!ipxe,IA32_EFI,Network boot,ipxe32.efi"
264264+ "--pxe-service=tag:!ipxe,x86PC,Network boot,undionly.kpxe"
265265+ "--dhcp-boot=tag:ipxe,${PXE_SCRIPT}"
266266+ )
267267+ echo "$PROG: proxy DHCP on ${IFACE} (${ADDR}) — not assigning addresses"
268268+else
269269+ echo "$PROG: serving files only on ${IFACE} (no DHCP)"
270270+fi
271271+272272+echo "$PROG: serving TFTP from ${ROOT} (boot script ${PXE_SCRIPT})"
273273+274274+if [[ -n $HTTP_ROOT ]]; then
275275+ # darkhttpd follows the symlinks in the served root into /nix/store, so the
276276+ # tree can point at the store instead of copying a multi-GB initrd around.
277277+ echo "$PROG: serving HTTP from ${HTTP_ROOT} on port ${HTTP_PORT}"
278278+ darkhttpd "$HTTP_ROOT" --port "$HTTP_PORT" &
279279+ HTTP_PID=$!
280280+fi
281281+282282+echo "$PROG: press Ctrl+C to stop"
283283+dnsmasq "${DNSMASQ_OPTS[@]}"