diff --git a/.github/workflows/package-openwrt.yml b/.github/workflows/package-openwrt.yml index 145ebe5..8939463 100644 --- a/.github/workflows/package-openwrt.yml +++ b/.github/workflows/package-openwrt.yml @@ -286,6 +286,7 @@ jobs: "$FILES_DIR/etc/hotplug.d/net/99-fips" "$FILES_DIR/etc/uci-defaults/90-fips-setup" "$FILES_DIR/usr/bin/fips-mesh-setup" + "$FILES_DIR/usr/bin/fips-ap-setup" ) fail=0 for f in "${TARGETS[@]}"; do @@ -406,6 +407,7 @@ jobs: ./usr/bin/fipstop ./usr/bin/fips-gateway ./usr/bin/fips-mesh-setup + ./usr/bin/fips-ap-setup ./etc/init.d/fips ./etc/init.d/fips-gateway ./etc/fips/fips.yaml @@ -719,7 +721,7 @@ jobs: for path in \ usr/bin/fips usr/bin/fipsctl usr/bin/fipstop usr/bin/fips-gateway \ - usr/bin/fips-mesh-setup \ + usr/bin/fips-mesh-setup usr/bin/fips-ap-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 a50812a..1774a9a 100644 --- a/docs/how-to/README.md +++ b/docs/how-to/README.md @@ -26,4 +26,5 @@ X" to "X is done". | [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) | +| [set-up-open-access-ssid.md](set-up-open-access-ssid.md) | Broadcast the open `!FIPS` access SSID so phones and laptops roam onto the mesh (one ESS: save once, roam every FIPS router) | | [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 index e7e1941..6cfde17 100644 --- a/docs/how-to/set-up-80211s-mesh-backhaul.md +++ b/docs/how-to/set-up-80211s-mesh-backhaul.md @@ -21,12 +21,14 @@ Two deliberate choices distinguish this from a stock 802.11s setup: 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. + 802.11s peering with your router *and* a FIPS peer link on top of it — + the same open model as mDNS and BLE discovery, where the advert is + only a hint and the handshake authenticates each link (no + impersonation, no MITM) rather than gating who may peer. Admission is + open up to the daemon's max-peers cap. What you concede: any nearby + radio can peer and reach the FIPS overlay surface; L2 metadata (MAC + addresses, frame sizes) is visible in the air; a hostile radio can + burn airtime — all inherent to an open radio link. - **`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 diff --git a/docs/how-to/set-up-open-access-ssid.md b/docs/how-to/set-up-open-access-ssid.md new file mode 100644 index 0000000..0b7e272 --- /dev/null +++ b/docs/how-to/set-up-open-access-ssid.md @@ -0,0 +1,267 @@ +# Set Up the Open FIPS Access SSID (OpenWrt) + +Give phones and laptops a way in: every FIPS router broadcasts the +same open SSID — `!FIPS` — from its access radio. Same SSID + unique +BSSIDs is one standard ESS, so a client saves the network once and +roams between all FIPS routers natively, with no per-router setup and +no shared credentials (the Freifunk model). The leading `!` sorts the +network to the top of alphabetically ordered pickers (iOS, desktop +OSes — Android sorts by signal strength) and is part of the name: +SSIDs match byte-for-byte or not at all. The radio layer provides +nothing but open L2 to the nearest router; FIPS provides everything +else: encryption and authentication (Noise IK), discovery +(mDNS/Ethernet beacons), and mobility (the overlay identity survives +roaming, so no 802.11r or L2 tricks are needed). + +This is the *access* layer — how clients reach FIPS routers. For the +router-to-router *backhaul*, see +[set-up-80211s-mesh-backhaul.md](set-up-80211s-mesh-backhaul.md). +For all `transports.ethernet.*` configuration keys, see +[../reference/configuration.md](../reference/configuration.md). + +## Why open, why this addressing + +Three deliberate choices distinguish this from a stock guest network: + +- **`encryption none`** — the SSID is open on purpose, and it *must* + be. Clients key a saved network on SSID **plus security type**: if + one router used a PSK and another OWE, the same `FIPS` name would be + three different saved networks and roaming would break. Open is the + only security type that needs zero provisioning, and OWE is left out + for now for exactly this uniformity reason (OWE-transition mode is + inconsistent across client vendors). Every FIPS peer link is already + authenticated and encrypted by the Noise IK handshake. A stranger + can associate *and* form a FIPS peer link — that is the point of open + access; the handshake authenticates each link (no impersonation, no + MITM) but does not gate who may peer, and admission is open up to the + daemon's max-peers cap. What confines a hostile peer is the isolated + `fips_ap` zone (no path to br-lan or the WAN — see below), not the + handshake. What you concede: any nearby device can reach the FIPS + overlay surface (handshake, discovery, lookup, routing) and peer with + the router; L2 metadata is visible in the air; a hostile radio can + burn airtime — all inherent to an open radio link. +- **DHCPv4 from a fixed subnet, plus IPv6 router advertisements.** + dnsmasq leases IPv4 out of `10.21..0/24` (`N` = the radio index; + the prefix echoes FIPS port 2121). The subnet is deliberately + **identical on every router**: a roaming phone keeps its lease + across routers, and dnsmasq's authoritative mode (the OpenWrt + default, pinned by the helper) ACKs a renew the new router never + issued. odhcpd additionally announces a ULA prefix (`fd..`-range) + for stateless SLAAC; DHCPv6 stays off. FIPS itself only needs + link-local + mDNS, but Android's provisioning check requires an RA + or a DHCP offer and *disconnects* with neither, and plain laptops + expect a real IPv4 address. Works with or without an upstream — + nothing here depends on the WAN. The IPv6 side stays per-router and + disposable; in all cases the FIPS overlay identity, not the IP, is + the mobility anchor. +- **Isolated interface** — its own network and firewall zone, with no + path to `br-lan` and no forwarding to the WAN. Inbound traffic is + rejected except DHCPv4, ICMPv6 (SLAAC itself), mDNS, and the FIPS + transport ports; the raw-Ethernet transport (EtherType 0x2121) is + not IP and never traverses the firewall. AP client isolation is on, so clients + cannot reach each other at L2 — two FIPS phones on one router still + reach each other through the router at the overlay layer. + +## The "no internet" behavior (expected, one-time acceptance) + +The network intentionally provides **no internet**. On first connect, +a phone's validation probe fails and it asks whether to stay on a +network without internet access — choose **stay connected** and +**don't ask again**. That choice is stored per SSID, so accepting it +once covers every FIPS router anywhere. + +After that, the network is marked "connected, no internet" +(unvalidated) and the phone keeps **cellular as its default route** +while staying associated — normal apps never notice the FIPS network +exists. FIPS apps bind their sockets to the Wi-Fi network explicitly, +so mesh traffic flows over Wi-Fi while everything else uses cellular. + +## When to use + +- Any FIPS router that should serve phones and laptops directly, not + just peer with other routers. +- You want clients to roam between FIPS routers with zero per-router + or per-site configuration. + +It is the complement of the 802.11s backhaul: the backhaul links +routers (clients cannot join it), the access SSID admits clients. +Both can share a radio, at an airtime cost (see constraints). + +## Requirements + +- OpenWrt 22.03+ with the FIPS package installed (fw4; dnsmasq and + odhcpd are part of the default images). +- Any radio — AP mode needs no special driver support. + +## Step 1 — create the access point(s) + +On **each** router, run the helper once per radio that should serve +clients: + +```sh +fips-ap-setup radio0 +``` + +This creates an open AP with SSID `!FIPS` and client isolation, an +isolated network with `10.21..1/24` and a static ULA `/64`, a +DHCPv4 + RA dhcp config (dnsmasq leases, SLAAC, no DHCPv6), and a +locked-down `fips_ap` firewall zone — then reloads the radio. Interfaces are named by radio index: `radio0` → +`fips-ap0`, `radio1` → `fips-ap1`. Pass a second argument to use a +different SSID — but the SSID, like the security type, must be +identical on **all** routers or clients will treat them as separate +networks and stop roaming. + +On dual-band routers, run it for both radios so clients can pick +either band: + +```sh +fips-ap-setup radio0 +fips-ap-setup radio1 +``` + +**Channels are free per router.** Unlike the mesh backhaul, there is +no same-channel constraint — clients scan when they roam — so leave +each router on whatever channel suits its RF environment. + +Equivalent manual UCI (per radio), if you prefer to see what it does +(`fdxx:...` stands for a `/64` out of the router's ULA prefix): + +```sh +uci batch <<'EOF' +set wireless.fips_ap_radio0=wifi-iface +set wireless.fips_ap_radio0.device='radio0' +set wireless.fips_ap_radio0.mode='ap' +set wireless.fips_ap_radio0.ssid='!FIPS' +set wireless.fips_ap_radio0.encryption='none' +set wireless.fips_ap_radio0.isolate='1' +set wireless.fips_ap_radio0.ifname='fips-ap0' +set wireless.fips_ap_radio0.network='fips_ap_radio0' +set network.fips_ap_radio0=interface +set network.fips_ap_radio0.proto='static' +set network.fips_ap_radio0.ipaddr='10.21.0.1' +set network.fips_ap_radio0.netmask='255.255.255.0' +set network.fips_ap_radio0.ip6addr='fdxx:xxxx:xxxx:fa00::1/64' +set dhcp.fips_ap_radio0=dhcp +set dhcp.fips_ap_radio0.interface='fips_ap_radio0' +set dhcp.fips_ap_radio0.ra='server' +set dhcp.fips_ap_radio0.ra_default='2' +set dhcp.fips_ap_radio0.dhcpv6='disabled' +set dhcp.fips_ap_radio0.dhcpv4='server' +set dhcp.fips_ap_radio0.start='10' +set dhcp.fips_ap_radio0.limit='200' +EOF +uci commit +wifi reload +``` + +plus the `fips_ap` firewall zone (input/forward REJECT, no +forwardings, ACCEPT rules for DHCPv4/UDP 67, ICMPv6, UDP 5353/2121, +TCP 8443). + +## Step 2 — check the FIPS transport binding + +The `fips.yaml` shipped in the OpenWrt package carries one transport +entry per access interface, but **commented out** — so a stock install +that never runs this helper logs no per-boot "interface missing" +warning. `fips-ap-setup` uncommented the matching `apN` entry in Step 1, +and also enabled `node.rendezvous.lan` (the daemon's mDNS/DNS-SD +rendezvous — phone FIPS apps cannot see raw-Ethernet beacons, so mDNS +is how they find the daemon; the switch is daemon-wide and stays on if +you later remove the AP). 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 both are present and uncommented: + +```yaml +node: + rendezvous: + lan: + enabled: true +``` + +```yaml +transports: + ethernet: + ap0: + interface: "fips-ap0" + discovery: true + announce: true + auto_connect: true + accept_connections: true + ap1: + interface: "fips-ap1" + 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 AP 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 access 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 and addressing first, with a phone or laptop connected to `!FIPS`: + +```sh +iw dev fips-ap0 station dump # one entry per associated client +ip addr show dev fips-ap0 # 10.21.0.1/24 and the fd..::1/64 +cat /tmp/dhcp.leases # one lease per connected client +``` + +No station entries means a radio problem; an association that drops +after ~30 s usually means the client never got an address — check +`logread | grep -e dnsmasq -e odhcpd` and that the +`dhcp.fips_ap_radio0` section survived +(`uci show dhcp | grep fips_ap`). + +Then the FIPS layer on top, for a client running FIPS: + +```sh +logread | grep -i beacon # beacons flowing on the new transport +fipsctl show peers # client authenticated and connected +``` + +On the phone itself: the network shows "connected, no internet" and +stays associated — that is the designed steady state, not an error. + +## Constraints + +- **SSID and security type must be uniform across ALL routers.** + One router with a PSK (or OWE) under the same name splits the ESS + into different saved networks and silently breaks roaming. Never + "harden" a single router. +- **Airtime is shared per radio.** An access AP and a mesh backhaul + on the same radio share one channel. On dual/tri-band hardware, + dedicate a band to the backhaul and serve clients on the others. +- **Strangers can associate and peer — by design.** Open access means + any nearby device can complete the Noise handshake and become a FIPS + peer (up to the max-peers cap); the handshake authenticates each link, + it does not restrict who joins. They reach only the FIPS overlay + surface — the isolated zone gives no path to br-lan or the WAN. Do not + add forwardings to the `fips_ap` zone: that would turn the open SSID + into a hotspot and hand the isolation away. +- **Roaming is client-driven.** Clients decide when to hop BSSIDs + (standard ESS behavior); the IPv4 lease survives the hop (same + subnet everywhere), the SLAAC address renumbers, and FIPS sessions + ride through because the overlay identity is the anchor. Expect a + brief L2 gap during the hop, as on any ESS without 802.11r. +- **The `10.21..0/24` convention must hold everywhere.** Lease + survival depends on every router serving the same subnet from the + same radio index — the helper guarantees this; don't hand-pick + per-router subnets. Two routers can lease the same address to two + different clients; after a roam the conflict is caught (dnsmasq + NAKs a renew for an address in use) and the client re-DHCPs. If a + laptop is *also* wired to a LAN that really uses `10.21..0/24`, + its routing table will conflict — a corner case worth knowing, not + designing around: the zone forwards nowhere, so the FIPS side never + reaches beyond the router either way. diff --git a/packaging/openwrt-apk/build-apk.sh b/packaging/openwrt-apk/build-apk.sh index e18ab91..0788f7d 100755 --- a/packaging/openwrt-apk/build-apk.sh +++ b/packaging/openwrt-apk/build-apk.sh @@ -183,6 +183,7 @@ 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 -m 0755 "$FILES_DIR/usr/bin/fips-ap-setup" "$STAGE_DIR/usr/bin/fips-ap-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 11e3091..cf828c7 100644 --- a/packaging/openwrt-ipk/Makefile +++ b/packaging/openwrt-ipk/Makefile @@ -99,6 +99,9 @@ define Package/fips/install # 802.11s mesh backhaul setup helper $(INSTALL_BIN) $(CURDIR)/files/usr/bin/fips-mesh-setup $(1)/usr/bin/fips-mesh-setup + # Open "FIPS" access SSID setup helper + $(INSTALL_BIN) $(CURDIR)/files/usr/bin/fips-ap-setup $(1)/usr/bin/fips-ap-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/build-ipk.sh b/packaging/openwrt-ipk/build-ipk.sh index fca8b73..ac9118c 100755 --- a/packaging/openwrt-ipk/build-ipk.sh +++ b/packaging/openwrt-ipk/build-ipk.sh @@ -162,6 +162,7 @@ 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 -m 0755 "$FILES_DIR/usr/bin/fips-ap-setup" "$DATA_DIR/usr/bin/fips-ap-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 e2b0a76..378fd8c 100644 --- a/packaging/openwrt-ipk/files/etc/fips/fips.yaml +++ b/packaging/openwrt-ipk/files/etc/fips/fips.yaml @@ -43,6 +43,14 @@ node: # - "stun:stun.cloudflare.com:3478" # - "stun:global.stun.twilio.com:3478" + # mDNS/DNS-SD peer rendezvous on the local link. Ships commented (the + # daemon default is off); 'fips-ap-setup' uncomments it when creating + # the access SSID — phone FIPS apps cannot see raw-Ethernet beacons, + # so mDNS is how they find this router's daemon. Daemon-wide switch, + # left enabled on 'fips-ap-setup remove'. + # lan: + # enabled: true + tun: enabled: true name: fips0 @@ -64,7 +72,11 @@ dns: transports: udp: - bind_addr: "0.0.0.0:2121" + # Dual-stack wildcard, not "0.0.0.0": access-SSID clients (phones) learn + # this node's addresses from the mDNS advert and prefer the IPv6 + # link-local — a v4-only bind silently drops their Noise msg1. + # OpenWrt is Linux (bindv6only=0), so "[::]" accepts v4 too. + bind_addr: "[::]:2121" # advertise_on_nostr: true # public: false # false => advertise udp:nat; true => advertise bound host:port # accept_connections: true # default; refuse inbound msg1 when false @@ -126,6 +138,30 @@ transports: # auto_connect: true # accept_connections: true + # Open "!FIPS" access SSID for phones and laptops running FIPS. These + # entries ship commented out so a stock install that never creates + # fips-ap* logs no per-boot "interface missing" bind warning. Running + # 'fips-ap-setup ' creates the interface AND uncomments the + # matching block here (once per radio; radio0 -> fips-ap0, radio1 -> + # fips-ap1); 'fips-ap-setup remove' re-comments it. Restart fips after + # — a transport whose interface is missing at startup is skipped, not + # retried. The SSID is OPEN and isolated on purpose: FIPS's Noise + # handshake is the only security layer, and associated clients reach + # nothing but the FIPS handshake surface. See + # docs/how-to/set-up-open-access-ssid.md. + # ap0: + # interface: "fips-ap0" + # discovery: true + # announce: true + # auto_connect: true + # accept_connections: true + # ap1: + # interface: "fips-ap1" + # 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-ap-setup b/packaging/openwrt-ipk/files/usr/bin/fips-ap-setup new file mode 100755 index 0000000..3551730 --- /dev/null +++ b/packaging/openwrt-ipk/files/usr/bin/fips-ap-setup @@ -0,0 +1,412 @@ +#!/bin/sh +# fips-ap-setup — configure the open "FIPS" access SSID for phones/laptops. +# +# Usage: +# fips-ap-setup [ssid] e.g. fips-ap-setup radio0 +# fips-ap-setup remove [radio] no radio: remove all instances +# +# Creates an open AP on the given radio so client devices running FIPS can +# reach the router. Every FIPS router broadcasts the SAME SSID ("!FIPS" by +# default — the leading '!' sorts it to the top of alphabetically ordered +# network pickers): same SSID + unique BSSIDs is one standard ESS, so a +# phone saves the network once and roams between all FIPS routers natively. +# +# - encryption 'none' — the AP is OPEN on purpose. FIPS's Noise IK +# handshake authenticates and encrypts everything above the radio, and +# the security type must be uniform across ALL routers anyway: clients +# key a saved network on SSID + security type, so one router with a PSK +# splits the ESS into a different saved network. A stranger can +# associate AND form a FIPS peer link — that is the point of open +# access. The Noise handshake authenticates each link (no +# impersonation of another identity, no MITM); it does NOT gate who +# may peer. Admission is open up to the daemon's max-peers cap; the +# firewall zone below is what confines every client to the FIPS +# overlay (no path to br-lan or the WAN). +# - DHCPv4 + RA IPv6 — dnsmasq serves DHCPv4 from a FIXED subnet, +# 10.21..0/24 (echoes FIPS port 2121), identical on every router: +# a roaming phone keeps its lease across routers, and dnsmasq's +# authoritative mode (the OpenWrt default) ACKs the renew a foreign +# router never issued. odhcpd additionally announces a ULA prefix in +# router advertisements (stateless SLAAC); DHCPv6 stays off. FIPS +# itself only needs link-local + mDNS, but client provisioning checks +# (Android disconnects without an RA or a DHCP offer) and plain +# laptops both want a real address. The network provides no internet, +# so phones mark it unvalidated and keep cellular as the default +# route while staying associated. +# - ISOLATED — own network and firewall zone: no path to +# br-lan, no forwarding to the WAN, and AP client isolation on. +# Associated clients reach only the FIPS handshake surface. +# +# Interfaces are named per radio index (radio0 -> fips-ap0, radio1 -> +# fips-ap1). Unlike the 802.11s backhaul there is NO same-channel +# constraint — clients scan when they roam, so every router picks its +# access channels freely. +# +# The shipped /etc/fips/fips.yaml carries 'ap0' and 'ap1' entries under +# 'transports.ethernet' bound to these names, but commented out — a stock +# install that never creates fips-ap* 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. It also uncomments the node.rendezvous.lan block +# (mDNS/DNS-SD — how phone FIPS apps discover the daemon); that switch is +# daemon-wide and stays on at remove. After an interface is up, restart +# fips. +# See docs/how-to/set-up-open-access-ssid.md for the full guide. + +DEFAULT_SSID="!FIPS" +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. +ap_config_write() { + chmod 600 "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG" +} + +# Uncomment the 'ap' transports.ethernet block in $CONFIG (created by +# 'fips-ap-setup'). Reversible with ap_config_disable. Returns: +# 0 enabled (or already active) 1 no config file 2 no such block +ap_config_enable() { + idx="$1" + [ -f "$CONFIG" ] || return 1 + grep -q "^ ap$idx:" "$CONFIG" && return 0 + grep -q "^ # ap$idx:" "$CONFIG" || return 2 + awk -v idx="$idx" ' + $0 ~ ("^ # ap" idx ":[ \t]*$") { blk = 1; sub(/^ # /, " "); print; next } + blk && /^ # / { sub(/^ # /, " "); print; next } + { blk = 0; print } + ' "$CONFIG" > "$CONFIG.tmp" && ap_config_write +} + +# Uncomment the 'lan' block under node.rendezvous in $CONFIG — the daemon's +# mDNS/DNS-SD responder+browser. Phone FIPS apps cannot open raw-Ethernet +# sockets, so mDNS is how they find this router's daemon. The match is +# scoped to node.rendezvous: transports.ethernet also has a 'lan' entry at +# the same indent. Daemon-wide switch — enabled here, deliberately NOT +# re-commented on remove (other transports use it once on). Returns: +# 0 enabled (or already active) 1 no config file 2 no such block +lan_rendezvous_enable() { + [ -f "$CONFIG" ] || return 1 + state="$(awk ' + /^[A-Za-z_]/ { top = $1 } + top == "node:" && /^ [A-Za-z_]/ { sec = $1 } + top == "node:" && sec == "rendezvous:" && /^ lan:[ \t]*$/ { print "active"; exit } + top == "node:" && sec == "rendezvous:" && /^ # lan:[ \t]*$/ { print "commented"; exit } + ' "$CONFIG")" + case "$state" in + active) return 0 ;; + commented) ;; + *) return 2 ;; + esac + awk ' + /^[A-Za-z_]/ { top = $1 } + top == "node:" && /^ [A-Za-z_]/ { sec = $1 } + top == "node:" && sec == "rendezvous:" && $0 ~ /^ # lan:[ \t]*$/ { blk = 1; sub(/^ # /, " "); print; next } + blk && /^ # / { sub(/^ # /, " "); print; next } + { blk = 0; print } + ' "$CONFIG" > "$CONFIG.tmp" && ap_config_write +} + +# Re-comment the 'ap' block so the daemon stops binding it (and stops +# warning about the now-missing interface). Inverse of ap_config_enable. +ap_config_disable() { + idx="$1" + [ -f "$CONFIG" ] || return 1 + grep -q "^ ap$idx:" "$CONFIG" || return 0 + awk -v idx="$idx" ' + $0 ~ ("^ ap" idx ":[ \t]*$") { blk = 1; sub(/^ /, " # "); print; next } + blk && /^ / { sub(/^ /, " # "); print; next } + { blk = 0; print } + ' "$CONFIG" > "$CONFIG.tmp" && ap_config_write +} + +usage() { + echo "Usage: fips-ap-setup [ssid]" >&2 + echo " fips-ap-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 access-point wifi-ifaces. +ap_sections() { + uci show wireless 2>/dev/null | sed -n "s/^wireless\.\(fips_ap[^.=]*\)=wifi-iface$/\1/p" +} + +# --------------------------------------------------------------------------- +# remove [radio] — delete the wireless, network, dhcp, and firewall sections +# created below +# --------------------------------------------------------------------------- + +if [ "$1" = "remove" ]; then + if [ -n "$2" ]; then + SECTIONS="fips_ap_$(printf '%s' "$2" | tr -c 'a-zA-Z0-9_' '_')" + else + SECTIONS="$(ap_sections)" + fi + [ -n "$SECTIONS" ] || { + echo "No fips access-point 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" + uci -q delete "dhcp.$section" + uci -q del_list "firewall.fips_ap.network=$section" + # Re-comment the matching ap 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" ] && ap_config_disable "$idx" + echo "Removed ${ifname:-$section}." + done + # Drop the shared zone and its rules once the last instance is gone. + if [ -z "$(uci -q get firewall.fips_ap.network)" ]; then + uci -q delete firewall.fips_ap + uci -q delete firewall.fips_ap_icmpv6 + uci -q delete firewall.fips_ap_dhcpv4 + uci -q delete firewall.fips_ap_mdns + uci -q delete firewall.fips_ap_fips_udp + uci -q delete firewall.fips_ap_fips_tcp + fi + uci commit wireless + uci commit network + uci commit dhcp + uci commit firewall + wifi reload + /etc/init.d/dnsmasq reload + /etc/init.d/odhcpd reload + /etc/init.d/firewall reload + echo "Restart fips: /etc/init.d/fips restart" + exit 0 +fi + +RADIO="$1" +SSID="${2:-$DEFAULT_SSID}" + +[ -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_ap_, netdev fips-ap +# where N is the radio's trailing index (radio0 -> fips-ap0). For radios +# named without a trailing number, fall back to the first free index. +SECTION="fips_ap_$(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-ap$IDX'"; do + IDX=$((IDX + 1)) + done +fi +AP_IFNAME="fips-ap$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_ap[^.=]*\)\.ifname='$AP_IFNAME'$/\1/p")" +if [ -n "$OWNER" ] && [ "$OWNER" != "$SECTION" ]; then + echo "Error: $AP_IFNAME is already used by section '$OWNER'." >&2 + echo "Remove it first: fips-ap-setup remove" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Wireless: open AP with client isolation. Clients of the same AP cannot +# exchange L2 frames directly — two FIPS phones on one router still reach +# each other through the router at the overlay layer. Isolation is an L2 +# control only: a stranger who peers is an overlay peer like any other, so +# the FIPS overlay (not L2) is the trust boundary between clients. +# --------------------------------------------------------------------------- + +uci -q delete "wireless.$SECTION" +uci set "wireless.$SECTION=wifi-iface" +uci set "wireless.$SECTION.device=$RADIO" +uci set "wireless.$SECTION.mode=ap" +uci set "wireless.$SECTION.ssid=$SSID" +uci set "wireless.$SECTION.encryption=none" +uci set "wireless.$SECTION.isolate=1" +uci set "wireless.$SECTION.ifname=$AP_IFNAME" +uci set "wireless.$SECTION.network=$SECTION" + +# Radios ship disabled on fresh OpenWrt installs; a disabled radio would +# leave the AP 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 + +CHANNEL="$(uci -q get "wireless.$RADIO.channel")" +BAND="$(uci -q get "wireless.$RADIO.band")" + +# --------------------------------------------------------------------------- +# Network: IPv4 from the fixed convention 10.21..1/24 — deterministic, +# so every router serving the same radio index lands on the same subnet and +# a roaming client's lease stays valid. IPv6 is a static ULA /64 so odhcpd +# has a prefix to announce; that space is per-router and disposable — a +# roaming phone SLAACs a fresh address on each router, and the FIPS overlay +# identity (not the IP) is the mobility anchor. The ULA is derived from the +# router's global ULA prefix; a re-run keeps the address already configured. +# --------------------------------------------------------------------------- + +AP_ADDR="$(uci -q get "network.$SECTION.ip6addr")" +case "$AP_ADDR" in +fd*) ;; # keep the existing address on re-run +*) + ULA_BASE="" + ULA_PREFIX="$(uci -q get network.globals.ula_prefix)" + case "$ULA_PREFIX" in + fd*::/48) ULA_BASE="${ULA_PREFIX%::/48}" ;; + esac + if [ -z "$ULA_BASE" ]; then + HEX="$(head -c 5 /dev/urandom | hexdump -e '5/1 "%02x"')" + ULA_BASE="fd$(printf '%s' "$HEX" | cut -c1-2):$(printf '%s' "$HEX" | cut -c3-6):$(printf '%s' "$HEX" | cut -c7-10)" + echo "Note: no usable ULA prefix in network.globals — generated $ULA_BASE::/48 for this AP." + fi + # 64000 = 0xfa00 — high subnet IDs keep clear of br-lan's low + # ip6assign allocations from the same ULA prefix. + AP_ADDR="$ULA_BASE:$(printf '%04x' $((64000 + IDX)))::1/64" + ;; +esac + +AP_ADDR4="10.21.$IDX.1" + +uci -q delete "network.$SECTION" +uci set "network.$SECTION=interface" +uci set "network.$SECTION.proto=static" +uci set "network.$SECTION.ipaddr=$AP_ADDR4" +uci set "network.$SECTION.netmask=255.255.255.0" +uci set "network.$SECTION.ip6addr=$AP_ADDR" + +# --------------------------------------------------------------------------- +# DHCP/RA: dnsmasq DHCPv4 leases out of 10.21..0/24, plus router +# advertisements for the ULA (stateless SLAAC, no DHCPv6). ra_default '2' +# announces a default router even without an upstream default route: +# Android's provisioning wants address + route + DNS, and its validation +# probe then fails by design (no internet), so the phone keeps cellular as +# the default route. 'dhcpv4 server' is read by BOTH dnsmasq (the default +# DHCPv4 server) and odhcpd (serves v4 only when odhcpd.maindhcp is set), +# so either arrangement hands out leases. +# --------------------------------------------------------------------------- + +uci -q delete "dhcp.$SECTION" +uci set "dhcp.$SECTION=dhcp" +uci set "dhcp.$SECTION.interface=$SECTION" +uci set "dhcp.$SECTION.ra=server" +uci set "dhcp.$SECTION.ra_default=2" +uci set "dhcp.$SECTION.dhcpv6=disabled" +uci set "dhcp.$SECTION.dhcpv4=server" +uci set "dhcp.$SECTION.start=10" +uci set "dhcp.$SECTION.limit=200" + +# Authoritative is the OpenWrt default, but roaming correctness depends on +# it (a foreign router must ACK a lease it never issued), so pin it. +[ -n "$(uci -q get dhcp.@dnsmasq[0])" ] && uci set dhcp.@dnsmasq[0].authoritative=1 + +# --------------------------------------------------------------------------- +# Firewall: one shared 'fips_ap' zone for all instances. Everything is +# rejected except what a FIPS client needs — DHCPv4 (addressing), ICMPv6 +# (SLAAC itself), mDNS (discovery), and the FIPS UDP/TCP transports (the +# handshake surface). +# The raw-Ethernet transport (EtherType 0x2121) is not IP and never +# traverses the firewall. No forwardings exist, so there is no path to +# br-lan or the WAN. +# --------------------------------------------------------------------------- + +if [ "$(uci -q get firewall.fips_ap)" != "zone" ]; then + uci set firewall.fips_ap=zone +fi +uci set firewall.fips_ap.name=fips_ap +uci set firewall.fips_ap.input=REJECT +uci set firewall.fips_ap.output=ACCEPT +uci set firewall.fips_ap.forward=REJECT +uci -q del_list "firewall.fips_ap.network=$SECTION" +uci add_list "firewall.fips_ap.network=$SECTION" + +# ap_rule [dest_port] +ap_rule() { + rule="firewall.fips_ap_$1" + uci -q delete "$rule" + uci set "$rule=rule" + uci set "$rule.name=$2" + uci set "$rule.src=fips_ap" + uci set "$rule.proto=$3" + uci set "$rule.target=ACCEPT" + [ -z "${4:-}" ] || uci set "$rule.dest_port=$4" +} + +ap_rule icmpv6 "FIPS-AP-ICMPv6" icmp +uci set firewall.fips_ap_icmpv6.family=ipv6 +ap_rule dhcpv4 "FIPS-AP-DHCPv4" udp 67 +uci set firewall.fips_ap_dhcpv4.family=ipv4 +ap_rule mdns "FIPS-AP-mDNS" udp 5353 +ap_rule fips_udp "FIPS-AP-FIPS-UDP" udp 2121 +ap_rule fips_tcp "FIPS-AP-FIPS-TCP" tcp 8443 + +uci commit wireless +uci commit network +uci commit dhcp +uci commit firewall +wifi reload +/etc/init.d/dnsmasq reload +/etc/init.d/odhcpd reload +/etc/init.d/firewall reload + +# Enable the matching ap transport in the shipped fips.yaml (it ships +# commented out). Tailor the restart hint to what we could do. +ap_config_enable "$IDX" +case $? in + 0) TRANSPORT_NOTE="The ap$IDX transport in $CONFIG that binds '$AP_IFNAME' is + now uncommented and enabled." ;; + 1) TRANSPORT_NOTE="No $CONFIG found — add a transports.ethernet entry binding + interface '$AP_IFNAME' by hand." ;; + *) TRANSPORT_NOTE="No 'ap$IDX' entry in $CONFIG — add a transports.ethernet + entry binding interface '$AP_IFNAME' by hand (copy the ap0 block)." ;; +esac + +# Phones discover the daemon via mDNS, not raw-Ethernet beacons — make sure +# the daemon-wide mDNS rendezvous is on. +if lan_rendezvous_enable; then + MDNS_NOTE="node.rendezvous.lan (mDNS) is enabled — phone FIPS apps + discover this router via DNS-SD." +else + MDNS_NOTE="Could not enable mDNS in $CONFIG — set + 'node.rendezvous.lan.enabled: true' by hand; phone FIPS apps rely + on it to discover this router." +fi + +cat <:` header and its ` # ` continuation lines, - /// leaving every other comment untouched. - fn uncomment_mesh_blocks(yaml: &str) -> String { + /// Mirror the setup helpers' block uncomment: strip the ` # ` prefix + /// from each `# :` header and its ` # ` continuation + /// lines, leaving every other comment untouched. + fn uncomment_transport_blocks(yaml: &str, prefix: &str) -> String { + let header = format!(" # {prefix}"); let mut out = String::new(); let mut in_block = false; for line in yaml.lines() { let is_header = line - .strip_prefix(" # mesh") + .strip_prefix(&header) .and_then(|r| r.strip_suffix(':')) .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); if is_header {