feat(start-wrt): SNI hostname routes end to end — UPnP vendor actions, StartWRT dataplane, Remote Access coexistence - #3783
Conversation
|
I think it's fine to lose source-ip preservation when hairpinning. that's what start-tunnel does. |
Done in 9d61d04. The demux now drops source preservation when the client sits in the target's own subnet, and only then — plain connect from the router, transparent_connect everywhere else. |
|
I completed a correctness and naming/comment pass and pushed three commits. The main fixes make StartTunnel persistence and StartWRT admission transactional, restore exact prior leases on failed renewal, serialize WAN re-keying with route updates, require diversion and firewall activation before acknowledging grants, enforce the draft’s hostname syntax, and align API/frontend terminology. I also repaired the hostname compile regression introduced by the latest master merge. Validation: full |
6962160 to
9a3f26f
Compare
dr-bonez
left a comment
There was a problem hiding this comment.
otherwise looks good. should probably be tested again on both start-wrt / start-tunnel since helix's changes
9a3f26f to
7bd98cc
Compare
|
Agreed. I reran the StartWRT Rust suite after the latest change (549 passed), and the earlier full StartTunnel suite covered the transactional changes, but those changes have not been re-tested on the physical setup. I updated the PR description to make that explicit: a final StartWRT and StartTunnel bench pass is still required before merge. |
StartWRT hardware retest of the review-fix commits (562184c, 90e5dea, 7bd98cc)Same double-NAT rig as the earlier phases: K1 on the 1. First grant on a fresh port (grant now blocks on the firewall reload) — pass. Admit rule written with 2. UPnP vendor add and delete under the stricter argument checks — pass. With UDP 5351 dropped at the router, a new domain was granted via 3. Manual forward saved over a demuxed 443 — pass. Dialog named both holders (Remote Access and the hostname route with its device). Confirming evicted the route at once; Not re-run: UDP-only forward, Forget with a static reservation, Remote Access matrix, WAN re-key, restart purge. Each is unit-pinned in these commits or unchanged in behaviour. Still outstanding: the StartTunnel bench pass for the transactional persistence and the forward write lock. One note: 562184c also changes |
7bd98cc to
6af4ab2
Compare
|
I dropped the |
Add X_START9_AddHostnameMapping / X_START9_DeleteHostnameMapping to the shared UPnP IGD server, giving UPnP parity with the PCP HOSTNAME option: a client that reaches a Start9 gateway over UPnP but not PCP no longer silently loses SNI demux. The StartOS port-map client falls back to the vendor action when no gateway grants the hostname over PCP, detecting support via the SCPD action list the discovery already fetched (no PatchDb field, no TS bindings). Refresh re-asserts the route without a remote delete — registration reclaims idempotently for the same target, so re-adding in place avoids a per-tick outage window. Unlike standard UPnP mappings, vendor-action routes are always lease-bearing (clamped to the server max): a permanent SNI binding is reserved for operator-created routes, and an unreaped device route would answer HostnameTaken (fault 800) to its legitimate owner forever. The delete action carries NewInternalPort because an SNI route's ownership is its full (peer, internal port) target, mirroring the PCP lifetime-0 MAP, and is gated on is_known_client like the PCP delete. Non-TCP requests are refused (the demux is TCP-only), and hostnames are validated client-side before being interpolated into the envelope. Both handlers guard on the backend having an SNI dataplane, faulting 801 HostnameNotSupported otherwise — the UPnP twin of the PCP path's RESULT_UNSUPP_HOSTNAME refusal. Served by StartTunnel today; a StartWRT gateway (which has no dataplane yet) advertises-but-refuses until its demux lands, then serves the action with no further edit.
StartWRT's port-control gateway now serves TLS-SNI hostname routes end to end — PCP HOSTNAME and the X_START9_AddHostnameMapping UPnP vendor action both work against the router instead of faulting 801, so several devices (or several services on one StartOS server) share an external port such as 443, demuxed by ClientHello hostname. Dataplane: the shared SniDemux runs as-is; what StartWRT needed was the plumbing around it. The reply-path divert's nft half ships declaratively as an fw4 include (12-startwrt-sni-divert.nft, `mark or` to preserve the 0x80 DNAT-return bit) — fw4 re-renders includes on every reload, so no reload window can drop it. The iproute2 half is parameterized via a new shared DivertConfig (route table 5344 to clear the VLAN-tag table namespace, masked fwmark to match the or-set mark, manage_nft off); defaults reproduce StartOS/StartTunnel behavior bit-for-bit. The `socket transparent` expression needs kmod-nft-socket (+kmod-nf-socket), added to the image diffconfig in this same commit — without the module fw4 refuses the entire ruleset, so the include and the kmods must ship together. Admission: each demuxed port gets a WAN-input ACCEPT rule (apf_sni_<port>, tagged _apf_label 'SNI' via a new FirewallRule field), written inline under the write lock so a concurrent plain-forward scan can never miss it; the demux's on_change teardown drops it, the sweep heals strays and gaps, and daemon start purges leftovers (routes are demux-memory only — finite-lease, device-renewed — so rules must not outlive them). The rule also makes the port read as router-reserved, keeping plain auto forwards off a demuxed port for free. Conversely add_sni_forward refuses ports already DNAT-forwarded or answered by the router itself (Remote Access, VPN): the demux's specific (wan_ip, port) bind would beat their wildcard binds and capture traffic it has no route for. WAN re-key: listeners bind the WAN address itself, so a new address strands them. A new wan hotplug hook fires published-ports.wan-changed (hidden RPC, daemon-forwarded like reconcile) to re-key immediately via the new shared SniDemux::rekey_ipv4 — which never fires the teardown callback, since the port set is unchanged — with the sweep as a once-a-minute backstop. Visibility: published-ports.auto-list now appends one row per live route (label "SNI", new hostname field, device resolved from the target address) via the new shared SniDemux::snapshot; the Automatic table gains a Hostname column. API_CONTRACT, the user docs' Automatic Port Forwarding page, and the unreleased 1.1.0 changelog entry (which claimed StartWRT has no SNI demux) updated to match; build.mk's staging deps now cover backend/hotplug and backend/nftables (pre-existing gap).
…e Access Bench testing found the StartWRT dataplane dead on 443 and worse than dead: the web UI wildcard-binds [::]:443, so the demux's specific (wan_ip, 443) bind failed EADDRINUSE forever in its spawned retry loop — while the grant had already succeeded and opened the apf_sni_443 WAN admit rule. WAN 443 traffic fell through to the UI's wildcard socket, serving the router admin interface to WAN clients with Remote Access set to Never. On a public-WAN router (where "behind NAT" mode writes no rules and so nothing conflict-refuses the route) that would have been the open internet. Three changes close it: - The demux listener and the daemon's UI 80/443 listeners all bind with SO_REUSEPORT (the DNS :53 pattern). TCP delivery prefers the most specific bound address, so the demux takes WAN-IP-destined connections and the UI wildcard keeps the LAN. - Grants are bind-gated: SniDemux::register/register_fallback bind inline and refuse with PCP NO_RESOURCES (UPnP fault 501) on failure, rolling back the registration — a grant can never outrun its socket and leave the admitted port served by whatever shares it. A re-key bind failure now drops that port's routes and fires the teardown callback rather than stranding the admit rule. - Remote Access coexists with hostname routes on 443 instead of reserving it (it is the default mode behind NAT, and demanding it be turned off to share 443 was untenable): its rules no longer count as SNI conflicts on 443; the daemon instead registers its own UI as the demuxed port's fallback, so no-SNI/unknown-SNI connections (browsing the router by IP sends no SNI) still reach the UI. The fallback leg is a plain connect (a source-preserving dial to ourselves would be martian-dropped) and enforces the same source scoping the displaced firewall rules encoded — any source in "always", RFC1918-only in "default" behind NAT, none in "never" — re-synced on route add, on a Remote Access change, and by the sweep, which also clears it when the last 443 route expires. SSH (server-speaks-first) and the port-80 redirect (plain HTTP) can't ride an SNI peek, so those Remote Access ports — and manual forwards and the VPN port — keep refusing routes. The IGD hostname stub tests move to an unprivileged external port: registration now really binds, and 443 needs root the runner lacks.
…older The publish-confirmation dialog said "Used by This Router" even when the colliding WAN-input rule was an SNI-demux admit rule — a port really held by a device's hostname routes. router_reserved_overlaps now classifies each overlapping rule, RouterPortCollision splits the specs into router_ports and sni_ports (the latter enriched from the live demux with the routed hostnames and owning devices, named the same way auto-list rows are), and the dialog composes its copy from the actual holders: router services, hostname routes, or both on a shared port. The override semantics are unchanged.
… routes `close_device_forwards` removed a device's `apf_*` UCI redirects but never touched the SNI demux, so its hostname routes kept delivering WAN traffic — admit rule and all — until their lease lapsed, up to an hour after the toggle went off or the device was forgotten. StartTunnel's `clear_for_peer` already covers routes; this brings StartWRT in line. Routes are keyed by target address, not device, so rather than carry a grant-time address→MAC map that can drift, the owner is re-derived the way a grant derives it (neighbor table, else DHCP leases and static hosts) and any route whose device is no longer `_allow_pcp` is unregistered. That audit runs on every revocation and once a minute from the sweep, which also catches an address recycled to a different, unauthorized device. An address that maps to no device is left to its lease rather than reaped on a guess.
…a hang A LAN client dialing the router's WAN address for a routed hostname hung. The demux opens the internal leg from the client's own source address, so the target — sitting on the same bridge as the client — answered it directly over that segment. The reply never came back through the router, and the client discarded it as coming from an address it never dialed. Drop source preservation for exactly that case and dial the target as ourselves instead, the trade the DNAT path already makes with the hairpin masquerade in `build/lib/scripts/forward-port` — and, on this router, the one fw4's redirect reflection already makes for every manual published port, so the two now behave alike. The demux learns a host's segments through `LocalPrefix`, the userspace equivalent of that script's `target_prefix`: StartWRT answers it from the `br-*` addresses it already parses for SSDP, so the test is per-bridge. A client on another profile's VLAN, or on the WAN, still routes its replies through the router and keeps its address. The tunnel supplies no resolver — every path through its WireGuard hub returns to it, so nothing there needs the trade. The check runs before the dial, never as a fallback after a failed one: the backend gates LAN-only addresses on the source being private, so a blanket plain-connect would present a WAN client as LAN-local.
…ofile The trust note tells the reader to isolate an untrusted device on its own profile so it cannot act for the devices they trust — true of opening forwards, and easy to over-read as reach isolation. A profile's LAN Access setting is a forwarding control, and the router serves a hostname route from its own socket, so no profile boundary stands between a client and a routed hostname. Say so, alongside the fact that makes it uninteresting: the service is published to the Internet either way.
…eptance Aiden's review flagged the bare "Status: accepted" as reading like a standards-status claim. The doc is not IETF-targeted — its own open question 4 notes a UPnP vendor action has no standards venue — so the status line now scopes the acceptance to Start9 and says where external documentation lives (the SCPD). Claude-Session: https://claude.ai/code/session_012peNJE6QiAEMDJEjWYDwjE
6af4ab2 to
8a534ea
Compare
|
Rebased onto |
Summary
Adds hostname-based shared-port mappings to StartTunnel and StartWRT. A device can register a hostname over PCP or the new UPnP vendor actions, and the gateway routes TLS by ClientHello SNI without terminating TLS.
Changes
X_START9_AddHostnameMappingandX_START9_DeleteHostnameMappingto the shared UPnP IGD server and client fallback path.rfcs/draft-start9-upnp-hostname.md; it does not claim external acceptance.Validation
cargo test -p start-core --lib— 739 passed, 3 ignoredcargo test -p startwrt-core --lib— 550 passednpm run check:wrtnpm run check:i18n:wrtmdbook build projects/start-wrt/docsmdbook build projects/start-tunnel/docscargo fmt --all -- --checkgit diff --checkThe original branch was bench-tested on a K1 router with a StartOS server behind it. PCP and UPnP fallback, SNI routing, Remote Access coexistence, manual-forward conflicts, WAN re-keying, restart reclaim, lease expiry, and same-/cross-subnet hairpin behavior were exercised. The StartWRT review-fix paths were retested: first-grant firewall activation, UPnP add/delete validation, and manual-forward displacement of an SNI route passed. A final StartTunnel bench pass for transactional persistence and the forward write lock remains.
Deployment note
A StartWRT image rebuild is required because this adds nftables rules and
kmod-nft-socketto the image.