This repository has no description
8.1 kB
283 lines
1#!/usr/bin/env bash
2set -euo pipefail
3
4PROG="pxe-share"
5
6# Defaults
7MODE="proxy"
8HTTP_WANTED=""
9HTTP_ROOT=""
10HTTP_PORT="8079"
11PXE_SCRIPT="netboot.ipxe"
12BACKEND="auto"
13IFACE=""
14ROOT=""
15
16usage() {
17 cat >&2 <<EOF
18Usage: $PROG [OPTIONS] <dir> <interface>
19
20Serve PXE network boot from <dir> on <interface>.
21
22By default this is a *proxy* DHCP server: it answers only the PXE boot
23questions (which boot file to fetch) and never hands out addresses or DNS, so
24it can run on a network whose DHCP server belongs to somebody else — a router,
25an office LAN — without fighting it. The existing server keeps assigning IPs.
26
27Nothing about the interface is reconfigured; only firewall rules are added,
28and they are removed again on exit.
29
30Runs in the foreground. Press Ctrl+C to stop and restore.
31
32Options:
33 --no-dhcp Do not answer DHCP at all; only serve the files
34 (TFTP, and HTTP with --http). Use when another DHCP
35 server on the link already points clients at us,
36 e.g. \`nat-share --pxe\`, which owns port 67 itself.
37 --http Also serve <dir> over HTTP, on --http-port. Needed
38 for netboot payloads: a kernel+initrd carrying a
39 nix store is far too big for TFTP.
40 --http-port <port> Port for --http (default: ${HTTP_PORT})
41 --script <name> iPXE script handed to iPXE (default: ${PXE_SCRIPT})
42 -b, --backend <name> Firewall backend: auto | iptables | nft
43 (default: auto-detect)
44 -h, --help Show this help
45
46<dir> should hold ipxe.efi (UEFI x64), optionally ipxe32.efi / undionly.kpxe
47for other client architectures, and ${PXE_SCRIPT}.
48EOF
49}
50
51while [[ $# -gt 0 ]]; do
52 case $1 in
53 --no-dhcp)
54 MODE="serve-only"
55 shift
56 ;;
57 --http)
58 # A plain flag on purpose: an optional argument here is ambiguous next
59 # to the positional <dir> <interface> and silently eats the interface.
60 HTTP_WANTED=1
61 shift
62 ;;
63 --http-port)
64 HTTP_PORT="$2"
65 shift 2
66 ;;
67 --script)
68 PXE_SCRIPT="$2"
69 shift 2
70 ;;
71 -b | --backend)
72 BACKEND="$2"
73 shift 2
74 ;;
75 -h | --help)
76 usage
77 exit 0
78 ;;
79 -*)
80 echo "$PROG: unknown option: $1" >&2
81 usage
82 exit 1
83 ;;
84 *)
85 if [[ -z $ROOT ]]; then
86 ROOT="$1"
87 elif [[ -z $IFACE ]]; then
88 IFACE="$1"
89 else
90 echo "$PROG: unexpected argument: $1" >&2
91 usage
92 exit 1
93 fi
94 shift
95 ;;
96 esac
97done
98
99if [[ -z $ROOT || -z $IFACE ]]; then
100 echo "$PROG: need both <dir> and <interface>" >&2
101 usage
102 exit 1
103fi
104
105case $BACKEND in
106 auto | iptables | nft) ;;
107 *)
108 echo "$PROG: invalid backend '$BACKEND' (expected auto|iptables|nft)" >&2
109 exit 1
110 ;;
111esac
112
113if [[ ! -d $ROOT ]]; then
114 echo "$PROG: '$ROOT' is not a directory" >&2
115 exit 1
116fi
117# Absolute, because we re-exec through sudo from an unknown cwd.
118ROOT=$(cd "$ROOT" && pwd -P)
119
120if [[ -n $HTTP_WANTED ]]; then
121 HTTP_ROOT="$ROOT"
122fi
123
124if ! ip link show "$IFACE" >/dev/null 2>&1; then
125 echo "$PROG: interface '$IFACE' does not exist" >&2
126 exit 1
127fi
128
129# Re-exec as root, preserving arguments.
130if [[ $EUID -ne 0 ]]; then
131 reexec=()
132 if [[ $MODE == serve-only ]]; then
133 reexec+=(--no-dhcp)
134 fi
135 if [[ -n $HTTP_WANTED ]]; then
136 reexec+=(--http --http-port "$HTTP_PORT")
137 fi
138 reexec+=(--script "$PXE_SCRIPT" --backend "$BACKEND" "$ROOT" "$IFACE")
139 exec sudo -- "$0" "${reexec[@]}"
140fi
141
142if [[ $BACKEND == auto ]]; then
143 if command -v nft >/dev/null 2>&1 && nft list table inet nixos-fw >/dev/null 2>&1; then
144 BACKEND=nft
145 else
146 BACKEND=iptables
147 fi
148fi
149
150# Proxy-DHCP has to answer from the address the clients can reach us on, and
151# dnsmasq wants that subnet in --dhcp-range to scope the proxy offer.
152ADDR=""
153if [[ $MODE == proxy ]]; then
154 ADDR=$(ip -4 -brief addr show dev "$IFACE" | awk '{print $3}' | cut -d/ -f1 | head -n1)
155 if [[ -z $ADDR ]]; then
156 echo "$PROG: $IFACE has no IPv4 address; proxy DHCP needs one (or use --no-dhcp)" >&2
157 exit 1
158 fi
159fi
160
161# Track what we changed so cleanup only reverts our own state.
162IPT_RULES=()
163FW_HANDLES=() # "chain handle" pairs inserted into inet nixos-fw
164HTTP_PID=""
165
166# Name of the "inet nixos-fw" base chain registered to the given hook, if any.
167nft_base_chain() {
168 nft list table inet nixos-fw 2>/dev/null | awk -v h="hook $1 " '
169 /chain [A-Za-z0-9_.-]+ \{/ { name = $2 }
170 index($0, h) { print name; exit }
171 '
172}
173
174# Insert a rule at the top of an "inet nixos-fw" chain, remembering its handle.
175fw_insert() {
176 local chain=$1
177 shift
178 local out handle
179 out=$(nft --handle --echo insert rule inet nixos-fw "$chain" "$@" 2>/dev/null) || return 1
180 handle=$(printf '%s\n' "$out" | grep -oE 'handle [0-9]+' | grep -oE '[0-9]+' | tail -n1)
181 [[ -n $handle ]] && FW_HANDLES+=("$chain $handle")
182}
183
184# Open a port on the served interface in whichever backend is active.
185open_port() {
186 local proto=$1 port=$2
187 if [[ $BACKEND == iptables ]]; then
188 iptables -I INPUT -i "$IFACE" -p "$proto" --dport "$port" -j ACCEPT
189 IPT_RULES+=("$proto $port")
190 else
191 local chain
192 chain=$(nft_base_chain input)
193 [[ -n $chain ]] && fw_insert "$chain" iifname "$IFACE" "$proto" dport "$port" accept
194 fi
195}
196
197cleanup() {
198 set +e
199 echo ""
200 echo "$PROG: restoring..."
201
202 if [[ -n $HTTP_PID ]]; then
203 kill "$HTTP_PID" 2>/dev/null
204 wait "$HTTP_PID" 2>/dev/null
205 fi
206
207 local entry
208 for entry in "${IPT_RULES[@]}"; do
209 # shellcheck disable=SC2086
210 set -- $entry
211 iptables -D INPUT -i "$IFACE" -p "$1" --dport "$2" -j ACCEPT 2>/dev/null
212 done
213 for entry in "${FW_HANDLES[@]}"; do
214 # shellcheck disable=SC2086
215 set -- $entry
216 nft delete rule inet nixos-fw "$1" handle "$2" 2>/dev/null
217 done
218
219 echo "$PROG: done"
220}
221trap cleanup EXIT INT TERM
222
223# TFTP is always ours. Only the initial request hits UDP/69; dnsmasq then
224# transfers from an ephemeral port, whose return traffic conntrack already
225# accepts as ESTABLISHED.
226open_port udp 69
227if [[ $MODE == proxy ]]; then
228 # 67 for the proxy offer, 4011 for the PXE client's follow-up request.
229 open_port udp 67
230 open_port udp 4011
231fi
232if [[ -n $HTTP_ROOT ]]; then
233 open_port tcp "$HTTP_PORT"
234fi
235
236DNSMASQ_OPTS=(
237 --keep-in-foreground
238 --log-facility=-
239 --interface="$IFACE"
240 --bind-interfaces
241 --except-interface=lo
242 # No DNS: somebody else's network already has one.
243 --port=0
244 --enable-tftp
245 "--tftp-root=${ROOT}"
246)
247
248if [[ $MODE == proxy ]]; then
249 # Chainloaded in two steps: bare firmware is matched on its client
250 # architecture and handed an iPXE binary; iPXE itself (which sets DHCP
251 # option 175) is then handed the boot script. Without that split the
252 # firmware would be pointed back at iPXE forever.
253 #
254 # ",proxy" is what makes this coexist with the network's real DHCP server:
255 # dnsmasq replies with boot information only, never with a lease.
256 DNSMASQ_OPTS+=(
257 "--dhcp-range=${ADDR},proxy"
258 "--dhcp-match=set:ipxe,175"
259 "--dhcp-match=set:efi64,option:client-arch,7"
260 "--dhcp-match=set:efi64,option:client-arch,9"
261 "--dhcp-match=set:efi32,option:client-arch,6"
262 "--pxe-service=tag:!ipxe,x86-64_EFI,Network boot,ipxe.efi"
263 "--pxe-service=tag:!ipxe,IA32_EFI,Network boot,ipxe32.efi"
264 "--pxe-service=tag:!ipxe,x86PC,Network boot,undionly.kpxe"
265 "--dhcp-boot=tag:ipxe,${PXE_SCRIPT}"
266 )
267 echo "$PROG: proxy DHCP on ${IFACE} (${ADDR}) — not assigning addresses"
268else
269 echo "$PROG: serving files only on ${IFACE} (no DHCP)"
270fi
271
272echo "$PROG: serving TFTP from ${ROOT} (boot script ${PXE_SCRIPT})"
273
274if [[ -n $HTTP_ROOT ]]; then
275 # darkhttpd follows the symlinks in the served root into /nix/store, so the
276 # tree can point at the store instead of copying a multi-GB initrd around.
277 echo "$PROG: serving HTTP from ${HTTP_ROOT} on port ${HTTP_PORT}"
278 darkhttpd "$HTTP_ROOT" --port "$HTTP_PORT" &
279 HTTP_PID=$!
280fi
281
282echo "$PROG: press Ctrl+C to stop"
283dnsmasq "${DNSMASQ_OPTS[@]}"