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