Skip to content

feat(start-wrt): SNI hostname routes end to end — UPnP vendor actions, StartWRT dataplane, Remote Access coexistence - #3783

Merged
dr-bonez merged 14 commits into
masterfrom
wrt/upnp-hostname-action
Sep 2, 2026
Merged

feat(start-wrt): SNI hostname routes end to end — UPnP vendor actions, StartWRT dataplane, Remote Access coexistence#3783
dr-bonez merged 14 commits into
masterfrom
wrt/upnp-hostname-action

Conversation

@Dominion5254

@Dominion5254 Dominion5254 commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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

  • Adds X_START9_AddHostnameMapping and X_START9_DeleteHostnameMapping to the shared UPnP IGD server and client fallback path.
  • Runs the shared SNI demultiplexer on StartWRT, including reply-path diversion, WAN admission rules, lease cleanup, WAN re-keying, and Remote Access fallback on port 443.
  • Preserves source addresses except for same-subnet hairpin connections.
  • Makes route grants transactional: listener binding, reply diversion, firewall activation, persistence, lease renewal, and rollback are coordinated before success is returned.
  • Serializes StartTunnel forward mutations and StartWRT WAN re-keying with route updates.
  • Treats manual/automatic collisions by transport, so UDP-only mappings do not block TCP SNI routes.
  • Reports hostname-route owners in the Published Ports UI and uses consistent automatic-forward/WAN-port terminology across the API and frontend.
  • Rewrites the UPnP proposal as the initial Internet-Draft rfcs/draft-start9-upnp-hostname.md; it does not claim external acceptance.
  • Updates StartWRT and StartTunnel documentation and the StartWRT changelog.
  • Repairs the hostname-validation compile regression carried in by the latest master merge.

Validation

  • cargo test -p start-core --lib — 739 passed, 3 ignored
  • cargo test -p startwrt-core --lib — 550 passed
  • npm run check:wrt
  • npm run check:i18n:wrt
  • mdbook build projects/start-wrt/docs
  • mdbook build projects/start-tunnel/docs
  • cargo fmt --all -- --check
  • git diff --check

The 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-socket to the image.

waterplea
waterplea previously approved these changes Aug 22, 2026
@dr-bonez

Copy link
Copy Markdown
Member

I think it's fine to lose source-ip preservation when hairpinning. that's what start-tunnel does.

@Dominion5254

Copy link
Copy Markdown
Collaborator Author

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.

Comment thread rfcs/upnp-vendor-hostname-action.md Outdated
@helix-nine

Copy link
Copy Markdown
Contributor

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 start-core lib suite (739 passed, 3 ignored), full startwrt-core lib suite (550 passed), StartWRT TypeScript/i18n checks, both affected mdBooks, rustfmt, and diff checks.

@helix-nine
helix-nine force-pushed the wrt/upnp-hostname-action branch from 6962160 to 9a3f26f Compare September 1, 2026 20:39

@dr-bonez dr-bonez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

otherwise looks good. should probably be tested again on both start-wrt / start-tunnel since helix's changes

Comment thread projects/start-wrt/backend/ctrl/src/bins/daemon.rs Outdated
Comment thread projects/start-wrt/backend/ctrl/src/port_control.rs
@helix-nine
helix-nine force-pushed the wrt/upnp-hostname-action branch from 9a3f26f to 7bd98cc Compare September 1, 2026 20:53
@helix-nine

Copy link
Copy Markdown
Contributor

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.

@Dominion5254

Copy link
Copy Markdown
Collaborator Author

StartWRT hardware retest of the review-fix commits (562184c, 90e5dea, 7bd98cc)

Same double-NAT rig as the earlier phases: K1 on the startwrt binary from 7bd98cc (no reflash needed), StartOS x86_64 behind it on startbox from the same commit, StartWRT's WAN on RFC1918 behind an outer NAT, a client between the two NATs using curl --resolve against the StartWRT WAN. Public domains on the StartWRT gateway, no ACME. Scoped to the behaviours in these commits that no unit test covers.

1. First grant on a fresh port (grant now blocks on the firewall reload) — pass. Admit rule written with family 'ipv4', loaded cleanly, route served from outside the WAN, PCP-held. firewall reload is 0.38 s on the K1 against the client's ~1.25 s PCP budget, so the timeout path was not reachable here.

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 X_START9_AddHostnameMapping and served. Removing it in StartOS dropped the route within seconds, not at lease expiry. No faults.

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; _pp_wan_override persisted, was dropped on disable, and re-enabling prompted again. Route re-registered after the manual port was deleted.

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 external_ip() in port_map/client.rs to skip hostname mappings, which makes the StartOS Port Forwarding check go red for a hostname route behind double NAT (observed here). That overlaps #3840, and both rewrite the same closure, so one should give way. Two reasons to prefer #3840: it filters on whether the reported IP is publicly routable, so a plain PCP forward behind double NAT goes red too, where this hunk still shows it green; and a hostname route on a public WAN keeps the no-traffic fast path, where this hunk sends every hostname-only check through an echoip round trip for the same answer. Suggest dropping the hunk here.

@helix-nine
helix-nine force-pushed the wrt/upnp-hostname-action branch from 7bd98cc to 6af4ab2 Compare September 2, 2026 02:36
@helix-nine

Copy link
Copy Markdown
Contributor

I dropped the external_ip() hostname-exclusion hunk so #3840 remains the sole fix for routability filtering. I also regenerated the stale SniRoute.ts binding that caused the Generated Artifacts failure, rebased onto current master, and pushed d13fc51e7 / 6af4ab224. The port-map client tests pass (15/15), and binding regeneration is clean. I updated the PR description with the successful StartWRT retest; the StartTunnel persistence/write-lock bench pass remains outstanding.

dr-bonez
dr-bonez previously approved these changes Sep 2, 2026
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.
Dominion5254 and others added 8 commits September 2, 2026 22:21
…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
@helix-nine

Copy link
Copy Markdown
Contributor

Rebased onto df7e7dbe1, including #3840. I resolved the port-map overlap by keeping #3840’s routability filtering, carrying the hostname mapping’s internal_port through Active::Upnp, and covering a publicly routable hostname mapping in the combined test. Validation passed: 21 port-map client tests, clean start-core TypeScript binding regeneration, rustfmt, and diff checks. The force-push moved the head to 8a534ea67 and dismissed the prior approval, so the PR needs a fresh approving review.

@dr-bonez
dr-bonez merged commit db7aa80 into master Sep 2, 2026
25 checks passed
@dr-bonez
dr-bonez deleted the wrt/upnp-hostname-action branch September 2, 2026 22:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants