diff --git a/.github/workflows/package-openwrt.yml b/.github/workflows/package-openwrt.yml index e28100f..145ebe5 100644 --- a/.github/workflows/package-openwrt.yml +++ b/.github/workflows/package-openwrt.yml @@ -285,6 +285,7 @@ jobs: "$FILES_DIR/etc/fips/firewall.sh" "$FILES_DIR/etc/hotplug.d/net/99-fips" "$FILES_DIR/etc/uci-defaults/90-fips-setup" + "$FILES_DIR/usr/bin/fips-mesh-setup" ) fail=0 for f in "${TARGETS[@]}"; do @@ -404,6 +405,7 @@ jobs: ./usr/bin/fipsctl ./usr/bin/fipstop ./usr/bin/fips-gateway + ./usr/bin/fips-mesh-setup ./etc/init.d/fips ./etc/init.d/fips-gateway ./etc/fips/fips.yaml @@ -717,6 +719,7 @@ jobs: for path in \ usr/bin/fips usr/bin/fipsctl usr/bin/fipstop usr/bin/fips-gateway \ + usr/bin/fips-mesh-setup \ etc/init.d/fips etc/init.d/fips-gateway \ etc/fips/fips.yaml etc/fips/firewall.sh etc/dnsmasq.d/fips.conf \ etc/sysctl.d/fips-gateway.conf etc/sysctl.d/fips-bridge.conf \ diff --git a/docs/how-to/README.md b/docs/how-to/README.md index ab2ac4f..a50812a 100644 --- a/docs/how-to/README.md +++ b/docs/how-to/README.md @@ -25,4 +25,5 @@ X" to "X is done". | [persistent-identity.md](persistent-identity.md) | Provision a stable Nostr keypair so the node keeps the same npub across restarts | | [host-aliases.md](host-aliases.md) | Use shortnames (`test-us01.fips`, `my-laptop.fips`) instead of full npubs by editing `/etc/fips/hosts` or setting peer aliases | | [set-up-bluetooth-peer.md](set-up-bluetooth-peer.md) | Configure a Bluetooth Low Energy peer link | +| [set-up-80211s-mesh-backhaul.md](set-up-80211s-mesh-backhaul.md) | Link OpenWrt FIPS routers over an open 802.11s radio backhaul (FIPS provides encryption, authentication, and routing) | | [diagnose-mtu-issues.md](diagnose-mtu-issues.md) | Triage MTU-shaped failures and rule out their imposters (bufferbloat, transport saturation) | diff --git a/docs/how-to/set-up-80211s-mesh-backhaul.md b/docs/how-to/set-up-80211s-mesh-backhaul.md new file mode 100644 index 0000000..e7e1941 --- /dev/null +++ b/docs/how-to/set-up-80211s-mesh-backhaul.md @@ -0,0 +1,247 @@ +# Set Up an 802.11s Mesh Backhaul (OpenWrt) + +Link FIPS routers over radio — no cables, no APs, no shared +infrastructure — by running the Ethernet transport on an open 802.11s +mesh interface. The radio layer provides nothing but L2 frames to +direct neighbors; FIPS provides everything else: encryption and +authentication (Noise IK), peer discovery (Ethernet beacons), and +routing (the spanning tree). + +For the transport design, see +[../design/fips-transport-layer.md](../design/fips-transport-layer.md). +For all `transports.ethernet.*` configuration keys, see +[../reference/configuration.md](../reference/configuration.md). + +## Why open, why forwarding off + +Two deliberate choices distinguish this from a stock 802.11s setup: + +- **`encryption none`** — the mesh is open on purpose. Every FIPS peer + link is already authenticated and encrypted by the Noise IK + handshake, so SAE at L2 would duplicate that work, add a shared + credential to provision across routers, and (on ath10k) force the + firmware into its slower raw Tx/Rx mode. A stranger can form an + 802.11s peering with your router, but their frames die at the FIPS + handshake — the same security model as mDNS and BLE discovery, where + the advert is only a hint and the handshake is the authentication. + What you concede: L2 metadata (MAC addresses, frame sizes) is + visible in the air, and a hostile radio can burn airtime — both true + of any radio link regardless of L2 encryption. +- **`mesh_fwding 0`** — disables 802.11s's own HWMP routing so each + mesh link is a plain neighbor link. FIPS is the routing layer; two + routing layers would fight, and broadcast discovery beacons would + flood the whole mesh instead of reaching direct neighbors only. + +The interface is **not** bridged into `br-lan` — the FIPS Ethernet +transport binds it directly. + +## When to use + +- Two or more OpenWrt FIPS routers within radio range of each other, + where running cable is impractical. +- You want the mesh segment to keep working with zero shared + credentials or per-site configuration ("flash and drop in"). + +It is **not** for connecting phones or laptops — client devices +cannot join an 802.11s mesh. They enter the mesh through a normal AP +on the same router (see constraints below), or over BLE. + +## Requirements + +- OpenWrt 22.03+ with the FIPS package installed. +- A radio whose driver supports mesh point interfaces. Check with: + + ```sh + iw list | grep -A 10 "Supported interface modes" | grep "mesh point" + ``` + + The mainstream OpenWrt chips (ath9k, ath10k, mt76) all qualify. +- Ideally a dual- or tri-band router, so one band can be dedicated to + the backhaul (see constraints). + +## Step 1 — create the mesh interface(s) + +On **each** router, run the helper once per radio you want in the +backhaul: + +```sh +fips-mesh-setup radio1 +``` + +This creates an open 802.11s interface with mesh ID `fips-mesh` and +HWMP forwarding off, attaches it to an unmanaged netifd interface (no +IP configuration — none is needed), uncomments the matching `meshN` +transport entry in `/etc/fips/fips.yaml` (see Step 2), and reloads the +radio. Interfaces are named by radio index: `radio0` → `fips-mesh0`, +`radio1` → `fips-mesh1`. Pass a second argument to use a different +mesh ID. + +Note: the helper runs `wifi reload`, which re-applies the whole +wireless config and so briefly drops every client AP on all radios for +a few seconds. `fips-mesh-setup remove` reloads the same way. Expect +the blip if clients are connected. + +On dual-band routers, meshing **both** bands is worth it: 2.4 GHz +reaches further at lower rates, 5 GHz carries more over shorter +links. Note this is **failover, not multipath**: FIPS keeps one +active link per peer, so traffic uses one band at a time — the other +is a standby that re-establishes the peer if the active link dies +(detection via keepalive timeout, so a cutover takes seconds, not +milliseconds): + +```sh +fips-mesh-setup radio0 +fips-mesh-setup radio1 +``` + +**Pin the same channel on every backhaul router, per band.** Mesh +points only peer on the same channel, and the mesh inherits whatever +the radio is set to — with `channel 'auto'` (the default on many +devices) each router picks its own and the mesh silently never forms. +The script prints the radio's current band and channel and warns on +`auto`: + +```sh +uci set wireless.radio1.channel='36' +uci commit wireless && wifi reload +``` + +Prefer a non-DFS channel (36–48 on 5 GHz): on DFS channels the radio +must wait ~60 s in CAC before transmitting after every reload. + +Equivalent manual UCI (per radio), if you prefer to see what it does: + +```sh +uci batch <<'EOF' +set wireless.fips_mesh_radio1=wifi-iface +set wireless.fips_mesh_radio1.device='radio1' +set wireless.fips_mesh_radio1.mode='mesh' +set wireless.fips_mesh_radio1.mesh_id='fips-mesh' +set wireless.fips_mesh_radio1.encryption='none' +set wireless.fips_mesh_radio1.mesh_fwding='0' +set wireless.fips_mesh_radio1.ifname='fips-mesh1' +set wireless.fips_mesh_radio1.network='fips_mesh_radio1' +set network.fips_mesh_radio1=interface +set network.fips_mesh_radio1.proto='none' +EOF +uci commit +wifi reload +``` + +## Step 2 — check the FIPS transport binding + +The `fips.yaml` shipped in the OpenWrt package carries one transport +entry per radio, but **commented out** — so a stock install that never +runs this helper logs no per-boot "interface missing" warning. +`fips-mesh-setup` uncommented the matching `meshN` entry in Step 1, so +there is normally nothing to do here. If you maintain your own config +(or ran the manual UCI above instead of the helper), make sure the +entries are present and uncommented: + +```yaml +transports: + ethernet: + mesh0: + interface: "fips-mesh0" + discovery: true + announce: true + auto_connect: true + accept_connections: true + mesh1: + interface: "fips-mesh1" + discovery: true + announce: true + auto_connect: true + accept_connections: true +``` + +## Step 3 — restart the daemon (order matters) + +```sh +/etc/init.d/fips restart +``` + +Restart fips **after** the mesh interface is up. A transport whose +interface is missing at startup is logged and skipped, not retried — +so if the daemon comes up before the radio, the mesh transport stays +dead until the next restart. (An interface that *vanishes and +returns* after startup is recovered automatically; only the missing- +at-startup case needs this ordering.) + +## Verify + +L2 first — the 802.11s peering, with a second configured router in +range: + +```sh +iw dev fips-mesh0 station dump +``` + +You should see one station entry per neighbor router, with signal +levels. No entries means a radio problem, not a FIPS problem — triage +in this order: + +1. **Channel mismatch** (the most common cause): compare + `iw dev fips-mesh0 info` on both routers — mesh ID *and* channel + must match exactly. +2. **The mesh interface never joined** — `iw dev fips-meshX info` + shows `type mesh point` but **no channel line**, and `station dump` + is empty. Usual cause: a client (`sta`) interface on the same + radio. A STA must follow its upstream AP's channel, the whole + radio follows the STA, and a mesh pinned to a different channel + silently stays down. Check for a STA sharing the radio + (`iw dev`, look for `type managed` on the same phy), compare + `iw dev info | grep channel`, and re-pin the mesh + channel to match — on every backhaul router. +3. **Is the other router transmitting at all?** + + ```sh + iw dev fips-mesh0 scan | grep -i -B4 "MESH ID" + ``` + + Its mesh ID visible → transmission works, peering is failing + (mesh ID typo, or one side has encryption set). Nothing visible → + check `wifi status` on the other router, remember the ~60 s DFS + CAC wait, and confirm the country code is set + (`uci get wireless.radio1.country`) — an unset regdomain can + block channels entirely. +4. `logread | grep -iE "mesh|fips-mesh0"` on both sides. + +Then the FIPS layer on top: + +```sh +logread | grep -i beacon # beacons flowing on the new transport +fipsctl show peers # neighbor authenticated and connected +fipsctl show links # link on the 'ethernet' transport +``` + +Discovery is automatic: each node beacons its pubkey every few +seconds, and `auto_connect` initiates the Noise handshake on first +sight. + +## Constraints + +- **Airtime is shared per radio.** All virtual interfaces on one + radio (AP + mesh) share one channel, and multi-hop forwarding on a + single radio roughly halves throughput per hop. On dual/tri-band + hardware, dedicate one band to `fips-mesh0` and serve clients on + the others. +- **AP + mesh coexistence is driver-dependent.** It works on the + mainstream chips (this is the standard Freifunk/Gluon setup), but + check `iw list` under "valid interface combinations" for your + hardware. +- **Clients can't join.** Phones and laptops reach the mesh through + the router's normal AP or via BLE — never through the 802.11s + interface. +- **Radio links are lossy.** A neighbor at the edge of range will + form an 802.11s peering yet deliver a fraction of its frames. + Expect link-quality effects that don't exist on wired Ethernet. +- **A client (STA) uplink on the same radio owns the channel.** The + STA must follow whatever channel its upstream AP uses; every other + interface on that radio follows the STA. A mesh pinned to a + different channel silently never joins, and it does **not** recover + when the STA disconnects — a `wifi reload` (plus a fips restart) is + needed. A *roaming* uplink (travel-router / hotspot-chasing setups) + is fundamentally incompatible with a fixed-channel mesh on the same + radio: dedicate the mesh to the radio the STA never uses, and treat + any mesh sharing a STA radio as best-effort. diff --git a/packaging/openwrt-apk/build-apk.sh b/packaging/openwrt-apk/build-apk.sh index fa88b7b..e18ab91 100755 --- a/packaging/openwrt-apk/build-apk.sh +++ b/packaging/openwrt-apk/build-apk.sh @@ -182,6 +182,7 @@ install -m 0755 "$RELEASE_DIR/fips" "$STAGE_DIR/usr/bin/fips" install -m 0755 "$RELEASE_DIR/fipsctl" "$STAGE_DIR/usr/bin/fipsctl" install -m 0755 "$RELEASE_DIR/fipstop" "$STAGE_DIR/usr/bin/fipstop" install -m 0755 "$RELEASE_DIR/fips-gateway" "$STAGE_DIR/usr/bin/fips-gateway" +install -m 0755 "$FILES_DIR/usr/bin/fips-mesh-setup" "$STAGE_DIR/usr/bin/fips-mesh-setup" install -d "$STAGE_DIR/etc/init.d" install -m 0755 "$FILES_DIR/etc/init.d/fips" "$STAGE_DIR/etc/init.d/fips" diff --git a/packaging/openwrt-ipk/Makefile b/packaging/openwrt-ipk/Makefile index d8f45da..11e3091 100644 --- a/packaging/openwrt-ipk/Makefile +++ b/packaging/openwrt-ipk/Makefile @@ -96,6 +96,9 @@ define Package/fips/install $(INSTALL_BIN) $(RUST_RELEASE_DIR)/fipstop $(1)/usr/bin/fipstop $(INSTALL_BIN) $(RUST_RELEASE_DIR)/fips-gateway $(1)/usr/bin/fips-gateway + # 802.11s mesh backhaul setup helper + $(INSTALL_BIN) $(CURDIR)/files/usr/bin/fips-mesh-setup $(1)/usr/bin/fips-mesh-setup + # procd init script $(INSTALL_DIR) $(1)/etc/init.d $(INSTALL_BIN) $(CURDIR)/files/etc/init.d/fips $(1)/etc/init.d/fips diff --git a/packaging/openwrt-ipk/README.md b/packaging/openwrt-ipk/README.md index a5ac806..71ebfe1 100644 --- a/packaging/openwrt-ipk/README.md +++ b/packaging/openwrt-ipk/README.md @@ -14,6 +14,7 @@ For ad-hoc deployment without the build system, see | `/usr/bin/fipsctl` | CLI control tool (`fipsctl show peers`, `fipsctl show links`, …) | | `/usr/bin/fipstop` | Live TUI dashboard | | `/usr/bin/fips-gateway` | Outbound LAN gateway service (not started by default) | +| `/usr/bin/fips-mesh-setup` | Opt-in helper — creates an open 802.11s mesh interface for router↔router backhaul | | `/etc/init.d/fips` | procd service for the daemon (auto-start, crash respawn) | | `/etc/init.d/fips-gateway` | procd service for the gateway (disabled by default) | | `/etc/fips/fips.yaml` | Node configuration (edit before first start) | diff --git a/packaging/openwrt-ipk/build-ipk.sh b/packaging/openwrt-ipk/build-ipk.sh index b20cf65..fca8b73 100755 --- a/packaging/openwrt-ipk/build-ipk.sh +++ b/packaging/openwrt-ipk/build-ipk.sh @@ -161,6 +161,7 @@ install -m 0755 "$RELEASE_DIR/fips" "$DATA_DIR/usr/bin/fips" install -m 0755 "$RELEASE_DIR/fipsctl" "$DATA_DIR/usr/bin/fipsctl" install -m 0755 "$RELEASE_DIR/fipstop" "$DATA_DIR/usr/bin/fipstop" install -m 0755 "$RELEASE_DIR/fips-gateway" "$DATA_DIR/usr/bin/fips-gateway" +install -m 0755 "$FILES_DIR/usr/bin/fips-mesh-setup" "$DATA_DIR/usr/bin/fips-mesh-setup" install -d "$DATA_DIR/etc/init.d" install -m 0755 "$FILES_DIR/etc/init.d/fips" "$DATA_DIR/etc/init.d/fips" diff --git a/packaging/openwrt-ipk/files/etc/fips/fips.yaml b/packaging/openwrt-ipk/files/etc/fips/fips.yaml index ff8ecf2..e2b0a76 100644 --- a/packaging/openwrt-ipk/files/etc/fips/fips.yaml +++ b/packaging/openwrt-ipk/files/etc/fips/fips.yaml @@ -100,6 +100,32 @@ transports: auto_connect: true accept_connections: true + # 802.11s mesh backhaul between FIPS routers. These entries ship + # commented out so a stock install that never creates fips-mesh* + # logs no per-boot "interface missing" bind warning. Running + # 'fips-mesh-setup ' creates the interface AND uncomments the + # matching block here (once per radio; radio0 -> fips-mesh0, radio1 -> + # fips-mesh1); 'fips-mesh-setup remove' re-comments it. Restart fips + # after — a transport whose interface is missing at startup is skipped, + # not retried. Dual-band routers can mesh on both bands at once — + # failover, not multipath: FIPS keeps one active link per peer, the + # other band stands by. The mesh runs OPEN (no SAE) with 802.11s + # forwarding off: FIPS's Noise handshake is the encryption and + # authentication, and FIPS is the routing layer. See + # docs/how-to/set-up-80211s-mesh-backhaul.md. + # mesh0: + # interface: "fips-mesh0" + # discovery: true + # announce: true + # auto_connect: true + # accept_connections: true + # mesh1: + # interface: "fips-mesh1" + # discovery: true + # announce: true + # auto_connect: true + # accept_connections: true + # Bluetooth Low Energy transport — requires BlueZ and the 'ble' feature. # ble: # adapter: "hci0" diff --git a/packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup b/packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup new file mode 100755 index 0000000..82d12ca --- /dev/null +++ b/packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup @@ -0,0 +1,259 @@ +#!/bin/sh +# fips-mesh-setup — configure open 802.11s mesh interfaces for FIPS backhaul. +# +# Usage: +# fips-mesh-setup [mesh-id] e.g. fips-mesh-setup radio1 +# fips-mesh-setup remove [radio] no radio: remove all instances +# +# Creates a mesh-point interface on the given radio and leaves everything +# above L2 to FIPS. Run once per radio: dual-band routers can mesh on both +# bands at once (2.4 GHz reaches further, 5 GHz carries more). Note this is +# failover, not multipath — FIPS keeps one active link per peer; the other +# band stands by and reconnects the peer if the active link dies. +# +# - encryption 'none' — the mesh is OPEN on purpose. FIPS's Noise IK +# handshake authenticates and encrypts every peer link, so SAE would +# only duplicate that (and on ath10k it forces the slower raw Tx/Rx +# firmware mode). A stranger can form an 802.11s peering but cannot +# pass the FIPS handshake. +# - mesh_fwding '0' — disables 802.11s HWMP forwarding so each mesh +# link is a plain L2 neighbor link. FIPS is the routing layer; two +# routing layers would fight. +# +# Interfaces are named per radio index (radio0 -> fips-mesh0, radio1 -> +# fips-mesh1) and are intentionally NOT bridged into br-lan: the FIPS +# Ethernet transport binds each directly and runs discovery beacons over it. +# +# The shipped /etc/fips/fips.yaml carries 'mesh0' and 'mesh1' entries under +# 'transports.ethernet' bound to these names, but commented out — a stock +# install that never creates fips-mesh* then logs no bind warning. This +# helper uncomments the matching entry when it creates an interface and +# re-comments it on remove, so the daemon binds the transport without a +# manual config edit. After an interface is up, restart fips. +# See docs/how-to/set-up-80211s-mesh-backhaul.md for the full guide. + +DEFAULT_MESH_ID="fips-mesh" +CONFIG="/etc/fips/fips.yaml" + +# Replace $CONFIG with the rewritten $CONFIG.tmp. Force mode 0600 first: the +# package installs fips.yaml 0600 (it may hold an inline 'nsec' private key), +# and a fresh tmp file would otherwise land world-readable after the move. +mesh_config_write() { + chmod 600 "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG" +} + +# Uncomment the 'mesh' transports.ethernet block in $CONFIG (created by +# 'fips-mesh-setup'). Reversible with mesh_config_disable. Returns: +# 0 enabled (or already active) 1 no config file 2 no such block +mesh_config_enable() { + idx="$1" + [ -f "$CONFIG" ] || return 1 + grep -q "^ mesh$idx:" "$CONFIG" && return 0 + grep -q "^ # mesh$idx:" "$CONFIG" || return 2 + awk -v idx="$idx" ' + $0 ~ ("^ # mesh" idx ":[ \t]*$") { blk = 1; sub(/^ # /, " "); print; next } + blk && /^ # / { sub(/^ # /, " "); print; next } + { blk = 0; print } + ' "$CONFIG" > "$CONFIG.tmp" && mesh_config_write +} + +# Re-comment the 'mesh' block so the daemon stops binding it (and stops +# warning about the now-missing interface). Inverse of mesh_config_enable. +mesh_config_disable() { + idx="$1" + [ -f "$CONFIG" ] || return 1 + grep -q "^ mesh$idx:" "$CONFIG" || return 0 + awk -v idx="$idx" ' + $0 ~ ("^ mesh" idx ":[ \t]*$") { blk = 1; sub(/^ /, " # "); print; next } + blk && /^ / { sub(/^ /, " # "); print; next } + { blk = 0; print } + ' "$CONFIG" > "$CONFIG.tmp" && mesh_config_write +} + +usage() { + echo "Usage: fips-mesh-setup [mesh-id]" >&2 + echo " fips-mesh-setup remove [radio]" >&2 + echo "Radios on this device:" >&2 + uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)=wifi-device$/ \1/p" >&2 + exit 1 +} + +# List the UCI section names of fips-managed mesh wifi-ifaces. +mesh_sections() { + uci show wireless 2>/dev/null | sed -n "s/^wireless\.\(fips_mesh[^.=]*\)=wifi-iface$/\1/p" +} + +# --------------------------------------------------------------------------- +# remove [radio] — delete the wireless and network sections created below +# --------------------------------------------------------------------------- + +if [ "$1" = "remove" ]; then + if [ -n "$2" ]; then + SECTIONS="fips_mesh_$(printf '%s' "$2" | tr -c 'a-zA-Z0-9_' '_')" + else + SECTIONS="$(mesh_sections)" + fi + [ -n "$SECTIONS" ] || { + echo "No fips mesh instances configured." + exit 0 + } + for section in $SECTIONS; do + ifname="$(uci -q get "wireless.$section.ifname")" + uci -q delete "wireless.$section" + uci -q delete "network.$section" + # Re-comment the matching mesh transport in fips.yaml so the + # daemon stops warning about the interface we just removed. + idx="$(printf '%s' "$ifname" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')" + [ -n "$idx" ] && mesh_config_disable "$idx" + echo "Removed ${ifname:-$section}." + done + uci commit wireless + uci commit network + # 'wifi reload' re-applies the whole wireless config, so it briefly drops + # every client AP on all radios (a few seconds) — expected on remove. + wifi reload + echo "Restart fips: /etc/init.d/fips restart" + exit 0 +fi + +RADIO="$1" +MESH_ID="${2:-$DEFAULT_MESH_ID}" + +[ -n "$RADIO" ] || usage + +if [ "$(uci -q get "wireless.$RADIO")" != "wifi-device" ]; then + echo "Error: '$RADIO' is not a wifi-device in /etc/config/wireless." >&2 + usage +fi + +# One instance per radio: section fips_mesh_, netdev fips-mesh +# where N is the radio's trailing index (radio0 -> fips-mesh0). For radios +# named without a trailing number, fall back to the first free index. +SECTION="fips_mesh_$(printf '%s' "$RADIO" | tr -c 'a-zA-Z0-9_' '_')" +IDX="$(printf '%s' "$RADIO" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')" +[ -n "$IDX" ] || IDX="$(printf '%s' "$RADIO" | sed -n 's/^\([0-9]\{1,\}\)$/\1/p')" +if [ -z "$IDX" ]; then + IDX=0 + while uci show wireless 2>/dev/null | grep -q "\.ifname='fips-mesh$IDX'"; do + IDX=$((IDX + 1)) + done +fi +MESH_IFNAME="fips-mesh$IDX" + +# Refuse a name collision from another radio's instance (e.g. two radios +# whose names end in the same digit) rather than silently hijacking it. +OWNER="$(uci show wireless 2>/dev/null \ + | sed -n "s/^wireless\.\(fips_mesh[^.=]*\)\.ifname='$MESH_IFNAME'$/\1/p")" +if [ -n "$OWNER" ] && [ "$OWNER" != "$SECTION" ]; then + echo "Error: $MESH_IFNAME is already used by section '$OWNER'." >&2 + echo "Remove it first: fips-mesh-setup remove" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Driver capability check (advisory — config below is harmless either way) +# --------------------------------------------------------------------------- + +if command -v iw >/dev/null 2>&1; then + if ! iw list 2>/dev/null | grep -q "\* mesh point"; then + echo "Warning: no radio on this device advertises 'mesh point' support" >&2 + echo "(iw list | grep 'mesh point'). The interface may fail to come up." >&2 + fi +fi + +# --------------------------------------------------------------------------- +# Wireless: open 802.11s mesh point, HWMP forwarding off +# --------------------------------------------------------------------------- + +uci -q delete "wireless.$SECTION" +uci set "wireless.$SECTION=wifi-iface" +uci set "wireless.$SECTION.device=$RADIO" +uci set "wireless.$SECTION.mode=mesh" +uci set "wireless.$SECTION.mesh_id=$MESH_ID" +uci set "wireless.$SECTION.encryption=none" +uci set "wireless.$SECTION.mesh_fwding=0" +uci set "wireless.$SECTION.ifname=$MESH_IFNAME" +uci set "wireless.$SECTION.network=$SECTION" + +# Radios ship disabled on fresh OpenWrt installs; a disabled radio would +# leave the mesh interface down with no error anywhere visible. +if [ "$(uci -q get "wireless.$RADIO.disabled")" = "1" ]; then + echo "Note: enabling $RADIO (was disabled)." + uci -q delete "wireless.$RADIO.disabled" +fi + +# The mesh inherits the radio's channel, and mesh points only peer on the +# same channel. 'auto' lets each router pick its own — the classic silent +# non-peering cause — so surface the setting loudly. +CHANNEL="$(uci -q get "wireless.$RADIO.channel")" +BAND="$(uci -q get "wireless.$RADIO.band")" +if [ -z "$CHANNEL" ] || [ "$CHANNEL" = "auto" ]; then + echo "Warning: $RADIO channel is '${CHANNEL:-unset}' — each router may" >&2 + echo "auto-select a different channel and mesh points only peer on the" >&2 + echo "same one. Pin the same channel on every backhaul router, e.g.:" >&2 + echo " uci set wireless.$RADIO.channel='36' && uci commit wireless && wifi reload" >&2 +fi + +# A client (sta) interface on the same radio follows its upstream AP's +# channel and drags every other interface with it — a mesh pinned to a +# different channel silently never joins, and does not recover when the +# STA disconnects. +for s in $(uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)\.mode='sta'$/\1/p"); do + if [ "$(uci -q get "wireless.$s.device")" = "$RADIO" ]; then + echo "Warning: $RADIO also carries client interface '$s' (mode 'sta')." >&2 + echo "The whole radio follows that STA's upstream channel — a mesh" >&2 + echo "pinned to a different channel stays down silently. Align the" >&2 + echo "mesh channel with the upstream AP, or put the mesh on a radio" >&2 + echo "without a STA (a roaming uplink is incompatible with a" >&2 + echo "fixed-channel mesh on the same radio)." >&2 + fi +done + +# --------------------------------------------------------------------------- +# Network: unmanaged interface so netifd brings the netdev up. No IP config — +# the FIPS Ethernet transport speaks raw frames on it. +# --------------------------------------------------------------------------- + +uci -q delete "network.$SECTION" +uci set "network.$SECTION=interface" +uci set "network.$SECTION.proto=none" + +uci commit wireless +uci commit network +# 'wifi reload' re-applies the whole wireless config, so it briefly drops +# every client AP on all radios (a few seconds) — expected when adding a mesh. +wifi reload + +# Enable the matching mesh transport in the shipped fips.yaml (it ships +# commented out). Tailor the restart hint to what we could do. +mesh_config_enable "$IDX" +case $? in + 0) TRANSPORT_NOTE="The mesh$IDX transport in $CONFIG that binds '$MESH_IFNAME' is + now uncommented and enabled." ;; + 1) TRANSPORT_NOTE="No $CONFIG found — add a transports.ethernet entry binding + interface '$MESH_IFNAME' by hand." ;; + *) TRANSPORT_NOTE="No 'mesh$IDX' entry in $CONFIG — add a transports.ethernet + entry binding interface '$MESH_IFNAME' by hand (copy the mesh0 block)." ;; +esac + +cat <:` header and its ` # ` continuation lines, + /// leaving every other comment untouched. + fn uncomment_mesh_blocks(yaml: &str) -> String { + let mut out = String::new(); + let mut in_block = false; + for line in yaml.lines() { + let is_header = line + .strip_prefix(" # mesh") + .and_then(|r| r.strip_suffix(':')) + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); + if is_header { + in_block = true; + out.push_str(&line.replacen(" # ", " ", 1)); + } else if in_block && line.starts_with(" # ") { + out.push_str(&line.replacen(" # ", " ", 1)); + } else { + in_block = false; + out.push_str(line); + } + out.push('\n'); + } + out + } + #[test] fn test_parse_yaml_with_hex() { let yaml = r#"