From 6b3d8dae25809d06548da534054f3ee6037defd3 Mon Sep 17 00:00:00 2001 From: felixfelix-bot Date: Sun, 27 Sep 2026 02:03:40 +0200 Subject: [PATCH] fix(packaging): the wired LAN ports get a bridge of their own, scoped to the admin surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wired LAN ports were members of the captive bridge, so the two administration guards dropped the board (:8090/:8443) and LuCI (:8080/:443) for the operator's own cable exactly as they do for a stranger on the open guest SSID, and that port shared a layer-2 domain with those strangers — the exposure this repo measured as unclosable by bridge-port isolation. setup_mgmt_bridge in 99-tollgate-setup now MOVES the port list the base image writes on the captive bridge onto a new br-mgmt (it never names a port, and a router with no ports anywhere is refused with an ERROR and no writes at all), gives the bridge its own firewall.mgmt_zone with no forwarding to the wan, and serves DHCP on dhcp.mgmt. The writer is re-asserted on the same-version (reinstall/upgrade) path as well as full setup, because a factory reset or `sysupgrade -n` regenerates the image-owned port list on the captive bridge. The new 33-mgmt-bridge-scope.nft scopes the bridge to an allow list — DHCP/DNS, SSH, :443/:8080/:8090/:8443 — with a catch-all drop, so the payment surfaces (:2050/:2051/:2121) are unreachable from the cable. One nodogsplash gates one bridge, and a network this module cannot gate must not take money it cannot deliver: a br-mgmt client resolves a MAC, can be quoted and can pay, and the grant then fails in the single instance's ndsctl with `Client not found.`. A cable into a LAN port is therefore an ADMINISTRATION port: admin surfaces before paying, no internet at all, internet still bought on the wireless network. The guest path is unchanged — the guest APs stay on the captive bridge, the pre-auth allow list, the portal and both guards keep their scope — and the scope is pinned in both directions by tests (nothing may extend 31-*.nft or 32-*.nft to br-mgmt, and nothing may add a second captive gate). Decision, evidence and the rejected alternatives (including why a second nodogsplash instance is the dangerous wrong answer): docs/architecture/lan-port-management-bridge-decision.md (PR #599). docs/ rc-tester-guide.md 7 now tells a tester what a cabled client can and cannot reach. Tests: tests/uci-defaults-mgmt-bridge_test.sh 57/0 (30 assertions RED against the pristine script) and tests/packaging/mgmt-bridge-scope_test.sh 37/0 (11 RED against the pristine tree); the rest of the offline uci-defaults and packaging tier is unchanged. No Go file is touched. --- .github/workflows/test.yml | 44 +- .ngit/act/workflows/test.yml | 43 +- CHANGELOG.md | 44 ++ docs/rc-tester-guide.md | 19 + packaging/Makefile | 3 +- .../etc/nftables.d/33-mgmt-bridge-scope.nft | 86 +++ .../files/etc/uci-defaults/99-tollgate-setup | 341 +++++---- packaging/local-build-ipk.sh | 1 + tests/packaging/mgmt-bridge-scope_test.sh | 334 +++++++++ tests/uci-defaults-lan-private-wired_test.sh | 658 ------------------ tests/uci-defaults-mgmt-bridge_test.sh | 617 ++++++++++++++++ 11 files changed, 1346 insertions(+), 844 deletions(-) create mode 100644 packaging/files/etc/nftables.d/33-mgmt-bridge-scope.nft create mode 100644 tests/packaging/mgmt-bridge-scope_test.sh delete mode 100755 tests/uci-defaults-lan-private-wired_test.sh create mode 100644 tests/uci-defaults-mgmt-bridge_test.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74c1d664a..92bbbc043 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -179,23 +179,35 @@ jobs: # both setup paths re-assert it. bash tests/uci-defaults-trusted-entry-80_test.sh - - name: The wired LAN ports move onto the operator's private bridge + - name: The wired LAN ports get their own management bridge run: | - # Offline (no router, no daemon, no network): the wired LAN ports - # must be MOVED off the captive bridge onto br-private - the - # operator's trusted network with internet, the admin board and - # LuCI, no payment step - discovered from the bridge's device - # section (never named per board), idempotently, with the captive - # bridge's port list cleared so a port is never on two layer-2 - # domains. The same-version verify/repair path re-asserts the - # placement (a factory reset is repaired), the module keep-list - # still carries /etc/config/network, and the guard fragments are - # pinned by digest (three byte-identical to main; the :2121 - # backend-firewall guard updated on purpose so the owner network can - # read the admin board's data) and still br-lan-scoped - that literal - # is what keeps a guest off :8090/:8443 and LuCI while a br-private - # client gets in. A neutralised-writer negative control must go red. - bash tests/uci-defaults-lan-private-wired_test.sh + # Offline (no router, no daemon, no network): the wired LAN ports must + # be MOVED off the captive bridge onto br-mgmt, the bridge must own a + # zone with no forwarding to the wan, and it must be a MANAGEMENT + # network - the payment surfaces (:2050/:2051/:2121) stay unreachable + # from a cable, because one nodogsplash gates one bridge and a network + # this module cannot gate must not sell (the grant would fail in the + # single instance's ndsctl with `Client not found.`: money taken, + # nothing delivered). The suite drives the shipped uci-defaults writer + # end to end against a fixture, pins the refusal when there is nothing + # to move, pins the re-assertion on the reinstall/upgrade path, and + # carries four negative controls that must each go red when the line + # they pin is removed. + bash tests/uci-defaults-mgmt-bridge_test.sh + + - name: The wired bridge is scoped to the administration surfaces + run: | + # Offline (no router, no SDK, no network; the `nft -c` half skips + # loudly where CAP_NET_ADMIN is unavailable): the new + # 33-mgmt-bridge-scope.nft fragment must be an allow list - DHCP/DNS, + # SSH, :443/:8080/:8090/:8443 - closed by a catch-all drop, and it must + # be installed by BOTH packaging recipes and present in a package built + # from them. The suite also pins the direction that must NOT move: the + # two administration guards (31-*.nft, 32-*.nft) and their :8080/:8090/ + # :8443 ports stay scoped to the CAPTIVE bridge, while they answer on + # br-mgmt - and it refuses an allow list that re-opens a payment + # surface (two of its RED controls do exactly that). + bash tests/packaging/mgmt-bridge-scope_test.sh - name: The backend API answers the owner network (admin board data path) run: | diff --git a/.ngit/act/workflows/test.yml b/.ngit/act/workflows/test.yml index ad15b47e7..522509b22 100644 --- a/.ngit/act/workflows/test.yml +++ b/.ngit/act/workflows/test.yml @@ -107,22 +107,35 @@ jobs: # both setup paths re-assert it. bash tests/uci-defaults-trusted-entry-80_test.sh - - name: The wired LAN ports move onto the operator's private bridge + - name: The wired LAN ports get their own management bridge run: | - # Offline (no router, no daemon, no network): the wired LAN ports - # must be MOVED off the captive bridge onto br-private - the - # operator's trusted network with internet, the admin board and - # LuCI, no payment step - discovered from the bridge's device - # section (never named per board), idempotently, with the captive - # bridge's port list cleared so a port is never on two layer-2 - # domains. The same-version verify/repair path re-asserts the - # placement (a factory reset is repaired), the module keep-list - # still carries /etc/config/network, and all four guard fragments - # are pinned byte-identical to main and still br-lan-scoped - that - # literal is what keeps a guest off :8090/:8443 and LuCI while a - # br-private client gets in. A neutralised-writer negative control - # must go red. - bash tests/uci-defaults-lan-private-wired_test.sh + # Offline (no router, no daemon, no network): the wired LAN ports must + # be MOVED off the captive bridge onto br-mgmt, the bridge must own a + # zone with no forwarding to the wan, and it must be a MANAGEMENT + # network - the payment surfaces (:2050/:2051/:2121) stay unreachable + # from a cable, because one nodogsplash gates one bridge and a network + # this module cannot gate must not sell (the grant would fail in the + # single instance's ndsctl with `Client not found.`: money taken, + # nothing delivered). The suite drives the shipped uci-defaults writer + # end to end against a fixture, pins the refusal when there is nothing + # to move, pins the re-assertion on the reinstall/upgrade path, and + # carries four negative controls that must each go red when the line + # they pin is removed. + bash tests/uci-defaults-mgmt-bridge_test.sh + + - name: The wired bridge is scoped to the administration surfaces + run: | + # Offline (no router, no SDK, no network; the `nft -c` half skips + # loudly where CAP_NET_ADMIN is unavailable): the new + # 33-mgmt-bridge-scope.nft fragment must be an allow list - DHCP/DNS, + # SSH, :443/:8080/:8090/:8443 - closed by a catch-all drop, and it must + # be installed by BOTH packaging recipes and present in a package built + # from them. The suite also pins the direction that must NOT move: the + # two administration guards (31-*.nft, 32-*.nft) and their :8080/:8090/ + # :8443 ports stay scoped to the CAPTIVE bridge, while they answer on + # br-mgmt - and it refuses an allow list that re-opens a payment + # surface (two of its RED controls do exactly that). + bash tests/packaging/mgmt-bridge-scope_test.sh - name: One device code (hostname, captive SSID and private SSID agree) run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8cc2a5d..a3060f71f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,50 @@ and [Semantic Versioning](https://semver.org/). > `v0.4.0` tag. ## [Unreleased] +### Changed / Internal + +- **The wired LAN ports move to a bridge of their own, `br-mgmt`: a cabled + operator reaches the administration surfaces before paying, and the cable + stops being a customer network.** The wired port was a member of the *captive* + bridge, so the two administration guards — `:8090`/`:8443` by + `31-admin-board-not-guest-reachable.nft`, `:8080`/`:443` by + `32-luci-not-guest-reachable.nft` — dropped the operator's own administration + surfaces for him exactly as they do for a stranger on the open guest SSID, and + the port shared a layer-2 domain with those strangers (the exposure + `99-tollgate-setup` records as unclosable by bridge-port isolation). The new + `setup_mgmt_bridge` writer in `99-tollgate-setup` now **moves** the port list + the base image writes on the captive bridge onto `br-mgmt`; it never names a + port (`eth1`/`lan1` differ per board — it moves what it finds, and a bridge + with no ports anywhere is refused with an ERROR and **no writes at all**, + because a portless `br-mgmt` would report a management bridge the operator has + no way into). The bridge gets its own `firewall.mgmt_zone` with **no forwarding + to the `wan`** (it does not reuse `firewall.private_zone`, whose `forward + ACCEPT` plus `private → wan` forwarding would hand the cable free internet) and + `dhcp.mgmt` so the laptop has a lease. `33-mgmt-bridge-scope.nft` scopes it to + an allow list — DHCP/DNS, SSH and `:443`/`:8080`/`:8090`/`:8443` — with a + catch-all drop, which is what makes **"this bridge cannot transact" + structural rather than incidental: `:2050`, `:2051` and `:2121` are + deliberately not reachable from the cable**, because with one nodogsplash one + bridge has no captive gate, and a client on a bridge this module cannot gate + would be sold a session that the single instance's `ndsctl` then refuses + (`Client not found.` — money taken, nothing delivered). A wired client + therefore has **no internet at all**; internet is still bought on the wireless + network, as before. The writer is re-asserted on the same-version + (reinstall/upgrade) path as well as full setup, because the port list is owned + by the base image: a factory reset or `sysupgrade -n` puts the wired port back + on the captive bridge and this path converges it again. The guest path is + untouched: the guest APs stay on the captive bridge, the pre-auth allow list, + the portal and both guards keep their scope, and `31-*.nft`/`32-*.nft` are + **pinned to the captive bridge** (with `20-nds-enforce.nft` and + `30-backend-firewall.nft`'s `:2121` ACL) so neither direction can be extended + onto the other bridge without a test failing. What the operator does **not** + get yet is a wired *customer* network: a paywalled second bridge needs a real + second gate (per-instance nodogsplash chains and control sockets, or a + module-owned nft gate), which is the follow-up the decision record names. + `docs/rc-tester-guide.md` §7 now tells a tester what a cabled client can and + cannot reach + ([#601](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/601)). + ## [v0.6.0-rc1] - 2026-10-05 ### Added diff --git a/docs/rc-tester-guide.md b/docs/rc-tester-guide.md index 5271d730b..162ec6539 100644 --- a/docs/rc-tester-guide.md +++ b/docs/rc-tester-guide.md @@ -473,6 +473,25 @@ the certificate uhttpd serves actually covers the address you used. So: covering identity on the entry point says nothing about the board's TLS listener: a **name mismatch** there is reportable on its own, and it is not the same finding as a mismatch on `https://.lan/`. +- The TollGate admin board has its **own** listeners — `http://:8090/` + and `https://:8443/` — on a separate uhttpd instance from LuCI's + `:8080`/`:443`. **Which client can reach them depends on where you plugged in:** + - **Wired into a LAN port** you are on the management bridge `br-mgmt`, and the + administration surfaces answer you **before you pay anything**: `:8090` + (board), `:8080` and `:443` (LuCI), `:8443` (the board over TLS if it is + enabled) and `:22`. Find the address with `uci get network.mgmt.ipaddr` — the + wired ports do **not** share the captive bridge's address, so + `http://.lan/` is not the URL to use from the cable. The same bridge + gives you **no internet at all** (`ping 9.9.9.9` fails, and `:2121`/`:2050`/ + `:2051` are dropped, so a purchase cannot even be started): with one + nodogsplash, internet is bought on the wireless network, exactly as before. + A cable into a LAN port is an administration port, not a customer one. + - **On the guest SSID** the guard still drops `:8090`/`:8443`/`:8080`/`:443` + (measured on the bench: tcp 8090/8443 dropped for a LAN/guest client), so + "connection refused" there is that guard, not TLS. Also true for any client + still on the captive bridge `br-lan`. + - **On the private SSID** (`br-private`, which nodogsplash does not gate) both + the board and LuCI answer, unchanged. --- diff --git a/packaging/Makefile b/packaging/Makefile index 033ee525f..6d152c5c9 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -244,7 +244,8 @@ FILES_$(PKG_NAME) += \ /etc/nftables.d/20-nds-enforce.nft \ /etc/nftables.d/30-backend-firewall.nft \ /etc/nftables.d/31-admin-board-not-guest-reachable.nft \ - /etc/nftables.d/32-luci-not-guest-reachable.nft + /etc/nftables.d/32-luci-not-guest-reachable.nft \ + /etc/nftables.d/33-mgmt-bridge-scope.nft $(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/packaging/files/etc/nftables.d/33-mgmt-bridge-scope.nft b/packaging/files/etc/nftables.d/33-mgmt-bridge-scope.nft new file mode 100644 index 000000000..99fbfc16f --- /dev/null +++ b/packaging/files/etc/nftables.d/33-mgmt-bridge-scope.nft @@ -0,0 +1,86 @@ +#!/usr/sbin/nft -f + +# Scope the wired management bridge (br-mgmt) to the administration surfaces. +# +# This include is loaded inside fw4's `inet` table (same shape as +# 20-nds-enforce.nft, 30-backend-firewall.nft, 31-admin-board-not-guest-reachable.nft +# and 32-luci-not-guest-reachable.nft), so it runs on `fw4 reload` and survives a +# nodogsplash restart. +# +# WHAT br-mgmt IS. The wired LAN ports are moved off the captive bridge onto a +# bridge of their own by `setup_mgmt_bridge` in 99-tollgate-setup, so the port an +# operator administers from stops sharing a layer-2 domain with the strangers on +# the open guest SSID, and so the administration surfaces answer his cable +# before he pays anything. The decision, the evidence and the rejected +# alternatives are docs/architecture/lan-port-management-bridge-decision.md +# (PR #599). This fragment is its D4/D5 half: the bridge is an ALLOW LIST of +# administration surfaces and nothing else. +# +# WHY AN ALLOW LIST AND NOT A BLACKLIST. Because it has to carry a property a +# blacklist cannot state: br-mgmt cannot transact. One nodogsplash process gates +# exactly one bridge (a single `gw_interface`, fixed shared `nds*` chain names, +# one control socket), so the wired bridge has NO captive gate. A client on a +# bridge this module cannot gate must not be able to buy: it resolves a MAC +# through the dnsmasq lease it gets from `dhcp.mgmt`, can be quoted, can pay, and +# the grant then fails in the single instance's `ndsctl` answer +# `Client not found.` — money taken, nothing delivered. So the payment +# surfaces (`:2050`, `:2051`, `:2121`) are deliberately NOT in the allow list, +# and the catch-all drop below is what makes that structural rather than +# incidental: `:2121` is a bridge that sells. Reopening any of the three belongs +# to the F1/F2 follow-up in the ADR (a real second gate), not to a wider list. +# +# WHY THE ADMIN PORTS ARE REACHABLE HERE. `:443`/`:8080` (LuCI, uhttpd.main) and +# `:8090`/`:8443` (the TollGate board, uhttpd.admin) are dropped on the CAPTIVE +# bridge by 32-luci-not-guest-reachable.nft and +# 31-admin-board-not-guest-reachable.nft, both of which match `iifname "br-lan"`. +# Those guards are about a stranger on an open SSID — the board is a +# root-capable login (`file:["exec"]`, `system:["password_set"]`, +# `tollgate wallet_drain_cashu` behind rpcd's session) and :8090 serves it over +# cleartext HTTP. br-mgmt holds no stranger: reaching it means walking to the +# router and plugging in a cable, which is the same trust boundary as the +# operator's own hands. That is why the four ports are reachable here and stay +# dropped there, and why this fragment must never be extended to `br-lan` and the +# two guards must never be extended to `br-mgmt` (both directions are pinned by +# tests/packaging/mgmt-bridge-scope_test.sh). +# +# WHAT IS DROPPED, INCLUDING BY THE CATCH-ALL: everything that is not DHCP, DNS, +# SSH or one of the four administration ports. No forwarding to the WAN exists +# for this bridge either (`firewall.mgmt_zone` has `forward 'REJECT'` and no +# `config forwarding`), so the cable has no internet at all — that is the +# deliberate consequence of "one gate" that makes the payment-surface half +# honest, not an oversight. ICMP is dropped too: a ping to the router's own +# address fails by design here; `:22` is the diagnostic path. +# +# CAVEAT, same as 30-backend-firewall.nft:15-17 — this assumes the wired +# management bridge is named br-mgmt, which is what `setup_mgmt_bridge` writes. +# An operator who renamed it loses this rule and nothing else (the ports then +# fall back to the zone's input policy). + +chain mgmt_bridge_scope { + type filter hook input priority -1; policy accept + + # What a client needs to exist on this bridge at all: a lease and DNS. + meta nfproto ipv4 iifname "br-mgmt" udp dport { 53, 67, 68 } counter accept + meta nfproto ipv4 iifname "br-mgmt" tcp dport 53 counter accept + + # SSH: one of the two documented recovery paths (the other is the private + # SSID), and the only diagnostic that works here because ICMP is dropped. + meta nfproto ipv4 iifname "br-mgmt" tcp dport 22 counter accept + + # The four administration surfaces: LuCI (:8080, and the :443 TLS listener + # uhttpd.main opens when a cert/key exists) and the board (:8090, and its + # opt-in :8443 TLS listener). See the header for why they are reachable + # here and nowhere else. + meta nfproto ipv4 iifname "br-mgmt" tcp dport { 443, 8080, 8090, 8443 } counter accept + + # No IPv6 is configured on this bridge (`network.mgmt.ip6assign='0'`, + # `dhcp.mgmt.ra`/`dhcpv6` disabled — the same posture the captive bridge + # takes in setup_disable_ipv6_lan), so nothing legitimate arrives over IPv6 + # and the family is dropped outright rather than left to the zone policy. + meta nfproto ipv6 iifname "br-mgmt" counter drop + + # The tail: everything else from the wired bridge, including the payment + # surfaces (`:2050`, `:2051`, `:2121`) and any path off the router that is + # not one of the accepts above. Keep it last. + meta nfproto ipv4 iifname "br-mgmt" counter drop +} diff --git a/packaging/files/etc/uci-defaults/99-tollgate-setup b/packaging/files/etc/uci-defaults/99-tollgate-setup index e5130aa22..9dbcb94eb 100755 --- a/packaging/files/etc/uci-defaults/99-tollgate-setup +++ b/packaging/files/etc/uci-defaults/99-tollgate-setup @@ -2138,161 +2138,179 @@ setup_private_network() { uci set firewall.private_forwarding.dest='wan' } -# -- the wired LAN ports: moved onto the operator's private bridge ---------- -# -# The wired LAN ports are the one network this writer MOVES. The base image -# puts them on the CAPTIVE bridge (network.lan.device, br-lan) — the same -# layer-2 domain as the open guest SSID and the captive portal — so a cabled -# client was a guest: it paid at the portal, and the two administration -# guards (31-admin-board-not-guest-reachable.nft, 32-luci-not-guest-reachable -# .nft, both `iifname "br-lan"`-literal) dropped the admin board -# (:8090/:8443) and LuCI (:8080/:443) for it. The operator's criterion for -# this release is the opposite: the physical LAN port is the operator's own -# trusted access — free internet, the admin board, and LuCI, with no payment -# step. br-private is exactly that network (firewall.private_zone is input/ -# forward ACCEPT and carries a private -> wan forwarding, and both admin -# guards answer there because they only drop on the captive bridge), so the -# ports MOVE onto it: -# -# * internet: firewall.private_forwarding (private -> wan) is written by -# setup_private_network above; -# * the admin board and LuCI answer: the guards' `iifname "br-lan"` scope -# keeps them OFF only for the captive bridge — on br-private a client is -# owner-class, deliberately (see the PR body for the trust discussion); -# * the public TollGate-* SSIDs stay on the captive bridge behind the -# portal, unchanged, and guests still cannot reach the board or LuCI. -# -# DISCOVERED, NEVER NAMED. The port list is IMAGE-OWNED (/bin/config_generate -# writes `list ports` from board.json — `eth1` on the MT3000, `lan1…lan5` -# elsewhere), so no port name appears here: the writer reads the ports the -# captive bridge's device section lists RIGHT NOW and moves that set. The -# section itself is anonymous in a shipped config, so it is located by NAME -# (bridge_device_section), never by a guessed index. -# -# IDEMPOTENT AND CONVERGENT BY CONSTRUCTION. A factory reset or a -# `sysupgrade -n` puts the ports back on the captive bridge; this writer is -# re-run on every setup path (full setup and the same-version verify/repair -# path), so a converged router is a no-op, a reset router is repaired, and a -# port is never added twice (the target's existing list is checked first). -# A port also never sits on two bridges at once: the captive section's port -# list is CLEARED after the private bridge's is written, so the move cannot -# leave a port in two layer-2 domains. -# -# FAIL LOUDLY, CHANGE NOTHING. The preconditions are checked before the -# first `uci set`: no private bridge (setup_private_network skipped — no -# usable radio), or no captive device section to read the ports from, or no -# port listed anywhere (nothing to move). Each logs an ERROR and leaves the -# ports where they are — a writer that guessed would move the operator's -# only cable access onto a network he cannot reach. -# -# UPGRADE SURVIVAL. The placement lives in /etc/config/network, which -# packaging/files/lib/upgrade/keep.d/tollgate already lists, so a settings- -# keeping sysupgrade carries it; the writer's convergence then repairs the -# reset (non-keeping) cases. - -# The device SECTION that carries . Device sections are -# anonymous in a shipped config (/bin/config_generate writes -# `add network device` + `option name 'br-lan'`), so the section is located -# by NAME, never by an index we guess. -bridge_device_section() { # -> the section path, or nothing - local want="$1" idx=0 - while [ "$idx" -lt 64 ]; do - if [ "$(uci -q get "network.@device[$idx].name" 2>/dev/null)" = "$want" ]; then - printf 'network.@device[%s]\n' "$idx" - return 0 - fi - idx=$((idx + 1)) - done - return 1 -} - -# Every port listed on
, in order, each one once. -ports_of_section() { #
- local p seen="" - for p in $(uci -q get "$1.ports" 2>/dev/null); do - case "$seen" in - *" $p "*) continue ;; - esac - seen="$seen $p" - printf '%s\n' "$p" - done -} - -setup_lan_ports_private() { - local lan_dev private_dev captive_sec ports p target_ports landed missing from="" +# -- the wired LAN ports: their own bridge ------------------------------------ +# +# The wired LAN ports are MOVED off the captive bridge onto one of their own +# (br-mgmt). Two things come out of that, and a third deliberately does not: +# +# * the lockout ends. The two administration guards +# (31-admin-board-not-guest-reachable.nft, 32-luci-not-guest-reachable.nft) +# and nodogsplash's pre-auth allow list are all scoped to the CAPTIVE bridge +# by name, so on br-mgmt the board (:8090/:8443) and LuCI (:8080/:443) answer +# before anything is paid — see 33-mgmt-bridge-scope.nft for why that is the +# right trust boundary for a cable and not for an open SSID; +# * the sharing ends. The operator's port stops sitting in the same layer-2 +# domain as the strangers on the open guest SSID — the exposure the +# isolation comment above records as unclosable by bridge-port isolation +# (the wired port is the one the repo could not separate, and the mechanism +# that comment names is a dedicated bridge); +# * the wired port does NOT become a selling network. One nodogsplash gates +# one bridge, so br-mgmt has no captive gate; a second instance is startable +# and unsafe (shared `nds*` chain names, name-scoped teardown, one control +# socket) and its failure mode is a bridge with free internet. br-mgmt is a +# MANAGEMENT network: no forwarding to the WAN, and the payment surfaces +# (:2050, :2051, :2121) kept off it, because a network this module cannot +# gate must not take money it cannot deliver (the paid purchase would fail +# in the single instance's `ndsctl` with `Client not found.`). A +# paywalled wired network is the F1/F2 follow-up in the ADR, not this +# writer. Decision, evidence and rejected alternatives: +# docs/architecture/lan-port-management-bridge-decision.md (PR #599). +# +# IDEMPOTENT AND CONVERGENT BY CONSTRUCTION. The port list is IMAGE-OWNED +# (/bin/config_generate writes `list ports` from board.json), so a factory reset +# or a `sysupgrade -n` puts the wired port back on the captive bridge and +# nothing else in this repository would notice. The writer therefore reads what +# is on the captive bridge's device section RIGHT NOW, appends whatever is not +# already on br-mgmt, and clears the captive bridge's list — a converged router +# is a no-op, a reset router is repaired, and the port is never named +# (eth1/lan1/... differ per board). +# +# FAIL LOUDLY, CHANGE NOTHING. The one case that cannot be repaired here is a +# captive bridge with no port list while br-mgmt holds none either: there is +# nothing to move, and writing a portless br-mgmt would report a management +# bridge the operator has no way into. That case logs an ERROR and writes +# nothing at all — every precondition is checked before the first `uci set`. +# +# The address: the bridge keeps whatever `network.mgmt.ipaddr` already says (a +# reinstall must not move an address the operator may have chosen), and takes +# the first free /24 next to the LAN's otherwise, skipping the private +# network's — br-private defaults to the LAN+1 case, and two bridges on one /24 +# is a broken router. Reuses the collision helpers above, so an upstream-side +# collision moves it the same way the private subnet moves. +setup_mgmt_bridge() { + local lan_dev sec idx ports mgmt_ports p converged=0 + local lan_ip lan_mask lan_prefix lan_octet3 occupied cand step octet existing_ip mgmt_ip mgmt_prefix lan_dev=$(uci -q get network.lan.device 2>/dev/null) [ -n "$lan_dev" ] || lan_dev="br-lan" - private_dev=$(uci -q get network.private_bridge.name 2>/dev/null) - [ -n "$private_dev" ] || private_dev="br-private" - - # Precondition: the private network must exist (setup_private_network - # writes the bridge; it is skipped on a router with no usable radio). - if ! uci -q get network.private_bridge >/dev/null 2>&1; then - log "ERROR: skipping the wired-LAN port move — no private bridge (network.private_bridge is absent; setup_private_network skipped?), so the wired LAN ports stay on ${lan_dev}. Nothing was changed." - return 1 - fi - # The ports to move are DISCOVERED from the captive bridge's device - # section (image-owned, board-specific names). - captive_sec=$(bridge_device_section "$lan_dev" 2>/dev/null) - if [ -z "$captive_sec" ]; then - log "ERROR: skipping the wired-LAN port move — no device section for the captive bridge ${lan_dev} (the base image writes one from the board definition), so there is no port list to read. Nothing was changed." - return 1 - fi - ports=$(ports_of_section "$captive_sec") - target_ports=$(uci -q get network.private_bridge.ports 2>/dev/null) - if [ -z "$ports" ]; then - # MEASURE, do not infer: an empty source list is a no-op only when the - # target really lists the ports. If neither bridge lists them the - # operator's cable is on no network at all, and reporting success - # there would hide it. - if [ -n "$target_ports" ]; then - log "Wired LAN ports already on ${private_dev} — nothing to move" - return 0 + # Device sections are anonymous in a shipped config (config_generate writes + # `add network device` + `option name 'br-lan'`), so the section that holds + # the wired ports is found by its name, not addressed by an index we guess. + sec="" + idx=0 + while [ "$idx" -lt 24 ]; do + if [ "$(uci -q get "network.@device[$idx].name" 2>/dev/null)" = "$lan_dev" ]; then + sec="network.@device[$idx]" + break fi - log "ERROR: the wired LAN ports are on neither bridge — ${lan_dev} and ${private_dev} both list no ports, so there is nothing to move and nothing to repair. Nothing was changed." - return 1 - fi - - # Write the target first (the target is never the section cleared - # below), adding each port exactly once. A write failure is not acted on - # here: the read-back below is the authority, because a silent failure - # and a success are indistinguishable at this point. - for p in $ports; do - case " $(echo $target_ports) " in - *" $p "*) : ;; - *) - uci add_list "network.private_bridge.ports=$p" 2>/dev/null - target_ports="$target_ports $p" - ;; - esac + idx=$((idx + 1)) done - # VERIFY BEFORE CLEARING. Clearing the source on an unverified write is - # how a port ends up on NO bridge and the operator's only cable access - # dies. Re-read the target and require every port to be present, matched - # on whole words (a target list of lan10 must not satisfy a port lan1). - landed=$(uci -q get network.private_bridge.ports 2>/dev/null) - missing="" - for p in $ports; do - case " $(echo $landed) " in - *" $p "*) : ;; - *) missing="$missing $p" ;; - esac - done - if [ -n "$missing" ]; then - log "ERROR: the wired LAN ports did not land on ${private_dev} (missing:$(printf ' %s' $missing)) — ${lan_dev} keeps its ports and nothing was cleared. The move is retried on the next setup run." + ports="" + [ -n "$sec" ] && ports=$(uci -q get "$sec.ports" 2>/dev/null) + mgmt_ports=$(uci -q get network.mgmt_bridge.ports 2>/dev/null) + + # ---- preconditions: nothing is written until all of them pass ---------- + if [ -z "$ports" ] && [ -z "$mgmt_ports" ]; then + log "ERROR: skipping the wired-LAN management bridge — ${lan_dev} declares no device ports and br-mgmt holds none, so there is nothing to move. The wired LAN ports stay on ${lan_dev}; nothing was changed." return 1 fi + if [ -z "$ports" ]; then + converged=1 + fi - # Then CLEAR the source: a port on two bridges is a port on two - # layer-2 domains. Safe only because the target is verified above. - uci -q delete "$captive_sec.ports" 2>/dev/null - from="$lan_dev" + existing_ip=$(uci -q get network.mgmt.ipaddr 2>/dev/null) + if [ -n "$existing_ip" ]; then + mgmt_ip="$existing_ip" + else + lan_ip=$(uci -q get network.lan.ipaddr 2>/dev/null) + if [ -z "$lan_ip" ]; then + log "ERROR: skipping the wired-LAN management bridge — no network.lan.ipaddr to derive its address from. Nothing was changed." + return 1 + fi + lan_mask=$(uci -q get network.lan.netmask 2>/dev/null) + [ -n "$lan_mask" ] || lan_mask="255.255.255.0" + occupied="${lan_ip}/$(netmask_to_prefixlen "$lan_mask")" + private_ip=$(uci -q get network.private.ipaddr 2>/dev/null) + [ -n "$private_ip" ] && occupied="$occupied ${private_ip}/24" + for cand in $(upstream_networks); do + occupied="$occupied $cand" + done - log "Wired LAN ports moved from ${from} to ${private_dev} ($(echo $ports | tr '\n' ' '))" - return 0 + lan_prefix=$(prefix24_of "$lan_ip") + lan_octet3=$(octet_of "$lan_prefix" 3) + mgmt_prefix="" + step=1 + while [ "$step" -le 3 ]; do + octet=$(( (lan_octet3 + step) % 256 )) + # .0 and .255 are not offered: an address that looks like a + # broadcast is a support call, not a management subnet. + if [ "$octet" -ne 0 ] && [ "$octet" -ne 255 ]; then + cand="$(octet_of "$lan_prefix" 1).$(octet_of "$lan_prefix" 2).${octet}" + if ! subnet_conflicts "$cand" $occupied; then + mgmt_prefix="$cand" + break + fi + fi + step=$((step + 1)) + done + if [ -z "$mgmt_prefix" ]; then + cand="$(octet_of "$lan_prefix" 1).$(octet_of "$lan_prefix" 2).$(( (lan_octet3 + 1) % 256 ))" + mgmt_prefix=$(choose_private_subnet "$cand" $occupied) + fi + mgmt_ip="${mgmt_prefix}.1" + log "Management bridge br-mgmt: ${mgmt_ip}/24 (first free /24 next to the LAN's, skipping the private network's)" + fi + + # ---- writes ----------------------------------------------------------- + uci set network.mgmt='interface' + uci set network.mgmt.proto='static' + uci set network.mgmt.device='br-mgmt' + uci set network.mgmt.ipaddr="$mgmt_ip" + uci set network.mgmt.netmask='255.255.255.0' + # No IPv6: the posture setup_disable_ipv6_lan takes for the captive bridge, + # and the reason 33-mgmt-bridge-scope.nft drops the family outright. + uci set network.mgmt.ip6assign='0' + + uci set network.mgmt_bridge='device' + uci set network.mgmt_bridge.type='bridge' + uci set network.mgmt_bridge.name='br-mgmt' + + if [ "$converged" = 0 ]; then + for p in $ports; do + case " $(echo $mgmt_ports) " in + *" $p "*) : ;; + *) uci add_list network.mgmt_bridge.ports="$p" ; mgmt_ports="$mgmt_ports $p" ;; + esac + done + uci -q delete "$sec.ports" 2>/dev/null + log "Wired LAN ports moved from ${lan_dev} to br-mgmt ($(echo $ports | tr '\n' ' '))" + else + log "Wired LAN ports already on br-mgmt ($(echo $mgmt_ports | tr '\n' ' ')); nothing to move" + fi + + uci set dhcp.mgmt='dhcp' + uci set dhcp.mgmt.interface='mgmt' + uci set dhcp.mgmt.start='100' + uci set dhcp.mgmt.limit='150' + uci set dhcp.mgmt.leasetime='12h' + uci set dhcp.mgmt.ra='disabled' + uci set dhcp.mgmt.dhcpv6='disabled' + + # Its own zone, NOT firewall.private_zone: that zone is forward 'ACCEPT' and + # carries a `private -> wan` forwarding, i.e. reusing it would hand every + # wired client free internet — the hole this change exists not to create. + # input ACCEPT is what lets the allow list in 33-mgmt-bridge-scope.nft be + # the thing that decides reachability (fw4 needs the zone permissive for + # DHCP/DNS/SSH/the admin ports to work at all); forward REJECT is the "no + # way out" half, and no `config forwarding` is created for this zone. + uci set firewall.mgmt_zone='zone' + uci set firewall.mgmt_zone.name='mgmt' + uci set firewall.mgmt_zone.network='mgmt' + uci set firewall.mgmt_zone.input='ACCEPT' + uci set firewall.mgmt_zone.output='ACCEPT' + uci set firewall.mgmt_zone.forward='REJECT' } commit_all() { @@ -2460,19 +2478,34 @@ if [ "$BRANCH" != "FULL" ]; then log "nodogsplash allow list already complete — nothing to commit" fi - # The wired LAN ports' placement is re-asserted here for the same reason - # the uhttpd contract and the allow list are: the port list is IMAGE- - # OWNED, so a factory reset or a `sysupgrade -n` puts the wired ports - # back on the captive bridge — and a router that was merely reinstalled - # would leave the operator's cable back on the guest network. The writer - # is idempotent (a converged router changes nothing), and its config is - # committed only when the export actually differs. + # The wired LAN ports' bridge is re-asserted here for the same reason the + # uhttpd contract and the allow list are: the port list is IMAGE-OWNED, so a + # factory reset or a `sysupgrade -n` puts the wired port back on the captive + # bridge — and a router that was merely reinstalled would leave the operator + # locked out of the admin surfaces he can reach from that port, which is the + # defect setup_mgmt_bridge exists to fix. The writer is idempotent (it moves + # only what is on the captive bridge and not already on br-mgmt), so a + # converged router changes nothing; each of the three configs it writes is + # committed only when its export actually differs, and an unreadable export + # commits unconditionally rather than drop a delta. net_before=$(uci export network) - setup_lan_ports_private + dhcp_before=$(uci export dhcp) + firewall_before=$(uci export firewall) + setup_mgmt_bridge net_after=$(uci export network) + dhcp_after=$(uci export dhcp) + firewall_after=$(uci export firewall) if [ -z "$net_before" ] || [ "$net_before" != "$net_after" ]; then uci commit network - log "network config re-asserted (wired LAN ports re-placed on br-private)" + log "network config re-asserted (wired LAN ports on br-mgmt)" + fi + if [ -z "$dhcp_before" ] || [ "$dhcp_before" != "$dhcp_after" ]; then + uci commit dhcp + log "dhcp config re-asserted (br-mgmt lease)" + fi + if [ -z "$firewall_before" ] || [ "$firewall_before" != "$firewall_after" ]; then + uci commit firewall + log "firewall config re-asserted (mgmt zone)" fi # The #428 loop guard must also fire here: a device upgrading from a @@ -2539,7 +2572,7 @@ setup_profile_hook setup_disable_ipv6_lan enable_modems setup_private_network # consumes CODE/NYM, R2G/R5G -setup_lan_ports_private # moves the wired LAN ports onto br-private +setup_mgmt_bridge # moves the wired LAN ports off the captive bridge commit_all # The router's TLS identity is provisioned and re-derived LAST on this path, after diff --git a/packaging/local-build-ipk.sh b/packaging/local-build-ipk.sh index 52c997e58..ceb5d854e 100644 --- a/packaging/local-build-ipk.sh +++ b/packaging/local-build-ipk.sh @@ -130,6 +130,7 @@ install -D -m 0644 packaging/files/etc/nftables.d/20-nds-enforce.nft install -D -m 0644 packaging/files/etc/nftables.d/30-backend-firewall.nft "$PAYLOAD/etc/nftables.d/30-backend-firewall.nft" install -D -m 0644 packaging/files/etc/nftables.d/31-admin-board-not-guest-reachable.nft "$PAYLOAD/etc/nftables.d/31-admin-board-not-guest-reachable.nft" install -D -m 0644 packaging/files/etc/nftables.d/32-luci-not-guest-reachable.nft "$PAYLOAD/etc/nftables.d/32-luci-not-guest-reachable.nft" +install -D -m 0644 packaging/files/etc/nftables.d/33-mgmt-bridge-scope.nft "$PAYLOAD/etc/nftables.d/33-mgmt-bridge-scope.nft" # Man pages mkdir -p "$PAYLOAD/usr/share/man/man8" diff --git a/tests/packaging/mgmt-bridge-scope_test.sh b/tests/packaging/mgmt-bridge-scope_test.sh new file mode 100644 index 000000000..c764e9843 --- /dev/null +++ b/tests/packaging/mgmt-bridge-scope_test.sh @@ -0,0 +1,334 @@ +#!/usr/bin/env bash +# Offline test for the scope of the wired management bridge (br-mgmt): the +# nftables half of the wired-LAN bridge decision. No router, no SDK, no network. +# +# What this pins. The wired LAN ports are moved onto a bridge of their own by +# `setup_mgmt_bridge` (99-tollgate-setup). That bridge is NOT a captive network: +# one nodogsplash gates one bridge, so br-mgmt has no gate, and a bridge this +# module cannot gate must not be able to buy — a br-mgmt client resolves a MAC, +# can be quoted and can pay, and the grant then fails in the single instance's +# `ndsctl` with `Client not found.` (money taken, nothing delivered). The +# reachability policy is therefore an ALLOW LIST with a catch-all drop: +# +# allow: DHCP/DNS, SSH, the four administration surfaces (:443, :8080, +# :8090, :8443), and nothing else +# drop: everything else, which is what makes "br-mgmt cannot transact" +# structural rather than incidental +# +# ...and the two administration guards keep their scope: 31-*.nft and 32-*.nft +# match the CAPTIVE bridge (br-lan) and must never be extended to br-mgmt, or a +# cable would be treated as the stranger those guards exist for. +# +# The repo's packaging tier is reused rather than reinvented: parse the recipe +# instead of the comment describing it, and build a real package from the +# recipe's own install lines — an nftables file under packaging/files/ that no +# recipe installs is not a control (the defect assert-artifact-contents.sh was +# written for). +# +# Usage: bash tests/packaging/mgmt-bridge-scope_test.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" || exit 1 + +PASS=0 +FAIL=0 +ok() { PASS=$((PASS + 1)); printf 'ok %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf 'FAIL %s\n' "$1"; } + +NFT_DIR="packaging/files/etc/nftables.d" +FRAG="$NFT_DIR/33-mgmt-bridge-scope.nft" +SETUP_SCRIPT="packaging/files/etc/uci-defaults/99-tollgate-setup" +# The four administration surfaces br-mgmt may reach before any payment. +ADMIN_PORTS="443 8080 8090 8443" +# The customer/payment surfaces it must NOT reach while one gate gates one +# bridge: the captive portal (:2050), the portal SPA (:2051) and the payment API +# (:2121, the rule in 30-backend-firewall.nft is what marks a selling network). +BLOCKED_PORTS="2050 2051 2121" + +# ------------------------------------------------------- 1. the packet filter +# Kept in a function over $1 so the negative control at the bottom can run the +# exact same checks against a fragment with the catch-all drop removed. +# +# Every rule-level assertion reads a COMMENT-STRIPPED copy of the fragment: the +# header explains at length what the rule deliberately does not do (`:2050`, +# `:2051`, `:2121` are named in it, and so is the captive bridge's own rule), and +# a grep over the raw file would read those explanations as configuration. +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +export PAYLOAD="$WORK/payload" +mkdir -p "$PAYLOAD" + +check_fragment() { # + local frag="$1" + if [ ! -f "$frag" ]; then + bad "missing nftables rule: $frag (nothing scopes the wired bridge to the admin surfaces)" + return + fi + local rules="$WORK/rules.$$.$RANDOM.nft" + grep -v '^[[:space:]]*#' "$frag" > "$rules" 2>/dev/null || : > "$rules" + # Each administration port, for ipv4: a rule that names only some of them + # leaves the rest reachable only by accident. + local port + for port in $ADMIN_PORTS; do + if grep -qE "meta nfproto ipv4 iifname \"br-mgmt\" tcp dport \{[^}]*[[:space:],]${port}[[:space:],}][^}]*\}[[:space:]]+counter accept" "$rules"; then + ok "the fragment accepts :$port on br-mgmt" + else + bad "the fragment does not accept :$port on br-mgmt — the administration surface is unreachable from the cable" + fi + done + # The accept rule must name exactly the four administration surfaces: a + # wider set is a capability this bridge must not have (see the header), and + # is what the customer/payment assertions below would catch. + local frag_ports + frag_ports=$(grep -oE "tcp dport \{[^}]*\}" "$rules" | head -n1 | grep -oE '[0-9]+' | sort -n | uniq | tr '\n' ' ' | sed 's/ $//') + [ "$frag_ports" = "443 8080 8090 8443" ] \ + && ok "the administration allow rule names exactly :443/:8080/:8090/:8443" \ + || bad "the administration allow rule names '$frag_ports' (want '443 8080 8090 8443')" + # What a client needs to exist on the bridge at all. + grep -qE 'meta nfproto ipv4 iifname "br-mgmt" udp dport \{ 53, 67, 68 \} counter accept' "$rules" \ + && ok "the fragment accepts DHCP/DNS (udp 53/67/68) on br-mgmt" \ + || bad "the fragment does not accept DHCP on br-mgmt — a client would get no lease and no DNS" + grep -qE 'meta nfproto ipv4 iifname "br-mgmt" tcp dport 53 counter accept' "$rules" \ + && ok "the fragment accepts DNS over TCP on br-mgmt" \ + || bad "the fragment does not accept DNS over TCP on br-mgmt" + grep -qE 'meta nfproto ipv4 iifname "br-mgmt" tcp dport 22 counter accept' "$rules" \ + && ok "the fragment accepts SSH on br-mgmt" \ + || bad "the fragment does not accept SSH on br-mgmt — the recovery path is gone" + # The catch-all drop, last, per family: this is the half that keeps the + # payment surfaces and every other service out of reach. + for proto in ipv4 ipv6; do + if grep -qE "meta nfproto $proto iifname \"br-mgmt\" counter drop" "$rules"; then + ok "the fragment ends with a $proto catch-all drop for br-mgmt" + else + bad "the fragment has no $proto catch-all drop for br-mgmt — the allow list is decorative" + fi + done + # ...and it must be the LAST rule of the chain: a drop above the accepts + # would kill the administration surfaces the fragment exists to allow. + local last_rule + last_rule=$(grep -nE '^[[:space:]]*(meta|tcp|udp|ct|iifname|oifname|counter|jump|return|accept|drop|reject)' "$rules" | tail -n1 | cut -d: -f2-) + if printf '%s' "$last_rule" | grep -qE 'iifname "br-mgmt" counter drop'; then + ok "the catch-all drop is the fragment's last rule (nothing after it is reachable)" + else + bad "the fragment's last rule is '$(printf '%s' "$last_rule" | sed -e 's/^[[:space:]]*//')' — the catch-all drop must be last" + fi + # The blocked ports must not be accepted anywhere in the fragment. + for port in $BLOCKED_PORTS; do + if grep -E "dport.*[[:space:]{,]${port}[[:space:],}]" "$rules" | grep -q "accept"; then + bad "the fragment ACCEPTS :$port on br-mgmt — a network this module cannot gate must not take money it cannot deliver" + else + ok "the fragment never accepts :$port on br-mgmt" + fi + done + # Scope: this fragment is about the wired bridge only. A rule matching the + # captive bridge here would extend an allow list onto the guest network. + if grep -qE '(iifname|oifname) "br-lan"' "$rules"; then + bad "the fragment matches br-lan in a rule — it must scope the wired bridge only" + else + ok "the fragment scopes br-mgmt only (no br-lan in a rule)" + fi + # One port, one owner: the administration ports are guarded on the captive + # bridge by 31/32 and allowed here, and nowhere twice. + local chain dup + chain=$(sed -n 's/^chain \([a-z0-9_]*\) {.*/\1/p' "$rules" | head -n1) + if [ -z "$chain" ]; then + bad "could not read a chain name out of $frag" + else + dup=$(grep -rl "^chain $chain {" "$NFT_DIR" | wc -l | tr -d ' ') + [ "$dup" = 1 ] && ok "chain '$chain' is declared once" \ + || bad "chain '$chain' is declared in $dup files under $NFT_DIR (nft -f would fail)" + fi + if grep -qE '^table ' "$rules"; then + bad "$frag opens its own table — fragments are includes inside fw4's table inet" + else + ok "$frag is an include fragment (no own table declaration)" + fi +} + +echo "== the wired bridge is scoped to the administration surfaces" +check_fragment "$FRAG" + +# ------------------------------------------------- 2. the guards keep br-lan +# The inverse of the card that this change answers: the two administration +# guards and the enforcement fragment match the CAPTIVE bridge by name, and a +# br-mgmt client is not a guest. Extending either direction re-opens what the +# other closes. +echo "== the guest guards stay pinned to the captive bridge" +for guard in 20-nds-enforce.nft 30-backend-firewall.nft \ + 31-admin-board-not-guest-reachable.nft 32-luci-not-guest-reachable.nft; do + if [ ! -f "$NFT_DIR/$guard" ]; then + bad "missing guard fragment: $NFT_DIR/$guard" + continue + fi + if grep -q 'br-mgmt' "$NFT_DIR/$guard"; then + bad "$guard mentions br-mgmt — a guest guard must not be extended to the operator's cable" + else + ok "$guard is pinned to the captive bridge (no br-mgmt)" + fi +done +# The :2121 ACL is the marker of a network that sells. 30-backend-firewall.nft +# allows the payment API only from the captive bridge, the operator's bridge +# (br-private — the admin board's data path, landed with #638's +# backend-api-owner-network exemption) and loopback — never from br-mgmt, so a +# wired client cannot pay even if this fragment were missing. The exact set is +# pinned: adding any interface to it (br-mgmt above all) must trip this check. +if grep -qE 'iifname != \{ "br-lan", "br-private", "lo" \} tcp dport 2121 counter drop' "$NFT_DIR/30-backend-firewall.nft"; then + ok "30-backend-firewall.nft drops :2121 from every network but br-lan/br-private/loopback (br-mgmt cannot transact)" +else + bad "30-backend-firewall.nft no longer restricts :2121 to br-lan/br-private/loopback — the payment API would be reachable from the wired bridge" +fi + +# -------------------------------------- 3. the two layers name the same ports +# The bridge the writer creates and the ports this fragment scopes must agree, +# and the union of the two guest guards' drop sets must equal the fragment's +# allow set: a fifth administration listener has to be added in both places in +# the same change, or one of them silently guards something else. +echo "== the allow set equals the administration surfaces the guards drop" +guard_ports=$(cat "$NFT_DIR/31-admin-board-not-guest-reachable.nft" "$NFT_DIR/32-luci-not-guest-reachable.nft" 2>/dev/null | + grep -oE 'tcp dport \{[^}]*\}' | grep -oE '[0-9]+' | sort -n | uniq | tr '\n' ' ' | sed 's/ $//') +[ "$guard_ports" = "443 8080 8090 8443" ] \ + && ok "the guest guards drop exactly :443/:8080/:8090/:8443 (got '$guard_ports')" \ + || bad "the guest guards drop '$guard_ports' (want '443 8080 8090 8443') — the allow list and the guards have drifted apart" +# And the writer really creates the bridge this fragment scopes, on both paths. +grep -q "setup_mgmt_bridge" "$SETUP_SCRIPT" \ + && ok "99-tollgate-setup still provides setup_mgmt_bridge" \ + || bad "99-tollgate-setup no longer provides setup_mgmt_bridge — the fragment would scope a bridge that is never created" +grep -qE "network\.mgmt_bridge\.name='br-mgmt'" "$SETUP_SCRIPT" \ + && ok "the writer names the bridge br-mgmt" \ + || bad "the writer does not name the bridge br-mgmt (fragment and writer disagree)" + +# --------------------------------------------- 4. the recipe installs the file +echo "== the recipe installs the rule" +if [ ! -f "$FRAG" ]; then + bad "cannot check recipe coverage: $FRAG does not exist" +else + base=$(basename "$FRAG") + if grep -qE "^[[:space:]]*install .*packaging/files/etc/nftables.d/${base}[[:space:]]" \ + packaging/local-build-ipk.sh; then + ok "local-build-ipk.sh installs $base" + else + bad "local-build-ipk.sh does not install $base (the built .ipk would ship a scope-less bridge)" + fi + if grep -q "files/etc/nftables\.d/\*.nft" packaging/Makefile; then + ok "packaging/Makefile installs the $base rule via its *.nft glob" + else + bad "packaging/Makefile no longer installs *.nft from the source directory ($base unreachable)" + fi + if grep -q "/etc/nftables\.d/$base" packaging/Makefile; then + ok "packaging/Makefile FILES_ lists /etc/nftables.d/$base" + else + bad "packaging/Makefile FILES_ does not list /etc/nftables.d/$base" + fi +fi + +# ----------------------------------------------------- 5. it ships in an ipk +echo "== the rule is present in a package built from the recipe" +installed=0 +while IFS= read -r line; do + case "$line" in + *nftables.d*) ;; + *) continue ;; + esac + if sh -c "$line" >/dev/null 2>&1; then + installed=$((installed + 1)) + else + bad "recipe install line failed: $line" + fi +done < <(grep -E "^[[:space:]]*install .*nftables\.d" packaging/local-build-ipk.sh) +if [ "$installed" -ge 1 ]; then + ok "ran $installed nftables install line(s) from local-build-ipk.sh" +else + bad "found no nftables install lines to run in packaging/local-build-ipk.sh" +fi + +base=$(basename "$FRAG") +if [ ! -f "$PAYLOAD/etc/nftables.d/$base" ]; then + bad "the recipe's own install lines do not put $base in the payload" +else + ok "the recipe's install lines put $base in the payload" + if env PKG_NAME="tollgate-wrt" PKG_VERSION="0.0.0-test" ARCH="aarch64_cortex-a53" \ + MAINTAINER="TollGate " LICENSE="GPL-3.0-only" \ + sh packaging/build-ipk.sh "$PAYLOAD" "$WORK/tollgate-wrt.ipk" >/dev/null 2>&1 && + [ -f "$WORK/tollgate-wrt.ipk" ]; then + ok "built a test .ipk from that payload" + out=$(bash tests/packaging/assert-artifact-contents.sh "$WORK/tollgate-wrt.ipk" 2>&1) + if [ "$?" = 0 ]; then + ok "assert-artifact-contents.sh passes on the built .ipk" + else + bad "assert-artifact-contents.sh rejected the built .ipk: $(printf '%s' "$out" | grep -E 'MISSING|EMPTY|SIZE|FAIL' | head -n 3 | tr '\n' ' ')" + fi + if printf '%s' "$out" | grep -q "ok etc/nftables.d/$base"; then + ok "the artifact checker lists etc/nftables.d/$base as packaged intact" + else + bad "the artifact checker did not report etc/nftables.d/$base as packaged intact" + fi + else + bad "could not build a test .ipk (needs packaging/build-ipk.sh, ar, tar, gzip)" + fi +fi + +# ------------------------------------------- 6. the fragments parse as a set +# Every fragment is loaded inside fw4's `table inet`, so validation has to use +# the same wrapper — `nft -c -f ` alone fails on the pre-existing +# files too, and a check that fails on the baseline has no signal. Needs +# CAP_NET_ADMIN; skipped, loudly, where it is unavailable (CI containers). +echo "== the fragment set parses the way fw4 loads it" +if ! command -v nft >/dev/null 2>&1; then + echo "skip nft -c: no nft binary on this host" +elif ! sudo -n true >/dev/null 2>&1; then + echo "skip nft -c: no passwordless sudo (CAP_NET_ADMIN) on this host" +else + WRAP="$WORK/fw4_wrap.nft" + { echo 'table inet fw4 {'; cat "$NFT_DIR"/*.nft; echo '}'; } > "$WRAP" + if sudo nft -c -f "$WRAP" >/dev/null 2>&1; then + ok "nft -c accepts every fragment inside one table inet fw4 (no chain collision)" + else + bad "nft -c rejected the fragment set: $(sudo nft -c -f "$WRAP" 2>&1 | head -n 2 | tr '\n' ' ')" + fi + BROKEN="$WORK/broken.nft" + { cat "$WRAP"; echo 'meta nfproto ipv4 iifname "br-mgmt" counter bogus drop'; } > "$BROKEN" + if sudo nft -c -f "$BROKEN" >/dev/null 2>&1; then + bad "the nft -c check is vacuous: a deliberately malformed rule was accepted" + else + ok "negative control: nft -c rejects a malformed rule (the check is not vacuous)" + fi +fi + +# ------------------------------------------------------------- 7. RED control +# A guard that has never failed is decoration: strip the catch-all drop from a +# copy of the fragment and require the same checks to fail against it. +echo "== RED control: the catch-all drop is what makes the allow list real" +MUTANT="$WORK/33-mgmt-bridge-scope.mutant.nft" +grep -v -E 'meta nfproto (ipv4|ipv6) iifname "br-mgmt" counter drop' "$FRAG" > "$MUTANT" +if grep -qE 'meta nfproto ipv4 iifname "br-mgmt" counter drop' "$MUTANT"; then + bad "RED control: could not strip the catch-all drop — the control is vacuous" +else + before_fail=$FAIL + red_fails=$( ( check_fragment "$MUTANT" >/dev/null 2>&1; echo "$((FAIL - before_fail))" ) ) + if [ "${red_fails:-0}" -gt 0 ]; then + ok "RED: the guard fails without the catch-all drop (${red_fails} assertions red)" + else + bad "RED: the guard passed against a fragment with no drop — it does not test what it names" + fi +fi +# ...and the same for an allow rule widened to include a payment surface, which +# is the shape a future contributor would reach for. +WIDE="$WORK/33-mgmt-bridge-scope.wide.nft" +sed 's/tcp dport { 443, 8080, 8090, 8443 }/tcp dport { 443, 8080, 8090, 8443, 2121 }/' "$FRAG" > "$WIDE" +if cmp -s "$WIDE" "$FRAG"; then + bad "RED control: could not widen the allow rule — the control is vacuous" +else + before_fail=$FAIL + red_fails=$( ( check_fragment "$WIDE" >/dev/null 2>&1; echo "$((FAIL - before_fail))" ) ) + if [ "${red_fails:-0}" -gt 0 ]; then + ok "RED: the guard fails when :2121 is added to the allow list (${red_fails} assertions red)" + else + bad "RED: the guard passed against a fragment that allows the payment API" + fi +fi + +echo +echo "passed=$PASS failed=$FAIL" +[ "$FAIL" = 0 ] diff --git a/tests/uci-defaults-lan-private-wired_test.sh b/tests/uci-defaults-lan-private-wired_test.sh deleted file mode 100755 index 0c5aeea5e..000000000 --- a/tests/uci-defaults-lan-private-wired_test.sh +++ /dev/null @@ -1,658 +0,0 @@ -#!/usr/bin/env bash -# Offline test for the wired LAN ports' placement on br-private in -# packaging/files/etc/uci-defaults/99-tollgate-setup. No router, no daemon, -# no network. -# -# What this pins. The wired LAN ports used to sit on the CAPTIVE bridge -# (br-lan) as members of the base image's bridge device section: a cabled -# client was a guest, paid at the portal, and both administration guards -# (31-admin-board-not-guest-reachable.nft, 32-luci-not-guest-reachable.nft) -# dropped the admin board (:8090/:8443) and LuCI (:8080/:443) for it. The -# operator's criterion for this release is that the physical LAN port lands -# on br-private — the operator's own trusted network: free internet -# (firewall.private_zone forwards to wan), the admin surfaces answering, -# and LuCI reachable — while the public TollGate-* SSID stays on the captive -# bridge behind the portal, with guests still unable to reach the admin -# board or LuCI. -# -# This is deliberately the MINIMAL release path: no role field, no br-mgmt, -# no /etc/config/tollgate role file. The full role machinery lives in #607 -# and lands after the release. -# -# Properties pinned here: -# 1. the placement is a MOVE: the wired ports discovered on the captive -# bridge's device section land on network.private_bridge.ports, and the -# captive section's port list is CLEARED (a port on two bridges is a -# port on two layer-2 domains); -# 2. the ports are DISCOVERED, never named: eth1 on the MT3000, lan1…lan5 -# elsewhere — whatever the image's device section lists is what moves, -# so a multi-port board moves all of its ports; -# 3. it is IDEMPOTENT: a second run changes nothing and duplicates no -# entry; -# 4. the UPGRADE path preserves the placement: the same-version -# verify/repair path re-runs the writer, and a router whose port list -# was reset back onto the captive bridge (factory reset, sysupgrade -n) -# is repaired; the module's keep-list still carries /etc/config/network -# so an upgrade keeps the config the writer produced; -# 5. the GUARDS are untouched and still br-lan-scoped: -# 20-nds-enforce.nft, 30-backend-firewall.nft, -# 31-admin-board-not-guest-reachable.nft and -# 32-luci-not-guest-reachable.nft are byte-identical to upstream main -# (pinned by digest below) and keep their `iifname "br-lan"` literals — -# that literal is what keeps a guest off :8090/:8443 and LuCI while a -# br-private client gets in; -# 6. a negative control: the assertions must FAIL against a copy of the -# script with the writer removed, so the suite cannot pass vacuously. -# -# How it checks: the shipped driver is copied into a temp dir (only its -# SETUP_FLAG and LOGFILE absolute paths are rewritten) and RUN against a -# fake `uci`, `apk`, `ip` and /etc/shadow — the same harness shape -# tests/uci-defaults-same-version-allowlist_test.sh and #607's -# tests/uci-defaults-lan-port-role_test.sh use — so the assertions are about -# the config the shipped code actually generates, not a grep of the source. -# -# Usage: bash tests/uci-defaults-lan-private-wired_test.sh -set -uo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" || exit 1 -SCRIPT="packaging/files/etc/uci-defaults/99-tollgate-setup" -NFT_DIR="packaging/files/etc/nftables.d" -KEEP_LIST="packaging/files/lib/upgrade/keep.d/tollgate" - -PASS=0 -FAIL=0 -ok() { PASS=$((PASS + 1)); printf 'ok %s\n' "$1"; } -bad() { FAIL=$((FAIL + 1)); printf 'FAIL %s\n' "$1"; } - -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT -mkdir -p "$TMP/bin" - -# ---------------------------------------------------------------- fake apk -FAKE_VERSION="0.6.0_alpha4-r0" -export FAKE_APK_VERSION="$FAKE_VERSION" -cat > "$TMP/bin/apk" <<'SHIM' -#!/bin/sh -if [ "${1:-}" = "list" ]; then - printf '%s\n' "tollgate-wrt-${FAKE_APK_VERSION} aarch64_cortex-a53 {tollgate-wrt} (GPL-3.0-only) [installed]" -fi -exit 0 -SHIM -chmod +x "$TMP/bin/apk" - -# ---------------------------------------------------------------- fake uci -# Flat-file stand-in: one "key=value" line per option, so a list option is -# one line per entry (which is how `uci get` renders a list). `delete` is -# SECTION-AWARE on purpose: real uci removes a section and every option in -# it, and this writer deletes option lists from sections. -export UCI_STATE="$TMP/uci.state" -cat > "$TMP/bin/uci" <<'SHIM' -#!/bin/sh -state="${UCI_STATE:?}" -q=0 -[ "${1:-}" = "-q" ] && { q=1; shift; } -cmd="${1:-}" -shift || true -values() { grep -F -- "$1=" "$state" 2>/dev/null | cut -d= -f2-; } -drop_key() { # remove = and any .*= — i.e. the section and its options - awk -v k="$1" 'index($0, k "=") != 1 && index($0, k ".") != 1' "$state" > "$state.tmp" 2>/dev/null - mv "$state.tmp" "$state" -} -case "$cmd" in - get) - vals="$(values "$1")" - if [ -z "$vals" ]; then - [ "$q" = 1 ] || echo "uci: Entry not found" >&2 - exit 1 - fi - printf '%s\n' "$vals" - ;; - set) - key="${1%%=*}" - key="${key%%[[:space:]]*}" - grep -v -F -- "$key=" "$state" > "$state.tmp" 2>/dev/null - mv "$state.tmp" "$state" - printf '%s\n' "$1" >> "$state" - ;; - add_list) - printf '%s\n' "$1" >> "$state" - ;; - add) - printf '%s=%s\n' "${2:-section}" "${1:-unknown}" >> "$state" - ;; - delete) - drop_key "$1" - ;; - del_list) - grep -v -F -x -- "$1" "$state" > "$state.tmp" 2>/dev/null - mv "$state.tmp" "$state" - ;; - show|export|commit|revert) : ;; - *) : ;; -esac -exit 0 -SHIM -chmod +x "$TMP/bin/uci" - -# ----------------------------------------------------------------- fake ip -export FAKE_IP_UPSTREAM="$TMP/ip.upstream" -: > "$FAKE_IP_UPSTREAM" -cat > "$TMP/bin/ip" <<'SHIM' -#!/bin/sh -lan_ip="$(grep -F -- 'network.lan.ipaddr=' "$UCI_STATE" 2>/dev/null | cut -d= -f2- | tail -n1)" -[ -n "$lan_ip" ] || lan_ip="192.168.1.1" -case "$lan_ip" in - */*) : ;; - *) lan_ip="$lan_ip/24" ;; -esac -printf '1: lo inet 127.0.0.1/8 scope host lo\n' -printf '2: br-lan inet %s brd 192.168.1.255 scope global br-lan\n' "$lan_ip" -[ -s "$FAKE_IP_UPSTREAM" ] && cat "$FAKE_IP_UPSTREAM" -exit 0 -SHIM -chmod +x "$TMP/bin/ip" - -# ------------------------------------------------------------- fake hexdump -export HEXDUMP_QUEUE_FILE="$TMP/hexdump.queue" -: > "$HEXDUMP_QUEUE_FILE" -cat > "$TMP/bin/hexdump" <<'SHIM' -#!/bin/sh -f="${HEXDUMP_QUEUE_FILE:-}" -if [ -n "$f" ] && [ -s "$f" ]; then - val="$(head -n1 "$f")" - tail -n +2 "$f" > "$f.tmp" 2>/dev/null && mv "$f.tmp" "$f" - printf '%s\n' "$val" - exit 0 -fi -printf '%s\n' "${HEXDUMP_FIXED:-5a}" -SHIM -chmod +x "$TMP/bin/hexdump" - -export PATH="$TMP/bin:$PATH" - -# --------------------------------------------------- the script under test -FLAG="$TMP/tollgate-setup-done" -LOGFILE="$TMP/setup.log" -SCRIPT_UNDER_TEST="$TMP/99-tollgate-setup" -sed -e "s|^SETUP_FLAG=\"/etc/tollgate-setup-done\"\$|SETUP_FLAG=\"$FLAG\"|" \ - -e "s|^LOGFILE=/tmp/tollgate-setup\.log\$|LOGFILE=$LOGFILE|" \ - "$ROOT/$SCRIPT" > "$SCRIPT_UNDER_TEST" -if grep -q "^SETUP_FLAG=\"$FLAG\"\$" "$SCRIPT_UNDER_TEST" && - grep -q "^LOGFILE=$LOGFILE\$" "$SCRIPT_UNDER_TEST"; then - ok "harness redirected SETUP_FLAG and LOGFILE in the copied script" -else - bad "could not redirect SETUP_FLAG/LOGFILE in the copied setup script" -fi - -# Credential/router-home fixtures so the driver never touches the host. -export SHADOW_FILE="$TMP/shadow" -export PASSWD_FILE="$TMP/passwd.db" -printf 'root:$1$fixture$0123456789abcdef:0:0:99999:7:::\n' > "$SHADOW_FILE" -: > "$PASSWD_FILE" -export ROUTER_HOME_DIR="$TMP/router-home" - -# ------------------------------------------------------------- the fixtures -# A stock router: two radios, the stock guest APs, the captive bridge with -# its IMAGE-OWNED port list, one nodogsplash section pinned to the captive -# bridge, and the operator's private network. -# seed_router -seed_router() { - local p - : > "$UCI_STATE" - { - printf '%s\n' 'wireless.radio0=wifi-device' 'wireless.radio0.band=2g' - printf '%s\n' 'wireless.radio0.channel=1' 'wireless.radio0.disabled=0' - printf '%s\n' 'wireless.radio1=wifi-device' 'wireless.radio1.band=5g' - printf '%s\n' 'wireless.radio1.channel=36' 'wireless.radio1.disabled=0' - printf '%s\n' 'wireless.default_radio0=wifi-iface' 'wireless.default_radio0.device=radio0' - printf '%s\n' 'wireless.default_radio0.mode=ap' 'wireless.default_radio0.network=lan' - printf '%s\n' 'wireless.default_radio1=wifi-iface' 'wireless.default_radio1.device=radio1' - printf '%s\n' 'wireless.default_radio1.mode=ap' 'wireless.default_radio1.network=lan' - printf '%s\n' 'network.lan=interface' 'network.lan.device=br-lan' - printf '%s\n' 'network.lan.proto=static' 'network.lan.ipaddr=192.168.1.1/24' - printf '%s\n' 'network.lan.netmask=255.255.255.0' - printf '%s\n' 'network.@device[0]=device' 'network.@device[0].name=br-lan' - printf '%s\n' 'network.@device[0].type=bridge' - for p in "$@"; do - printf 'network.@device[0].ports=%s\n' "$p" - done - printf '%s\n' 'network.wan=interface' 'network.wan.device=eth0' 'network.wan.proto=dhcp' - printf '%s\n' 'system.@system[0]=system' 'system.@system[0].hostname=TollGate' - printf '%s\n' 'dhcp.@dnsmasq[0]=dnsmasq' - printf '%s\n' 'nodogsplash.@nodogsplash[0]=nodogsplash' - printf '%s\n' 'nodogsplash.@nodogsplash[0].gatewayinterface=br-lan' - printf '%s\n' 'nodogsplash.@nodogsplash[0].users_to_router=allow tcp port 443' - printf '%s\n' 'wireless.private_radio0.ssid=test-private' - printf '%s\n' 'wireless.private_radio0.key=Alpha-Bravo-Charlie-11' - } >> "$UCI_STATE" -} - -count() { grep -F -c -- "$1" "$UCI_STATE" 2>/dev/null | tr -d ' '; } -has() { grep -F -q -- "$1" "$UCI_STATE"; } -line() { grep -F -x -- "$1" "$UCI_STATE" 2>/dev/null; } -no_upstream() { : > "$FAKE_IP_UPSTREAM"; } -draw() { printf '%s\n' "$@" > "$HEXDUMP_QUEUE_FILE"; } - -# Run the writer through the repo's LIB_ONLY seam: sources the shipped -# script (which returns before its own driver logic) and calls the private -# network setup followed by the wired-port writer. -run_writer() { - : > "$LOGFILE" - # shellcheck disable=SC1090 # the path is built from $ROOT on purpose - TOLLGATE_SETUP_LIB_ONLY=1 . "$SCRIPT_UNDER_TEST" >/dev/null 2>&1 - R2G=radio0 R5G=radio1 setup_private_network >/dev/null 2>&1 - setup_lan_ports_private >/dev/null 2>&1 - return $? -} - -# The whole shipped script on the reinstall/upgrade (same-version) path. -run_same_version() { - printf '%s\n' "$FAKE_VERSION" > "$FLAG" - : > "$LOGFILE" - sh "$SCRIPT_UNDER_TEST" >/dev/null 2>"$TMP/run.err" - return $? -} - -# Save the stock fake uci so per-test shims can restore it. -cp "$TMP/bin/uci" "$TMP/bin/uci.real" -uci_restore() { cp -f "$TMP/bin/uci.real" "$TMP/bin/uci"; } - -log_contains() { grep -qF -- "$1" "$LOGFILE"; } - -# Run the writer with a custom fake uci shim installed at $TMP/bin/uci. -# The shim runs from a clean $TMP/bin and must exit 0 after forking the -# stock implementation for unrelated commands. -run_writer_with_uci() { - local shim="$1" - : > "$LOGFILE" - # Build the PRECONDITION with the stock fake uci first: the private bridge - # must exist before the writer under test runs, otherwise the writer - # correctly bails at its "no private bridge" precondition and the shim - # would be testing that path instead of the port-move guard. The shim is - # installed only for the writer itself, so the only thing it breaks is the - # write this negative control is about. - uci_restore - # shellcheck disable=SC1090 - TOLLGATE_SETUP_LIB_ONLY=1 . "$SCRIPT_UNDER_TEST" >/dev/null 2>&1 - R2G=radio0 R5G=radio1 setup_private_network >/dev/null 2>&1 - cp -f "$shim" "$TMP/bin/uci" - chmod +x "$TMP/bin/uci" - setup_lan_ports_private >/dev/null 2>&1 - local rc=$? - uci_restore - return $rc -} - -# ------------------------------------------------------------- assertions -ports_on_private() { # [ ...] - local port want=0 n - for port in "$@"; do - want=$((want + 1)) - n=$(count "network.private_bridge.ports=$port") - [ "$n" = 1 ] && ok "network.private_bridge lists $port exactly once" \ - || bad "network.private_bridge lists $port $n time(s) (want 1)" - done -} - -captive_cleared() { # the image's device section holds no port list - if grep -qE '^network\.@device\[0\]\.ports=' "$UCI_STATE"; then - bad "the captive bridge's device section still lists ports ($(grep -E '^network\.@device\[0\]\.ports=' "$UCI_STATE" | cut -d= -f2- | tr '\n' ' ')) — the move must CLEAR it" - else - ok "the captive bridge's device section lists no ports" - fi -} - -nodogsplash_still_captive() { - if has 'nodogsplash.@nodogsplash[0].gatewayinterface=br-lan'; then - ok "nodogsplash is still pinned to the captive bridge (br-lan)" - else - bad "nodogsplash.gatewayinterface is no longer br-lan" - fi -} - -# =========================================================================== -# 1. the writer — a stock router's wired ports move to br-private -# =========================================================================== -echo "== a stock router's wired ports move from the captive bridge to br-private" -seed_router eth1 -no_upstream -draw 5a 5a -run_writer -rc=$? -if [ "$rc" = 0 ]; then ok "the writer returns 0 on a stock router"; else bad "the writer returned $rc on a stock router"; fi -ports_on_private eth1 -captive_cleared -nodogsplash_still_captive - -echo "== a multi-port board moves every port it finds (lan1 lan2 lan3)" -seed_router lan1 lan2 lan3 -no_upstream -draw 5a 5a -run_writer -ports_on_private lan1 lan2 lan3 -captive_cleared - -echo "== the ports are discovered, never named: no port name is hard-coded" -# Comments may name boards' port shapes (eth1 on the MT3000); the CODE must -# not. Strip comment lines before checking, so the assertion bites on a -# hard-coded port in a uci write, not on documentation. -if sed 's/#.*$//' "$ROOT/$SCRIPT" | grep -qE '(^|[^a-z])eth1([^a-z]|$)'; then - bad "the setup script's CODE names 'eth1' — the ports must be discovered from the bridge's device section" -else - ok "the setup script's code never names a specific port (eth1/lan1/... are board-specific)" -fi - -# =========================================================================== -# 2. idempotence — a second run changes nothing -# =========================================================================== -echo "== a converged router is a no-op: a second run duplicates nothing" -seed_router eth1 -no_upstream -draw 5a 5a -run_writer -snapshot1=$(grep -E '^(network\.private_bridge\.ports|network\.@device)' "$UCI_STATE" | sort) -run_writer -snapshot2=$(grep -E '^(network\.private_bridge\.ports|network\.@device)' "$UCI_STATE" | sort) -if [ "$snapshot1" = "$snapshot2" ]; then - ok "a second run leaves the placement untouched" -else - bad "a second run changed the placement:"$'\n'"before: $snapshot1"$'\n'"after: $snapshot2" -fi -ports_on_private eth1 -captive_cleared - -# =========================================================================== -# 3. the upgrade path — same-version repair re-places reset ports -# =========================================================================== -echo "== the same-version (reinstall/upgrade) path repairs a reset port list" -seed_router eth1 -no_upstream -draw 5a 5a -# Simulate the converged state (previous install placed the port), then a -# factory reset put it back on the captive bridge's image-owned section. -run_writer -printf 'network.@device[0].ports=eth1\n' >> "$UCI_STATE" -grep -v -F -- 'network.private_bridge.ports=eth1' "$UCI_STATE" > "$UCI_STATE.tmp" -mv "$UCI_STATE.tmp" "$UCI_STATE" -run_same_version -rc=$? -if [ "$rc" = 0 ]; then ok "the same-version path returns 0"; else bad "the same-version path returned $rc"; fi -ports_on_private eth1 -captive_cleared - -echo "== the same-version path leaves a converged router's placement alone" -seed_router eth1 -no_upstream -draw 5a 5a -run_writer -run_same_version -ports_on_private eth1 -captive_cleared - -echo "== the module's keep-list still carries /etc/config/network (upgrade survival)" -if grep -qF -- '/etc/config/network' "$KEEP_LIST"; then - ok "packaging/files/lib/upgrade/keep.d/tollgate lists /etc/config/network — a sysupgrade keeps the placement" -else - bad "the keep-list no longer lists /etc/config/network — an upgrade would lose the wired-port placement" -fi - -# =========================================================================== -# 4. the guards — untouched, and still scoped to the captive bridge -# =========================================================================== -echo "== the guard fragments are unchanged and still br-lan-scoped" -# Digests of the four guards. Three are byte-identical to upstream main -# (54e8c368) because the wired-port placement must not touch them; the guards -# are iifname "br-lan"-literal, which is what keeps a guest off :8090/:8443 and -# LuCI while a br-private client gets in. -# -# 30-backend-firewall is the deliberate exception: it now exempts br-private as -# well as br-lan, because br-private is the one network the admin board is -# reachable from and the board reads every value out of the :2121 API. The -# digest pinned below is therefore the NEW content, not main's. The invariant -# itself is owned by tests/packaging/backend-api-owner-network_test.sh; this -# block only pins the bytes. -GUARD_DIGESTS='20-nds-enforce 4cae6ef31d23d10ee4a730e1b2797281 -30-backend-firewall 9d4702bdc1d2b468fb39a402372bb27c -31-admin-board-not-guest-reachable a712298f3b833b78eaa15483c90846a5 -32-luci-not-guest-reachable c86e5dc022b8a4108f305eba7febefe1' -while read -r frag digest; do - [ -n "$frag" ] || continue - got=$(md5sum "$NFT_DIR/$frag.nft" 2>/dev/null | cut -d' ' -f1) - if [ "$got" = "$digest" ]; then - ok "$frag.nft matches its pinned digest ($digest)" - else - bad "$frag.nft changed (md5 $got, want $digest)" - fi -done <<< "$GUARD_DIGESTS" -# The scope literal itself: each guard must still carry iifname "br-lan". -if grep -qF -- 'iifname "br-lan"' "$NFT_DIR/20-nds-enforce.nft"; then - ok "20-nds-enforce.nft is still br-lan-scoped" -else - bad "20-nds-enforce.nft lost its iifname \"br-lan\" scope" -fi -if grep -qF -- 'iifname "br-lan"' "$NFT_DIR/31-admin-board-not-guest-reachable.nft"; then - ok "31-admin-board-not-guest-reachable.nft is still br-lan-scoped" -else - bad "31-admin-board-not-guest-reachable.nft lost its iifname \"br-lan\" scope" -fi -if grep -qF -- 'iifname "br-lan"' "$NFT_DIR/32-luci-not-guest-reachable.nft"; then - ok "32-luci-not-guest-reachable.nft is still br-lan-scoped" -else - bad "32-luci-not-guest-reachable.nft lost its iifname \"br-lan\" scope" -fi -# 30-backend-firewall scopes by NOT-the-LAN-bridges -# (iifname != { "br-lan", "br-private", "lo" }): br-lan because a guest pays -# through the :2121 API, br-private because the admin board — which lives on -# that network, not on the captive bridge — reads all of its data from the same -# API. Dropping br-private there is what left the board rendering with every -# panel empty. -if grep -qF -- 'iifname != { "br-lan", "br-private", "lo" }' "$NFT_DIR/30-backend-firewall.nft"; then - ok "30-backend-firewall.nft exempts both LAN bridges and lo" -else - bad "30-backend-firewall.nft lost its LAN-bridge exemption set" -fi - -# =========================================================================== -# 1b. negative controls for the verify-before-clear guard -# =========================================================================== -echo "== add_list failure: the captive bridge's port list is NOT cleared" -FAIL_ADD_LIST="$TMP/uci-fail-add-list" -cat > "$FAIL_ADD_LIST" <<'SHIM' -#!/bin/sh -state="${UCI_STATE:?}" -q=0 -[ "${1:-}" = "-q" ] && { q=1; shift; } -cmd="${1:-}" -shift || true -values() { grep -F -- "$1=" "$state" 2>/dev/null | cut -d= -f2-; } -drop_key() { - awk -v k="$1" 'index($0, k "=") != 1 && index($0, k ".") != 1' "$state" > "$state.tmp" 2>/dev/null - mv "$state.tmp" "$state" -} -case "$cmd" in - get) - vals="$(values "$1")" - if [ -z "$vals" ]; then - [ "$q" = 1 ] || echo "uci: Entry not found" >&2 - exit 1 - fi - printf '%s\n' "$vals" - ;; - set|add|del_list|delete) - # delegate to the real fake uci by re-exec - exec "$TMP/bin/uci.real" ${q:+-q} "$cmd" "$@" - ;; - add_list) - key="${1%%=*}" - if [ "$key" = "network.private_bridge.ports" ]; then - echo "fake uci add_list failure" >&2 - exit 1 - fi - printf '%s\n' "$1" >> "$state" - ;; - show|export|commit|revert) : ;; - *) : ;; -esac -exit 0 -SHIM -chmod +x "$FAIL_ADD_LIST" -seed_router eth1 -no_upstream -draw 5a 5a -run_writer_with_uci "$FAIL_ADD_LIST" -rc=$? -if [ "$rc" != 0 ]; then ok "add_list failure: writer returns non-zero ($rc)"; else bad "add_list failure: writer returned 0"; fi -if grep -qE '^network\.@device\[0\]\.ports=' "$UCI_STATE"; then - ok "add_list failure: the captive bridge's port list is preserved" -else - bad "add_list failure: the captive bridge's port list was cleared despite add_list failure" -fi -if ! grep -qE '^network\.private_bridge\.ports=' "$UCI_STATE"; then - ok "add_list failure: private_bridge received no ports" -else - bad "add_list failure: private_bridge received ports despite add_list failure" -fi -if log_contains "ERROR" && log_contains "eth1"; then - ok "add_list failure: the log names the missing port" -else - bad "add_list failure: the error log does not name the missing port" -fi - - -echo "== add_list silent no-op (rc 0, target unchanged): verification catches it" -NOOP_ADD_LIST="$TMP/uci-noop-add-list" -cat > "$NOOP_ADD_LIST" <<'SHIM' -#!/bin/sh -state="${UCI_STATE:?}" -q=0 -[ "${1:-}" = "-q" ] && { q=1; shift; } -cmd="${1:-}" -shift || true -values() { grep -F -- "$1=" "$state" 2>/dev/null | cut -d= -f2-; } -drop_key() { - awk -v k="$1" 'index($0, k "=") != 1 && index($0, k ".") != 1' "$state" > "$state.tmp" 2>/dev/null - mv "$state.tmp" "$state" -} -case "$cmd" in - get) - vals="$(values "$1")" - if [ -z "$vals" ]; then - [ "$q" = 1 ] || echo "uci: Entry not found" >&2 - exit 1 - fi - printf '%s\n' "$vals" - ;; - set|add|del_list|delete) - exec "$TMP/bin/uci.real" ${q:+-q} "$cmd" "$@" - ;; - add_list) - # silently accept but do nothing - exit 0 - ;; - show|export|commit|revert) : ;; - *) : ;; -esac -exit 0 -SHIM -chmod +x "$NOOP_ADD_LIST" -seed_router eth1 -no_upstream -draw 5a 5a -run_writer_with_uci "$NOOP_ADD_LIST" -rc=$? -if [ "$rc" != 0 ]; then ok "silent no-op: writer returns non-zero ($rc)"; else bad "silent no-op: writer returned 0"; fi -if grep -qE '^network\.@device\[0\]\.ports=' "$UCI_STATE"; then - ok "silent no-op: the captive bridge's port list is preserved" -else - bad "silent no-op: the captive bridge's port list was cleared despite verification failure" -fi -if log_contains "ERROR" && log_contains "eth1"; then - ok "silent no-op: the log names the missing port" -else - bad "silent no-op: the error log does not name the missing port" -fi - - -echo "== empty source and empty target: reported as an error, nothing written" -seed_router -no_upstream -draw 5a 5a -# setup_private_network above creates private_bridge; captive has no ports -run_writer -rc=$? -if [ "$rc" != 0 ]; then ok "empty source+target: writer returns non-zero ($rc)"; else bad "empty source+target: writer returned 0"; fi -if ! grep -qE '^network\.private_bridge\.ports=' "$UCI_STATE"; then - ok "empty source+target: private_bridge still has no ports" -else - bad "empty source+target: private_bridge gained ports from nowhere" -fi -if log_contains "ERROR" && log_contains "neither bridge"; then - ok "empty source+target: log reports the broken state" -else - bad "empty source+target: log does not report the broken state" -fi - - -echo "== bridge device section beyond index 23 is still found and moved" -seed_router eth1 -no_upstream -# push br-lan far down the anonymous device list -awk 'BEGIN { for (i=0; i<30; i++) print "network.@device[" i "]=device"; print "network.@device[30].name=br-lan"; print "network.@device[30].type=bridge"; print "network.@device[30].ports=eth1" }' /dev/null >> "$UCI_STATE" -# remove the original @device[0] block that seed_router wrote -awk 'index($0, "network.@device[0]") != 1' "$UCI_STATE" > "$UCI_STATE.tmp" && mv "$UCI_STATE.tmp" "$UCI_STATE" -# ensure network.lan still points at br-lan -grep -qF -- 'network.lan.device=br-lan' "$UCI_STATE" || printf 'network.lan.device=br-lan\n' >> "$UCI_STATE" -draw 5a 5a -run_writer -rc=$? -if [ "$rc" = 0 ]; then ok "late bridge: writer returns 0"; else bad "late bridge: writer returned $rc"; fi -ports_on_private eth1 -if ! grep -qE '^network\.@device\[30\]\.ports=' "$UCI_STATE"; then - ok "late bridge: the source section was cleared" -else - bad "late bridge: the source section still lists ports" -fi - - -# =========================================================================== -# 5. negative control — the assertions must fail without the writer -# =========================================================================== -echo "== negative control: a script with a no-op writer fails the assertions" -NEG="$TMP/99-tollgate-setup.neg" -sed -e "s|^setup_lan_ports_private() {|setup_lan_ports_private() {\n return 0 # negative control: the writer is neutralised|" \ - "$SCRIPT_UNDER_TEST" > "$NEG" -if ! grep -qF -- '# negative control: the writer is neutralised' "$NEG"; then - bad "negative control: could not neutralise setup_lan_ports_private in the copy" -else - ok "negative control: the writer is neutralised in the copied script" -fi -export UCI_STATE="$TMP/uci.neg" -seed_router eth1 -no_upstream -draw 5a 5a -: > "$LOGFILE" -# shellcheck disable=SC1090 # the path is built from $ROOT on purpose -TOLLGATE_SETUP_LIB_ONLY=1 . "$NEG" >/dev/null 2>&1 -R2G=radio0 R5G=radio1 setup_private_network >/dev/null 2>&1 -setup_lan_ports_private >/dev/null 2>&1 -if grep -qE '^network\.private_bridge\.ports=' "$UCI_STATE"; then - bad "negative control: the port moved without the writer (control is vacuous)" -else - ok "negative control: without the writer the port stays on the captive bridge" -fi -if grep -qE '^network\.@device\[0\]\.ports=eth1$' "$UCI_STATE"; then - ok "negative control: the captive bridge keeps its port (the move assertion would fail)" -else - bad "negative control: the captive bridge lost its port without the writer" -fi -export UCI_STATE="$TMP/uci.state" - -# =========================================================================== -echo -if [ "$FAIL" -eq 0 ]; then - echo "lan-private-wired: all $PASS checks passed" - exit 0 -fi -echo "lan-private-wired: $FAIL check(s) FAILED, $PASS passed" -exit 1 diff --git a/tests/uci-defaults-mgmt-bridge_test.sh b/tests/uci-defaults-mgmt-bridge_test.sh new file mode 100644 index 000000000..519e7737f --- /dev/null +++ b/tests/uci-defaults-mgmt-bridge_test.sh @@ -0,0 +1,617 @@ +#!/usr/bin/env bash +# Offline test for setup_mgmt_bridge() in +# packaging/files/etc/uci-defaults/99-tollgate-setup — no router, no network. +# +# What this pins. The wired LAN ports used to sit on the CAPTIVE bridge, so the +# operator administering the router from an Ethernet cable was treated as a +# guest: both administration guards are scoped to the captive bridge by name +# (31-admin-board-not-guest-reachable.nft, 32-luci-not-guest-reachable.nft), so +# a cable reached neither the board (:8090/:8443) nor LuCI (:8080/:443) before +# paying — and the port shared a layer-2 domain with the strangers on the open +# SSID, the one exposure this repo measured as unclosable by bridge-port +# isolation. The writer MOVES the ports onto a bridge of their own, br-mgmt, +# which nodogsplash does not gate and which reaches the administration surfaces +# only (see 33-mgmt-bridge-scope.nft). +# +# Three properties are load-bearing and each has assertions here: +# 1. the move is a MOVE — the captive bridge's port list is cleared, not +# copied (a copy would leave the port on both bridges); +# 2. it is idempotent AND convergent — a second run changes nothing, and a +# `sysupgrade -n`/factory reset that puts the port back on the captive +# bridge is repaired by the same-version install path, because the port +# list is owned by the base image, not by this repository; +# 3. it FAILS LOUDLY AND CHANGES NOTHING when there is nothing to move (a +# portless br-mgmt would report a management bridge with no way in), and +# it gives the bridge no path off the router (its own zone, forward +# REJECT, no forwarding to wan, no reuse of firewall.private_zone — that +# zone forwards to the wan and would hand the cable free internet). +# +# How it checks: the shipped driver is copied into a temp dir (only its +# SETUP_FLAG and LOGFILE absolute paths are rewritten) and RUN against a fake +# `uci`, `apk`, `ip` and /etc/shadow — the harness +# tests/uci-defaults-private-subnet_test.sh and +# tests/packaging/guest-client-isolation_test.sh use — so the assertions are +# about the config the shipped code actually generates, not about a grep of the +# source. The last section mutates the writer out of a copy of the script and +# requires the assertions to FAIL against it, so the guard is proven to bite. +# +# Usage: bash tests/uci-defaults-mgmt-bridge_test.sh +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" || exit 1 +SCRIPT="packaging/files/etc/uci-defaults/99-tollgate-setup" + +PASS=0 +FAIL=0 +ok() { PASS=$((PASS + 1)); printf 'ok %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf 'FAIL %s\n' "$1"; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" + +# ---------------------------------------------------------------- fake apk +FAKE_VERSION="0.6.0_alpha4-r0" +export FAKE_APK_VERSION="$FAKE_VERSION" +cat > "$TMP/bin/apk" <<'SHIM' +#!/bin/sh +if [ "${1:-}" = "list" ]; then + printf '%s\n' "tollgate-wrt-${FAKE_APK_VERSION} aarch64_cortex-a53 {tollgate-wrt} (GPL-3.0-only) [installed]" +fi +exit 0 +SHIM +chmod +x "$TMP/bin/apk" + +# ---------------------------------------------------------------- fake uci +# Flat-file stand-in: one "key=value" line per option, so a list option is one +# line per entry (which is how `uci get` renders a list). `del_list` is +# implemented properly — the trap the repo has already been bitten by is a shim +# that falls through to `*) :` and turns every assertion about a removal into a +# tautology. +export UCI_STATE="$TMP/uci.state" +cat > "$TMP/bin/uci" <<'SHIM' +#!/bin/sh +state="${UCI_STATE:?}" +q=0 +[ "${1:-}" = "-q" ] && { q=1; shift; } +cmd="${1:-}" +shift || true +values() { grep -F -- "$1=" "$state" 2>/dev/null | cut -d= -f2-; } +case "$cmd" in + get) + vals="$(values "$1")" + if [ -z "$vals" ]; then + [ "$q" = 1 ] || echo "uci: Entry not found" >&2 + exit 1 + fi + printf '%s\n' "$vals" + ;; + set) + key="${1%%=*}" + grep -v -F -- "$key=" "$state" > "$state.tmp" 2>/dev/null + mv "$state.tmp" "$state" + printf '%s\n' "$1" >> "$state" + ;; + add_list) + printf '%s\n' "$1" >> "$state" + ;; + add) + printf '%s=%s\n' "${2:-section}" "${1:-unknown}" >> "$state" + ;; + delete) + grep -v -F -- "$1=" "$state" > "$state.tmp" 2>/dev/null + mv "$state.tmp" "$state" + ;; + del_list) + grep -v -F -x -- "$1" "$state" > "$state.tmp" 2>/dev/null + mv "$state.tmp" "$state" + ;; + show|export|commit|revert) : ;; + *) : ;; +esac +exit 0 +SHIM +chmod +x "$TMP/bin/uci" + +# ----------------------------------------------------------------- fake ip +# `upstream_networks` reads the kernel for addresses that are not the LAN or the +# private bridge, so the host's own interfaces must never be consulted (the test +# would otherwise depend on, and leak, the machine it runs on). +export FAKE_IP_UPSTREAM="$TMP/ip.upstream" +: > "$FAKE_IP_UPSTREAM" +cat > "$TMP/bin/ip" <<'SHIM' +#!/bin/sh +lan_ip="$(grep -F -- 'network.lan.ipaddr=' "$UCI_STATE" 2>/dev/null | cut -d= -f2- | tail -n1)" +[ -n "$lan_ip" ] || lan_ip="192.168.1.1" +case "$lan_ip" in + */*) : ;; + *) lan_ip="$lan_ip/24" ;; +esac +printf '1: lo inet 127.0.0.1/8 scope host lo\n' +printf '2: br-lan inet %s brd 192.168.1.255 scope global br-lan\n' "$lan_ip" +[ -s "$FAKE_IP_UPSTREAM" ] && cat "$FAKE_IP_UPSTREAM" +exit 0 +SHIM +chmod +x "$TMP/bin/ip" + +# ------------------------------------------------------------- fake hexdump +# The random-fallback path draws octets with hexdump; the queue makes a draw +# deterministic (and proves the fallback is a draw, not a constant). +export HEXDUMP_QUEUE_FILE="$TMP/hexdump.queue" +: > "$HEXDUMP_QUEUE_FILE" +cat > "$TMP/bin/hexdump" <<'SHIM' +#!/bin/sh +f="${HEXDUMP_QUEUE_FILE:-}" +if [ -n "$f" ] && [ -s "$f" ]; then + val="$(head -n1 "$f")" + tail -n +2 "$f" > "$f.tmp" 2>/dev/null && mv "$f.tmp" "$f" + printf '%s\n' "$val" + exit 0 +fi +printf '%s\n' "${HEXDUMP_FIXED:-5a}" +SHIM +chmod +x "$TMP/bin/hexdump" + +export PATH="$TMP/bin:$PATH" + +# --------------------------------------------------- the script under test +FLAG="$TMP/tollgate-setup-done" +LOGFILE="$TMP/setup.log" +SCRIPT_UNDER_TEST="$TMP/99-tollgate-setup" +sed -e "s|^SETUP_FLAG=\"/etc/tollgate-setup-done\"\$|SETUP_FLAG=\"$FLAG\"|" \ + -e "s|^LOGFILE=/tmp/tollgate-setup\.log\$|LOGFILE=$LOGFILE|" \ + "$ROOT/$SCRIPT" > "$SCRIPT_UNDER_TEST" +if grep -q "^SETUP_FLAG=\"$FLAG\"\$" "$SCRIPT_UNDER_TEST" && + grep -q "^LOGFILE=$LOGFILE\$" "$SCRIPT_UNDER_TEST"; then + ok "harness redirected SETUP_FLAG and LOGFILE in the copied script" +else + bad "could not redirect SETUP_FLAG/LOGFILE in the copied setup script" +fi + +# The driver also runs the admin-credential gate (reads the live /etc/shadow and +# would invoke `passwd root` on an empty hash) and writes the :80 stub document +# into a docroot — pin all of them to fixtures so the test never touches the +# machine it runs on. +export SHADOW_FILE="$TMP/shadow" +export PASSWD_FILE="$TMP/passwd.db" +printf 'root:$1$fixture$0123456789abcdef:0:0:99999:7:::\n' > "$SHADOW_FILE" +: > "$PASSWD_FILE" +export ROUTER_HOME_DIR="$TMP/router-home" + +# ------------------------------------------------------------- the fixtures +# A stock-router seed: two radios, the stock guest APs, the captive bridge with +# its IMAGE-OWNED port list, and (optionally) the owner's private network — +# which by default takes the /24 next to the LAN's, and is therefore exactly the +# network the management bridge must not land on. +# seed_stock [private-ipaddr] +seed_stock() { + local ports="$1" private_ip="${2:-}" p + : > "$UCI_STATE" + { + printf '%s\n' 'wireless.radio0=wifi-device' 'wireless.radio0.band=2g' + printf '%s\n' 'wireless.radio0.channel=1' 'wireless.radio0.disabled=0' + printf '%s\n' 'wireless.radio1=wifi-device' 'wireless.radio1.band=5g' + printf '%s\n' 'wireless.radio1.channel=36' 'wireless.radio1.disabled=0' + printf '%s\n' 'wireless.default_radio0=wifi-iface' 'wireless.default_radio0.device=radio0' + printf '%s\n' 'wireless.default_radio0.mode=ap' 'wireless.default_radio0.network=lan' + printf '%s\n' 'wireless.default_radio1=wifi-iface' 'wireless.default_radio1.device=radio1' + printf '%s\n' 'wireless.default_radio1.mode=ap' 'wireless.default_radio1.network=lan' + printf '%s\n' 'network.lan=interface' 'network.lan.device=br-lan' + printf '%s\n' 'network.lan.proto=static' 'network.lan.ipaddr=192.168.1.1/24' + printf '%s\n' 'network.lan.netmask=255.255.255.0' + printf '%s\n' 'network.@device[0]=device' 'network.@device[0].name=br-lan' + printf '%s\n' 'network.@device[0].type=bridge' + for p in $ports; do + printf 'network.@device[0].ports=%s\n' "$p" + done + printf '%s\n' 'network.wan=interface' 'network.wan.device=eth0' 'network.wan.proto=dhcp' + printf '%s\n' 'system.@system[0]=system' 'system.@system[0].hostname=TollGate' + printf '%s\n' 'dhcp.@dnsmasq[0]=dnsmasq' + if [ -n "$private_ip" ]; then + printf '%s\n' 'network.private=interface' 'network.private.proto=static' + printf '%s\n' 'network.private.device=br-private' + printf 'network.private.ipaddr=%s\n' "$private_ip" + printf '%s\n' 'network.private.netmask=255.255.255.0' + printf '%s\n' 'network.private_bridge=device' 'network.private_bridge.type=bridge' + printf '%s\n' 'network.private_bridge.name=br-private' + printf '%s\n' 'firewall.private_zone=zone' 'firewall.private_zone.name=private' + printf '%s\n' 'firewall.private_zone.network=private' + printf '%s\n' 'firewall.private_zone.input=ACCEPT' + printf '%s\n' 'firewall.private_zone.output=ACCEPT' + printf '%s\n' 'firewall.private_zone.forward=ACCEPT' + printf '%s\n' 'firewall.private_forwarding=forwarding' + printf '%s\n' 'firewall.private_forwarding.src=private' + printf '%s\n' 'firewall.private_forwarding.dest=wan' + fi + } >> "$UCI_STATE" +} + +# A stock router whose captive bridge declares NO ports and which has no +# management bridge yet: the one case the writer must refuse. +seed_portless() { + : > "$UCI_STATE" + { + printf '%s\n' 'network.lan=interface' 'network.lan.device=br-lan' + printf '%s\n' 'network.lan.proto=static' 'network.lan.ipaddr=192.168.1.1/24' + printf '%s\n' 'network.lan.netmask=255.255.255.0' + printf '%s\n' 'network.@device[0]=device' 'network.@device[0].name=br-lan' + printf '%s\n' 'network.@device[0].type=bridge' + } >> "$UCI_STATE" +} + +count() { grep -F -c "$1" "$UCI_STATE" 2>/dev/null | tr -d ' '; } +has() { grep -F -q "$1" "$UCI_STATE"; } +line() { grep -F -x "$1" "$UCI_STATE" 2>/dev/null; } +upstream() { printf '%s\n' "$1" > "$FAKE_IP_UPSTREAM"; } +no_upstream() { : > "$FAKE_IP_UPSTREAM"; } +draw() { printf '%s\n' "$@" > "$HEXDUMP_QUEUE_FILE"; } + +run_same_version() { # the reinstall/upgrade path: flag already at SETUP_VERSION + printf '%s\n' "$FAKE_VERSION" > "$FLAG" + : > "$LOGFILE" + sh "$SCRIPT_UNDER_TEST" >/dev/null 2>"$TMP/run.err" + return $? +} + +ipaddr() { sed -n 's/^network\.mgmt\.ipaddr=//p' "$UCI_STATE" | tail -n1; } + +# --------------------------------------------------------------- assertions +# Kept in functions so the RED controls at the bottom can run the exact same +# checks against deliberately broken copies of the script. +check_bridge_written() { #