diff --git a/docs/how-to/set-up-80211s-mesh-backhaul.md b/docs/how-to/set-up-80211s-mesh-backhaul.md index 78c19aaa..18c0bfc9 100644 --- a/docs/how-to/set-up-80211s-mesh-backhaul.md +++ b/docs/how-to/set-up-80211s-mesh-backhaul.md @@ -72,9 +72,9 @@ 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`, +IP configuration — none is needed), and reloads the radio. It does not +touch `/etc/fips/fips.yaml`: the matching `meshN` transport ships +enabled and the daemon binds the interface once it exists (see Step 2). Interfaces are named by radio index: `radio0` → `fips-mesh0`, `radio1` → `fips-mesh1`. Pass a second argument to use a different mesh ID. @@ -133,18 +133,20 @@ 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: +entry per radio, **enabled** and marked `optional: true`. The daemon +treats a named interface that is not there as absent rather than as a +failure, and `optional: true` is what keeps a stock install that never +runs this helper quiet and un-`Degraded` about a radio it was never +going to have. 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: ```yaml transports: ethernet: mesh0: interface: "fips-mesh0" + optional: true listen: true announce: true auto_connect: true @@ -161,19 +163,33 @@ transports: parses as an alias, so an existing config keeps working (see [../reference/configuration.md](../reference/configuration.md)). -## Step 3 — restart the daemon (order matters) +## Step 3 — no restart needed + +The daemon binds an interface when it appears. A transport whose +interface is missing is *absent*, not skipped: it waits, binds within +a second of the interface coming up, unbinds if it goes away, and +rebinds when it returns. Order does not matter, and neither +`/etc/init.d/fips restart` nor any hotplug rule is part of this +procedure. + +Watch it happen: + +```sh +fipsctl show transports +``` + +The transport's `interface` block reports `presence` (`absent` / +`binding` / `present`), `policy` (`required` / `optional`) and how +long it has held that state. + +If you *changed a config value* above rather than only creating an +interface, that does need a restart — configuration is read at +startup, interfaces are not: ```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 diff --git a/docs/how-to/set-up-open-access-ssid.md b/docs/how-to/set-up-open-access-ssid.md index b4b0d01b..9124be53 100644 --- a/docs/how-to/set-up-open-access-ssid.md +++ b/docs/how-to/set-up-open-access-ssid.md @@ -161,15 +161,17 @@ 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: +entry per access interface, **enabled** and marked `optional: true` — +the daemon binds the interface once `fips-ap-setup` creates it, and +`optional: true` keeps a stock install that never runs the helper quiet +and un-`Degraded`. `fips-ap-setup` does still edit one thing: it enables +`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). That one is a config value rather than an interface, so it needs a +restart to take effect. Otherwise 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: ```yaml node: @@ -183,6 +185,7 @@ transports: ethernet: ap0: interface: "fips-ap0" + optional: true listen: true announce: true auto_connect: true @@ -199,19 +202,33 @@ transports: parses as an alias, so an existing config keeps working (see [../reference/configuration.md](../reference/configuration.md)). -## Step 3 — restart the daemon (order matters) +## Step 3 — no restart needed + +The daemon binds an interface when it appears. A transport whose +interface is missing is *absent*, not skipped: it waits, binds within +a second of the interface coming up, unbinds if it goes away, and +rebinds when it returns. Order does not matter, and neither +`/etc/init.d/fips restart` nor any hotplug rule is part of this +procedure. + +Watch it happen: + +```sh +fipsctl show transports +``` + +The transport's `interface` block reports `presence` (`absent` / +`binding` / `present`), `policy` (`required` / `optional`) and how +long it has held that state. + +If you *changed a config value* above rather than only creating an +interface, that does need a restart — configuration is read at +startup, interfaces are not: ```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`: diff --git a/packaging/openwrt-ipk/files/etc/fips/fips.yaml b/packaging/openwrt-ipk/files/etc/fips/fips.yaml index 6bb09aee..bc38b976 100644 --- a/packaging/openwrt-ipk/files/etc/fips/fips.yaml +++ b/packaging/openwrt-ipk/files/etc/fips/fips.yaml @@ -101,6 +101,9 @@ transports: accept_connections: true wwan: interface: "phy0-sta0" + # Only exists while a radio is in station mode. Absence is normal, so + # it must not report the router Degraded. + optional: true listen: true announce: true auto_connect: true @@ -113,54 +116,55 @@ transports: 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" - # listen: true - # announce: true - # auto_connect: true - # accept_connections: true - # mesh1: - # interface: "fips-mesh1" - # listen: true - # announce: true - # auto_connect: true - # accept_connections: true + # ENABLED and marked 'optional: true'. The daemon treats a named + # interface that is not there as absent rather than as a failure: it + # waits, binds the moment 'fips-mesh-setup ' creates the + # interface, and unbinds again if it goes away — no config edit and no + # restart, and 'optional: true' keeps a stock install that never runs + # that script quiet and un-Degraded. + # + # 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" + optional: true + listen: true + announce: true + auto_connect: true + accept_connections: true + mesh1: + interface: "fips-mesh1" + optional: true + listen: true + announce: true + 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" - # listen: true - # announce: true - # auto_connect: true - # accept_connections: true - # ap1: - # interface: "fips-ap1" - # listen: true - # announce: true - # auto_connect: true - # accept_connections: true + # Open "!FIPS" access SSID for phones and laptops running FIPS. Ships + # ENABLED and marked 'optional: true', for the same reason as the mesh + # blocks above: 'fips-ap-setup ' creates the interface and the + # daemon binds it when it appears, with no config edit and no restart. + # + # 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" + optional: true + listen: true + announce: true + auto_connect: true + accept_connections: true + ap1: + interface: "fips-ap1" + optional: true + listen: true + announce: true + auto_connect: true + accept_connections: true # Bluetooth Low Energy transport — requires BlueZ and the 'ble' feature. # ble: diff --git a/packaging/openwrt-ipk/files/usr/bin/fips-ap-setup b/packaging/openwrt-ipk/files/usr/bin/fips-ap-setup index 3551730e..fe3935b0 100755 --- a/packaging/openwrt-ipk/files/usr/bin/fips-ap-setup +++ b/packaging/openwrt-ipk/files/usr/bin/fips-ap-setup @@ -43,14 +43,17 @@ # 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. +# 'transports.ethernet' bound to these names, enabled and marked +# 'optional: true'. The daemon treats a named interface that is not there as +# ABSENT rather than as a start failure: it waits, binds the moment the +# interface appears, and unbinds again when it goes away — so creating the +# interface is all this helper has to do for the transport, with no config +# rewrite and no daemon restart. +# +# It does still edit one thing: the node.rendezvous.lan block (mDNS/DNS-SD — +# how phone FIPS apps discover the daemon). That is a config value rather than +# an interface, so it does need a restart to take effect. The switch is +# daemon-wide and stays on at remove. # See docs/how-to/set-up-open-access-ssid.md for the full guide. DEFAULT_SSID="!FIPS" @@ -63,19 +66,14 @@ 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() { +# Whether $CONFIG carries an enabled 'ap' transports.ethernet block. +# Reports only; the daemon owns the binding. Returns: +# 0 present 1 no config file 2 no such block +ap_config_present() { 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 + grep -q "^ ap$idx:" "$CONFIG" || return 2 + return 0 } # Uncomment the 'lan' block under node.rendezvous in $CONFIG — the daemon's @@ -107,19 +105,6 @@ lan_rendezvous_enable() { ' "$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 @@ -154,10 +139,9 @@ if [ "$1" = "remove" ]; then 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" + # The fips.yaml block stays as it is. The daemon notices the + # interface going away, unbinds, and waits for it — silently, + # because the block is marked 'optional: true'. echo "Removed ${ifname:-$section}." done # Drop the shared zone and its rules once the last instance is gone. @@ -356,12 +340,12 @@ wifi 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" +# Report whether the shipped fips.yaml still carries the matching transport. +# It ships enabled, so this is a check, not an edit. +ap_config_present "$IDX" case $? in - 0) TRANSPORT_NOTE="The ap$IDX transport in $CONFIG that binds '$AP_IFNAME' is - now uncommented and enabled." ;; + 0) TRANSPORT_NOTE="The ap$IDX transport in $CONFIG binds '$AP_IFNAME'; the + daemon picks the interface up on its own, with no restart." ;; 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 @@ -398,9 +382,9 @@ per SSID, so it covers every FIPS router. Next steps: 1. $TRANSPORT_NOTE + Watch it bind with: fipsctl show transports 2. $MDNS_NOTE - Restart the daemon AFTER the interface is up — a transport whose - interface is missing at startup is skipped, not retried: + mDNS is a config value, not an interface, so it needs a restart: /etc/init.d/fips restart 3. Associate a phone or laptop running FIPS and verify: iw dev $AP_IFNAME station dump diff --git a/packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup b/packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup index 55ec8dd3..58d7b767 100755 --- a/packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup +++ b/packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup @@ -27,49 +27,26 @@ # 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. +# 'transports.ethernet' bound to these names, enabled and marked +# 'optional: true'. The daemon treats a named interface that is not there as +# ABSENT rather than as a start failure: it waits, binds the moment the +# interface appears, and unbinds again when it goes away. So this helper +# creates the interface and nothing else — no config rewrite, and no daemon +# restart. 'optional: true' is what keeps a stock install that never runs this +# script from reporting Degraded for an interface it was never going to have. # 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() { +# Whether $CONFIG carries an enabled 'mesh' transports.ethernet block. +# Reports only; the daemon owns the binding. Returns: +# 0 present 1 no config file 2 no such block +mesh_config_present() { 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 + grep -q "^ mesh$idx:" "$CONFIG" || return 2 + return 0 } usage() { @@ -103,10 +80,9 @@ if [ "$1" = "remove" ]; then 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" + # The fips.yaml block stays as it is. The daemon notices the + # interface going away, unbinds, and waits for it — silently, + # because the block is marked 'optional: true'. echo "Removed ${ifname:-$section}." done uci commit wireless @@ -114,7 +90,7 @@ if [ "$1" = "remove" ]; then # '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" + echo "No fips restart needed — the daemon unbinds the interface itself." exit 0 fi @@ -226,12 +202,12 @@ uci commit network # 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" +# Report whether the shipped fips.yaml still carries the matching transport. +# It ships enabled, so this is a check, not an edit. +mesh_config_present "$IDX" case $? in - 0) TRANSPORT_NOTE="The mesh$IDX transport in $CONFIG that binds '$MESH_IFNAME' is - now uncommented and enabled." ;; + 0) TRANSPORT_NOTE="The mesh$IDX transport in $CONFIG binds '$MESH_IFNAME'; the + daemon picks the interface up on its own." ;; 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 @@ -248,9 +224,9 @@ radio too — second band is a standby path (failover, not multipath). Next steps: 1. $TRANSPORT_NOTE - Restart the daemon AFTER the interface is up — a transport whose - interface is missing at startup is skipped, not retried: - /etc/init.d/fips restart + No restart: the daemon binds an interface when it appears and + rebinds it if it goes away. Watch it happen with: + fipsctl show transports 2. Verify L2 peering with a second FIPS router in range: iw dev $MESH_IFNAME station dump and the FIPS link on top of it: diff --git a/src/config/mod.rs b/src/config/mod.rs index 67491aa6..9823afc0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1353,83 +1353,65 @@ node: } /// The fips.yaml shipped in the OpenWrt package must keep parsing as the - /// config schema evolves. Both the 802.11s mesh backhaul entries + /// config schema evolves, and must keep the absence policy it depends on. + /// + /// The 802.11s mesh backhaul entries /// (docs/how-to/set-up-80211s-mesh-backhaul.md) and the open-access SSID - /// entries (docs/how-to/set-up-open-access-ssid.md) ship commented out — - /// one per radio, so dual-band routers can run either on both bands — so - /// a stock install that never creates fips-mesh*/fips-ap* logs no - /// per-boot bind warning; `fips-mesh-setup`/`fips-ap-setup` uncomment the - /// matching block when they create the interface. Verify both states - /// parse: as shipped (both inactive), and after the uncomment the helpers - /// perform. + /// entries (docs/how-to/set-up-open-access-ssid.md) ship **enabled** — one + /// per radio, so dual-band routers can run either on both bands — and + /// marked `optional: true`. They used to ship commented out, with + /// `fips-mesh-setup`/`fips-ap-setup` uncommenting the matching block; that + /// was a workaround for a transport whose missing interface was skipped + /// for the life of the process. The daemon now waits for the interface and + /// binds it when it appears, and `optional: true` is what keeps a stock + /// install that never runs those helpers quiet and un-`Degraded` about a + /// radio it was never going to have. #[test] fn shipped_openwrt_config_parses() { let yaml = include_str!("../../packaging/openwrt-ipk/files/etc/fips/fips.yaml"); - - // As shipped: parses, and the mesh/ap entries are commented out (a - // running daemon binds no fips-mesh*/fips-ap* transport, no warning). let config: Config = serde_yaml::from_str(yaml).expect("shipped OpenWrt fips.yaml"); - for name in ["mesh0", "mesh1", "ap0", "ap1"] { - assert!( - !config - .transports - .ethernet - .iter() - .any(|(n, _)| n == Some(name)), - "{name} must ship commented out, not active, in fips.yaml" - ); - } - // What `fips-mesh-setup`/`fips-ap-setup` produce: uncomment each - // block, which must still parse into a transport bound to the right - // netdev. - let uncommented = - uncomment_transport_blocks(&uncomment_transport_blocks(yaml, "mesh"), "ap"); - let config: Config = serde_yaml::from_str(&uncommented) - .expect("fips.yaml with mesh and ap transports uncommented"); + let eth = |name: &str| { + config + .transports + .ethernet + .iter() + .find(|(n, _)| *n == Some(name)) + .map(|(_, cfg)| cfg.clone()) + }; + + // The interfaces the setup helpers create: present, bound to the right + // netdev, and optional. A regression to `optional: false` here would + // report every stock router `Degraded` for a mesh it never configured. for (name, interface) in [ ("mesh0", "fips-mesh0"), ("mesh1", "fips-mesh1"), ("ap0", "fips-ap0"), ("ap1", "fips-ap1"), ] { + let cfg = eth(name).unwrap_or_else(|| panic!("{name} entry missing from fips.yaml")); + assert_eq!(cfg.interface, interface, "{name} binds the wrong netdev"); + assert!(cfg.optional(), "{name} must ship optional"); + } + + // `phy0-sta0` only exists while a radio is in station mode. + assert!( + eth("wwan").expect("wwan entry").optional(), + "wwan must ship optional" + ); + + // The wired ports exist on every supported board, so their absence is + // a real fault and must stay loud. Marking these optional too would + // make the whole ethernet block silent, which is the failure mode the + // presence mechanism exists to stop hiding. + for name in ["wan", "lan"] { assert!( - config - .transports - .ethernet - .iter() - .any(|(n, eth)| n == Some(name) && eth.interface == interface), - "{name} entry missing after uncommenting shipped fips.yaml" + !eth(name).expect("wired entry").optional(), + "{name} must stay required" ); } } - /// 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(&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 { - 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#"