#!/bin/sh # shellcheck shell=sh # # zt-gateway-setup — configure this OpenWrt router as a ZeroTier exit # gateway for the WIBLAN subnet (10.11.13.0/24). # # Subcommands: # status Show current setup state # setup-bridge Create the br-zt bridge interface # setup-routing Configure policy routing (tables 100/101, ip rules) # setup-dhcp Configure DHCP for WIBLAN on br-zt # setup-hotplug Create hotplug script to re-apply routes on ifup # setup-persistence Write rc.local + UCI network routes # setup-all Run all setup-* commands in order # # Exit codes: # 0 success # 1 usage / argument error # 2 precondition failed # 3 runtime failure # # Environment overrides (for testing and non-default configs): # ZTG_BRIDGE bridge device (default: br-zt) # ZTG_BRIDGE_PORTS space-separated ports (default: ztabc0) # ZTG_WIBLAN_CIDR WIBLAN subnet (default: 10.11.13.0/24) # ZTG_WIBLAN_GW WIBLAN gateway IP (default: 10.11.13.1) # ZTG_WIBLAN_LEASE_FIRST first DHCP IP (default: 10.11.13.100) # ZTG_WIBLAN_LEASE_LAST last DHCP IP (default: 10.11.13.200) # ZTG_TABLE_MAIN main policy table (default: 100) # ZTG_TABLE_DRAIN drain policy table (default: 101) # ZTG_TABLE_MWAN mwan3 return table (default: 1) # ZTG_FWMARK drain fwmark (default: 0x100) # ZTG_DRAIN_PRIORITY drain rule priority (default: 99) # ZTG_HOTPLUG hotplug script path # ZTG_RCLOCAL rc.local path # ZTG_DHCPCONF DHCP UCI config file (default: /etc/config/dhcp) # ZTG_NETWORKCONF network UCI config file (default: /etc/config/network) # ZTG_SKIP_PERSIST skip UCI persistence (testing) set -eu # ---------------------------------------------------------------------------- # Config # ---------------------------------------------------------------------------- BRIDGE="${ZTG_BRIDGE:-br-zt}" BRIDGE_PORTS="${ZTG_BRIDGE_PORTS:-ztabc0}" WIBLAN_CIDR="${ZTG_WIBLAN_CIDR:-10.11.13.0/24}" WIBLAN_GW="${ZTG_WIBLAN_GW:-10.11.13.1}" WIBLAN_LEASE_FIRST="${ZTG_WIBLAN_LEASE_FIRST:-10.11.13.100}" WIBLAN_LEASE_LAST="${ZTG_WIBLAN_LEASE_LAST:-10.11.13.200}" TABLE_MAIN="${ZTG_TABLE_MAIN:-100}" TABLE_DRAIN="${ZTG_TABLE_DRAIN:-101}" TABLE_MWAN="${ZTG_TABLE_MWAN:-1}" FWMARK="${ZTG_FWMARK:-0x100}" DRAIN_PRIORITY="${ZTG_DRAIN_PRIORITY:-99}" HOTPLUG="${ZTG_HOTPLUG:-/etc/hotplug.d/net/99-zerotier-bridge}" RCLOCAL="${ZTG_RCLOCAL:-/etc/rc.local}" DHCPCONF="${ZTG_DHCPCONF:-/etc/config/dhcp}" SKIP_PERSIST="${ZTG_SKIP_PERSIST:-0}" # Derived: extract prefix bits from CIDR WIBLAN_BITS="${WIBLAN_CIDR##*/}" # UCI-safe section name (replace hyphens with underscores) BRIDGE_UCI=$(printf '%s' "$BRIDGE" | tr '-' '_') # ---------------------------------------------------------------------------- # Logging # ---------------------------------------------------------------------------- log() { printf '[zt-gateway-setup] %s\n' "$*" >&2; } die() { rc=$1; shift; log "ERROR: $*"; exit "$rc"; } # Ensure a UCI config file exists (touch it if missing) ensure_uci_config() { _conf=/etc/config/"$1" if [ ! -f "$_conf" ]; then touch "$_conf" log "created empty UCI config: ${_conf}" fi } # CIDR to dotted mask (e.g. 24 -> 255.255.255.0) _cidr_to_mask() { bits=$1 mask="" while [ "$bits" -gt 0 ]; do if [ "$bits" -ge 8 ]; then oct=255 bits=$((bits - 8)) else # Build partial octet: bits leading 1s in MSB position oct=0 j=0 while [ $j -lt "$bits" ]; do oct=$(( oct | (1 << (7 - j)) )) j=$((j + 1)) done bits=0 fi if [ -n "$mask" ]; then mask="${mask}.${oct}" else mask="${oct}" fi done printf '%s' "$mask" } # ---------------------------------------------------------------------------- # Status # ---------------------------------------------------------------------------- cmd_status() { log "checking setup status..." # Bridge if ip link show "$BRIDGE" >/dev/null 2>&1; then printf 'bridge: %s (up)\n' "$BRIDGE" # List ports ports=$(ip link show master "$BRIDGE" 2>/dev/null \ | awk -F': ' '/^[0-9]+:/{gsub(/@.*/, "", $2); print $2}' \ | tr '\n' ' ') if [ -n "$ports" ]; then printf ' ports: %s\n' "$ports" fi # IP on bridge br_addr=$(ip -4 addr show dev "$BRIDGE" 2>/dev/null \ | awk '/inet /{gsub(/\/.*/, "", $2); print $2; exit}') if [ -n "$br_addr" ]; then printf ' addr: %s\n' "$br_addr" fi else printf 'bridge: %s (missing)\n' "$BRIDGE" fi # Routing tables table_main_gw=$(ip route show table "$TABLE_MAIN" 2>/dev/null \ | awk '/^[[:space:]]*default/{ for (i=1; i<=NF; i++) if ($i=="via") { print $(i+1); exit } }') if [ -n "$table_main_gw" ]; then printf 'table %s: default via %s (configured)\n' "$TABLE_MAIN" "$table_main_gw" else printf 'table %s: (empty)\n' "$TABLE_MAIN" fi table_drain_gw=$(ip route show table "$TABLE_DRAIN" 2>/dev/null \ | awk '/^[[:space:]]*default/{ for (i=1; i<=NF; i++) if ($i=="via") { print $(i+1); exit } }') if [ -n "$table_drain_gw" ]; then printf 'table %s: default via %s (configured)\n' "$TABLE_DRAIN" "$table_drain_gw" else printf 'table %s: (empty)\n' "$TABLE_DRAIN" fi # ip rule for fwmark if ip rule show 2>/dev/null | grep -q "fwmark ${FWMARK}"; then printf 'ip rule: fwmark %s -> table %s (configured)\n' "$FWMARK" "$TABLE_DRAIN" else printf 'ip rule: fwmark %s (missing)\n' "$FWMARK" fi # DHCP if grep -q "interface '${BRIDGE}'" "$DHCPCONF" 2>/dev/null; then printf 'dhcp: %s configured in %s\n' "$BRIDGE" "$DHCPCONF" else printf 'dhcp: %s (not configured)\n' "$BRIDGE" fi # Hotplug if [ -x "$HOTPLUG" ] || [ -f "$HOTPLUG" ]; then printf 'hotplug: %s (present)\n' "$HOTPLUG" else printf 'hotplug: %s (missing)\n' "$HOTPLUG" fi # Persistence in rc.local if grep -q "zt-gateway" "$RCLOCAL" 2>/dev/null; then printf 'rc.local: entries present\n' else printf 'rc.local: no zt-gateway entries\n' fi } # ---------------------------------------------------------------------------- # setup-bridge # ---------------------------------------------------------------------------- cmd_setup_bridge() { log "setting up bridge ${BRIDGE}..." if ! command -v uci >/dev/null 2>&1; then die 3 "uci not found; cannot configure bridge" fi ensure_uci_config network # Create or update bridge device in network.uci # Note: UCI section names cannot contain hyphens, so we use BRIDGE_UCI if uci -q get "network.${BRIDGE_UCI}" >/dev/null 2>&1; then log "bridge device ${BRIDGE} already exists in UCI; updating" else uci -q set "network.${BRIDGE_UCI}=device" uci -q set "network.${BRIDGE_UCI}.type=bridge" uci -q set "network.${BRIDGE_UCI}.name=${BRIDGE}" fi # Set bridge ports (space-separated in UCI list) uci -q delete "network.${BRIDGE_UCI}.ports" 2>/dev/null || true for port in $BRIDGE_PORTS; do uci -q add_list "network.${BRIDGE_UCI}.ports=${port}" done # Create interface section bridging to br-zt for WIBLAN if ! uci -q get "network.zt_wiblan" >/dev/null 2>&1; then uci -q set "network.zt_wiblan=interface" uci -q set "network.zt_wiblan.proto='static'" uci -q set "network.zt_wiblan.device='${BRIDGE}'" uci -q set "network.zt_wiblan.ipaddr='${WIBLAN_GW}'" uci -q set "network.zt_wiblan.netmask='$(_cidr_to_mask "$WIBLAN_BITS")'" fi if [ "$SKIP_PERSIST" != "1" ]; then uci commit network log "bridge UCI config committed" fi # Bring up the bridge (best-effort; may need netifd restart) if command -v ifup >/dev/null 2>&1; then ifup "zt_wiblan" 2>/dev/null || \ log "warning: ifup zt_wiblan failed; may need 'service network restart'" fi log "bridge ${BRIDGE} setup complete" } # ---------------------------------------------------------------------------- # setup-routing # ---------------------------------------------------------------------------- cmd_setup_routing() { log "setting up routing (tables ${TABLE_MAIN}/${TABLE_DRAIN})..." # Host route to WIBLAN gateway via bridge ip route replace "$WIBLAN_GW" dev "$BRIDGE" 2>/dev/null || \ log "warning: host route to ${WIBLAN_GW} failed" # Table 100 (main policy): default via WIBLAN_GW ip route replace default via "$WIBLAN_GW" dev "$BRIDGE" table "$TABLE_MAIN" # Table 101 (drain): default via WIBLAN_GW (same default; drain overrides per-flow) ip route replace default via "$WIBLAN_GW" dev "$BRIDGE" table "$TABLE_DRAIN" # mwan3 return-traffic table: route WIBLAN back through bridge ip route replace "$WIBLAN_CIDR" dev "$BRIDGE" table "$TABLE_MWAN" # ip rule: fwmark 0x100 -> drain table ip rule add fwmark "$FWMARK" table "$TABLE_DRAIN" priority "$DRAIN_PRIORITY" 2>/dev/null || \ ip rule replace fwmark "$FWMARK" table "$TABLE_DRAIN" priority "$DRAIN_PRIORITY" log "routing setup complete" # Persist to UCI if [ "$SKIP_PERSIST" != "1" ] && command -v uci >/dev/null 2>&1; then ensure_uci_config network # Host route if ! uci -q get "network.zt_wiblan_host" >/dev/null 2>&1; then uci -q set "network.zt_wiblan_host=route" uci -q set "network.zt_wiblan_host.target='${WIBLAN_GW}'" uci -q set "network.zt_wiblan_host.interface='${BRIDGE}'" fi # Main policy table default route if ! uci -q get "network.zt_wiblan_default" >/dev/null 2>&1; then uci -q set "network.zt_wiblan_default=route" uci -q set "network.zt_wiblan_default.target='0.0.0.0'" uci -q set "network.zt_wiblan_default.netmask='0.0.0.0'" uci -q set "network.zt_wiblan_default.gateway='${WIBLAN_GW}'" uci -q set "network.zt_wiblan_default.interface='${BRIDGE}'" uci -q set "network.zt_wiblan_default.table='${TABLE_MAIN}'" fi uci commit network log "routing UCI config committed" fi } # ---------------------------------------------------------------------------- # setup-dhcp # ---------------------------------------------------------------------------- cmd_setup_dhcp() { log "setting up DHCP for ${BRIDGE} (${WIBLAN_CIDR})..." if ! command -v uci >/dev/null 2>&1; then die 3 "uci not found; cannot configure DHCP" fi ensure_uci_config dhcp # Create DHCP subnet entry for br-zt # Note: UCI section names cannot contain hyphens, so we use BRIDGE_UCI if uci -q get "dhcp.${BRIDGE_UCI}" >/dev/null 2>&1; then log "DHCP entry for ${BRIDGE} already exists; updating" else uci -q set "dhcp.${BRIDGE_UCI}=dhcp" fi uci -q set "dhcp.${BRIDGE_UCI}.interface=${BRIDGE}" uci -q set "dhcp.${BRIDGE_UCI}.start=${WIBLAN_LEASE_FIRST##*.}" uci -q set "dhcp.${BRIDGE_UCI}.limit=$(( ${WIBLAN_LEASE_LAST##*.} - ${WIBLAN_LEASE_FIRST##*.} + 1 ))" uci -q set "dhcp.${BRIDGE_UCI}.leasetime=12h" # Ignore WIBLAN subnet in upstream DHCP (prevent handing out # conflicting leases on the LAN side) lan_iface=$(uci -q get dhcp.lan.interface 2>/dev/null || echo "lan") if [ -n "$lan_iface" ]; then # Add WIBLAN to lan's ignore list if not already there if ! uci -q get "dhcp.lan.ignore" 2>/dev/null | grep -q "$WIBLAN_CIDR"; then uci -q add_list "dhcp.lan.dhcp_option='6,${WIBLAN_GW}'" 2>/dev/null || true fi fi if [ "$SKIP_PERSIST" != "1" ]; then uci commit dhcp log "DHCP UCI config committed" fi # Restart dnsmasq to pick up changes if command -v service >/dev/null 2>&1; then service dnsmasq restart 2>/dev/null || \ log "warning: dnsmasq restart failed; do it manually" fi log "DHCP setup complete" } # ---------------------------------------------------------------------------- # setup-hotplug # ---------------------------------------------------------------------------- cmd_setup_hotplug() { log "creating hotplug script ${HOTPLUG}..." mkdir -p "$(dirname "$HOTPLUG")" cat >"$HOTPLUG" <<'HOTPLUG_SCRIPT' #!/bin/sh # shellcheck shell=sh # ZeroTier gateway hotplug — re-apply routing when the bridge interface # comes up (e.g. after boot, after ZeroTier restart). # # Environment: INTERFACE, ACTION (set by netifd hotplug) # Config: /etc/config/zt-gateway (read at runtime for active gateway IP) ZTG_BRIDGE="${ZTG_BRIDGE:-br-zt}" ZTG_TABLE_MAIN="${ZTG_TABLE_MAIN:-100}" ZTG_TABLE_DRAIN="${ZTG_TABLE_DRAIN:-101}" ZTG_TABLE_MWAN="${ZTG_TABLE_MWAN:-1}" ZTG_FWMARK="${ZTG_FWMARK:-0x100}" ZTG_DRAIN_PRIORITY="${ZTG_DRAIN_PRIORITY:-99}" ZTG_WIBLAN_CIDR="${ZTG_WIBLAN_CIDR:-10.11.13.0/24}" # Only act on our bridge interface [ "$INTERFACE" = "$ZTG_BRIDGE" ] || exit 0 case "$ACTION" in ifup) # Read active gateway from UCI active_ip=$(uci -q get zt-gateway.global.active_ip 2>/dev/null || \ uci -q get zt-gateway.global.active_gateway 2>/dev/null) # If active_ip is a region name, resolve to IP via gateway section if [ -n "$active_ip" ] && ! echo "$active_ip" | grep -q '^[0-9]'; then active_ip=$(uci -q get "zt-gateway.@gateway[0].ip" 2>/dev/null || \ for sec in $(uci -q show zt-gateway 2>/dev/null | \ awk -F'=' '/\.region=/{print $1}' | \ sed 's/\.region//'); do r=$(uci -q get "${sec}.region" 2>/dev/null) if [ "$r" = "$active_ip" ]; then uci -q get "${sec}.ip" 2>/dev/null break fi done) fi if [ -z "$active_ip" ]; then # No active gateway configured; try reading from table active_ip=$(ip route show table "$ZTG_TABLE_MAIN" 2>/dev/null \ | awk '/^[[:space:]]*default/{ for (i=1; i<=NF; i++) if ($i=="via") { print $(i+1); exit } }') fi if [ -z "$active_ip" ]; then logger -t zt-gw-hotplug "No active gateway IP; skipping route setup" exit 0 fi logger -t zt-gw-hotplug "ifup ${ZTG_BRIDGE}: applying routes via ${active_ip}" # Host route ip route replace "$active_ip" dev "$ZTG_BRIDGE" # Policy routes ip route replace default via "$active_ip" dev "$ZTG_BRIDGE" table "$ZTG_TABLE_MAIN" ip route replace default via "$active_ip" dev "$ZTG_BRIDGE" table "$ZTG_TABLE_DRAIN" # mwan3 return ip route replace "$ZTG_WIBLAN_CIDR" dev "$ZTG_BRIDGE" table "$ZTG_TABLE_MWAN" # ip rule for drain fwmark ip rule add fwmark "$ZTG_FWMARK" table "$ZTG_TABLE_DRAIN" \ priority "$ZTG_DRAIN_PRIORITY" 2>/dev/null || \ ip rule replace fwmark "$ZTG_FWMARK" table "$ZTG_TABLE_DRAIN" \ priority "$ZTG_DRAIN_PRIORITY" ;; ifdown) logger -t zt-gw-hotplug "ifdown ${ZTG_BRIDGE}: cleaning up" ip rule del fwmark "$ZTG_FWMARK" table "$ZTG_TABLE_DRAIN" \ priority "$ZTG_DRAIN_PRIORITY" 2>/dev/null || true ;; esac HOTPLUG_SCRIPT chmod +x "$HOTPLUG" log "hotplug script created at ${HOTPLUG}" } # ---------------------------------------------------------------------------- # setup-persistence # ---------------------------------------------------------------------------- cmd_setup_persistence() { log "setting up persistence..." # Ensure rc.local has the gateway restoration logic if ! grep -q "zt-gateway" "$RCLOCAL" 2>/dev/null; then log "adding zt-gateway entry to ${RCLOCAL}" # Read current rc.local content rc_content="" if [ -f "$RCLOCAL" ]; then rc_content=$(cat "$RCLOCAL") fi # Remove trailing 'exit 0' if present, add our block, re-add exit 0 rc_stripped=$(printf '%s\n' "$rc_content" | sed '/^exit 0$/d') cat >"$RCLOCAL" </dev/null 2>&1; then ensure_uci_config zt-gateway # Store the WIBLAN config for the hotplug to read if ! uci -q get "zt-gateway.global.wiblan_subnet" >/dev/null 2>&1; then uci -q set "zt-gateway.global.wiblan_subnet='${WIBLAN_CIDR}'" fi if ! uci -q get "zt-gateway.global.bridge_device" >/dev/null 2>&1; then uci -q set "zt-gateway.global.bridge_device='${BRIDGE}'" fi if ! uci -q get "zt-gateway.global.bridge_ports" >/dev/null 2>&1; then uci -q set "zt-gateway.global.bridge_ports='${BRIDGE_PORTS}'" fi if ! uci -q get "zt-gateway.global.wiblan_gw" >/dev/null 2>&1; then uci -q set "zt-gateway.global.wiblan_gw='${WIBLAN_GW}'" fi if ! uci -q get "zt-gateway.global.table_main" >/dev/null 2>&1; then uci -q set "zt-gateway.global.table_main='${TABLE_MAIN}'" fi if ! uci -q get "zt-gateway.global.table_drain" >/dev/null 2>&1; then uci -q set "zt-gateway.global.table_drain='${TABLE_DRAIN}'" fi if ! uci -q get "zt-gateway.global.dhcp_lease_first" >/dev/null 2>&1; then uci -q set "zt-gateway.global.dhcp_lease_first='${WIBLAN_LEASE_FIRST}'" fi if ! uci -q get "zt-gateway.global.dhcp_lease_last" >/dev/null 2>&1; then uci -q set "zt-gateway.global.dhcp_lease_last='${WIBLAN_LEASE_LAST}'" fi uci commit zt-gateway log "UCI global config updated with setup parameters" fi log "persistence setup complete" } # ---------------------------------------------------------------------------- # setup-all # ---------------------------------------------------------------------------- cmd_setup_all() { log "running full gateway setup..." cmd_setup_bridge cmd_setup_routing cmd_setup_dhcp cmd_setup_hotplug cmd_setup_persistence log "=========================================" log "Full gateway setup complete!" log " Bridge: ${BRIDGE}" log " Ports: ${BRIDGE_PORTS}" log " Subnet: ${WIBLAN_CIDR}" log " Gateway IP: ${WIBLAN_GW}" log " Tables: ${TABLE_MAIN} (main), ${TABLE_DRAIN} (drain)" log " DHCP: ${WIBLAN_LEASE_FIRST} - ${WIBLAN_LEASE_LAST}" log "=========================================" } # ---------------------------------------------------------------------------- # Usage # ---------------------------------------------------------------------------- usage() { cat >&2 <<'USAGE' Usage: zt-gateway-setup Commands: status Show current setup state setup-bridge Create the br-zt bridge interface setup-routing Configure policy routing (tables 100/101, ip rules) setup-dhcp Configure DHCP for WIBLAN on br-zt setup-hotplug Create hotplug script to re-apply routes on ifup setup-persistence Write rc.local + UCI network routes setup-all Run all setup-* commands in order USAGE } # ---------------------------------------------------------------------------- # Entry point # ---------------------------------------------------------------------------- main() { if [ $# -lt 1 ]; then usage die 1 "missing command" fi cmd=$1 shift case "$cmd" in status) cmd_status "$@" ;; setup-bridge) cmd_setup_bridge "$@" ;; setup-routing) cmd_setup_routing "$@" ;; setup-dhcp) cmd_setup_dhcp "$@" ;; setup-hotplug) cmd_setup_hotplug "$@" ;; setup-persistence) cmd_setup_persistence "$@" ;; setup-all) cmd_setup_all "$@" ;; -h|--help) usage; exit 0 ;; *) usage; die 1 "unknown command: $cmd" ;; esac } main "$@"