# 0059 — LAN exposure is opt-in and serves only the bootstrap surface

Status: accepted (2026-09-06 WEBSERVE grill round 1, releases#272 / #266)

## Context

The docs server is ruled loopback-only (REQ-DOCS-LOCAL-SERVER): a non-loopback bind would
re-publish what privating the docs hid. #266 wants a fresh machine with no spt installed to
fetch the binary from a peer (`hfenduleam:5474/bin/spt.exe`) — a fresh node has no Iroh
identity yet, so only plain LAN HTTP can reach it. The two collide.

## Decision

**A separate, opt-in LAN listener that serves ONLY the bootstrap surface** — the binaries and an
install page — started explicitly (`spt serve lan --bootstrap`), off by default, and loud
while active. The docs and the serving registry stay loopback-only; the ruling stands
unchanged. Licensing lines up with the split: the binary is redistributable, the docs are
private.

## Alternatives rejected

- **Drop #266; bootstrap stays "copy the binary by hand".** Safe, but the whole reason a fresh
  box wants a peer is that hand-copying is what people get wrong. Rejected as scope cut, kept
  as the fallback if the listener is ever found leaking.
- **Relax loopback-only generally.** Rejected outright: it silently re-publishes the privated
  docs. The ruling is not being revisited.

## Consequences

- Two listeners with two audiences: loopback for the node's own surfaces, LAN for strangers
  who are not yet nodes. Anything served on the LAN one is by definition public on that LAN.
- **Listener mechanics (round 2, Q12):** a SEPARATE listener on its own port — **5470**
  (IANA `apsolab-col`, the same obscure Apsolab family as the docs server's 5474 `apsolab-rpc`;
  no popular software claims it; 5471–5473 unassigned) — with its own process-level allowlist
  (`/bin/*`, `/install`). Not a path allowlist on the 5474 server: a routing bug on 5474 can
  never expose the docs on the LAN because the LAN listener does not have them.
  `spt serve lan --bootstrap` prints the LAN URL and the install one-liner, and the now-signal
  carries a LAN-EXPOSED line for as long as it is up.

## Amendment 1 — integrity: the applied signed set, per-triple sha-anchored

Ruled 2026-09-07 (doyle, releases#266), refined the same day after measuring the real
release cache on `hfenduleam`: three triples (`x86_64-pc-windows-msvc`,
`x86_64-unknown-linux-gnu`, `x86_64-unknown-linux-musl`) under ONE `release.json`, so the
signature is per SET, not per platform.

The decision above settles ACCESS. It does not settle what the bytes are, and the puller
**executes what it downloads over plain HTTP** — so access rules were never the story. Three
conjuncts, all required:

1. **Set gate (whole listener, once at start).** The served set is the release-cache set whose
   HOST-triple `artifact_sha256` equals the running daemon's own executable — canonical path
   captured at t=0, never a per-request `current_exe()` (KH 6.11) — AND whose applied state
   reads `applied` at that set's version. Failure refuses the START by name
   (`LAN_BOOTSTRAP_REFUSED:unsigned-exe` / `:sha-mismatch` / `:set-not-applied`) and serves
   nothing. **There is deliberately no "serve it marked unsigned" branch** — that is the
   supply-chain shape this amendment exists to refuse. A staged-but-unapplied newer set is
   never served: a stranger gets what this node RUNS, never what it is about to run.
2. **Per-triple gate (per request).** `/bin/<triple>/spt[.exe]` serves staged bytes only if
   they hash to that triple's `artifact_sha256` in the same signed metadata. A missing or
   mismatched NON-host artifact answers `404
   LAN_BOOTSTRAP_TRIPLE_UNAVAILABLE:<triple>:<missing|sha-mismatch>` while the host triple
   keeps serving — a missing musl artifact must not block a Windows→Windows bootstrap. The
   host triple failing IS the set gate.
3. **Out-of-band anchor (the operator's eyes).** `--bootstrap` prints one
   `sha256 <triple> <hex>` line per served triple; the LAN-EXPOSED now-signal line carries the
   host triple's; `/install/<triple>` bakes THAT triple's hex into `--expect-sha256` (chosen by
   the page's segment, **never sniffed** from the request); and `spt install` prints the sha256
   of what was downloaded BEFORE it verifies. Provenance rides beside the bytes as
   `<binary>.release.json` (the exact signed record, byte-identical on every triple path), and
   `install --release-json` re-runs signature and digest verification against the puller's
   BUILT-IN keys before touching disk.

Conjunct 3 exists because conjunct 2 is circular alone — a tampered binary carries tampered
trust roots. Two machines on one LAN and a human comparing two lines is the only non-circular
anchor a bootstrap without transport security can honestly offer.

**Ephemeral by construction.** No up-state is persisted: a daemon restart comes back with the
listener DOWN. Off-by-default is a property of every boot, not of the first one.

## Amendment 2 — bootstrap firewall admission and owned-rule cleanup

Ruled 2026-09-11 (REMOTE-FRICTION #304, releases#297); pair shape and program-scope
clauses re-ruled 2026-09-12 after arm E (releases#304 comments 5642444382, 5642452628).

<!-- [doc->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] -->
Starting bootstrap reconciles an owned inbound TCP **pair** of rules for the listener's
**actual bound port**, including port overrides: a tailnet half (all profiles, remotes
`100.64.0.0/10`) and a LAN half (`Private,Domain`, remotes the **literal IPv4 prefixes of
the host's connected interfaces**, e.g. `192.168.1.0/24`). The halves are
not mergeable -- a LAN prefix on an all-profile rule would admit that subnet on a
public network, and a CGNAT-only rule admits no LAN peer, which is what this surface
exists to do.

**The LAN half's remotes are derived from the host, not fixed (FOLD-3, ruled 2026-09-12).**
`LocalSubnet` was measured on Windows (HFENDULEAM, releases#304 W2) to **drop** traffic a
literal `192.168.1.0/24` **admits** -- same port, listener, peer and minute, discriminated
four ways plus a one-column A/B -- so the product's own LAN admission did not admit.
Loopback, link-local/APIPA, the CGNAT range (the tailnet half's own scope) and any address
the host will not route from are excluded, and a prefix outside `/8`..`/32` is refused
rather than rendered, because a `/0` would admit the entire internet to this port.
**This makes admission non-stationary: moving the machine to another network leaves the
written LAN rule scoped to a network it has left, so verification reports UNVERIFIED and
the next bootstrap rewrites the LAN half -- that rewrite is the repair, and it is logged
with both the old and the new scope.** A host carrying no connected IPv4 address at all
(tailnet-only, or APIPA) has no LAN to admit: the LAN half is **omitted rather than written
with an empty scope**, the tailnet half is unaffected, and admission reports UNVERIFIED
naming the cause, since elevation cannot repair a missing network. Neither half carries a program (executable) filter; see the platform
paragraph below. Request Windows UAC or an available Linux
elevation mechanism when necessary. This changes neither docs' loopback-only binding
nor tailnet policy.

Admission failure, denied elevation, cancellation, or unavailable tooling does **not**
prove the listener unreachable. If the normal signature and bind gates pass, leave
bootstrap running, report firewall admission **UNVERIFIED**, and tell the operator to
rerun bootstrap to retry admission. Repeated start retries admission without replacing
the already-admitted signed set or rebinding its listener.

Elevation launch is not repair completion. Report admission reconciled only after
observing BOTH owned rules and verifying each one's TCP port and complete network scope
(profiles and remotes). There is no executable to verify: the rules carry no program
filter. Even that observation is not an end-to-end reachability test: host
policy outside these rules and upstream filtering can still prevent a connection.

On **both platforms**, admission is port-scoped: executable identity is
ownership/reporting metadata, **not an enforced firewall restriction**. Another process
using that port may also be admitted. This is a narrow-scope choice (releases#297), not a
platform limitation -- arm E measured on the LAN (HFENDULEAM, 2026-09-12) that a
program-scoped Windows rule DOES admit. It is omitted because port+remote already narrows
admission to this port from these remotes, so a program filter admits no peer they do not,
while pinning an absolute exe path that stops matching after a reinstall, rename or
rebuild. The earlier reading that program scope admits nothing came from a TAILNET fetch,
where the blocker is receiver-side and above the host firewall; that citation is void.

Preserve active-manager precedence (UFW, firewalld, then raw
nftables); an opaque active manager stays UNVERIFIED, without replacing its backend.
Cleanup requires provable ownership and must never delete an indistinguishable
pre-existing port allowance.

Stop the listener independently of rule removal. Remove only bootstrap-owned TCP rules;
installation's UDP rule and unrelated rules are never cleanup targets. Reconcile stale
owned rules left by abnormal termination. If cleanup cannot complete, report the residual
rule and its cleanup command even though the listener is stopped.

The existing `SPT_INSTALL_NO_FIREWALL` host-policy opt-out also suppresses bootstrap
firewall writes and elevation requests. It does not suppress listener startup, shutdown,
or an UNVERIFIED admission/cleanup report. Tests invoking the CLI on an elevated host
must set this opt-out unless they explicitly own an isolated firewall fixture.

<!-- [doc->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] -->
<!-- [doc->REQ-HAZARD-SUBPROCESS-TIMEOUT] -->
**Rule-heavy Windows admission (2026-09-14, fifth-window repair).** The previous
candidate's initial census completed in 2650 ms, but a redundant pre-write census
was killed at 3027 ms by the three-second child limit; neither owned rule was
written. Elevated bootstrap retries now enter the serialized helper before any
advisory verification. That helper verifies one census and uses the same evidence
to derive LAN scope and any repair, without transporting observations across an
elevation boundary. An already-matching pair needs no write. Write-side ownership
checks and post-write verification remain fresh, and scope-change reporting is
unchanged. Opted-out callers retain read-only verification.

Each Windows NetSecurity invocation has a **30-second operation budget**, including
the ownership queries inside write/cleanup scripts. This is a bounded allowance for
administrative store walks, not a measured guarantee for every store size; Linux's
existing three-second command budget is unchanged. The elevated repair path has at
most three such children: pre-write census, write, post-write verification
(3 × 30 s of nominal child budgets). Ownership queries inside a script are not
additional children. A hung pre-write child returns after its first budget, so later
legs do not run. Process creation, kill/reap and scheduling can add wall time; these
budgets are not a strict end-to-end CLI deadline. The listener is bound and
`LAN_BOOTSTRAP_UP` is printed before admission begins: slow policy work delays the
admission verdict, not listener startup, and still does not prove remote reachability.

<!-- [doc->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] -->
**Package binding and multi-profile enforcement (2026-09-14, sixth-window repair).**
Window 6 completed the previously killed query and wrote both rules, but they
remained inactive (`PrimaryStatus=Inactive`, raw enforcement `[5,20]`); a remote
LAN GET failed while program-scoped positive-control ports admitted traffic.
The subsequent 19-variant scratch experiment isolated the explicit `-Package Any`
argument: adding only that argument to a minimal rule changed `OK/[5,1]` to
`Inactive/[5,20]`. All four Package-bearing variants were inactive; all fifteen
others were OK. The write now omits only `-Package Any`; other arguments and both
profile/remote scopes remain unchanged. NetSecurity reports the omitted application
filter's Package as empty, so read-side hygiene now requires an empty Package,
not the literal `Any` binding.

The earlier exact-`[1]` requirement incorrectly refused a multi-profile rule on
a host with only Private interfaces. Per doyle's measured ruling, enforcement
requires raw `PrimaryStatus=1` (OK), code `1` present, and code `20` absent.
Inactive-profile code `5` beside success is allowed; the Domain profile is not
removed to hide it. Both fields retain typed raw-CIM transport; absent, null or
malformed evidence remains a query fault. A non-OK status or missing success
remains unverified, as does code `20` even beside success. This verdict is still
not an end-to-end reachability guarantee.

**Firewalld provisioning boundary (doyle, 2026-09-11):** a fresh named policy can
only be created permanently; activating it requires reload, which discards unrelated
runtime-only configuration. Bootstrap does not reload, silently create pending permanent
policy, or substitute an indistinguishable port allowance. Without an existing provably
owned runtime policy, keep the normally-valid listener running and report UNVERIFIED
with the specific provisioning prerequisite. Reconcile an existing owned runtime policy
only where safe. A managed provisioning/reload flow requires separate operator
authorization. See firewalld's [new-policy and reload contract](https://firewalld.org/documentation/man-pages/firewall-cmd.html).
