This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / tools / nat-share / nat-share.sh
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"