Skip to content

docs(architecture): the board is the default face - the switch, the cross-links, and the release gate - #603

Merged
c03rad0r merged 4 commits into
OpenTollGate:mainfrom
felixfelix-bot:pr/board-default-ui
Sep 27, 2026
Merged

c03rad0r merged 4 commits into
OpenTollGate:mainfrom
felixfelix-bot:pr/board-default-ui

Conversation

@felixfelix-bot

Copy link
Copy Markdown
Contributor

What

Operator decision 2026-09-26: the TollGate board becomes the default face —
the hostname/entry point opens the board, not LuCI — and LuCI stays
available but secondary, via (a) a cross-link button in the board UI and
(b) a config switch for what the hostname/ports serve. This PR is docs
only
: it records the decision, the mechanism, and the three defects the
decision has to answer; it ships no behaviour.

Files:

The decision in one screen

entry_ui entry pair (:8080 + :443 + derived redirect) secondary pair (:8090 + :8443)
board (default) board — uhttpd.admin, home=$ADMIN_HOME, error_page=/index.html, ubus_prefix=/ubus, no cgi/lua prefixes LuCI — uhttpd.main, home=/www
luci (legacy) LuCI — uhttpd.main board — uhttpd.admin, i.e. today

Ports move; webroots do not. The two port sets are unchanged, so
31-*.nft:52-53, 32-*.nft:54-55, the br-mgmt scope (PR #601), the pre-auth
list and the guest path are all untouched — what changes is which UI answers on
each pair, and the whole change reverts with one value (entry_ui=luci).

Why the entry point is a port question: uhttpd resolves one docroot per
instance
and resolves CGI against it, so LuCI's webroot must be /www and
the board's must not be — which is exactly the 2026-09-20 MT3000 incident the
portal's 92-tollgate-admin-setup header records (home=/www on the board's
port served the real LuCI CGI, #566). A path prefix on one instance, a shipped
/www/index.html (image-owned; the next apk transaction restores or deletes
it) and a hostname alias (DNS selects an address, not a port) are each rejected
with the reason in the record.

Two things the record decides against shipping blind

These are the parts a reviewer should look at hardest, because both are how this
change would take an admin UI away rather than add one:

  1. A half-converged pair degrades to today's mapping, never to a bind fight
    (D4).
    One port, one binder. The two halves live in two repos and arrive by
    different installers (module postinst runs 90/99; the feed's recipe brings
    92; at boot the numeric order is 90, 92, 99), so a router carrying a new
    99 beside a pre-switch 92 would have both sections claiming :8090
    and one instance would not start. The mode-aware 92 therefore announces the
    mapping protocol (a marker) and 99 honours entry_ui=board only when it
    is present; otherwise it repairs to the luci mapping, logs one WARNING, and
    leaves the operator's value recorded for the release that carries both halves.
    Stated as a release gate: the board default may ship only in a release whose
    feed pin (vendor.lock.json / PKG_SOURCE_VERSION) carries the mode-aware
    92. The feed is the atomicity boundary — the same shape the br-mgmt
    record's promotion order uses.

  2. The cross-link is a router answer or it is not rendered (D5). A
    per-client answer is not available to the board: its backend is rpcd over
    the uhttpd instance's ubus_prefix and the plugin sees the request payload
    only (openwrt/rpcd/tollgate pipes it through cat), while the one place
    that can answer per socket is the module's identity resolver on :2121 —
    which the admin path deliberately cannot reach (30-backend-firewall.nft:21-22
    drops :2121 for iifname != { "br-lan", "lo" }). So the record keeps
    liveness a router-wide fact (tollgate ui links --json →
    https://<same-host>:<router-supplied-port>/, only while that listener exists
    with an identity that covers this router and that UI's port pair is inside
    the admin-access scope; otherwise url: null and no button) and makes "the two
    UIs' network scope is identical" an invariant with a drift guard. That is the
    honest shape of the card's second constraint, and A5 in the record says what a
    genuine per-client rule would require.

The three defects read from source

  1. The docs called LuCI's URL "the admin UI". docs/rc-tester-guide.md:419
    sends the tester to https://<hostname>.lan/; :442 then says the board has
    listeners of its own. Both true, the pair ambiguous, and :443 answers
    LuCI — which is what an operator read as "the board is at
    https://<hostname>.lan/". fix(packaging): provision the router's TLS identity on setup, and require a covering certificate for redirect_https #593's subject was TLS identity, not which UI a
    URL answers.
  2. The board's own cross-link is the thing being removed. Portal
    origin/main admin/src/routes/login.tsx:336 is
    href={`http://${window.location.hostname}:8080/`} — cleartext, a
    hardcoded port, an admin login on the far side, and self-referential once
    :8080 answers the board.
  3. The board's :8443 still serves the image's placeholder identity. Portal
    92 keys the opt-in listener off /etc/uhttpd.crt, which is the OpenWrt
    image's placeholder (CN=OpenWrt, no covering SAN); the module's generator
    writes /etc/tollgate/ssl/server.{crt,key} (src/cmd/tollgate-cli/ssl.go:23-31,
    /etc/uhttpd.crt survives only as a fallback for uhttpd.main,
    ssl.go:1143-1155). fix(packaging): provision the router's TLS identity on setup, and require a covering certificate for redirect_https #593 fixed the entry listener and left this one, so an
    HTTPS-only cross-link cannot point at :8443 as shipped.

Also in this PR: the guide half of defect 1

docs/rc-tester-guide.md §7 is now "The two admin UIs, and what a
certificate warning means": one table naming both UIs and their listeners, a
plain statement that the entry point answers LuCI on this build (so a report
saying so describes the shipped mapping, not a defect), the HTTPS-only rule for
admin cross-links, and the board's :8443 certificate question separated from
LuCI's :443 one.

What is not delivered here

The mapping itself. It is split across two repositories and one release
boundary by design, and the record names the slices:

  1. tollgate-module-basic-go — the entry_ui field/schema/defaults/migration,
    99's mapping writer and the D4 marker gate, the identity-follows-listener
    change (setup_uhttpd_tls_identity + ssl.go), the mode-aware rewrites of
    drop_admin_listeners / setup_uhttpd_configui /
    sanitize_uhttpd_main_configui_port, tollgate ui links, offline suite 1-10;
  2. tollgate-captive-portal-site — 92 becomes mode-aware (entry vs secondary
    pair, marker, the provisioned identity, the pre-auth add_list block removed
    — it contradicts the module's own del_list and, in one mode or the other,
    names the LuCI admin login; :8443 no longer keyed off /etc/uhttpd.crt),
    the login page's anchor replaced by the ui links render, the portal-side
    guards;
  3. FreedomTechFeed/packages — re-vendor + re-pin in the same release that turns
    entry_ui=board on by default;
  4. bench — the eight bench assertions, on the artifact that carries 1-3.

Nothing here has been measured on hardware. The assertions are the only
honest statement of what "done" means, and the record says explicitly which of
them need the router.

Gates

merge_claim: this PR is the single merge for this change — branch
pr/board-default-ui, files docs/architecture/default-ui-and-entry-port-decision.md
(new), docs/rc-tester-guide.md, CHANGELOG.md. No other open PR carries it.
docs/rc-tester-guide.md is a hotspot (#601 also edits it) and CHANGELOG.md is
the usual prepend conflict; both are docs-only and resolve by keeping both sides.

…hips in the other repo

Operator decision 2026-09-26: the TollGate board becomes the DEFAULT face - the
hostname/entry point opens the board, not LuCI - and LuCI stays available but
secondary, via a cross-link BUTTON in the board UI and a config SWITCH for what
the hostname/ports serve. This PR records the decision, the mechanism that makes
it a two-repo change, and the three defects it has to answer. It ships no
behaviour: docs-only.

The record: docs/architecture/default-ui-and-entry-port-decision.md

  * The switch is a flat config.json field, entry_ui in {board, luci}, default
    board (D1): declared in the schema so the board's settings page renders it
    with no SPA code, read by the uci-defaults writers with jq, and a missing
    file / missing key / unparseable value all resolve to the SAME mapping as
    board - never to "no admin listener" (invariant 6).

  * The mapping moves PORTS, not webroots (D2): in board mode the entry pair
    (:8080 HTTP + :443 HTTPS + the derived redirect) is answered by uhttpd.admin
    (the board) and the secondary pair (:8090/:8443) by uhttpd.main (LuCI); luci
    mode is today's mapping exactly. Because the two port SETS do not change,
    neither guard fragment, nor the br-mgmt scope, nor the pre-auth list, nor
    any guest-path byte moves - and the whole change reverts with one value.

  * Why the entry point is a port question at all: uhttpd resolves ONE docroot
    per instance and resolves CGI against it, so LuCI's webroot must be /www and
    the board's must not be. The 2026-09-20 MT3000 incident (portal
    92-tollgate-admin-setup header, OpenTollGate#566) is what that costs when it is wrong.
    Path prefix, a shipped /www/index.html (image-owned, restored by apk) and a
    hostname alias are each rejected with the reason.

  * One writer per section, and the identity follows the listener (D3): the
    portal's 92 owns uhttpd.admin (the only writer that knows the build-time
    brand webroot), the module's 99 owns uhttpd.main, and both compute the
    mapping from the same switch value. uhttpdCertPath()/applyRedirectHTTPS()
    (src/cmd/tollgate-cli/ssl.go:858-880) read uhttpd.main.cert BY SECTION NAME
    today and must read the certificate of the section that owns :443, or in
    board mode the module would keep provisioning the secondary listener while
    the entry served the image placeholder.

  * A half-converged pair degrades to today's mapping, never to a bind fight
    (D4). One port, one binder: a new 99 beside a pre-switch 92 would have both
    sections claiming :8090 and an admin UI would not start. The mode-aware 92
    announces the protocol; 99 honours entry_ui=board only when it does.
    Consequence, stated as a release gate: the board default may ship only in a
    release whose feed pin carries the mode-aware 92 - the feed is the
    atomicity boundary, as in the br-mgmt record's promotion order.

  * A cross-link is a router answer or nothing (D5): tollgate ui links --json
    returns an https:// URL on the same host with a port the router supplies,
    only while that UI's TLS listener exists with an identity that covers this
    router and that UI's port pair is inside the admin-access scope; otherwise
    url is null and NO button is rendered, with the reason surfaced instead of a
    dead link.

Three defects read from source, all in the Context section:

  1. The release guide called LuCI's URL "the admin UI" (docs/rc-tester-guide.md
     :419 vs :442): both statements true, the pair ambiguous, and :443 answers
     LuCI - the inconsistency OpenTollGate#593 left, and what the operator read as "the
     board's URL is https://<hostname>.lan/".
  2. The board SPA's own cross-link is
     href={`http://${window.location.hostname}:8080/`}
     (portal origin/main admin/src/routes/login.tsx:336): cleartext, a
     hardcoded port, and self-referential once :8080 answers the board.
  3. The board's opt-in :8443 still serves the image's placeholder identity:
     portal 92 keys it off /etc/uhttpd.crt while the module's generator writes
     /etc/tollgate/ssl/server.{crt,key} (ssl.go:23-31; uhttpdCertDefault is a
     fallback for uhttpd.main, ssl.go:1143-1155). OpenTollGate#593 fixed the entry listener
     and left this one.

Also in this PR, the guide half of (1): docs/rc-tester-guide.md section 7 now
names BOTH admin UIs and their listeners in one table, says plainly that the
entry point answers LuCI on this build (a report saying so is describing the
shipped mapping, not a defect), states the HTTPS-only rule for admin
cross-links, and separates the board's :8443 certificate question from LuCI's
:443 one.

17 assertions that must hold before this ships, split offline/bench, each one
something a person or a script can run, plus the implementation slices per repo
(module, portal, feed, bench card). Two things this record deliberately does not
decide are listed as such: the admin-access network field (card t_868d0fa7,
folding into the br-mgmt record) and the paywalled wired network (t_ba0558f2).
…t opened as

The entry was written before the PR existed and guessed OpenTollGate#602; OpenTollGate#602 was taken.
@Amperstrand

Copy link
Copy Markdown
Collaborator

Technical review (evidence pass) — PR head 5a5de56090ed561ee1b686243c27df023dd70519 (base 0 commits behind main).

Docs-only: a decision record, the guide half of defect 1, and a CHANGELOG entry. Compact pass per the triage rule — but the record's citations were still walked against source, because a document that will be cited as the source of the mapping is only as good as its line numbers.

The claims check out. Every in-tree citation verifies exactly: uhttpdCertPath()/applyRedirectHTTPS() read uhttpd.main.cert by section name (ssl.go, L857-L881); /etc/uhttpd.crt survives only as the uhttpd.main fallback (ssl.go L1141-L1157); the board pair is dropped on br-lan at 31-admin-board-not-guest-reachable.nft:52-53 and LuCI's at 32-luci-not-guest-reachable.nft:54-55, both families; :2121 is dropped off br-lan/lo at 30-backend-firewall.nft:21-22; the stale ":8080 (LuCI, kept reachable pre-auth on purpose)" comment is at 31-*.nft:45-47 and does contradict 32-*.nft; setup_uhttpd, setup_uhttpd_tls_identity, setup_uhttpd_trusted_entry, and setup_uhttpd_configui (with the /www/net4sats accident) sit at the cited 99-tollgate-setup lines; DEPENDS:=+jq is packaging/Makefile:59 exactly; defaults_parity_test.go and the three named test files exist. Cross-repo: portal origin/main really has href={http://${window.location.hostname}:8080/} at admin/src/routes/login.tsx:336, and 92 really keys :8443 off IMAGE_CERT="${UHTTPD_IMAGE_CERT:-/etc/uhttpd.crt}". The base-guide quotes (:419 "Log in at https://<hostname>.lan/" against the board-listeners bullet lower down) are accurate against this PR's parent — which is current main 37c51415, the same SHA the record's "verified in-tree" line names. Defects 1–3 are real and correctly described; no issue needs a Closes for a decision record, consistent with the sibling records.

One factual slip worth fixing. The SOP-surface cost bullet cites "the operator guide's 'where do I log in' paragraph" (record, L290-L295) and assertion 10 folds docs/operator-guide.md into the grep-level drift test over the four port numbers (record, L398-L403) — but docs/operator-guide.md (812 lines) contains none of the four ports and no login/admin-UI paragraph at all (zero hits for 8080|8090|8443|443, LuCI, board, admin, or "log in"). As written, the operator-guide half of assertion 10 is vacuous today: a drift test cannot fail on a file that names nothing. Fix: either drop operator-guide.md from both spots and let docs/rc-tester-guide.md carry the mapping alone, or have slice 1 add the operator-guide paragraph first so the test has something to grep — the latter is probably what you want anyway, since after the flip an operator asking "where do I log in" lands in that file. Two lines; fine to push before merge or fold into slice 1.

The §7 rewrite itself is the honest correction of defect 1 — one table naming both UIs and their listeners, a plain statement that the entry point answers LuCI on this build, and the board's :8443 certificate question separated from LuCI's :443 (guide, L410-L437); the heading rename breaks no anchors (nothing else links the old heading text). Coordination is explicit rather than implied: bench item 15 gated on #601's port move, the admin-scope field (card t_868d0fa7, now PR #604) deferred by name, t_ba0558f2 untouched, and the rc-tester-guide.md/CHANGELOG.md hotspot with #601 called out in the merge claim. Deferring the 31-*.nft:45-47 comment fix to the implementing PR is right — touching it here would break this PR's own docs-only gate. The D4 marker + feed-pin release gate is the load-bearing part of the record and is stated as a checkable gate, not a hope; the 17-assertion split (offline vs bench, "a test is not evidence for the bench and vice versa") is the correct shape for this repo.

Hygiene: two clean commits with the "#602 was taken" repair as its own commit, no assistant-attribution footers; CHANGELOG entry under Changed / Internal in the house wrapped format with the correct PR link; no code, dependencies, or packaging surface touched, so the Go battery and contract lint are unaffected; the pre-commit claim matches the configured hook set and the new file is clean on trailing whitespace and EOF newline.

Disposition: land-with-followups —

  • Fix or re-scope the two docs/operator-guide.md references (record L291 and L399) as above, preferably before merge;
  • slice 1 then adds the operator-guide "where do I log in" paragraph so assertion 10 has a real target.

@c03rad0r
c03rad0r self-requested a review September 27, 2026 10:35
@c03rad0r
c03rad0r enabled auto-merge (squash) September 27, 2026 10:36
main moved four commits ahead of this branch's base (37c5141) while the PR was
in review. One conflict, and it is the CHANGELOG [Unreleased] / Changed /
Internal section only: both sides prepend an entry at the same anchor. Kept
both (OpenTollGate#603's board-default-face record first, then main's OpenTollGate#606 src/cli
module-file tidy). Nothing else in this branch conflicts; the branch is
docs-only (docs/architecture/default-ui-and-entry-port-decision.md,
docs/rc-tester-guide.md, CHANGELOG.md) and the merge adds nothing to that set.

Checked rather than assumed: `git diff upstream/main -- CHANGELOG.md` has zero
removed lines, i.e. no side of the conflict was dropped.
auto-merge was automatically disabled September 27, 2026 11:21

Head branch was pushed to by a user without write access

OpenTollGate#603)

Second pass: main moved again while this branch was in review (0a7c3bb ->
b6ecb16, the squash-merge of OpenTollGate#608). Same single conflict, same anchor:
CHANGELOG [Unreleased] / Changed / Internal, where both sides prepend an entry.
Kept both (this branch's board-default-face record, then OpenTollGate#608's per-merchant
pre-flight seam); the previously merged OpenTollGate#606 entry is untouched below them.
Zero removed lines against main.
@felixfelix-bot

Copy link
Copy Markdown
Contributor Author

Conflicts resolved — the PR is MERGEABLE/CLEAN at 3f4512f3.

What conflicted

Only CHANGELOG.md, and only at the same anchor, in the [Unreleased] / Changed / Internal section where both this branch and main prepend an entry. Nothing in docs/ conflicted, and no file of this branch was touched by the merge.

The conflict had to be resolved twice because main moved while I was working:

pass base merged in what collided head
1 0a7c3bbc (#602) this entry vs #606's bbc240d4
2 b6ecb167 (#608, squash) this entry vs #608's 3f4512f3

Both passes were unions — this branch's entry keeps its place first, and main's entries (#608, then #606) follow, which is the section's existing newest-first order. Checked rather than asserted: git diff upstream/main -- CHANGELOG.md has zero removed lines at the final head, i.e. neither side of either conflict was dropped.

Verification of the resolved head

  • Docs-only, re-derived from the diff, not from the title: git diff --name-only upstream/main is exactly CHANGELOG.md, docs/architecture/default-ui-and-entry-port-decision.md, docs/rc-tester-guide.md — no code, no workflow, no packaging file.
  • The guide's cross-reference still resolves: docs/rc-tester-guide.md:422 names docs/architecture/default-ui-and-entry-port-decision.md, which is the file this PR adds.
  • Both sides of the union are present in the merged file (the #603 entry and main's #606 entry each appear once).
  • No docs lint runs in this repo's CI (no markdownlint config, no docs lane in .github/workflows/test.yml), so there is nothing further to run locally; the merge itself introduces no non-docs byte.

Note for the record: this repo's live CI lane is ngit CI — a pushed GitHub PR branch is not mirrored, so no workflow fires on this head unless it is pushed to the mirror and triggered manually. Nothing in this PR is coverable by the Go lanes anyway (docs-only), so I did not spend a CI run on it. If you want a green check recorded for the release gate, say so and I will push the head to ci/board-default-ui and replay the go-test lane.

@c03rad0r
c03rad0r merged commit afc86b3 into OpenTollGate:main Sep 27, 2026
Amperstrand pushed a commit that referenced this pull request Sep 30, 2026
Status is Decided and names v0.6, so the record follows the #603
precedent (a decided ADR carries an entry) rather than the #615
proposal-only one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants