Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 28 additions & 16 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mac> 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: |
Expand Down
43 changes: 28 additions & 15 deletions .ngit/act/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mac> 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: |
Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mac> 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
Expand Down
19 changes: 19 additions & 0 deletions docs/rc-tester-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<hostname>.lan/`.
- The TollGate admin board has its **own** listeners — `http://<router>:8090/`
and `https://<router>: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://<router>.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.

---

Expand Down
3 changes: 2 additions & 1 deletion packaging/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
86 changes: 86 additions & 0 deletions packaging/files/etc/nftables.d/33-mgmt-bridge-scope.nft
Original file line number Diff line number Diff line change
@@ -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 <mac> 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
}
Loading