This repository has no description
13 kB
401 lines
1#!/usr/bin/env bash
2set -euo pipefail
3
4PROG="nat-share"
5
6# Defaults
7SUBNET_PREFIX="10.42.0"
8NETMASK="24"
9DHCP_NETMASK="255.255.255.0"
10DHCP_START_HOST="10"
11DHCP_END_HOST="254"
12LEASE_TIME="12h"
13UPSTREAM=""
14IFACE=""
15DNS_SERVER=""
16BACKEND="auto"
17PXE_ROOT=""
18PXE_SCRIPT="netboot.ipxe"
19HTTP=""
20HTTP_PORT="8079"
21
22usage() {
23 cat >&2 <<EOF
24Usage: $PROG [OPTIONS] <interface>
25
26Start a DHCP + DNS (dnsmasq) NAT server on <interface>, sharing the host's
27upstream internet connection with clients on that link. The interface is
28unmanaged from NetworkManager while the server runs; every change (IP,
29NetworkManager state, forwarding sysctl, firewall rules) is reverted on exit.
30
31Runs in the foreground. Press Ctrl+C to stop and restore.
32
33Options:
34 -u, --upstream <iface> Upstream/WAN interface to NAT through
35 (default: interface of the current default route)
36 -s, --subnet <prefix> /24 subnet prefix for the served link
37 (default: ${SUBNET_PREFIX}, gateway <prefix>.1)
38 --range <start-end> DHCP host range within the /24
39 (default: ${DHCP_START_HOST}-${DHCP_END_HOST})
40 --lease <time> DHCP lease time (default: ${LEASE_TIME})
41 --dns <ip> Upstream DNS server to forward to
42 (default: system resolv.conf)
43 --pxe <dir> Additionally boot PXE clients from <dir>. Handing
44 out the boot filename happens here (only the DHCP
45 server can); serving the files is delegated to
46 \`pxe-share --no-dhcp\`. <dir> should hold ipxe.efi
47 (UEFI x64), optionally ipxe32.efi /
48 undionly.kpxe, and ${PXE_SCRIPT}.
49 --http Have pxe-share serve the --pxe dir over HTTP as
50 well; requires --pxe. Needed for netboot payloads:
51 a kernel+initrd carrying a nix store is far too big
52 for TFTP.
53 --http-port <port> Port for --http (default: ${HTTP_PORT})
54 -b, --backend <name> Firewall backend: auto | iptables | nft
55 (default: auto-detect)
56 -h, --help Show this help
57EOF
58}
59
60# Parse arguments
61while [[ $# -gt 0 ]]; do
62 case $1 in
63 -u | --upstream)
64 UPSTREAM="$2"
65 shift 2
66 ;;
67 -s | --subnet)
68 SUBNET_PREFIX="$2"
69 shift 2
70 ;;
71 --range)
72 DHCP_START_HOST="${2%%-*}"
73 DHCP_END_HOST="${2##*-}"
74 shift 2
75 ;;
76 --lease)
77 LEASE_TIME="$2"
78 shift 2
79 ;;
80 --dns)
81 DNS_SERVER="$2"
82 shift 2
83 ;;
84 --pxe)
85 PXE_ROOT="$2"
86 shift 2
87 ;;
88 --http)
89 HTTP=1
90 shift
91 ;;
92 --http-port)
93 HTTP_PORT="$2"
94 shift 2
95 ;;
96 -b | --backend)
97 BACKEND="$2"
98 shift 2
99 ;;
100 -h | --help)
101 usage
102 exit 0
103 ;;
104 -*)
105 echo "$PROG: unknown option: $1" >&2
106 usage
107 exit 1
108 ;;
109 *)
110 if [[ -n $IFACE ]]; then
111 echo "$PROG: unexpected argument: $1" >&2
112 usage
113 exit 1
114 fi
115 IFACE="$1"
116 shift
117 ;;
118 esac
119done
120
121if [[ -z $IFACE ]]; then
122 echo "$PROG: no interface specified" >&2
123 usage
124 exit 1
125fi
126
127case $BACKEND in
128 auto | iptables | nft) ;;
129 *)
130 echo "$PROG: invalid backend '$BACKEND' (expected auto|iptables|nft)" >&2
131 exit 1
132 ;;
133esac
134
135if ! ip link show "$IFACE" >/dev/null 2>&1; then
136 echo "$PROG: interface '$IFACE' does not exist" >&2
137 exit 1
138fi
139
140if [[ -n $PXE_ROOT ]]; then
141 if [[ ! -d $PXE_ROOT ]]; then
142 echo "$PROG: pxe root '$PXE_ROOT' is not a directory" >&2
143 exit 1
144 fi
145 # Absolute, because we re-exec through sudo from an unknown cwd.
146 PXE_ROOT=$(cd "$PXE_ROOT" && pwd -P)
147fi
148
149if [[ -n $HTTP && -z $PXE_ROOT ]]; then
150 echo "$PROG: --http only makes sense together with --pxe" >&2
151 exit 1
152fi
153
154# Re-exec as root, preserving arguments.
155if [[ $EUID -ne 0 ]]; then
156 exec sudo -- "$0" \
157 ${UPSTREAM:+--upstream "$UPSTREAM"} \
158 --subnet "$SUBNET_PREFIX" \
159 --range "${DHCP_START_HOST}-${DHCP_END_HOST}" \
160 --lease "$LEASE_TIME" \
161 ${DNS_SERVER:+--dns "$DNS_SERVER"} \
162 --backend "$BACKEND" \
163 ${PXE_ROOT:+--pxe "$PXE_ROOT"} \
164 ${HTTP:+--http --http-port "$HTTP_PORT"} \
165 "$IFACE"
166fi
167
168GATEWAY="${SUBNET_PREFIX}.1"
169DHCP_START="${SUBNET_PREFIX}.${DHCP_START_HOST}"
170DHCP_END="${SUBNET_PREFIX}.${DHCP_END_HOST}"
171
172# Autodetect the upstream interface from the default route.
173if [[ -z $UPSTREAM ]]; then
174 UPSTREAM=$(ip -4 route show default | awk '/default/ {print $5; exit}')
175 if [[ -z $UPSTREAM ]]; then
176 echo "$PROG: could not detect upstream interface; pass --upstream <iface>" >&2
177 exit 1
178 fi
179fi
180
181if [[ $UPSTREAM == "$IFACE" ]]; then
182 echo "$PROG: upstream interface must differ from the served interface" >&2
183 exit 1
184fi
185
186# ---------------------------------------------------------------------------
187# Firewall / NAT backend.
188#
189# iptables backend: INPUT/FORWARD are single chains, so inserting ACCEPT rules
190# at the top preempts the firewall's reject.
191#
192# nftables backend: an ACCEPT in a separate base chain does NOT override a DROP
193# in "inet nixos-fw" (only DROP is terminal across chains). So DHCP/DNS accepts
194# must be inserted directly into the firewall's own chains, and NAT lives in a
195# private table we can drop wholesale.
196# ---------------------------------------------------------------------------
197if [[ $BACKEND == auto ]]; then
198 if command -v nft >/dev/null 2>&1 && nft list table inet nixos-fw >/dev/null 2>&1; then
199 BACKEND=nft
200 else
201 BACKEND=iptables
202 fi
203fi
204
205# Track what we changed so cleanup only reverts our own state.
206NM_UNMANAGED=false
207IP_ADDED=false
208FORWARD_WAS=""
209IPT_NAT_ADDED=false
210IPT_FWD_IN_ADDED=false
211IPT_FWD_OUT_ADDED=false
212IPT_INPUT_OPENED=false
213PXE_PID=""
214DNSMASQ_PID=""
215NFT_NAT_TABLE=false
216FW_HANDLES=() # "chain handle" pairs inserted into inet nixos-fw
217
218# Name of the "inet nixos-fw" base chain registered to the given hook, if any.
219nft_base_chain() {
220 nft list table inet nixos-fw 2>/dev/null | awk -v h="hook $1 " '
221 /chain [A-Za-z0-9_.-]+ \{/ { name = $2 }
222 index($0, h) { print name; exit }
223 '
224}
225
226# Insert a rule at the top of an "inet nixos-fw" chain, remembering its handle.
227fw_insert() {
228 local chain=$1
229 shift
230 local out handle
231 out=$(nft --handle --echo insert rule inet nixos-fw "$chain" "$@" 2>/dev/null) || return 1
232 handle=$(printf '%s\n' "$out" | grep -oE 'handle [0-9]+' | grep -oE '[0-9]+' | tail -n1)
233 [[ -n $handle ]] && FW_HANDLES+=("$chain $handle")
234}
235
236cleanup() {
237 set +e
238 echo ""
239 echo "$PROG: restoring..."
240
241 # dnsmasq ignores SIGINT outside debug mode, so stop it with SIGTERM.
242 if [[ -n $DNSMASQ_PID ]]; then
243 kill "$DNSMASQ_PID" 2>/dev/null
244 wait "$DNSMASQ_PID" 2>/dev/null
245 fi
246
247 if [[ -n $PXE_PID ]]; then
248 kill "$PXE_PID" 2>/dev/null
249 wait "$PXE_PID" 2>/dev/null
250 fi
251
252 if [[ $BACKEND == iptables ]]; then
253 $IPT_NAT_ADDED && iptables -t nat -D POSTROUTING -o "$UPSTREAM" -j MASQUERADE 2>/dev/null
254 $IPT_FWD_IN_ADDED && iptables -D FORWARD -i "$UPSTREAM" -o "$IFACE" \
255 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null
256 $IPT_FWD_OUT_ADDED && iptables -D FORWARD -i "$IFACE" -o "$UPSTREAM" -j ACCEPT 2>/dev/null
257 if $IPT_INPUT_OPENED; then
258 iptables -D INPUT -i "$IFACE" -p udp --dport 67 -j ACCEPT 2>/dev/null
259 iptables -D INPUT -i "$IFACE" -p udp --dport 53 -j ACCEPT 2>/dev/null
260 iptables -D INPUT -i "$IFACE" -p tcp --dport 53 -j ACCEPT 2>/dev/null
261 fi
262
263 else
264 local entry
265 for entry in "${FW_HANDLES[@]}"; do
266 # shellcheck disable=SC2086
267 set -- $entry
268 nft delete rule inet nixos-fw "$1" handle "$2" 2>/dev/null
269 done
270 $NFT_NAT_TABLE && nft delete table ip nat_share 2>/dev/null
271 fi
272
273 if [[ -n $FORWARD_WAS ]]; then
274 echo "$FORWARD_WAS" >/proc/sys/net/ipv4/ip_forward 2>/dev/null
275 fi
276
277 $IP_ADDED && ip addr del "${GATEWAY}/${NETMASK}" dev "$IFACE" 2>/dev/null
278
279 if $NM_UNMANAGED && command -v nmcli >/dev/null 2>&1; then
280 nmcli device set "$IFACE" managed yes 2>/dev/null
281 fi
282
283 echo "$PROG: done"
284}
285# Signals only trigger a normal exit; cleanup runs exactly once via EXIT.
286# (Ctrl+C reaches dnsmasq too, but it ignores SIGINT; bash's `wait` below is
287# interruptible, so the INT trap fires immediately and cleanup SIGTERMs it.)
288trap cleanup EXIT
289trap 'exit 130' INT
290trap 'exit 143' TERM
291
292# Hand the interface off from NetworkManager, if present.
293if command -v nmcli >/dev/null 2>&1 && nmcli -t -f RUNNING general >/dev/null 2>&1; then
294 echo "$PROG: unmanaging $IFACE from NetworkManager"
295 nmcli device set "$IFACE" managed no || true
296 NM_UNMANAGED=true
297fi
298
299# Bring up the link with the gateway address.
300echo "$PROG: configuring ${IFACE} as ${GATEWAY}/${NETMASK}"
301ip link set "$IFACE" up
302ip addr flush dev "$IFACE"
303ip addr add "${GATEWAY}/${NETMASK}" dev "$IFACE"
304IP_ADDED=true
305
306# Enable IPv4 forwarding.
307FORWARD_WAS=$(cat /proc/sys/net/ipv4/ip_forward)
308echo 1 >/proc/sys/net/ipv4/ip_forward
309
310# NAT + firewall openings.
311echo "$PROG: enabling NAT via ${UPSTREAM} (${BACKEND} backend)"
312if [[ $BACKEND == iptables ]]; then
313 iptables -t nat -A POSTROUTING -o "$UPSTREAM" -j MASQUERADE
314 IPT_NAT_ADDED=true
315 # Insert at the top of FORWARD so a DROP policy / reject can't shadow us.
316 iptables -I FORWARD -i "$UPSTREAM" -o "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT
317 IPT_FWD_IN_ADDED=true
318 iptables -I FORWARD -i "$IFACE" -o "$UPSTREAM" -j ACCEPT
319 IPT_FWD_OUT_ADDED=true
320 # Let clients reach our DHCP (67) and DNS (53) despite the host firewall.
321 iptables -I INPUT -i "$IFACE" -p udp --dport 67 -j ACCEPT
322 iptables -I INPUT -i "$IFACE" -p udp --dport 53 -j ACCEPT
323 iptables -I INPUT -i "$IFACE" -p tcp --dport 53 -j ACCEPT
324 IPT_INPUT_OPENED=true
325
326else
327 # NAT masquerade in a private table (torn down wholesale on exit).
328 nft add table ip nat_share
329 nft add chain ip nat_share postrouting '{ type nat hook postrouting priority 100 ; }'
330 nft add rule ip nat_share postrouting oifname "$UPSTREAM" masquerade
331 NFT_NAT_TABLE=true
332
333 # DHCP/DNS accepts must live inside the firewall's own input chain to beat
334 # its drop; likewise forward accepts if the firewall filters forwarding.
335 in_chain=$(nft_base_chain input)
336 fwd_chain=$(nft_base_chain forward)
337 if [[ -n $in_chain ]]; then
338 fw_insert "$in_chain" iifname "$IFACE" udp dport '{ 53, 67 }' accept
339 fw_insert "$in_chain" iifname "$IFACE" tcp dport 53 accept
340
341 fi
342 if [[ -n $fwd_chain ]]; then
343 fw_insert "$fwd_chain" iifname "$UPSTREAM" oifname "$IFACE" ct state related,established accept
344 fw_insert "$fwd_chain" iifname "$IFACE" oifname "$UPSTREAM" accept
345 fi
346fi
347
348# DHCP + DNS via dnsmasq, bound only to the served interface/address.
349DNS_OPTS=()
350if [[ -n $DNS_SERVER ]]; then
351 DNS_OPTS+=(--no-resolv "--server=${DNS_SERVER}")
352fi
353
354# PXE. Only the DHCP half belongs here: nothing but the DHCP server can tell a
355# client what to boot. Serving the files (TFTP, and HTTP for payloads too big
356# for TFTP) is pxe-share's job — the same tool that can do this on networks we
357# don't own, where it answers as a proxy DHCP server instead. Here we already
358# own port 67, so it runs with --no-dhcp and just serves, and opens its own
359# firewall ports.
360#
361# Chainloaded in two steps: bare firmware is matched on its client
362# architecture and handed an iPXE binary; iPXE itself (which sets DHCP option
363# 175) is then handed the boot script. Without that split the firmware would
364# be pointed back at iPXE forever.
365PXE_OPTS=()
366if [[ -n $PXE_ROOT ]]; then
367 PXE_OPTS+=(
368 "--dhcp-match=set:ipxe,175"
369 "--dhcp-match=set:efi64,option:client-arch,7"
370 "--dhcp-match=set:efi64,option:client-arch,9"
371 "--dhcp-match=set:efi32,option:client-arch,6"
372 "--dhcp-boot=tag:ipxe,${PXE_SCRIPT}"
373 "--dhcp-boot=tag:!ipxe,tag:efi64,ipxe.efi"
374 "--dhcp-boot=tag:!ipxe,tag:efi32,ipxe32.efi"
375 "--dhcp-boot=tag:!ipxe,tag:!efi64,tag:!efi32,undionly.kpxe"
376 )
377 pxe-share --no-dhcp \
378 ${HTTP:+--http --http-port "$HTTP_PORT"} \
379 --script "$PXE_SCRIPT" \
380 --backend "$BACKEND" \
381 "$PXE_ROOT" "$IFACE" &
382 PXE_PID=$!
383fi
384
385echo "$PROG: serving DHCP ${DHCP_START}-${DHCP_END} (lease ${LEASE_TIME}) on ${IFACE}"
386echo "$PROG: press Ctrl+C to stop"
387dnsmasq \
388 --keep-in-foreground \
389 --log-facility=- \
390 --interface="$IFACE" \
391 --bind-interfaces \
392 --except-interface=lo \
393 --listen-address="$GATEWAY" \
394 --dhcp-authoritative \
395 --dhcp-range="${DHCP_START},${DHCP_END},${DHCP_NETMASK},${LEASE_TIME}" \
396 --dhcp-option=option:router,"$GATEWAY" \
397 --dhcp-option=option:dns-server,"$GATEWAY" \
398 "${DNS_OPTS[@]}" \
399 "${PXE_OPTS[@]}" &
400DNSMASQ_PID=$!
401wait "$DNSMASQ_PID"