From 2737d8081c01465e7d260738e9bc0b08b168255c Mon Sep 17 00:00:00 2001 From: Shadowy Super Coder Date: Thu, 13 Aug 2026 14:57:01 -0600 Subject: [PATCH 01/14] feat(start-tunnel): UPnP vendor action for SNI hostname mappings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 2 +- projects/start-os/CHANGELOG.md | 9 + projects/start-tunnel/CHANGELOG.md | 16 +- projects/start-tunnel/Cargo.toml | 2 +- .../start-tunnel/docs/src/published-ports.md | 2 + rfcs/upnp-vendor-hostname-action.md | 323 +++++++++ .../start-core/src/net/port_map/client.rs | 155 ++++- .../start-core/src/net/port_map/server/igd.rs | 641 +++++++++++++++++- .../src/net/port_map/server/igd_xml/scpd.xml | 29 + .../start-core/src/net/port_map/upnp.rs | 172 +++++ 10 files changed, 1328 insertions(+), 23 deletions(-) create mode 100644 rfcs/upnp-vendor-hostname-action.md diff --git a/Cargo.lock b/Cargo.lock index 053266edd2..37952ffbf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7869,7 +7869,7 @@ dependencies = [ [[package]] name = "start-tunnel" -version = "1.2.2" +version = "1.3.0" dependencies = [ "include_dir", "start-core", diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md index f5ed130c05..0d3ee0872a 100644 --- a/projects/start-os/CHANGELOG.md +++ b/projects/start-os/CHANGELOG.md @@ -22,6 +22,15 @@ file tracks notable changes since the move to the monorepo. one canonical origin can now direct Open UI to that address while it remains enabled and compatible with the current browser session. +- **Private-domain routes on a shared port survive a network path that blocks + PCP.** When you bind a private domain through a Start9 gateway, StartOS asks + the gateway to route the hostname by TLS SNI — previously only over PCP, so + a network that filters UDP 5351 between your server and the gateway kept + ordinary port forwards working while the domain silently stopped being + routed. StartOS now falls back to asking over UPnP (a Start9 vendor action + the gateway advertises), so the route comes up either way; PCP remains + preferred when it gets through. + - **A service can permanently retire a network host or a port it no longer uses, and the port numbers it held become available again.** A service that reorganizes its interfaces across an update — renaming a host, dropping a diff --git a/projects/start-tunnel/CHANGELOG.md b/projects/start-tunnel/CHANGELOG.md index 7d53682570..f8f6d16647 100644 --- a/projects/start-tunnel/CHANGELOG.md +++ b/projects/start-tunnel/CHANGELOG.md @@ -5,7 +5,21 @@ All notable changes to StartTunnel are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.2.2] +## [1.3.0] + +### Added + +- **Devices can request SNI hostname routes over UPnP, not only PCP.** The + tunnel's UPnP IGD now serves two Start9 vendor actions + (`X_START9_AddHostnameMapping` / `X_START9_DeleteHostnameMapping`, advertised + in its `WANIPConnection:1` SCPD) that bind a hostname on a shared external + port via SNI demultiplexing — the same capability the PCP `HOSTNAME` option + provides. A device that reaches the tunnel over UPnP but not PCP (for + example, UDP 5351 filtered by an intermediate device) previously kept its + ordinary port forwards but silently lost SNI demux; StartOS now falls back to + the vendor action automatically. Unlike standard UPnP mappings, these routes + are always lease-bearing and expire if the device stops renewing them, so a + vanished device can never squat a hostname against its legitimate owner. ### Fixed diff --git a/projects/start-tunnel/Cargo.toml b/projects/start-tunnel/Cargo.toml index c49dc9398c..f3c7df595a 100644 --- a/projects/start-tunnel/Cargo.toml +++ b/projects/start-tunnel/Cargo.toml @@ -3,7 +3,7 @@ edition = "2024" license = "MIT" name = "start-tunnel" repository = "https://github.com/Start9Labs/start-technologies" -version = "1.2.2" # VERSION_BUMP +version = "1.3.0" # VERSION_BUMP [[bin]] name = "tunnelbox" diff --git a/projects/start-tunnel/docs/src/published-ports.md b/projects/start-tunnel/docs/src/published-ports.md index 71ebc4c6d1..f191b1df38 100644 --- a/projects/start-tunnel/docs/src/published-ports.md +++ b/projects/start-tunnel/docs/src/published-ports.md @@ -42,3 +42,5 @@ Deleting a device or demoting it to a client clears all of its published ports ( ## SNI hostnames (IPv4 only) When IP Version includes IPv4 (`IPv4` or `IPv4 + IPv6`), an optional **Hostname** routes by TLS SNI so several hostnames can share one external port. SNI demultiplexing is IPv4-only — in `IPv4 + IPv6` mode it applies to the IPv4 side only, and the IPv6 side is a plain pinhole (each device already has its own address, so no demux is needed) — and it cannot be combined with a port range. + +Connected devices can also create SNI hostname routes **automatically**, the same way they open automatic ports. A device asks over PCP (preferred) or, when PCP can't get through, over a UPnP vendor action the tunnel advertises (`X_START9_AddHostnameMapping`). Either way the route appears in the **Automatic** table, carries a lease, and expires on its own if the device stops renewing it — and, like every automatic mapping, it can only point at the requesting device's own address. diff --git a/rfcs/upnp-vendor-hostname-action.md b/rfcs/upnp-vendor-hostname-action.md new file mode 100644 index 0000000000..150700dad3 --- /dev/null +++ b/rfcs/upnp-vendor-hostname-action.md @@ -0,0 +1,323 @@ +# UPnP Vendor-Defined Action for SNI Hostname Mappings + +Status: accepted; implemented (server + StartOS client, branch +`wrt/upnp-hostname-action`). + +A second front door onto the existing SNI demux: today a client binds a hostname +to a shared external port only over PCP (the `HOSTNAME` private-use option, +`rfcs/draft-start9-pcp-hostname.md`). This adds a UPnP IGD vendor-defined action +that does the same thing, so a client whose gateway is reachable over UPnP but +not PCP can still get an SNI-demuxed mapping. + +Almost all of it lands in already-shared code, so StartTunnel and StartWRT get +the server side from one change. + +## Background + +### What exists + +**Server.** `shared-libs/crates/start-core/src/net/port_map/server/igd.rs` is the +shared UPnP IGD server. `handle_control` (`:183`) dispatches four standard SOAP actions — +`GetExternalIPAddress`, `AddPortMapping`, `AddAnyPortMapping`, +`DeletePortMapping` — and falls through to `fault(401, "Invalid Action")`. It is +used by StartTunnel today and by StartWRT as of PR #3634. + +**SNI backend.** `GatewayBackend` (`server/mod.rs`) already carries +`add_sni_forward` / `remove_sni_forward`, driven by the PCP `HOSTNAME` path. +PR #3634 makes the dataplane optional — `fn sni(&self) -> Option<&Arc>` +— with `add_sni_forward` early-returning when it is `None`. + +**Client.** `net/port_map/client.rs:695` short-circuits: _"HOSTNAME (SNI-demux) +mapping: PCP-only, since NAT-PMP/UPnP can't demux by SNI."_ Support is confirmed +per-gateway via a PCP `ANNOUNCE` capability marker, cached as +`pcp_hostname: CapabilityVerdict` (`db/model/public.rs:292`) with a negative +trust window. + +### Why a vendor action is legal UPnP + +The UPnP Device Architecture permits a vendor to add non-standard actions to a +service, named `X__` and declared in the SCPD beside the standard +ones. Clients ignore actions they do not recognize. AVM ships `X_AVM-DE_*` on +FRITZ!Box WANIPConnection in volume, which is the deployment evidence that +extending a standard service this way does not upset third-party clients. + +### Why a second transport, given PCP already works + +The feature still requires a Start9 gateway on the other end — a third-party +router has no SNI demux dataplane regardless of how it is asked. So this adds no +reach on the _gateway_ side; every Start9 gateway speaks PCP already. + +It adds reach on the **client** side, in two ways: + +1. **The existing fallback chain.** The port-mapping client tries PCP, NAT-PMP, + and UPnP against a gateway. Hostname mappings are the one capability with no + UPnP rung, so a client that reaches a Start9 gateway over UPnP but not PCP — + UDP 5351 filtered by an intermediate device, a source-address binding the + PCP path cannot satisfy — silently loses SNI demux while ordinary forwards + keep working. This closes that asymmetry. +2. **Third-party client implementers.** A `HOSTNAME` mapping over PCP means + implementing a private-use option over raw UDP. The same thing over UPnP is a + SOAP POST against a documented, discoverable action. For anyone outside Start9 + writing a client, that is a large difference in cost. + +Note that "vendor-defined action" is UPnP terminology with no PCP counterpart to +add: PCP's extension mechanism is the private-use option range, and it is already +implemented and shipping as `OPTION_HOSTNAME = 224`. This proposal brings the +UPnP side up to parity with a PCP capability that already exists. + +## Goals + +- A client that can reach the gateway's UPnP IGD control endpoint can create and + delete SNI hostname mappings. +- One implementation serves both products. StartWRT must require no + product-specific code in this change. +- Capability discovery with no extra round trip. +- Identical authorization and ownership semantics to the PCP path — no new + trust granted by choosing a different transport. +- Standard IGD clients are unaffected by the extended SCPD. + +## Non-goals + +- Changing the PCP `HOSTNAME` path, which stays the preferred transport. +- Building StartWRT's SNI dataplane. That is a separate, larger project; this + change is inert on StartWRT until it lands, and then works with no further + edit. +- IPv6 (the demux is v4-only today). +- NAT-PMP, which has no extension mechanism to carry a hostname. + +## Design + +### Naming and placement + +Extend the existing `WANIPConnection:1` service rather than defining a new one: +one control URL, one SCPD the client already fetches, and the AVM precedent. + +``` +X_START9_AddHostnameMapping +X_START9_DeleteHostnameMapping +``` + +Arguments mirror `AddPortMapping` exactly, plus `NewHostname` — so the handler is +a near-copy of `add_mapping` and the existing `soap_u16` helper is reused +unchanged: + +| Action | In-args | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `X_START9_AddHostnameMapping` | `NewRemoteHost`, `NewExternalPort`, `NewProtocol`, `NewInternalPort`, `NewInternalClient`, `NewEnabled`, `NewPortMappingDescription`, `NewLeaseDuration`, `NewHostname` | +| `X_START9_DeleteHostnameMapping` | `NewRemoteHost`, `NewExternalPort`, `NewProtocol`, `NewInternalPort`, `NewHostname` | + +Delete carries `NewInternalPort` — unlike `DeletePortMapping`, which identifies +a mapping by external port alone — because an SNI route's identity and +ownership is its full target `(peer, internal port)`: both the demux and the +tunnel's persistence match on it exactly, and the PCP delete (a lifetime-0 MAP) +carries the internal port the same way. + +The alternative — a separate `urn:start9-com:service:HostnameMapping:1` — is +cleaner in principle but costs a second service block in the root description, a +second control endpoint, and a second SCPD fetch, to avoid a risk AVM's +deployment suggests is not real. See Open questions. + +### Error codes + +UPnP reserves errors 800–899 for vendor use. Map the PCP result codes across so +both transports report the same conditions: + +| Condition | PCP | UPnP | +| ------------------------------------------------- | ------------------------------ | ------------------------------ | +| Hostname already bound on this `(extIP, extPort)` | `RESULT_HOSTNAME_TAKEN` (192) | **800** `HostnameTaken` | +| Gateway has no SNI dataplane | `RESULT_UNSUPP_HOSTNAME` (193) | **801** `HostnameNotSupported` | +| Malformed hostname | malformed-option | 402 `Invalid Args` | +| Peer not an authorized device | — | 606 `Action not authorized` | + +**801 is what makes this change inert-but-correct on StartWRT.** Both handlers +check `backend.sni().is_none()` before doing anything else and fault 801 +(possible because this branch stacks on PR #3634's `Option`-ification of +`sni()`), so the action is advertised-but-refused on StartWRT — whose backend +returns `None` — until its dataplane exists. `sni_fault` additionally maps a +backend `RESULT_UNSUPP_HOSTNAME` to the same 801. + +### Capability discovery — free, via `control_schema` + +`igd_next::Gateway` (0.17.1, `gateway.rs:13`) carries +`pub control_schema: HashMap>` — "Control schema for all +actions" — populated during `discover()`. The client therefore already holds the +gateway's parsed action list in memory the moment it is on the UPnP path at all. + +Detecting support is `control_schema.contains_key("X_START9_AddHostnameMapping")`. +No probe, no marker option, no extra request, no XML parsing to write. This is +strictly simpler than the PCP `ANNOUNCE` marker it parallels, and it is why no +persisted capability verdict is needed (see Client, below). + +### Lease semantics — the one real semantic gap + +`add_mapping` comments that _"UPnP IGD leases are permanent here (StartOS +requests lease 0); PCP is the lease-bearing path."_ Left alone, a UPnP-created +SNI route would be permanent, and StartTunnel's lease-expiry sweep — which +exists precisely so an automatic mapping dies when its device stops renewing — +would never reap it. That is a behavior regression relative to the PCP path. + +It is worse than an ordinary stale forward. A stale DNAT wastes a port and is +visible and removable in the published-ports UI. A stale **SNI route holds a +name**: `add_sni_forward` answers `HOSTNAME_TAKEN` to anyone else asking for it, +so the legitimate owner is locked out with no path to recover, and the condition +never self-heals. + +The decisive detail is in `sni.rs`'s `Binding`: + +```rust +struct Binding { + target: SocketAddrV4, + /// `None` for a permanent (DB-backed/manual) binding that never expires. + expiry: Option, +} +``` + +`None` is **reserved for operator-created bindings**. A device-initiated route +passing `None` would be indistinguishable from one an admin added by hand — a +category error, not merely an expiry policy choice. + +**Resolution: vendor-action routes are lease-bearing.** `add_sni_forward` +already takes `lifetime: Option` and the PCP path already passes +`Some(lifetime)`, so this is passing a different value at one call site — not +new machinery. Honor `NewLeaseDuration` when nonzero, clamped by +`MAX_LIFETIME_SECONDS` (3600); apply that clamp as the default when the client +sends 0. The existing sweep then reaps unrefreshed routes with no change. + +Alternatives considered: + +- **Permanent, reaped by device lifecycle only.** Relies on the existing "delete + or demote a device clears its forwards, SNI routes, and pinholes" path. + Rejected: it misses the common case — a device that goes offline or withdraws + the exposure without being deleted — which is exactly what the lease sweep was + added for. +- **Permanent, recovered by owner re-registration.** Ownership is keyed to the + target, so a returning device can overwrite its own route. Rejected: it fails + precisely when the device's address changes, which on StartWRT happens + routinely on a profile move (the DNS-injection RFC's decision 5). The stale + route then squats the name against the device's own new address. + +### Authorization + +Unchanged from `add_mapping`: `is_known_client(peer)` gates the call, and the +target is forced to the requesting peer's own address (`target = +SocketAddrV4::new(peer, internal_port)`), so `NewInternalClient` cannot be used +to publish someone else. Delete is owner-scoped the same way `delete_mapping` +is, so a peer cannot remove or probe for another's route. + +## Changes by layer + +### Shared server — `net/port_map/server/igd.rs` (both products, one change) + +- Two arms in `handle_control`'s match. +- `add_hostname_mapping()` / `delete_hostname_mapping()`, structured as + `add_mapping` / `delete_mapping` but calling `backend.add_sni_forward()` / + `remove_sni_forward()`. +- A `soap_str(body, tag)` extractor beside the existing `soap_u16`. +- Hostname validation reuses `pcp::hostname::validate_hostname`. It is no longer + PCP-specific; consider lifting it to `port_map/hostname.rs`. Cosmetic — do it + only if it stays a small diff. +- `igd_xml/scpd.xml`: two `` blocks. The existing SCPD test asserts named + actions rather than an exhaustive list, so it does not need loosening. + +### StartTunnel + +Nothing. `sni()` already returns `Some`, and the tunnel's `add_sni_forward` +override persists routes to PatchDb — so persistence, restart survival, and +dashboard visibility come free via the shared trait method. + +### StartWRT + +Nothing in this change. `sni()` returns `None` (PR #3634), so the action faults 801. When StartWRT's SNI dataplane lands and `sni()` becomes `Some`, the vendor +action starts working with no edit here. + +### Client — `net/port_map/{client,upnp}.rs` + +- `upnp.rs`: SOAP calls for the two actions, alongside the existing `add_port` / + `remove_port`. +- `client.rs:695`: replace the PCP-only short-circuit. PCP stays first; when a + gateway's HOSTNAME verdict is known-absent, fall through to the UPnP path if + `gateway.control_schema` advertises `X_START9_AddHostnameMapping`. + +**No PatchDb change.** Support is read from `control_schema`, which `discover()` +already populated on the `Gateway` the UPnP path is holding — so there is no +probe to suppress and nothing worth persisting. Adding a +`upnp_hostname: CapabilityVerdict` beside `pcp_hostname` would be symmetric, but +it buys nothing here: the negative trust window exists to avoid re-probing, and +this costs no probe. It would also drag in the full cross-layer sequence +(`make start-core-ts-bindings` → SDK rebuild → web and container-runtime type +checks) for a field no UI reads — `pcpHostname` today appears only as a seed +value in `projects/start-tunnel/web/src/app/services/patch-db/data-model.ts:133` +and is rendered nowhere. + +This keeps the whole proposal inside `start-core`, touching no product's UI, +bindings, or database. + +## Phasing + +1. **Shared server + SCPD + lease semantics**, with StartTunnel regression tests. + Ships working on StartTunnel; inert (801) on StartWRT. +2. **Client UPnP hostname path + capability caching.** The cross-layer step. +3. **StartWRT** — no work. Inherits when its SNI dataplane lands. + +Phase 1 is independently useful and independently reviewable: it makes the +gateway answer the action, which is what a manual `curl` or a third-party client +would exercise. + +## Testing + +Unit-testable in `start-core`: + +- SOAP parse of both actions, including `NewHostname` extraction. +- SCPD advertises both actions; the standard action set is unchanged. +- Fault 801 when `sni()` is `None` — the StartWRT-shaped backend. +- Fault 800 when the hostname is held by a different target. +- Fault 606 for an unauthorized peer; 402 for a malformed hostname. +- Target forcing: `NewInternalClient` naming another host does not publish it. +- Owner-scoped delete: a different peer's delete does not remove the route. +- Lease: `NewLeaseDuration` 0 → clamped default; nonzero → honored and clamped; + the sweep reaps an unrefreshed route. + +Integration: + +- StartOS client against StartTunnel with PCP blocked (drop UDP 5351) — the + mapping still comes up over UPnP, and the SNI route serves. +- **Third-party regression:** `miniupnpc` and `igd-next` against the extended + SCPD, confirming the added actions do not disturb standard `AddPortMapping` / + `DeletePortMapping` flows. This is the check that validates the + extend-WANIPConnection decision. + +## Landing obligations + +Per the root `AGENTS.md`: + +- `projects/start-tunnel/CHANGELOG.md` under the prospective next version, and + `projects/start-tunnel/docs/src/published-ports.md` if the behavior is + user-visible. +- `projects/start-os/CHANGELOG.md` under the prospective next version — the + client fallback changes StartOS behavior, and client-side port-map changes + carry StartOS entries by precedent. +- No StartWRT changelog entry — nothing user-visible changes there until its SNI + dataplane lands. +- If the capability field is added: TS bindings → SDK rebuild → web / + container-runtime type checks, in that order, in the same change. +- `API_CONTRACT.md` is untouched; this is not a JSON-RPC surface. +- No CI `paths:` change — no new build inputs. + +## Open questions + +1. **Extend `WANIPConnection:1`, or define a separate vendor service?** + Recommended: extend, per AVM precedent; the third-party regression test is + what confirms it. +2. **Lease-bearing vendor-action routes, diverging from `AddPortMapping`'s + permanence on the same server?** Recommended: yes — see Lease semantics. A + client that assumes UPnP mappings are permanent would see a route expire, but + the only clients are ours. +3. **One hostname per call, or several?** A PCP `MAP` can carry multiple + `HOSTNAME` options in one request; SOAP has no natural framing for a repeated + argument. One call per hostname is proposed. This is a wire-efficiency + question only — the backend already registers hostnames one at a time. +4. **Does anything document the vendor action externally?** The PCP side has an + IETF-style draft (`draft-start9-pcp-hostname`). A UPnP vendor action has no + equivalent venue, so if third-party client implementers are part of the + justification, the SCPD needs to be the documentation — which argues for + precise `` entries and a short section in the tunnel's docs. diff --git a/shared-libs/crates/start-core/src/net/port_map/client.rs b/shared-libs/crates/start-core/src/net/port_map/client.rs index cf0e5af41e..ee2a707d2d 100644 --- a/shared-libs/crates/start-core/src/net/port_map/client.rs +++ b/shared-libs/crates/start-core/src/net/port_map/client.rs @@ -210,7 +210,13 @@ struct Spec { enum Active { Pcp(PortMapping), - Upnp { external_ip: Option }, + Upnp { + external_ip: Option, + /// Kept for teardown: a hostname mapping's delete action must name the + /// internal port to identify the peer-scoped SNI route, and the spec it + /// came from is already gone by then. + internal_port: u16, + }, } enum Command { @@ -395,7 +401,7 @@ fn external_ip_of(active: &BTreeMap, external_port: u16) -> .and_then(|(_, a)| { routable_external_ip(match a { Active::Pcp(m) => m.external_ip(), - Active::Upnp { external_ip } => external_ip.map(IpAddr::V4), + Active::Upnp { external_ip, .. } => external_ip.map(IpAddr::V4), }) }) } @@ -628,8 +634,17 @@ impl State { } // A PCP mapping not yet at its renewal point: leave it be. Some(Active::Pcp(_)) => {} - // UPnP has no lease; re-assert in case a gateway reboot dropped - // it. + // Re-assert UPnP in case a gateway reboot dropped it. A + // hostname key re-registers idempotently (the same target + // reclaims), so skip the remote delete — deleting first would + // leave the route down until the backoff-gated retry if the + // re-add fails, and churn the gateway's persisted state every + // tick. Only the local entry is dropped so apply() sees an + // inactive key. + Some(Active::Upnp { .. }) if key.2.is_some() => { + self.active.remove(&key); + self.apply(interfaces, key).await; + } Some(Active::Upnp { .. }) => { self.teardown(key.clone()).await; self.apply(interfaces, key).await; @@ -654,13 +669,28 @@ impl State { crate::dev_log!(debug, "PCP/NAT-PMP unmap for {key:?} failed: {e}"); } } - Some(Active::Upnp { .. }) => { - let (local_ip, external_port, _, protocol) = key; + Some(Active::Upnp { internal_port, .. }) => { + let (local_ip, external_port, hostname, protocol) = key; if let IpAddr::V4(local_v4) = local_ip { if let Some(gw) = self.gateway_for(local_v4).await { - upnp::remove_port(gw, protocol.upnp(), external_port) - .await - .log_err(); + match &hostname { + // A hostname key is an SNI route, not a port forward. + Some(host) => { + upnp::remove_hostname_mapping( + gw, + external_port, + internal_port, + host, + ) + .await + .log_err(); + } + None => { + upnp::remove_port(gw, protocol.upnp(), external_port) + .await + .log_err(); + } + } } } } @@ -709,8 +739,10 @@ impl State { }; let now = Utc::now(); - // HOSTNAME (SNI-demux) mapping: PCP-only, since NAT-PMP/UPnP can't demux - // by SNI. Other hostnames on the same port are separate mappings. + // HOSTNAME (SNI-demux) mapping: PCP first, falling back to the UPnP + // vendor action (X_START9_AddHostnameMapping) when no gateway grants it + // over PCP. NAT-PMP has no way to carry a hostname. Other hostnames on + // the same port are separate mappings. if let Some(hostname) = &hostname { let options = [pcp::PcpOption { code: OPTION_HOSTNAME, @@ -796,6 +828,88 @@ impl State { } } } + + // No gateway granted the mapping over PCP: fall back to the UPnP + // vendor action if the IGD's SCPD advertises it (IPv4 only, like + // the plain UPnP path). + if let IpAddr::V4(local_v4) = local_ip { + let upnp_dead = capabilities_for_local(interfaces, local_ip) + .and_then(|c| c.upnp.fresh(now)) + == Some(false); + if upnp_dead { + crate::dev_log!( + debug, + "UPnP HOSTNAME skip on {local_ip}: known to have no IGD" + ); + return attempted; + } + attempted = true; + // (added, drop_cache): the cache is shared with every other + // mapping on this local IP, so only evict it when the gateway + // is gone or misbehaving — an IGD that answers but doesn't + // advertise the vendor action is a stable condition, and + // evicting for it would force sibling mappings to re-discover. + let (added, drop_cache) = match self.gateway_for(local_v4).await { + Some(gw) => { + // Discovery alone proves the IGD, whatever the call says. + report_local(interfaces, local_ip, true); + if !upnp::supports_hostname(gw) { + crate::dev_log!( + debug, + "UPnP HOSTNAME skip on {local_ip}: IGD doesn't advertise the vendor action" + ); + (false, false) + } else { + match upnp::add_hostname_mapping( + gw, + external_port, + local_v4, + spec.internal_port, + hostname, + ) + .await + { + Ok(()) => { + tracing::debug!( + "UPnP HOSTNAME mapped {external_port}->{local_v4}:{} {hostname}", + spec.internal_port + ); + (true, false) + } + Err(e) => { + crate::dev_log!( + debug, + "UPnP HOSTNAME map {local_v4}:{external_port} {hostname} failed: {e}" + ); + (false, true) + } + } + } + } + None => { + report_local(interfaces, local_ip, false); + (false, true) + } + }; + if added { + // Best-effort external IP from the cached discovery so a + // reachability check can short-circuit. + let external_ip = match self.gateway_for(local_v4).await { + Some(gw) => upnp::external_ipv4(gw).await.ok().flatten(), + None => None, + }; + self.active.insert( + key.clone(), + Active::Upnp { + external_ip, + internal_port: spec.internal_port, + }, + ); + } else if drop_cache { + // Re-discover next time in case the gateway went away. + self.upnp_cache.remove(&local_v4); + } + } return attempted; } @@ -977,8 +1091,13 @@ impl State { // Best-effort external IP (local IGD query) so a reachability check // can short-circuit; `get_external_ipv4` discards private/CGNAT. let external_ip = upnp::get_external_ipv4(local_v4).await.ok().flatten(); - self.active - .insert(key.clone(), Active::Upnp { external_ip }); + self.active.insert( + key.clone(), + Active::Upnp { + external_ip, + internal_port: spec.internal_port, + }, + ); } else { // Re-discover next time in case the gateway went away. self.upnp_cache.remove(&local_v4); @@ -1092,15 +1211,22 @@ mod tests { let public = Ipv4Addr::new(1, 2, 3, 4); let mut active = BTreeMap::new(); active.insert( - (ip, 443, None, TransportProtocol::Tcp), + ( + ip, + 443, + Some("example.com".into()), + TransportProtocol::Tcp, + ), Active::Upnp { external_ip: Some(public), + internal_port: 443, }, ); active.insert( (ip, 8080, None, TransportProtocol::Udp), Active::Upnp { external_ip: Some(public), + internal_port: 8080, }, ); @@ -1121,6 +1247,7 @@ mod tests { (ip, 443, None, TransportProtocol::Tcp), Active::Upnp { external_ip: Some(Ipv4Addr::new(192, 168, 8, 1)), + internal_port: 443, }, ); assert_eq!(external_ip_of(&active, 443), None); diff --git a/shared-libs/crates/start-core/src/net/port_map/server/igd.rs b/shared-libs/crates/start-core/src/net/port_map/server/igd.rs index 6029c8a59e..917f38d1c8 100644 --- a/shared-libs/crates/start-core/src/net/port_map/server/igd.rs +++ b/shared-libs/crates/start-core/src/net/port_map/server/igd.rs @@ -17,7 +17,10 @@ use std::sync::Arc; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; -use crate::net::port_map::server::{GatewayBackend, MappingEntry}; +use crate::net::port_map::pcp::hostname::{ + RESULT_HOSTNAME_TAKEN, RESULT_UNSUPP_HOSTNAME, validate_hostname, +}; +use crate::net::port_map::server::{GatewayBackend, MAX_LIFETIME_SECONDS, MappingEntry}; pub const SSDP_MULTICAST: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); pub const SSDP_PORT: u16 = 1900; @@ -40,6 +43,11 @@ pub const CIF_SCPD_PATH: &str = "/WANCfg.xml"; /// Both services share one control endpoint: actions are dispatched by name, /// which is unambiguous across the two. pub const CONTROL_PATH: &str = "/ctl/IPConn"; +/// Start9 vendor actions binding/removing an SNI hostname on a shared external +/// port — the UPnP transport for what the PCP `HOSTNAME` option does. The one +/// definition of the wire names; the client (`port_map::upnp`) imports them. +pub const ADD_HOSTNAME_ACTION: &str = "X_START9_AddHostnameMapping"; +pub const DELETE_HOSTNAME_ACTION: &str = "X_START9_DeleteHostnameMapping"; /// Minimal WANIPConnection SCPD. Clients (e.g. igd-next) read its `actionList` /// to learn each action's input argument names before issuing a request. @@ -195,10 +203,24 @@ fn upnp_error_text(code: u16) -> &'static str { 714 => "NoSuchEntryInArray", 718 => "ConflictInMappingEntry", 725 => "OnlyPermanentLeasesSupported", + // Vendor range (800-899): SNI hostname mappings, mirroring the PCP + // HOSTNAME result codes. + 800 => "HostnameTaken", + 801 => "HostnameNotSupported", _ => "Action Failed", } } +/// UPnP vendor fault for an `add_sni_forward` PCP result code. +fn sni_fault(code: u8) -> Response { + let upnp = match code { + RESULT_HOSTNAME_TAKEN => 800, + RESULT_UNSUPP_HOSTNAME => 801, + _ => 501, + }; + fault(upnp, upnp_error_text(upnp)) +} + /// Render the IGD root device description for `uuid`, identifying the gateway /// as `product` — this is the name UPnP clients display for it, so each product /// must pass its own rather than inherit whichever one the template was written @@ -271,6 +293,19 @@ fn host_is_ip_literal(headers: &HeaderMap) -> bool { addr.parse::().is_ok() } +/// Whether `action` changes gateway state, and so must name itself in the +/// `SOAPAction` header rather than the body alone. Every mutating arm of +/// [`handle_control`]'s dispatch belongs here; a read left out of it keeps the +/// body fallback, which is what lets a client read the external IP during +/// discovery. +fn is_mutation(action: &str) -> bool { + matches!( + action, + "AddPortMapping" | "AddAnyPortMapping" | "DeletePortMapping" + ) || action == ADD_HOSTNAME_ACTION + || action == DELETE_HOSTNAME_ACTION +} + /// Dispatch a SOAP control request from `peer` to the matching IGD action. pub async fn handle_control( backend: &B, @@ -291,11 +326,7 @@ pub async fn handle_control( // header. So mutations must name themselves in the header (the UPnP spec // mandates it; every real client complies), leaving the body fallback to // the reads, whose responses a cross-origin page can't see anyway. - if matches!( - action.as_deref(), - Some("AddPortMapping" | "AddAnyPortMapping" | "DeletePortMapping") - ) && !headers.contains_key("SOAPAction") - { + if action.as_deref().is_some_and(is_mutation) && !headers.contains_key("SOAPAction") { return StatusCode::FORBIDDEN.into_response(); } match action.as_deref() { @@ -336,6 +367,10 @@ pub async fn handle_control( 0\ Up", ), + Some(a) if a == ADD_HOSTNAME_ACTION => add_hostname_mapping(backend, peer, body).await, + Some(a) if a == DELETE_HOSTNAME_ACTION => { + delete_hostname_mapping(backend, peer, body).await + } _ => fault(401, "Invalid Action"), } } @@ -500,6 +535,115 @@ async fn delete_mapping( } } +/// Start9 vendor action: bind `NewHostname` on the shared external port via the +/// gateway's SNI demux — the UPnP transport for what the PCP HOSTNAME option +/// does. Same guards as [`add_mapping`]. +async fn add_hostname_mapping( + backend: &B, + peer: Ipv4Addr, + body: &str, +) -> Response { + // Advertised-but-refused where the gateway has no SNI dataplane (StartWRT + // until its demux lands): the shared SCPD lists the action regardless, so + // refuse explicitly — the UPnP spelling of the PCP path's + // RESULT_UNSUPP_HOSTNAME refusal. + if backend.sni().is_none() { + return fault(801, upnp_error_text(801)); + } + let (Some(external_port), Some(internal_port)) = ( + soap_u16(body, "NewExternalPort"), + soap_u16(body, "NewInternalPort"), + ) else { + return fault(402, "Invalid Args"); + }; + if external_port == 0 || internal_port == 0 { + return fault(402, "Invalid Args"); + } + let Some(hostname) = soap_arg::(body, "NewHostname") + .filter(|h| validate_hostname(h)) + .map(|h| h.to_ascii_lowercase()) + else { + return fault(402, "Invalid Args"); + }; + // The SNI demux is TCP-only; the PCP path likewise refuses a non-TCP + // HOSTNAME MAP rather than granting a silently-TCP route. + if let Some(p) = soap_arg::(body, "NewProtocol") { + if !p.eq_ignore_ascii_case("TCP") { + return fault(402, "Invalid Args"); + } + } + if !backend.is_known_client(peer).await { + return fault(606, "Action not authorized"); + } + let Some(source_ip) = backend.external_ipv4(peer).await else { + return fault(501, "Action Failed"); + }; + let source = SocketAddrV4::new(source_ip, external_port); + // Secure mode: force the target to the requesting peer's own address. + let target = SocketAddrV4::new(peer, internal_port); + + // Always lease-bearing, unlike add_mapping: a `None` lifetime is reserved + // for operator-created SNI routes, and an unreaped device route would answer + // HOSTNAME_TAKEN to its legitimate owner forever. Lease 0 means "default". + let lifetime = match soap_u32(body, "NewLeaseDuration") { + Some(n) if n > 0 => n.min(MAX_LIFETIME_SECONDS), + _ => MAX_LIFETIME_SECONDS, + }; + match backend + .add_sni_forward( + source, + target, + std::slice::from_ref(&hostname), + Some(lifetime), + ) + .await + { + Ok(()) => ok(ADD_HOSTNAME_ACTION, ""), + Err(code) => sni_fault(code), + } +} + +/// Start9 vendor action: remove the SNI route for `NewHostname`. Owner-scoped +/// — the target is forced to the requesting peer, so a peer can only remove +/// routes pointing at itself — and gated on `is_known_client`, matching the +/// PCP lifetime-0 delete rather than the ungated [`delete_mapping`]. +async fn delete_hostname_mapping( + backend: &B, + peer: Ipv4Addr, + body: &str, +) -> Response { + if backend.sni().is_none() { + return fault(801, upnp_error_text(801)); + } + let (Some(external_port), Some(internal_port)) = ( + soap_u16(body, "NewExternalPort"), + soap_u16(body, "NewInternalPort"), + ) else { + return fault(402, "Invalid Args"); + }; + let Some(hostname) = soap_arg::(body, "NewHostname") + .filter(|h| validate_hostname(h)) + .map(|h| h.to_ascii_lowercase()) + else { + return fault(402, "Invalid Args"); + }; + // Gated like the PCP delete (a lifetime-0 MAP sits behind the same check): + // ownership alone protects other peers' routes, but an unknown peer should + // not reach the backend's removal path at all. + if !backend.is_known_client(peer).await { + return fault(606, "Action not authorized"); + } + let Some(source_ip) = backend.external_ipv4(peer).await else { + return fault(714, "NoSuchEntryInArray"); + }; + let source = SocketAddrV4::new(source_ip, external_port); + let target = SocketAddrV4::new(peer, internal_port); + backend + .remove_sni_forward(source, target, std::slice::from_ref(&hostname)) + .await; + ok(DELETE_HOSTNAME_ACTION, "") +} + #[cfg(test)] mod tests { use xmltree::Element; @@ -877,6 +1021,37 @@ mod tests { assert_eq!(resp.status(), StatusCode::OK); } + /// The `SOAPAction` requirement covers every mutating action, not only the + /// standard three: a vendor action that changes state is exactly as + /// reachable from a cross-origin page as `AddPortMapping` is. + #[tokio::test] + async fn browser_shaped_requests_cannot_reach_vendor_mutations() { + for body in [ + add_hostname_body("0", "git.example.com"), + delete_hostname_body("git.example.com"), + ] { + let stub = HostnameStub::new(true); + + let resp = handle_control(&stub, PEER, &host_headers("192.168.1.1:49001"), &body).await; + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "a mutation named only in the body must be refused" + ); + assert!( + stub.calls.lock().unwrap().is_empty(), + "a refused request must not reach the backend" + ); + + let resp = control(&stub, PEER, &body).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "the same body from a real client goes through" + ); + } + } + #[tokio::test] async fn static_docs_share_the_host_gate() { let doc: Arc = Arc::from(""); @@ -930,4 +1105,458 @@ mod tests { assert_eq!(uuid, "00010203-0405-0607-0809-0a0b0c0d0e0f"); assert_eq!(format_uuid(&bytes), format_uuid(&bytes)); } + + // ---- Start9 hostname vendor actions ---- + + use std::future::Future; + use std::sync::Mutex; + + use crate::tunnel::forward::sni::SniDemux; + + const PEER: Ipv4Addr = Ipv4Addr::new(10, 59, 0, 2); + const OTHER_PEER: Ipv4Addr = Ipv4Addr::new(10, 59, 0, 3); + const EXT_IP: Ipv4Addr = Ipv4Addr::new(203, 0, 113, 1); + + /// Backend recording `add_sni_forward` calls while still driving the real + /// demux, so ownership semantics (HOSTNAME_TAKEN, owner-scoped delete) are + /// exercised against the actual registration logic. + struct HostnameStub { + sni: Arc, + known: bool, + calls: Mutex, Option)>>, + } + impl HostnameStub { + fn new(known: bool) -> Self { + Self { + sni: SniDemux::new(), + known, + calls: Mutex::new(Vec::new()), + } + } + } + impl GatewayBackend for HostnameStub { + fn add_forward( + &self, + _: SocketAddrV4, + _: SocketAddrV4, + _: u16, + _: Ipv4Addr, + _: Option, + ) -> impl Future> + Send { + async { Ok(()) } + } + fn remove_forward(&self, _: Ipv4Addr, _: u16) -> impl Future + Send { + async {} + } + fn remove_forward_by_source( + &self, + _: SocketAddrV4, + _: Ipv4Addr, + ) -> impl Future + Send { + async { false } + } + fn external_ipv4(&self, _: Ipv4Addr) -> impl Future> + Send { + async { Some(EXT_IP) } + } + fn is_known_client(&self, _: Ipv4Addr) -> impl Future + Send { + let known = self.known; + async move { known } + } + fn sni(&self) -> Option<&Arc> { + Some(&self.sni) + } + fn add_sni_forward( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + hostnames: &[String], + lifetime: Option, + ) -> impl Future> + Send { + self.calls + .lock() + .unwrap() + .push((source, target, hostnames.to_vec(), lifetime)); + let res = self + .sni + .register(*source.ip(), source.port(), hostnames, target, lifetime); + async move { res } + } + } + + /// A hostname-action body with `NewInternalClient` deliberately naming a + /// DIFFERENT host, so target forcing is what the tests observe. + fn add_hostname_body(lease: &str, hostname: &str) -> String { + format!( + r#" + + + + +443 +TCP +8443 +10.59.1.99 +1 +StartOS +{lease} +{hostname} + + +"# + ) + } + + fn delete_hostname_body(hostname: &str) -> String { + format!( + r#" + + + + +443 +TCP +8443 +{hostname} + + +"# + ) + } + + async fn body_text(resp: Response) -> String { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8_lossy(&bytes).into_owned() + } + + /// The headers a real client sends for `body`: an IP-literal `Host`, and + /// the `SOAPAction` naming the action the body carries. + fn client_headers(body: &str) -> HeaderMap { + let action = soap_action(&HeaderMap::new(), body).expect("body names an action"); + let mut headers = host_headers("192.168.1.1:49001"); + headers.insert( + "SOAPAction", + format!(r#""{WANIP_SERVICE}#{action}""#).parse().unwrap(), + ); + headers + } + + async fn control(stub: &HostnameStub, peer: Ipv4Addr, body: &str) -> Response { + handle_control(stub, peer, &client_headers(body), body).await + } + + #[test] + fn scpd_lists_input_args_for_hostname_actions() { + let scpd = Element::parse(SCPD.as_bytes()).unwrap(); + let action_list = scpd.get_child("actionList").unwrap(); + let mut actions = std::collections::HashMap::new(); + for child in &action_list.children { + if let Some(a) = child.as_element() { + let name = a + .get_child("name") + .unwrap() + .get_text() + .unwrap() + .into_owned(); + let ins: Vec = a + .get_child("argumentList") + .map(|al| { + al.children + .iter() + .filter_map(|c| c.as_element()) + .filter(|arg| { + arg.get_child("direction") + .and_then(|d| d.get_text()) + .as_deref() + == Some("in") + }) + .filter_map(|arg| { + arg.get_child("name")?.get_text().map(|t| t.into_owned()) + }) + .collect() + }) + .unwrap_or_default(); + actions.insert(name, ins); + } + } + let add = actions + .get("X_START9_AddHostnameMapping") + .expect("X_START9_AddHostnameMapping"); + assert_eq!( + add, + &[ + "NewRemoteHost", + "NewExternalPort", + "NewProtocol", + "NewInternalPort", + "NewInternalClient", + "NewEnabled", + "NewPortMappingDescription", + "NewLeaseDuration", + "NewHostname", + ] + ); + let del = actions + .get("X_START9_DeleteHostnameMapping") + .expect("X_START9_DeleteHostnameMapping"); + assert_eq!( + del, + &[ + "NewRemoteHost", + "NewExternalPort", + "NewProtocol", + "NewInternalPort", + "NewHostname", + ] + ); + // Every referenced state variable must exist in the serviceStateTable. + let table = scpd.get_child("serviceStateTable").unwrap(); + assert!( + table + .children + .iter() + .filter_map(|c| c.as_element()) + .any( + |v| v.get_child("name").and_then(|n| n.get_text()).as_deref() + == Some("X_START9_Hostname") + ) + ); + } + + // The client's hand-rolled envelopes parse to exactly the values it put in — + // the round-trip check standing in for igd-next's private request machinery. + #[test] + fn client_envelopes_round_trip_through_server_parser() { + let body = crate::net::port_map::upnp::add_hostname_body( + 443, + Ipv4Addr::new(10, 59, 1, 5), + 8443, + "git.example.com", + ); + assert_eq!( + soap_action(&HeaderMap::new(), &body).as_deref(), + Some("X_START9_AddHostnameMapping") + ); + assert_eq!(soap_u16(&body, "NewExternalPort"), Some(443)); + assert_eq!(soap_u16(&body, "NewInternalPort"), Some(8443)); + assert_eq!(soap_u16(&body, "NewLeaseDuration"), Some(3600)); + assert_eq!( + soap_arg::(&body, "NewHostname").as_deref(), + Some("git.example.com") + ); + + let body = crate::net::port_map::upnp::delete_hostname_body(443, 8443, "git.example.com"); + assert_eq!( + soap_action(&HeaderMap::new(), &body).as_deref(), + Some("X_START9_DeleteHostnameMapping") + ); + assert_eq!(soap_u16(&body, "NewExternalPort"), Some(443)); + assert_eq!(soap_u16(&body, "NewInternalPort"), Some(8443)); + assert_eq!( + soap_arg::(&body, "NewHostname").as_deref(), + Some("git.example.com") + ); + } + + // Lease grants: 0 and absent request the default; a nonzero request is + // honored up to the cap; and the backend NEVER receives `None` (permanent), + // which is reserved for operator-created routes. + #[tokio::test] + async fn hostname_mapping_is_always_lease_bearing() { + for (lease, granted) in [ + ("0", super::super::MAX_LIFETIME_SECONDS), + ("600", 600), + ("7200", super::super::MAX_LIFETIME_SECONDS), + // Past u16 — NewLeaseDuration is ui4, so this must clamp, not + // fall back to a failed parse. + ("100000", super::super::MAX_LIFETIME_SECONDS), + ] { + let stub = HostnameStub::new(true); + let resp = control(&stub, PEER, &add_hostname_body(lease, "git.example.com")).await; + assert_eq!(resp.status(), StatusCode::OK, "lease {lease}"); + let calls = stub.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].3, Some(granted), "lease {lease}"); + } + } + + // `NewInternalClient` names another host, but the route target is forced to + // the requesting peer — a peer can only publish itself. + #[tokio::test] + async fn hostname_mapping_forces_target_to_peer() { + let stub = HostnameStub::new(true); + let resp = control(&stub, PEER, &add_hostname_body("0", "Git.Example.Com")).await; + assert_eq!(resp.status(), StatusCode::OK); + let calls = stub.calls.lock().unwrap(); + assert_eq!(calls[0].0, SocketAddrV4::new(EXT_IP, 443)); + assert_eq!(calls[0].1, SocketAddrV4::new(PEER, 8443)); + // Hostnames are lowercased before registration, like the PCP parser. + assert_eq!(calls[0].2, vec!["git.example.com".to_string()]); + } + + // The SNI demux is TCP-only: an explicit non-TCP protocol is refused, like + // the PCP path's non-TCP HOSTNAME check, instead of granting a + // silently-TCP route. + #[tokio::test] + async fn hostname_mapping_rejects_non_tcp() { + let stub = HostnameStub::new(true); + let body = add_hostname_body("0", "git.example.com").replace( + "TCP", + "UDP", + ); + let resp = control(&stub, PEER, &body).await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(body_text(resp).await.contains("402")); + assert!(stub.calls.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn hostname_mapping_rejects_unknown_peer() { + let stub = HostnameStub::new(false); + let resp = control(&stub, PEER, &add_hostname_body("0", "git.example.com")).await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(body_text(resp).await.contains("606")); + assert!(stub.calls.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn hostname_mapping_rejects_malformed_hostname() { + for bad in ["ex ample.com", ".example.com", ""] { + let stub = HostnameStub::new(true); + let resp = control(&stub, PEER, &add_hostname_body("0", bad)).await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR, "{bad:?}"); + assert!( + body_text(resp).await.contains("402"), + "{bad:?}" + ); + assert!(stub.calls.lock().unwrap().is_empty(), "{bad:?}"); + } + } + + // A hostname held by a different target answers the vendor fault 800 + // (HostnameTaken), the UPnP spelling of RESULT_HOSTNAME_TAKEN. + #[tokio::test] + async fn hostname_taken_by_another_target_faults_800() { + let stub = HostnameStub::new(true); + let resp = control(&stub, PEER, &add_hostname_body("0", "git.example.com")).await; + assert_eq!(resp.status(), StatusCode::OK); + // Same hostname from another peer (different target) is refused… + let resp = control( + &stub, + OTHER_PEER, + &add_hostname_body("0", "git.example.com"), + ) + .await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(body_text(resp).await.contains("800")); + // …while the owner reclaims (refresh) fine. + let resp = control(&stub, PEER, &add_hostname_body("0", "git.example.com")).await; + assert_eq!(resp.status(), StatusCode::OK); + } + + // Delete is gated on is_known_client like the PCP lifetime-0 MAP, so an + // unknown peer never reaches the backend's removal path. + #[tokio::test] + async fn hostname_delete_rejects_unknown_peer() { + let stub = HostnameStub::new(false); + let resp = control(&stub, PEER, &delete_hostname_body("git.example.com")).await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(body_text(resp).await.contains("606")); + } + + // Delete is owner-scoped: another peer's delete of the same hostname is a + // no-op (the route stays, observable as 800 for a third target), while the + // owner's delete frees the name. + #[tokio::test] + async fn hostname_delete_is_owner_scoped() { + let stub = HostnameStub::new(true); + let resp = control(&stub, PEER, &add_hostname_body("0", "git.example.com")).await; + assert_eq!(resp.status(), StatusCode::OK); + + // Another peer's delete succeeds as a request but removes nothing. + let resp = control(&stub, OTHER_PEER, &delete_hostname_body("git.example.com")).await; + assert_eq!(resp.status(), StatusCode::OK); + let resp = control( + &stub, + OTHER_PEER, + &add_hostname_body("0", "git.example.com"), + ) + .await; + assert!( + body_text(resp).await.contains("800"), + "route should still be held by the original owner" + ); + + // The owner's delete frees the hostname for a new target. + let resp = control(&stub, PEER, &delete_hostname_body("git.example.com")).await; + assert_eq!(resp.status(), StatusCode::OK); + let resp = control( + &stub, + OTHER_PEER, + &add_hostname_body("0", "git.example.com"), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + } + + // The StartWRT-shaped backend: no SNI dataplane, but the shared SCPD still + // advertises the vendor actions — both must refuse with the vendor fault + // 801 before reaching the backend, the UPnP twin of the PCP path's + // RESULT_UNSUPP_HOSTNAME refusal. + #[tokio::test] + async fn hostname_actions_fault_801_without_sni_dataplane() { + struct NoSniStub; + impl GatewayBackend for NoSniStub { + fn add_forward( + &self, + _: SocketAddrV4, + _: SocketAddrV4, + _: u16, + _: Ipv4Addr, + _: Option, + ) -> impl Future> + Send { + async { Ok(()) } + } + fn remove_forward(&self, _: Ipv4Addr, _: u16) -> impl Future + Send { + async {} + } + fn remove_forward_by_source( + &self, + _: SocketAddrV4, + _: Ipv4Addr, + ) -> impl Future + Send { + async { false } + } + fn external_ipv4(&self, _: Ipv4Addr) -> impl Future> + Send { + async { Some(EXT_IP) } + } + fn is_known_client(&self, _: Ipv4Addr) -> impl Future + Send { + async { true } + } + fn sni(&self) -> Option<&Arc> { + None + } + // The refusal must happen before any registration is attempted. + fn add_sni_forward( + &self, + _: SocketAddrV4, + _: SocketAddrV4, + _: &[String], + _: Option, + ) -> impl Future> + Send { + async { panic!("add_sni_forward reached on a backend without an SNI dataplane") } + } + } + + let stub = NoSniStub; + let body = add_hostname_body("0", "git.example.com"); + let resp = handle_control(&stub, PEER, &client_headers(&body), &body).await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(body_text(resp).await.contains("801")); + let body = delete_hostname_body("git.example.com"); + let resp = handle_control(&stub, PEER, &client_headers(&body), &body).await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(body_text(resp).await.contains("801")); + } } diff --git a/shared-libs/crates/start-core/src/net/port_map/server/igd_xml/scpd.xml b/shared-libs/crates/start-core/src/net/port_map/server/igd_xml/scpd.xml index 0e25e59217..cfd4e680a2 100644 --- a/shared-libs/crates/start-core/src/net/port_map/server/igd_xml/scpd.xml +++ b/shared-libs/crates/start-core/src/net/port_map/server/igd_xml/scpd.xml @@ -33,6 +33,34 @@ NewProtocolinPortMappingProtocol + + + X_START9_AddHostnameMapping + + NewRemoteHostinRemoteHost + NewExternalPortinExternalPort + NewProtocolinPortMappingProtocol + NewInternalPortinInternalPort + NewInternalClientinInternalClient + NewEnabledinPortMappingEnabled + NewPortMappingDescriptioninPortMappingDescription + NewLeaseDurationinPortMappingLeaseDuration + NewHostnameinX_START9_Hostname + + + + X_START9_DeleteHostnameMapping + + NewRemoteHostinRemoteHost + NewExternalPortinExternalPort + NewProtocolinPortMappingProtocol + NewInternalPortinInternalPort + NewHostnameinX_START9_Hostname + + ExternalIPAddressstring @@ -44,5 +72,6 @@ PortMappingEnabledboolean PortMappingDescriptionstring PortMappingLeaseDurationui4 + X_START9_Hostnamestring \ No newline at end of file diff --git a/shared-libs/crates/start-core/src/net/port_map/upnp.rs b/shared-libs/crates/start-core/src/net/port_map/upnp.rs index 02e7317808..fe9babb536 100644 --- a/shared-libs/crates/start-core/src/net/port_map/upnp.rs +++ b/shared-libs/crates/start-core/src/net/port_map/upnp.rs @@ -10,6 +10,10 @@ use igd_next::aio::Gateway; use igd_next::aio::tokio::{Tokio, search_gateway}; use igd_next::{PortMappingProtocol, SearchOptions}; +use crate::net::port_map::pcp::hostname::validate_hostname; +use crate::net::port_map::server::igd::{ + ADD_HOSTNAME_ACTION, DELETE_HOSTNAME_ACTION, WANIP_SERVICE, +}; use crate::prelude::*; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(4); @@ -86,6 +90,174 @@ pub async fn remove_port( } } +/// Lease we request for a hostname mapping; the server clamps to its own max +/// (3600s) and the controller re-asserts every refresh tick, well within it. +const HOSTNAME_LEASE_SECONDS: u32 = 3600; + +/// Whether `gateway` advertises the Start9 hostname vendor action. A plain +/// lookup: `discover()` already parsed the SCPD into `control_schema`, so +/// capability detection costs no extra round trip. +pub fn supports_hostname(gateway: &Gateway) -> bool { + gateway.control_schema.contains_key(ADD_HOSTNAME_ACTION) +} + +/// SOAP envelope for [`ADD_HOSTNAME_ACTION`], shaped like igd-next's +/// `format_add_port_mapping_message` (which the server's parser is tested +/// against). The caller has validated the hostname to `[A-Za-z0-9.*-]` +/// ([`add_hostname_mapping`]), so interpolation needs no XML escaping. +pub(crate) fn add_hostname_body( + external_port: u16, + local_ip: Ipv4Addr, + internal_port: u16, + hostname: &str, +) -> String { + format!( + r#" + + + + +{external_port} +TCP +{internal_port} +{local_ip} +1 +{DESCRIPTION} +{HOSTNAME_LEASE_SECONDS} +{hostname} + + +"#, + service = WANIP_SERVICE, + ) +} + +/// SOAP envelope for [`DELETE_HOSTNAME_ACTION`]. Carries the internal port so +/// the server can reconstruct the peer-scoped route target it is deleting. +pub(crate) fn delete_hostname_body( + external_port: u16, + internal_port: u16, + hostname: &str, +) -> String { + format!( + r#" + + + + +{external_port} +TCP +{internal_port} +{hostname} + + +"#, + service = WANIP_SERVICE, + ) +} + +/// POST a vendor-action SOAP request to `gateway`'s control endpoint. +/// igd-next's own `perform_request` is private, so this hand-rolls the same +/// HTTP shape (`SOAPAction: "#"`, text/xml body). +async fn vendor_control_call( + gateway: &Gateway, + action: &str, + body: String, +) -> Result<(), Error> { + let url = format!("http://{}{}", gateway.addr, gateway.control_url); + let soap_action = format!("\"{WANIP_SERVICE}#{action}\""); + let call = async { + // no_proxy: this is a LAN control call; reqwest otherwise honors + // HTTP_PROXY/ALL_PROXY, which igd-next's transport ignores — a proxy + // env would break (or leak) only the vendor actions. + let resp = reqwest::Client::builder() + .no_proxy() + .build() + .with_kind(ErrorKind::Network)? + .post(&url) + .header("SOAPAction", soap_action) + .header(reqwest::header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"") + .body(body) + .send() + .await + .map_err(|e| Error::new(eyre!("UPnP {action} failed: {e}"), ErrorKind::Network))?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + // Require the action's own <...Response> element, not just a 2xx — an + // intercepting middlebox answering 200 is not a created mapping. + if status.is_success() && text.contains(&format!("{action}Response")) { + Ok(()) + } else { + // A fault body carries ; surface it for diagnostics. + let code = text + .split("") + .nth(1) + .and_then(|t| t.split("").next()) + .unwrap_or("unknown"); + Err(Error::new( + eyre!("UPnP {action} failed: HTTP {status}, UPnP error {code}"), + ErrorKind::Network, + )) + } + }; + match tokio::time::timeout(CONTROL_TIMEOUT, call).await { + Ok(r) => r, + Err(_) => Err(Error::new( + eyre!("UPnP {action} timed out"), + ErrorKind::Network, + )), + } +} + +/// Bind `hostname` on `external_port` -> `local_ip:internal_port` via the +/// Start9 vendor action. The gateway SNI-demuxes the shared external port; the +/// granted lease is finite, so the caller must re-assert before expiry. +pub async fn add_hostname_mapping( + gateway: &Gateway, + external_port: u16, + local_ip: Ipv4Addr, + internal_port: u16, + hostname: &str, +) -> Result<(), Error> { + // Nothing upstream character-validates a configured domain, and the + // envelope interpolates it unescaped — reject here rather than emit + // malformed XML the server can only 402. + if !validate_hostname(hostname) { + return Err(Error::new( + eyre!("invalid hostname for SNI mapping: {hostname:?}"), + ErrorKind::InvalidRequest, + )); + } + vendor_control_call( + gateway, + ADD_HOSTNAME_ACTION, + add_hostname_body(external_port, local_ip, internal_port, hostname), + ) + .await +} + +/// Remove the SNI route for `hostname` on `external_port` targeting +/// `internal_port` on the caller. +pub async fn remove_hostname_mapping( + gateway: &Gateway, + external_port: u16, + internal_port: u16, + hostname: &str, +) -> Result<(), Error> { + if !validate_hostname(hostname) { + return Err(Error::new( + eyre!("invalid hostname for SNI mapping: {hostname:?}"), + ErrorKind::InvalidRequest, + )); + } + vendor_control_call( + gateway, + DELETE_HOSTNAME_ACTION, + delete_hostname_body(external_port, internal_port, hostname), + ) + .await +} + /// Whether `ip` is a routable public IPv4 worth reporting. A gateway behind /// CGNAT/double-NAT reports a private external IP, useless for clearnet, so the /// caller falls back to an echoip probe. From 2b7ba11566313c9f30e9b6cb3161df1a0cc337c2 Mon Sep 17 00:00:00 2001 From: Shadowy Super Coder Date: Fri, 14 Aug 2026 12:23:02 -0600 Subject: [PATCH 02/14] feat(start-wrt): SNI hostname-route dataplane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_, 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). --- projects/start-wrt/API_CONTRACT.md | 196 +++--- projects/start-wrt/CHANGELOG.md | 17 +- .../start-wrt/backend/ctrl/src/bins/daemon.rs | 13 + .../backend/ctrl/src/port_control.rs | 560 +++++++++++++++++- .../backend/ctrl/src/published_ports.rs | 9 + .../start-wrt/backend/ctrl/src/vpn_server.rs | 1 + .../backend/hotplug/99-startwrt-port-control | 7 + .../nftables/12-startwrt-sni-divert.nft | 17 + .../start-wrt/backend/uciedit/src/openwrt.rs | 5 + projects/start-wrt/build.mk | 4 +- projects/start-wrt/build/openwrt.diffconfig | 4 + projects/start-wrt/build/stage-files.sh | 2 + .../start-wrt/docs/src/published-ports.md | 6 + .../app/routes/published-ports/auto-table.ts | 4 + .../src/app/routes/published-ports/service.ts | 1 + .../src/app/routes/published-ports/types.ts | 4 +- .../web/src/app/services/api/api.service.ts | 4 +- .../src/app/services/api/mock-api.service.ts | 13 + rfcs/upnp-vendor-hostname-action.md | 41 +- .../crates/start-core/src/net/transparent.rs | 111 +++- .../start-core/src/tunnel/forward/sni.rs | 158 +++++ 21 files changed, 1041 insertions(+), 136 deletions(-) create mode 100644 projects/start-wrt/backend/hotplug/99-startwrt-port-control create mode 100644 projects/start-wrt/backend/nftables/12-startwrt-sni-divert.nft diff --git a/projects/start-wrt/API_CONTRACT.md b/projects/start-wrt/API_CONTRACT.md index 43985688fa..ae9a0c072e 100644 --- a/projects/start-wrt/API_CONTRACT.md +++ b/projects/start-wrt/API_CONTRACT.md @@ -1049,6 +1049,13 @@ appear in `published-ports.list`, and `published-ports.set` leaves them alone _except_ that a manual rule claiming an auto-held external port wins: `set` removes the overlapping auto forward. +Besides plain forwards, the list includes **SNI hostname routes** (label +`"SNI"`, `hostname` set): TLS routes on a shared external port, demultiplexed +by ClientHello SNI so several devices/hostnames share one port. These live in +daemon memory (not UCI), always carry a lease (≤1h, device-renewed), and do +not survive a daemon restart — the device re-asserts them. A demuxed port +reads as router-reserved to manual and plain-auto forwards. + ```rust // Request: {} @@ -1056,10 +1063,13 @@ removes the overlapping auto forward. struct AutoForward { /// UCI section name (`apf__`), stable per (device, external /// port) — UPnP's mapping identity, so one device may hold several - /// external ports to the same internal port. + /// external ports to the same internal port. SNI rows use the synthetic + /// `sni__` instead. id: String, - /// Which protocol created it: "PCP" or "UPnP". + /// Which protocol created it: "PCP" or "UPnP" — or "SNI" for a hostname + /// route (created over either protocol). label: String, + /// May be empty on an SNI row whose target address matches no known device. device_mac: String, device_name: Option, /// Forward target address on the LAN. @@ -1071,12 +1081,29 @@ struct AutoForward { /// Seconds until the lease expires if the device stops renewing it. None /// right after a daemon restart, before the first grace lease is granted. expires_secs: Option, + /// TLS-SNI hostname for an SNI route; None for plain forwards. + hostname: Option, } // Response: Vec -// Backend: reads `_apf_*`-tagged firewall redirects; names enriched from DHCP -// host entries and the persistent device-name cache. +// Backend: reads `_apf_*`-tagged firewall redirects plus the SNI demux's live +// routes; names enriched from DHCP host entries and the persistent +// device-name cache. +``` + +### `published-ports.wan-changed` + +```rust +// Request: {} +// Response: null ``` +Internal endpoint (`no_auth`), **not called from the frontend**. Fired by the +`/etc/hotplug.d/iface/99-startwrt-port-control` hook on `wan` `ifup`/ +`ifupdate`: forwards to the daemon, which re-keys live SNI hostname routes +onto the (possibly changed) WAN IPv4 — their listeners bind the WAN address +itself. The daemon's sweep re-checks once a minute as a backstop. No-op in +configs-only mode or when no routes exist. + ### `published-ports.reconcile` ```rust @@ -1814,86 +1841,87 @@ The daemon (`backend/ctrl/src/bins/daemon.rs`) also serves: ## Endpoint Summary -| RPC Method | Category | Notes | -| ---------------------------- | --------------- | --------------------------- | -| `auth.login` | Auth | Rate-limited | -| `auth.logout` | Auth | | -| `auth.verify-password` | Auth | | -| `auth.set-password` | Auth | | -| `auth.check-initialized` | Auth | No auth | -| `auth.set-initial-password` | Auth | No session; rate-limited | -| `system.info` | System | No auth | -| `system.newer-versions` | System | No auth | -| `system.update` | System | | -| `system.restart` | System | | -| `system.factory-reset` | System | | -| `system.set-preferences` | System | | -| `system.apply-remote-access` | System | No auth; internal, hotplug | -| `system.set-timezone` | System | No auth | -| `system.get-timezones` | System | No auth | -| `system.logs` | System | | -| `setup.status` | Setup | No auth | -| `wan.ipv4-get` | WAN | | -| `wan.ipv4-set` | WAN | | -| `wan.ipv6-get` | WAN | | -| `wan.ipv6-set` | WAN | | -| `wan.mac-get` | WAN | | -| `wan.mac-set` | WAN | | -| `wan.dns-get` | WAN | | -| `wan.dns-set` | WAN | | -| `wan.ddns-get` | WAN | | -| `wan.ddns-set` | WAN | | -| `lan.ipv4-get` | LAN | | -| `lan.ipv4-set` | LAN | | -| `lan.ipv6-get` | LAN | | -| `lan.ipv6-set` | LAN | | -| `ethernet.get` | Ethernet | | -| `ethernet.set` | Ethernet | | -| `ethernet.edit` | Ethernet | CLI editor | -| `devices.list` | Devices | | -| `devices.update` | Devices | | -| `devices.set-auto-forward` | Devices | | -| `devices.forget` | Devices | | -| `devices.data-usage` | Devices | | -| `published-ports.list` | Published Ports | | -| `published-ports.set` | Published Ports | | -| `published-ports.auto-list` | Published Ports | Automatic PCP/UPnP forwards | -| `published-ports.reconcile` | Published Ports | No auth; internal, hotplug | -| `vpn-client.list` | Outbound VPN | | -| `vpn-client.create` | Outbound VPN | | -| `vpn-client.update` | Outbound VPN | | -| `vpn-client.delete` | Outbound VPN | | -| `vpn-client.set-enabled` | Outbound VPN | | -| `vpn-server.list` | Inbound VPN | | -| `vpn-server.set` | Inbound VPN | | -| `vpn-server.delete` | Inbound VPN | | -| `vpn-server.peer-add` | Inbound VPN | | -| `vpn-server.peer-delete` | Inbound VPN | | -| `wifi.get` | WiFi | | -| `wifi.set` | WiFi | | -| `wifi.edit` | WiFi | CLI editor | -| `wifi.blackout-get` | WiFi | | -| `wifi.blackout-set` | WiFi | | -| `wifi.generate-password` | WiFi | | -| `profiles.list` | Profiles | | -| `profiles.get` | Profiles | | -| `profiles.create` | Profiles | | -| `profiles.set` | Profiles | | -| `profiles.delete` | Profiles | | -| `profiles.edit` | Profiles | CLI editor | -| `profiles.schedule-get` | Profiles | | -| `profiles.schedule-set` | Profiles | | -| `ssh-keys.list` | SSH Keys | | -| `ssh-keys.add` | SSH Keys | | -| `ssh-keys.delete` | SSH Keys | | -| `activity.list` | Activity | | -| `activity.delete` | Activity | | -| `activity.clear` | Activity | | -| `backup.create` | Backup | | -| `backup.restore` | Backup | | -| `diagnostics.create` | Diagnostics | | - -**Totals:** 76 RPC methods across 16 categories, plus the HTTP/WebSocket routes +| RPC Method | Category | Notes | +| ----------------------------- | --------------- | --------------------------- | +| `auth.login` | Auth | Rate-limited | +| `auth.logout` | Auth | | +| `auth.verify-password` | Auth | | +| `auth.set-password` | Auth | | +| `auth.check-initialized` | Auth | No auth | +| `auth.set-initial-password` | Auth | No session; rate-limited | +| `system.info` | System | No auth | +| `system.newer-versions` | System | No auth | +| `system.update` | System | | +| `system.restart` | System | | +| `system.factory-reset` | System | | +| `system.set-preferences` | System | | +| `system.apply-remote-access` | System | No auth; internal, hotplug | +| `system.set-timezone` | System | No auth | +| `system.get-timezones` | System | No auth | +| `system.logs` | System | | +| `setup.status` | Setup | No auth | +| `wan.ipv4-get` | WAN | | +| `wan.ipv4-set` | WAN | | +| `wan.ipv6-get` | WAN | | +| `wan.ipv6-set` | WAN | | +| `wan.mac-get` | WAN | | +| `wan.mac-set` | WAN | | +| `wan.dns-get` | WAN | | +| `wan.dns-set` | WAN | | +| `wan.ddns-get` | WAN | | +| `wan.ddns-set` | WAN | | +| `lan.ipv4-get` | LAN | | +| `lan.ipv4-set` | LAN | | +| `lan.ipv6-get` | LAN | | +| `lan.ipv6-set` | LAN | | +| `ethernet.get` | Ethernet | | +| `ethernet.set` | Ethernet | | +| `ethernet.edit` | Ethernet | CLI editor | +| `devices.list` | Devices | | +| `devices.update` | Devices | | +| `devices.set-auto-forward` | Devices | | +| `devices.forget` | Devices | | +| `devices.data-usage` | Devices | | +| `published-ports.list` | Published Ports | | +| `published-ports.set` | Published Ports | | +| `published-ports.auto-list` | Published Ports | Automatic PCP/UPnP forwards | +| `published-ports.reconcile` | Published Ports | No auth; internal, hotplug | +| `published-ports.wan-changed` | Published Ports | No auth; internal, hotplug | +| `vpn-client.list` | Outbound VPN | | +| `vpn-client.create` | Outbound VPN | | +| `vpn-client.update` | Outbound VPN | | +| `vpn-client.delete` | Outbound VPN | | +| `vpn-client.set-enabled` | Outbound VPN | | +| `vpn-server.list` | Inbound VPN | | +| `vpn-server.set` | Inbound VPN | | +| `vpn-server.delete` | Inbound VPN | | +| `vpn-server.peer-add` | Inbound VPN | | +| `vpn-server.peer-delete` | Inbound VPN | | +| `wifi.get` | WiFi | | +| `wifi.set` | WiFi | | +| `wifi.edit` | WiFi | CLI editor | +| `wifi.blackout-get` | WiFi | | +| `wifi.blackout-set` | WiFi | | +| `wifi.generate-password` | WiFi | | +| `profiles.list` | Profiles | | +| `profiles.get` | Profiles | | +| `profiles.create` | Profiles | | +| `profiles.set` | Profiles | | +| `profiles.delete` | Profiles | | +| `profiles.edit` | Profiles | CLI editor | +| `profiles.schedule-get` | Profiles | | +| `profiles.schedule-set` | Profiles | | +| `ssh-keys.list` | SSH Keys | | +| `ssh-keys.add` | SSH Keys | | +| `ssh-keys.delete` | SSH Keys | | +| `activity.list` | Activity | | +| `activity.delete` | Activity | | +| `activity.clear` | Activity | | +| `backup.create` | Backup | | +| `backup.restore` | Backup | | +| `diagnostics.create` | Diagnostics | | + +**Totals:** 77 RPC methods across 16 categories, plus the HTTP/WebSocket routes table above and the deprecated generic endpoints below. --- diff --git a/projects/start-wrt/CHANGELOG.md b/projects/start-wrt/CHANGELOG.md index 80b7ff4564..062d488f4c 100644 --- a/projects/start-wrt/CHANGELOG.md +++ b/projects/start-wrt/CHANGELOG.md @@ -45,11 +45,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 UPnP endpoints refuse browser-shaped requests — DNS-rebinding requests and blind cross-origin writes alike — so a malicious web page cannot use a LAN device's browser to read the network's public IP, fingerprint the router, or - open that device's ports. Uses the shared `start-core` PCP/IGD - server cores; since StartWRT has no SNI demux, the shared PCP server now - advertises the Start9 HOSTNAME capability only on gateways that really - implement it (StartTunnel), so StartOS clients fall back to plain forwards - here instead of recording hostname mappings that would route nothing. + open that device's ports. Uses the shared `start-core` PCP/IGD server cores. + Devices can also register **SNI hostname routes** on a shared external port + (over PCP's HOSTNAME extension or the `X_START9_AddHostnameMapping` UPnP + vendor action): the router reads each TLS connection's requested hostname + and delivers it to whichever device owns it, so several devices — or several + services on one StartOS server with their own domains — share one port such + as 443. Hostname routes appear in the Automatic section with their hostname, + follow the same per-device permission and lease expiry as plain forwards, + claim their shared port whole (plain forwards on it are refused; ports the + router itself answers on are refused to hostname routes for the same + reason), and are re-registered by the device within minutes after a router + restart rather than persisted. - The UI now detects when the running firmware ships a newer interface than the page is displaying (every RPC response and `system.info` report the firmware's build stamp and the UI compares it to its own). An update diff --git a/projects/start-wrt/backend/ctrl/src/bins/daemon.rs b/projects/start-wrt/backend/ctrl/src/bins/daemon.rs index 069244fef9..78dac63e17 100644 --- a/projects/start-wrt/backend/ctrl/src/bins/daemon.rs +++ b/projects/start-wrt/backend/ctrl/src/bins/daemon.rs @@ -350,6 +350,19 @@ async fn inner_main() -> Result<(), Error> { // Port-control servers (PCP + UPnP IGD): automatic port forwarding for // per-device-authorized LAN clients. After init_ssl so the IGD device // UUID (derived from the root CA) is stable. + // + // SNI-demux divert parameters, before anything can run the demux: the + // nft mark rule ships as an fw4 include (12-startwrt-sni-divert.nft, + // `mark or` — hence the masked match), so only the iproute2 half is + // managed in-process; table 5344 clears the VLAN-tag (1-4094) table + // namespace; priority 49 sits below the 100/150/200 rule ladder. + let _ = + startos::net::transparent::set_divert_config(startos::net::transparent::DivertConfig { + route_table: 5344, + rule_priority: 49, + masked_fwmark: true, + manage_nft: false, + }); let pc = crate::port_control::PortControl::new("/etc/config".into()); if crate::port_control::PORT_CONTROL.set(pc.clone()).is_ok() { tokio::spawn(crate::port_control::run(pc)); diff --git a/projects/start-wrt/backend/ctrl/src/port_control.rs b/projects/start-wrt/backend/ctrl/src/port_control.rs index e1a2e68b13..d4f8db5478 100644 --- a/projects/start-wrt/backend/ctrl/src/port_control.rs +++ b/projects/start-wrt/backend/ctrl/src/port_control.rs @@ -38,9 +38,31 @@ //! reservation pins the forward regardless, since nobody else can be given that //! address. //! -//! This gateway has no SNI demux dataplane, so [`GatewayBackend::sni`] is -//! `None`: the PCP ANNOUNCE reply omits the Start9 capability marker and -//! clients use plain forwards only. +//! # SNI hostname routes +//! +//! Besides plain forwards, an authorized device can register TLS-SNI hostname +//! routes on a shared external port (PCP HOSTNAME options, or the UPnP +//! `X_START9_AddHostnameMapping` vendor action): the shared [`SniDemux`] reads +//! each connection's ClientHello on the WAN address and splices it to whichever +//! device owns that hostname, so several devices share one port. Unlike plain +//! forwards, these routes are *not* UCI sections: they live in demux memory +//! with the finite lease both protocols grant (≤1h, self-expiring — the demux +//! prunes them, not the sweep), and a daemon restart drops them until the +//! client's next re-assertion. What does touch UCI is admission: each demuxed +//! port gets a WAN-input ACCEPT rule (`apf_sni_`, tagged +//! `_apf_label 'SNI'`) so fw4 lets the listener's traffic in — written inline +//! with registration, removed by the demux's `on_change` teardown callback, +//! healed by the sweep, and purged at daemon start (rules must not outlive the +//! in-memory routes they admit). That rule also makes the port read as +//! router-reserved, so plain forwards can't grab a demuxed port out from under +//! its hostnames. Conversely a hostname route is refused on a port already +//! DNAT-forwarded or answered by the router itself (Remote Access, VPN) — the +//! demux's specific `(wan_ip, port)` bind would beat the router service's +//! wildcard bind and capture traffic it has no route for. The reply-path +//! divert's nft half ships declaratively as an fw4 include +//! (`/etc/nftables.d/12-startwrt-sni-divert.nft`); the iproute2 half is +//! configured at daemon init (`set_divert_config`) and re-asserted by the +//! demux. //! //! # LAN source-address spoofing: cross-segment closed, same-segment open //! @@ -89,11 +111,13 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; +use imbl_value::Value; use nix::net::if_::if_nametoindex; use nix::sys::socket::sockopt::Ipv4PacketInfo; use nix::sys::socket::{recvmsg, setsockopt, ControlMessageOwned, MsgFlags, SockaddrIn}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use startos::net::port_map::pcp::hostname::RESULT_HOSTNAME_TAKEN; use startos::net::port_map::server::igd::{ format_uuid, handle_control, header_value, render_root_desc, serve_static, ssdp_response, st_matches, CIF_SCPD, CIF_SCPD_PATH, CONTROL_PATH, IGD_HTTP_PORT, ROOT_DESC_PATH, SCPD, @@ -129,6 +153,9 @@ const UCI_RETRIES: usize = 4; pub const LABEL_PCP: &str = "PCP"; pub const LABEL_UPNP: &str = "UPnP"; +/// `_apf_label` tag on the WAN-input ACCEPT rules admitting SNI-demux +/// listeners (and the `label` of SNI rows in `auto-list`). +pub const LABEL_SNI: &str = "SNI"; // UPnP IGD error codes, reused verbatim by the shared PCP core. const IGD_ACTION_FAILED: u16 = 501; @@ -152,6 +179,8 @@ pub struct PortControl { lan_cache: Mutex>)>>, /// Requesting IP → resolved+authorized device (None = unauthorized). client_cache: Mutex)>>, + /// SNI hostname-route dataplane; see the module doc. + sni: Arc, } #[derive(Clone, Debug)] @@ -169,15 +198,29 @@ struct LanAddr { } impl PortControl { + /// Requires a tokio runtime: the demux's constructor spawns its prune task. pub fn new(uci_root: PathBuf) -> Arc { - Arc::new(Self { - uci_root, - started: Instant::now(), - write_serial: tokio::sync::Mutex::new(()), - leases: Mutex::new(HashMap::new()), - wan_cache: Mutex::new(None), - lan_cache: Mutex::new(None), - client_cache: Mutex::new(HashMap::new()), + Arc::new_cyclic(|weak: &std::sync::Weak| { + let weak = weak.clone(); + Self { + uci_root, + started: Instant::now(), + write_serial: tokio::sync::Mutex::new(()), + leases: Mutex::new(HashMap::new()), + wan_cache: Mutex::new(None), + lan_cache: Mutex::new(None), + client_cache: Mutex::new(HashMap::new()), + // The teardown half of the ACCEPT-rule lifecycle: expiry/ + // unregister reaps a port's listener and this drops its rule. + // Creation is inline in `add_sni_route` (under the write lock, + // so the rule exists before any concurrent conflict scan runs); + // for `(port, true)` this is just an idempotent upsert. + sni: SniDemux::with_on_change(move |port, active| { + if let Some(pc) = weak.upgrade() { + tokio::spawn(on_sni_change(pc, port, active)); + } + }), + } }) } @@ -283,6 +326,12 @@ impl PortControl { ip } + /// Drop the cached WAN address so the next read sees a change immediately + /// (the wan hotplug hook fires this ahead of an SNI re-key). + pub fn invalidate_wan(&self) { + *self.wan_cache.lock().unwrap() = None; + } + /// The router's own IPv4 addresses on LAN bridges (`br-*`), cached briefly. async fn lan_addrs(&self) -> Arc> { { @@ -609,6 +658,96 @@ impl PortControl { } Ok(()) } + + /// Register SNI hostname routes on `source` for `target` (the shared cores + /// force `target` to the requesting device and gate on `is_known_client` + /// before we're called). Refuses a port whose traffic already has another + /// consumer — see [`sni_port_conflicts`]. The write lock is held across + /// scan → demux registration → admit-rule write, so a concurrent plain + /// forward can't DNAT the port between the scan and the rule landing (and + /// its own scan sees our rule as router-reserved). + async fn add_sni_route( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + hostnames: &[String], + lifetime: Option, + ) -> Result<(), u8> { + let _serial = self.write_serial.lock().await; + let port = source.port(); + let uci_root = self.uci_root.clone(); + let conflicts = uci_task(move || async move { + let arena = Arena::new(); + let cfgs = parse_all(&uci_root, &arena, &["firewall"]).await?; + Ok(sni_port_conflicts(&cfgs["firewall"], port)) + }) + .await + .map_err(|e| { + tracing::warn!("port-control: SNI conflict scan failed: {e}"); + RESULT_HOSTNAME_TAKEN + })?; + if conflicts { + return Err(RESULT_HOSTNAME_TAKEN); + } + self.sni + .register(*source.ip(), port, hostnames, target, lifetime)?; + tracing::info!( + "port-control: SNI route(s) {hostnames:?} on {source} -> {target} \ + (lease {lifetime:?}s)" + ); + // Best-effort: the demux routes regardless, and the sweep heals a + // missed rule within a minute. + self.sync_sni_rules().await; + Ok(()) + } + + /// Reconcile the `apf_sni_*` WAN-admit rules against the demux's live port + /// set, reloading the firewall when anything changed. Callers must hold + /// `write_serial`. + async fn sync_sni_rules(&self) { + let want: std::collections::BTreeSet = self + .sni + .snapshot() + .into_iter() + .map(|r| r.ext_port) + .collect(); + let uci_root = self.uci_root.clone(); + match uci_task(move || async move { reconcile_sni_rules_uci(&uci_root, want).await }).await + { + Ok(true) => self.reload_firewall(), + Ok(false) => {} + Err(e) => tracing::warn!("port-control: reconciling SNI admit rules failed: {e}"), + } + } + + /// Sweep companion for the SNI dataplane: re-key live routes onto a changed + /// WAN address (backstop for the wan hotplug hook), then bring the admit + /// rules back in line with the live ports (heals a failed callback write, + /// an external edit, or a stale rule from before a daemon restart). + async fn sni_maintain(&self) { + if let Some(ip) = self.wan_ipv4().await { + if self.sni.snapshot().iter().any(|r| r.ext_ip != ip) { + tracing::info!("port-control: re-keying SNI routes onto WAN address {ip}"); + self.sni.rekey_ipv4(ip); + } + } + let _serial = self.write_serial.lock().await; + self.sync_sni_rules().await; + } +} + +/// [`SniDemux`] `on_change` body: a port's listener started or stopped — +/// reconcile the admit rules against the live port set rather than acting on +/// the edge itself, which makes the spawned callback idempotent and immune to +/// reordering. Creation is additionally done inline in +/// [`PortControl::add_sni_route`]; this is the expiry/teardown path. +async fn on_sni_change(pc: Arc, port: u16, active: bool) { + tracing::debug!( + "port-control: SNI listener on port {port} {}", + if active { "started" } else { "stopped" } + ); + let _serial = pc.write_serial.lock().await; + pc.sync_sni_rules().await; } /// Which interface a request physically arrived on, for the arrival-interface @@ -711,10 +850,25 @@ impl GatewayBackend for Via { self.pc.forwards_for(peer).await } - /// No SNI dataplane on this gateway: the ANNOUNCE marker is omitted and - /// HOSTNAME-bound mappings are refused by the shared core. + /// The ANNOUNCE capability marker is advertised and HOSTNAME-bound + /// mappings accepted; see the module doc's "SNI hostname routes". fn sni(&self) -> Option<&Arc> { - None + Some(&self.pc.sni) + } + + /// Overridden (vs the default demux-only registration) to gate admission + /// on this gateway's port ownership and to open the WAN firewall for the + /// listener; see [`PortControl::add_sni_route`]. + async fn add_sni_forward( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + hostnames: &[String], + lifetime: Option, + ) -> Result<(), u8> { + self.pc + .add_sni_route(source, target, hostnames, lifetime) + .await } } @@ -1029,6 +1183,140 @@ async fn remove_auto_sections(uci_root: &Path, names: &[String]) -> Result String { + format!("apf_sni_{port}") +} + +/// The WAN-input ACCEPT rule admitting the SNI-demux listener on `port`. +/// tcp-only — the demux reads TLS ClientHellos. Its presence also makes +/// [`router_reserved_overlaps`] count the port as router-held, which is what +/// keeps plain forwards off a demuxed port. +fn desired_sni_rule(port: u16) -> FirewallRule { + FirewallRule { + name: "SNI demux (hostname routes)".into(), + src: "wan".into(), + // No dest zone: input chain — traffic terminates on the router. + proto: vec!["tcp".into()], + dest_port: Some(port.to_string()), + target: FirewallTarget::ACCEPT, + enabled: Some("1".into()), + _apf_label: Some(LABEL_SNI.into()), + ..Default::default() + } +} + +/// Whether tcp `port` is unavailable to the SNI demux: an enabled WAN-ingress +/// DNAT redirect overlaps it (that traffic already goes wholesale to one +/// device), or the router itself answers on it from the WAN (Remote Access, +/// the VPN server — the demux's specific `(wan_ip, port)` bind would beat +/// their wildcard binds and capture traffic it has no route for). The demux's +/// own admit rules are excluded, so a second hostname on an already-demuxed +/// port is not a self-conflict. +fn sni_port_conflicts(firewall: &uciedit::Config<'_>, port: u16) -> bool { + let want = (port, port); + for sec in &firewall.sections { + if let Ok(r) = sec.get::() { + if r.target == "DNAT" + && r.enabled.as_deref() != Some("0") + && r.src == "wan" + && r.src_dport + .as_deref() + .and_then(parse_port_range) + .is_some_and(|range| ranges_overlap(want, range)) + { + return true; + } + continue; + } + let Ok(rule) = sec.get::() else { + continue; + }; + if rule._apf_label.as_deref() == Some(LABEL_SNI) { + continue; + } + if rule.target != FirewallTarget::ACCEPT || rule.enabled.as_deref() == Some("0") { + continue; + } + if rule.src != "wan" || rule.dest.is_some() || rule.family.as_deref() == Some("ipv6") { + continue; + } + // Default proto is tcp+udp; only tcp reachability matters to the demux. + let tcp = rule.proto.is_empty() + || rule.proto.iter().any(|p| { + p.eq_ignore_ascii_case("tcp") + || p.eq_ignore_ascii_case("all") + || p.eq_ignore_ascii_case("tcpudp") + }); + if !tcp { + continue; + } + if rule + .dest_port + .as_deref() + .and_then(parse_port_range) + .is_some_and(|range| ranges_overlap(want, range)) + { + return true; + } + } + false +} + +/// Make the set of `_apf_label 'SNI'` rules exactly match `want` (one rule per +/// port): strays — a port no longer demuxed, a duplicate, an unparseable +/// section — are removed, missing ports appended. Returns whether anything was +/// written. `want = ∅` is the daemon-start purge (in-memory routes did not +/// survive, so neither may the rules admitting them). +async fn reconcile_sni_rules_uci( + uci_root: &Path, + want: std::collections::BTreeSet, +) -> Result { + let mut retries = UCI_RETRIES; + loop { + let arena = Arena::new(); + let mut cfgs = parse_all(uci_root, &arena, &["firewall"]).await?; + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut changed = false; + cfgs["firewall"].sections.retain(|sec| { + let Ok(rule) = sec.get::() else { + return true; + }; + if rule._apf_label.as_deref() != Some(LABEL_SNI) { + return true; + } + let port = rule + .dest_port + .as_deref() + .and_then(parse_port_range) + .map(|r| r.0); + let keep = port.is_some_and(|p| want.contains(&p) && seen.insert(p)); + if !keep { + changed = true; + } + keep + }); + for port in want.iter().filter(|p| !seen.contains(p)) { + cfgs["firewall"].append(&desired_sni_rule(*port), Some(&sni_section_name(*port)))?; + changed = true; + } + if !changed { + return Ok(false); + } + match dump_all(uci_root, cfgs).await { + Err(uciedit::Error::Conflict { .. }) if retries > 0 => { + retries -= 1; + continue; + } + Err(e) => return Err(e.into()), + Ok(()) => return Ok(true), + } + } +} + // ── Parsing helpers ────────────────────────────────────────────── /// "443" → (443, 443); "1000-1009" → (1000, 1009). None on garbage. @@ -1125,6 +1413,13 @@ fn device_uuid() -> String { /// Run all port-control servers for the life of the daemon. Each half /// self-restarts on error, and each runs in its own supervised task. pub async fn run(pc: Arc) { + // SNI routes are in-memory and did not survive the restart; drop any admit + // rules left behind so no port stays open with nothing routing it. Clients + // that still want their routes re-assert within their lease. + { + let _serial = pc.write_serial.lock().await; + pc.sync_sni_rules().await; + } tokio::join!( supervise("PCP", pc.clone(), run_pcp), supervise("IGD", pc.clone(), run_igd), @@ -1383,6 +1678,7 @@ async fn run_sweep(pc: Arc) { if let Err(e) = pc.sweep().await { tracing::warn!("port-control sweep failed: {e}"); } + pc.sni_maintain().await; } } @@ -1406,6 +1702,9 @@ pub struct AutoForward { /// Seconds until the lease expires if not renewed (None when the daemon /// isn't tracking it yet, e.g. right after boot). pub expires_secs: Option, + /// The TLS-SNI hostname, for an SNI hostname route (label "SNI") sharing + /// its external port with other hostnames. None for plain forwards. + pub hostname: Option, } #[instrument(skip_all)] @@ -1445,11 +1744,59 @@ pub async fn auto_list(ctx: ServerContext) -> Result, Error> { internal_ip: r.dest_ip.clone(), ports: r.dest_port.clone().unwrap_or_default(), public_ports: r.src_dport.clone().unwrap_or_default(), + hostname: None, }); } + + // SNI hostname routes live in the demux, not UCI — one row per hostname. + // The owning device is whoever holds the target address: a static + // reservation first, then the live DHCP lease. + if let Some(pc) = PORT_CONTROL.get() { + let mut ip_to_mac: HashMap = HashMap::new(); + if let Some(leases) = crate::devices::current_lease_ips().await { + for (mac, ip) in leases { + ip_to_mac.insert(ip, mac); + } + } + cfgs["dhcp"].each::(|_, host| { + if let Some(ip) = host.ip.clone().filter(|ip| !ip.is_empty()) { + ip_to_mac.insert(ip, host.mac.to_uppercase()); + } + })?; + for route in pc.sni.snapshot() { + let target_ip = route.target.ip().to_string(); + let device_mac = ip_to_mac.get(&target_ip).cloned().unwrap_or_default(); + out.push(AutoForward { + id: format!("sni_{}_{}", route.ext_port, route.hostname), + label: LABEL_SNI.into(), + device_name: names.get(&device_mac).cloned(), + device_mac, + internal_ip: Some(target_ip), + ports: route.target.port().to_string(), + public_ports: route.ext_port.to_string(), + expires_secs: route.remaining_secs, + hostname: Some(route.hostname), + }); + } + } Ok(out) } +/// Hidden: the wan hotplug hook. The WAN IPv4 may have changed — re-key live +/// SNI routes onto it immediately rather than waiting for the sweep's +/// once-a-minute backstop. Runs in the daemon (the demux is daemon memory). +#[instrument(skip_all)] +pub async fn wan_changed(ctx: ServerContext) -> Result { + if !ctx.effectful() { + return Ok(Value::Null); + } + if let Some(pc) = PORT_CONTROL.get() { + pc.invalidate_wan(); + pc.sni_maintain().await; + } + Ok(Value::Null) +} + #[derive(Debug, Serialize, Deserialize)] pub struct SetAutoForwardReq { pub mac: String, @@ -2067,4 +2414,189 @@ config redirect 'dns_override_lan' // ...and when the arrival interface itself was indeterminate. assert!(!arrival_matches(Arrival::Indeterminate, "lo")); } + + // ── SNI hostname routes ── + + #[tokio::test] + async fn sni_route_registers_and_admits_inline() { + let dir = temp_root(""); + let pc = PortControl::new(dir.path().to_path_buf()); + let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443); + let target = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 50), 443); + pc.add_sni_route(source, target, &["nas.example.com".to_string()], Some(3600)) + .await + .unwrap(); + + let snap = pc.sni.snapshot(); + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].hostname, "nas.example.com"); + assert_eq!(snap[0].target, target); + + // The WAN-admit rule is written inline with registration (under the + // write lock), not deferred to the spawned callback. + let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); + assert!(written.contains("config rule apf_sni_8443")); + assert!(written.contains("option _apf_label 'SNI'")); + assert!(written.contains("option dest_port '8443'")); + assert!(written.contains("option target 'ACCEPT'")); + assert!(written.contains("list proto 'tcp'")); + + // A second hostname on the same port is the point of the demux, not a + // conflict — and the port still has exactly one admit rule. + let target2 = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 51), 443); + pc.add_sni_route( + source, + target2, + &["cloud.example.com".to_string()], + Some(3600), + ) + .await + .unwrap(); + assert_eq!(pc.sni.snapshot().len(), 2); + let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); + assert_eq!(written.matches("apf_sni_8443").count(), 1); + } + + #[tokio::test] + async fn sni_route_refused_on_owned_ports() { + // A DNAT'd port already sends its traffic wholesale to one device. + let dir = temp_root(MANUAL_FW); + let pc = PortControl::new(dir.path().to_path_buf()); + let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 443); + let target = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 60), 443); + let err = pc + .add_sni_route(source, target, &["a.example.com".to_string()], Some(3600)) + .await + .unwrap_err(); + assert_eq!(err, RESULT_HOSTNAME_TAKEN); + assert!( + pc.sni.snapshot().is_empty(), + "nothing registered on refusal" + ); + + // A port the router answers on itself (Remote Access): the demux's + // specific (wan_ip, port) bind would beat the router's wildcard bind + // and capture its WAN UI. + let dir = temp_root(ROUTER_FW); + let pc = PortControl::new(dir.path().to_path_buf()); + let err = pc + .add_sni_route(source, target, &["a.example.com".to_string()], Some(3600)) + .await + .unwrap_err(); + assert_eq!(err, RESULT_HOSTNAME_TAKEN); + + // ...but a UDP-only router port (WireGuard) shares nothing with TLS. + let wg = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 51820); + pc.add_sni_route( + wg, + SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 60), 51820), + &["b.example.com".to_string()], + Some(3600), + ) + .await + .unwrap(); + } + + // The admit rule makes a demuxed port read as router-held, so a plain auto + // forward can't DNAT it out from under its hostnames. + #[tokio::test] + async fn sni_admit_rule_reserves_the_port() { + let dir = temp_root(""); + let pc = PortControl::new(dir.path().to_path_buf()); + let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443); + let target = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 50), 443); + pc.add_sni_route(source, target, &["nas.example.com".to_string()], Some(3600)) + .await + .unwrap(); + + let outcome = apply_forward_uci( + dir.path(), + §ion_name("11:22:33:44:55:66", 8443), + LABEL_PCP, + "11:22:33:44:55:66", + Some("br-lan"), + source, + SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 60), 8443), + 1, + ) + .await + .unwrap(); + assert!(matches!(outcome, ApplyOutcome::Conflict)); + } + + #[tokio::test] + async fn sni_teardown_removes_the_admit_rule() { + let dir = temp_root(""); + let pc = PortControl::new(dir.path().to_path_buf()); + let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443); + let target = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 50), 443); + let hostnames = vec!["nas.example.com".to_string()]; + pc.add_sni_route(source, target, &hostnames, Some(3600)) + .await + .unwrap(); + + // Unregistering the last hostname reaps the listener; the demux's + // teardown callback (spawned) drops the admit rule. + pc.sni + .unregister(*source.ip(), source.port(), &hostnames, target); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); + if !written.contains("apf_sni_8443") { + break; + } + assert!( + Instant::now() < deadline, + "teardown callback never removed the admit rule" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + #[tokio::test] + async fn sni_maintain_purges_strays_and_heals_missing() { + let dir = temp_root(""); + let pc = PortControl::new(dir.path().to_path_buf()); + + // A stale admit rule from a previous daemon life (its in-memory route + // did not survive) is purged; unrelated rules are untouched. + std::fs::write( + dir.path().join("firewall"), + "config rule 'apf_sni_9443'\n\ + \toption name 'SNI demux (hostname routes)'\n\ + \toption src 'wan'\n\ + \tlist proto 'tcp'\n\ + \toption dest_port '9443'\n\ + \toption target 'ACCEPT'\n\ + \toption enabled '1'\n\ + \toption _apf_label 'SNI'\n\ + \n\ + config rule 'startwrt_remote_80'\n\ + \toption name 'Remote access 80'\n\ + \toption src 'wan'\n\ + \tlist proto 'tcp'\n\ + \toption dest_port '80'\n\ + \toption target 'ACCEPT'\n", + ) + .unwrap(); + pc.sni_maintain().await; + let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); + assert!(!written.contains("apf_sni_9443"), "stray rule purged"); + assert!( + written.contains("startwrt_remote_80"), + "non-SNI rules survive the purge" + ); + + // A live route whose admit rule went missing (failed write, external + // edit) is healed. + let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443); + let target = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 50), 443); + pc.add_sni_route(source, target, &["nas.example.com".to_string()], Some(3600)) + .await + .unwrap(); + std::fs::write(dir.path().join("firewall"), "").unwrap(); + pc.sni_maintain().await; + let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); + assert!(written.contains("apf_sni_8443"), "rule healed"); + } } diff --git a/projects/start-wrt/backend/ctrl/src/published_ports.rs b/projects/start-wrt/backend/ctrl/src/published_ports.rs index ea419d6ccd..64ad061a23 100644 --- a/projects/start-wrt/backend/ctrl/src/published_ports.rs +++ b/projects/start-wrt/backend/ctrl/src/published_ports.rs @@ -45,6 +45,15 @@ pub fn published_ports() -> ParentHandler { .with_display_serializable() .with_call_remote::(), ) + .subcommand( + "wan-changed", + // Forwarded to the daemon like `reconcile`: the SNI routes to + // re-key live only in the daemon's demux memory. + from_fn_async_local(crate::port_control::wan_changed) + .with_metadata("no_auth", Value::Bool(true)) + .no_display() + .with_call_remote::(), + ) } /// Uppercase MACs referenced by `pp_*_v6` rules, kept current by [`set`] and diff --git a/projects/start-wrt/backend/ctrl/src/vpn_server.rs b/projects/start-wrt/backend/ctrl/src/vpn_server.rs index cf542e0572..1d1efd8814 100644 --- a/projects/start-wrt/backend/ctrl/src/vpn_server.rs +++ b/projects/start-wrt/backend/ctrl/src/vpn_server.rs @@ -2012,6 +2012,7 @@ fn ensure_wireguard_firewall_rule( set_mark: None, _pp_id: None, _pp_mac: None, + _apf_label: None, }; // Use a valid UCI section name (alphanumeric and underscores only) diff --git a/projects/start-wrt/backend/hotplug/99-startwrt-port-control b/projects/start-wrt/backend/hotplug/99-startwrt-port-control new file mode 100644 index 0000000000..cf8fbe3ad4 --- /dev/null +++ b/projects/start-wrt/backend/hotplug/99-startwrt-port-control @@ -0,0 +1,7 @@ +#!/bin/sh +# Re-key live SNI hostname routes when the WAN IPv4 changes: demux listeners +# bind the WAN address itself, so a new address strands them until re-keyed. +# The daemon's sweep would catch it within a minute; this makes it immediate. +[ "$ACTION" = "ifup" -o "$ACTION" = "ifupdate" ] && [ "$INTERFACE" = "wan" ] && { + /usr/bin/startwrt-cli published-ports wan-changed 2>/dev/null & +} diff --git a/projects/start-wrt/backend/nftables/12-startwrt-sni-divert.nft b/projects/start-wrt/backend/nftables/12-startwrt-sni-divert.nft new file mode 100644 index 0000000000..ea7aa38e03 --- /dev/null +++ b/projects/start-wrt/backend/nftables/12-startwrt-sni-divert.nft @@ -0,0 +1,17 @@ +# SNI-demux reply-path divert. The demux originates the internal leg of a +# demuxed connection from the *client's* source address (IP_TRANSPARENT), so +# device->client replies transit this router addressed to the client; this rule +# marks any inbound packet belonging to such a local transparent socket, and +# the daemon's `ip rule fwmark 0x540001/0x540001 lookup 5344` delivers it into +# that socket instead of forwarding it on. `mark or` preserves unrelated mark +# bits (10-startwrt-dnat-mark.nft's 0x80) — which is also why the rule is +# matched masked. Shipping it here (fw4 re-renders /etc/nftables.d includes on +# every reload) means no reload can produce a ruleset without it. +# +# `socket transparent` needs kmod-nft-socket; without that package fw4 fails +# to load the ENTIRE ruleset, so this file and the kmod-nft-socket/ +# kmod-nf-socket entries in build/openwrt.diffconfig must ship together. +chain startwrt_sni_divert { + type filter hook prerouting priority mangle; policy accept; + meta l4proto tcp socket transparent 1 meta mark set meta mark or 0x00540001 +} diff --git a/projects/start-wrt/backend/uciedit/src/openwrt.rs b/projects/start-wrt/backend/uciedit/src/openwrt.rs index 3d0ee43d18..72404f81d1 100644 --- a/projects/start-wrt/backend/uciedit/src/openwrt.rs +++ b/projects/start-wrt/backend/uciedit/src/openwrt.rs @@ -80,6 +80,11 @@ pub struct FirewallRule { /// Published-port metadata: device MAC #[uci(default)] pub _pp_mac: Option, + /// Auto port-control metadata: marks a rule owned by the port-control + /// server ("SNI" on the WAN-input ACCEPT admitting an SNI-demux listener). + /// Mirrors [`FirewallRedirect::_apf_label`]. + #[uci(default)] + pub _apf_label: Option, } #[derive(Debug, TypedSection, Default)] diff --git a/projects/start-wrt/build.mk b/projects/start-wrt/build.mk index a9d05edcb7..2c87bc0a89 100644 --- a/projects/start-wrt/build.mk +++ b/projects/start-wrt/build.mk @@ -105,7 +105,9 @@ $(STARTWRT_OPENWRT)/.config: $(STARTWRT_DIR)/build/openwrt.diffconfig $(STARTWRT # Stage the binary + UCI configs + init scripts into openwrt/files/. .PHONY: start-wrt-stage start-wrt-stage: $(STARTWRT_OPENWRT)/files/.staged -$(STARTWRT_OPENWRT)/files/.staged: $(STARTWRT_BIN) $(call ls-files, $(STARTWRT_DIR)/backend/firstboot_config) $(STARTWRT_DIR)/build/stage-files.sh +$(STARTWRT_OPENWRT)/files/.staged: $(STARTWRT_BIN) $(call ls-files, $(STARTWRT_DIR)/backend/firstboot_config) \ + $(call ls-files, $(STARTWRT_DIR)/backend/hotplug) $(call ls-files, $(STARTWRT_DIR)/backend/nftables) \ + $(STARTWRT_DIR)/build/stage-files.sh ARCH=$(STARTWRT_ARCH) RUST_ARCH=$(STARTWRT_RUST_ARCH) PROFILE=$(PROFILE) ./$(STARTWRT_DIR)/build/stage-files.sh touch $(STARTWRT_OPENWRT)/files/.staged diff --git a/projects/start-wrt/build/openwrt.diffconfig b/projects/start-wrt/build/openwrt.diffconfig index 605146a2d6..1fcfaeed8a 100644 --- a/projects/start-wrt/build/openwrt.diffconfig +++ b/projects/start-wrt/build/openwrt.diffconfig @@ -124,11 +124,15 @@ CONFIG_PACKAGE_kmod-mt7916-firmware=y # CONFIG_PACKAGE_kmod-nf-log6 is not set CONFIG_PACKAGE_kmod-br-netfilter=y CONFIG_PACKAGE_kmod-nf-reject6=y +CONFIG_PACKAGE_kmod-nf-socket=y CONFIG_PACKAGE_conntrack=y CONFIG_PACKAGE_kmod-nft-core=y CONFIG_PACKAGE_kmod-nft-fib=y CONFIG_PACKAGE_kmod-nft-nat=y CONFIG_PACKAGE_kmod-nft-offload=y +# The SNI-demux divert (12-startwrt-sni-divert.nft) uses `socket transparent`; +# without this package fw4 fails to load the whole ruleset. +CONFIG_PACKAGE_kmod-nft-socket=y # CONFIG_PACKAGE_kmod-ppp is not set CONFIG_PACKAGE_kmod-sched-core=y CONFIG_PACKAGE_kmod-thermal=y diff --git a/projects/start-wrt/build/stage-files.sh b/projects/start-wrt/build/stage-files.sh index a52c1d1d50..408fdf86b1 100755 --- a/projects/start-wrt/build/stage-files.sh +++ b/projects/start-wrt/build/stage-files.sh @@ -140,6 +140,8 @@ cp "$PROJECT_DIR"/backend/hotplug/99-startwrt-proxy-arp "${FILES_DIR}/etc/hotplu chmod +x "${FILES_DIR}/etc/hotplug.d/iface/99-startwrt-proxy-arp" cp "$PROJECT_DIR"/backend/hotplug/99-startwrt-published-ports "${FILES_DIR}/etc/hotplug.d/iface/99-startwrt-published-ports" chmod +x "${FILES_DIR}/etc/hotplug.d/iface/99-startwrt-published-ports" +cp "$PROJECT_DIR"/backend/hotplug/99-startwrt-port-control "${FILES_DIR}/etc/hotplug.d/iface/99-startwrt-port-control" +chmod +x "${FILES_DIR}/etc/hotplug.d/iface/99-startwrt-port-control" # Custom nftables rules auto-included by fw4 (/etc/nftables.d/*.nft). # 10-startwrt-dnat-mark.nft marks DNAT-state reply traffic so port-forward diff --git a/projects/start-wrt/docs/src/published-ports.md b/projects/start-wrt/docs/src/published-ports.md index b808c009ef..a2c480dbca 100644 --- a/projects/start-wrt/docs/src/published-ports.md +++ b/projects/start-wrt/docs/src/published-ports.md @@ -75,6 +75,12 @@ Automatic forwards survive router reboots, so a self-configured device stays rea Ports the router answers on itself are protected the same way. If you have [Remote Access](settings.md#remote-access) turned on, or an [inbound VPN](inbound-vpn.md) reachable from the Internet, a device cannot take those ports over — requests for them are refused, so automatic forwarding can never cost you access to your own router. (Publishing such a port manually asks you to confirm instead — a device can't be asked, but you can.) +### Hostname routes (shared ports) + +A device can also ask for a **hostname route** instead of a whole port: the router inspects each incoming TLS connection's requested hostname (SNI) and delivers it to whichever device registered that hostname, so several devices — or several services on one StartOS server — can share a single external port such as 443. StartOS servers use this automatically when you give services on a shared port their own domains. + +Hostname routes appear in the same **Automatic** section with `SNI` in the Protocol column and the hostname shown alongside. They follow the same rules as other automatic forwards — per-device permission, the device renews them, they expire on their own — with two differences: a shared port is claimed whole (an ordinary forward on that port is refused while hostname routes hold it, and publishing it manually asks you to confirm), and hostname routes do not survive a router restart — the device simply re-registers them within a few minutes, so no action is needed. + > **A note on trust.** The PCP protocol runs over plain UDP, which carries no proof of who sent a request. The router verifies that each request actually arrives from the network the requesting device is on, so a device on one network can never open forwards on behalf of a device on another. Within a single network, though, automatic forwarding trusts the devices sharing it — exactly as UPnP and PCP do on every router, which is why it is off by default. If you run devices you don't fully trust, keep them on their own [Security Profile](security-profiles.md) so they cannot act for the devices you do. ## Endpoints diff --git a/projects/start-wrt/web/src/app/routes/published-ports/auto-table.ts b/projects/start-wrt/web/src/app/routes/published-ports/auto-table.ts index 813a929d2e..bd49058014 100644 --- a/projects/start-wrt/web/src/app/routes/published-ports/auto-table.ts +++ b/projects/start-wrt/web/src/app/routes/published-ports/auto-table.ts @@ -33,6 +33,9 @@ import { i18nPipe } from 'src/app/i18n/i18n.pipe' > {{ 'Public port' | i18n }} + + {{ 'Hostname' | i18n }} + {{ 'Protocol' | i18n }} @@ -54,6 +57,7 @@ import { i18nPipe } from 'src/app/i18n/i18n.pipe' {{ item.ports }} {{ item.publicPorts }} + {{ item.hostname || '—' }} {{ item.label }} {{ expiry(item) }} diff --git a/projects/start-wrt/web/src/app/routes/published-ports/service.ts b/projects/start-wrt/web/src/app/routes/published-ports/service.ts index 9f9a8be58d..51ba028690 100644 --- a/projects/start-wrt/web/src/app/routes/published-ports/service.ts +++ b/projects/start-wrt/web/src/app/routes/published-ports/service.ts @@ -174,6 +174,7 @@ function autoFromApiToDisplay(a: AutoForwardFromApi): AutoForwardDisplay { ports: a.ports, publicPorts: a.public_ports, expiresSecs: a.expires_secs ?? undefined, + hostname: a.hostname ?? undefined, } } diff --git a/projects/start-wrt/web/src/app/routes/published-ports/types.ts b/projects/start-wrt/web/src/app/routes/published-ports/types.ts index fb3e273fe3..9847c28e56 100644 --- a/projects/start-wrt/web/src/app/routes/published-ports/types.ts +++ b/projects/start-wrt/web/src/app/routes/published-ports/types.ts @@ -60,10 +60,12 @@ export interface PublishedPortDisplay extends PublishedPort { */ export interface AutoForwardDisplay { id: string - label: string // "PCP" | "UPnP" + label: string // "PCP" | "UPnP" | "SNI" deviceMac: string deviceName?: string ports: string publicPorts: string expiresSecs?: number + /** TLS-SNI hostname for an SNI route sharing its port with other hostnames */ + hostname?: string } diff --git a/projects/start-wrt/web/src/app/services/api/api.service.ts b/projects/start-wrt/web/src/app/services/api/api.service.ts index c879e74d46..3ef7e0f654 100644 --- a/projects/start-wrt/web/src/app/services/api/api.service.ts +++ b/projects/start-wrt/web/src/app/services/api/api.service.ts @@ -693,7 +693,7 @@ export type PublishedPortsSetResult = { */ export interface AutoForwardFromApi { id: string - /** Which protocol created it: "PCP" or "UPnP". */ + /** Which protocol created it: "PCP", "UPnP", or "SNI" (a hostname route). */ label: string device_mac: string device_name: string | null @@ -701,6 +701,8 @@ export interface AutoForwardFromApi { ports: string public_ports: string expires_secs: number | null + /** TLS-SNI hostname for an SNI route sharing its port; null for forwards. */ + hostname: string | null } // Outbound VPN (WireGuard Client) types diff --git a/projects/start-wrt/web/src/app/services/api/mock-api.service.ts b/projects/start-wrt/web/src/app/services/api/mock-api.service.ts index 2bce1c9684..819839fbbd 100644 --- a/projects/start-wrt/web/src/app/services/api/mock-api.service.ts +++ b/projects/start-wrt/web/src/app/services/api/mock-api.service.ts @@ -1454,6 +1454,7 @@ export class MockApiService extends ApiService { ports: '5443', public_ports: '5443', expires_secs: 3542, + hostname: null, }, { id: 'apf_001a2b3c4d5e_80', @@ -1464,6 +1465,18 @@ export class MockApiService extends ApiService { ports: '5080', public_ports: '80', expires_secs: 3211, + hostname: null, + }, + { + id: 'sni_443_nextcloud.example.com', + label: 'SNI', + device_mac: mac, + device_name: device.name, + internal_ip: device.ipv4, + ports: '443', + public_ports: '443', + expires_secs: 2954, + hostname: 'nextcloud.example.com', }, ] } diff --git a/rfcs/upnp-vendor-hostname-action.md b/rfcs/upnp-vendor-hostname-action.md index 150700dad3..19490971c7 100644 --- a/rfcs/upnp-vendor-hostname-action.md +++ b/rfcs/upnp-vendor-hostname-action.md @@ -79,9 +79,10 @@ UPnP side up to parity with a PCP capability that already exists. ## Non-goals - Changing the PCP `HOSTNAME` path, which stays the preferred transport. -- Building StartWRT's SNI dataplane. That is a separate, larger project; this - change is inert on StartWRT until it lands, and then works with no further - edit. +- ~~Building StartWRT's SNI dataplane.~~ Originally out of scope; the dataplane + was subsequently folded into the same branch (divert infra as an fw4 include + - `DivertConfig`, `Via::sni() -> Some`, WAN-admit rules, WAN re-key), so the + vendor action ships _working_ on StartWRT, not inert. - IPv6 (the demux is v4-only today). - NAT-PMP, which has no extension mechanism to carry a hostname. @@ -129,12 +130,14 @@ both transports report the same conditions: | Malformed hostname | malformed-option | 402 `Invalid Args` | | Peer not an authorized device | — | 606 `Action not authorized` | -**801 is what makes this change inert-but-correct on StartWRT.** Both handlers -check `backend.sni().is_none()` before doing anything else and fault 801 -(possible because this branch stacks on PR #3634's `Option`-ification of -`sni()`), so the action is advertised-but-refused on StartWRT — whose backend -returns `None` — until its dataplane exists. `sni_fault` additionally maps a -backend `RESULT_UNSUPP_HOSTNAME` to the same 801. +**801 is the honest answer from any gateway without an SNI dataplane.** Both +handlers check `backend.sni().is_none()` before doing anything else and fault +801 (possible because this branch stacks on PR #3634's `Option`-ification of +`sni()`), so a backend returning `None` advertises-but-refuses rather than +"succeeding" into a demux nothing listens on. StartWRT's backend originally +returned `None`; its dataplane now rides this same branch, so on both Start9 +gateways `sni()` is `Some` and 801 remains for future/partial backends. +`sni_fault` additionally maps a backend `RESULT_UNSUPP_HOSTNAME` to the same 801. ### Capability discovery — free, via `control_schema` @@ -227,8 +230,14 @@ dashboard visibility come free via the shared trait method. ### StartWRT -Nothing in this change. `sni()` returns `None` (PR #3634), so the action faults 801. When StartWRT's SNI dataplane lands and `sni()` becomes `Some`, the vendor -action starts working with no edit here. +The SNI dataplane rides this branch: `kmod-nft-socket` in the image, the +reply-path divert as an fw4 include (`12-startwrt-sni-divert.nft`) plus a +`DivertConfig` for the iproute2 half (table 5344, masked fwmark), `sni()` +returning the shared demux, per-port WAN-admit ACCEPT rules (`apf_sni_`, +which also make the port read as router-reserved), WAN re-key via a `wan` +hotplug hook + sweep backstop, and SNI rows in `published-ports.auto-list`. +Routes are demux-memory only (finite-lease, device-renewed); a daemon restart +drops them until the device re-asserts. ### Client — `net/port_map/{client,upnp}.rs` @@ -255,9 +264,10 @@ bindings, or database. ## Phasing 1. **Shared server + SCPD + lease semantics**, with StartTunnel regression tests. - Ships working on StartTunnel; inert (801) on StartWRT. + Ships working on StartTunnel. 2. **Client UPnP hostname path + capability caching.** The cross-layer step. -3. **StartWRT** — no work. Inherits when its SNI dataplane lands. +3. **StartWRT SNI dataplane** — folded into the same branch (see the StartWRT + section above), so the router serves the action rather than faulting 801. Phase 1 is independently useful and independently reviewable: it makes the gateway answer the action, which is what a manual `curl` or a third-party client @@ -296,8 +306,9 @@ Per the root `AGENTS.md`: - `projects/start-os/CHANGELOG.md` under the prospective next version — the client fallback changes StartOS behavior, and client-side port-map changes carry StartOS entries by precedent. -- No StartWRT changelog entry — nothing user-visible changes there until its SNI - dataplane lands. +- `projects/start-wrt/CHANGELOG.md` — the dataplane rides this branch, so the + unreleased automatic-port-forwarding entry describes hostname routes, and + the StartWRT docs book's published-ports page documents them. - If the capability field is added: TS bindings → SDK rebuild → web / container-runtime type checks, in that order, in the same change. - `API_CONTRACT.md` is untouched; this is not a JSON-RPC surface. diff --git a/shared-libs/crates/start-core/src/net/transparent.rs b/shared-libs/crates/start-core/src/net/transparent.rs index 247406feaa..e60c7e6591 100644 --- a/shared-libs/crates/start-core/src/net/transparent.rs +++ b/shared-libs/crates/start-core/src/net/transparent.rs @@ -33,12 +33,70 @@ use crate::prelude::*; use crate::util::Invoke; /// Firewall mark for transparent-egress reply diversion. Outside the gateway's -/// per-interface `1000 + ifindex` mark space and its priority-50 rule. +/// per-interface `1000 + ifindex` mark space and its priority-50 rule. The mark +/// value is fixed across hosts (the StartWRT fw4 include hardcodes it); only +/// how it is matched varies ([`DivertConfig::masked_fwmark`]). pub const DIVERT_MARK: u32 = 0x0054_0001; -/// Dedicated routing table holding the local-delivery default for diverted -/// replies. Outside the gateway's `1000 + ifindex` table space. +/// Default routing table holding the local-delivery default for diverted +/// replies. Outside the gateway's `1000 + ifindex` table space. Overridable via +/// [`DivertConfig::route_table`] where the number collides with a host's own +/// table namespace (StartWRT keys per-profile tables by VLAN tag, 1-4094). pub const DIVERT_TABLE: u32 = 1344; +/// Host-specific parameters for the reply-path divert. The defaults are the +/// StartOS/StartTunnel values; a host with a different routing/firewall layout +/// (StartWRT) installs its own via [`set_divert_config`] at startup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DivertConfig { + /// Routing table for the local-delivery default route. + pub route_table: u32, + /// Priority of the fwmark policy rule. + pub rule_priority: u32, + /// Match the fwmark with a mask (`mark/mark`) instead of exactly — required + /// on hosts whose mark rule sets the divert bits with `or` (preserving + /// unrelated mark bits, e.g. StartWRT's `0x80` DNAT-return bit), so a + /// diverted packet's mark may carry more than [`DIVERT_MARK`] alone. + pub masked_fwmark: bool, + /// Whether [`ensure_divert_infra`] owns the nft mark rule. `false` on a + /// host whose firewall framework ships the rule declaratively (StartWRT's + /// fw4 auto-included `/etc/nftables.d` file), leaving only the iproute2 + /// half managed here. + pub manage_nft: bool, +} + +impl Default for DivertConfig { + fn default() -> Self { + DivertConfig { + route_table: DIVERT_TABLE, + rule_priority: 49, + masked_fwmark: false, + manage_nft: true, + } + } +} + +static DIVERT_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Install host-specific divert parameters. Call before anything runs the SNI +/// demux — the first [`ensure_divert_infra`] latches whatever is set at that +/// point. `Err` returns the rejected value when a config was already installed. +pub fn set_divert_config(cfg: DivertConfig) -> Result<(), DivertConfig> { + DIVERT_CONFIG.set(cfg) +} + +fn divert_config() -> &'static DivertConfig { + DIVERT_CONFIG.get_or_init(DivertConfig::default) +} + +/// The fwmark match for the divert policy rule under `cfg`. +fn fwmark_arg(cfg: &DivertConfig) -> String { + if cfg.masked_fwmark { + format!("{DIVERT_MARK:#x}/{DIVERT_MARK:#x}") + } else { + format!("{DIVERT_MARK:#x}") + } +} + /// `mangle_prerouting` rule that marks inbound packets belonging to a local /// `IP_TRANSPARENT` (SNI-demux) socket — the replies to a source-preserving /// egress connection — so the priority-49 `ip rule` diverts them to the local @@ -140,8 +198,11 @@ static DIVERT_ASSERT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()) /// to be (re-)added, so periodic callers can surface external flushes. pub async fn ensure_divert_infra() -> Result { let _guard = DIVERT_ASSERT.lock().await; + let cfg = divert_config(); let mut repaired = false; - let table = DIVERT_TABLE.to_string(); + let table = cfg.route_table.to_string(); + let priority = cfg.rule_priority.to_string(); + let fwmark = fwmark_arg(cfg); // Both families, and independently: `ip` and `ip -6` keep separate rule and // route tables, so the same DIVERT_TABLE/DIVERT_MARK numbers serve each. @@ -163,8 +224,9 @@ pub async fn ensure_divert_infra() -> Result { .invoke(ErrorKind::Network) .await?; - // Policy rule at priority 49 — above the gateway's per-interface symmetric - // -return rules at 50 — so diverted replies win. + // Policy rule at the configured priority (default 49 — above the + // gateway's per-interface symmetric-return rules at 50, and below + // StartWRT's 100/150/200 ladder) so diverted replies win. let rules = Command::new("ip") .args([flag, "rule", "list"]) .invoke(ErrorKind::Network) @@ -173,15 +235,7 @@ pub async fn ensure_divert_infra() -> Result { if !String::from_utf8_lossy(&rules).contains(&format!("lookup {table}")) { Command::new("ip") .args([ - flag, - "rule", - "add", - "fwmark", - &format!("{DIVERT_MARK:#x}"), - "lookup", - &table, - "priority", - "49", + flag, "rule", "add", "fwmark", &fwmark, "lookup", &table, "priority", &priority, ]) .invoke(ErrorKind::Network) .await?; @@ -191,6 +245,10 @@ pub async fn ensure_divert_infra() -> Result { // nft `sni-divert` mark rule. The gateway reconcile owns (and re-adds) it on // hosts that run it; install it directly where nothing else does (the tunnel), // skipping if already present so we never duplicate or fight the reconcile. + // A host whose firewall framework ships the rule itself opts out entirely. + if !cfg.manage_nft { + continue; + } let chain = Command::new("nft") .args(["list", "chain", family, "startos", "mangle_prerouting"]) .invoke(ErrorKind::Network) @@ -228,3 +286,26 @@ pub async fn ensure_divert_infra() -> Result { // (verified in a netns harness under both strict and loose). Ok(repaired) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_matches_legacy_behavior() { + let cfg = DivertConfig::default(); + assert_eq!(cfg.route_table, DIVERT_TABLE); + assert_eq!(cfg.rule_priority, 49); + assert_eq!(fwmark_arg(&cfg), format!("{DIVERT_MARK:#x}")); + assert!(cfg.manage_nft); + } + + #[test] + fn masked_fwmark_matches_or_set_marks() { + let cfg = DivertConfig { + masked_fwmark: true, + ..DivertConfig::default() + }; + assert_eq!(fwmark_arg(&cfg), "0x540001/0x540001"); + } +} diff --git a/shared-libs/crates/start-core/src/tunnel/forward/sni.rs b/shared-libs/crates/start-core/src/tunnel/forward/sni.rs index 3acbb9fc59..b308e101df 100644 --- a/shared-libs/crates/start-core/src/tunnel/forward/sni.rs +++ b/shared-libs/crates/start-core/src/tunnel/forward/sni.rs @@ -68,6 +68,17 @@ impl PortBindings { } } +/// One live hostname route, as reported by [`SniDemux::snapshot`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SniRoute { + pub ext_ip: Ipv4Addr, + pub ext_port: u16, + pub hostname: String, + pub target: SocketAddrV4, + /// Seconds until the binding expires; `None` for a permanent binding. + pub remaining_secs: Option, +} + /// Called `(ext_port, active)` when a port's listener starts/stops, so a gateway /// can open/close inbound access (e.g. a StartWRT firewall ACCEPT rule). type OnChange = Box; @@ -225,6 +236,69 @@ impl SniDemux { self.reap_if_empty(key); } + /// The live hostname routes, for gateway UIs (StartWRT's Automatic table). + /// Fallbacks are not reported — they are port-level, not hostname routes. + pub fn snapshot(&self) -> Vec { + let now = Instant::now(); + self.ports.peek(|ports| { + ports + .iter() + .flat_map(|(&(ext_ip, ext_port), entry)| { + entry + .hostnames + .iter() + .filter(|(_, b)| b.expiry.is_none_or(|e| e > now)) + .map(move |(name, b)| SniRoute { + ext_ip, + ext_port, + hostname: name.clone(), + target: b.target, + remaining_secs: b + .expiry + .map(|e| e.saturating_duration_since(now).as_secs()), + }) + }) + .collect() + }) + } + + /// Move every binding keyed to another external IPv4 onto `new_ip` — the + /// gateway's WAN address changed, but the routes (and their ports) live on. + /// On hostname collision the binding already at the new key wins; a fallback + /// already at the new key likewise. Stranded listeners are dropped without + /// firing `on_change(port, false)` — the port set is unchanged, and a + /// spawned teardown could race the re-add and close a live port — + /// then re-ensured on the new key (`on_change(port, true)` is an idempotent + /// upsert for the gateway). No-op when everything is already on `new_ip`. + pub fn rekey_ipv4(self: &Arc, new_ip: Ipv4Addr) { + let moved: Vec = self.ports.mutate(|ports| { + let old_keys: Vec = ports.keys().filter(|k| k.0 != new_ip).copied().collect(); + let mut moved = Vec::new(); + for old in old_keys { + let Some(bindings) = ports.remove(&old) else { + continue; + }; + let entry = ports.entry((new_ip, old.1)).or_default(); + for (name, b) in bindings.hostnames { + entry.hostnames.entry(name).or_insert(b); + } + if entry.fallback.is_none() { + entry.fallback = bindings.fallback; + } + moved.push(old); + } + moved + }); + for old in &moved { + if let Some(handle) = self.listeners.mutate(|l| l.remove(old)) { + drop(handle); // aborts the stranded listener; no on_change + } + } + for old in moved { + self.ensure_listener((new_ip, old.1)); + } + } + fn prune(&self) { let now = Instant::now(); let empty: Vec = self.ports.mutate(|ports| { @@ -509,4 +583,88 @@ mod tests { assert_eq!(pb.select(Some("other.org")).unwrap().ip().octets()[3], 9); assert_eq!(pb.select(None).unwrap().ip().octets()[3], 9); } + + #[tokio::test] + async fn snapshot_reports_live_routes_with_remaining() { + let demux = SniDemux::new(); + let ip = Ipv4Addr::LOCALHOST; + let t1 = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443); + let t2 = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 2), 443); + demux + .register(ip, 44311, &["a.example.com".to_string()], t1, Some(3600)) + .unwrap(); + demux + .register(ip, 44311, &["b.example.com".to_string()], t2, None) + .unwrap(); + // Fallbacks are port-level, not hostname routes: not reported. + demux.register_fallback(ip, 44312, t1).unwrap(); + + let mut snap = demux.snapshot(); + snap.sort_by(|a, b| a.hostname.cmp(&b.hostname)); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].hostname, "a.example.com"); + assert_eq!(snap[0].target, t1); + assert_eq!((snap[0].ext_ip, snap[0].ext_port), (ip, 44311)); + let remaining = snap[0].remaining_secs.unwrap(); + assert!(remaining > 3590 && remaining <= 3600, "got {remaining}"); + assert_eq!(snap[1].remaining_secs, None); + } + + #[tokio::test] + async fn rekey_moves_bindings_and_never_fires_teardown() { + let events = Arc::new(SyncMutex::new(Vec::<(u16, bool)>::new())); + let recorded = events.clone(); + let demux = SniDemux::with_on_change(move |port, active| { + recorded.mutate(|e| e.push((port, active))) + }); + let old_ip = Ipv4Addr::new(203, 0, 113, 1); + let new_ip = Ipv4Addr::new(203, 0, 113, 2); + let t1 = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443); + let t2 = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 2), 443); + + demux + .register( + old_ip, + 44313, + &["a.example.com".to_string()], + t1, + Some(3600), + ) + .unwrap(); + demux.register_fallback(old_ip, 44313, t1).unwrap(); + // A binding already on the new key: survives the merge and wins any + // hostname collision. + demux + .register(new_ip, 44313, &["a.example.com".to_string()], t2, None) + .unwrap(); + demux + .register(new_ip, 44313, &["c.example.com".to_string()], t2, None) + .unwrap(); + + demux.rekey_ipv4(new_ip); + + demux.ports.peek(|p| { + assert!(p.get(&(old_ip, 44313)).is_none(), "old key drained"); + let pb = p.get(&(new_ip, 44313)).unwrap(); + assert_eq!( + pb.hostnames.get("a.example.com").unwrap().target, + t2, + "existing binding at the new key wins the collision" + ); + assert_eq!(pb.hostnames.get("c.example.com").unwrap().target, t2); + assert_eq!(pb.fallback, Some(t1), "moved fallback fills the empty slot"); + }); + demux.listeners.peek(|l| { + assert!(l.contains_key(&(new_ip, 44313)) && !l.contains_key(&(old_ip, 44313))) + }); + assert!( + events.peek(|e| e.iter().all(|&(_, active)| active)), + "rekey must never fire on_change(port, false)" + ); + + // Already keyed to new_ip: a second rekey is a no-op (no new events). + let before = events.peek(|e| e.len()); + demux.rekey_ipv4(new_ip); + assert_eq!(events.peek(|e| e.len()), before); + } } From 2d93c00069c8f7750e4764e4fc2705b7cefcb5ca Mon Sep 17 00:00:00 2001 From: Shadowy Super Coder Date: Mon, 17 Aug 2026 13:36:41 -0600 Subject: [PATCH 03/14] fix(start-wrt): SNI demux binds beside the UI and coexists with Remote Access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- projects/start-tunnel/CHANGELOG.md | 3 + projects/start-wrt/API_CONTRACT.md | 10 +- projects/start-wrt/CHANGELOG.md | 12 +- .../start-wrt/backend/ctrl/src/bins/daemon.rs | 12 +- .../backend/ctrl/src/port_control.rs | 136 +++++- projects/start-wrt/backend/ctrl/src/system.rs | 37 +- .../start-wrt/docs/src/published-ports.md | 2 + .../start-core/src/net/port_map/pcp/mod.rs | 5 + .../start-core/src/net/port_map/server/igd.rs | 10 +- .../start-core/src/net/port_map/server/mod.rs | 2 +- .../start-core/src/tunnel/forward/sni.rs | 424 +++++++++++++++--- 11 files changed, 547 insertions(+), 106 deletions(-) diff --git a/projects/start-tunnel/CHANGELOG.md b/projects/start-tunnel/CHANGELOG.md index f8f6d16647..4fdeeb0ddf 100644 --- a/projects/start-tunnel/CHANGELOG.md +++ b/projects/start-tunnel/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the vendor action automatically. Unlike standard UPnP mappings, these routes are always lease-bearing and expire if the device stops renewing them, so a vanished device can never squat a hostname against its legitimate owner. + A hostname grant (over either protocol) is now also refused outright when + the tunnel cannot bind the shared port's listener, instead of being + acknowledged while routing nothing. ### Fixed diff --git a/projects/start-wrt/API_CONTRACT.md b/projects/start-wrt/API_CONTRACT.md index ae9a0c072e..298febd417 100644 --- a/projects/start-wrt/API_CONTRACT.md +++ b/projects/start-wrt/API_CONTRACT.md @@ -1054,7 +1054,15 @@ Besides plain forwards, the list includes **SNI hostname routes** (label by ClientHello SNI so several devices/hostnames share one port. These live in daemon memory (not UCI), always carry a lease (≤1h, device-renewed), and do not survive a daemon restart — the device re-asserts them. A demuxed port -reads as router-reserved to manual and plain-auto forwards. +reads as router-reserved to manual and plain-auto forwards. Ports the router +answers on itself refuse hostname routes — except 443, where Remote Access +coexists with the demux: while routes share the port, connections naming no +routed hostname (e.g. browsing the router by IP, which sends no SNI) are piped +to the router's own UI, admitted from exactly the sources the Remote Access +mode allows (any in "always"; RFC1918 in "default" behind NAT; none in +"never"). A route is granted only once its listener is actually bound +(`NO_RESOURCES` / UPnP fault 501 otherwise), so a granted route can never +leave the port open with nothing serving it. ```rust // Request: {} diff --git a/projects/start-wrt/CHANGELOG.md b/projects/start-wrt/CHANGELOG.md index 062d488f4c..027e69dcc5 100644 --- a/projects/start-wrt/CHANGELOG.md +++ b/projects/start-wrt/CHANGELOG.md @@ -54,9 +54,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 as 443. Hostname routes appear in the Automatic section with their hostname, follow the same per-device permission and lease expiry as plain forwards, claim their shared port whole (plain forwards on it are refused; ports the - router itself answers on are refused to hostname routes for the same - reason), and are re-registered by the device within minutes after a router - restart rather than persisted. + router itself answers on — SSH, an inbound VPN — are refused to hostname + routes for the same reason), and are re-registered by the device within + minutes after a router restart rather than persisted. Remote access to the + router's own web interface is the exception, not a casualty: hostname + routes and remote access share port 443 — connections naming a routed + hostname reach its device, and everything else (such as browsing the + router by IP address) still reaches the router interface, accepted from + exactly the sources your Remote Access setting allows, so enabling one + feature never silently disables the other. - The UI now detects when the running firmware ships a newer interface than the page is displaying (every RPC response and `system.info` report the firmware's build stamp and the UI compares it to its own). An update diff --git a/projects/start-wrt/backend/ctrl/src/bins/daemon.rs b/projects/start-wrt/backend/ctrl/src/bins/daemon.rs index 78dac63e17..06e345af49 100644 --- a/projects/start-wrt/backend/ctrl/src/bins/daemon.rs +++ b/projects/start-wrt/backend/ctrl/src/bins/daemon.rs @@ -15,7 +15,6 @@ use rpc_toolkit::Server; use serde::Deserialize; use startos::net::tls::TlsListener; use startos::net::web_server::{Accept, Acceptor, DynAccept, MetadataVisitor, WebServer}; -use tokio::net::TcpListener; use tokio::signal::unix::SignalKind; use tokio::sync::mpsc; use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; @@ -455,9 +454,13 @@ async fn inner_main() -> Result<(), Error> { // WebSocket upgrades. `TlsListener` adds slow-loris-resistant handshake // timeouts (5s ClientHello, 15s full handshake) and runs each handshake // in a per-connection task so a stalled client cannot block accept. + // SO_REUSEPORT on the UI wildcards: the SNI demux binds `(wan_ip, 443)` + // *specific* alongside these when hostname routes share the port, and a + // reuseport group admits a member only if every socket on the port opted + // in. TCP delivery prefers the most-specific bound address, so the demux + // takes WAN-IP-destined connections and these wildcards keep the LAN. let http_addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], 80)); - let http_listener = TcpListener::bind(http_addr) - .await + let http_listener = startos::net::utils::bind_tokio_listener_reuse_port(http_addr) .with_kind(ErrorKind::Network)?; tracing::info!("HTTP listening on {}", http_addr); @@ -467,8 +470,7 @@ async fn inner_main() -> Result<(), Error> { if tls_ready { let materials = ssl::init_tls_materials()?; let https_addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], 443)); - let https_listener = TcpListener::bind(https_addr) - .await + let https_listener = startos::net::utils::bind_tokio_listener_reuse_port(https_addr) .with_kind(ErrorKind::Network)?; tracing::info!("HTTPS listening on {}", https_addr); let tls = TlsListener::new(https_listener, ssl::StaticTlsHandler::new(materials)); diff --git a/projects/start-wrt/backend/ctrl/src/port_control.rs b/projects/start-wrt/backend/ctrl/src/port_control.rs index d4f8db5478..6991861c91 100644 --- a/projects/start-wrt/backend/ctrl/src/port_control.rs +++ b/projects/start-wrt/backend/ctrl/src/port_control.rs @@ -124,7 +124,7 @@ use startos::net::port_map::server::igd::{ SCPD_PATH, SSDP_MULTICAST, SSDP_PORT, }; use startos::net::port_map::server::{handle, GatewayBackend, MappingEntry, PCP_PORT}; -use startos::tunnel::forward::sni::SniDemux; +use startos::tunnel::forward::sni::{FallbackSource, SniDemux}; use tokio::io::Interest; use tokio::net::UdpSocket; use uciedit::openwrt::{DhcpHost, FirewallRedirect, FirewallRule, FirewallTarget}; @@ -698,9 +698,45 @@ impl PortControl { // Best-effort: the demux routes regardless, and the sweep heals a // missed rule within a minute. self.sync_sni_rules().await; + self.sync_sni_fallback(*source.ip()).await; Ok(()) } + /// Keep the demux's remote-access fallback in step with the Remote Access + /// setting: while hostname routes share 443 — the one port both the demux + /// and the router's WAN UI can serve — no-SNI/unknown-SNI connections pipe + /// to the router's own UI listener, scoped to the same sources the + /// setting's firewall rules admit, so taking the port never breaks (or + /// widens) remote access. Registered on route add and a Remote Access + /// change, swept once a minute as the backstop (which also clears it once + /// the last 443 route expires). + pub(crate) async fn sync_sni_fallback(&self, wan: Ipv4Addr) { + let has_443 = self + .sni + .snapshot() + .iter() + .any(|r| r.ext_port == 443 && r.ext_ip == wan); + let mode = { + let uci_root = self.uci_root.clone(); + uci_task(move || async move { + let arena = Arena::new(); + let cfgs = parse_all(&uci_root, &arena, &["startwrt"]).await?; + Ok(crate::system::preferences(&cfgs["startwrt"])?.remote_access) + }) + .await + .unwrap_or_else(|_| "default".to_string()) + }; + let ui = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 443); + match (has_443, remote_access_fallback_source(&mode, wan)) { + (true, Some(source)) => { + if let Err(code) = self.sni.register_local_fallback(wan, 443, ui, source) { + tracing::warn!("port-control: remote-access fallback on 443 refused ({code})"); + } + } + _ => self.sni.unregister_fallback(wan, 443, ui), + } + } + /// Reconcile the `apf_sni_*` WAN-admit rules against the demux's live port /// set, reloading the firewall when anything changed. Callers must hold /// `write_serial`. @@ -725,14 +761,20 @@ impl PortControl { /// rules back in line with the live ports (heals a failed callback write, /// an external edit, or a stale rule from before a daemon restart). async fn sni_maintain(&self) { - if let Some(ip) = self.wan_ipv4().await { + let wan = self.wan_ipv4().await; + if let Some(ip) = wan { if self.sni.snapshot().iter().any(|r| r.ext_ip != ip) { tracing::info!("port-control: re-keying SNI routes onto WAN address {ip}"); self.sni.rekey_ipv4(ip); } } - let _serial = self.write_serial.lock().await; - self.sync_sni_rules().await; + { + let _serial = self.write_serial.lock().await; + self.sync_sni_rules().await; + } + if let Some(ip) = wan { + self.sync_sni_fallback(ip).await; + } } } @@ -1209,13 +1251,27 @@ fn desired_sni_rule(port: u16) -> FirewallRule { } } +/// Which sources the remote-access fallback admits, mirroring exactly the +/// firewall rules `apply_remote_access_config` writes for the mode: "always" +/// is unscoped; "default" behind NAT (RFC1918 WAN — std's `is_private` matches +/// `system::is_private_ipv4`) admits private sources only; a public WAN in +/// "default", or "never", writes no WAN rules and so gets no fallback. +fn remote_access_fallback_source(mode: &str, wan: Ipv4Addr) -> Option { + match mode { + "always" => Some(FallbackSource::Any), + "default" if wan.is_private() => Some(FallbackSource::PrivateOnly), + _ => None, + } +} + /// Whether tcp `port` is unavailable to the SNI demux: an enabled WAN-ingress /// DNAT redirect overlaps it (that traffic already goes wholesale to one /// device), or the router itself answers on it from the WAN (Remote Access, /// the VPN server — the demux's specific `(wan_ip, port)` bind would beat /// their wildcard binds and capture traffic it has no route for). The demux's /// own admit rules are excluded, so a second hostname on an already-demuxed -/// port is not a self-conflict. +/// port is not a self-conflict — and Remote Access's 443 rules are excluded +/// in favor of the UI fallback (see `sync_sni_fallback`). fn sni_port_conflicts(firewall: &uciedit::Config<'_>, port: u16) -> bool { let want = (port, port); for sec in &firewall.sections { @@ -1238,6 +1294,20 @@ fn sni_port_conflicts(firewall: &uciedit::Config<'_>, port: u16) -> bool { if rule._apf_label.as_deref() == Some(LABEL_SNI) { continue; } + // Remote Access's own 443 rules don't conflict: the daemon registers + // its web UI as the demuxed port's fallback with the same source + // scoping (see `sync_sni_fallback`), so remote access rides the shared + // port instead of losing it. Only 443 — the UI's TLS port — can do + // this: SSH (22) is server-speaks-first and the port-80 redirect is + // plain HTTP, neither of which an SNI peek can serve. + if port == 443 + && (rule.name.starts_with(crate::system::REMOTE_RULE_PREFIX) + || sec + .name() + .is_some_and(|n| n.starts_with(crate::system::REMOTE_RULE_PREFIX))) + { + continue; + } if rule.target != FirewallTarget::ACCEPT || rule.enabled.as_deref() == Some("0") { continue; } @@ -2474,13 +2544,19 @@ config redirect 'dns_override_lan' "nothing registered on refusal" ); - // A port the router answers on itself (Remote Access): the demux's - // specific (wan_ip, port) bind would beat the router's wildcard bind - // and capture its WAN UI. + // A non-443 port the router answers on itself (Remote Access SSH): + // still refused — SSH is server-speaks-first, so it can't ride the + // demux's fallback the way the 443 UI does. let dir = temp_root(ROUTER_FW); let pc = PortControl::new(dir.path().to_path_buf()); + let ssh = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 22); let err = pc - .add_sni_route(source, target, &["a.example.com".to_string()], Some(3600)) + .add_sni_route( + ssh, + SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 60), 22), + &["a.example.com".to_string()], + Some(3600), + ) .await .unwrap_err(); assert_eq!(err, RESULT_HOSTNAME_TAKEN); @@ -2497,6 +2573,48 @@ config redirect 'dns_override_lan' .unwrap(); } + // Remote Access's own rules stop conflicting exactly on 443 — the port + // whose traffic the UI fallback can serve — while every other + // router-answered or forwarded port keeps refusing. + #[tokio::test] + async fn remote_access_coexists_on_443_only() { + let dir = temp_root(ROUTER_FW); + let arena = Arena::new(); + let cfgs = parse_all(dir.path(), &arena, &["firewall"]).await.unwrap(); + let fw = &cfgs["firewall"]; + assert!( + !sni_port_conflicts(fw, 443), + "RA's 443 rides the UI fallback" + ); + assert!(sni_port_conflicts(fw, 22), "RA's SSH keeps its refusal"); + + let dir = temp_root(MANUAL_FW); + let arena = Arena::new(); + let cfgs = parse_all(dir.path(), &arena, &["firewall"]).await.unwrap(); + assert!( + sni_port_conflicts(&cfgs["firewall"], 443), + "a manual DNAT on 443 is not Remote Access" + ); + } + + // The fallback's source scoping mirrors apply_remote_access_config's rule + // emission case-for-case. + #[test] + fn fallback_source_mirrors_remote_access_modes() { + let private_wan = Ipv4Addr::new(192, 168, 10, 92); + let public_wan = Ipv4Addr::new(203, 0, 113, 7); + assert_eq!( + remote_access_fallback_source("default", private_wan), + Some(FallbackSource::PrivateOnly) + ); + assert_eq!(remote_access_fallback_source("default", public_wan), None); + assert_eq!( + remote_access_fallback_source("always", public_wan), + Some(FallbackSource::Any) + ); + assert_eq!(remote_access_fallback_source("never", private_wan), None); + } + // The admit rule makes a demuxed port read as router-held, so a plain auto // forward can't DNAT it out from under its hostnames. #[tokio::test] diff --git a/projects/start-wrt/backend/ctrl/src/system.rs b/projects/start-wrt/backend/ctrl/src/system.rs index 102d75781f..4150d22c13 100644 --- a/projects/start-wrt/backend/ctrl/src/system.rs +++ b/projects/start-wrt/backend/ctrl/src/system.rs @@ -58,18 +58,24 @@ struct SetPreferencesReq { remote_access: Option, } -#[instrument(skip_all)] -async fn info(ctx: C) -> Result { - let arena = Arena::new(); - let cfgs = parse_all(ctx.uci_root(), &arena, &["startwrt"]).await?; - +/// The `startwrt.preferences` section, defaulted when absent. +pub(crate) fn preferences(cfg: &uciedit::Config<'_>) -> Result { let mut prefs = UciPreferences::default(); - cfgs["startwrt"].try_each(|name, p: UciPreferences| { + cfg.try_each(|name, p: UciPreferences| { if name == Some("preferences") { prefs = p; } Ok::<_, Error>(()) })?; + Ok(prefs) +} + +#[instrument(skip_all)] +async fn info(ctx: C) -> Result { + let arena = Arena::new(); + let cfgs = parse_all(ctx.uci_root(), &arena, &["startwrt"]).await?; + + let prefs = preferences(&cfgs["startwrt"])?; // Read timezone from system UCI config (spaces → underscores for IANA format) let timezone = String::from_utf8( @@ -181,7 +187,7 @@ const VALID_LANGUAGES: &[&str] = &["en_US", "es_ES", "de_DE", "fr_FR", "pl_PL"]; const VALID_THEMES: &[&str] = &["dark", "light", "system"]; const VALID_REMOTE_ACCESS: &[&str] = &["default", "never", "always"]; -const REMOTE_RULE_PREFIX: &str = "startwrt_remote_"; +pub(crate) const REMOTE_RULE_PREFIX: &str = "startwrt_remote_"; const REMOTE_ACCESS_PORTS: &[&str] = &["80", "443", "22"]; /// ULA range (RFC 4193). Disjoint from global unicast (`2000::/3`). @@ -538,13 +544,7 @@ pub async fn apply_remote_access(ctx: C) -> Result let arena = Arena::new(); let mut cfgs = parse_all(ctx.uci_root(), &arena, &["startwrt", "firewall"]).await?; - let mut prefs = UciPreferences::default(); - cfgs["startwrt"].try_each(|name, p: UciPreferences| { - if name == Some("preferences") { - prefs = p; - } - Ok::<_, Error>(()) - })?; + let prefs = preferences(&cfgs["startwrt"])?; let (wan_ipv4, wan_ipv6s) = if ctx.effectful() { (get_wan_ipv4().await?, get_wan_ipv6s().await?) @@ -565,6 +565,15 @@ pub async fn apply_remote_access(ctx: C) -> Result Ok(()) => { if ctx.effectful() { reload_firewall(wan_ipv4, wan_ipv6s); + // The SNI demux's remote-access fallback mirrors the rules + // just written — re-sync it so a mode change takes effect + // on a shared 443 without waiting for the sweep. + if let (Some(pc), Some(wan)) = + (crate::port_control::PORT_CONTROL.get(), wan_ipv4) + { + let pc = pc.clone(); + tokio::spawn(async move { pc.sync_sni_fallback(wan).await }); + } } return Ok(Value::Null); } diff --git a/projects/start-wrt/docs/src/published-ports.md b/projects/start-wrt/docs/src/published-ports.md index a2c480dbca..d0d3173d77 100644 --- a/projects/start-wrt/docs/src/published-ports.md +++ b/projects/start-wrt/docs/src/published-ports.md @@ -81,6 +81,8 @@ A device can also ask for a **hostname route** instead of a whole port: the rout Hostname routes appear in the same **Automatic** section with `SNI` in the Protocol column and the hostname shown alongside. They follow the same rules as other automatic forwards — per-device permission, the device renews them, they expire on their own — with two differences: a shared port is claimed whole (an ordinary forward on that port is refused while hostname routes hold it, and publishing it manually asks you to confirm), and hostname routes do not survive a router restart — the device simply re-registers them within a few minutes, so no action is needed. +Hostname routes and [Remote Access](settings.md#remote-access) can share port 443. While routes hold the port, a connection naming a routed hostname reaches its device, and everything else — including browsing the router by its IP address — still reaches the router's own interface, accepted from exactly the sources your Remote Access setting allows. Enabling one feature never disables the other. (Router SSH and an inbound VPN's port still refuse hostname routes outright — those protocols can't share a port this way.) + > **A note on trust.** The PCP protocol runs over plain UDP, which carries no proof of who sent a request. The router verifies that each request actually arrives from the network the requesting device is on, so a device on one network can never open forwards on behalf of a device on another. Within a single network, though, automatic forwarding trusts the devices sharing it — exactly as UPnP and PCP do on every router, which is why it is off by default. If you run devices you don't fully trust, keep them on their own [Security Profile](security-profiles.md) so they cannot act for the devices you do. ## Endpoints diff --git a/shared-libs/crates/start-core/src/net/port_map/pcp/mod.rs b/shared-libs/crates/start-core/src/net/port_map/pcp/mod.rs index 826785ca2d..a38ae824ac 100644 --- a/shared-libs/crates/start-core/src/net/port_map/pcp/mod.rs +++ b/shared-libs/crates/start-core/src/net/port_map/pcp/mod.rs @@ -5,6 +5,11 @@ pub mod capability; pub mod hostname; pub mod portset; +/// Base PCP NO_RESOURCES result code (RFC 6887 §7.4): the server lacks the +/// resources to honor an otherwise-valid request — e.g. the SNI demux cannot +/// bind the port a grant would open. +pub const RESULT_NO_RESOURCES: u8 = 8; + /// Walk the PCP option area (RFC 6887 §7.3): each option is code(1), /// reserved(1), length(2), value(length), padded to a 32-bit boundary. Yields /// `(code, value)` per option; one `Err(())` then stops on a length overrun. diff --git a/shared-libs/crates/start-core/src/net/port_map/server/igd.rs b/shared-libs/crates/start-core/src/net/port_map/server/igd.rs index 917f38d1c8..93b67c1846 100644 --- a/shared-libs/crates/start-core/src/net/port_map/server/igd.rs +++ b/shared-libs/crates/start-core/src/net/port_map/server/igd.rs @@ -1119,7 +1119,9 @@ mod tests { /// Backend recording `add_sni_forward` calls while still driving the real /// demux, so ownership semantics (HOSTNAME_TAKEN, owner-scoped delete) are - /// exercised against the actual registration logic. + /// exercised against the actual registration logic. The request bodies use + /// an unprivileged external port (44300): registration is bind-gated, and + /// a real 443 bind needs root the test runner doesn't have. struct HostnameStub { sni: Arc, known: bool, @@ -1192,7 +1194,7 @@ mod tests { -443 +44300 TCP 8443 10.59.1.99 @@ -1213,7 +1215,7 @@ mod tests { -443 +44300 TCP 8443 {hostname} @@ -1389,7 +1391,7 @@ mod tests { let resp = control(&stub, PEER, &add_hostname_body("0", "Git.Example.Com")).await; assert_eq!(resp.status(), StatusCode::OK); let calls = stub.calls.lock().unwrap(); - assert_eq!(calls[0].0, SocketAddrV4::new(EXT_IP, 443)); + assert_eq!(calls[0].0, SocketAddrV4::new(EXT_IP, 44300)); assert_eq!(calls[0].1, SocketAddrV4::new(PEER, 8443)); // Hostnames are lowercased before registration, like the PCP parser. assert_eq!(calls[0].2, vec!["git.example.com".to_string()]); diff --git a/shared-libs/crates/start-core/src/net/port_map/server/mod.rs b/shared-libs/crates/start-core/src/net/port_map/server/mod.rs index 953b7e03fb..05827d9ee9 100644 --- a/shared-libs/crates/start-core/src/net/port_map/server/mod.rs +++ b/shared-libs/crates/start-core/src/net/port_map/server/mod.rs @@ -36,7 +36,7 @@ const NOT_AUTHORIZED: u8 = 2; const MALFORMED_REQUEST: u8 = 3; const UNSUPP_OPCODE: u8 = 4; const MALFORMED_OPTION: u8 = 6; -const NO_RESOURCES: u8 = 8; +use crate::net::port_map::pcp::RESULT_NO_RESOURCES as NO_RESOURCES; const CANNOT_PROVIDE_EXTERNAL: u8 = 11; /// PCP protocol field value for TCP (the only transport the SNI demux handles). diff --git a/shared-libs/crates/start-core/src/tunnel/forward/sni.rs b/shared-libs/crates/start-core/src/tunnel/forward/sni.rs index b308e101df..588e3e018f 100644 --- a/shared-libs/crates/start-core/src/tunnel/forward/sni.rs +++ b/shared-libs/crates/start-core/src/tunnel/forward/sni.rs @@ -16,6 +16,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt, copy_bidirectional}; use tokio::net::TcpStream; use tokio::time::timeout; +use crate::net::port_map::pcp::RESULT_NO_RESOURCES; use crate::net::port_map::pcp::hostname::RESULT_HOSTNAME_TAKEN; use crate::util::future::NonDetachingJoinHandle; use crate::util::sync::SyncMutex; @@ -25,9 +26,6 @@ type PortKey = (Ipv4Addr, u16); const CLIENTHELLO_CAP: usize = 16384; const CLIENTHELLO_TIMEOUT: Duration = Duration::from_secs(5); -/// Backoff for bind/accept failures (e.g. fd exhaustion); the listener retries -/// rather than giving up its port. -const BIND_RETRY_DELAY: Duration = Duration::from_secs(5); const ACCEPT_RETRY_DELAY: Duration = Duration::from_millis(100); #[derive(Clone)] @@ -37,11 +35,33 @@ struct Binding { expiry: Option, } +/// Which sources a port's fallback admits. Hostname routes are never +/// source-scoped (an SNI client can come from anywhere); the fallback can be, +/// because it may stand in for a firewall rule that was source-scoped — e.g. +/// StartWRT remote access in "behind NAT" mode admits only private sources, +/// and the demux taking its port must not widen that. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FallbackSource { + Any, + /// RFC1918 sources only. + PrivateOnly, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Fallback { + target: SocketAddrV4, + source: FallbackSource, + /// Open the internal leg source-preserving (`transparent_connect`). False + /// for a gateway-self fallback: a spoofed-source dial to the gateway's own + /// listener would be martian-dropped on the loopback reply path. + transparent: bool, +} + #[derive(Default)] struct PortBindings { /// hostname (lowercase) -> binding; a `*.suffix` key is a wildcard. hostnames: BTreeMap, - fallback: Option, + fallback: Option, } impl PortBindings { @@ -53,18 +73,29 @@ impl PortBindings { self.hostnames.is_empty() && self.fallback.is_none() } /// exact match, then a `*.suffix` wildcard on the parent, then fallback. - fn select(&self, sni: Option<&str>) -> Option { + /// Returns the target and whether to open the internal leg + /// source-preserving. `peer` gates a source-scoped fallback only — hostname + /// routes match regardless of source. + fn select(&self, sni: Option<&str>, peer: Ipv4Addr) -> Option<(SocketAddrV4, bool)> { if let Some(name) = sni { if let Some(b) = self.hostnames.get(name) { - return Some(b.target); + return Some((b.target, true)); } if let Some((_, rest)) = name.split_once('.') { if let Some(b) = self.hostnames.get(&format!("*.{rest}")) { - return Some(b.target); + return Some((b.target, true)); } } } - self.fallback + let f = self.fallback?; + match f.source { + FallbackSource::Any => {} + // std's `is_private` is exactly RFC1918, matching the firewall + // source scoping this fallback stands in for. + FallbackSource::PrivateOnly if peer.is_private() => {} + FallbackSource::PrivateOnly => return None, + } + Some((f.target, f.transparent)) } } @@ -143,6 +174,9 @@ impl SniDemux { /// Register hostname bindings for `(ext_ip, ext_port) -> target` and ensure /// the listener runs. `Err(RESULT_HOSTNAME_TAKEN)` if any name is held by a /// different target — all-or-nothing; the same target reclaims. + /// `Err(RESULT_NO_RESOURCES)` if the listener cannot bind: a grant must + /// never outrun its socket, since the gateway opens firewall access on the + /// strength of it. pub fn register( self: &Arc, ext_ip: Ipv4Addr, @@ -171,7 +205,32 @@ impl SniDemux { } Ok(()) })?; - self.ensure_listener(key); + if let Err(e) = self.ensure_listener(key) { + tracing::warn!( + "SNI demux bind on {}:{} failed; refusing the grant: {e}", + key.0, + key.1 + ); + // Roll back this call's insertions so snapshot/auto-list never + // report a route with no listener behind it. Same-target removal is + // safe: a distinct pre-existing same-target binding implies a live + // listener, in which case the bind was never attempted. + self.ports.mutate(|ports| { + if let Some(entry) = ports.get_mut(&key) { + for name in hostnames { + if entry + .hostnames + .get(name) + .is_some_and(|b| b.target == target) + { + entry.hostnames.remove(name); + } + } + } + }); + self.reap_if_empty(key); + return Err(RESULT_NO_RESOURCES); + } Ok(()) } @@ -202,24 +261,83 @@ impl SniDemux { /// Set the hostname-less fallback for `(ext_ip, ext_port) -> target` and /// ensure the listener runs. Traffic matching no hostname route (or sending - /// no SNI) is spliced here. `Err(RESULT_HOSTNAME_TAKEN)` if a different - /// target already holds the fallback; the same target reclaims (idempotent). + /// no SNI) is spliced here, source-preserving, from any source. + /// `Err(RESULT_HOSTNAME_TAKEN)` if a different target already holds the + /// fallback; the same target reclaims (idempotent). + /// `Err(RESULT_NO_RESOURCES)` if the listener cannot bind. pub fn register_fallback( self: &Arc, ext_ip: Ipv4Addr, ext_port: u16, target: SocketAddrV4, + ) -> Result<(), u8> { + self.register_fallback_with( + ext_ip, + ext_port, + Fallback { + target, + source: FallbackSource::Any, + transparent: true, + }, + ) + } + + /// Like [`register_fallback`](Self::register_fallback), but for the + /// gateway's *own* listener (e.g. StartWRT's web UI behind a shared 443): + /// the internal leg is a plain connect — a source-preserving dial to + /// ourselves would be martian-dropped — and `source` scopes who may reach + /// it, mirroring the firewall rule the demux displaced. + pub fn register_local_fallback( + self: &Arc, + ext_ip: Ipv4Addr, + ext_port: u16, + target: SocketAddrV4, + source: FallbackSource, + ) -> Result<(), u8> { + self.register_fallback_with( + ext_ip, + ext_port, + Fallback { + target, + source, + transparent: false, + }, + ) + } + + fn register_fallback_with( + self: &Arc, + ext_ip: Ipv4Addr, + ext_port: u16, + fallback: Fallback, ) -> Result<(), u8> { let key = (ext_ip, ext_port); self.ports.mutate(|ports| { let entry = ports.entry(key).or_default(); - if entry.fallback.is_some_and(|t| t != target) { + if entry.fallback.is_some_and(|f| f.target != fallback.target) { return Err(RESULT_HOSTNAME_TAKEN); } - entry.fallback = Some(target); + // Same-target re-register also refreshes source/transparency, so a + // policy change (e.g. a remote-access mode switch) applies in place. + entry.fallback = Some(fallback); Ok(()) })?; - self.ensure_listener(key); + if let Err(e) = self.ensure_listener(key) { + tracing::warn!( + "SNI demux bind on {}:{} failed; refusing the fallback: {e}", + key.0, + key.1 + ); + self.ports.mutate(|ports| { + if let Some(entry) = ports.get_mut(&key) { + if entry.fallback.is_some_and(|f| f.target == fallback.target) { + entry.fallback = None; + } + } + }); + self.reap_if_empty(key); + return Err(RESULT_NO_RESOURCES); + } Ok(()) } @@ -228,7 +346,7 @@ impl SniDemux { let key = (ext_ip, ext_port); self.ports.mutate(|ports| { if let Some(entry) = ports.get_mut(&key) { - if entry.fallback == Some(target) { + if entry.fallback.is_some_and(|f| f.target == target) { entry.fallback = None; } } @@ -270,6 +388,9 @@ impl SniDemux { /// spawned teardown could race the re-add and close a live port — /// then re-ensured on the new key (`on_change(port, true)` is an idempotent /// upsert for the gateway). No-op when everything is already on `new_ip`. + /// If a re-bind on the new key fails, that port's routes are dropped and + /// `on_change(port, false)` *does* fire — dead routes must not hold the + /// gateway's port open; clients re-assert within their lease. pub fn rekey_ipv4(self: &Arc, new_ip: Ipv4Addr) { let moved: Vec = self.ports.mutate(|ports| { let old_keys: Vec = ports.keys().filter(|k| k.0 != new_ip).copied().collect(); @@ -295,7 +416,20 @@ impl SniDemux { } } for old in moved { - self.ensure_listener((new_ip, old.1)); + let key = (new_ip, old.1); + if let Err(e) = self.ensure_listener(key) { + tracing::error!( + "SNI demux re-key bind on {}:{} failed; dropping the port's routes: {e}", + key.0, + key.1 + ); + self.ports.mutate(|ports| { + ports.remove(&key); + }); + if let Some(cb) = &self.on_change { + cb(key.1, false); + } + } } } @@ -333,41 +467,44 @@ impl SniDemux { } } - fn ensure_listener(self: &Arc, key: PortKey) { + /// Ensure a listener for `key`, binding inline so a failure is observable + /// to the caller — never grant first and bind later, or traffic the + /// gateway admits for the grant falls through to whatever wildcard socket + /// shares the port (on StartWRT, the router's own web UI). + /// `SO_REUSEPORT` lets this specific `(ext_ip, port)` socket coexist with a + /// same-process wildcard listener on the same port: TCP delivery prefers + /// the most-specific bound address, so the demux receives only traffic to + /// its external IP and the wildcard keeps the rest. + fn ensure_listener(self: &Arc, key: PortKey) -> std::io::Result<()> { let already = self.listeners.mutate(|l| l.contains_key(&key)); if already { - return; + return Ok(()); } + let listener = crate::net::utils::bind_tokio_listener_reuse_port( + SocketAddrV4::new(key.0, key.1).into(), + )?; let ports = self.ports.clone(); - let handle = NonDetachingJoinHandle::from(tokio::spawn(run_listener(key, ports))); + let handle = NonDetachingJoinHandle::from(tokio::spawn(run_listener(listener, key, ports))); self.listeners.mutate(|l| { l.insert(key, handle); }); if let Some(cb) = &self.on_change { cb(key.1, true); } + Ok(()) } } -async fn run_listener(key: PortKey, ports: Arc>>) { +async fn run_listener( + listener: tokio::net::TcpListener, + key: PortKey, + ports: Arc>>, +) { if let Err(e) = crate::net::transparent::ensure_divert_infra_once().await { tracing::warn!( "SNI demux reply-path divert setup failed (source preservation may be degraded): {e}" ); } - let listener = loop { - match crate::net::utils::bind_tokio_listener(SocketAddrV4::new(key.0, key.1).into()) { - Ok(listener) => break listener, - Err(e) => { - tracing::warn!( - "SNI demux bind on {}:{} failed (retrying): {e}", - key.0, - key.1 - ); - tokio::time::sleep(BIND_RETRY_DELAY).await; - } - } - }; tracing::info!("SNI demux listening on {}:{}", key.0, key.1); loop { match listener.accept().await { @@ -417,27 +554,42 @@ async fn handle_conn( } }; - let target = ports.peek(|p| p.get(&key).and_then(|e| e.select(sni.as_deref()))); - let Some(target) = target else { - return; // no match and no fallback: close - }; let SocketAddr::V4(peer) = peer else { return; // IPv4-only listener; should not occur }; - // Open the internal leg from the client's own source address (RFC §4.6). - // No plain-connect fallback: the backend gates LAN-only addresses on the - // source being private, and this server's own wg address is private — a - // fallback would present every WAN client as LAN-local. - let mut upstream = match crate::net::transparent::transparent_connect( - SocketAddr::V4(peer), - SocketAddr::V4(target), - ) - .await - { - Ok(upstream) => upstream, - Err(e) => { - tracing::warn!("SNI demux transparent egress to {target} for {peer} failed: {e}"); - return; + let selected = ports.peek(|p| { + p.get(&key) + .and_then(|e| e.select(sni.as_deref(), *peer.ip())) + }); + let Some((target, transparent)) = selected else { + return; // no match and no admissible fallback: close + }; + let mut upstream = if transparent { + // Open the internal leg from the client's own source address (RFC + // §4.6). No plain-connect fallback on failure: the backend gates + // LAN-only addresses on the source being private, and this server's + // own wg address is private — a fallback would present every WAN + // client as LAN-local. + match crate::net::transparent::transparent_connect( + SocketAddr::V4(peer), + SocketAddr::V4(target), + ) + .await + { + Ok(upstream) => upstream, + Err(e) => { + tracing::warn!("SNI demux transparent egress to {target} for {peer} failed: {e}"); + return; + } + } + } else { + // Gateway-self fallback: plain connect to our own listener. + match TcpStream::connect(SocketAddr::V4(target)).await { + Ok(upstream) => upstream, + Err(e) => { + tracing::warn!("SNI demux local egress to {target} for {peer} failed: {e}"); + return; + } } }; if upstream.write_all(&buf).await.is_err() { @@ -536,52 +688,182 @@ mod tests { // A named route coexists with the fallback: exact SNI hits the route, // no/unmatched SNI hits the fallback. + let anywhere = Ipv4Addr::new(203, 0, 113, 50); demux .register(ip, port, &["a.example.com".to_string()], host_target, None) .unwrap(); demux.ports.peek(|p| { let pb = p.get(&(ip, port)).unwrap(); - assert_eq!(pb.select(Some("a.example.com")), Some(host_target)); - assert_eq!(pb.select(Some("nope.example.com")), Some(fb)); - assert_eq!(pb.select(None), Some(fb)); + assert_eq!( + pb.select(Some("a.example.com"), anywhere), + Some((host_target, true)) + ); + assert_eq!( + pb.select(Some("nope.example.com"), anywhere), + Some((fb, true)) + ); + assert_eq!(pb.select(None, anywhere), Some((fb, true))); }); // Unregister with the wrong target is a no-op; the right target clears it, // leaving the named route intact. demux.unregister_fallback(ip, port, SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 8), 443)); demux.ports.peek(|p| { - assert_eq!(p.get(&(ip, port)).unwrap().fallback, Some(fb)); + assert_eq!( + p.get(&(ip, port)).unwrap().fallback.map(|f| f.target), + Some(fb) + ); }); demux.unregister_fallback(ip, port, fb); demux.ports.peek(|p| { let pb = p.get(&(ip, port)).unwrap(); assert_eq!(pb.fallback, None); - assert_eq!(pb.select(None), None); - assert_eq!(pb.select(Some("a.example.com")), Some(host_target)); + assert_eq!(pb.select(None, anywhere), None); + assert_eq!( + pb.select(Some("a.example.com"), anywhere), + Some((host_target, true)) + ); }); } + // A local (gateway-self) fallback: plain-connect leg, and PrivateOnly + // scoping admits RFC1918 sources while public sources fall through to a + // close — never to the gateway's own listener. + #[tokio::test] + async fn local_fallback_source_policy() { + let demux = SniDemux::new(); + let ip = Ipv4Addr::LOCALHOST; + let port = 44320u16; + let ui = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 443); + let host_target = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443); + demux + .register_local_fallback(ip, port, ui, FallbackSource::PrivateOnly) + .unwrap(); + demux + .register(ip, port, &["a.example.com".to_string()], host_target, None) + .unwrap(); + let private = Ipv4Addr::new(192, 168, 1, 2); + let public = Ipv4Addr::new(203, 0, 113, 50); + demux.ports.peek(|p| { + let pb = p.get(&(ip, port)).unwrap(); + // Hostname routes are never source-scoped. + assert_eq!( + pb.select(Some("a.example.com"), public), + Some((host_target, true)) + ); + // The local fallback is plain-connect and private-only. + assert_eq!(pb.select(None, private), Some((ui, false))); + assert_eq!(pb.select(None, public), None); + }); + // Re-registering with a new policy updates in place (mode switch). + demux + .register_local_fallback(ip, port, ui, FallbackSource::Any) + .unwrap(); + demux.ports.peek(|p| { + let pb = p.get(&(ip, port)).unwrap(); + assert_eq!(pb.select(None, public), Some((ui, false))); + }); + } + + // A grant must never outrun its socket: with the port held by a non- + // SO_REUSEPORT listener the bind fails, the register is refused with + // NO_RESOURCES, nothing is recorded, and on_change never fires — so a + // gateway never opens firewall access for a route with no listener. + #[tokio::test] + async fn bind_failure_refuses_grant_and_rolls_back() { + let events = Arc::new(SyncMutex::new(Vec::<(u16, bool)>::new())); + let recorded = events.clone(); + let demux = SniDemux::with_on_change(move |port, active| { + recorded.mutate(|e| e.push((port, active))) + }); + // Plain bind (no SO_REUSEPORT) — the demux's reuseport bind cannot join. + let blocker = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = blocker.local_addr().unwrap().port(); + let target = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443); + + let err = demux + .register( + Ipv4Addr::LOCALHOST, + port, + &["a.example.com".to_string()], + target, + Some(3600), + ) + .unwrap_err(); + assert_eq!(err, RESULT_NO_RESOURCES); + assert!(demux.snapshot().is_empty(), "rolled back on bind failure"); + demux.listeners.peek(|l| assert!(l.is_empty())); + assert!(events.peek(|e| e.is_empty()), "on_change must not fire"); + assert_eq!( + demux + .register_fallback(Ipv4Addr::LOCALHOST, port, target) + .unwrap_err(), + RESULT_NO_RESOURCES + ); + demux + .ports + .peek(|p| assert!(!p.contains_key(&(Ipv4Addr::LOCALHOST, port)))); + + // Blocker gone: the same register now succeeds and on_change fires. + drop(blocker); + demux + .register( + Ipv4Addr::LOCALHOST, + port, + &["a.example.com".to_string()], + target, + Some(3600), + ) + .unwrap(); + assert_eq!(demux.snapshot().len(), 1); + assert_eq!(events.peek(|e| e.clone()), vec![(port, true)]); + } + + // The coexistence the SO_REUSEPORT bind exists for: a same-process + // wildcard listener (StartWRT's web UI) shares the port with the demux's + // specific bind. + #[tokio::test] + async fn reuseport_bind_coexists_with_wildcard_listener() { + let wildcard = + crate::net::utils::bind_tokio_listener_reuse_port((Ipv4Addr::UNSPECIFIED, 0).into()) + .unwrap(); + let port = wildcard.local_addr().unwrap().port(); + let demux = SniDemux::new(); + demux + .register( + Ipv4Addr::LOCALHOST, + port, + &["a.example.com".to_string()], + SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443), + Some(3600), + ) + .unwrap(); + demux + .listeners + .peek(|l| assert!(l.contains_key(&(Ipv4Addr::LOCALHOST, port)))); + } + #[test] fn select_exact_wildcard_fallback() { let mut pb = PortBindings::default(); let exp = Instant::now() + Duration::from_secs(60); + let peer = Ipv4Addr::new(203, 0, 113, 50); let mk = |o: u8| Binding { target: SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, o), 443), expiry: Some(exp), }; pb.hostnames.insert("a.example.com".into(), mk(1)); pb.hostnames.insert("*.example.com".into(), mk(2)); - pb.fallback = Some(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 9), 443)); - assert_eq!( - pb.select(Some("a.example.com")).unwrap().ip().octets()[3], - 1 - ); - assert_eq!( - pb.select(Some("b.example.com")).unwrap().ip().octets()[3], - 2 - ); - assert_eq!(pb.select(Some("other.org")).unwrap().ip().octets()[3], 9); - assert_eq!(pb.select(None).unwrap().ip().octets()[3], 9); + pb.fallback = Some(Fallback { + target: SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 9), 443), + source: FallbackSource::Any, + transparent: true, + }); + let sel = |sni| pb.select(sni, peer).unwrap().0; + assert_eq!(sel(Some("a.example.com")).ip().octets()[3], 1); + assert_eq!(sel(Some("b.example.com")).ip().octets()[3], 2); + assert_eq!(sel(Some("other.org")).ip().octets()[3], 9); + assert_eq!(sel(None).ip().octets()[3], 9); } #[tokio::test] @@ -652,7 +934,11 @@ mod tests { "existing binding at the new key wins the collision" ); assert_eq!(pb.hostnames.get("c.example.com").unwrap().target, t2); - assert_eq!(pb.fallback, Some(t1), "moved fallback fills the empty slot"); + assert_eq!( + pb.fallback.map(|f| f.target), + Some(t1), + "moved fallback fills the empty slot" + ); }); demux.listeners.peek(|l| { assert!(l.contains_key(&(new_ip, 44313)) && !l.contains_key(&(old_ip, 44313))) From b8bb5d17f803b4ffe13763c2fdadcf2bad62776b Mon Sep 17 00:00:00 2001 From: Shadowy Super Coder Date: Fri, 21 Aug 2026 13:01:51 -0600 Subject: [PATCH 04/14] feat(start-wrt): router-port confirm dialog names the port's actual holder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- projects/start-wrt/API_CONTRACT.md | 20 +++ .../backend/ctrl/src/port_control.rs | 125 ++++++++++++++---- .../backend/ctrl/src/published_ports.rs | 90 ++++++++++++- .../start-wrt/docs/src/published-ports.md | 2 +- .../web/src/app/i18n/dictionaries/de.ts | 6 +- .../web/src/app/i18n/dictionaries/en.ts | 6 +- .../web/src/app/i18n/dictionaries/es.ts | 6 +- .../web/src/app/i18n/dictionaries/fr.ts | 6 +- .../web/src/app/i18n/dictionaries/pl.ts | 6 +- .../src/app/routes/published-ports/service.ts | 54 ++++++-- .../web/src/app/services/api/api.service.ts | 15 +++ .../src/app/services/api/mock-api.service.ts | 59 +++++---- 12 files changed, 320 insertions(+), 75 deletions(-) diff --git a/projects/start-wrt/API_CONTRACT.md b/projects/start-wrt/API_CONTRACT.md index 298febd417..4914542029 100644 --- a/projects/start-wrt/API_CONTRACT.md +++ b/projects/start-wrt/API_CONTRACT.md @@ -992,6 +992,19 @@ struct RouterPortCollision { label: String, /// The colliding router-service port spec(s), e.g. ["443", "22"]. router_ports: Vec, + /// Colliding ports whose holder is a device's SNI hostname routes rather + /// than a router service — informational, so the dialog names the actual + /// use; the override semantics are the same. + sni_ports: Vec, +} + +#[derive(Serialize)] +struct SniPortUse { + ports: String, + /// The routed hostnames on the port, deduped and sorted. + hostnames: Vec, + /// Display names (or MACs) of the devices the routes deliver to. + devices: Vec, } #[derive(Serialize)] @@ -1011,6 +1024,13 @@ device (issue #3451). In that case nothing is applied and `pending_router_port_collisions` names the offending ports; the UI shows a confirmation dialog and re-saves with `override_router_ports: true` on the named ports. An empty list in the response means the request was applied. +A colliding port whose WAN-input rule is an SNI-demux admit rule is reported +under `sni_ports` instead of `router_ports`, with the routed hostnames and +owning devices filled in from the live demux — the real holder is a device's +hostname routes, and the dialog names them rather than blaming the router. +The split is informational only: the override works identically, and after an +override the displaced hostname routes are refused at renewal and expire +within their lease. Detection is transport-aware (Remote Access is TCP, WireGuard is UDP — a UDP-only forward on 443 collides with nothing) and skipped in configs-only mode (the CLI editor confirms implicitly, like `ethernet.set` / `wifi.set`). diff --git a/projects/start-wrt/backend/ctrl/src/port_control.rs b/projects/start-wrt/backend/ctrl/src/port_control.rs index 6991861c91..0c2b0a1026 100644 --- a/projects/start-wrt/backend/ctrl/src/port_control.rs +++ b/projects/start-wrt/backend/ctrl/src/port_control.rs @@ -1039,22 +1039,33 @@ fn reserves_router_port(firewall: &uciedit::Config<'_>, want: (u16, u16)) -> boo !router_reserved_overlaps(firewall, want, true, true).is_empty() } +/// One overlapping WAN-input rule from [`router_reserved_overlaps`]: the +/// rule's port spec, and whether the rule is an SNI-demux admit rule — i.e. +/// the port's real holder is a device's hostname routes, not one of the +/// router's own services. The distinction only changes what the confirm +/// dialog tells the user; either way the port counts as router-reserved. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ReservedOverlap { + pub ports: String, + pub sni: bool, +} + /// The ports the router itself answers on from the WAN — input-chain rules /// (`src wan`, no `dest` zone, `ACCEPT`) — whose port range overlaps `want` /// over a requested transport (`tcp`/`udp`). Returns each overlapping rule's -/// `dest_port` spec, deduped. Reading the live rules rather than a hardcoded -/// list means a port stops being reserved when its feature is turned off, and -/// any future WAN-exposed service is covered without touching this code. -/// IPv6-only rules are ignored — they share no port space with an IPv4 -/// redirect. Transport matters: Remote Access (80/443/22) is TCP, WireGuard -/// is UDP, so e.g. a UDP-only forward on 443 collides with nothing. +/// `dest_port` spec, deduped per kind. Reading the live rules rather than a +/// hardcoded list means a port stops being reserved when its feature is +/// turned off, and any future WAN-exposed service is covered without touching +/// this code. IPv6-only rules are ignored — they share no port space with an +/// IPv4 redirect. Transport matters: Remote Access (80/443/22) is TCP, +/// WireGuard is UDP, so e.g. a UDP-only forward on 443 collides with nothing. pub(crate) fn router_reserved_overlaps( firewall: &uciedit::Config<'_>, want: (u16, u16), tcp: bool, udp: bool, -) -> Vec { - let mut overlaps: Vec = Vec::new(); +) -> Vec { + let mut overlaps: Vec = Vec::new(); for sec in &firewall.sections { let Ok(rule) = sec.get::() else { continue; @@ -1086,10 +1097,14 @@ pub(crate) fn router_reserved_overlaps( let Some(spec) = rule.dest_port.as_deref() else { continue; }; + let sni = rule._apf_label.as_deref() == Some(LABEL_SNI); if parse_port_range(spec).is_some_and(|range| ranges_overlap(want, range)) - && !overlaps.iter().any(|p| p == spec) + && !overlaps.iter().any(|p| p.ports == spec && p.sni == sni) { - overlaps.push(spec.to_string()); + overlaps.push(ReservedOverlap { + ports: spec.to_string(), + sni, + }); } } overlaps @@ -1778,22 +1793,84 @@ pub struct AutoForward { } #[instrument(skip_all)] -pub async fn auto_list(ctx: ServerContext) -> Result, Error> { - let uci_root = ctx.uci_root(); - let arena = Arena::new(); - let cfgs = parse_all(&uci_root, &arena, &["firewall", "dhcp"]).await?; - - // Device display names: UCI static host names win, cached learned names - // fill the gaps. +/// Device display names by uppercased MAC: UCI static host names win, cached +/// learned names fill the gaps. +pub(crate) fn device_display_names( + dhcp: &uciedit::Config<'_>, +) -> Result, Error> { let mut names: HashMap = crate::device_names::load_all() .into_iter() .filter_map(|(mac, cached)| cached.hostname.map(|name| (mac, name))) .collect(); - cfgs["dhcp"].each::(|_, host| { + dhcp.each::(|_, host| { if let Some(name) = host.name.as_ref().filter(|n| !n.is_empty()) { names.insert(host.mac.to_uppercase(), name.clone()); } })?; + Ok(names) +} + +/// LAN IPv4 → uppercased MAC: live DHCP leases, overridden by static +/// reservations (the reservation is where the device is supposed to be, so it +/// wins on disagreement). +pub(crate) async fn ip_to_mac_map( + dhcp: &uciedit::Config<'_>, +) -> Result, Error> { + let mut ip_to_mac: HashMap = HashMap::new(); + if let Some(leases) = crate::devices::current_lease_ips().await { + for (mac, ip) in leases { + ip_to_mac.insert(ip, mac); + } + } + dhcp.each::(|_, host| { + if let Some(ip) = host.ip.clone().filter(|ip| !ip.is_empty()) { + ip_to_mac.insert(ip, host.mac.to_uppercase()); + } + })?; + Ok(ip_to_mac) +} + +/// The hostname routes whose external port overlaps `want`, for naming a +/// port's holder in the router-port confirm dialog: the routed hostnames and +/// the owning devices' display names (name, else MAC; an unresolvable target +/// is skipped), each deduped and sorted. Informational only, so lookup +/// failures degrade to empty lists rather than failing the caller. +pub(crate) async fn sni_route_holders( + dhcp: &uciedit::Config<'_>, + want: (u16, u16), +) -> (Vec, Vec) { + let Some(pc) = PORT_CONTROL.get() else { + return (Vec::new(), Vec::new()); + }; + let names = device_display_names(dhcp).unwrap_or_default(); + let ip_to_mac = ip_to_mac_map(dhcp).await.unwrap_or_default(); + let mut hostnames = Vec::new(); + let mut devices = Vec::new(); + for route in pc.sni.snapshot() { + if !ranges_overlap(want, (route.ext_port, route.ext_port)) { + continue; + } + if !hostnames.contains(&route.hostname) { + hostnames.push(route.hostname.clone()); + } + if let Some(mac) = ip_to_mac.get(&route.target.ip().to_string()) { + let display = names.get(mac).cloned().unwrap_or_else(|| mac.clone()); + if !devices.contains(&display) { + devices.push(display); + } + } + } + hostnames.sort(); + devices.sort(); + (hostnames, devices) +} + +pub async fn auto_list(ctx: ServerContext) -> Result, Error> { + let uci_root = ctx.uci_root(); + let arena = Arena::new(); + let cfgs = parse_all(&uci_root, &arena, &["firewall", "dhcp"]).await?; + + let names = device_display_names(&cfgs["dhcp"])?; let mut out = Vec::new(); for sec in &cfgs["firewall"].sections { @@ -1822,17 +1899,7 @@ pub async fn auto_list(ctx: ServerContext) -> Result, Error> { // The owning device is whoever holds the target address: a static // reservation first, then the live DHCP lease. if let Some(pc) = PORT_CONTROL.get() { - let mut ip_to_mac: HashMap = HashMap::new(); - if let Some(leases) = crate::devices::current_lease_ips().await { - for (mac, ip) in leases { - ip_to_mac.insert(ip, mac); - } - } - cfgs["dhcp"].each::(|_, host| { - if let Some(ip) = host.ip.clone().filter(|ip| !ip.is_empty()) { - ip_to_mac.insert(ip, host.mac.to_uppercase()); - } - })?; + let ip_to_mac = ip_to_mac_map(&cfgs["dhcp"]).await?; for route in pc.sni.snapshot() { let target_ip = route.target.ip().to_string(); let device_mac = ip_to_mac.get(&target_ip).cloned().unwrap_or_default(); diff --git a/projects/start-wrt/backend/ctrl/src/published_ports.rs b/projects/start-wrt/backend/ctrl/src/published_ports.rs index 64ad061a23..4960bc6cd6 100644 --- a/projects/start-wrt/backend/ctrl/src/published_ports.rs +++ b/projects/start-wrt/backend/ctrl/src/published_ports.rs @@ -158,6 +158,21 @@ pub struct RouterPortCollision { pub label: String, /// The colliding router-service port spec(s), e.g. ["443", "22"]. pub router_ports: Vec, + /// Colliding ports whose holder is a device's SNI hostname routes rather + /// than a router service — the dialog names the actual use instead of + /// blaming the router. Informational: the override semantics are the same. + pub sni_ports: Vec, +} + +/// One colliding port held by hostname routes: the port spec plus who is +/// using it, for the confirm dialog's copy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SniPortUse { + pub ports: String, + /// The routed hostnames on the port, deduped and sorted. + pub hostnames: Vec, + /// Display names (or MACs) of the devices the routes deliver to. + pub devices: Vec, } /// [`set`] response. A non-empty collision list means nothing was applied — @@ -972,8 +987,19 @@ pub async fn set( // mode has no dialog: the CLI editor confirms implicitly, matching // `ethernet::set` / `wifi::set`. if ctx.effectful() { - let pending = router_port_collisions(&cfgs["firewall"], &req.ports); + let mut pending = router_port_collisions(&cfgs["firewall"], &req.ports); if !pending.is_empty() { + // Name each SNI-held port's holder (hostnames + devices) so + // the dialog can say what the port is really carrying; the + // demux and the dhcp config are only at hand here. + for collision in &mut pending { + for sni in &mut collision.sni_ports { + if let Some(want) = crate::port_control::parse_port_range(&sni.ports) { + (sni.hostnames, sni.devices) = + crate::port_control::sni_route_holders(&cfgs["dhcp"], want).await; + } + } + } return Ok(PublishedPortsSetResult { pending_router_port_collisions: pending, }); @@ -1270,12 +1296,24 @@ fn router_port_collisions( Protocol::Udp => (false, true), Protocol::TcpUdp => (true, true), }; - let router_ports = - crate::port_control::router_reserved_overlaps(firewall, range, tcp, udp); - (!router_ports.is_empty()).then(|| RouterPortCollision { - id: p.id.clone(), - label: p.label.clone(), - router_ports, + let overlaps = crate::port_control::router_reserved_overlaps(firewall, range, tcp, udp); + (!overlaps.is_empty()).then(|| { + let (sni, router): (Vec<_>, Vec<_>) = overlaps.into_iter().partition(|o| o.sni); + RouterPortCollision { + id: p.id.clone(), + label: p.label.clone(), + router_ports: router.into_iter().map(|o| o.ports).collect(), + // Holder details are filled in by [`set`], which has the + // dhcp config and the live demux at hand. + sni_ports: sni + .into_iter() + .map(|o| SniPortUse { + ports: o.ports, + hostnames: Vec::new(), + devices: Vec::new(), + }) + .collect(), + } }) }) .collect() @@ -2729,6 +2767,22 @@ config rule 'disabled_rule' \tlist proto 'tcp' \toption target 'ACCEPT' \toption enabled '0' + +config rule 'apf_sni_443' +\toption name 'SNI demux (hostname routes)' +\toption src 'wan' +\toption dest_port '443' +\tlist proto 'tcp' +\toption target 'ACCEPT' +\toption _apf_label 'SNI' + +config rule 'apf_sni_8444' +\toption name 'SNI demux (hostname routes)' +\toption src 'wan' +\toption dest_port '8444' +\tlist proto 'tcp' +\toption target 'ACCEPT' +\toption _apf_label 'SNI' "; async fn collisions_for(ports: Vec) -> Vec { @@ -2759,6 +2813,28 @@ config rule 'disabled_rule' assert_eq!(hits[0].router_ports, vec!["443", "22", "51820"]); } + #[tokio::test] + async fn sni_admit_rules_are_reported_as_hostname_holders() { + // A port held only by hostname routes still collides, but lands in + // `sni_ports` so the dialog names the real holder instead of blaming + // the router. Holder details stay empty here — the demux isn't + // running in unit tests; `set` fills them in the daemon. + let hits = collisions_for(vec![make_input(|p| p.ports = "8444".into())]).await; + assert_eq!(hits.len(), 1); + assert!(hits[0].router_ports.is_empty()); + assert_eq!(hits[0].sni_ports.len(), 1); + assert_eq!(hits[0].sni_ports[0].ports, "8444"); + assert!(hits[0].sni_ports[0].hostnames.is_empty()); + assert!(hits[0].sni_ports[0].devices.is_empty()); + + // Remote Access and hostname routes share 443: both kinds reported, + // each under its own heading. + let hits = collisions_for(vec![make_input(|p| p.ports = "443".into())]).await; + assert_eq!(hits[0].router_ports, vec!["443"]); + assert_eq!(hits[0].sni_ports.len(), 1); + assert_eq!(hits[0].sni_ports[0].ports, "443"); + } + #[tokio::test] async fn router_collision_respects_transport_and_flags() { // UDP-only on 443 shares no transport with the TCP remote rule; TCP diff --git a/projects/start-wrt/docs/src/published-ports.md b/projects/start-wrt/docs/src/published-ports.md index d0d3173d77..f6ad882247 100644 --- a/projects/start-wrt/docs/src/published-ports.md +++ b/projects/start-wrt/docs/src/published-ports.md @@ -30,7 +30,7 @@ Published ports (port forwarding) allow devices on the Internet to reach specifi > If the device's [Security Profile](security-profiles.md) routes its traffic through an [Outbound VPN](outbound-vpn.md), creating or re-enabling a rule prompts for confirmation: published ports are reached over your public WAN address, not through the VPN, so the port is exposed on your real public IP. > [!WARNING] -> Some ports the router answers on itself. If [Remote Access](settings.md#remote-access) is on — including the default "When behind NAT" mode while the router sits behind another router — the router serves its own web interface, and optionally SSH, on WAN ports 80, 443, and 22; an [Inbound VPN](inbound-vpn.md) listens on its configured port. Publishing one of these ports sends that traffic to your device instead, cutting the router's own service off from outside your network (access from your LAN is unaffected). Saving such a rule therefore prompts for confirmation first — you can override it deliberately, e.g. to run your own web server on 443 when you don't use remote access to the router. You are asked once per rule; editing the rule asks again. +> Some ports the router answers on itself. If [Remote Access](settings.md#remote-access) is on — including the default "When behind NAT" mode while the router sits behind another router — the router serves its own web interface, and optionally SSH, on WAN ports 80, 443, and 22; an [Inbound VPN](inbound-vpn.md) listens on its configured port. Publishing one of these ports sends that traffic to your device instead, cutting the router's own service off from outside your network (access from your LAN is unaffected). Saving such a rule therefore prompts for confirmation first — you can override it deliberately, e.g. to run your own web server on 443 when you don't use remote access to the router. You are asked once per rule; editing the rule asks again. When the port's actual holder is a device's [hostname routes](#hostname-routes-shared-ports) rather than a router service, the prompt says so instead, naming the routed hostnames and the device they belong to. ## Editing a Rule diff --git a/projects/start-wrt/web/src/app/i18n/dictionaries/de.ts b/projects/start-wrt/web/src/app/i18n/dictionaries/de.ts index 5df0dd44f5..f5a0884201 100644 --- a/projects/start-wrt/web/src/app/i18n/dictionaries/de.ts +++ b/projects/start-wrt/web/src/app/i18n/dictionaries/de.ts @@ -533,10 +533,14 @@ export default { 540: 'Berechtigungen', 541: 'Min.', 542: 'Port wird von diesem Router verwendet', - 543: 'Die Ports {ports} werden von diesem Router selbst verwendet — für den Fernzugriff auf seine Weboberfläche, SSH oder einen VPN-Server. Werden sie veröffentlicht, geht dieser Datenverkehr stattdessen an das ausgewählte Gerät, wodurch diese Dienste von außerhalb Ihres Netzwerks nicht mehr erreichbar sind. Trotzdem veröffentlichen?', 544: 'Trotzdem veröffentlichen', 545: 'Aktualisierung erforderlich', 546: 'Die Router-Firmware wurde aktualisiert, aber diese Seite verwendet noch die vorherige Oberfläche. Laden Sie die Seite neu, um die neueste Version zu erhalten.', 547: 'Neu laden', 548: 'Auf v{version} aktualisiert', + 549: 'Die Ports {ports} werden von diesem Router selbst verwendet — für den Fernzugriff auf seine Weboberfläche, SSH oder einen VPN-Server. Werden sie veröffentlicht, geht dieser Datenverkehr stattdessen an das ausgewählte Gerät, wodurch diese Dienste von außerhalb Ihres Netzwerks nicht mehr erreichbar sind.', + 550: 'Über die Ports {ports} laufen derzeit Hostnamen-Routen ({list}), registriert von {devices}. Werden sie veröffentlicht, geht der gesamte Datenverkehr auf diesen Ports stattdessen an das ausgewählte Gerät — diese Hostnamen-Routen funktionieren nicht mehr, bis diese Regel entfernt wird.', + 551: 'Über die Ports {ports} laufen derzeit Hostnamen-Routen ({list}). Werden sie veröffentlicht, geht der gesamte Datenverkehr auf diesen Ports stattdessen an das ausgewählte Gerät — diese Hostnamen-Routen funktionieren nicht mehr, bis diese Regel entfernt wird.', + 552: 'Trotzdem veröffentlichen?', + 553: 'Port für Hostnamen-Routen verwendet', } satisfies i18n diff --git a/projects/start-wrt/web/src/app/i18n/dictionaries/en.ts b/projects/start-wrt/web/src/app/i18n/dictionaries/en.ts index f524d21612..32e69896e2 100644 --- a/projects/start-wrt/web/src/app/i18n/dictionaries/en.ts +++ b/projects/start-wrt/web/src/app/i18n/dictionaries/en.ts @@ -538,10 +538,14 @@ export const ENGLISH: Record = { 'Permissions': 540, 'min': 541, 'Port Used by This Router': 542, - 'Port(s) {ports} are used by this router itself — for remote access to its web interface, SSH, or a VPN server. Publishing them will send that traffic to the selected device instead, cutting those services off from outside your network. Publish anyway?': 543, 'Publish Anyway': 544, 'Refresh Needed': 545, 'The router firmware has been updated, but this page is still running the previous interface. Reload the page to get the latest version.': 546, 'Reload': 547, 'Updated to v{version}': 548, + 'Port(s) {ports} are used by this router itself — for remote access to its web interface, SSH, or a VPN server. Publishing them will send that traffic to the selected device instead, cutting those services off from outside your network.': 549, + 'Port(s) {ports} currently carry hostname routes ({list}) registered by {devices}. Publishing them will send all traffic on these ports to the selected device instead — those hostname routes will stop working until this rule is removed.': 550, + 'Port(s) {ports} currently carry hostname routes ({list}). Publishing them will send all traffic on these ports to the selected device instead — those hostname routes will stop working until this rule is removed.': 551, + 'Publish anyway?': 552, + 'Port Used for Hostname Routes': 553, } diff --git a/projects/start-wrt/web/src/app/i18n/dictionaries/es.ts b/projects/start-wrt/web/src/app/i18n/dictionaries/es.ts index 15ac8cdbdb..fcb708e493 100644 --- a/projects/start-wrt/web/src/app/i18n/dictionaries/es.ts +++ b/projects/start-wrt/web/src/app/i18n/dictionaries/es.ts @@ -533,10 +533,14 @@ export default { 540: 'Permisos', 541: 'min', 542: 'Puerto utilizado por este router', - 543: 'Los puertos {ports} los utiliza este propio router — para el acceso remoto a su interfaz web, SSH o un servidor VPN. Si los publica, ese tráfico se enviará al dispositivo seleccionado, dejando esos servicios inaccesibles desde fuera de su red. ¿Publicar de todos modos?', 544: 'Publicar de todos modos', 545: 'Actualización necesaria', 546: 'El firmware del router se ha actualizado, pero esta página sigue ejecutando la interfaz anterior. Recarga la página para obtener la versión más reciente.', 547: 'Recargar', 548: 'Actualizado a la v{version}', + 549: 'Los puertos {ports} los utiliza este propio router — para el acceso remoto a su interfaz web, SSH o un servidor VPN. Si los publica, ese tráfico se enviará al dispositivo seleccionado, dejando esos servicios inaccesibles desde fuera de su red.', + 550: 'Los puertos {ports} llevan actualmente rutas por nombre de host ({list}) registradas por {devices}. Si los publica, todo el tráfico de estos puertos se enviará al dispositivo seleccionado — esas rutas por nombre de host dejarán de funcionar hasta que se elimine esta regla.', + 551: 'Los puertos {ports} llevan actualmente rutas por nombre de host ({list}). Si los publica, todo el tráfico de estos puertos se enviará al dispositivo seleccionado — esas rutas por nombre de host dejarán de funcionar hasta que se elimine esta regla.', + 552: '¿Publicar de todos modos?', + 553: 'Puerto usado por rutas de nombre de host', } satisfies i18n diff --git a/projects/start-wrt/web/src/app/i18n/dictionaries/fr.ts b/projects/start-wrt/web/src/app/i18n/dictionaries/fr.ts index f208c541b6..50af9134ba 100644 --- a/projects/start-wrt/web/src/app/i18n/dictionaries/fr.ts +++ b/projects/start-wrt/web/src/app/i18n/dictionaries/fr.ts @@ -533,10 +533,14 @@ export default { 540: 'Autorisations', 541: 'min', 542: 'Port utilisé par ce routeur', - 543: 'Les ports {ports} sont utilisés par ce routeur lui-même — pour l’accès à distance à son interface web, SSH ou un serveur VPN. En les publiant, ce trafic sera envoyé vers l’appareil sélectionné, rendant ces services inaccessibles depuis l’extérieur de votre réseau. Publier quand même ?', 544: 'Publier quand même', 545: 'Actualisation requise', 546: "Le micrologiciel du routeur a été mis à jour, mais cette page exécute encore l'interface précédente. Rechargez la page pour obtenir la dernière version.", 547: 'Recharger', 548: 'Mise à jour vers la v{version} effectuée', + 549: 'Les ports {ports} sont utilisés par ce routeur lui-même — pour l’accès à distance à son interface web, SSH ou un serveur VPN. En les publiant, ce trafic sera envoyé vers l’appareil sélectionné, rendant ces services inaccessibles depuis l’extérieur de votre réseau.', + 550: 'Les ports {ports} portent actuellement des routes par nom d’hôte ({list}) enregistrées par {devices}. En les publiant, tout le trafic de ces ports sera envoyé vers l’appareil sélectionné — ces routes par nom d’hôte cesseront de fonctionner tant que cette règle ne sera pas supprimée.', + 551: 'Les ports {ports} portent actuellement des routes par nom d’hôte ({list}). En les publiant, tout le trafic de ces ports sera envoyé vers l’appareil sélectionné — ces routes par nom d’hôte cesseront de fonctionner tant que cette règle ne sera pas supprimée.', + 552: 'Publier quand même ?', + 553: 'Port utilisé par des routes par nom d’hôte', } satisfies i18n diff --git a/projects/start-wrt/web/src/app/i18n/dictionaries/pl.ts b/projects/start-wrt/web/src/app/i18n/dictionaries/pl.ts index f250c0c811..1bb052533e 100644 --- a/projects/start-wrt/web/src/app/i18n/dictionaries/pl.ts +++ b/projects/start-wrt/web/src/app/i18n/dictionaries/pl.ts @@ -533,10 +533,14 @@ export default { 540: 'Uprawnienia', 541: 'min', 542: 'Port używany przez ten router', - 543: 'Porty {ports} są używane przez sam ten router — do zdalnego dostępu do jego interfejsu WWW, SSH lub serwera VPN. Ich opublikowanie przekieruje ten ruch do wybranego urządzenia, odcinając te usługi od dostępu spoza Twojej sieci. Opublikować mimo to?', 544: 'Opublikuj mimo to', 545: 'Wymagane odświeżenie', 546: 'Oprogramowanie routera zostało zaktualizowane, ale ta strona nadal używa poprzedniego interfejsu. Załaduj stronę ponownie, aby uzyskać najnowszą wersję.', 547: 'Załaduj ponownie', 548: 'Zaktualizowano do v{version}', + 549: 'Porty {ports} są używane przez sam ten router — do zdalnego dostępu do jego interfejsu WWW, SSH lub serwera VPN. Ich opublikowanie przekieruje ten ruch do wybranego urządzenia, odcinając te usługi od dostępu spoza Twojej sieci.', + 550: 'Porty {ports} obsługują obecnie trasy po nazwie hosta ({list}) zarejestrowane przez {devices}. Ich opublikowanie przekieruje cały ruch na tych portach do wybranego urządzenia — te trasy przestaną działać, dopóki ta reguła nie zostanie usunięta.', + 551: 'Porty {ports} obsługują obecnie trasy po nazwie hosta ({list}). Ich opublikowanie przekieruje cały ruch na tych portach do wybranego urządzenia — te trasy przestaną działać, dopóki ta reguła nie zostanie usunięta.', + 552: 'Opublikować mimo to?', + 553: 'Port używany przez trasy po nazwie hosta', } satisfies i18n diff --git a/projects/start-wrt/web/src/app/routes/published-ports/service.ts b/projects/start-wrt/web/src/app/routes/published-ports/service.ts index 51ba028690..4c00044a0e 100644 --- a/projects/start-wrt/web/src/app/routes/published-ports/service.ts +++ b/projects/start-wrt/web/src/app/routes/published-ports/service.ts @@ -76,8 +76,9 @@ export class PublishedPortsService extends FormService { /** * If `pending` is non-empty, prompt the user to confirm publishing port(s) - * the router itself answers on from the WAN (remote access to its web - * interface, SSH, or a VPN server) — the forward would capture that traffic. + * something else already answers on from the WAN, naming the actual holder: + * the router's own services (remote access to its web interface, SSH, a VPN + * server), a device's SNI hostname routes, or both on a shared port. * Returns true when there is nothing to confirm or the user confirmed, false * when they cancelled. */ @@ -85,17 +86,50 @@ export class PublishedPortsService extends FormService { pending: RouterPortCollision[], ): Promise { if (!pending.length) return true - const ports = [...new Set(pending.flatMap(c => c.router_ports))].join(', ') + const routerPorts = [...new Set(pending.flatMap(c => c.router_ports))] + const sni = pending.flatMap(c => c.sni_ports) + const parts: string[] = [] + if (routerPorts.length) { + parts.push( + fill( + this.i18n.transform( + 'Port(s) {ports} are used by this router itself — for remote access to its web interface, SSH, or a VPN server. Publishing them will send that traffic to the selected device instead, cutting those services off from outside your network.', + ), + { ports: routerPorts.join(', ') }, + ), + ) + } + if (sni.length) { + const hostnames = [...new Set(sni.flatMap(s => s.hostnames))] + const devices = [...new Set(sni.flatMap(s => s.devices))] + const vars = { + ports: [...new Set(sni.map(s => s.ports))].join(', '), + list: + hostnames.slice(0, 3).join(', ') + + (hostnames.length > 3 ? ` (+${hostnames.length - 3})` : ''), + devices: devices.join(', '), + } + parts.push( + fill( + this.i18n.transform( + devices.length + ? 'Port(s) {ports} currently carry hostname routes ({list}) registered by {devices}. Publishing them will send all traffic on these ports to the selected device instead — those hostname routes will stop working until this rule is removed.' + : 'Port(s) {ports} currently carry hostname routes ({list}). Publishing them will send all traffic on these ports to the selected device instead — those hostname routes will stop working until this rule is removed.', + ), + vars, + ), + ) + } + parts.push(this.i18n.transform('Publish anyway?')) return firstValueFrom( this.dialogs.open(TUI_CONFIRM, { - label: this.i18n.transform('Port Used by This Router'), + label: this.i18n.transform( + routerPorts.length + ? 'Port Used by This Router' + : 'Port Used for Hostname Routes', + ), data: { - content: fill( - this.i18n.transform( - 'Port(s) {ports} are used by this router itself — for remote access to its web interface, SSH, or a VPN server. Publishing them will send that traffic to the selected device instead, cutting those services off from outside your network. Publish anyway?', - ), - { ports }, - ), + content: parts.join(' '), yes: this.i18n.transform('Publish Anyway'), no: this.i18n.transform('Cancel'), }, diff --git a/projects/start-wrt/web/src/app/services/api/api.service.ts b/projects/start-wrt/web/src/app/services/api/api.service.ts index 3ef7e0f654..a521d76602 100644 --- a/projects/start-wrt/web/src/app/services/api/api.service.ts +++ b/projects/start-wrt/web/src/app/services/api/api.service.ts @@ -680,6 +680,21 @@ export interface RouterPortCollision { label: string /** The colliding router-service port spec(s), e.g. ["443", "22"]. */ router_ports: string[] + /** + * Colliding ports whose holder is a device's SNI hostname routes rather + * than a router service, so the dialog can name the actual use. The + * override semantics are the same. + */ + sni_ports: SniPortUse[] +} + +/** One colliding port held by hostname routes: the spec plus who is using it. */ +export interface SniPortUse { + ports: string + /** The routed hostnames on the port, deduped and sorted. */ + hostnames: string[] + /** Display names (or MACs) of the devices the routes deliver to. */ + devices: string[] } // A non-empty collision list means nothing was applied — confirm and re-save. diff --git a/projects/start-wrt/web/src/app/services/api/mock-api.service.ts b/projects/start-wrt/web/src/app/services/api/mock-api.service.ts index 819839fbbd..e34cb1b81b 100644 --- a/projects/start-wrt/web/src/app/services/api/mock-api.service.ts +++ b/projects/start-wrt/web/src/app/services/api/mock-api.service.ts @@ -1374,29 +1374,42 @@ export class MockApiService extends ApiService { // Router-port collision handshake (matches the real backend): an enabled, // unconfirmed IPv4 forward capturing a port the router answers on itself - // (Remote Access 80/443/22, TCP — active unless remote access is off) - // reports the collision and applies nothing. - if (this.mockSystemInfo.remoteAccess !== 'never') { - const pending = params.ports - .filter( - p => - p.enabled && - p.ipv4 && - !p.override_router_ports && - p.protocol !== 'udp', - ) - .map(p => { - const spec = p.ipv4_public_port || p.ports - const [lo, hi = lo] = spec.split('-').map(Number) - const routerPorts = ['80', '443', '22'].filter( - rp => Number(rp) >= lo && Number(rp) <= hi, - ) - return { id: p.id, label: p.label, router_ports: routerPorts } - }) - .filter(c => c.router_ports.length) - if (pending.length) { - return { pending_router_port_collisions: pending } - } + // (Remote Access 80/443/22, TCP — active unless remote access is off) or + // one held by the mock SNI hostname route on 443 (see + // publishedPortsAutoList) reports the collision and applies nothing. + const sniMac = '00:1A:2B:3C:4D:5E' + const pending = params.ports + .filter( + p => + p.enabled && + p.ipv4 && + !p.override_router_ports && + p.protocol !== 'udp', + ) + .map(p => { + const spec = p.ipv4_public_port || p.ports + const [lo, hi = lo] = spec.split('-').map(Number) + const router_ports = + this.mockSystemInfo.remoteAccess !== 'never' + ? ['80', '443', '22'].filter( + rp => Number(rp) >= lo && Number(rp) <= hi, + ) + : [] + const sni_ports = + this.autoForwardAllowed.has(sniMac) && lo <= 443 && 443 <= hi + ? [ + { + ports: '443', + hostnames: ['nextcloud.example.com'], + devices: [this.lookupDeviceByMac(sniMac).name || sniMac], + }, + ] + : [] + return { id: p.id, label: p.label, router_ports, sni_ports } + }) + .filter(c => c.router_ports.length || c.sni_ports.length) + if (pending.length) { + return { pending_router_port_collisions: pending } } // Auto-reserve static IPv4 for enabled ports (matches real backend From 17a562ebe79fe8f59a0e59aaf8f8777b387188cf Mon Sep 17 00:00:00 2001 From: Shadowy Super Coder Date: Tue, 25 Aug 2026 13:17:46 -0600 Subject: [PATCH 05/14] fix(start-wrt): revoking a device's permission drops its SNI hostname routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- projects/start-wrt/CHANGELOG.md | 4 +- .../backend/ctrl/src/port_control.rs | 236 ++++++++++++++++-- .../start-wrt/docs/src/published-ports.md | 2 +- 3 files changed, 225 insertions(+), 17 deletions(-) diff --git a/projects/start-wrt/CHANGELOG.md b/projects/start-wrt/CHANGELOG.md index 027e69dcc5..754d7e40e2 100644 --- a/projects/start-wrt/CHANGELOG.md +++ b/projects/start-wrt/CHANGELOG.md @@ -33,8 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 lapsed, or it returned on a different address — so a forward can never quietly deliver Internet traffic to whichever device is given that address next (devices with a reserved address are unaffected). Turning the toggle - back off — or forgetting the device — closes that device's forwards - immediately. + back off — or forgetting the device — closes that device's forwards and + hostname routes immediately. The Published Ports page gains a read-only "Automatic" section showing each forward's device, protocol, and expiry. UPnP clients see a complete gateway: the router advertises the `WANCommonInterfaceConfig` service clients use to diff --git a/projects/start-wrt/backend/ctrl/src/port_control.rs b/projects/start-wrt/backend/ctrl/src/port_control.rs index 0c2b0a1026..bd555071d2 100644 --- a/projects/start-wrt/backend/ctrl/src/port_control.rs +++ b/projects/start-wrt/backend/ctrl/src/port_control.rs @@ -124,7 +124,7 @@ use startos::net::port_map::server::igd::{ SCPD_PATH, SSDP_MULTICAST, SSDP_PORT, }; use startos::net::port_map::server::{handle, GatewayBackend, MappingEntry, PCP_PORT}; -use startos::tunnel::forward::sni::{FallbackSource, SniDemux}; +use startos::tunnel::forward::sni::{FallbackSource, SniDemux, SniRoute}; use tokio::io::Interest; use tokio::net::UdpSocket; use uciedit::openwrt::{DhcpHost, FirewallRedirect, FirewallRule, FirewallTarget}; @@ -292,25 +292,89 @@ impl PortControl { .and_then(|out| String::from_utf8(out).ok())?; let (mac, iface) = parse_neigh(&neigh, peer)?; let uci_root = self.uci_root.clone(); - let mac_for_lookup = mac.clone(); let allowed = uci_task(move || async move { let arena = Arena::new(); let cfgs = parse_all(&uci_root, &arena, &["dhcp"]).await?; - let mut allowed = false; - cfgs["dhcp"].each::(|_, host| { - if host.mac.to_uppercase() == mac_for_lookup - && host._allow_pcp.as_deref() == Some("1") - { - allowed = true; - } - })?; - Ok(allowed) + Ok(pcp_allowed_macs(&cfgs["dhcp"])?) }) .await + .map(|allowed| allowed.contains(&mac)) .unwrap_or(false); allowed.then_some(Client { mac, iface }) } + /// Drop every hostname route whose target address now belongs to a device + /// without the automatic-port-forwarding permission. Routes live in the + /// demux keyed by address, not by device, so the owner is re-derived the + /// way a grant derives it — the neighbor table, else the DHCP leases and + /// static hosts — and a target that resolves to no device at all is left + /// alone, its lease still bounding it. Runs on every permission revocation + /// and once a minute from the sweep, which also catches an address handed + /// on to a different, unauthorized device. + pub(crate) async fn reap_unauthorized_sni_routes(&self) { + let routes = self.sni.snapshot(); + if routes.is_empty() { + return; + } + let uci_root = self.uci_root.clone(); + let lookup = uci_task(move || async move { + let arena = Arena::new(); + let cfgs = parse_all(&uci_root, &arena, &["dhcp"]).await?; + let ip_to_mac = ip_to_mac_map(&cfgs["dhcp"]).await?; + Ok((ip_to_mac, pcp_allowed_macs(&cfgs["dhcp"])?)) + }) + .await; + let (mut ip_to_mac, allowed) = match lookup { + Ok(v) => v, + Err(e) => { + tracing::warn!("port-control: SNI route ownership lookup failed: {e}"); + return; + } + }; + // The neighbor table is what a grant trusted, so it outranks a lease. + if let Some(neigh) = tokio::process::Command::new("ip") + .args(["neigh", "show"]) + .invoke(ErrorKind::Network.into()) + .await + .ok() + .and_then(|out| String::from_utf8(out).ok()) + { + for e in crate::devices::parse_neigh_output(&neigh) { + if e.interface.starts_with("br-") { + ip_to_mac.insert(e.ip, e.mac); + } + } + } + let doomed = unauthorized_sni_routes(routes, &ip_to_mac, &allowed); + if doomed.is_empty() { + return; + } + for route in &doomed { + tracing::warn!( + "port-control: dropping SNI route {} on {}:{} -> {}: its device is no longer \ + authorized", + route.hostname, + route.ext_ip, + route.ext_port, + route.target + ); + self.sni.unregister( + route.ext_ip, + route.ext_port, + std::slice::from_ref(&route.hostname), + route.target, + ); + } + { + let _serial = self.write_serial.lock().await; + self.sync_sni_rules().await; + } + let wans: std::collections::BTreeSet = doomed.iter().map(|r| r.ext_ip).collect(); + for wan in wans { + self.sync_sni_fallback(wan).await; + } + } + /// The router's WAN IPv4 (via ubus), cached briefly. async fn wan_ipv4(&self) -> Option { { @@ -761,6 +825,7 @@ impl PortControl { /// rules back in line with the live ports (heals a failed callback write, /// an external edit, or a stale rule from before a daemon restart). async fn sni_maintain(&self) { + self.reap_unauthorized_sni_routes().await; let wan = self.wan_ipv4().await; if let Some(ip) = wan { if self.sni.snapshot().iter().any(|r| r.ext_ip != ip) { @@ -1450,6 +1515,37 @@ fn parse_neigh(neigh: &str, peer: Ipv4Addr) -> Option<(String, String)> { .map(|e| (e.mac, e.interface)) } +/// The uppercase MACs whose DHCP host entry grants automatic port forwarding. +fn pcp_allowed_macs( + dhcp: &uciedit::Config<'_>, +) -> Result, Error> { + let mut allowed = std::collections::HashSet::new(); + dhcp.each::(|_, host| { + if host._allow_pcp.as_deref() == Some("1") { + allowed.insert(host.mac.to_uppercase()); + } + })?; + Ok(allowed) +} + +/// The routes among `routes` whose target address maps (via `ip_to_mac`) to a +/// device outside `allowed`. An address that maps to no device is not +/// evidence of anything, so its routes are kept. +fn unauthorized_sni_routes( + routes: Vec, + ip_to_mac: &HashMap, + allowed: &std::collections::HashSet, +) -> Vec { + routes + .into_iter() + .filter(|route| { + ip_to_mac + .get(&route.target.ip().to_string()) + .is_some_and(|mac| !allowed.contains(&mac.to_uppercase())) + }) + .collect() +} + /// The router's own `br-*` IPv4 addresses from `ip -j addr show`. fn parse_lan_addrs(json: &str) -> Vec { let Ok(parsed) = serde_json::from_str::(json) else { @@ -2004,9 +2100,9 @@ pub async fn set_auto_forward( Ok(()) } -/// Close every automatic forward owned by `mac` and drop its cached -/// authorization. Called wherever a device loses the permission that created -/// those forwards — the per-device toggle going off, or the device being +/// Close every automatic forward and hostname route owned by `mac` and drop +/// its cached authorization. Called wherever a device loses the permission +/// that created them — the per-device toggle going off, or the device being /// forgotten outright. Without it the ports outlive the authorization by up to /// [`MAX_LEASE`], and the user's only control over an automatic forward /// wouldn't take effect for as much as a week. The client discovers the loss on @@ -2019,6 +2115,7 @@ pub(crate) async fn close_device_forwards(mac: &str) { }; pc.invalidate_clients(); pc.remove_client_forwards(mac, |_| true).await; + pc.reap_unauthorized_sni_routes().await; } #[cfg(test)] @@ -2594,6 +2691,117 @@ config redirect 'dns_override_lan' assert_eq!(written.matches("apf_sni_8443").count(), 1); } + // Revocation is enforced by re-deriving each route's owner from the + // address it targets: a device that lost the permission loses its routes, + // a device that still holds it keeps them, and an address no device can be + // attributed to is left to its lease. + #[tokio::test] + async fn sni_routes_follow_the_permission() { + let dir = temp_root(""); + std::fs::write( + dir.path().join("dhcp"), + "\ +config host +\toption mac 'AA:AA:AA:AA:AA:AA' +\toption ip '192.168.1.50' +\toption _allow_pcp '1' + +config host +\toption mac 'BB:BB:BB:BB:BB:BB' +\toption ip '192.168.1.51' +\toption _allow_pcp '1' +", + ) + .unwrap(); + let pc = PortControl::new(dir.path().to_path_buf()); + let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443); + let a = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 50), 443); + let b = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 51), 443); + let stray = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 52), 443); + for (target, host) in [ + (a, "a.example.com"), + (b, "b.example.com"), + (stray, "c.example.com"), + ] { + pc.add_sni_route(source, target, &[host.to_string()], Some(3600)) + .await + .unwrap(); + } + assert_eq!(pc.sni.snapshot().len(), 3); + + // Everyone still authorized: nothing moves. + pc.reap_unauthorized_sni_routes().await; + assert_eq!(pc.sni.snapshot().len(), 3); + + // A loses the permission the way the toggle revokes it. + std::fs::write( + dir.path().join("dhcp"), + "\ +config host +\toption mac 'AA:AA:AA:AA:AA:AA' +\toption ip '192.168.1.50' + +config host +\toption mac 'BB:BB:BB:BB:BB:BB' +\toption ip '192.168.1.51' +\toption _allow_pcp '1' +", + ) + .unwrap(); + pc.reap_unauthorized_sni_routes().await; + let left: Vec = pc.sni.snapshot().into_iter().map(|r| r.hostname).collect(); + assert_eq!(left, vec!["b.example.com", "c.example.com"]); + + // The port still has holders, so its admit rule stays. + let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); + assert!(written.contains("config rule apf_sni_8443")); + + // Forgetting B drops its host entry outright. With no lease or + // neighbor entry left to attribute its address to anything, B's route + // is now — like the stray's — evidence of nothing, and both are left + // to their leases rather than reaped on a guess. + std::fs::write( + dir.path().join("dhcp"), + "\ +config host +\toption mac 'AA:AA:AA:AA:AA:AA' +\toption ip '192.168.1.50' +", + ) + .unwrap(); + pc.reap_unauthorized_sni_routes().await; + let left: Vec = pc.sni.snapshot().into_iter().map(|r| r.hostname).collect(); + assert_eq!(left, vec!["b.example.com", "c.example.com"]); + } + + #[test] + fn unauthorized_routes_need_a_resolved_owner() { + let route = |ip: [u8; 4], host: &str| SniRoute { + ext_ip: Ipv4Addr::new(203, 0, 113, 7), + ext_port: 443, + hostname: host.to_string(), + target: SocketAddrV4::new(Ipv4Addr::from(ip), 443), + remaining_secs: Some(60), + }; + let routes = vec![ + route([192, 168, 1, 50], "revoked"), + route([192, 168, 1, 51], "kept"), + route([192, 168, 1, 52], "unknown"), + ]; + let ip_to_mac: HashMap = [ + ("192.168.1.50".to_string(), "aa:aa:aa:aa:aa:aa".to_string()), + ("192.168.1.51".to_string(), "BB:BB:BB:BB:BB:BB".to_string()), + ] + .into_iter() + .collect(); + let allowed = std::collections::HashSet::from(["BB:BB:BB:BB:BB:BB".to_string()]); + let doomed: Vec = unauthorized_sni_routes(routes, &ip_to_mac, &allowed) + .into_iter() + .map(|r| r.hostname) + .collect(); + assert_eq!(doomed, vec!["revoked"]); + } + #[tokio::test] async fn sni_route_refused_on_owned_ports() { // A DNAT'd port already sends its traffic wholesale to one device. diff --git a/projects/start-wrt/docs/src/published-ports.md b/projects/start-wrt/docs/src/published-ports.md index f6ad882247..36c1fd3ae1 100644 --- a/projects/start-wrt/docs/src/published-ports.md +++ b/projects/start-wrt/docs/src/published-ports.md @@ -69,7 +69,7 @@ Forwards created this way appear in the **Automatic** section of the Published P - The device itself creates, renews, and removes its forwards. - A forward the device stops renewing expires and is removed automatically once the lifetime the device asked for runs out — about an hour for typical clients, and never longer than a week even for a device that asks to keep the port indefinitely. - A forward is also removed once the device no longer holds the address it points at — if the device leaves the network long enough for its DHCP lease to lapse, or comes back on a different address. This keeps a forward from quietly delivering Internet traffic to whichever device is given that address next. Devices with a reserved address are unaffected. -- To stop a device from creating forwards, turn its toggle back off on the device page — or forget the device entirely. Either way its existing forwards are closed immediately, and it can no longer open new ones. +- To stop a device from creating forwards, turn its toggle back off on the device page — or forget the device entirely. Either way its existing forwards and hostname routes are closed immediately, and it can no longer open new ones. Automatic forwards survive router reboots, so a self-configured device stays reachable while the router restarts. They can never take over a port that one of your manual rules already uses — the device's request is refused instead. The reverse also holds: if you publish a port manually that an automatic forward is currently using, your manual rule wins and the automatic forward is removed. From bc30b814d57f223a042586222328823b22367c2a Mon Sep 17 00:00:00 2001 From: Shadowy Super Coder Date: Thu, 27 Aug 2026 10:41:22 -0600 Subject: [PATCH 06/14] fix(start-wrt): hairpinned SNI clients get a working connection, not a hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- projects/start-wrt/CHANGELOG.md | 7 ++ .../backend/ctrl/src/port_control.rs | 41 +++++-- .../start-wrt/docs/src/published-ports.md | 2 + .../start-core/src/tunnel/forward/sni.rs | 114 +++++++++++++++--- 4 files changed, 140 insertions(+), 24 deletions(-) diff --git a/projects/start-wrt/CHANGELOG.md b/projects/start-wrt/CHANGELOG.md index 754d7e40e2..3a3f18c4c2 100644 --- a/projects/start-wrt/CHANGELOG.md +++ b/projects/start-wrt/CHANGELOG.md @@ -63,6 +63,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 router by IP address) still reaches the router interface, accepted from exactly the sources your Remote Access setting allows, so enabling one feature never silently disables the other. + A routed hostname works from inside your own network too — a laptop on your + LAN can open the same public address and reach the device — with one + consequence worth knowing: because the device answers a local client + directly rather than through the router, the router puts its own address on + those connections, so the device cannot tell one local client from another + in its logs. Connections from another Security Profile, and from the + Internet, still carry the original address. - The UI now detects when the running firmware ships a newer interface than the page is displaying (every RPC response and `system.info` report the firmware's build stamp and the UI compares it to its own). An update diff --git a/projects/start-wrt/backend/ctrl/src/port_control.rs b/projects/start-wrt/backend/ctrl/src/port_control.rs index bd555071d2..20fd636914 100644 --- a/projects/start-wrt/backend/ctrl/src/port_control.rs +++ b/projects/start-wrt/backend/ctrl/src/port_control.rs @@ -201,6 +201,7 @@ impl PortControl { /// Requires a tokio runtime: the demux's constructor spawns its prune task. pub fn new(uci_root: PathBuf) -> Arc { Arc::new_cyclic(|weak: &std::sync::Weak| { + let hairpin_weak = weak.clone(); let weak = weak.clone(); Self { uci_root, @@ -215,11 +216,20 @@ impl PortControl { // Creation is inline in `add_sni_route` (under the write lock, // so the rule exists before any concurrent conflict scan runs); // for `(port, true)` this is just an idempotent upsert. - sni: SniDemux::with_on_change(move |port, active| { - if let Some(pc) = weak.upgrade() { - tokio::spawn(on_sni_change(pc, port, active)); - } - }), + sni: SniDemux::with_on_change( + move |port, active| { + if let Some(pc) = weak.upgrade() { + tokio::spawn(on_sni_change(pc, port, active)); + } + }, + // A routed device shares its bridge with the clients on + // that segment, so the demux needs the bridge's prefix to + // tell a hairpinning client from a routed one. + Some(Arc::new(move |ip| { + let weak = hairpin_weak.clone(); + Box::pin(async move { weak.upgrade()?.lan_prefix_for(ip).await }) + })), + ), } }) } @@ -418,15 +428,28 @@ impl PortControl { addrs } - /// The router's LAN address on the subnet containing `peer` — the address - /// SSDP advertises as the IGD location. - async fn lan_ip_for(&self, peer: Ipv4Addr) -> Option { + /// The router's own bridged address on the subnet containing `ip`. + async fn lan_addr_for(&self, ip: Ipv4Addr) -> Option { self.lan_addrs().await.iter().find_map(|a| { let mask = prefix_mask(a.prefix); - (u32::from(a.addr) & mask == u32::from(peer) & mask).then_some(a.addr) + (u32::from(a.addr) & mask == u32::from(ip) & mask).then(|| a.clone()) }) } + /// The router's LAN address on the subnet containing `peer` — the address + /// SSDP advertises as the IGD location. + async fn lan_ip_for(&self, peer: Ipv4Addr) -> Option { + self.lan_addr_for(peer).await.map(|a| a.addr) + } + + /// The prefix length of the bridged subnet containing `ip` — how the SNI + /// demux tells a hairpinning client from a routed one. Only `br-*` + /// interfaces are considered (see [`parse_lan_addrs`]), so this answers for + /// exactly the segments on which two hosts reach each other directly. + async fn lan_prefix_for(&self, ip: Ipv4Addr) -> Option { + self.lan_addr_for(ip).await.map(|a| a.prefix) + } + async fn add_forward_labeled( &self, source: SocketAddrV4, diff --git a/projects/start-wrt/docs/src/published-ports.md b/projects/start-wrt/docs/src/published-ports.md index 36c1fd3ae1..89d06c1abb 100644 --- a/projects/start-wrt/docs/src/published-ports.md +++ b/projects/start-wrt/docs/src/published-ports.md @@ -81,6 +81,8 @@ A device can also ask for a **hostname route** instead of a whole port: the rout Hostname routes appear in the same **Automatic** section with `SNI` in the Protocol column and the hostname shown alongside. They follow the same rules as other automatic forwards — per-device permission, the device renews them, they expire on their own — with two differences: a shared port is claimed whole (an ordinary forward on that port is refused while hostname routes hold it, and publishing it manually asks you to confirm), and hostname routes do not survive a router restart — the device simply re-registers them within a few minutes, so no action is needed. +A routed hostname also works from inside your own network, not only from the Internet: a laptop on your LAN can open the same public address and reach the device, with the router turning the connection around. The device sees those connections as coming from the router itself rather than from the laptop, so its own access logs and any per-client rules it applies won't tell one local device from another. Connections from a different [Security Profile](security-profiles.md), and from the Internet, still arrive with the original address intact. + Hostname routes and [Remote Access](settings.md#remote-access) can share port 443. While routes hold the port, a connection naming a routed hostname reaches its device, and everything else — including browsing the router by its IP address — still reaches the router's own interface, accepted from exactly the sources your Remote Access setting allows. Enabling one feature never disables the other. (Router SSH and an inbound VPN's port still refuse hostname routes outright — those protocols can't share a port this way.) > **A note on trust.** The PCP protocol runs over plain UDP, which carries no proof of who sent a request. The router verifies that each request actually arrives from the network the requesting device is on, so a device on one network can never open forwards on behalf of a device on another. Within a single network, though, automatic forwarding trusts the devices sharing it — exactly as UPnP and PCP do on every router, which is why it is off by default. If you run devices you don't fully trust, keep them on their own [Security Profile](security-profiles.md) so they cannot act for the devices you do. diff --git a/shared-libs/crates/start-core/src/tunnel/forward/sni.rs b/shared-libs/crates/start-core/src/tunnel/forward/sni.rs index 588e3e018f..f70d28da32 100644 --- a/shared-libs/crates/start-core/src/tunnel/forward/sni.rs +++ b/shared-libs/crates/start-core/src/tunnel/forward/sni.rs @@ -3,7 +3,8 @@ //! and splices to the internal host. TLS is never terminated; the ClientHello //! bytes are forwarded verbatim. The internal leg is opened from the client's //! own source address (source-address preservation, RFC §4.6) via -//! [`crate::net::transparent`]. +//! [`crate::net::transparent`], except where the reply would never come back +//! through this host (see [`LocalPrefix`]). //! //! QUIC (§4.5) and wildcards beyond a single leading `*` label are out of scope. @@ -12,6 +13,8 @@ use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; use std::sync::Arc; use std::time::{Duration, Instant}; +use futures::future::BoxFuture; +use ipnet::Ipv4Net; use tokio::io::{AsyncReadExt, AsyncWriteExt, copy_bidirectional}; use tokio::net::TcpStream; use tokio::time::timeout; @@ -114,27 +117,44 @@ pub struct SniRoute { /// can open/close inbound access (e.g. a StartWRT firewall ACCEPT rule). type OnChange = Box; +/// Resolves the prefix length of the local subnet holding an address, or `None` +/// where the host has no such subnet — the demux's equivalent of the DNAT +/// path's `target_prefix` (`crate::net::forward::add_forward`). +/// +/// A host supplies this when a client and a target can share a broadcast +/// segment, because a hairpinned connection between two such addresses returns +/// over that segment and never reaches this host again. Leave it unset where +/// every internal path routes through the host regardless (the tunnel's +/// hub-and-spoke WireGuard mesh), since source preservation works there. +pub type LocalPrefix = Arc BoxFuture<'static, Option> + Send + Sync>; + pub struct SniDemux { ports: Arc>>, listeners: SyncMutex>>, on_change: Option, + local_prefix: Option, } impl SniDemux { pub fn new() -> Arc { - Self::build(None) + Self::build(None, None) } - /// Like [`new`](Self::new) but invokes `on_change` on listener create/teardown. - pub fn with_on_change(on_change: impl Fn(u16, bool) + Send + Sync + 'static) -> Arc { - Self::build(Some(Box::new(on_change))) + /// Like [`new`](Self::new) but invokes `on_change` on listener + /// create/teardown and consults `local_prefix` to spot a hairpinned client. + pub fn with_on_change( + on_change: impl Fn(u16, bool) + Send + Sync + 'static, + local_prefix: Option, + ) -> Arc { + Self::build(Some(Box::new(on_change)), local_prefix) } - fn build(on_change: Option) -> Arc { + fn build(on_change: Option, local_prefix: Option) -> Arc { let this = Arc::new(Self { ports: Arc::new(SyncMutex::new(BTreeMap::new())), listeners: SyncMutex::new(BTreeMap::new()), on_change, + local_prefix, }); let weak = Arc::downgrade(&this); tokio::spawn(async move { @@ -484,7 +504,12 @@ impl SniDemux { SocketAddrV4::new(key.0, key.1).into(), )?; let ports = self.ports.clone(); - let handle = NonDetachingJoinHandle::from(tokio::spawn(run_listener(listener, key, ports))); + let handle = NonDetachingJoinHandle::from(tokio::spawn(run_listener( + listener, + key, + ports, + self.local_prefix.clone(), + ))); self.listeners.mutate(|l| { l.insert(key, handle); }); @@ -499,6 +524,7 @@ async fn run_listener( listener: tokio::net::TcpListener, key: PortKey, ports: Arc>>, + local_prefix: Option, ) { if let Err(e) = crate::net::transparent::ensure_divert_infra_once().await { tracing::warn!( @@ -510,8 +536,9 @@ async fn run_listener( match listener.accept().await { Ok((conn, peer)) => { let ports = ports.clone(); + let local_prefix = local_prefix.clone(); tokio::spawn(async move { - handle_conn(conn, peer, key, ports).await; + handle_conn(conn, peer, key, ports, local_prefix).await; }); } // Transient (EMFILE, ECONNABORTED): never tear down the listener. @@ -528,6 +555,7 @@ async fn handle_conn( peer: SocketAddr, key: PortKey, ports: Arc>>, + local_prefix: Option, ) { // Reap silently-vanished peers, else copy_bidirectional pins the fd pair forever. if let Err(e) = @@ -564,6 +592,13 @@ async fn handle_conn( let Some((target, transparent)) = selected else { return; // no match and no admissible fallback: close }; + // A client on the target's own segment is hairpinning — it dialed our + // external address from inside — and the target answers it directly over + // that segment, so a source-preserved leg leaves the client waiting on a + // reply it discards as coming from the wrong address. Dial from our own + // address there instead, the same trade the DNAT path makes with its + // hairpin masquerade (`build/lib/scripts/forward-port`). + let transparent = transparent && !is_hairpin(&local_prefix, *peer.ip(), *target.ip()).await; let mut upstream = if transparent { // Open the internal leg from the client's own source address (RFC // §4.6). No plain-connect fallback on failure: the backend gates @@ -583,7 +618,7 @@ async fn handle_conn( } } } else { - // Gateway-self fallback: plain connect to our own listener. + // A gateway-self fallback or a hairpinned client: dial as ourselves. match TcpStream::connect(SocketAddr::V4(target)).await { Ok(upstream) => upstream, Err(e) => { @@ -598,6 +633,21 @@ async fn handle_conn( let _ = copy_bidirectional(&mut conn, &mut upstream).await; } +/// Whether `peer` reaches `target` without traversing this host, because both +/// sit in the same local subnet. `false` whenever the host declines to say +/// (no [`LocalPrefix`], or no local subnet holds `target`) — source +/// preservation is the safe answer, since losing it hands every client the +/// gateway's own address. +async fn is_hairpin(local_prefix: &Option, peer: Ipv4Addr, target: Ipv4Addr) -> bool { + let Some(resolve) = local_prefix else { + return false; + }; + let Some(prefix) = resolve(target).await else { + return false; + }; + Ipv4Net::new(target, prefix).is_ok_and(|net| net.contains(&peer)) +} + /// Whether `buf` holds at least one complete TLS handshake record. fn record_complete(buf: &[u8]) -> bool { buf.len() >= 5 && buf.len() >= 5 + u16::from_be_bytes([buf[3], buf[4]]) as usize @@ -629,6 +679,7 @@ impl Default for SniDemux { ports: Arc::new(SyncMutex::new(BTreeMap::new())), listeners: SyncMutex::new(BTreeMap::new()), on_change: None, + local_prefix: None, } } } @@ -773,9 +824,10 @@ mod tests { async fn bind_failure_refuses_grant_and_rolls_back() { let events = Arc::new(SyncMutex::new(Vec::<(u16, bool)>::new())); let recorded = events.clone(); - let demux = SniDemux::with_on_change(move |port, active| { - recorded.mutate(|e| e.push((port, active))) - }); + let demux = SniDemux::with_on_change( + move |port, active| recorded.mutate(|e| e.push((port, active))), + None, + ); // Plain bind (no SO_REUSEPORT) — the demux's reuseport bind cannot join. let blocker = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); let port = blocker.local_addr().unwrap().port(); @@ -896,9 +948,10 @@ mod tests { async fn rekey_moves_bindings_and_never_fires_teardown() { let events = Arc::new(SyncMutex::new(Vec::<(u16, bool)>::new())); let recorded = events.clone(); - let demux = SniDemux::with_on_change(move |port, active| { - recorded.mutate(|e| e.push((port, active))) - }); + let demux = SniDemux::with_on_change( + move |port, active| recorded.mutate(|e| e.push((port, active))), + None, + ); let old_ip = Ipv4Addr::new(203, 0, 113, 1); let new_ip = Ipv4Addr::new(203, 0, 113, 2); let t1 = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443); @@ -953,4 +1006,35 @@ mod tests { demux.rekey_ipv4(new_ip); assert_eq!(events.peek(|e| e.len()), before); } + + // Only a client sharing the target's subnet is hairpinning. A client on + // another of the gateway's subnets still routes its replies through the + // gateway, so it keeps source preservation, and so does everyone when the + // host supplies no prefix or knows no subnet holding the target. + #[tokio::test] + async fn hairpin_is_scoped_to_the_targets_own_subnet() { + let lan = Ipv4Net::new(Ipv4Addr::new(192, 168, 1, 0), 24).unwrap(); + let resolver: LocalPrefix = Arc::new(move |ip: Ipv4Addr| { + Box::pin(async move { lan.contains(&ip).then_some(lan.prefix_len()) }) + as BoxFuture<'static, Option> + }); + let host = Some(resolver); + let target = Ipv4Addr::new(192, 168, 1, 10); + + assert!(is_hairpin(&host, Ipv4Addr::new(192, 168, 1, 50), target).await); + // Another VLAN, and the WAN: both reach the target through us. + assert!(!is_hairpin(&host, Ipv4Addr::new(192, 168, 9, 50), target).await); + assert!(!is_hairpin(&host, Ipv4Addr::new(203, 0, 113, 50), target).await); + // No local subnet holds the target — the host cannot say, so preserve. + assert!( + !is_hairpin( + &host, + Ipv4Addr::new(10, 0, 0, 5), + Ipv4Addr::new(10, 0, 0, 9) + ) + .await + ); + // A host that supplies no prefix at all preserves unconditionally. + assert!(!is_hairpin(&None, Ipv4Addr::new(192, 168, 1, 50), target).await); + } } From d726068624f0a4ee6fc584e19e79be61403b41f3 Mon Sep 17 00:00:00 2001 From: Shadowy Super Coder Date: Thu, 27 Aug 2026 11:02:17 -0600 Subject: [PATCH 07/14] docs(start-wrt): a hostname route is reachable from every Security Profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- projects/start-wrt/docs/src/published-ports.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/start-wrt/docs/src/published-ports.md b/projects/start-wrt/docs/src/published-ports.md index 89d06c1abb..a380d19b08 100644 --- a/projects/start-wrt/docs/src/published-ports.md +++ b/projects/start-wrt/docs/src/published-ports.md @@ -85,7 +85,7 @@ A routed hostname also works from inside your own network, not only from the Int Hostname routes and [Remote Access](settings.md#remote-access) can share port 443. While routes hold the port, a connection naming a routed hostname reaches its device, and everything else — including browsing the router by its IP address — still reaches the router's own interface, accepted from exactly the sources your Remote Access setting allows. Enabling one feature never disables the other. (Router SSH and an inbound VPN's port still refuse hostname routes outright — those protocols can't share a port this way.) -> **A note on trust.** The PCP protocol runs over plain UDP, which carries no proof of who sent a request. The router verifies that each request actually arrives from the network the requesting device is on, so a device on one network can never open forwards on behalf of a device on another. Within a single network, though, automatic forwarding trusts the devices sharing it — exactly as UPnP and PCP do on every router, which is why it is off by default. If you run devices you don't fully trust, keep them on their own [Security Profile](security-profiles.md) so they cannot act for the devices you do. +> **A note on trust.** The PCP protocol runs over plain UDP, which carries no proof of who sent a request. The router verifies that each request actually arrives from the network the requesting device is on, so a device on one network can never open forwards on behalf of a device on another. Within a single network, though, automatic forwarding trusts the devices sharing it — exactly as UPnP and PCP do on every router, which is why it is off by default. If you run devices you don't fully trust, keep them on their own [Security Profile](security-profiles.md) so they cannot act for the devices you do. A profile governs which devices can reach one another directly; a hostname route is served by the router itself, so it stays reachable from every profile — the same exposure the service already has on the Internet. ## Endpoints From 454220c7d77fccca60715e9788494cbeec456eb3 Mon Sep 17 00:00:00 2001 From: Shadowy Super Coder Date: Mon, 31 Aug 2026 17:09:07 -0600 Subject: [PATCH 08/14] docs(rfcs): UPnP vendor-action status claims only Start9-internal acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- rfcs/upnp-vendor-hostname-action.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rfcs/upnp-vendor-hostname-action.md b/rfcs/upnp-vendor-hostname-action.md index 19490971c7..750d147537 100644 --- a/rfcs/upnp-vendor-hostname-action.md +++ b/rfcs/upnp-vendor-hostname-action.md @@ -1,7 +1,9 @@ # UPnP Vendor-Defined Action for SNI Hostname Mappings -Status: accepted; implemented (server + StartOS client, branch -`wrt/upnp-hostname-action`). +Status: accepted as a Start9-internal design; implemented (server + StartOS +client, branch `wrt/upnp-hostname-action`). Not a standards submission: unlike +the PCP companion (`draft-start9-pcp-hostname`), a UPnP vendor-defined action +has no standards venue — the SCPD is the external documentation. A second front door onto the existing SNI demux: today a client binds a hostname to a shared external port only over PCP (the `HOSTNAME` private-use option, From 6f5914b9021444d40bfb3f0bb87e18b0489d2d65 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:01:55 +0000 Subject: [PATCH 09/14] fix: address SNI hostname route review findings --- .../start-tunnel/docs/src/published-ports.md | 2 +- projects/start-wrt/API_CONTRACT.md | 62 +- projects/start-wrt/CHANGELOG.md | 2 +- .../start-wrt/backend/ctrl/src/bins/daemon.rs | 59 +- .../start-wrt/backend/ctrl/src/devices.rs | 37 +- .../backend/ctrl/src/port_control.rs | 754 +++++++++--------- .../backend/ctrl/src/published_ports.rs | 235 +++--- projects/start-wrt/backend/ctrl/src/system.rs | 6 +- .../backend/hotplug/99-startwrt-port-control | 4 +- .../nftables/12-startwrt-sni-divert.nft | 16 +- .../start-wrt/backend/uciedit/src/openwrt.rs | 6 +- projects/start-wrt/build/openwrt.diffconfig | 3 +- .../start-wrt/docs/src/published-ports.md | 4 +- .../web/src/app/i18n/dictionaries/de.ts | 1 + .../web/src/app/i18n/dictionaries/en.ts | 1 + .../web/src/app/i18n/dictionaries/es.ts | 1 + .../web/src/app/i18n/dictionaries/fr.ts | 1 + .../web/src/app/i18n/dictionaries/pl.ts | 1 + .../{auto-table.ts => automatic-table.ts} | 44 +- .../src/app/routes/published-ports/index.ts | 8 +- .../src/app/routes/published-ports/service.ts | 87 +- .../src/app/routes/published-ports/types.ts | 47 +- .../web/src/app/services/api/api.service.ts | 50 +- .../src/app/services/api/live-api.service.ts | 4 +- .../src/app/services/api/mock-api.service.ts | 50 +- rfcs/draft-start9-upnp-hostname.md | 377 +++++++++ rfcs/upnp-vendor-hostname-action.md | 336 -------- .../start-core/src/net/port_map/client.rs | 124 +-- .../start-core/src/net/port_map/pcp/mod.rs | 4 +- .../start-core/src/net/port_map/server/igd.rs | 253 +++--- .../start-core/src/net/port_map/upnp.rs | 73 +- .../crates/start-core/src/net/transparent.rs | 106 +-- .../crates/start-core/src/tunnel/context.rs | 179 +++-- .../start-core/src/tunnel/forward/pcp.rs | 219 +++-- .../start-core/src/tunnel/forward/sni.rs | 263 +++--- 35 files changed, 1714 insertions(+), 1705 deletions(-) rename projects/start-wrt/web/src/app/routes/published-ports/{auto-table.ts => automatic-table.ts} (61%) create mode 100644 rfcs/draft-start9-upnp-hostname.md delete mode 100644 rfcs/upnp-vendor-hostname-action.md diff --git a/projects/start-tunnel/docs/src/published-ports.md b/projects/start-tunnel/docs/src/published-ports.md index f191b1df38..a0358644fc 100644 --- a/projects/start-tunnel/docs/src/published-ports.md +++ b/projects/start-tunnel/docs/src/published-ports.md @@ -20,7 +20,7 @@ Because each device has its own IPv6 address, two different devices can both pub ## Manual and automatic ports -The `Published Ports` page shows two tables: **Manual** ports you added by hand, and **Automatic** ports opened by connected devices via PCP/UPnP. A row's **External IP** is your VPS's public IPv4 (a v4 published port) or the device's IPv6 GUA (a v6 pinhole). You can enable, disable, or remove either; automatic ports have no editable label (they're owned by the device that created them) and may be re-created if you remove one while the device still wants it. Manual ports are persistent — they stay until you delete them. Automatic ports are lease-based: one that stops being renewed (its device went offline or no longer wants the port) expires and is removed on its own. +The `Published Ports` page shows two tables: **Manual** ports you added by hand, and **Automatic** port uses created by connected devices. Automatic entries include PCP/UPnP port mappings and SNI hostname routes; their kind identifies which one they are. A row's **External IP** is your VPS's public IPv4 (a v4 published port) or the device's IPv6 GUA (a v6 pinhole). You can enable, disable, or remove either; automatic entries are owned by the device that created them and may be re-created if you remove one while the device still wants it. Manual ports are persistent — they stay until you delete them. Automatic port uses are lease-based: one that stops being renewed (its device went offline or no longer wants the port) expires and is removed on its own. Deleting a device or demoting it to a client clears all of its published ports (manual and automatic, IPv4 and IPv6). Turning off **auto-publish** for a device clears its automatic ports but leaves any you added by hand. diff --git a/projects/start-wrt/API_CONTRACT.md b/projects/start-wrt/API_CONTRACT.md index 4914542029..e97359d41c 100644 --- a/projects/start-wrt/API_CONTRACT.md +++ b/projects/start-wrt/API_CONTRACT.md @@ -943,9 +943,9 @@ struct PublishedPort { ipv4_public_port: Option, /// "any" or CIDR like "203.0.113.0/24" source: String, - /// The user confirmed capturing a port the router answers on itself (see + /// The user confirmed capturing a WAN port already in use (see /// `published-ports.set`); round-tripped so later saves don't re-prompt. - override_router_ports: bool, + override_wan_ports: bool, // --- Enriched by backend --- status: PublishedPortStatus, status_reason: Option, @@ -974,11 +974,11 @@ struct PublishedPortInput { ipv6: bool, ipv4_public_port: Option, source: String, - /// Confirms forwarding a port the router itself answers on from the WAN - /// (persisted as `_pp_router_override` on the redirect and round-tripped - /// through `list`, so the confirmation is asked once per port). + /// Confirms forwarding a WAN port already used by a router service or + /// hostname route. Round-tripped through `list` so confirmation is asked + /// once per port. #[serde(default)] - override_router_ports: bool, + override_wan_ports: bool, } #[derive(Deserialize)] @@ -987,15 +987,13 @@ struct PublishedPortsSetRequest { } #[derive(Serialize)] -struct RouterPortCollision { +struct WanPortCollision { id: String, label: String, /// The colliding router-service port spec(s), e.g. ["443", "22"]. - router_ports: Vec, - /// Colliding ports whose holder is a device's SNI hostname routes rather - /// than a router service — informational, so the dialog names the actual - /// use; the override semantics are the same. - sni_ports: Vec, + router_service_ports: Vec, + /// Colliding ports held by hostname routes. + hostname_route_ports: Vec, } #[derive(Serialize)] @@ -1009,28 +1007,26 @@ struct SniPortUse { #[derive(Serialize)] struct PublishedPortsSetResult { - pending_router_port_collisions: Vec, + pending_wan_port_collisions: Vec, } // Backend: rebuilds firewall redirect+rule sections, resolves device IPs, restarts firewall ``` The request is applied **unless** an enabled IPv4 forward without -`override_router_ports` would capture a port the router itself answers on from -the WAN — a live input-chain ACCEPT rule: Remote Access (80/443/22, including +`override_wan_ports` would capture a port already in use on the WAN — a live input-chain ACCEPT rule: Remote Access (80/443/22, including "When behind NAT" mode whenever the WAN address is private) or the VPN server's listen port. nftables applies prerouting DNAT before the routing decision, so such a forward silently diverts those router services to the device (issue #3451). In that case nothing is applied and -`pending_router_port_collisions` names the offending ports; the UI shows a -confirmation dialog and re-saves with `override_router_ports: true` on the -named ports. An empty list in the response means the request was applied. +`pending_wan_port_collisions` names the offending ports; the UI shows a +confirmation dialog and re-saves with `override_wan_ports: true` on the named +ports. An empty list in the response means the request was applied. A colliding port whose WAN-input rule is an SNI-demux admit rule is reported -under `sni_ports` instead of `router_ports`, with the routed hostnames and -owning devices filled in from the live demux — the real holder is a device's +under `hostname_route_ports` instead of `router_service_ports`, with the routed +hostnames and owning devices filled in from the live demux — the real holder is a device's hostname routes, and the dialog names them rather than blaming the router. -The split is informational only: the override works identically, and after an -override the displaced hostname routes are refused at renewal and expire -within their lease. +The split is informational only: the override works identically and removes +the displaced hostname routes when the manual rule is saved. Detection is transport-aware (Remote Access is TCP, WireGuard is UDP — a UDP-only forward on 443 collides with nothing) and skipped in configs-only mode (the CLI editor confirms implicitly, like `ethernet.set` / `wifi.set`). @@ -1060,8 +1056,8 @@ serve as a last-resort reconcile fallback. ### `published-ports.auto-list` -Automatic forwards created by authorized LAN devices themselves via PCP or UPnP -IGD (the server half of StartOS's gateway autoconfiguration). Read-only from +Automatic port uses created by authorized LAN devices themselves via PCP or +UPnP IGD (the server half of StartOS's gateway autoconfiguration). Read-only from the UI: the requesting device creates, renews, and withdraws them; unrenewed forwards expire on the lifetime the protocol granted the client (the daemon sweeps leases every minute). Distinct from manual published ports — they never @@ -1088,15 +1084,11 @@ leave the port open with nothing serving it. // Request: {} #[derive(Serialize)] -struct AutoForward { - /// UCI section name (`apf__`), stable per (device, external - /// port) — UPnP's mapping identity, so one device may hold several - /// external ports to the same internal port. SNI rows use the synthetic - /// `sni__` instead. +struct AutomaticPortUse { + /// Stable per automatic port use. id: String, - /// Which protocol created it: "PCP" or "UPnP" — or "SNI" for a hostname - /// route (created over either protocol). - label: String, + /// "PCP", "UPnP", or "SNI" for a hostname route. + kind: String, /// May be empty on an SNI row whose target address matches no known device. device_mac: String, device_name: Option, @@ -1112,8 +1104,8 @@ struct AutoForward { /// TLS-SNI hostname for an SNI route; None for plain forwards. hostname: Option, } -// Response: Vec -// Backend: reads `_apf_*`-tagged firewall redirects plus the SNI demux's live +// Response: Vec +// Backend: reads automatic firewall redirects plus the SNI demux's live // routes; names enriched from DHCP host entries and the persistent // device-name cache. ``` diff --git a/projects/start-wrt/CHANGELOG.md b/projects/start-wrt/CHANGELOG.md index 3a3f18c4c2..7359db3445 100644 --- a/projects/start-wrt/CHANGELOG.md +++ b/projects/start-wrt/CHANGELOG.md @@ -36,7 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 back off — or forgetting the device — closes that device's forwards and hostname routes immediately. The Published Ports page gains a read-only "Automatic" section showing each - forward's device, protocol, and expiry. UPnP clients see a complete gateway: + port use's device, kind (PCP, UPnP, or SNI), and expiry. UPnP clients see a complete gateway: the router advertises the `WANCommonInterfaceConfig` service clients use to recognize an Internet Gateway Device, answers the status actions they check before mapping anything, and supports reading mappings back diff --git a/projects/start-wrt/backend/ctrl/src/bins/daemon.rs b/projects/start-wrt/backend/ctrl/src/bins/daemon.rs index 06e345af49..a831259a3d 100644 --- a/projects/start-wrt/backend/ctrl/src/bins/daemon.rs +++ b/projects/start-wrt/backend/ctrl/src/bins/daemon.rs @@ -197,6 +197,17 @@ impl Visit for WebserverListener { } } +fn require_divert_config( + result: Result<(), startos::net::transparent::DivertConfig>, +) -> Result<(), Error> { + result.map_err(|config| { + Error::new( + eyre!("SNI divert config rejected: {config:?}"), + ErrorKind::Network, + ) + }) +} + #[instrument(skip_all)] async fn inner_main() -> Result<(), Error> { // Generate local auth cookie so CLI commands over SSH bypass session auth @@ -346,22 +357,16 @@ async fn inner_main() -> Result<(), Error> { let tls_ready = init_ssl().await; if !setup_mode { - // Port-control servers (PCP + UPnP IGD): automatic port forwarding for - // per-device-authorized LAN clients. After init_ssl so the IGD device - // UUID (derived from the root CA) is stable. - // - // SNI-demux divert parameters, before anything can run the demux: the - // nft mark rule ships as an fw4 include (12-startwrt-sni-divert.nft, - // `mark or` — hence the masked match), so only the iproute2 half is - // managed in-process; table 5344 clears the VLAN-tag (1-4094) table - // namespace; priority 49 sits below the 100/150/200 rule ladder. - let _ = - startos::net::transparent::set_divert_config(startos::net::transparent::DivertConfig { + // The IGD UUID derives from the initialized root CA. + // Configure reply diversion before constructing the SNI demux. + require_divert_config(startos::net::transparent::set_divert_config( + startos::net::transparent::DivertConfig { route_table: 5344, rule_priority: 49, masked_fwmark: true, manage_nft: false, - }); + }, + ))?; let pc = crate::port_control::PortControl::new("/etc/config".into()); if crate::port_control::PORT_CONTROL.set(pc.clone()).is_ok() { tokio::spawn(crate::port_control::run(pc)); @@ -446,19 +451,7 @@ async fn inner_main() -> Result<(), Error> { .layer(Extension(proxy_client)) .layer(Extension(app_state)); - // Build the listener map. start-os's `WebServer` provides the connection- - // lifecycle defenses we used to need to hand-roll: TCP keepalive on each - // accepted socket, HTTP/2 PING keepalives (25s/300s), accept retry with - // backoff on transient errors (EMFILE/ENFILE), GracefulShutdown tracking - // of in-flight connections, and RFC 8441 extended CONNECT for h2 - // WebSocket upgrades. `TlsListener` adds slow-loris-resistant handshake - // timeouts (5s ClientHello, 15s full handshake) and runs each handshake - // in a per-connection task so a stalled client cannot block accept. - // SO_REUSEPORT on the UI wildcards: the SNI demux binds `(wan_ip, 443)` - // *specific* alongside these when hostname routes share the port, and a - // reuseport group admits a member only if every socket on the port opted - // in. TCP delivery prefers the most-specific bound address, so the demux - // takes WAN-IP-destined connections and these wildcards keep the LAN. + // WAN-specific demux listeners require every wildcard listener to use SO_REUSEPORT. let http_addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], 80)); let http_listener = startos::net::utils::bind_tokio_listener_reuse_port(http_addr) .with_kind(ErrorKind::Network)?; @@ -495,6 +488,22 @@ async fn inner_main() -> Result<(), Error> { Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejected_divert_config_fails_startup() { + let rejected = startos::net::transparent::DivertConfig { + route_table: 5344, + rule_priority: 49, + masked_fwmark: true, + manage_nft: false, + }; + assert!(require_divert_config(Err(rejected)).is_err()); + } +} + pub fn main(_args: VecDeque) { init_logging("startwrt-ctrld"); tracing::info!("startwrt-ctrld starting (luci proxy v10)"); diff --git a/projects/start-wrt/backend/ctrl/src/devices.rs b/projects/start-wrt/backend/ctrl/src/devices.rs index fa83af61b5..f8a3740e0c 100644 --- a/projects/start-wrt/backend/ctrl/src/devices.rs +++ b/projects/start-wrt/backend/ctrl/src/devices.rs @@ -1795,6 +1795,18 @@ pub async fn update( } } +fn static_ips_for_mac(dhcp: &uciedit::Config<'_>, mac: &str) -> Vec { + dhcp.sections + .iter() + .filter_map(|section| { + let host = section.get::().ok()?; + (host.mac.eq_ignore_ascii_case(mac)) + .then_some(host.ip) + .flatten() + }) + .collect() +} + #[instrument(skip_all)] pub async fn forget( ctx: C, @@ -1805,8 +1817,8 @@ pub async fn forget( loop { let arena = Arena::new(); let mut cfgs = parse_all(ctx.uci_root(), &arena, &["dhcp"]).await?; + let removed_static_ips = static_ips_for_mac(&cfgs["dhcp"], &mac_upper); - // Remove DHCP host cfgs["dhcp"].sections.retain(|section| { if let Ok(host) = section.get::() { if host.mac.to_uppercase() == mac_upper { @@ -1840,11 +1852,7 @@ pub async fn forget( None, ); crate::device_names::forget(&mac_upper).await; - // Forgetting a device drops the `_allow_pcp` flag with its DHCP - // host entry, so it can no longer create forwards — but the - // ones it already holds are ordinary firewall sections that - // would otherwise stay open until their leases lapse. - crate::port_control::close_device_forwards(&mac_upper).await; + crate::port_control::close_device_forwards(&mac_upper, &removed_static_ips).await; // Drop the mDNS attempt history too: a forgotten device that // reconnects "appears as a new entry" (per the user docs), so // it starts a fresh retry schedule. @@ -2071,6 +2079,23 @@ fn days_to_ymd(days: u64) -> (u64, u64, u64) { mod tests { use super::*; + #[tokio::test] + async fn forget_preserves_removed_static_ip_for_route_reaping() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("dhcp"), + "config host\n\toption mac 'AA:BB:CC:DD:EE:FF'\n\toption ip '192.168.1.50'\n", + ) + .unwrap(); + let arena = Arena::new(); + let cfgs = parse_all(dir.path(), &arena, &["dhcp"]).await.unwrap(); + + assert_eq!( + static_ips_for_mac(&cfgs["dhcp"], "aa:bb:cc:dd:ee:ff"), + vec!["192.168.1.50"] + ); + } + #[test] fn mdns_retry_schedule() { // Never attempted: eligible immediately. diff --git a/projects/start-wrt/backend/ctrl/src/port_control.rs b/projects/start-wrt/backend/ctrl/src/port_control.rs index 20fd636914..14eb2b1393 100644 --- a/projects/start-wrt/backend/ctrl/src/port_control.rs +++ b/projects/start-wrt/backend/ctrl/src/port_control.rs @@ -40,29 +40,9 @@ //! //! # SNI hostname routes //! -//! Besides plain forwards, an authorized device can register TLS-SNI hostname -//! routes on a shared external port (PCP HOSTNAME options, or the UPnP -//! `X_START9_AddHostnameMapping` vendor action): the shared [`SniDemux`] reads -//! each connection's ClientHello on the WAN address and splices it to whichever -//! device owns that hostname, so several devices share one port. Unlike plain -//! forwards, these routes are *not* UCI sections: they live in demux memory -//! with the finite lease both protocols grant (≤1h, self-expiring — the demux -//! prunes them, not the sweep), and a daemon restart drops them until the -//! client's next re-assertion. What does touch UCI is admission: each demuxed -//! port gets a WAN-input ACCEPT rule (`apf_sni_`, tagged -//! `_apf_label 'SNI'`) so fw4 lets the listener's traffic in — written inline -//! with registration, removed by the demux's `on_change` teardown callback, -//! healed by the sweep, and purged at daemon start (rules must not outlive the -//! in-memory routes they admit). That rule also makes the port read as -//! router-reserved, so plain forwards can't grab a demuxed port out from under -//! its hostnames. Conversely a hostname route is refused on a port already -//! DNAT-forwarded or answered by the router itself (Remote Access, VPN) — the -//! demux's specific `(wan_ip, port)` bind would beat the router service's -//! wildcard bind and capture traffic it has no route for. The reply-path -//! divert's nft half ships declaratively as an fw4 include -//! (`/etc/nftables.d/12-startwrt-sni-divert.nft`); the iproute2 half is -//! configured at daemon init (`set_divert_config`) and re-asserted by the -//! demux. +//! Hostname routes live in [`SniDemux`] memory and share one IPv4 WAN admission +//! rule per port. The sweep reconciles those rules and re-keys routes when the +//! WAN address changes. Protocol leases bound every device-created route. //! //! # LAN source-address spoofing: cross-segment closed, same-segment open //! @@ -118,6 +98,7 @@ use nix::sys::socket::{recvmsg, setsockopt, ControlMessageOwned, MsgFlags, Socka use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use startos::net::port_map::pcp::hostname::RESULT_HOSTNAME_TAKEN; +use startos::net::port_map::pcp::RESULT_NO_RESOURCES; use startos::net::port_map::server::igd::{ format_uuid, handle_control, header_value, render_root_desc, serve_static, ssdp_response, st_matches, CIF_SCPD, CIF_SCPD_PATH, CONTROL_PATH, IGD_HTTP_PORT, ROOT_DESC_PATH, SCPD, @@ -151,11 +132,9 @@ const WAN_CACHE_TTL: Duration = Duration::from_secs(30); const LAN_CACHE_TTL: Duration = Duration::from_secs(30); const UCI_RETRIES: usize = 4; -pub const LABEL_PCP: &str = "PCP"; -pub const LABEL_UPNP: &str = "UPnP"; -/// `_apf_label` tag on the WAN-input ACCEPT rules admitting SNI-demux -/// listeners (and the `label` of SNI rows in `auto-list`). -pub const LABEL_SNI: &str = "SNI"; +pub const KIND_PCP: &str = "PCP"; +pub const KIND_UPNP: &str = "UPnP"; +pub const KIND_SNI: &str = "SNI"; // UPnP IGD error codes, reused verbatim by the shared PCP core. const IGD_ACTION_FAILED: u16 = 501; @@ -179,7 +158,6 @@ pub struct PortControl { lan_cache: Mutex>)>>, /// Requesting IP → resolved+authorized device (None = unauthorized). client_cache: Mutex)>>, - /// SNI hostname-route dataplane; see the module doc. sni: Arc, } @@ -211,20 +189,13 @@ impl PortControl { wan_cache: Mutex::new(None), lan_cache: Mutex::new(None), client_cache: Mutex::new(HashMap::new()), - // The teardown half of the ACCEPT-rule lifecycle: expiry/ - // unregister reaps a port's listener and this drops its rule. - // Creation is inline in `add_sni_route` (under the write lock, - // so the rule exists before any concurrent conflict scan runs); - // for `(port, true)` this is just an idempotent upsert. + // Reconcile all live ports because callbacks may arrive out of order. sni: SniDemux::with_on_change( move |port, active| { if let Some(pc) = weak.upgrade() { tokio::spawn(on_sni_change(pc, port, active)); } }, - // A routed device shares its bridge with the clients on - // that segment, so the demux needs the bridge's prefix to - // tell a hairpinning client from a routed one. Some(Arc::new(move |ip| { let weak = hairpin_weak.clone(); Box::pin(async move { weak.upgrade()?.lan_prefix_for(ip).await }) @@ -247,8 +218,7 @@ impl PortControl { self.client_cache.lock().unwrap().clear(); } - /// Stop tracking leases for sections something else removed from UCI - /// (manual published-ports taking over an auto-held external port). + /// Stop tracking leases for sections removed outside port control. pub fn forget_leases(&self, sections: &[String]) { let mut leases = self.leases.lock().unwrap(); for section in sections { @@ -271,6 +241,10 @@ impl PortControl { crate::published_ports::reload_firewall(); } + pub(crate) async fn lock_writes(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.write_serial.lock().await + } + /// Resolve the requesting IP to an authorized device: a neighbor-table /// entry on a LAN bridge, whose MAC has an `_allow_pcp`-flagged DHCP host /// entry. `None` = unknown or not authorized. Both outcomes are cached @@ -313,14 +287,8 @@ impl PortControl { allowed.then_some(Client { mac, iface }) } - /// Drop every hostname route whose target address now belongs to a device - /// without the automatic-port-forwarding permission. Routes live in the - /// demux keyed by address, not by device, so the owner is re-derived the - /// way a grant derives it — the neighbor table, else the DHCP leases and - /// static hosts — and a target that resolves to no device at all is left - /// alone, its lease still bounding it. Runs on every permission revocation - /// and once a minute from the sweep, which also catches an address handed - /// on to a different, unauthorized device. + /// Drops hostname routes attributed to unauthorized devices. + /// Unattributed routes expire with their leases. pub(crate) async fn reap_unauthorized_sni_routes(&self) { let routes = self.sni.snapshot(); if routes.is_empty() { @@ -341,7 +309,7 @@ impl PortControl { return; } }; - // The neighbor table is what a grant trusted, so it outranks a lease. + // Live neighbors override DHCP records. if let Some(neigh) = tokio::process::Command::new("ip") .args(["neigh", "show"]) .invoke(ErrorKind::Network.into()) @@ -377,7 +345,9 @@ impl PortControl { } { let _serial = self.write_serial.lock().await; - self.sync_sni_rules().await; + if let Err(e) = self.sync_sni_rules().await { + tracing::warn!("port-control: reconciling SNI admission failed: {e}"); + } } let wans: std::collections::BTreeSet = doomed.iter().map(|r| r.ext_ip).collect(); for wan in wans { @@ -400,8 +370,7 @@ impl PortControl { ip } - /// Drop the cached WAN address so the next read sees a change immediately - /// (the wan hotplug hook fires this ahead of an SNI re-key). + /// Forces the next WAN lookup to bypass the cache. pub fn invalidate_wan(&self) { *self.wan_cache.lock().unwrap() = None; } @@ -428,7 +397,7 @@ impl PortControl { addrs } - /// The router's own bridged address on the subnet containing `ip`. + /// The router address on the matching bridged subnet. async fn lan_addr_for(&self, ip: Ipv4Addr) -> Option { self.lan_addrs().await.iter().find_map(|a| { let mask = prefix_mask(a.prefix); @@ -436,28 +405,24 @@ impl PortControl { }) } - /// The router's LAN address on the subnet containing `peer` — the address - /// SSDP advertises as the IGD location. + /// The IGD address advertised to the peer. async fn lan_ip_for(&self, peer: Ipv4Addr) -> Option { self.lan_addr_for(peer).await.map(|a| a.addr) } - /// The prefix length of the bridged subnet containing `ip` — how the SNI - /// demux tells a hairpinning client from a routed one. Only `br-*` - /// interfaces are considered (see [`parse_lan_addrs`]), so this answers for - /// exactly the segments on which two hosts reach each other directly. + /// The prefix length of the matching bridged subnet. async fn lan_prefix_for(&self, ip: Ipv4Addr) -> Option { self.lan_addr_for(ip).await.map(|a| a.prefix) } - async fn add_forward_labeled( + async fn add_forward_for_kind( &self, source: SocketAddrV4, target: SocketAddrV4, count: u16, peer: Ipv4Addr, lifetime: Option, - label: &'static str, + kind: &'static str, ) -> Result<(), u16> { if count == 0 || source.port().checked_add(count - 1).is_none() @@ -465,8 +430,7 @@ impl PortControl { { return Err(IGD_ACTION_FAILED); } - // The shared cores force target = peer; re-assert it here so a future - // caller can't route one device's traffic to another. + // The forwarding target must be the requesting peer. if *target.ip() != peer { return Err(IGD_ACTION_FAILED); } @@ -481,7 +445,7 @@ impl PortControl { apply_forward_uci( &uci_root, §ion_task, - label, + kind, &client.mac, Some(&client.iface), source, @@ -504,7 +468,7 @@ impl PortControl { } ApplyOutcome::Written => { tracing::info!( - "port-control: {label} forward {}#{count} -> {target} ({section})", + "port-control: {kind} forward {}#{count} -> {target} ({section})", source ); self.bump_lease(section, lease); @@ -618,13 +582,13 @@ impl PortControl { .iter() .filter_map(|sec| { let r = sec.get::().ok()?; - let label = r._apf_label?; + let kind = r._apf_label?; if r.dest_ip.as_deref() != Some(target.as_str()) { return None; } let external = r.src_dport.as_deref().and_then(parse_port_range)?.0; let internal = r.dest_port.as_deref().and_then(parse_port_range)?.0; - Some((sec.name()?.to_string(), external, internal, label)) + Some((sec.name()?.to_string(), external, internal, kind)) }) .collect()) }) @@ -638,7 +602,7 @@ impl PortControl { let leases = self.leases.lock().unwrap(); found .into_iter() - .flat_map(|(section, external_port, internal_port, label)| { + .flat_map(|(section, external_port, internal_port, kind)| { // An untracked section (fresh boot) reports 0 = permanent // rather than "already expired". let lease_seconds = leases @@ -651,7 +615,7 @@ impl PortControl { external_port, internal: SocketAddrV4::new(peer, internal_port), protocol, - description: format!("Auto forward ({label})"), + description: format!("Automatic port use ({kind})"), lease_seconds, }) }) @@ -746,13 +710,7 @@ impl PortControl { Ok(()) } - /// Register SNI hostname routes on `source` for `target` (the shared cores - /// force `target` to the requesting device and gate on `is_known_client` - /// before we're called). Refuses a port whose traffic already has another - /// consumer — see [`sni_port_conflicts`]. The write lock is held across - /// scan → demux registration → admit-rule write, so a concurrent plain - /// forward can't DNAT the port between the scan and the rule landing (and - /// its own scan sees our rule as router-reserved). + /// Refuses a grant unless its listener and WAN admission both succeed. async fn add_sni_route( &self, source: SocketAddrV4, @@ -771,32 +729,132 @@ impl PortControl { .await .map_err(|e| { tracing::warn!("port-control: SNI conflict scan failed: {e}"); - RESULT_HOSTNAME_TAKEN + RESULT_NO_RESOURCES })?; if conflicts { return Err(RESULT_HOSTNAME_TAKEN); } + + let existing: std::collections::BTreeSet<_> = self + .sni + .snapshot() + .into_iter() + .filter(|route| { + route.ext_ip == *source.ip() + && route.ext_port == port + && route.target == target + && hostnames.contains(&route.hostname) + }) + .map(|route| route.hostname) + .collect(); self.sni .register(*source.ip(), port, hostnames, target, lifetime)?; + + if let Err(e) = self.sync_sni_rules().await { + let added: Vec<_> = hostnames + .iter() + .filter(|hostname| !existing.contains(*hostname)) + .cloned() + .collect(); + if !added.is_empty() { + self.sni.unregister(*source.ip(), port, &added, target); + } + if let Err(rollback) = self.sync_sni_rules().await { + tracing::warn!("port-control: rolling back SNI admission failed: {rollback}"); + } + tracing::warn!("port-control: SNI admission failed: {e}"); + return Err(RESULT_NO_RESOURCES); + } + tracing::info!( "port-control: SNI route(s) {hostnames:?} on {source} -> {target} \ (lease {lifetime:?}s)" ); - // Best-effort: the demux routes regardless, and the sweep heals a - // missed rule within a minute. - self.sync_sni_rules().await; self.sync_sni_fallback(*source.ip()).await; Ok(()) } - /// Keep the demux's remote-access fallback in step with the Remote Access - /// setting: while hostname routes share 443 — the one port both the demux - /// and the router's WAN UI can serve — no-SNI/unknown-SNI connections pipe - /// to the router's own UI listener, scoped to the same sources the - /// setting's firewall rules admit, so taking the port never breaks (or - /// widens) remote access. Registered on route add and a Remote Access - /// change, swept once a minute as the backstop (which also clears it once - /// the last 443 route expires). + async fn remove_sni_route( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + hostnames: &[String], + ) { + let _serial = self.write_serial.lock().await; + self.sni + .unregister(*source.ip(), source.port(), hostnames, target); + if let Err(e) = self.sync_sni_rules().await { + tracing::warn!("port-control: reconciling SNI admission failed: {e}"); + } + self.sync_sni_fallback(*source.ip()).await; + } + + async fn remove_sni_routes_for_ips(&self, ips: &[String]) { + if ips.is_empty() { + return; + } + let routes: Vec = self + .sni + .snapshot() + .into_iter() + .filter(|route| ips.iter().any(|ip| ip == &route.target.ip().to_string())) + .collect(); + if routes.is_empty() { + return; + } + let _serial = self.write_serial.lock().await; + for route in &routes { + self.sni.unregister( + route.ext_ip, + route.ext_port, + std::slice::from_ref(&route.hostname), + route.target, + ); + } + if let Err(e) = self.sync_sni_rules().await { + tracing::warn!("port-control: reconciling SNI admission failed: {e}"); + } + let wans: std::collections::BTreeSet = + routes.iter().map(|route| route.ext_ip).collect(); + for wan in wans { + self.sync_sni_fallback(wan).await; + } + } + + pub(crate) async fn displace_sni_routes(&self, ranges: &[(u16, u16)]) { + let routes: Vec = self + .sni + .snapshot() + .into_iter() + .filter(|route| { + ranges + .iter() + .any(|range| ranges_overlap(*range, (route.ext_port, route.ext_port))) + }) + .collect(); + if routes.is_empty() { + return; + } + let _serial = self.write_serial.lock().await; + for route in &routes { + self.sni.unregister( + route.ext_ip, + route.ext_port, + std::slice::from_ref(&route.hostname), + route.target, + ); + } + if let Err(e) = self.sync_sni_rules().await { + tracing::warn!("port-control: reconciling SNI admission failed: {e}"); + } + let wans: std::collections::BTreeSet = + routes.iter().map(|route| route.ext_ip).collect(); + for wan in wans { + self.sync_sni_fallback(wan).await; + } + } + + /// Mirrors Remote Access source policy for unknown SNI on port 443. pub(crate) async fn sync_sni_fallback(&self, wan: Ipv4Addr) { let has_443 = self .sni @@ -811,10 +869,16 @@ impl PortControl { Ok(crate::system::preferences(&cfgs["startwrt"])?.remote_access) }) .await - .unwrap_or_else(|_| "default".to_string()) }; let ui = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 443); - match (has_443, remote_access_fallback_source(&mode, wan)) { + let source = match mode.as_ref() { + Ok(mode) => remote_access_fallback_source(Ok(mode), wan), + Err(e) => { + tracing::warn!("port-control: reading Remote Access preference failed: {e}"); + remote_access_fallback_source(Err(()), wan) + } + }; + match (has_443, source) { (true, Some(source)) => { if let Err(code) = self.sni.register_local_fallback(wan, 443, ui, source) { tracing::warn!("port-control: remote-access fallback on 443 refused ({code})"); @@ -824,29 +888,26 @@ impl PortControl { } } - /// Reconcile the `apf_sni_*` WAN-admit rules against the demux's live port - /// set, reloading the firewall when anything changed. Callers must hold - /// `write_serial`. - async fn sync_sni_rules(&self) { - let want: std::collections::BTreeSet = self + /// Reconciles WAN admission against the demux's live ports. + async fn sync_sni_rules(&self) -> Result<(), Error> { + let want = self .sni .snapshot() .into_iter() - .map(|r| r.ext_port) + .map(|route| route.ext_port) .collect(); + self.sync_sni_rules_to(want).await + } + + async fn sync_sni_rules_to(&self, want: std::collections::BTreeSet) -> Result<(), Error> { let uci_root = self.uci_root.clone(); - match uci_task(move || async move { reconcile_sni_rules_uci(&uci_root, want).await }).await - { - Ok(true) => self.reload_firewall(), - Ok(false) => {} - Err(e) => tracing::warn!("port-control: reconciling SNI admit rules failed: {e}"), + if uci_task(move || async move { reconcile_sni_rules_uci(&uci_root, want).await }).await? { + self.reload_firewall(); } + Ok(()) } - /// Sweep companion for the SNI dataplane: re-key live routes onto a changed - /// WAN address (backstop for the wan hotplug hook), then bring the admit - /// rules back in line with the live ports (heals a failed callback write, - /// an external edit, or a stale rule from before a daemon restart). + /// Re-keys routes and reconciles WAN admission. async fn sni_maintain(&self) { self.reap_unauthorized_sni_routes().await; let wan = self.wan_ipv4().await; @@ -858,7 +919,9 @@ impl PortControl { } { let _serial = self.write_serial.lock().await; - self.sync_sni_rules().await; + if let Err(e) = self.sync_sni_rules().await { + tracing::warn!("port-control: reconciling SNI admission failed: {e}"); + } } if let Some(ip) = wan { self.sync_sni_fallback(ip).await; @@ -866,18 +929,16 @@ impl PortControl { } } -/// [`SniDemux`] `on_change` body: a port's listener started or stopped — -/// reconcile the admit rules against the live port set rather than acting on -/// the edge itself, which makes the spawned callback idempotent and immune to -/// reordering. Creation is additionally done inline in -/// [`PortControl::add_sni_route`]; this is the expiry/teardown path. +/// Reconciles live ports so reordered callbacks remain idempotent. async fn on_sni_change(pc: Arc, port: u16, active: bool) { tracing::debug!( "port-control: SNI listener on port {port} {}", if active { "started" } else { "stopped" } ); let _serial = pc.write_serial.lock().await; - pc.sync_sni_rules().await; + if let Err(e) = pc.sync_sni_rules().await { + tracing::warn!("port-control: reconciling SNI admission failed: {e}"); + } } /// Which interface a request physically arrived on, for the arrival-interface @@ -933,7 +994,7 @@ fn arrival_matches(arrival: Arrival, neigh_iface: &str) -> bool { /// can't distinguish a PCP MAP from a UPnP AddPortMapping. struct Via { pc: Arc, - label: &'static str, + kind: &'static str, /// Interface the request arrived on, for the PCP arrival-interface check. arrival: Arrival, } @@ -948,7 +1009,7 @@ impl GatewayBackend for Via { lifetime: Option, ) -> Result<(), u16> { self.pc - .add_forward_labeled(source, target, count, peer, lifetime, self.label) + .add_forward_for_kind(source, target, count, peer, lifetime, self.kind) .await } @@ -980,15 +1041,10 @@ impl GatewayBackend for Via { self.pc.forwards_for(peer).await } - /// The ANNOUNCE capability marker is advertised and HOSTNAME-bound - /// mappings accepted; see the module doc's "SNI hostname routes". fn sni(&self) -> Option<&Arc> { Some(&self.pc.sni) } - /// Overridden (vs the default demux-only registration) to gate admission - /// on this gateway's port ownership and to open the WAN firewall for the - /// listener; see [`PortControl::add_sni_route`]. async fn add_sni_forward( &self, source: SocketAddrV4, @@ -1000,14 +1056,18 @@ impl GatewayBackend for Via { .add_sni_route(source, target, hostnames, lifetime) .await } -} -// ── UCI plumbing ────────────────────────────────────────────── + async fn remove_sni_forward( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + hostnames: &[String], + ) { + self.pc.remove_sni_route(source, target, hostnames).await + } +} -/// Run !Send uciedit work from a Send context. uciedit's `Arena` is !Send, but -/// `GatewayBackend` futures must be Send — so the closure's future runs to -/// completion on a scratch current-thread runtime inside `spawn_blocking` -/// (the same pattern as the daemon's setup-flash thread). +/// Runs !Send uciedit work on a dedicated current-thread runtime. async fn uci_task(f: F) -> Result where T: Send + 'static, @@ -1025,9 +1085,7 @@ where .map_err(|e| Error::new(eyre!("uci task panicked: {e}"), ErrorKind::Filesystem))? } -/// Section name for a device's forward at `external_port` — external because -/// that's a mapping's identity in UPnP (and what conflicts are judged by), so -/// one device can hold several external ports to the same internal port. +/// Stable section key scoped by device and external port. fn section_name(mac: &str, external_port: u16) -> String { format!( "apf_{}_{}", @@ -1036,12 +1094,7 @@ fn section_name(mac: &str, external_port: u16) -> String { ) } -/// How long to hold a forward for a client granted `lifetime` seconds — the -/// schedule the protocol layer reported to the client, clamped so the sweep can -/// always see it. `None` (a UPnP client asking for a *permanent* mapping) is -/// granted [`MAX_LEASE`] rather than kept forever: an unattended router must -/// not hold a WAN port open with nothing scheduled to close it, and a client -/// that still wants the port renews long before a week is out. +/// Clamps permanent and short requests to a sweepable finite lease. fn lease_for(lifetime: Option) -> Duration { lifetime .map(|s| Duration::from_secs(u64::from(s))) @@ -1050,20 +1103,14 @@ fn lease_for(lifetime: Option) -> Duration { .min(MAX_LEASE) } -/// An auto-forward section as the sweep sees it: enough to decide whether it -/// has expired and whether it still points at its owner's address. struct AutoSection { name: String, mac: Option, dest_ip: Option, } -/// The sections whose owner no longer holds the address they forward to — -/// either the device was given a different one or its lease is gone, and in -/// both cases the traffic would now land on some other host. A statically -/// reserved address pins the forward regardless: nobody else can be given it. -/// Sections missing their `_apf_mac`/`dest_ip` tags are left alone; there's -/// nothing to check them against. +/// Sections whose tagged owner no longer holds the destination address. +/// Untagged sections are ignored. fn unbound_sections( sections: &[AutoSection], reserved: &HashMap, @@ -1083,16 +1130,13 @@ fn unbound_sections( } enum ApplyOutcome { - /// The external range is held by someone else (718 to the client). Conflict, - /// An identical forward already exists — renewal, nothing written. Unchanged, - /// Created or updated; the firewall needs a restart. Written, } fn desired_redirect( - label: &str, + kind: &str, mac: &str, zone: String, source: SocketAddrV4, @@ -1100,12 +1144,10 @@ fn desired_redirect( count: u16, ) -> FirewallRedirect { FirewallRedirect { - name: format!("Auto forward ({label})"), + name: format!("Automatic port use ({kind})"), src: "wan".into(), dest: Some(zone), target: "DNAT".into(), - // The protocol cores are transport-agnostic (`add_forward` carries no - // protocol), matching the tunnel's both-transports forwards. proto: vec!["tcp".into(), "udp".into()], src_dport: Some(range_string(source.port(), count)), src_ip: None, @@ -1114,40 +1156,25 @@ fn desired_redirect( enabled: Some("1".into()), _pp_id: None, _pp_mac: None, - _apf_label: Some(label.into()), + _apf_label: Some(kind.into()), _apf_mac: Some(mac.into()), - _pp_router_override: None, + _pp_wan_override: None, } } -/// Whether `want` (a requested external range) overlaps a port the router -/// itself answers on from the WAN. Auto forwards are always tcp+udp, so any -/// transport overlap reserves the port. -fn reserves_router_port(firewall: &uciedit::Config<'_>, want: (u16, u16)) -> bool { - !router_reserved_overlaps(firewall, want, true, true).is_empty() +/// Whether the requested range overlaps a router-owned WAN port. +fn reserves_wan_port(firewall: &uciedit::Config<'_>, want: (u16, u16)) -> bool { + !wan_reserved_overlaps(firewall, want, true, true).is_empty() } -/// One overlapping WAN-input rule from [`router_reserved_overlaps`]: the -/// rule's port spec, and whether the rule is an SNI-demux admit rule — i.e. -/// the port's real holder is a device's hostname routes, not one of the -/// router's own services. The distinction only changes what the confirm -/// dialog tells the user; either way the port counts as router-reserved. #[derive(Debug, Clone, PartialEq)] pub(crate) struct ReservedOverlap { pub ports: String, - pub sni: bool, + pub held_by_sni: bool, } -/// The ports the router itself answers on from the WAN — input-chain rules -/// (`src wan`, no `dest` zone, `ACCEPT`) — whose port range overlaps `want` -/// over a requested transport (`tcp`/`udp`). Returns each overlapping rule's -/// `dest_port` spec, deduped per kind. Reading the live rules rather than a -/// hardcoded list means a port stops being reserved when its feature is -/// turned off, and any future WAN-exposed service is covered without touching -/// this code. IPv6-only rules are ignored — they share no port space with an -/// IPv4 redirect. Transport matters: Remote Access (80/443/22) is TCP, -/// WireGuard is UDP, so e.g. a UDP-only forward on 443 collides with nothing. -pub(crate) fn router_reserved_overlaps( +/// Enabled IPv4 WAN-input rules overlapping the requested ports and transports. +pub(crate) fn wan_reserved_overlaps( firewall: &uciedit::Config<'_>, want: (u16, u16), tcp: bool, @@ -1164,9 +1191,7 @@ pub(crate) fn router_reserved_overlaps( if rule.src != "wan" || rule.dest.is_some() || rule.family.as_deref() == Some("ipv6") { continue; } - // fw4's default proto for a rule is tcp+udp; "all"/"tcpudp" also match - // either transport. A rule reachable over neither requested transport - // (e.g. proto icmp) shares nothing with this forward. + // fw4 treats an empty protocol list as TCP and UDP. let (rule_tcp, rule_udp) = if rule.proto.is_empty() { (true, true) } else { @@ -1185,22 +1210,22 @@ pub(crate) fn router_reserved_overlaps( let Some(spec) = rule.dest_port.as_deref() else { continue; }; - let sni = rule._apf_label.as_deref() == Some(LABEL_SNI); + let held_by_sni = rule._apf_label.as_deref() == Some(KIND_SNI); if parse_port_range(spec).is_some_and(|range| ranges_overlap(want, range)) - && !overlaps.iter().any(|p| p.ports == spec && p.sni == sni) + && !overlaps + .iter() + .any(|p| p.ports == spec && p.held_by_sni == held_by_sni) { overlaps.push(ReservedOverlap { ports: spec.to_string(), - sni, + held_by_sni, }); } } overlaps } -/// Renewal detection: same external range, target, zone, and owner. The label -/// is ignored so a PCP renewal of a UPnP-created forward (or vice versa) -/// doesn't churn the config. +/// Matches renewals across PCP and UPnP. fn redirect_matches(existing: &FirewallRedirect, desired: &FirewallRedirect) -> bool { existing.src_dport == desired.src_dport && existing.dest_port == desired.dest_port @@ -1215,7 +1240,7 @@ fn redirect_matches(existing: &FirewallRedirect, desired: &FirewallRedirect) -> async fn apply_forward_uci( uci_root: &Path, section: &str, - label: &str, + kind: &str, mac: &str, arp_iface: Option<&str>, source: SocketAddrV4, @@ -1230,7 +1255,7 @@ async fn apply_forward_uci( let zone = arp_iface .and_then(|iface| crate::published_ports::zone_for_arp_iface(&cfgs, iface)) .unwrap_or_else(|| "lan".into()); - let desired = desired_redirect(label, mac, zone, source, target, count); + let desired = desired_redirect(kind, mac, zone, source, target, count); // Any *other* enabled WAN-ingress DNAT redirect overlapping the // requested external range blocks the mapping — manual published ports @@ -1268,7 +1293,7 @@ async fn apply_forward_uci( // override — see `published_ports::set`). A protocol client can't be // asked, so refuse, as StartTunnel reserves the port its HTTP→HTTPS // redirect owns. - if reserves_router_port(&cfgs["firewall"], want) { + if reserves_wan_port(&cfgs["firewall"], want) { return Ok(ApplyOutcome::Conflict); } if let Some(ours) = &ours { @@ -1328,53 +1353,43 @@ async fn remove_auto_sections(uci_root: &Path, names: &[String]) -> Result String { format!("apf_sni_{port}") } -/// The WAN-input ACCEPT rule admitting the SNI-demux listener on `port`. -/// tcp-only — the demux reads TLS ClientHellos. Its presence also makes -/// [`router_reserved_overlaps`] count the port as router-held, which is what -/// keeps plain forwards off a demuxed port. fn desired_sni_rule(port: u16) -> FirewallRule { FirewallRule { name: "SNI demux (hostname routes)".into(), src: "wan".into(), - // No dest zone: input chain — traffic terminates on the router. proto: vec!["tcp".into()], dest_port: Some(port.to_string()), target: FirewallTarget::ACCEPT, + family: Some("ipv4".into()), enabled: Some("1".into()), - _apf_label: Some(LABEL_SNI.into()), + _apf_label: Some(KIND_SNI.into()), ..Default::default() } } -/// Which sources the remote-access fallback admits, mirroring exactly the -/// firewall rules `apply_remote_access_config` writes for the mode: "always" -/// is unscoped; "default" behind NAT (RFC1918 WAN — std's `is_private` matches -/// `system::is_private_ipv4`) admits private sources only; a public WAN in -/// "default", or "never", writes no WAN rules and so gets no fallback. -fn remote_access_fallback_source(mode: &str, wan: Ipv4Addr) -> Option { +/// Mirrors Remote Access firewall source policy. +fn remote_access_fallback_source(mode: Result<&str, ()>, wan: Ipv4Addr) -> Option { match mode { - "always" => Some(FallbackSource::Any), - "default" if wan.is_private() => Some(FallbackSource::PrivateOnly), + Ok("always") => Some(FallbackSource::Any), + Ok("default") if wan.is_private() => Some(FallbackSource::PrivateOnly), _ => None, } } -/// Whether tcp `port` is unavailable to the SNI demux: an enabled WAN-ingress -/// DNAT redirect overlaps it (that traffic already goes wholesale to one -/// device), or the router itself answers on it from the WAN (Remote Access, -/// the VPN server — the demux's specific `(wan_ip, port)` bind would beat -/// their wildcard binds and capture traffic it has no route for). The demux's -/// own admit rules are excluded, so a second hostname on an already-demuxed -/// port is not a self-conflict — and Remote Access's 443 rules are excluded -/// in favor of the UI fallback (see `sync_sni_fallback`). +fn protocols_include_tcp(protocols: &[String]) -> bool { + protocols.is_empty() + || protocols.iter().any(|protocol| { + protocol.eq_ignore_ascii_case("tcp") + || protocol.eq_ignore_ascii_case("all") + || protocol.eq_ignore_ascii_case("tcpudp") + }) +} + +/// Whether the TCP port is held by a DNAT or incompatible router service. fn sni_port_conflicts(firewall: &uciedit::Config<'_>, port: u16) -> bool { let want = (port, port); for sec in &firewall.sections { @@ -1382,6 +1397,7 @@ fn sni_port_conflicts(firewall: &uciedit::Config<'_>, port: u16) -> bool { if r.target == "DNAT" && r.enabled.as_deref() != Some("0") && r.src == "wan" + && protocols_include_tcp(&r.proto) && r.src_dport .as_deref() .and_then(parse_port_range) @@ -1394,15 +1410,10 @@ fn sni_port_conflicts(firewall: &uciedit::Config<'_>, port: u16) -> bool { let Ok(rule) = sec.get::() else { continue; }; - if rule._apf_label.as_deref() == Some(LABEL_SNI) { + if rule._apf_label.as_deref() == Some(KIND_SNI) { continue; } - // Remote Access's own 443 rules don't conflict: the daemon registers - // its web UI as the demuxed port's fallback with the same source - // scoping (see `sync_sni_fallback`), so remote access rides the shared - // port instead of losing it. Only 443 — the UI's TLS port — can do - // this: SSH (22) is server-speaks-first and the port-80 redirect is - // plain HTTP, neither of which an SNI peek can serve. + // Remote Access on 443 is served through the SNI fallback. if port == 443 && (rule.name.starts_with(crate::system::REMOTE_RULE_PREFIX) || sec @@ -1417,14 +1428,7 @@ fn sni_port_conflicts(firewall: &uciedit::Config<'_>, port: u16) -> bool { if rule.src != "wan" || rule.dest.is_some() || rule.family.as_deref() == Some("ipv6") { continue; } - // Default proto is tcp+udp; only tcp reachability matters to the demux. - let tcp = rule.proto.is_empty() - || rule.proto.iter().any(|p| { - p.eq_ignore_ascii_case("tcp") - || p.eq_ignore_ascii_case("all") - || p.eq_ignore_ascii_case("tcpudp") - }); - if !tcp { + if !protocols_include_tcp(&rule.proto) { continue; } if rule @@ -1439,11 +1443,8 @@ fn sni_port_conflicts(firewall: &uciedit::Config<'_>, port: u16) -> bool { false } -/// Make the set of `_apf_label 'SNI'` rules exactly match `want` (one rule per -/// port): strays — a port no longer demuxed, a duplicate, an unparseable -/// section — are removed, missing ports appended. Returns whether anything was -/// written. `want = ∅` is the daemon-start purge (in-memory routes did not -/// survive, so neither may the rules admitting them). +/// Replaces SNI admission rules with one per requested port. +/// Returns whether UCI changed. async fn reconcile_sni_rules_uci( uci_root: &Path, want: std::collections::BTreeSet, @@ -1458,7 +1459,7 @@ async fn reconcile_sni_rules_uci( let Ok(rule) = sec.get::() else { return true; }; - if rule._apf_label.as_deref() != Some(LABEL_SNI) { + if rule._apf_label.as_deref() != Some(KIND_SNI) { return true; } let port = rule @@ -1490,8 +1491,6 @@ async fn reconcile_sni_rules_uci( } } -// ── Parsing helpers ────────────────────────────────────────────── - /// "443" → (443, 443); "1000-1009" → (1000, 1009). None on garbage. pub(crate) fn parse_port_range(s: &str) -> Option<(u16, u16)> { match s.split_once('-') { @@ -1538,7 +1537,6 @@ fn parse_neigh(neigh: &str, peer: Ipv4Addr) -> Option<(String, String)> { .map(|e| (e.mac, e.interface)) } -/// The uppercase MACs whose DHCP host entry grants automatic port forwarding. fn pcp_allowed_macs( dhcp: &uciedit::Config<'_>, ) -> Result, Error> { @@ -1551,9 +1549,8 @@ fn pcp_allowed_macs( Ok(allowed) } -/// The routes among `routes` whose target address maps (via `ip_to_mac`) to a -/// device outside `allowed`. An address that maps to no device is not -/// evidence of anything, so its routes are kept. +/// Unauthorized routes with identifiable owners. +/// Unattributed routes are retained. fn unauthorized_sni_routes( routes: Vec, ip_to_mac: &HashMap, @@ -1612,17 +1609,15 @@ fn device_uuid() -> String { format_uuid(&Sha256::digest(seed.as_bytes())) } -// ── Listeners ────────────────────────────────────────────── - /// Run all port-control servers for the life of the daemon. Each half /// self-restarts on error, and each runs in its own supervised task. pub async fn run(pc: Arc) { - // SNI routes are in-memory and did not survive the restart; drop any admit - // rules left behind so no port stays open with nothing routing it. Clients - // that still want their routes re-assert within their lease. + // In-memory routes do not survive restart. { let _serial = pc.write_serial.lock().await; - pc.sync_sni_rules().await; + if let Err(e) = pc.sync_sni_rules().await { + tracing::warn!("port-control: purging SNI admission failed: {e}"); + } } tokio::join!( supervise("PCP", pc.clone(), run_pcp), @@ -1730,7 +1725,7 @@ async fn serve_pcp(pc: &Arc) -> Result<(), Error> { let socket = socket.clone(); let via = Via { pc: pc.clone(), - label: LABEL_PCP, + kind: KIND_PCP, arrival, }; tokio::spawn(async move { @@ -1792,7 +1787,7 @@ async fn igd_control( } let via = Via { pc, - label: LABEL_UPNP, + kind: KIND_UPNP, // UPnP is TCP: the handshake proves the source address. arrival: Arrival::Unchecked, }; @@ -1886,15 +1881,13 @@ async fn run_sweep(pc: Arc) { } } -// ── RPC handlers ────────────────────────────────────────────── - -/// One automatic (PCP/UPnP-created) forward, for the published-ports UI. +/// One automatic port use for the published-ports UI. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AutoForward { +pub struct AutomaticPortUse { /// UCI section name (`apf__`). pub id: String, - /// Which protocol created it ("PCP" or "UPnP"). - pub label: String, + /// Which mechanism created it ("PCP", "UPnP", or "SNI"). + pub kind: String, pub device_mac: String, pub device_name: Option, /// Forward target address on the LAN. @@ -1906,14 +1899,11 @@ pub struct AutoForward { /// Seconds until the lease expires if not renewed (None when the daemon /// isn't tracking it yet, e.g. right after boot). pub expires_secs: Option, - /// The TLS-SNI hostname, for an SNI hostname route (label "SNI") sharing - /// its external port with other hostnames. None for plain forwards. + /// Hostname for an SNI route; absent for plain forwards. pub hostname: Option, } -#[instrument(skip_all)] -/// Device display names by uppercased MAC: UCI static host names win, cached -/// learned names fill the gaps. +/// Display names keyed by uppercase MAC; static UCI names take precedence. pub(crate) fn device_display_names( dhcp: &uciedit::Config<'_>, ) -> Result, Error> { @@ -1929,9 +1919,7 @@ pub(crate) fn device_display_names( Ok(names) } -/// LAN IPv4 → uppercased MAC: live DHCP leases, overridden by static -/// reservations (the reservation is where the device is supposed to be, so it -/// wins on disagreement). +/// LAN IPv4 to uppercase MAC; static reservations override live leases. pub(crate) async fn ip_to_mac_map( dhcp: &uciedit::Config<'_>, ) -> Result, Error> { @@ -1949,11 +1937,8 @@ pub(crate) async fn ip_to_mac_map( Ok(ip_to_mac) } -/// The hostname routes whose external port overlaps `want`, for naming a -/// port's holder in the router-port confirm dialog: the routed hostnames and -/// the owning devices' display names (name, else MAC; an unresolvable target -/// is skipped), each deduped and sorted. Informational only, so lookup -/// failures degrade to empty lists rather than failing the caller. +/// Sorted hostname and device labels for routes overlapping the requested range. +/// Lookup failures produce empty labels. pub(crate) async fn sni_route_holders( dhcp: &uciedit::Config<'_>, want: (u16, u16), @@ -1984,7 +1969,8 @@ pub(crate) async fn sni_route_holders( (hostnames, devices) } -pub async fn auto_list(ctx: ServerContext) -> Result, Error> { +#[instrument(skip_all)] +pub async fn auto_list(ctx: ServerContext) -> Result, Error> { let uci_root = ctx.uci_root(); let arena = Arena::new(); let cfgs = parse_all(&uci_root, &arena, &["firewall", "dhcp"]).await?; @@ -1996,16 +1982,16 @@ pub async fn auto_list(ctx: ServerContext) -> Result, Error> { let Ok(r) = sec.get::() else { continue; }; - let Some(label) = r._apf_label.clone() else { + let Some(kind) = r._apf_label.clone() else { continue; }; let id = sec.name().unwrap_or_default().to_string(); let device_mac = r._apf_mac.clone().unwrap_or_default().to_uppercase(); - out.push(AutoForward { + out.push(AutomaticPortUse { expires_secs: PORT_CONTROL.get().and_then(|pc| pc.lease_remaining(&id)), device_name: names.get(&device_mac).cloned(), id, - label, + kind, device_mac, internal_ip: r.dest_ip.clone(), ports: r.dest_port.clone().unwrap_or_default(), @@ -2014,17 +2000,14 @@ pub async fn auto_list(ctx: ServerContext) -> Result, Error> { }); } - // SNI hostname routes live in the demux, not UCI — one row per hostname. - // The owning device is whoever holds the target address: a static - // reservation first, then the live DHCP lease. if let Some(pc) = PORT_CONTROL.get() { let ip_to_mac = ip_to_mac_map(&cfgs["dhcp"]).await?; for route in pc.sni.snapshot() { let target_ip = route.target.ip().to_string(); let device_mac = ip_to_mac.get(&target_ip).cloned().unwrap_or_default(); - out.push(AutoForward { + out.push(AutomaticPortUse { id: format!("sni_{}_{}", route.ext_port, route.hostname), - label: LABEL_SNI.into(), + kind: KIND_SNI.into(), device_name: names.get(&device_mac).cloned(), device_mac, internal_ip: Some(target_ip), @@ -2038,9 +2021,7 @@ pub async fn auto_list(ctx: ServerContext) -> Result, Error> { Ok(out) } -/// Hidden: the wan hotplug hook. The WAN IPv4 may have changed — re-key live -/// SNI routes onto it immediately rather than waiting for the sweep's -/// once-a-minute backstop. Runs in the daemon (the demux is daemon memory). +/// Re-keys live SNI routes after a WAN address change. #[instrument(skip_all)] pub async fn wan_changed(ctx: ServerContext) -> Result { if !ctx.effectful() { @@ -2054,21 +2035,18 @@ pub async fn wan_changed(ctx: ServerContext) -> Result { } #[derive(Debug, Serialize, Deserialize)] -pub struct SetAutoForwardReq { +pub struct SetAutoForwardRequest { pub mac: String, pub allow: bool, } -/// Set a device's "may auto-create port forwards" toggle (default off). Stored -/// on the device's DHCP host entry so it survives sysupgrade with the rest of -/// the device config. +/// Changes a device's default-off automatic port-use permission. #[instrument(skip_all)] pub async fn set_auto_forward( ctx: C, - DeserializeStdin(req): DeserializeStdin, + DeserializeStdin(req): DeserializeStdin, ) -> Result<(), Error> { - // A bad MAC would otherwise be written into a new `config host` section - // that dnsmasq may refuse to load — taking the rest of the file with it. + // Reject malformed UCI host identifiers. if !crate::published_ports::validate_mac(&req.mac) { return Err(Error::new( eyre!("invalid mac: {}", req.mac), @@ -2080,7 +2058,7 @@ pub async fn set_auto_forward( let written = crate::devices::upsert_dhcp_host(&ctx.uci_root(), &req.mac, move |host, existed| { if !existed && !allow { - return false; // nothing to do: absent = denied + return false; } host._allow_pcp = allow.then(|| "1".to_string()); true @@ -2107,15 +2085,12 @@ pub async fn set_auto_forward( ), None, ); - // The write may have created the device's host section; dnsmasq owns - // that file, so let it re-read rather than pick the change up at some - // arbitrary later reload. + // The write may create a host section that dnsmasq must reload. if ctx.effectful() { crate::devices::reload_dnsmasq(); } - // Apply immediately: drop cached (un)authorization results. if !allow { - close_device_forwards(&req.mac).await; + close_device_forwards(&req.mac, &[]).await; } else if let Some(pc) = PORT_CONTROL.get() { pc.invalidate_clients(); } @@ -2123,21 +2098,15 @@ pub async fn set_auto_forward( Ok(()) } -/// Close every automatic forward and hostname route owned by `mac` and drop -/// its cached authorization. Called wherever a device loses the permission -/// that created them — the per-device toggle going off, or the device being -/// forgotten outright. Without it the ports outlive the authorization by up to -/// [`MAX_LEASE`], and the user's only control over an automatic forward -/// wouldn't take effect for as much as a week. The client discovers the loss on -/// its next refresh and stops re-asserting, since it is no longer authorized. -/// -/// A no-op outside daemon mode, where [`PORT_CONTROL`] is unset. -pub(crate) async fn close_device_forwards(mac: &str) { +/// Revokes a device's automatic forwards and cached authorization. +/// No-op outside daemon mode. +pub(crate) async fn close_device_forwards(mac: &str, known_ips: &[String]) { let Some(pc) = PORT_CONTROL.get() else { return; }; pc.invalidate_clients(); pc.remove_client_forwards(mac, |_| true).await; + pc.remove_sni_routes_for_ips(known_ips).await; pc.reap_unauthorized_sni_routes().await; } @@ -2241,7 +2210,7 @@ config redirect 'pp_a' let outcome = apply_forward_uci( dir.path(), "apf_aabbccddeeff_8443", - LABEL_PCP, + KIND_PCP, "AA:BB:CC:DD:EE:FF", Some("br-lan"), source, @@ -2262,7 +2231,7 @@ config redirect 'pp_a' let outcome = apply_forward_uci( dir.path(), "apf_aabbccddeeff_8443", - LABEL_PCP, + KIND_PCP, "AA:BB:CC:DD:EE:FF", Some("br-lan"), source, @@ -2280,7 +2249,7 @@ config redirect 'pp_a' let outcome = apply_forward_uci( dir.path(), "apf_aabbccddeeff_8443", - LABEL_PCP, + KIND_PCP, "AA:BB:CC:DD:EE:FF", Some("br-lan"), source, @@ -2303,7 +2272,7 @@ config redirect 'pp_a' let outcome = apply_forward_uci( dir.path(), "apf_112233445566_443", - LABEL_UPNP, + KIND_UPNP, "11:22:33:44:55:66", Some("br-lan"), source, @@ -2374,7 +2343,7 @@ config rule 'wan_to_lan' apply_forward_uci( dir.path(), §ion_name("11:22:33:44:55:66", port), - LABEL_PCP, + KIND_PCP, "11:22:33:44:55:66", Some("br-lan"), SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), port), @@ -2452,7 +2421,7 @@ config rule 'wan_to_lan' let outcome = apply_forward_uci( dir.path(), "apf_112233445566_440", - LABEL_PCP, + KIND_PCP, "11:22:33:44:55:66", Some("br-lan"), source, @@ -2468,7 +2437,7 @@ config rule 'wan_to_lan' let outcome = apply_forward_uci( dir.path(), "apf_112233445566_450", - LABEL_PCP, + KIND_PCP, "11:22:33:44:55:66", Some("br-lan"), source, @@ -2495,7 +2464,7 @@ config rule 'wan_to_lan' let outcome = apply_forward_uci( dir.path(), §ion_name("AA:BB:CC:DD:EE:FF", ext), - LABEL_UPNP, + KIND_UPNP, "AA:BB:CC:DD:EE:FF", Some("br-lan"), source, @@ -2533,7 +2502,7 @@ config redirect 'dns_override_lan' let outcome = apply_forward_uci( dir.path(), §ion_name("AA:BB:CC:DD:EE:FF", 53), - LABEL_PCP, + KIND_PCP, "AA:BB:CC:DD:EE:FF", Some("br-lan"), source, @@ -2553,7 +2522,7 @@ config redirect 'dns_override_lan' apply_forward_uci( dir.path(), "apf_aabbccddeeff_8443", - LABEL_PCP, + KIND_PCP, "AA:BB:CC:DD:EE:FF", Some("br-lan"), source, @@ -2598,7 +2567,7 @@ config redirect 'dns_override_lan' apply_forward_uci( dir.path(), "apf_aabbccddeeff_8443", - LABEL_PCP, + KIND_PCP, "AA:BB:CC:DD:EE:FF", Some("br-lan"), source, @@ -2612,7 +2581,7 @@ config redirect 'dns_override_lan' apply_forward_uci( dir.path(), "apf_112233445566_9000", - LABEL_UPNP, + KIND_UPNP, "11:22:33:44:55:66", Some("br-lan"), other_source, @@ -2672,8 +2641,6 @@ config redirect 'dns_override_lan' assert!(!arrival_matches(Arrival::Indeterminate, "lo")); } - // ── SNI hostname routes ── - #[tokio::test] async fn sni_route_registers_and_admits_inline() { let dir = temp_root(""); @@ -2689,17 +2656,14 @@ config redirect 'dns_override_lan' assert_eq!(snap[0].hostname, "nas.example.com"); assert_eq!(snap[0].target, target); - // The WAN-admit rule is written inline with registration (under the - // write lock), not deferred to the spawned callback. let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); assert!(written.contains("config rule apf_sni_8443")); assert!(written.contains("option _apf_label 'SNI'")); assert!(written.contains("option dest_port '8443'")); assert!(written.contains("option target 'ACCEPT'")); + assert!(written.contains("option family 'ipv4'")); assert!(written.contains("list proto 'tcp'")); - // A second hostname on the same port is the point of the demux, not a - // conflict — and the port still has exactly one admit rule. let target2 = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 51), 443); pc.add_sni_route( source, @@ -2714,10 +2678,6 @@ config redirect 'dns_override_lan' assert_eq!(written.matches("apf_sni_8443").count(), 1); } - // Revocation is enforced by re-deriving each route's owner from the - // address it targets: a device that lost the permission loses its routes, - // a device that still holds it keeps them, and an address no device can be - // attributed to is left to its lease. #[tokio::test] async fn sni_routes_follow_the_permission() { let dir = temp_root(""); @@ -2752,11 +2712,9 @@ config host } assert_eq!(pc.sni.snapshot().len(), 3); - // Everyone still authorized: nothing moves. pc.reap_unauthorized_sni_routes().await; assert_eq!(pc.sni.snapshot().len(), 3); - // A loses the permission the way the toggle revokes it. std::fs::write( dir.path().join("dhcp"), "\ @@ -2775,14 +2733,9 @@ config host let left: Vec = pc.sni.snapshot().into_iter().map(|r| r.hostname).collect(); assert_eq!(left, vec!["b.example.com", "c.example.com"]); - // The port still has holders, so its admit rule stays. let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); assert!(written.contains("config rule apf_sni_8443")); - // Forgetting B drops its host entry outright. With no lease or - // neighbor entry left to attribute its address to anything, B's route - // is now — like the stray's — evidence of nothing, and both are left - // to their leases rather than reaped on a guess. std::fs::write( dir.path().join("dhcp"), "\ @@ -2827,7 +2780,6 @@ config host #[tokio::test] async fn sni_route_refused_on_owned_ports() { - // A DNAT'd port already sends its traffic wholesale to one device. let dir = temp_root(MANUAL_FW); let pc = PortControl::new(dir.path().to_path_buf()); let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 443); @@ -2842,9 +2794,6 @@ config host "nothing registered on refusal" ); - // A non-443 port the router answers on itself (Remote Access SSH): - // still refused — SSH is server-speaks-first, so it can't ride the - // demux's fallback the way the 443 UI does. let dir = temp_root(ROUTER_FW); let pc = PortControl::new(dir.path().to_path_buf()); let ssh = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 22); @@ -2859,7 +2808,6 @@ config host .unwrap_err(); assert_eq!(err, RESULT_HOSTNAME_TAKEN); - // ...but a UDP-only router port (WireGuard) shares nothing with TLS. let wg = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 51820); pc.add_sni_route( wg, @@ -2871,9 +2819,6 @@ config host .unwrap(); } - // Remote Access's own rules stop conflicting exactly on 443 — the port - // whose traffic the UI fallback can serve — while every other - // router-answered or forwarded port keeps refusing. #[tokio::test] async fn remote_access_coexists_on_443_only() { let dir = temp_root(ROUTER_FW); @@ -2895,26 +2840,51 @@ config host ); } - // The fallback's source scoping mirrors apply_remote_access_config's rule - // emission case-for-case. + #[tokio::test] + async fn udp_dnat_does_not_block_sni() { + let dir = temp_root( + "config redirect\n\ + \toption src 'wan'\n\ + \toption target 'DNAT'\n\ + \tlist proto 'udp'\n\ + \toption src_dport '8443'\n\ + \toption dest_port '8443'\n\ + \toption dest_ip '192.168.1.50'\n", + ); + let pc = PortControl::new(dir.path().to_path_buf()); + pc.add_sni_route( + SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443), + SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 60), 8443), + &["tcp.example.com".to_string()], + Some(3600), + ) + .await + .unwrap(); + } + #[test] fn fallback_source_mirrors_remote_access_modes() { let private_wan = Ipv4Addr::new(192, 168, 10, 92); let public_wan = Ipv4Addr::new(203, 0, 113, 7); assert_eq!( - remote_access_fallback_source("default", private_wan), + remote_access_fallback_source(Ok("default"), private_wan), Some(FallbackSource::PrivateOnly) ); - assert_eq!(remote_access_fallback_source("default", public_wan), None); assert_eq!( - remote_access_fallback_source("always", public_wan), + remote_access_fallback_source(Ok("default"), public_wan), + None + ); + assert_eq!( + remote_access_fallback_source(Ok("always"), public_wan), Some(FallbackSource::Any) ); - assert_eq!(remote_access_fallback_source("never", private_wan), None); + assert_eq!( + remote_access_fallback_source(Ok("never"), private_wan), + None + ); + assert_eq!(remote_access_fallback_source(Err(()), private_wan), None); } - // The admit rule makes a demuxed port read as router-held, so a plain auto - // forward can't DNAT it out from under its hostnames. #[tokio::test] async fn sni_admit_rule_reserves_the_port() { let dir = temp_root(""); @@ -2928,7 +2898,7 @@ config host let outcome = apply_forward_uci( dir.path(), §ion_name("11:22:33:44:55:66", 8443), - LABEL_PCP, + KIND_PCP, "11:22:33:44:55:66", Some("br-lan"), source, @@ -2941,7 +2911,7 @@ config host } #[tokio::test] - async fn sni_teardown_removes_the_admit_rule() { + async fn sni_teardown_removes_the_admit_rule_inline() { let dir = temp_root(""); let pc = PortControl::new(dir.path().to_path_buf()); let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443); @@ -2951,22 +2921,80 @@ config host .await .unwrap(); - // Unregistering the last hostname reaps the listener; the demux's - // teardown callback (spawned) drops the admit rule. - pc.sni - .unregister(*source.ip(), source.port(), &hostnames, target); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); - if !written.contains("apf_sni_8443") { - break; - } - assert!( - Instant::now() < deadline, - "teardown callback never removed the admit rule" - ); - tokio::time::sleep(Duration::from_millis(50)).await; - } + let via = Via { + pc: pc.clone(), + kind: KIND_PCP, + arrival: Arrival::Unchecked, + }; + via.remove_sni_forward(source, target, &hostnames).await; + + let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); + assert!(!written.contains("apf_sni_8443")); + } + + #[tokio::test] + async fn failed_sni_admission_preserves_an_existing_binding() { + let dir = temp_root(""); + let pc = PortControl::new(dir.path().to_path_buf()); + let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443); + let target = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 50), 443); + let hostnames = vec!["nas.example.com".to_string()]; + pc.add_sni_route(source, target, &hostnames, Some(3600)) + .await + .unwrap(); + std::fs::remove_file(dir.path().join("firewall")).unwrap(); + std::fs::create_dir(dir.path().join("firewall")).unwrap(); + + assert!(pc + .add_sni_route(source, target, &hostnames, Some(3600)) + .await + .is_err()); + assert_eq!(pc.sni.snapshot().len(), 1); + + let missing = temp_root(""); + std::fs::remove_file(missing.path().join("firewall")).unwrap(); + std::fs::create_dir(missing.path().join("firewall")).unwrap(); + let fresh = PortControl::new(missing.path().to_path_buf()); + assert!(fresh + .add_sni_route(source, target, &hostnames, Some(3600)) + .await + .is_err()); + assert!(fresh.sni.snapshot().is_empty()); + } + + #[tokio::test] + async fn manual_port_use_displaces_sni_routes() { + let dir = temp_root(""); + let pc = PortControl::new(dir.path().to_path_buf()); + let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443); + let target = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 50), 443); + pc.add_sni_route(source, target, &["nas.example.com".to_string()], Some(3600)) + .await + .unwrap(); + + pc.displace_sni_routes(&[(8443, 8443)]).await; + + assert!(pc.sni.snapshot().is_empty()); + let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); + assert!(!written.contains("apf_sni_8443")); + } + + #[tokio::test] + async fn removed_static_ip_identifies_offline_sni_routes() { + let dir = temp_root(""); + let pc = PortControl::new(dir.path().to_path_buf()); + let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443); + let target = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 50), 443); + pc.add_sni_route(source, target, &["nas.example.com".to_string()], Some(3600)) + .await + .unwrap(); + + pc.remove_sni_routes_for_ips(&["192.168.1.50".to_string()]) + .await; + + assert!(pc.sni.snapshot().is_empty()); + let written = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); + assert!(!written.contains("apf_sni_8443")); } #[tokio::test] @@ -2974,8 +3002,6 @@ config host let dir = temp_root(""); let pc = PortControl::new(dir.path().to_path_buf()); - // A stale admit rule from a previous daemon life (its in-memory route - // did not survive) is purged; unrelated rules are untouched. std::fs::write( dir.path().join("firewall"), "config rule 'apf_sni_9443'\n\ @@ -3003,8 +3029,6 @@ config host "non-SNI rules survive the purge" ); - // A live route whose admit rule went missing (failed write, external - // edit) is healed. let source = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 8443); let target = SocketAddrV4::new(Ipv4Addr::new(192, 168, 1, 50), 443); pc.add_sni_route(source, target, &["nas.example.com".to_string()], Some(3600)) diff --git a/projects/start-wrt/backend/ctrl/src/published_ports.rs b/projects/start-wrt/backend/ctrl/src/published_ports.rs index 4960bc6cd6..2ebd4cb338 100644 --- a/projects/start-wrt/backend/ctrl/src/published_ports.rs +++ b/projects/start-wrt/backend/ctrl/src/published_ports.rs @@ -47,8 +47,7 @@ pub fn published_ports() -> ParentHandler { ) .subcommand( "wan-changed", - // Forwarded to the daemon like `reconcile`: the SNI routes to - // re-key live only in the daemon's demux memory. + // Live SNI routes exist only in daemon memory. from_fn_async_local(crate::port_control::wan_changed) .with_metadata("no_auth", Value::Bool(true)) .no_display() @@ -111,10 +110,8 @@ pub struct PublishedPort { pub ipv6: bool, pub ipv4_public_port: Option, pub source: String, - /// The user confirmed forwarding a port the router itself answers on from - /// the WAN (see [`RouterPortCollision`]); round-tripped so later saves of - /// the full list don't re-prompt for the same collision. - pub override_router_ports: bool, + /// Whether WAN collision confirmation remains active. + pub override_wan_ports: bool, pub status: PublishedPortStatus, pub status_reason: Option, pub device_name: Option, @@ -134,11 +131,9 @@ pub struct PublishedPortInput { pub ipv6: bool, pub ipv4_public_port: Option, pub source: String, - /// Confirms forwarding a port the router itself answers on from the WAN - /// (persisted as `_pp_router_override` on the redirect). Without it, a - /// colliding port makes [`set`] report the collision and apply nothing. + /// Confirms an enabled IPv4 WAN collision. #[serde(default)] - pub override_router_ports: bool, + pub override_wan_ports: bool, } #[derive(Debug, Serialize, Deserialize)] @@ -146,32 +141,19 @@ pub struct PublishedPortsSetRequest { pub ports: Vec, } -/// An enabled IPv4 forward in a [`set`] request whose external range captures -/// a port the router itself answers on from the WAN (Remote Access 80/443/22, -/// the VPN server's listen port). nftables applies prerouting DNAT before the -/// routing decision, so the forward would silently divert those services to -/// the device (issue #3451). Surfaced to the UI for a confirmation dialog; the -/// user overrides by re-saving with `override_router_ports` on the named port. +/// An enabled IPv4 forward overlapping a router- or SNI-owned WAN port. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RouterPortCollision { +pub struct WanPortCollision { pub id: String, pub label: String, - /// The colliding router-service port spec(s), e.g. ["443", "22"]. - pub router_ports: Vec, - /// Colliding ports whose holder is a device's SNI hostname routes rather - /// than a router service — the dialog names the actual use instead of - /// blaming the router. Informational: the override semantics are the same. - pub sni_ports: Vec, + pub router_service_ports: Vec, + pub hostname_route_ports: Vec, } -/// One colliding port held by hostname routes: the port spec plus who is -/// using it, for the confirm dialog's copy. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SniPortUse { pub ports: String, - /// The routed hostnames on the port, deduped and sorted. pub hostnames: Vec, - /// Display names (or MACs) of the devices the routes deliver to. pub devices: Vec, } @@ -179,7 +161,7 @@ pub struct SniPortUse { /// the caller confirms and re-saves; empty means the request was applied. #[derive(Debug, Serialize, Deserialize)] pub struct PublishedPortsSetResult { - pub pending_router_port_collisions: Vec, + pub pending_wan_port_collisions: Vec, } /// A published port that will be removed because the device it forwards to can @@ -554,8 +536,8 @@ struct RawPort { ipv6: bool, ipv4_public_port: Option, source: String, - /// Confirmed router-port collision (`_pp_router_override` on the redirect). - override_router_ports: bool, + /// Confirmed WAN-port collision (`_pp_wan_override` on the redirect). + override_wan_ports: bool, /// The IPv6 rule's stored `dest_ip` (the full GUA it forwards to), if any. /// Used by `compute_status` to detect a rule stranded on an old prefix. dest_ipv6: Option, @@ -688,8 +670,8 @@ fn sections_to_raw_port( ipv6: rule.is_some(), ipv4_public_port, source, - override_router_ports: redirect - .is_some_and(|r| r._pp_router_override.as_deref() == Some("1")), + override_wan_ports: enabled + && redirect.is_some_and(|redirect| redirect._pp_wan_override.as_deref() == Some("1")), dest_ipv6: rule.and_then(|r| r.dest_ip.clone()), } } @@ -863,7 +845,7 @@ pub async fn list(_ctx: ServerContext) -> Result, Error> { ipv6: raw.ipv6, ipv4_public_port: raw.ipv4_public_port.clone(), source: raw.source.clone(), - override_router_ports: raw.override_router_ports, + override_wan_ports: raw.override_wan_ports, status, status_reason, device_name: device.map(|d| d.name.clone()), @@ -905,6 +887,14 @@ pub async fn set( && crate::system::has_global_ipv6( &crate::system::get_wan_ipv6s().await.unwrap_or_default(), ); + let port_control = ctx + .effectful() + .then(|| crate::port_control::PORT_CONTROL.get()) + .flatten(); + let port_control_serial = match port_control { + Some(port_control) => Some(port_control.lock_writes().await), + None => None, + }; let mut retries = 4; loop { @@ -979,21 +969,12 @@ pub async fn set( let arena = Arena::new(); let mut cfgs = parse_all(ctx.uci_root(), &arena, &["firewall", "dhcp"]).await?; - // A forward capturing a port the router itself answers on from the - // WAN needs the user's explicit say-so (issue #3451): report the - // collisions and apply nothing until each named port is re-saved with - // `override_router_ports`. The auto (PCP/UPnP) path refuses the same - // collision outright — a protocol client can't be asked. Configs-only - // mode has no dialog: the CLI editor confirms implicitly, matching - // `ethernet::set` / `wifi::set`. + // Effectful saves require explicit confirmation for occupied WAN ports. if ctx.effectful() { - let mut pending = router_port_collisions(&cfgs["firewall"], &req.ports); + let mut pending = wan_port_collisions(&cfgs["firewall"], &req.ports); if !pending.is_empty() { - // Name each SNI-held port's holder (hostnames + devices) so - // the dialog can say what the port is really carrying; the - // demux and the dhcp config are only at hand here. for collision in &mut pending { - for sni in &mut collision.sni_ports { + for sni in &mut collision.hostname_route_ports { if let Some(want) = crate::port_control::parse_port_range(&sni.ports) { (sni.hostnames, sni.devices) = crate::port_control::sni_route_holders(&cfgs["dhcp"], want).await; @@ -1001,7 +982,7 @@ pub async fn set( } } return Ok(PublishedPortsSetResult { - pending_router_port_collisions: pending, + pending_wan_port_collisions: pending, }); } } @@ -1094,16 +1075,8 @@ pub async fn set( // two DNAT redirects never coexist (fw4 first-match would silently // keep routing to the auto target). The device is refused on its next // renewal by the auto path's own conflict check. - let manual_ranges: Vec<(u16, u16)> = req - .ports - .iter() - .filter(|p| p.enabled && p.ipv4) - .filter_map(|p| { - crate::port_control::parse_port_range( - p.ipv4_public_port.as_deref().unwrap_or(&p.ports), - ) - }) - .collect(); + let manual_ranges = active_ipv4_ranges(&req.ports); + let sni_displacement_ranges = active_sni_conflicting_ranges(&req.ports); let mut displaced_auto: Vec = Vec::new(); cfgs["firewall"].sections.retain(|section| { let Ok(r) = section.get::() else { @@ -1171,7 +1144,7 @@ pub async fn set( _pp_mac: Some(port.device_mac.clone()), _apf_label: None, _apf_mac: None, - _pp_router_override: port.override_router_ports.then(|| "1".into()), + _pp_wan_override: (port.enabled && port.override_wan_ports).then(|| "1".into()), }; let section_name = format!("pp_{}", safe_id); cfgs["firewall"].append(&redirect, Some(§ion_name))?; @@ -1236,6 +1209,12 @@ pub async fn set( return Err(err.into()); } Ok(()) => { + drop(port_control_serial); + if let Some(port_control) = port_control { + port_control + .displace_sni_routes(&sni_displacement_ranges) + .await; + } if !displaced_auto.is_empty() { tracing::info!( "published-ports: manual rule(s) displaced auto forward(s): {displaced_auto:?}" @@ -1268,25 +1247,45 @@ pub async fn set( None, ); return Ok(PublishedPortsSetResult { - pending_router_port_collisions: Vec::new(), + pending_wan_port_collisions: Vec::new(), }); } } } } -/// The unconfirmed router-port collisions in a `set` request: enabled IPv4 -/// forwards without `override_router_ports` whose external range captures a -/// port the router answers on itself over a shared transport (see -/// [`RouterPortCollision`]). An unparseable port spec is skipped here — -/// `validate_inputs` already rejects malformed requests. -fn router_port_collisions( +fn active_ipv4_ranges(ports: &[PublishedPortInput]) -> Vec<(u16, u16)> { + ports + .iter() + .filter(|port| port.enabled && port.ipv4) + .filter_map(|port| { + crate::port_control::parse_port_range( + port.ipv4_public_port.as_deref().unwrap_or(&port.ports), + ) + }) + .collect() +} + +fn active_sni_conflicting_ranges(ports: &[PublishedPortInput]) -> Vec<(u16, u16)> { + ports + .iter() + .filter(|port| port.enabled && port.ipv4 && !matches!(port.protocol, Protocol::Udp)) + .filter_map(|port| { + crate::port_control::parse_port_range( + port.ipv4_public_port.as_deref().unwrap_or(&port.ports), + ) + }) + .collect() +} + +/// Unconfirmed IPv4 forwards overlapping occupied WAN ports. +fn wan_port_collisions( firewall: &uciedit::Config<'_>, ports: &[PublishedPortInput], -) -> Vec { +) -> Vec { ports .iter() - .filter(|p| p.enabled && p.ipv4 && !p.override_router_ports) + .filter(|p| p.enabled && p.ipv4 && !p.override_wan_ports) .filter_map(|p| { let range = crate::port_control::parse_port_range( p.ipv4_public_port.as_deref().unwrap_or(&p.ports), @@ -1296,19 +1295,22 @@ fn router_port_collisions( Protocol::Udp => (false, true), Protocol::TcpUdp => (true, true), }; - let overlaps = crate::port_control::router_reserved_overlaps(firewall, range, tcp, udp); + let overlaps = crate::port_control::wan_reserved_overlaps(firewall, range, tcp, udp); (!overlaps.is_empty()).then(|| { - let (sni, router): (Vec<_>, Vec<_>) = overlaps.into_iter().partition(|o| o.sni); - RouterPortCollision { + let (hostname_routes, router_services): (Vec<_>, Vec<_>) = overlaps + .into_iter() + .partition(|overlap| overlap.held_by_sni); + WanPortCollision { id: p.id.clone(), label: p.label.clone(), - router_ports: router.into_iter().map(|o| o.ports).collect(), - // Holder details are filled in by [`set`], which has the - // dhcp config and the live demux at hand. - sni_ports: sni + router_service_ports: router_services .into_iter() - .map(|o| SniPortUse { - ports: o.ports, + .map(|overlap| overlap.ports) + .collect(), + hostname_route_ports: hostname_routes + .into_iter() + .map(|overlap| SniPortUse { + ports: overlap.ports, hostnames: Vec::new(), devices: Vec::new(), }) @@ -2065,7 +2067,7 @@ config rule 'pp_a_v6' ipv6: true, ipv4_public_port: None, source: "any".into(), - override_router_ports: false, + override_wan_ports: false, dest_ipv6: dest_ipv6.map(str::to_string), } } @@ -2449,7 +2451,7 @@ config redirect 'pp_pub2' ipv6, ipv4_public_port: None, source: "any".to_string(), - override_router_ports: false, + override_wan_ports: false, } } @@ -2723,8 +2725,6 @@ config redirect 'pp_del1' ); } - // ── router_port_collisions tests ── - /// The WAN-input ACCEPT rules the router writes for its own services: /// Remote Access in `default` (behind-NAT) mode — src_ip-scoped, one /// family per rule — plus an unscoped SSH rule (`always` shape), a @@ -2785,12 +2785,25 @@ config rule 'apf_sni_8444' \toption _apf_label 'SNI' "; - async fn collisions_for(ports: Vec) -> Vec { + async fn collisions_for(ports: Vec) -> Vec { let dir = tempfile::tempdir().unwrap(); setup_firewall(dir.path(), ROUTER_SERVICE_RULES); let arena = Arena::new(); let cfgs = parse_all(dir.path(), &arena, &["firewall"]).await.unwrap(); - router_port_collisions(&cfgs["firewall"], &ports) + wan_port_collisions(&cfgs["firewall"], &ports) + } + + #[test] + fn sni_displacement_requires_tcp() { + let tcp = make_input(|port| { + port.ports = "443".into(); + port.protocol = Protocol::Tcp; + }); + let udp = make_input(|port| { + port.ports = "8443".into(); + port.protocol = Protocol::Udp; + }); + assert_eq!(active_sni_conflicting_ranges(&[tcp, udp]), vec![(443, 443)]); } #[tokio::test] @@ -2800,7 +2813,7 @@ config rule 'apf_sni_8444' let hits = collisions_for(vec![make_input(|p| p.ports = "443".into())]).await; assert_eq!(hits.len(), 1); assert_eq!(hits[0].id, "valid-id"); - assert_eq!(hits[0].router_ports, vec!["443"]); + assert_eq!(hits[0].router_service_ports, vec!["443"]); // A range sweeping several router ports reports each of them; the // external range is what counts when it differs from the device port. @@ -2810,29 +2823,23 @@ config rule 'apf_sni_8444' p.protocol = Protocol::TcpUdp; })]) .await; - assert_eq!(hits[0].router_ports, vec!["443", "22", "51820"]); + assert_eq!(hits[0].router_service_ports, vec!["443", "22", "51820"]); } #[tokio::test] async fn sni_admit_rules_are_reported_as_hostname_holders() { - // A port held only by hostname routes still collides, but lands in - // `sni_ports` so the dialog names the real holder instead of blaming - // the router. Holder details stay empty here — the demux isn't - // running in unit tests; `set` fills them in the daemon. let hits = collisions_for(vec![make_input(|p| p.ports = "8444".into())]).await; assert_eq!(hits.len(), 1); - assert!(hits[0].router_ports.is_empty()); - assert_eq!(hits[0].sni_ports.len(), 1); - assert_eq!(hits[0].sni_ports[0].ports, "8444"); - assert!(hits[0].sni_ports[0].hostnames.is_empty()); - assert!(hits[0].sni_ports[0].devices.is_empty()); - - // Remote Access and hostname routes share 443: both kinds reported, - // each under its own heading. + assert!(hits[0].router_service_ports.is_empty()); + assert_eq!(hits[0].hostname_route_ports.len(), 1); + assert_eq!(hits[0].hostname_route_ports[0].ports, "8444"); + assert!(hits[0].hostname_route_ports[0].hostnames.is_empty()); + assert!(hits[0].hostname_route_ports[0].devices.is_empty()); + let hits = collisions_for(vec![make_input(|p| p.ports = "443".into())]).await; - assert_eq!(hits[0].router_ports, vec!["443"]); - assert_eq!(hits[0].sni_ports.len(), 1); - assert_eq!(hits[0].sni_ports[0].ports, "443"); + assert_eq!(hits[0].router_service_ports, vec!["443"]); + assert_eq!(hits[0].hostname_route_ports.len(), 1); + assert_eq!(hits[0].hostname_route_ports[0].ports, "443"); } #[tokio::test] @@ -2858,7 +2865,7 @@ config rule 'apf_sni_8444' p.protocol = Protocol::Udp; })]) .await[0] - .router_ports, + .router_service_ports, vec!["51820"] ); @@ -2889,14 +2896,14 @@ config rule 'apf_sni_8444' .is_empty()); assert!(collisions_for(vec![make_input(|p| { p.ports = "443".into(); - p.override_router_ports = true; + p.override_wan_ports = true; })]) .await .is_empty()); } #[tokio::test] - async fn set_persists_router_override_flag() { + async fn set_persists_wan_override_only_while_enabled() { let dir = tempfile::tempdir().unwrap(); setup_firewall(dir.path(), ""); let ctx = TestContext(dir.path().to_path_buf()); @@ -2906,7 +2913,7 @@ config rule 'apf_sni_8444' DeserializeStdin(PublishedPortsSetRequest { ports: vec![make_input(|p| { p.ports = "443".into(); - p.override_router_ports = true; + p.override_wan_ports = true; })], }), ) @@ -2915,13 +2922,31 @@ config rule 'apf_sni_8444' let content = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); assert!( - content.contains("option _pp_router_override '1'"), + content.contains("option _pp_wan_override '1'"), "override not persisted: {content}" ); - // And it round-trips through extraction for the next save. let arena = Arena::new(); let ports = extract_ports(&arena, dir.path()).await.unwrap(); - assert!(ports[0].override_router_ports); + assert!(ports[0].override_wan_ports); + + set( + TestContext(dir.path().to_path_buf()), + DeserializeStdin(PublishedPortsSetRequest { + ports: vec![make_input(|p| { + p.enabled = false; + p.ports = "443".into(); + p.override_wan_ports = true; + })], + }), + ) + .await + .unwrap(); + + let content = std::fs::read_to_string(dir.path().join("firewall")).unwrap(); + assert!(!content.contains("_pp_wan_override")); + let arena = Arena::new(); + let ports = extract_ports(&arena, dir.path()).await.unwrap(); + assert!(!ports[0].override_wan_ports); } // ── Validation tests ── diff --git a/projects/start-wrt/backend/ctrl/src/system.rs b/projects/start-wrt/backend/ctrl/src/system.rs index 4150d22c13..26edd15fbc 100644 --- a/projects/start-wrt/backend/ctrl/src/system.rs +++ b/projects/start-wrt/backend/ctrl/src/system.rs @@ -8,7 +8,7 @@ use chrono::Utc; use imbl_value::Value; use nix::sys::signal::{self, Signal}; use nix::unistd::Pid; -use rpc_toolkit::{from_fn, from_fn_async, from_fn_async_local, HandlerExt, ParentHandler}; +use rpc_toolkit::{from_fn_async, from_fn_async_local, HandlerExt, ParentHandler}; use serde::{Deserialize, Serialize}; use tracing::instrument; use uciedit::openwrt::{FirewallRule, FirewallTarget}; @@ -565,9 +565,7 @@ pub async fn apply_remote_access(ctx: C) -> Result Ok(()) => { if ctx.effectful() { reload_firewall(wan_ipv4, wan_ipv6s); - // The SNI demux's remote-access fallback mirrors the rules - // just written — re-sync it so a mode change takes effect - // on a shared 443 without waiting for the sweep. + // Apply mode changes to the shared SNI fallback. if let (Some(pc), Some(wan)) = (crate::port_control::PORT_CONTROL.get(), wan_ipv4) { diff --git a/projects/start-wrt/backend/hotplug/99-startwrt-port-control b/projects/start-wrt/backend/hotplug/99-startwrt-port-control index cf8fbe3ad4..5b87df3c97 100644 --- a/projects/start-wrt/backend/hotplug/99-startwrt-port-control +++ b/projects/start-wrt/backend/hotplug/99-startwrt-port-control @@ -1,7 +1,5 @@ #!/bin/sh -# Re-key live SNI hostname routes when the WAN IPv4 changes: demux listeners -# bind the WAN address itself, so a new address strands them until re-keyed. -# The daemon's sweep would catch it within a minute; this makes it immediate. +# Re-key SNI listeners after WAN address changes. [ "$ACTION" = "ifup" -o "$ACTION" = "ifupdate" ] && [ "$INTERFACE" = "wan" ] && { /usr/bin/startwrt-cli published-ports wan-changed 2>/dev/null & } diff --git a/projects/start-wrt/backend/nftables/12-startwrt-sni-divert.nft b/projects/start-wrt/backend/nftables/12-startwrt-sni-divert.nft index ea7aa38e03..4eee506e72 100644 --- a/projects/start-wrt/backend/nftables/12-startwrt-sni-divert.nft +++ b/projects/start-wrt/backend/nftables/12-startwrt-sni-divert.nft @@ -1,16 +1,6 @@ -# SNI-demux reply-path divert. The demux originates the internal leg of a -# demuxed connection from the *client's* source address (IP_TRANSPARENT), so -# device->client replies transit this router addressed to the client; this rule -# marks any inbound packet belonging to such a local transparent socket, and -# the daemon's `ip rule fwmark 0x540001/0x540001 lookup 5344` delivers it into -# that socket instead of forwarding it on. `mark or` preserves unrelated mark -# bits (10-startwrt-dnat-mark.nft's 0x80) — which is also why the rule is -# matched masked. Shipping it here (fw4 re-renders /etc/nftables.d includes on -# every reload) means no reload can produce a ruleset without it. -# -# `socket transparent` needs kmod-nft-socket; without that package fw4 fails -# to load the ENTIRE ruleset, so this file and the kmod-nft-socket/ -# kmod-nf-socket entries in build/openwrt.diffconfig must ship together. +# Deliver replies for source-preserving SNI sockets locally. +# Preserve unrelated marks used by DNAT return routing. +# `socket transparent` requires kmod-nft-socket or fw4 rejects the ruleset. chain startwrt_sni_divert { type filter hook prerouting priority mangle; policy accept; meta l4proto tcp socket transparent 1 meta mark set meta mark or 0x00540001 diff --git a/projects/start-wrt/backend/uciedit/src/openwrt.rs b/projects/start-wrt/backend/uciedit/src/openwrt.rs index 72404f81d1..c92ada233e 100644 --- a/projects/start-wrt/backend/uciedit/src/openwrt.rs +++ b/projects/start-wrt/backend/uciedit/src/openwrt.rs @@ -80,9 +80,7 @@ pub struct FirewallRule { /// Published-port metadata: device MAC #[uci(default)] pub _pp_mac: Option, - /// Auto port-control metadata: marks a rule owned by the port-control - /// server ("SNI" on the WAN-input ACCEPT admitting an SNI-demux listener). - /// Mirrors [`FirewallRedirect::_apf_label`]. + /// Port-control rule owner. #[uci(default)] pub _apf_label: Option, } @@ -124,7 +122,7 @@ pub struct FirewallRedirect { /// port the router itself answers on from the WAN (remote access, SSH, /// VPN), so later saves don't re-prompt for the same collision. #[uci(default)] - pub _pp_router_override: Option, + pub _pp_wan_override: Option, } #[derive(Debug, TypedSection)] diff --git a/projects/start-wrt/build/openwrt.diffconfig b/projects/start-wrt/build/openwrt.diffconfig index 1fcfaeed8a..a4450220a3 100644 --- a/projects/start-wrt/build/openwrt.diffconfig +++ b/projects/start-wrt/build/openwrt.diffconfig @@ -130,8 +130,7 @@ CONFIG_PACKAGE_kmod-nft-core=y CONFIG_PACKAGE_kmod-nft-fib=y CONFIG_PACKAGE_kmod-nft-nat=y CONFIG_PACKAGE_kmod-nft-offload=y -# The SNI-demux divert (12-startwrt-sni-divert.nft) uses `socket transparent`; -# without this package fw4 fails to load the whole ruleset. +# Required by `socket transparent` in 12-startwrt-sni-divert.nft. CONFIG_PACKAGE_kmod-nft-socket=y # CONFIG_PACKAGE_kmod-ppp is not set CONFIG_PACKAGE_kmod-sched-core=y diff --git a/projects/start-wrt/docs/src/published-ports.md b/projects/start-wrt/docs/src/published-ports.md index a380d19b08..599ea279bf 100644 --- a/projects/start-wrt/docs/src/published-ports.md +++ b/projects/start-wrt/docs/src/published-ports.md @@ -64,7 +64,7 @@ Some devices can configure port forwarding for themselves using the standard UPn This is **off by default** for every device. To allow it, open the device's [detail page](devices.md#device-detail-page) and turn on **Allow automatic port forwarding**. From then on, that device — and only that device — can ask the router to forward ports, and only to itself: a device can never open a port that routes traffic to another device. -Forwards created this way appear in the **Automatic** section of the Published Ports page, showing which device opened them, which protocol was used (PCP or UPnP), and when they expire. They are read-only: +Port uses created this way appear in the **Automatic** section of the Published Ports page, showing which device opened them, their kind (PCP, UPnP, or SNI), and when they expire. They are read-only: - The device itself creates, renews, and removes its forwards. - A forward the device stops renewing expires and is removed automatically once the lifetime the device asked for runs out — about an hour for typical clients, and never longer than a week even for a device that asks to keep the port indefinitely. @@ -79,7 +79,7 @@ Ports the router answers on itself are protected the same way. If you have [Remo A device can also ask for a **hostname route** instead of a whole port: the router inspects each incoming TLS connection's requested hostname (SNI) and delivers it to whichever device registered that hostname, so several devices — or several services on one StartOS server — can share a single external port such as 443. StartOS servers use this automatically when you give services on a shared port their own domains. -Hostname routes appear in the same **Automatic** section with `SNI` in the Protocol column and the hostname shown alongside. They follow the same rules as other automatic forwards — per-device permission, the device renews them, they expire on their own — with two differences: a shared port is claimed whole (an ordinary forward on that port is refused while hostname routes hold it, and publishing it manually asks you to confirm), and hostname routes do not survive a router restart — the device simply re-registers them within a few minutes, so no action is needed. +Hostname routes appear in the same **Automatic** section with `SNI` in the Kind column and the hostname shown alongside. They follow the same rules as other automatic forwards — per-device permission, the device renews them, they expire on their own — with two differences: a shared port is claimed whole (an ordinary forward on that port is refused while hostname routes hold it, and publishing it manually asks you to confirm), and hostname routes do not survive a router restart — the device simply re-registers them within a few minutes, so no action is needed. A routed hostname also works from inside your own network, not only from the Internet: a laptop on your LAN can open the same public address and reach the device, with the router turning the connection around. The device sees those connections as coming from the router itself rather than from the laptop, so its own access logs and any per-client rules it applies won't tell one local device from another. Connections from a different [Security Profile](security-profiles.md), and from the Internet, still arrive with the original address intact. diff --git a/projects/start-wrt/web/src/app/i18n/dictionaries/de.ts b/projects/start-wrt/web/src/app/i18n/dictionaries/de.ts index f5a0884201..f61bdc337b 100644 --- a/projects/start-wrt/web/src/app/i18n/dictionaries/de.ts +++ b/projects/start-wrt/web/src/app/i18n/dictionaries/de.ts @@ -543,4 +543,5 @@ export default { 551: 'Über die Ports {ports} laufen derzeit Hostnamen-Routen ({list}). Werden sie veröffentlicht, geht der gesamte Datenverkehr auf diesen Ports stattdessen an das ausgewählte Gerät — diese Hostnamen-Routen funktionieren nicht mehr, bis diese Regel entfernt wird.', 552: 'Trotzdem veröffentlichen?', 553: 'Port für Hostnamen-Routen verwendet', + 554: 'Art', } satisfies i18n diff --git a/projects/start-wrt/web/src/app/i18n/dictionaries/en.ts b/projects/start-wrt/web/src/app/i18n/dictionaries/en.ts index 32e69896e2..df65da7262 100644 --- a/projects/start-wrt/web/src/app/i18n/dictionaries/en.ts +++ b/projects/start-wrt/web/src/app/i18n/dictionaries/en.ts @@ -548,4 +548,5 @@ export const ENGLISH: Record = { 'Port(s) {ports} currently carry hostname routes ({list}). Publishing them will send all traffic on these ports to the selected device instead — those hostname routes will stop working until this rule is removed.': 551, 'Publish anyway?': 552, 'Port Used for Hostname Routes': 553, + 'Kind': 554, } diff --git a/projects/start-wrt/web/src/app/i18n/dictionaries/es.ts b/projects/start-wrt/web/src/app/i18n/dictionaries/es.ts index fcb708e493..899be36ff9 100644 --- a/projects/start-wrt/web/src/app/i18n/dictionaries/es.ts +++ b/projects/start-wrt/web/src/app/i18n/dictionaries/es.ts @@ -543,4 +543,5 @@ export default { 551: 'Los puertos {ports} llevan actualmente rutas por nombre de host ({list}). Si los publica, todo el tráfico de estos puertos se enviará al dispositivo seleccionado — esas rutas por nombre de host dejarán de funcionar hasta que se elimine esta regla.', 552: '¿Publicar de todos modos?', 553: 'Puerto usado por rutas de nombre de host', + 554: 'Tipo', } satisfies i18n diff --git a/projects/start-wrt/web/src/app/i18n/dictionaries/fr.ts b/projects/start-wrt/web/src/app/i18n/dictionaries/fr.ts index 50af9134ba..ea56154585 100644 --- a/projects/start-wrt/web/src/app/i18n/dictionaries/fr.ts +++ b/projects/start-wrt/web/src/app/i18n/dictionaries/fr.ts @@ -543,4 +543,5 @@ export default { 551: 'Les ports {ports} portent actuellement des routes par nom d’hôte ({list}). En les publiant, tout le trafic de ces ports sera envoyé vers l’appareil sélectionné — ces routes par nom d’hôte cesseront de fonctionner tant que cette règle ne sera pas supprimée.', 552: 'Publier quand même ?', 553: 'Port utilisé par des routes par nom d’hôte', + 554: 'Type', } satisfies i18n diff --git a/projects/start-wrt/web/src/app/i18n/dictionaries/pl.ts b/projects/start-wrt/web/src/app/i18n/dictionaries/pl.ts index 1bb052533e..d6bd3c43d4 100644 --- a/projects/start-wrt/web/src/app/i18n/dictionaries/pl.ts +++ b/projects/start-wrt/web/src/app/i18n/dictionaries/pl.ts @@ -543,4 +543,5 @@ export default { 551: 'Porty {ports} obsługują obecnie trasy po nazwie hosta ({list}). Ich opublikowanie przekieruje cały ruch na tych portach do wybranego urządzenia — te trasy przestaną działać, dopóki ta reguła nie zostanie usunięta.', 552: 'Opublikować mimo to?', 553: 'Port używany przez trasy po nazwie hosta', + 554: 'Rodzaj', } satisfies i18n diff --git a/projects/start-wrt/web/src/app/routes/published-ports/auto-table.ts b/projects/start-wrt/web/src/app/routes/published-ports/automatic-table.ts similarity index 61% rename from projects/start-wrt/web/src/app/routes/published-ports/auto-table.ts rename to projects/start-wrt/web/src/app/routes/published-ports/automatic-table.ts index bd49058014..24fb4feca9 100644 --- a/projects/start-wrt/web/src/app/routes/published-ports/auto-table.ts +++ b/projects/start-wrt/web/src/app/routes/published-ports/automatic-table.ts @@ -2,17 +2,11 @@ import { Component, inject, input } from '@angular/core' import { RouterLink } from '@angular/router' import { TuiTable, TuiTableDirective } from '@taiga-ui/addon-table' import { TuiLink } from '@taiga-ui/core' -import { AutoForwardDisplay } from './types' +import { AutomaticPortUseDisplay } from './types' import { i18nPipe } from 'src/app/i18n/i18n.pipe' -/** - * Read-only table of forwards trusted devices opened for themselves via - * PCP/UPnP. There are no actions: the device renews or withdraws its own - * forwards, and an unrenewed forward expires on its own. To stop a device - * creating them, turn off its toggle on the device page. - */ @Component({ - selector: '[autoForwards]', + selector: '[automaticPortUses]', template: ` @@ -36,29 +30,33 @@ import { i18nPipe } from 'src/app/i18n/i18n.pipe' {{ 'Hostname' | i18n }} - - {{ 'Protocol' | i18n }} + + {{ 'Kind' | i18n }} {{ 'Expires' | i18n }} - @for (item of autoForwards() | tuiTableSort; track item.id) { + @for (item of automaticPortUses() | tuiTableSort; track item.id) { - - {{ item.deviceName || item.deviceMac }} - + @if (item.deviceMac) { + + {{ item.deviceName || item.deviceMac }} + + } @else { + {{ 'Unknown' | i18n }} + } {{ item.ports }} {{ item.publicPorts }} {{ item.hostname || '—' }} - {{ item.label }} + {{ item.kind }} {{ expiry(item) }} } @@ -68,12 +66,12 @@ import { i18nPipe } from 'src/app/i18n/i18n.pipe' host: { class: 'g-table' }, imports: [RouterLink, TuiTable, TuiLink, i18nPipe], }) -export class AutoForwardsTable { - public readonly autoForwards = input([]) +export class AutomaticPortUsesTable { + public readonly automaticPortUses = input([]) private readonly i18n = inject(i18nPipe) - protected expiry(item: AutoForwardDisplay): string { + protected expiry(item: AutomaticPortUseDisplay): string { return item.expiresSecs === undefined ? '—' : `${Math.max(1, Math.round(item.expiresSecs / 60))} ${this.i18n.transform('min')}` diff --git a/projects/start-wrt/web/src/app/routes/published-ports/index.ts b/projects/start-wrt/web/src/app/routes/published-ports/index.ts index 0afea4f4a5..335aeb12c3 100644 --- a/projects/start-wrt/web/src/app/routes/published-ports/index.ts +++ b/projects/start-wrt/web/src/app/routes/published-ports/index.ts @@ -9,7 +9,7 @@ import { provideFormService } from 'src/app/services/form.service' import { PublishPortDialog } from './dialog' import { PublishedPortsService } from './service' import { PublishedPortsTable } from './table' -import { AutoForwardsTable } from './auto-table' +import { AutomaticPortUsesTable } from './automatic-table' import { isGua, PublishedPortDialogResult, PublishedPortDisplay } from './types' import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { confirmVpnExposedPort } from 'src/app/services/vpn-exposed-port' @@ -39,7 +39,7 @@ import { confirmVpnExposedPort } from 'src/app/services/vpn-exposed-port' [tuiSkeleton]="loading()" (edit)="edit($event)" > - @if (service.autoForwards().length) { + @if (service.automaticPortUses().length) {

{{ 'Automatic' | i18n }}

@@ -53,7 +53,7 @@ import { confirmVpnExposedPort } from 'src/app/services/vpn-exposed-port'
} `, @@ -63,7 +63,7 @@ import { confirmVpnExposedPort } from 'src/app/services/vpn-exposed-port' TuiTitle, TuiButton, PublishedPortsTable, - AutoForwardsTable, + AutomaticPortUsesTable, TuiSkeleton, i18nPipe, ], diff --git a/projects/start-wrt/web/src/app/routes/published-ports/service.ts b/projects/start-wrt/web/src/app/routes/published-ports/service.ts index 4c00044a0e..0890120e44 100644 --- a/projects/start-wrt/web/src/app/routes/published-ports/service.ts +++ b/projects/start-wrt/web/src/app/routes/published-ports/service.ts @@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs' import { fill } from 'src/app/i18n/validation-errors' import { FormService } from 'src/app/services/form.service' import { - AutoForwardDisplay, + AutomaticPortUseDisplay, PublishedPort, PublishedPortDisplay, } from './types' @@ -13,10 +13,10 @@ import { DevicesApiService } from 'src/app/routes/devices/service' import { Device, DeviceUpdateData } from 'src/app/routes/devices/utils' import { ApiService, - AutoForwardFromApi, + AutomaticPortUseFromApi, PublishedPortFromApi, PublishedPortsSetRequest, - RouterPortCollision, + WanPortCollision, } from 'src/app/services/api/api.service' import { i18nPipe } from 'src/app/i18n/i18n.pipe' @@ -29,11 +29,9 @@ export class PublishedPortsService extends FormService { private devices: Device[] = [] - /** Automatic (PCP/UPnP-created) forwards; refreshed alongside the manual list. */ - readonly autoForwards = signal([]) + readonly automaticPortUses = signal([]) async load(): Promise { - // Load devices (for reserveDeviceIpv4) and both port lists in parallel const [devices, portsFromApi, autoFromApi] = await Promise.all([ this.devicesApi.get(), this.api.publishedPortsList(), @@ -41,53 +39,37 @@ export class PublishedPortsService extends FormService { ]) this.devices = devices - this.autoForwards.set(autoFromApi.map(autoFromApiToDisplay)) + this.automaticPortUses.set( + autoFromApi.map(automaticPortUseFromApiToDisplay), + ) return portsFromApi.map(fromApiToDisplay) } - /** - * The backend applies the request unless an unconfirmed forward captures a - * port the router itself answers on from the WAN (remote access, SSH, VPN) - * — then it reports the collisions and applies nothing. Surface those for - * confirmation and re-save with the override set on the named ports, so the - * question is asked once per port (the override is persisted). The re-save - * recurses through this same path: a collision that appears between attempts - * (the config changed while the dialog was open) gets its own prompt rather - * than a silently unapplied save. Overriding save() here covers every call - * site: dialog save, toggle, delete. - */ override async save(data: PublishedPortDisplay[]): Promise { - let pending: RouterPortCollision[] = [] + let pending: WanPortCollision[] = [] const ok = await this.actions.run(async () => { const result = await this.api.publishedPortsSet(this.buildRequest(data)) - pending = result.pending_router_port_collisions + pending = result.pending_wan_port_collisions if (!pending.length) await this.refreshAndWait() }) if (!ok || !pending.length) return ok - if (!(await this.confirmRouterPortOverride(pending))) { - return false - } + if (!(await this.confirmWanPortOverride(pending))) return false + const ids = new Set(pending.map(c => c.id)) return this.save( - data.map(p => (ids.has(p.id) ? { ...p, overrideRouterPorts: true } : p)), + data.map(p => (ids.has(p.id) ? { ...p, overrideWanPorts: true } : p)), ) } - /** - * If `pending` is non-empty, prompt the user to confirm publishing port(s) - * something else already answers on from the WAN, naming the actual holder: - * the router's own services (remote access to its web interface, SSH, a VPN - * server), a device's SNI hostname routes, or both on a shared port. - * Returns true when there is nothing to confirm or the user confirmed, false - * when they cancelled. - */ - private async confirmRouterPortOverride( - pending: RouterPortCollision[], + private async confirmWanPortOverride( + pending: WanPortCollision[], ): Promise { if (!pending.length) return true - const routerPorts = [...new Set(pending.flatMap(c => c.router_ports))] - const sni = pending.flatMap(c => c.sni_ports) + const routerPorts = [ + ...new Set(pending.flatMap(c => c.router_service_ports)), + ] + const sni = pending.flatMap(c => c.hostname_route_ports) const parts: string[] = [] if (routerPorts.length) { parts.push( @@ -156,7 +138,7 @@ export class PublishedPortsService extends FormService { ipv6: item.ipv6, ipv4_public_port: item.ipv4PublicPort, source: item.source, - override_router_ports: item.overrideRouterPorts ?? false, + override_wan_ports: item.overrideWanPorts ?? false, })), } } @@ -169,11 +151,6 @@ export class PublishedPortsService extends FormService { return this.devices.find(d => d.mac?.toUpperCase() === mac.toUpperCase()) } - /** - * Reserve the device's current IPv4 address as a static lease. There is no - * IPv6 counterpart: the device chooses its own IPv6 address (SLAAC), so the - * router cannot reserve one. - */ async reserveDeviceIpv4(mac: string): Promise { const device = this.getDevice(mac) if (!device) return @@ -189,9 +166,6 @@ export class PublishedPortsService extends FormService { device.ipv4Static = true } - /** - * Check if a device has any published ports - */ deviceHasPublishedPorts(mac: string): boolean { const data = this.data() if (!data) return false @@ -199,20 +173,21 @@ export class PublishedPortsService extends FormService { } } -function autoFromApiToDisplay(a: AutoForwardFromApi): AutoForwardDisplay { +function automaticPortUseFromApiToDisplay( + portUse: AutomaticPortUseFromApi, +): AutomaticPortUseDisplay { return { - id: a.id, - label: a.label, - deviceMac: a.device_mac, - deviceName: a.device_name ?? undefined, - ports: a.ports, - publicPorts: a.public_ports, - expiresSecs: a.expires_secs ?? undefined, - hostname: a.hostname ?? undefined, + id: portUse.id, + kind: portUse.kind, + deviceMac: portUse.device_mac, + deviceName: portUse.device_name ?? undefined, + ports: portUse.ports, + publicPorts: portUse.public_ports, + expiresSecs: portUse.expires_secs ?? undefined, + hostname: portUse.hostname ?? undefined, } } -/** Map backend snake_case response to frontend camelCase types */ function fromApiToDisplay(p: PublishedPortFromApi): PublishedPortDisplay { return { id: p.id, @@ -225,7 +200,7 @@ function fromApiToDisplay(p: PublishedPortFromApi): PublishedPortDisplay { ipv6: p.ipv6, ipv4PublicPort: p.ipv4_public_port ?? undefined, source: p.source, - overrideRouterPorts: p.override_router_ports, + overrideWanPorts: p.override_wan_ports, status: p.status, statusReason: p.status_reason ?? undefined, deviceName: p.device_name ?? undefined, diff --git a/projects/start-wrt/web/src/app/routes/published-ports/types.ts b/projects/start-wrt/web/src/app/routes/published-ports/types.ts index 9847c28e56..e40615a9d4 100644 --- a/projects/start-wrt/web/src/app/routes/published-ports/types.ts +++ b/projects/start-wrt/web/src/app/routes/published-ports/types.ts @@ -1,12 +1,7 @@ +import type { AutomaticPortUseKind } from 'src/app/services/api/api.service' + export type Protocol = 'tcp' | 'udp' | 'tcp+udp' -/** - * Mirror of the backend `is_gua`: a Global Unicast Address (2000::/3) is the - * only IPv6 scope reachable from the WAN. ULA (fc00::/7) and link-local - * (fe80::/10) are not, so they can't back an IPv6 published port. Checks the - * leading hextet against 2000::/3, matching the backend's parsed range check - * (a malformed/empty leading hextet — e.g. from `::1` — yields NaN → false). - */ export function isGua(ip: string): boolean { const firstHextet = parseInt(ip.split(':')[0], 16) return firstHextet >= 0x2000 && firstHextet <= 0x3fff @@ -14,29 +9,24 @@ export function isGua(ip: string): boolean { export type PublishedPortStatus = | 'active' - | 'partial' // IPv4 unavailable (e.g., CGNAT) - | 'paused' // Device offline or identity mismatch - | 'error' // Failed to apply rule + | 'partial' + | 'paused' + | 'error' | 'disabled' export interface PublishedPort { - id: string // Unique identifier + id: string enabled: boolean label: string - deviceMac: string // Link to device by MAC - ports: string // Internal port/range (e.g., "8123" or "27015-27030") + deviceMac: string + ports: string protocol: Protocol ipv4: boolean ipv6: boolean - ipv4PublicPort?: string // External port for IPv4 (defaults to internal) - source: 'any' | string // 'any' or CIDR like "203.0.113.0/24" - /** - * The user confirmed capturing a port the router answers on itself (remote - * access, SSH, VPN). Round-tripped so later saves of the full list don't - * re-prompt; absent/false on ports built by the edit dialog, so editing a - * rule re-validates it. - */ - overrideRouterPorts?: boolean + ipv4PublicPort?: string + source: 'any' | string + /** Preserves prior WAN collision confirmation. */ + overrideWanPorts?: boolean } export interface PublishedPortDialogResult { @@ -50,22 +40,17 @@ export interface PublishedPortDisplay extends PublishedPort { deviceName?: string deviceIpv4?: string deviceIpv6?: string - endpointIpv4?: string // e.g., "example.ddns.net:8123" - endpointIpv6?: string // e.g., "[2001:db8::50]:8123" + endpointIpv4?: string + endpointIpv6?: string } -/** - * A forward a trusted device opened for itself via PCP or UPnP. Read-only in - * the UI: the device renews or withdraws it, and unrenewed forwards expire. - */ -export interface AutoForwardDisplay { +export interface AutomaticPortUseDisplay { id: string - label: string // "PCP" | "UPnP" | "SNI" + kind: AutomaticPortUseKind deviceMac: string deviceName?: string ports: string publicPorts: string expiresSecs?: number - /** TLS-SNI hostname for an SNI route sharing its port with other hostnames */ hostname?: string } diff --git a/projects/start-wrt/web/src/app/services/api/api.service.ts b/projects/start-wrt/web/src/app/services/api/api.service.ts index a521d76602..7085081d0a 100644 --- a/projects/start-wrt/web/src/app/services/api/api.service.ts +++ b/projects/start-wrt/web/src/app/services/api/api.service.ts @@ -79,7 +79,7 @@ export abstract class ApiService { abstract publishedPortsSet( params: PublishedPortsSetRequest, ): Promise - abstract publishedPortsAutoList(): Promise + abstract publishedPortsAutoList(): Promise abstract vpnClientList(): Promise abstract vpnClientCreate( params: OutboundVpnCreateRequest, @@ -636,8 +636,7 @@ export interface PublishedPortFromApi { ipv6: boolean ipv4_public_port: string | null source: string - /** The user confirmed capturing a port the router answers on itself. */ - override_router_ports: boolean + override_wan_ports: boolean status: PublishedPortStatusValue status_reason: string | null device_name: string | null @@ -656,67 +655,42 @@ export interface PublishedPortInputForApi { ipv6: boolean ipv4_public_port?: string | null source: string - /** - * Confirms forwarding a port the router itself answers on from the WAN. - * Without it, a colliding port makes `set` report the collision and apply - * nothing (see PublishedPortsSetResult). - */ - override_router_ports: boolean + /** Confirms an enabled IPv4 WAN collision. */ + override_wan_ports: boolean } export type PublishedPortsSetRequest = { ports: PublishedPortInputForApi[] } -/** - * An enabled IPv4 forward whose external range captures a port the router - * itself answers on from the WAN (remote access 80/443/22, the VPN server's - * listen port). DNAT precedes the routing decision, so saving it would divert - * those router services to the device — the user must confirm by re-saving - * with `override_router_ports` on the named port. - */ -export interface RouterPortCollision { +export interface WanPortCollision { id: string label: string - /** The colliding router-service port spec(s), e.g. ["443", "22"]. */ - router_ports: string[] - /** - * Colliding ports whose holder is a device's SNI hostname routes rather - * than a router service, so the dialog can name the actual use. The - * override semantics are the same. - */ - sni_ports: SniPortUse[] + router_service_ports: string[] + hostname_route_ports: SniPortUse[] } -/** One colliding port held by hostname routes: the spec plus who is using it. */ export interface SniPortUse { ports: string - /** The routed hostnames on the port, deduped and sorted. */ hostnames: string[] - /** Display names (or MACs) of the devices the routes deliver to. */ devices: string[] } -// A non-empty collision list means nothing was applied — confirm and re-save. export type PublishedPortsSetResult = { - pending_router_port_collisions: RouterPortCollision[] + pending_wan_port_collisions: WanPortCollision[] } -/** - * A forward created automatically by an authorized LAN device via PCP or UPnP. - * Read-only: the device renews or withdraws it; unrenewed forwards expire. - */ -export interface AutoForwardFromApi { +export type AutomaticPortUseKind = 'PCP' | 'UPnP' | 'SNI' + +export interface AutomaticPortUseFromApi { id: string - /** Which protocol created it: "PCP", "UPnP", or "SNI" (a hostname route). */ - label: string + kind: AutomaticPortUseKind device_mac: string device_name: string | null internal_ip: string | null ports: string public_ports: string expires_secs: number | null - /** TLS-SNI hostname for an SNI route sharing its port; null for forwards. */ hostname: string | null } diff --git a/projects/start-wrt/web/src/app/services/api/live-api.service.ts b/projects/start-wrt/web/src/app/services/api/live-api.service.ts index 85954e08ff..6296c528ba 100644 --- a/projects/start-wrt/web/src/app/services/api/live-api.service.ts +++ b/projects/start-wrt/web/src/app/services/api/live-api.service.ts @@ -54,7 +54,7 @@ import { PublishedPortFromApi, PublishedPortsSetRequest, PublishedPortsSetResult, - AutoForwardFromApi, + AutomaticPortUseFromApi, OutboundVpn, OutboundVpnCreateRequest, OutboundVpnCreateResponse, @@ -346,7 +346,7 @@ export class LiveApiService extends ApiService { return this.rpc.request({ method: 'published-ports.set', params }) } - async publishedPortsAutoList(): Promise { + async publishedPortsAutoList(): Promise { return this.rpc.request({ method: 'published-ports.auto-list', params: {}, diff --git a/projects/start-wrt/web/src/app/services/api/mock-api.service.ts b/projects/start-wrt/web/src/app/services/api/mock-api.service.ts index e34cb1b81b..ecc911bdd6 100644 --- a/projects/start-wrt/web/src/app/services/api/mock-api.service.ts +++ b/projects/start-wrt/web/src/app/services/api/mock-api.service.ts @@ -58,7 +58,7 @@ import { PublishedPortFromApi, PublishedPortsSetRequest, PublishedPortsSetResult, - AutoForwardFromApi, + AutomaticPortUseFromApi, OutboundVpn, OutboundVpnCreateRequest, OutboundVpnCreateResponse, @@ -1016,6 +1016,7 @@ export class MockApiService extends ApiService { this.mockDeviceDefs = this.mockDeviceDefs.filter( d => d.mac.toUpperCase() !== macUpper, ) + this.autoForwardAllowed.delete(macUpper) this.logActivity( 'device', 'deleted', @@ -1274,7 +1275,7 @@ export class MockApiService extends ApiService { ipv6: true, ipv4_public_port: null, source: 'any', - override_router_ports: false, + override_wan_ports: false, status: 'active', status_reason: null, device_name: 'Home Server', @@ -1292,7 +1293,7 @@ export class MockApiService extends ApiService { ipv6: false, ipv4_public_port: null, source: 'any', - override_router_ports: false, + override_wan_ports: false, status: 'active', status_reason: null, device_name: 'Gaming PC', @@ -1310,7 +1311,7 @@ export class MockApiService extends ApiService { ipv6: true, ipv4_public_port: '2222', source: '203.0.113.0/24', - override_router_ports: false, + override_wan_ports: false, status: 'disabled', status_reason: null, device_name: null, @@ -1372,30 +1373,22 @@ export class MockApiService extends ApiService { ): Promise { await pauseFor(250) - // Router-port collision handshake (matches the real backend): an enabled, - // unconfirmed IPv4 forward capturing a port the router answers on itself - // (Remote Access 80/443/22, TCP — active unless remote access is off) or - // one held by the mock SNI hostname route on 443 (see - // publishedPortsAutoList) reports the collision and applies nothing. const sniMac = '00:1A:2B:3C:4D:5E' const pending = params.ports .filter( p => - p.enabled && - p.ipv4 && - !p.override_router_ports && - p.protocol !== 'udp', + p.enabled && p.ipv4 && !p.override_wan_ports && p.protocol !== 'udp', ) .map(p => { const spec = p.ipv4_public_port || p.ports const [lo, hi = lo] = spec.split('-').map(Number) - const router_ports = + const router_service_ports = this.mockSystemInfo.remoteAccess !== 'never' ? ['80', '443', '22'].filter( rp => Number(rp) >= lo && Number(rp) <= hi, ) : [] - const sni_ports = + const hostname_route_ports = this.autoForwardAllowed.has(sniMac) && lo <= 443 && 443 <= hi ? [ { @@ -1405,11 +1398,18 @@ export class MockApiService extends ApiService { }, ] : [] - return { id: p.id, label: p.label, router_ports, sni_ports } + return { + id: p.id, + label: p.label, + router_service_ports, + hostname_route_ports, + } }) - .filter(c => c.router_ports.length || c.sni_ports.length) + .filter( + c => c.router_service_ports.length || c.hostname_route_ports.length, + ) if (pending.length) { - return { pending_router_port_collisions: pending } + return { pending_wan_port_collisions: pending } } // Auto-reserve static IPv4 for enabled ports (matches real backend @@ -1449,10 +1449,10 @@ export class MockApiService extends ApiService { 'updated', `Updated published ports (${params.ports.length} rule${params.ports.length !== 1 ? 's' : ''})`, ) - return { pending_router_port_collisions: [] } + return { pending_wan_port_collisions: [] } } - async publishedPortsAutoList(): Promise { + async publishedPortsAutoList(): Promise { await pauseFor(250) const mac = '00:1A:2B:3C:4D:5E' if (!this.autoForwardAllowed.has(mac)) return [] @@ -1460,7 +1460,7 @@ export class MockApiService extends ApiService { return [ { id: 'apf_001a2b3c4d5e_5443', - label: 'PCP', + kind: 'PCP', device_mac: mac, device_name: device.name, internal_ip: device.ipv4, @@ -1471,7 +1471,7 @@ export class MockApiService extends ApiService { }, { id: 'apf_001a2b3c4d5e_80', - label: 'UPnP', + kind: 'UPnP', device_mac: mac, device_name: device.name, internal_ip: device.ipv4, @@ -1482,9 +1482,9 @@ export class MockApiService extends ApiService { }, { id: 'sni_443_nextcloud.example.com', - label: 'SNI', - device_mac: mac, - device_name: device.name, + kind: 'SNI', + device_mac: '', + device_name: null, internal_ip: device.ipv4, ports: '443', public_ports: '443', diff --git a/rfcs/draft-start9-upnp-hostname.md b/rfcs/draft-start9-upnp-hostname.md new file mode 100644 index 0000000000..bcd72da90c --- /dev/null +++ b/rfcs/draft-start9-upnp-hostname.md @@ -0,0 +1,377 @@ +--- +v: 3 +title: UPnP WANIPConnection Actions for Hostname-Based Port Mappings +abbrev: UPnP Hostname Mappings +docname: draft-start9-upnp-hostname-00 +category: std +submissiontype: IETF +consensus: false +ipr: trust200902 +area: Internet +workgroup: Individual Submission +keyword: + - UPnP + - SNI + - NAT + - port mapping + - WANIPConnection +date: 2026-08-31 +author: + - ins: A. McClelland + name: Aiden McClelland + org: Start9 + email: me@drbonez.dev +normative: + RFC6066: + UDA2: + title: UPnP Device Architecture 2.0 + date: 2020-04-17 + author: + - org: Open Connectivity Foundation + target: https://openconnectivity.org/wp-content/uploads/2023/10/UPnP-arch-DeviceArchitecture-v2.0-20200417.pdf + WANIPC1: + title: WANIPConnection:1 Service + date: 2001-11-12 + author: + - org: UPnP Forum + target: http://upnp.org/specs/gw/UPnP-gw-WANIPConnection-v1-Service.pdf +informative: + RFC6887: +--- + +abstract + +This document defines two vendor-defined actions for the UPnP +WANIPConnection:1 service. The actions create and delete finite-lived TCP port +mappings selected by the hostname in the TLS Server Name Indication (SNI) +extension. Multiple internal hosts can thereby share an external address and +port while retaining end-to-end TLS. The actions are discoverable through the +existing WANIPConnection service description and do not change the behavior of +standard WANIPConnection actions. + +--- middle + +# Introduction {#intro} + +A conventional UPnP Internet Gateway Device (IGD) port mapping assigns an +external port to one internal endpoint. A gateway that can inspect the TLS +ClientHello without terminating TLS can instead use SNI to select among +multiple internal endpoints sharing that external port. + +This document specifies vendor-defined WANIPConnection:1 actions for creating +and deleting such hostname mappings. The extension uses the action-extension +mechanism defined by {{UDA2}}. It does not modify `AddPortMapping`, +`DeletePortMapping`, or any other standard action in {{WANIPC1}}. + +The actions provide a UPnP control path for the same class of mapping that can +be represented by a PCP hostname option. The two control protocols are +independent; support for one does not imply support for the other. + +## Requirements Language {#conventions} + +{::boilerplate bcp14-tagged} + +## Terminology {#terminology} + +This document uses the UPnP and WANIPConnection terminology of {{UDA2}} and +{{WANIPC1}}. In addition: + +Control point: +: the UPnP client invoking a WANIPConnection action. + +Hostname mapping: +: a finite-lived association from an external IPv4 address, TCP port, and +hostname to an internal IPv4 address and TCP port. + +Mapping owner: +: the control point authorized to create, renew, and delete a hostname mapping. +Ownership is scoped by the control point's source address and the internal port +in addition to the externally visible mapping tuple. + +SNI demultiplexer: +: the gateway function that reads the TLS ClientHello, selects a hostname +mapping using the SNI `HostName`, and forwards the TCP stream without +terminating TLS. + +# Protocol Overview {#overview} + +A supporting gateway adds two actions to the action list of its existing +`urn:schemas-upnp-org:service:WANIPConnection:1` service: + +- `X_START9_AddHostnameMapping` +- `X_START9_DeleteHostnameMapping` + +A control point discovers support by retrieving the WANIPConnection SCPD as +specified by {{UDA2}} and checking that both action names are present. A +control point that requires the complete mapping lifecycle MUST treat the +extension as unsupported unless both actions are advertised. + +The control point invokes the actions using the WANIPConnection control URL and +SOAP rules already used for standard actions. A successful add creates or +renews a finite lease. A successful delete removes the mapping owned by the +requesting control point. Both actions apply only to TCP mappings. + +The extension adds declarations to an existing SCPD but does not alter standard +action declarations or semantics. Control points that do not recognize these +actions continue to use the standard WANIPConnection actions. A gateway MUST +NOT infer hostname-mapping support from the presence of ordinary UPnP IGD +support. + +# Common Processing Rules {#common} + +## Transport and Lease Requirements {#transport-lease} + +Both actions defined by this document operate exclusively on TCP hostname +mappings. A control point invoking either action MUST set `NewProtocol` to +`TCP`. A gateway MUST reject any other value with error 402, `Invalid Args`. + +Mappings managed by either action MUST be finite-lived. A gateway MUST NOT use +these actions to create, identify, renew, or delete a permanent mapping. The +maximum granted lifetime is 3600 seconds. A control point MUST renew a mapping +before the granted lifetime expires if continued operation is required. + +## Hostname Syntax and Matching {#hostname} + +`NewHostname` carries an ASCII hostname from 1 through 255 octets. It consists +of non-empty labels separated by dots. Each label contains only ASCII letters, +digits, or hyphens. The name has no trailing dot. Internationalized names are +carried as A-labels. Comparison is case-insensitive, and a gateway SHOULD store +the lowercase form. + +A leading label consisting only of `*` is permitted and matches exactly one +left-most label. An exact hostname mapping takes precedence over a wildcard +mapping. A `*` appearing anywhere else is invalid. + +Literal IPv4 and IPv6 addresses, empty labels, non-ASCII characters, leading or +trailing dots, and names longer than 255 octets are invalid. A gateway MUST +reject an invalid `NewHostname` with error 402, `Invalid Args`. + +## Mapping Identity and Ownership {#ownership} + +For conflict detection, a hostname mapping is selected by the external IPv4 +address, external TCP port, and normalized hostname. Only one internal target +can hold that selection at a time. + +The gateway determines the external IPv4 address associated with the control +point. The gateway MUST use the control point's source IPv4 address as the +internal address of the mapping, regardless of the value supplied in +`NewInternalClient`. This prevents a control point from publishing another +internal host through this action. + +The gateway MUST scope renewal and deletion to the mapping owner. The +`NewInternalPort` argument is part of that ownership check. A control point +MUST NOT be permitted to renew, delete, or probe another control point's +mapping by changing only the internal address or port. + +# `X_START9_AddHostnameMapping` {#add} + +## Input Arguments {#add-args} + +The action has the following required input arguments, in this order: + +| Argument | UPnP data type | Semantics | +| --------------------------- | -------------- | ------------------------------------------------------------------------------------------- | +| `NewRemoteHost` | string | MUST be empty; non-empty source filters are not supported by this extension. | +| `NewExternalPort` | `ui2` | External TCP port. Zero is invalid. | +| `NewProtocol` | string | MUST be `TCP`. | +| `NewInternalPort` | `ui2` | Internal TCP port. Zero is invalid. | +| `NewInternalClient` | string | Address asserted by the control point; the gateway uses the request source address instead. | +| `NewEnabled` | boolean | MUST indicate enabled. | +| `NewPortMappingDescription` | string | Control-point-provided description. | +| `NewLeaseDuration` | `ui4` | Requested lifetime in seconds. Zero requests the gateway's finite default. | +| `NewHostname` | string | Hostname demultiplexing key, as specified in {{hostname}}. | + +All arguments MUST appear even when their value is empty. A gateway MUST reject +a missing or malformed required argument with error 402, `Invalid Args`. + +`NewRemoteHost`, `NewEnabled`, and `NewPortMappingDescription` retain the wire +shape of `AddPortMapping` for interoperability with WANIPConnection SOAP +stacks. A gateway implementing this specification MAY apply a non-empty +`NewRemoteHost` as an additional source filter. A gateway that does not support +that filter MUST reject a non-empty value with error 801, +`HostnameNotSupported`; it MUST NOT silently broaden the requested mapping. + +## Lease Processing {#add-lease} + +A nonzero `NewLeaseDuration` requests that number of seconds. The gateway MUST +grant a finite lifetime no greater than the requested lifetime and no greater +than 3600 seconds. A zero value requests a server-selected finite lifetime no +greater than 3600 seconds; it does not request a permanent lease for this +action. + +The action has no output argument for the granted lifetime. A control point +therefore MUST assume that the mapping expires no later than 3600 seconds after +a successful response and SHOULD renew it sufficiently before that time. A +renewal uses the same external port, internal port, hostname, and owner. + +## Server Behavior {#add-behavior} + +After validating and authorizing the request, the gateway determines its +external IPv4 address and creates a mapping from that address, +`NewExternalPort`, and `NewHostname` to the request source address and +`NewInternalPort`. + +If the same owner and target already hold the mapping, the gateway MUST treat +the request as a renewal and replace its remaining lifetime according to +{{add-lease}}. If another target holds the hostname on the same external +address and port, the gateway MUST leave the existing mapping unchanged and +return error 800, `HostnameTaken`. + +On success, the gateway returns an +`X_START9_AddHostnameMappingResponse` element with no output arguments. + +# `X_START9_DeleteHostnameMapping` {#delete} + +## Input Arguments {#delete-args} + +The action has the following required input arguments, in this order: + +| Argument | UPnP data type | Semantics | +| ----------------- | -------------- | -------------------------------------------------- | +| `NewRemoteHost` | string | MUST be empty. | +| `NewExternalPort` | `ui2` | External TCP port. | +| `NewProtocol` | string | MUST be `TCP`. | +| `NewInternalPort` | `ui2` | Internal TCP port and part of the ownership check. | +| `NewHostname` | string | Hostname key, as specified in {{hostname}}. | + +All arguments MUST appear even when their value is empty. A gateway MUST reject +a missing or malformed required argument with error 402, `Invalid Args`. + +Unlike `DeletePortMapping`, this action carries `NewInternalPort`. A hostname +mapping's target and ownership include the requesting control point and its +internal port, while multiple targets can share one external TCP port under +different hostnames. + +## Server Behavior {#delete-behavior} + +The gateway reconstructs the target from the request source address and +`NewInternalPort`, then removes the matching finite-lived TCP hostname mapping. +The action MUST NOT remove a conventional port mapping, a permanent +operator-created hostname mapping, another hostname on the same port, or a +mapping owned by another control point. + +Deletion SHOULD be idempotent. To avoid exposing another control point's +mapping state, a gateway MAY return success when no owner-matching mapping +exists. On success, the gateway returns an +`X_START9_DeleteHostnameMappingResponse` element with no output arguments. + +# Error Responses {#errors} + +Errors use the SOAP fault format defined by {{UDA2}}. The following codes apply: + +| Code | Description | Condition | +| ---- | ----------------------- | --------------------------------------------------------------------------------------------- | +| 402 | `Invalid Args` | A required argument is absent or malformed, a port is invalid, or `NewProtocol` is not `TCP`. | +| 501 | `Action Failed` | The gateway cannot complete the action for an otherwise valid request. | +| 606 | `Action not authorized` | The control point is not authorized to manage hostname mappings. | +| 800 | `HostnameTaken` | Another target holds the hostname on the selected external address and port. | +| 801 | `HostnameNotSupported` | The gateway lacks SNI demultiplexing or an optional requested behavior. | + +Codes 800 and 801 are vendor-defined UPnP errors from the 800--899 range. A +malformed hostname is reported as 402 rather than 800 or 801. + +A gateway that advertises both actions but cannot provide an SNI +demultiplexer MUST return 801. Advertising the actions does not permit a +gateway to accept a mapping that its data plane cannot serve. + +# Discovery {#discovery} + +Discovery follows the existing UPnP IGD process: + +1. The control point discovers the InternetGatewayDevice and its + WANIPConnection:1 service according to {{UDA2}}. +2. The control point retrieves that service's SCPD. +3. The control point checks the SCPD action list for both action names in + {{overview}}. +4. The control point invokes the advertised actions at that service's control + URL. + +No separate capability probe, service, control URL, or evented state variable is +required. The action declarations MUST reference the existing WANIPConnection +state variables where their semantics match. `NewHostname` references a +vendor-defined, non-evented string state variable named `X_START9_Hostname`. + +A control point MUST NOT invoke either action merely because the gateway's +manufacturer or model is recognized. The SCPD is the authoritative capability +signal for the current service instance. + +# Data-Plane Behavior {#data-plane} + +For an inbound TCP connection to an external port with hostname mappings, the +gateway reads enough of the TLS ClientHello to obtain the `server_name` +extension defined by {{RFC6066}}. It selects an exact hostname mapping first, +then a wildcard mapping. The gateway forwards the buffered bytes and subsequent +stream without terminating TLS. + +If no mapping matches, the gateway MAY forward to a conventional mapping on the +same external port if one exists. Otherwise, it MUST NOT select an arbitrary +hostname mapping and SHOULD close the connection. + +The gateway MUST bound the bytes, time, and concurrent state used while waiting +for a ClientHello. The gateway SHOULD preserve the external client's source +address and port when forwarding to the internal endpoint. If it does not, +operators and internal endpoints need to account for the gateway address as the +apparent peer. + +This specification does not define hostname demultiplexing for UDP or QUIC. +Both actions reject `UDP` even if the gateway independently supports QUIC +demultiplexing through another protocol. + +# Security Considerations {#security} + +The actions create externally reachable mappings and therefore MUST be subject +to the gateway's authorization policy for port mapping. A gateway SHOULD limit +the actions to control points on an authorized internal network. Where stronger +identity is available, ownership SHOULD be bound to that identity rather than +source address alone. + +Forcing the target address to the control request's source address prevents one +internal host from directing traffic to another. Owner-scoped deletion prevents +a control point from removing another control point's mapping. An implementation +MUST perform these checks before changing mapping state. + +The hostname in a request and the SNI in an inbound ClientHello are untrusted. +An implementation MUST NOT use either value to construct file paths or commands +and SHOULD sanitize them before logging. Hostname comparison is +case-insensitive, but authorization and ownership comparisons MUST NOT depend on +hostname casing. + +Waiting for a ClientHello creates state before an internal target is selected. +An implementation MUST impose buffer, timeout, and concurrency limits to +mitigate handshake-and-stall attacks. + +A compromised authorized control point can claim an unbound hostname first. +Deployments requiring stronger policy MAY restrict the hostnames each control +point can claim or verify that a hostname resolves to the gateway's external +address. DNS verification is policy, not proof of control, and does not replace +control-point authorization. + +# Privacy Considerations {#privacy} + +A hostname mapping reveals the requested hostname to the gateway and to any +party authorized to inspect its mapping state. Implementations SHOULD minimize +retention of expired mappings and SHOULD avoid logging hostnames unless needed +for operation or auditing. + +The data plane observes the SNI already visible in an unencrypted TLS +ClientHello. This mechanism does not decrypt TLS application data. Encrypted +ClientHello can hide the origin hostname; a gateway can route only on a name +visible in the outer ClientHello and cannot demultiplex on an encrypted name. + +Advertising the actions in the SCPD reveals that the gateway supports hostname +mappings, but it does not reveal configured hostnames. + +# IANA Considerations {#iana} + +This document requests no IANA actions. The action names and error codes are in +vendor-defined namespaces provided by {{UDA2}} and {{WANIPC1}}. Publication of +this document would not assign these names to the UPnP or IANA standards +namespace and would not change the status of the underlying WANIPConnection +service. + +--- back + +# Acknowledgements + +The authors thank implementers and reviewers of UPnP IGD port-mapping software +whose interoperability feedback informed this specification. diff --git a/rfcs/upnp-vendor-hostname-action.md b/rfcs/upnp-vendor-hostname-action.md deleted file mode 100644 index 750d147537..0000000000 --- a/rfcs/upnp-vendor-hostname-action.md +++ /dev/null @@ -1,336 +0,0 @@ -# UPnP Vendor-Defined Action for SNI Hostname Mappings - -Status: accepted as a Start9-internal design; implemented (server + StartOS -client, branch `wrt/upnp-hostname-action`). Not a standards submission: unlike -the PCP companion (`draft-start9-pcp-hostname`), a UPnP vendor-defined action -has no standards venue — the SCPD is the external documentation. - -A second front door onto the existing SNI demux: today a client binds a hostname -to a shared external port only over PCP (the `HOSTNAME` private-use option, -`rfcs/draft-start9-pcp-hostname.md`). This adds a UPnP IGD vendor-defined action -that does the same thing, so a client whose gateway is reachable over UPnP but -not PCP can still get an SNI-demuxed mapping. - -Almost all of it lands in already-shared code, so StartTunnel and StartWRT get -the server side from one change. - -## Background - -### What exists - -**Server.** `shared-libs/crates/start-core/src/net/port_map/server/igd.rs` is the -shared UPnP IGD server. `handle_control` (`:183`) dispatches four standard SOAP actions — -`GetExternalIPAddress`, `AddPortMapping`, `AddAnyPortMapping`, -`DeletePortMapping` — and falls through to `fault(401, "Invalid Action")`. It is -used by StartTunnel today and by StartWRT as of PR #3634. - -**SNI backend.** `GatewayBackend` (`server/mod.rs`) already carries -`add_sni_forward` / `remove_sni_forward`, driven by the PCP `HOSTNAME` path. -PR #3634 makes the dataplane optional — `fn sni(&self) -> Option<&Arc>` -— with `add_sni_forward` early-returning when it is `None`. - -**Client.** `net/port_map/client.rs:695` short-circuits: _"HOSTNAME (SNI-demux) -mapping: PCP-only, since NAT-PMP/UPnP can't demux by SNI."_ Support is confirmed -per-gateway via a PCP `ANNOUNCE` capability marker, cached as -`pcp_hostname: CapabilityVerdict` (`db/model/public.rs:292`) with a negative -trust window. - -### Why a vendor action is legal UPnP - -The UPnP Device Architecture permits a vendor to add non-standard actions to a -service, named `X__` and declared in the SCPD beside the standard -ones. Clients ignore actions they do not recognize. AVM ships `X_AVM-DE_*` on -FRITZ!Box WANIPConnection in volume, which is the deployment evidence that -extending a standard service this way does not upset third-party clients. - -### Why a second transport, given PCP already works - -The feature still requires a Start9 gateway on the other end — a third-party -router has no SNI demux dataplane regardless of how it is asked. So this adds no -reach on the _gateway_ side; every Start9 gateway speaks PCP already. - -It adds reach on the **client** side, in two ways: - -1. **The existing fallback chain.** The port-mapping client tries PCP, NAT-PMP, - and UPnP against a gateway. Hostname mappings are the one capability with no - UPnP rung, so a client that reaches a Start9 gateway over UPnP but not PCP — - UDP 5351 filtered by an intermediate device, a source-address binding the - PCP path cannot satisfy — silently loses SNI demux while ordinary forwards - keep working. This closes that asymmetry. -2. **Third-party client implementers.** A `HOSTNAME` mapping over PCP means - implementing a private-use option over raw UDP. The same thing over UPnP is a - SOAP POST against a documented, discoverable action. For anyone outside Start9 - writing a client, that is a large difference in cost. - -Note that "vendor-defined action" is UPnP terminology with no PCP counterpart to -add: PCP's extension mechanism is the private-use option range, and it is already -implemented and shipping as `OPTION_HOSTNAME = 224`. This proposal brings the -UPnP side up to parity with a PCP capability that already exists. - -## Goals - -- A client that can reach the gateway's UPnP IGD control endpoint can create and - delete SNI hostname mappings. -- One implementation serves both products. StartWRT must require no - product-specific code in this change. -- Capability discovery with no extra round trip. -- Identical authorization and ownership semantics to the PCP path — no new - trust granted by choosing a different transport. -- Standard IGD clients are unaffected by the extended SCPD. - -## Non-goals - -- Changing the PCP `HOSTNAME` path, which stays the preferred transport. -- ~~Building StartWRT's SNI dataplane.~~ Originally out of scope; the dataplane - was subsequently folded into the same branch (divert infra as an fw4 include - - `DivertConfig`, `Via::sni() -> Some`, WAN-admit rules, WAN re-key), so the - vendor action ships _working_ on StartWRT, not inert. -- IPv6 (the demux is v4-only today). -- NAT-PMP, which has no extension mechanism to carry a hostname. - -## Design - -### Naming and placement - -Extend the existing `WANIPConnection:1` service rather than defining a new one: -one control URL, one SCPD the client already fetches, and the AVM precedent. - -``` -X_START9_AddHostnameMapping -X_START9_DeleteHostnameMapping -``` - -Arguments mirror `AddPortMapping` exactly, plus `NewHostname` — so the handler is -a near-copy of `add_mapping` and the existing `soap_u16` helper is reused -unchanged: - -| Action | In-args | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `X_START9_AddHostnameMapping` | `NewRemoteHost`, `NewExternalPort`, `NewProtocol`, `NewInternalPort`, `NewInternalClient`, `NewEnabled`, `NewPortMappingDescription`, `NewLeaseDuration`, `NewHostname` | -| `X_START9_DeleteHostnameMapping` | `NewRemoteHost`, `NewExternalPort`, `NewProtocol`, `NewInternalPort`, `NewHostname` | - -Delete carries `NewInternalPort` — unlike `DeletePortMapping`, which identifies -a mapping by external port alone — because an SNI route's identity and -ownership is its full target `(peer, internal port)`: both the demux and the -tunnel's persistence match on it exactly, and the PCP delete (a lifetime-0 MAP) -carries the internal port the same way. - -The alternative — a separate `urn:start9-com:service:HostnameMapping:1` — is -cleaner in principle but costs a second service block in the root description, a -second control endpoint, and a second SCPD fetch, to avoid a risk AVM's -deployment suggests is not real. See Open questions. - -### Error codes - -UPnP reserves errors 800–899 for vendor use. Map the PCP result codes across so -both transports report the same conditions: - -| Condition | PCP | UPnP | -| ------------------------------------------------- | ------------------------------ | ------------------------------ | -| Hostname already bound on this `(extIP, extPort)` | `RESULT_HOSTNAME_TAKEN` (192) | **800** `HostnameTaken` | -| Gateway has no SNI dataplane | `RESULT_UNSUPP_HOSTNAME` (193) | **801** `HostnameNotSupported` | -| Malformed hostname | malformed-option | 402 `Invalid Args` | -| Peer not an authorized device | — | 606 `Action not authorized` | - -**801 is the honest answer from any gateway without an SNI dataplane.** Both -handlers check `backend.sni().is_none()` before doing anything else and fault -801 (possible because this branch stacks on PR #3634's `Option`-ification of -`sni()`), so a backend returning `None` advertises-but-refuses rather than -"succeeding" into a demux nothing listens on. StartWRT's backend originally -returned `None`; its dataplane now rides this same branch, so on both Start9 -gateways `sni()` is `Some` and 801 remains for future/partial backends. -`sni_fault` additionally maps a backend `RESULT_UNSUPP_HOSTNAME` to the same 801. - -### Capability discovery — free, via `control_schema` - -`igd_next::Gateway` (0.17.1, `gateway.rs:13`) carries -`pub control_schema: HashMap>` — "Control schema for all -actions" — populated during `discover()`. The client therefore already holds the -gateway's parsed action list in memory the moment it is on the UPnP path at all. - -Detecting support is `control_schema.contains_key("X_START9_AddHostnameMapping")`. -No probe, no marker option, no extra request, no XML parsing to write. This is -strictly simpler than the PCP `ANNOUNCE` marker it parallels, and it is why no -persisted capability verdict is needed (see Client, below). - -### Lease semantics — the one real semantic gap - -`add_mapping` comments that _"UPnP IGD leases are permanent here (StartOS -requests lease 0); PCP is the lease-bearing path."_ Left alone, a UPnP-created -SNI route would be permanent, and StartTunnel's lease-expiry sweep — which -exists precisely so an automatic mapping dies when its device stops renewing — -would never reap it. That is a behavior regression relative to the PCP path. - -It is worse than an ordinary stale forward. A stale DNAT wastes a port and is -visible and removable in the published-ports UI. A stale **SNI route holds a -name**: `add_sni_forward` answers `HOSTNAME_TAKEN` to anyone else asking for it, -so the legitimate owner is locked out with no path to recover, and the condition -never self-heals. - -The decisive detail is in `sni.rs`'s `Binding`: - -```rust -struct Binding { - target: SocketAddrV4, - /// `None` for a permanent (DB-backed/manual) binding that never expires. - expiry: Option, -} -``` - -`None` is **reserved for operator-created bindings**. A device-initiated route -passing `None` would be indistinguishable from one an admin added by hand — a -category error, not merely an expiry policy choice. - -**Resolution: vendor-action routes are lease-bearing.** `add_sni_forward` -already takes `lifetime: Option` and the PCP path already passes -`Some(lifetime)`, so this is passing a different value at one call site — not -new machinery. Honor `NewLeaseDuration` when nonzero, clamped by -`MAX_LIFETIME_SECONDS` (3600); apply that clamp as the default when the client -sends 0. The existing sweep then reaps unrefreshed routes with no change. - -Alternatives considered: - -- **Permanent, reaped by device lifecycle only.** Relies on the existing "delete - or demote a device clears its forwards, SNI routes, and pinholes" path. - Rejected: it misses the common case — a device that goes offline or withdraws - the exposure without being deleted — which is exactly what the lease sweep was - added for. -- **Permanent, recovered by owner re-registration.** Ownership is keyed to the - target, so a returning device can overwrite its own route. Rejected: it fails - precisely when the device's address changes, which on StartWRT happens - routinely on a profile move (the DNS-injection RFC's decision 5). The stale - route then squats the name against the device's own new address. - -### Authorization - -Unchanged from `add_mapping`: `is_known_client(peer)` gates the call, and the -target is forced to the requesting peer's own address (`target = -SocketAddrV4::new(peer, internal_port)`), so `NewInternalClient` cannot be used -to publish someone else. Delete is owner-scoped the same way `delete_mapping` -is, so a peer cannot remove or probe for another's route. - -## Changes by layer - -### Shared server — `net/port_map/server/igd.rs` (both products, one change) - -- Two arms in `handle_control`'s match. -- `add_hostname_mapping()` / `delete_hostname_mapping()`, structured as - `add_mapping` / `delete_mapping` but calling `backend.add_sni_forward()` / - `remove_sni_forward()`. -- A `soap_str(body, tag)` extractor beside the existing `soap_u16`. -- Hostname validation reuses `pcp::hostname::validate_hostname`. It is no longer - PCP-specific; consider lifting it to `port_map/hostname.rs`. Cosmetic — do it - only if it stays a small diff. -- `igd_xml/scpd.xml`: two `` blocks. The existing SCPD test asserts named - actions rather than an exhaustive list, so it does not need loosening. - -### StartTunnel - -Nothing. `sni()` already returns `Some`, and the tunnel's `add_sni_forward` -override persists routes to PatchDb — so persistence, restart survival, and -dashboard visibility come free via the shared trait method. - -### StartWRT - -The SNI dataplane rides this branch: `kmod-nft-socket` in the image, the -reply-path divert as an fw4 include (`12-startwrt-sni-divert.nft`) plus a -`DivertConfig` for the iproute2 half (table 5344, masked fwmark), `sni()` -returning the shared demux, per-port WAN-admit ACCEPT rules (`apf_sni_`, -which also make the port read as router-reserved), WAN re-key via a `wan` -hotplug hook + sweep backstop, and SNI rows in `published-ports.auto-list`. -Routes are demux-memory only (finite-lease, device-renewed); a daemon restart -drops them until the device re-asserts. - -### Client — `net/port_map/{client,upnp}.rs` - -- `upnp.rs`: SOAP calls for the two actions, alongside the existing `add_port` / - `remove_port`. -- `client.rs:695`: replace the PCP-only short-circuit. PCP stays first; when a - gateway's HOSTNAME verdict is known-absent, fall through to the UPnP path if - `gateway.control_schema` advertises `X_START9_AddHostnameMapping`. - -**No PatchDb change.** Support is read from `control_schema`, which `discover()` -already populated on the `Gateway` the UPnP path is holding — so there is no -probe to suppress and nothing worth persisting. Adding a -`upnp_hostname: CapabilityVerdict` beside `pcp_hostname` would be symmetric, but -it buys nothing here: the negative trust window exists to avoid re-probing, and -this costs no probe. It would also drag in the full cross-layer sequence -(`make start-core-ts-bindings` → SDK rebuild → web and container-runtime type -checks) for a field no UI reads — `pcpHostname` today appears only as a seed -value in `projects/start-tunnel/web/src/app/services/patch-db/data-model.ts:133` -and is rendered nowhere. - -This keeps the whole proposal inside `start-core`, touching no product's UI, -bindings, or database. - -## Phasing - -1. **Shared server + SCPD + lease semantics**, with StartTunnel regression tests. - Ships working on StartTunnel. -2. **Client UPnP hostname path + capability caching.** The cross-layer step. -3. **StartWRT SNI dataplane** — folded into the same branch (see the StartWRT - section above), so the router serves the action rather than faulting 801. - -Phase 1 is independently useful and independently reviewable: it makes the -gateway answer the action, which is what a manual `curl` or a third-party client -would exercise. - -## Testing - -Unit-testable in `start-core`: - -- SOAP parse of both actions, including `NewHostname` extraction. -- SCPD advertises both actions; the standard action set is unchanged. -- Fault 801 when `sni()` is `None` — the StartWRT-shaped backend. -- Fault 800 when the hostname is held by a different target. -- Fault 606 for an unauthorized peer; 402 for a malformed hostname. -- Target forcing: `NewInternalClient` naming another host does not publish it. -- Owner-scoped delete: a different peer's delete does not remove the route. -- Lease: `NewLeaseDuration` 0 → clamped default; nonzero → honored and clamped; - the sweep reaps an unrefreshed route. - -Integration: - -- StartOS client against StartTunnel with PCP blocked (drop UDP 5351) — the - mapping still comes up over UPnP, and the SNI route serves. -- **Third-party regression:** `miniupnpc` and `igd-next` against the extended - SCPD, confirming the added actions do not disturb standard `AddPortMapping` / - `DeletePortMapping` flows. This is the check that validates the - extend-WANIPConnection decision. - -## Landing obligations - -Per the root `AGENTS.md`: - -- `projects/start-tunnel/CHANGELOG.md` under the prospective next version, and - `projects/start-tunnel/docs/src/published-ports.md` if the behavior is - user-visible. -- `projects/start-os/CHANGELOG.md` under the prospective next version — the - client fallback changes StartOS behavior, and client-side port-map changes - carry StartOS entries by precedent. -- `projects/start-wrt/CHANGELOG.md` — the dataplane rides this branch, so the - unreleased automatic-port-forwarding entry describes hostname routes, and - the StartWRT docs book's published-ports page documents them. -- If the capability field is added: TS bindings → SDK rebuild → web / - container-runtime type checks, in that order, in the same change. -- `API_CONTRACT.md` is untouched; this is not a JSON-RPC surface. -- No CI `paths:` change — no new build inputs. - -## Open questions - -1. **Extend `WANIPConnection:1`, or define a separate vendor service?** - Recommended: extend, per AVM precedent; the third-party regression test is - what confirms it. -2. **Lease-bearing vendor-action routes, diverging from `AddPortMapping`'s - permanence on the same server?** Recommended: yes — see Lease semantics. A - client that assumes UPnP mappings are permanent would see a route expire, but - the only clients are ours. -3. **One hostname per call, or several?** A PCP `MAP` can carry multiple - `HOSTNAME` options in one request; SOAP has no natural framing for a repeated - argument. One call per hostname is proposed. This is a wire-efficiency - question only — the backend already registers hostnames one at a time. -4. **Does anything document the vendor action externally?** The PCP side has an - IETF-style draft (`draft-start9-pcp-hostname`). A UPnP vendor action has no - equivalent venue, so if third-party client implementers are part of the - justification, the SCPD needs to be the documentation — which argues for - precise `` entries and a short section in the tunnel's docs. diff --git a/shared-libs/crates/start-core/src/net/port_map/client.rs b/shared-libs/crates/start-core/src/net/port_map/client.rs index ee2a707d2d..abff40ae04 100644 --- a/shared-libs/crates/start-core/src/net/port_map/client.rs +++ b/shared-libs/crates/start-core/src/net/port_map/client.rs @@ -52,29 +52,20 @@ use crate::prelude::*; use crate::util::collections::OrdMapIterMut; use crate::util::sync::{SyncMutex, Watch}; -/// Cadence for the refresh tick: re-assert UPnP and retry not-yet-active -/// mappings whose backoff has elapsed, and check whether each active PCP -/// mapping has crossed half its lease (the point it's renewed). Well under the -/// PCP lease so a renewal that's come due is caught with ample margin before -/// expiry. +/// Refresh cadence for active mappings and backoff-eligible retries. const REFRESH_INTERVAL: Duration = Duration::from_secs(180); -/// Initial retry delay for a desired-but-not-active mapping; doubles per -/// consecutive failure up to [`BACKOFF_MAX`], so boot/tunnel-restart races -/// still recover in seconds while a permanently-failing mapping quiets down. +/// Initial retry delay; doubles to [`BACKOFF_MAX`]. const RETRY_INTERVAL: Duration = Duration::from_secs(15); const BACKOFF_MAX: Duration = Duration::from_secs(960); const GATEWAY_CACHE_TTL: Duration = Duration::from_secs(600); const PCP_LIFETIME_SECONDS: u32 = 3600; -/// Fail fast onto UPnP instead of the crate's multi-minute RFC backoff when a -/// gateway doesn't speak PCP/NAT-PMP. +/// Short probe timeout before falling back to UPnP. const PCP_TIMEOUTS: TimeoutConfig = TimeoutConfig { initial_timeout: Duration::from_millis(250), max_retries: 1, max_retry_timeout: Some(Duration::from_secs(1)), }; -/// Delay before the next apply after `failures` consecutive failures: 15s, -/// 30s, 60s, … capped at 16 minutes. fn retry_delay(failures: u32) -> Duration { (RETRY_INTERVAL * 2u32.pow(failures.saturating_sub(1).min(6))).min(BACKOFF_MAX) } @@ -101,10 +92,7 @@ impl TransportProtocol { } } -/// (local IP, external port, optional SNI hostname, transport protocol). -/// Hostname is part of the identity: many hostnames share one external port via -/// gateway SNI demux, each an independent mapping. Protocol is also part of the -/// identity so a raw TCP+UDP forward can be renewed and removed independently. +/// Mapping identity, including independent hostname and transport bindings. type MappingKey = (IpAddr, u16, Option, TransportProtocol); /// Candidate PCP/NAT-PMP servers for a gateway interface: the NM default @@ -114,10 +102,7 @@ type MappingKey = (IpAddr, u16, Option, TransportProtocol); /// the tunnel server's address, the subnet's first host, where its PCP server /// listens. pub fn candidate_gateways(info: &NetworkInterfaceInfo) -> Vec<(IpAddr, Option)> { - // Port mapping is inbound-only: an OutboundOnly gateway (e.g. a commercial - // VPN) exposes no PCP/NAT-PMP server we'd ever ask for a pinhole. Return no - // candidates so every port-map call site — this is the one they all funnel - // through — never attempts PCP against it. + // Outbound-only gateways cannot accept inbound mappings. if info.gateway_type == GatewayType::OutboundOnly { return Vec::new(); } @@ -138,11 +123,7 @@ pub fn candidate_gateways(info: &NetworkInterfaceInfo) -> Vec<(IpAddr, Option { if ip_info.subnets.iter().any(|s| s.contains(ip)) { @@ -160,12 +141,7 @@ pub fn candidate_gateways(info: &NetworkInterfaceInfo) -> Vec<(IpAddr, Option Vec<(IpAddr, Option { Some(*n) } @@ -212,9 +185,7 @@ enum Active { Pcp(PortMapping), Upnp { external_ip: Option, - /// Kept for teardown: a hostname mapping's delete action must name the - /// internal port to identify the peer-scoped SNI route, and the spec it - /// came from is already gone by then. + /// Needed by owner-scoped hostname deletion. internal_port: u16, }, } @@ -281,9 +252,7 @@ impl PortMapController { } } - /// Like [`ensure`](Self::ensure) but binds one FQDN via PCP HOSTNAME so the - /// gateway SNI-demuxes this external port. PCP-only; each hostname is an - /// independent mapping sharing the port. + /// Binds one FQDN through the gateway's hostname mapping extension. pub fn ensure_hostname( &self, local_ip: IpAddr, @@ -578,6 +547,18 @@ struct State { } impl State { + fn external_ip(&self, external_port: u16) -> Option { + self.active + .iter() + .find(|(key, _)| { + key.1 == external_port && key.2.is_none() && key.3 == TransportProtocol::Tcp + }) + .and_then(|(_, active)| match active { + Active::Pcp(mapping) => mapping.external_ip(), + Active::Upnp { external_ip, .. } => external_ip.map(IpAddr::V4), + }) + } + async fn ensure( &mut self, interfaces: &Watch>, @@ -590,8 +571,7 @@ impl State { || s.count != spec.count }); self.desired.insert(key.clone(), spec); - // A spec change is new information (operator or config) — retry - // promptly, ignoring any accumulated backoff. + // A changed request bypasses accumulated backoff. if changed { self.failures.remove(&key); } @@ -615,11 +595,9 @@ impl State { async fn refresh(&mut self, interfaces: &Watch>) { for key in self.desired.keys().cloned().collect::>() { + let retry_ready = self.backoff_elapsed(&key); match self.active.get_mut(&key) { - // expiration()/lifetime() reflect the gateway's last grant - // (crab_nat uses std::time::Instant), so renewal self-corrects if - // the gateway caps the lease below what we asked for, and the - // ticks before it's due are skipped. + // Renew against the lifetime granted by the gateway. Some(Active::Pcp(m)) if renew_due(std::time::Instant::now(), m.expiration(), m.lifetime()) => { @@ -632,24 +610,18 @@ impl State { self.apply(interfaces, key).await; } } - // A PCP mapping not yet at its renewal point: leave it be. Some(Active::Pcp(_)) => {} - // Re-assert UPnP in case a gateway reboot dropped it. A - // hostname key re-registers idempotently (the same target - // reclaims), so skip the remote delete — deleting first would - // leave the route down until the backoff-gated retry if the - // re-add fails, and churn the gateway's persisted state every - // tick. Only the local entry is dropped so apply() sees an - // inactive key. - Some(Active::Upnp { .. }) if key.2.is_some() => { - self.active.remove(&key); - self.apply(interfaces, key).await; + // Re-register hostname routes without deleting the live mapping first. + Some(Active::Upnp { .. }) if key.2.is_some() && retry_ready => { + let previous = self.active.remove(&key).expect("active mapping"); + self.apply(interfaces, key.clone()).await; + self.active.entry(key).or_insert(previous); } + Some(Active::Upnp { .. }) if key.2.is_some() => {} Some(Active::Upnp { .. }) => { self.teardown(key.clone()).await; self.apply(interfaces, key).await; } - // A prior failure: retry once its backoff has elapsed. None => { if self.backoff_elapsed(&key) { self.apply(interfaces, key).await; @@ -674,7 +646,6 @@ impl State { if let IpAddr::V4(local_v4) = local_ip { if let Some(gw) = self.gateway_for(local_v4).await { match &hostname { - // A hostname key is an SNI route, not a port forward. Some(host) => { upnp::remove_hostname_mapping( gw, @@ -698,10 +669,7 @@ impl State { } } - /// Wrapper around the attempt paths: on success any backoff is cleared; - /// after a real (network) attempt that left the key inactive the failure - /// count grows. An attempt fully short-circuited by capability verdicts - /// does no I/O and counts neither as success nor failure. + /// Updates retry backoff after a mapping attempt. async fn apply( &mut self, interfaces: &Watch>, @@ -739,10 +707,7 @@ impl State { }; let now = Utc::now(); - // HOSTNAME (SNI-demux) mapping: PCP first, falling back to the UPnP - // vendor action (X_START9_AddHostnameMapping) when no gateway grants it - // over PCP. NAT-PMP has no way to carry a hostname. Other hostnames on - // the same port are separate mappings. + // Try PCP HOSTNAME before the UPnP vendor action. if let Some(hostname) = &hostname { let options = [pcp::PcpOption { code: OPTION_HOSTNAME, @@ -752,9 +717,6 @@ impl State { if gw.is_ipv4() != local_ip.is_ipv4() { continue; } - // Skip a gateway whose HOSTNAME support is fresh-known-absent; - // probe only when the verdict is unknown or stale, and feed the - // result back to the interface's capability state. let caps = capabilities_for(interfaces, *gw); match caps.and_then(|c| c.pcp_hostname.fresh(now)) { Some(false) => { @@ -796,8 +758,7 @@ impl State { ) .await { - // Require the gateway to echo the HOSTNAME option too: it - // confirms the binding took, independent of the ANNOUNCE marker. + // The echoed option confirms the gateway applied HOSTNAME. Ok(m) if m.external_port() == ext && m.response_options() @@ -811,7 +772,6 @@ impl State { self.active.insert(key.clone(), Active::Pcp(m)); return true; } - // Answered but didn't echo HOSTNAME: doesn't honor it. Ok(m) => { report(interfaces, *gw, |caps, now| { set_verdict(&mut caps.pcp, true, now) @@ -829,9 +789,6 @@ impl State { } } - // No gateway granted the mapping over PCP: fall back to the UPnP - // vendor action if the IGD's SCPD advertises it (IPv4 only, like - // the plain UPnP path). if let IpAddr::V4(local_v4) = local_ip { let upnp_dead = capabilities_for_local(interfaces, local_ip) .and_then(|c| c.upnp.fresh(now)) @@ -844,14 +801,8 @@ impl State { return attempted; } attempted = true; - // (added, drop_cache): the cache is shared with every other - // mapping on this local IP, so only evict it when the gateway - // is gone or misbehaving — an IGD that answers but doesn't - // advertise the vendor action is a stable condition, and - // evicting for it would force sibling mappings to re-discover. - let (added, drop_cache) = match self.gateway_for(local_v4).await { + let (added, invalidate_upnp_cache) = match self.gateway_for(local_v4).await { Some(gw) => { - // Discovery alone proves the IGD, whatever the call says. report_local(interfaces, local_ip, true); if !upnp::supports_hostname(gw) { crate::dev_log!( @@ -892,8 +843,6 @@ impl State { } }; if added { - // Best-effort external IP from the cached discovery so a - // reachability check can short-circuit. let external_ip = match self.gateway_for(local_v4).await { Some(gw) => upnp::external_ipv4(gw).await.ok().flatten(), None => None, @@ -905,8 +854,7 @@ impl State { internal_port: spec.internal_port, }, ); - } else if drop_cache { - // Re-discover next time in case the gateway went away. + } else if invalidate_upnp_cache { self.upnp_cache.remove(&local_v4); } } diff --git a/shared-libs/crates/start-core/src/net/port_map/pcp/mod.rs b/shared-libs/crates/start-core/src/net/port_map/pcp/mod.rs index a38ae824ac..46a9e7d0f7 100644 --- a/shared-libs/crates/start-core/src/net/port_map/pcp/mod.rs +++ b/shared-libs/crates/start-core/src/net/port_map/pcp/mod.rs @@ -5,9 +5,7 @@ pub mod capability; pub mod hostname; pub mod portset; -/// Base PCP NO_RESOURCES result code (RFC 6887 §7.4): the server lacks the -/// resources to honor an otherwise-valid request — e.g. the SNI demux cannot -/// bind the port a grant would open. +/// PCP NO_RESOURCES result code (RFC 6887 §7.4). pub const RESULT_NO_RESOURCES: u8 = 8; /// Walk the PCP option area (RFC 6887 §7.3): each option is code(1), diff --git a/shared-libs/crates/start-core/src/net/port_map/server/igd.rs b/shared-libs/crates/start-core/src/net/port_map/server/igd.rs index 93b67c1846..643e3cddda 100644 --- a/shared-libs/crates/start-core/src/net/port_map/server/igd.rs +++ b/shared-libs/crates/start-core/src/net/port_map/server/igd.rs @@ -43,9 +43,7 @@ pub const CIF_SCPD_PATH: &str = "/WANCfg.xml"; /// Both services share one control endpoint: actions are dispatched by name, /// which is unambiguous across the two. pub const CONTROL_PATH: &str = "/ctl/IPConn"; -/// Start9 vendor actions binding/removing an SNI hostname on a shared external -/// port — the UPnP transport for what the PCP `HOSTNAME` option does. The one -/// definition of the wire names; the client (`port_map::upnp`) imports them. +/// UPnP vendor action wire names for SNI hostname mappings. pub const ADD_HOSTNAME_ACTION: &str = "X_START9_AddHostnameMapping"; pub const DELETE_HOSTNAME_ACTION: &str = "X_START9_DeleteHostnameMapping"; @@ -160,14 +158,24 @@ fn soap_u32(body: &str, arg: &str) -> Option { soap_arg(body, arg) } -fn soap_arg(body: &str, arg: &str) -> Option { +fn soap_text(body: &str, arg: &str) -> Option { let root = xmltree::Element::parse(body.as_bytes()).ok()?; let action = root .get_child("Body")? .children .iter() .find_map(|n| n.as_element())?; - action.get_child(arg)?.get_text()?.trim().parse().ok() + let element = action.get_child(arg)?; + Some( + element + .get_text() + .map(|text| text.trim().to_string()) + .unwrap_or_default(), + ) +} + +fn soap_arg(body: &str, arg: &str) -> Option { + soap_text(body, arg)?.parse().ok() } fn ok(action: &str, inner: &str) -> Response { @@ -203,15 +211,12 @@ fn upnp_error_text(code: u16) -> &'static str { 714 => "NoSuchEntryInArray", 718 => "ConflictInMappingEntry", 725 => "OnlyPermanentLeasesSupported", - // Vendor range (800-899): SNI hostname mappings, mirroring the PCP - // HOSTNAME result codes. 800 => "HostnameTaken", 801 => "HostnameNotSupported", _ => "Action Failed", } } -/// UPnP vendor fault for an `add_sni_forward` PCP result code. fn sni_fault(code: u8) -> Response { let upnp = match code { RESULT_HOSTNAME_TAKEN => 800, @@ -293,11 +298,7 @@ fn host_is_ip_literal(headers: &HeaderMap) -> bool { addr.parse::().is_ok() } -/// Whether `action` changes gateway state, and so must name itself in the -/// `SOAPAction` header rather than the body alone. Every mutating arm of -/// [`handle_control`]'s dispatch belongs here; a read left out of it keeps the -/// body fallback, which is what lets a client read the external IP during -/// discovery. +/// Whether the action requires `SOAPAction` to prevent blind browser writes. fn is_mutation(action: &str) -> bool { matches!( action, @@ -313,19 +314,12 @@ pub async fn handle_control( headers: &HeaderMap, body: &str, ) -> Response { - // Reject cross-origin (DNS-rebinding) requests before any action runs, so a - // rebound browser can neither read state (`GetExternalIPAddress`) nor mutate - // it (`AddPortMapping`). A real client always carries an IP-literal `Host`. + // An IP-literal Host blocks DNS rebinding. if !host_is_ip_literal(headers) { return StatusCode::FORBIDDEN.into_response(); } let action = soap_action(headers, body); - // The Host check stops rebinding, but a browser can still fire a "simple" - // cross-origin POST (no CORS preflight) straight at a guessed gateway IP, - // SOAP body and all — the one thing it can never attach is the SOAPAction - // header. So mutations must name themselves in the header (the UPnP spec - // mandates it; every real client complies), leaving the body fallback to - // the reads, whose responses a cross-origin page can't see anyway. + // SOAPAction blocks browser simple-request writes. if action.as_deref().is_some_and(is_mutation) && !headers.contains_key("SOAPAction") { return StatusCode::FORBIDDEN.into_response(); } @@ -535,28 +529,33 @@ async fn delete_mapping( } } -/// Start9 vendor action: bind `NewHostname` on the shared external port via the -/// gateway's SNI demux — the UPnP transport for what the PCP HOSTNAME option -/// does. Same guards as [`add_mapping`]. async fn add_hostname_mapping( backend: &B, peer: Ipv4Addr, body: &str, ) -> Response { - // Advertised-but-refused where the gateway has no SNI dataplane (StartWRT - // until its demux lands): the shared SCPD lists the action regardless, so - // refuse explicitly — the UPnP spelling of the PCP path's - // RESULT_UNSUPP_HOSTNAME refusal. - if backend.sni().is_none() { - return fault(801, upnp_error_text(801)); - } let (Some(external_port), Some(internal_port)) = ( soap_u16(body, "NewExternalPort"), soap_u16(body, "NewInternalPort"), ) else { return fault(402, "Invalid Args"); }; - if external_port == 0 || internal_port == 0 { + let (Some(remote_host), Some(_internal_client), Some(enabled), Some(_description)) = ( + soap_text(body, "NewRemoteHost"), + soap_text(body, "NewInternalClient"), + soap_text(body, "NewEnabled"), + soap_text(body, "NewPortMappingDescription"), + ) else { + return fault(402, "Invalid Args"); + }; + if !remote_host.is_empty() { + return fault(801, upnp_error_text(801)); + } + if external_port == 0 + || internal_port == 0 + || soap_protocol(body) != Some("TCP") + || !matches!(enabled.as_str(), "1" | "true") + { return fault(402, "Invalid Args"); } let Some(hostname) = soap_arg::(body, "NewHostname") @@ -565,12 +564,8 @@ async fn add_hostname_mapping( else { return fault(402, "Invalid Args"); }; - // The SNI demux is TCP-only; the PCP path likewise refuses a non-TCP - // HOSTNAME MAP rather than granting a silently-TCP route. - if let Some(p) = soap_arg::(body, "NewProtocol") { - if !p.eq_ignore_ascii_case("TCP") { - return fault(402, "Invalid Args"); - } + if backend.sni().is_none() { + return fault(801, upnp_error_text(801)); } if !backend.is_known_client(peer).await { return fault(606, "Action not authorized"); @@ -579,15 +574,15 @@ async fn add_hostname_mapping( return fault(501, "Action Failed"); }; let source = SocketAddrV4::new(source_ip, external_port); - // Secure mode: force the target to the requesting peer's own address. let target = SocketAddrV4::new(peer, internal_port); - // Always lease-bearing, unlike add_mapping: a `None` lifetime is reserved - // for operator-created SNI routes, and an unreaped device route would answer - // HOSTNAME_TAKEN to its legitimate owner forever. Lease 0 means "default". - let lifetime = match soap_u32(body, "NewLeaseDuration") { - Some(n) if n > 0 => n.min(MAX_LIFETIME_SECONDS), - _ => MAX_LIFETIME_SECONDS, + let Some(requested_lifetime) = soap_u32(body, "NewLeaseDuration") else { + return fault(402, "Invalid Args"); + }; + let lifetime = if requested_lifetime > 0 { + requested_lifetime.min(MAX_LIFETIME_SECONDS) + } else { + MAX_LIFETIME_SECONDS }; match backend .add_sni_forward( @@ -603,33 +598,33 @@ async fn add_hostname_mapping( } } -/// Start9 vendor action: remove the SNI route for `NewHostname`. Owner-scoped -/// — the target is forced to the requesting peer, so a peer can only remove -/// routes pointing at itself — and gated on `is_known_client`, matching the -/// PCP lifetime-0 delete rather than the ungated [`delete_mapping`]. async fn delete_hostname_mapping( backend: &B, peer: Ipv4Addr, body: &str, ) -> Response { - if backend.sni().is_none() { - return fault(801, upnp_error_text(801)); - } - let (Some(external_port), Some(internal_port)) = ( + let (Some(external_port), Some(internal_port), Some(remote_host)) = ( soap_u16(body, "NewExternalPort"), soap_u16(body, "NewInternalPort"), + soap_text(body, "NewRemoteHost"), ) else { return fault(402, "Invalid Args"); }; + if !remote_host.is_empty() { + return fault(801, upnp_error_text(801)); + } + if external_port == 0 || internal_port == 0 || soap_protocol(body) != Some("TCP") { + return fault(402, "Invalid Args"); + } let Some(hostname) = soap_arg::(body, "NewHostname") .filter(|h| validate_hostname(h)) .map(|h| h.to_ascii_lowercase()) else { return fault(402, "Invalid Args"); }; - // Gated like the PCP delete (a lifetime-0 MAP sits behind the same check): - // ownership alone protects other peers' routes, but an unknown peer should - // not reach the backend's removal path at all. + if backend.sni().is_none() { + return fault(801, upnp_error_text(801)); + } if !backend.is_known_client(peer).await { return fault(606, "Action not authorized"); } @@ -996,14 +991,10 @@ mod tests { let resp = handle_control(&OpenStub, peer, &host_headers("evil.com"), &body).await; assert_eq!(resp.status(), StatusCode::FORBIDDEN); - // IP Host but the mutation named only in the body — exactly what a - // browser can send with no preflight: refused. let headers = host_headers("192.168.1.1:49001"); let resp = handle_control(&OpenStub, peer, &headers, &body).await; assert_eq!(resp.status(), StatusCode::FORBIDDEN); - // The same mutation with the SOAPAction header — what every real - // client sends — goes through. let mut headers = host_headers("192.168.1.1:49001"); headers.insert( "SOAPAction", @@ -1014,16 +1005,11 @@ mod tests { let resp = handle_control(&OpenStub, peer, &headers, &body).await; assert_eq!(resp.status(), StatusCode::OK); - // Reads keep the body fallback: their responses are unreadable - // cross-origin, so a headerless read is harmless. let headers = host_headers("192.168.1.1:49001"); let resp = handle_control(&OpenStub, peer, &headers, &status_info_body()).await; assert_eq!(resp.status(), StatusCode::OK); } - /// The `SOAPAction` requirement covers every mutating action, not only the - /// standard three: a vendor action that changes state is exactly as - /// reachable from a cross-origin page as `AddPortMapping` is. #[tokio::test] async fn browser_shaped_requests_cannot_reach_vendor_mutations() { for body in [ @@ -1106,8 +1092,6 @@ mod tests { assert_eq!(format_uuid(&bytes), format_uuid(&bytes)); } - // ---- Start9 hostname vendor actions ---- - use std::future::Future; use std::sync::Mutex; @@ -1117,15 +1101,12 @@ mod tests { const OTHER_PEER: Ipv4Addr = Ipv4Addr::new(10, 59, 0, 3); const EXT_IP: Ipv4Addr = Ipv4Addr::new(203, 0, 113, 1); - /// Backend recording `add_sni_forward` calls while still driving the real - /// demux, so ownership semantics (HOSTNAME_TAKEN, owner-scoped delete) are - /// exercised against the actual registration logic. The request bodies use - /// an unprivileged external port (44300): registration is bind-gated, and - /// a real 443 bind needs root the test runner doesn't have. + /// Uses the real demux with an unprivileged external port. struct HostnameStub { sni: Arc, known: bool, calls: Mutex, Option)>>, + remove_calls: Mutex)>>, } impl HostnameStub { fn new(known: bool) -> Self { @@ -1133,6 +1114,7 @@ mod tests { sni: SniDemux::new(), known, calls: Mutex::new(Vec::new()), + remove_calls: Mutex::new(Vec::new()), } } } @@ -1183,10 +1165,22 @@ mod tests { .register(*source.ip(), source.port(), hostnames, target, lifetime); async move { res } } + fn remove_sni_forward( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + hostnames: &[String], + ) -> impl Future + Send { + self.remove_calls + .lock() + .unwrap() + .push((source, target, hostnames.to_vec())); + self.sni + .unregister(*source.ip(), source.port(), hostnames, target); + async {} + } } - /// A hostname-action body with `NewInternalClient` deliberately naming a - /// DIFFERENT host, so target forcing is what the tests observe. fn add_hostname_body(lease: &str, hostname: &str) -> String { format!( r#" @@ -1232,8 +1226,6 @@ mod tests { String::from_utf8_lossy(&bytes).into_owned() } - /// The headers a real client sends for `body`: an IP-literal `Host`, and - /// the `SOAPAction` naming the action the body carries. fn client_headers(body: &str) -> HeaderMap { let action = soap_action(&HeaderMap::new(), body).expect("body names an action"); let mut headers = host_headers("192.168.1.1:49001"); @@ -1312,7 +1304,6 @@ mod tests { "NewHostname", ] ); - // Every referenced state variable must exist in the serviceStateTable. let table = scpd.get_child("serviceStateTable").unwrap(); assert!( table @@ -1326,8 +1317,6 @@ mod tests { ); } - // The client's hand-rolled envelopes parse to exactly the values it put in — - // the round-trip check standing in for igd-next's private request machinery. #[test] fn client_envelopes_round_trip_through_server_parser() { let body = crate::net::port_map::upnp::add_hostname_body( @@ -1361,17 +1350,12 @@ mod tests { ); } - // Lease grants: 0 and absent request the default; a nonzero request is - // honored up to the cap; and the backend NEVER receives `None` (permanent), - // which is reserved for operator-created routes. #[tokio::test] async fn hostname_mapping_is_always_lease_bearing() { for (lease, granted) in [ ("0", super::super::MAX_LIFETIME_SECONDS), ("600", 600), ("7200", super::super::MAX_LIFETIME_SECONDS), - // Past u16 — NewLeaseDuration is ui4, so this must clamp, not - // fall back to a failed parse. ("100000", super::super::MAX_LIFETIME_SECONDS), ] { let stub = HostnameStub::new(true); @@ -1383,8 +1367,6 @@ mod tests { } } - // `NewInternalClient` names another host, but the route target is forced to - // the requesting peer — a peer can only publish itself. #[tokio::test] async fn hostname_mapping_forces_target_to_peer() { let stub = HostnameStub::new(true); @@ -1393,24 +1375,77 @@ mod tests { let calls = stub.calls.lock().unwrap(); assert_eq!(calls[0].0, SocketAddrV4::new(EXT_IP, 44300)); assert_eq!(calls[0].1, SocketAddrV4::new(PEER, 8443)); - // Hostnames are lowercased before registration, like the PCP parser. assert_eq!(calls[0].2, vec!["git.example.com".to_string()]); } - // The SNI demux is TCP-only: an explicit non-TCP protocol is refused, like - // the PCP path's non-TCP HOSTNAME check, instead of granting a - // silently-TCP route. #[tokio::test] - async fn hostname_mapping_rejects_non_tcp() { - let stub = HostnameStub::new(true); - let body = add_hostname_body("0", "git.example.com").replace( - "TCP", - "UDP", - ); - let resp = control(&stub, PEER, &body).await; - assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(body_text(resp).await.contains("402")); - assert!(stub.calls.lock().unwrap().is_empty()); + async fn hostname_actions_require_tcp_without_backend_mutation() { + for valid_body in [ + add_hostname_body("0", "git.example.com"), + delete_hostname_body("git.example.com"), + ] { + for body in [ + valid_body.replace("TCP", ""), + valid_body.replace( + "TCP", + "UDP", + ), + ] { + let stub = HostnameStub::new(true); + let resp = control(&stub, PEER, &body).await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(body_text(resp).await.contains("402")); + assert!(stub.calls.lock().unwrap().is_empty()); + assert!(stub.remove_calls.lock().unwrap().is_empty()); + } + } + } + + #[tokio::test] + async fn hostname_add_rejects_unsupported_or_missing_fields() { + let valid = add_hostname_body("0", "git.example.com"); + for (body, code) in [ + ( + valid.replace( + "", + "198.51.100.0/24", + ), + 801, + ), + ( + valid.replace("1", "0"), + 402, + ), + (valid.replace("1", ""), 402), + ( + valid.replace( + "StartOS", + "", + ), + 402, + ), + ( + valid.replace("0", ""), + 402, + ), + ( + valid.replace( + "0", + "invalid", + ), + 402, + ), + ] { + let stub = HostnameStub::new(true); + let resp = control(&stub, PEER, &body).await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!( + body_text(resp) + .await + .contains(&format!("{code}")) + ); + assert!(stub.calls.lock().unwrap().is_empty()); + } } #[tokio::test] @@ -1436,14 +1471,11 @@ mod tests { } } - // A hostname held by a different target answers the vendor fault 800 - // (HostnameTaken), the UPnP spelling of RESULT_HOSTNAME_TAKEN. #[tokio::test] async fn hostname_taken_by_another_target_faults_800() { let stub = HostnameStub::new(true); let resp = control(&stub, PEER, &add_hostname_body("0", "git.example.com")).await; assert_eq!(resp.status(), StatusCode::OK); - // Same hostname from another peer (different target) is refused… let resp = control( &stub, OTHER_PEER, @@ -1452,13 +1484,10 @@ mod tests { .await; assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); assert!(body_text(resp).await.contains("800")); - // …while the owner reclaims (refresh) fine. let resp = control(&stub, PEER, &add_hostname_body("0", "git.example.com")).await; assert_eq!(resp.status(), StatusCode::OK); } - // Delete is gated on is_known_client like the PCP lifetime-0 MAP, so an - // unknown peer never reaches the backend's removal path. #[tokio::test] async fn hostname_delete_rejects_unknown_peer() { let stub = HostnameStub::new(false); @@ -1467,16 +1496,12 @@ mod tests { assert!(body_text(resp).await.contains("606")); } - // Delete is owner-scoped: another peer's delete of the same hostname is a - // no-op (the route stays, observable as 800 for a third target), while the - // owner's delete frees the name. #[tokio::test] async fn hostname_delete_is_owner_scoped() { let stub = HostnameStub::new(true); let resp = control(&stub, PEER, &add_hostname_body("0", "git.example.com")).await; assert_eq!(resp.status(), StatusCode::OK); - // Another peer's delete succeeds as a request but removes nothing. let resp = control(&stub, OTHER_PEER, &delete_hostname_body("git.example.com")).await; assert_eq!(resp.status(), StatusCode::OK); let resp = control( @@ -1490,7 +1515,6 @@ mod tests { "route should still be held by the original owner" ); - // The owner's delete frees the hostname for a new target. let resp = control(&stub, PEER, &delete_hostname_body("git.example.com")).await; assert_eq!(resp.status(), StatusCode::OK); let resp = control( @@ -1502,10 +1526,6 @@ mod tests { assert_eq!(resp.status(), StatusCode::OK); } - // The StartWRT-shaped backend: no SNI dataplane, but the shared SCPD still - // advertises the vendor actions — both must refuse with the vendor fault - // 801 before reaching the backend, the UPnP twin of the PCP path's - // RESULT_UNSUPP_HOSTNAME refusal. #[tokio::test] async fn hostname_actions_fault_801_without_sni_dataplane() { struct NoSniStub; @@ -1539,7 +1559,6 @@ mod tests { fn sni(&self) -> Option<&Arc> { None } - // The refusal must happen before any registration is attempted. fn add_sni_forward( &self, _: SocketAddrV4, diff --git a/shared-libs/crates/start-core/src/net/port_map/upnp.rs b/shared-libs/crates/start-core/src/net/port_map/upnp.rs index fe9babb536..a28add8fd1 100644 --- a/shared-libs/crates/start-core/src/net/port_map/upnp.rs +++ b/shared-libs/crates/start-core/src/net/port_map/upnp.rs @@ -17,8 +17,7 @@ use crate::net::port_map::server::igd::{ use crate::prelude::*; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(4); -/// IGD SOAP control calls are unbounded in igd-next; without this a gateway that -/// accepts TCP but never answers would wedge the single-threaded port-map daemon. +/// Bounds IGD SOAP calls that otherwise have no timeout. const CONTROL_TIMEOUT: Duration = Duration::from_secs(5); /// `0` requests an indefinite lease; the controller re-asserts periodically. const LEASE_DURATION: u32 = 0; @@ -90,21 +89,15 @@ pub async fn remove_port( } } -/// Lease we request for a hostname mapping; the server clamps to its own max -/// (3600s) and the controller re-asserts every refresh tick, well within it. const HOSTNAME_LEASE_SECONDS: u32 = 3600; -/// Whether `gateway` advertises the Start9 hostname vendor action. A plain -/// lookup: `discover()` already parsed the SCPD into `control_schema`, so -/// capability detection costs no extra round trip. +/// Whether `gateway` advertises both Start9 hostname vendor actions. pub fn supports_hostname(gateway: &Gateway) -> bool { gateway.control_schema.contains_key(ADD_HOSTNAME_ACTION) + && gateway.control_schema.contains_key(DELETE_HOSTNAME_ACTION) } -/// SOAP envelope for [`ADD_HOSTNAME_ACTION`], shaped like igd-next's -/// `format_add_port_mapping_message` (which the server's parser is tested -/// against). The caller has validated the hostname to `[A-Za-z0-9.*-]` -/// ([`add_hostname_mapping`]), so interpolation needs no XML escaping. +/// SOAP envelope for [`ADD_HOSTNAME_ACTION`]. pub(crate) fn add_hostname_body( external_port: u16, local_ip: Ipv4Addr, @@ -132,8 +125,7 @@ pub(crate) fn add_hostname_body( ) } -/// SOAP envelope for [`DELETE_HOSTNAME_ACTION`]. Carries the internal port so -/// the server can reconstruct the peer-scoped route target it is deleting. +/// SOAP envelope for [`DELETE_HOSTNAME_ACTION`]. pub(crate) fn delete_hostname_body( external_port: u16, internal_port: u16, @@ -156,9 +148,7 @@ pub(crate) fn delete_hostname_body( ) } -/// POST a vendor-action SOAP request to `gateway`'s control endpoint. -/// igd-next's own `perform_request` is private, so this hand-rolls the same -/// HTTP shape (`SOAPAction: "#"`, text/xml body). +/// Sends a vendor SOAP action to the discovered control endpoint. async fn vendor_control_call( gateway: &Gateway, action: &str, @@ -167,9 +157,7 @@ async fn vendor_control_call( let url = format!("http://{}{}", gateway.addr, gateway.control_url); let soap_action = format!("\"{WANIP_SERVICE}#{action}\""); let call = async { - // no_proxy: this is a LAN control call; reqwest otherwise honors - // HTTP_PROXY/ALL_PROXY, which igd-next's transport ignores — a proxy - // env would break (or leak) only the vendor actions. + // LAN control traffic must bypass environment proxies. let resp = reqwest::Client::builder() .no_proxy() .build() @@ -183,12 +171,10 @@ async fn vendor_control_call( .map_err(|e| Error::new(eyre!("UPnP {action} failed: {e}"), ErrorKind::Network))?; let status = resp.status(); let text = resp.text().await.unwrap_or_default(); - // Require the action's own <...Response> element, not just a 2xx — an - // intercepting middlebox answering 200 is not a created mapping. + // A 2xx without the matching response body does not prove mutation. if status.is_success() && text.contains(&format!("{action}Response")) { Ok(()) } else { - // A fault body carries ; surface it for diagnostics. let code = text .split("") .nth(1) @@ -209,9 +195,8 @@ async fn vendor_control_call( } } -/// Bind `hostname` on `external_port` -> `local_ip:internal_port` via the -/// Start9 vendor action. The gateway SNI-demuxes the shared external port; the -/// granted lease is finite, so the caller must re-assert before expiry. +/// Binds a hostname to a target through the Start9 vendor action. +/// The caller must renew the finite lease. pub async fn add_hostname_mapping( gateway: &Gateway, external_port: u16, @@ -219,9 +204,7 @@ pub async fn add_hostname_mapping( internal_port: u16, hostname: &str, ) -> Result<(), Error> { - // Nothing upstream character-validates a configured domain, and the - // envelope interpolates it unescaped — reject here rather than emit - // malformed XML the server can only 402. + // Hostnames are interpolated without XML escaping. if !validate_hostname(hostname) { return Err(Error::new( eyre!("invalid hostname for SNI mapping: {hostname:?}"), @@ -236,8 +219,7 @@ pub async fn add_hostname_mapping( .await } -/// Remove the SNI route for `hostname` on `external_port` targeting -/// `internal_port` on the caller. +/// Removes a hostname mapping through the Start9 vendor action. pub async fn remove_hostname_mapping( gateway: &Gateway, external_port: u16, @@ -298,8 +280,39 @@ pub async fn get_external_ipv4(local_ip: Ipv4Addr) -> Result, E #[cfg(test)] mod tests { + use std::collections::HashMap; + use super::*; + fn gateway_with_actions(actions: &[&str]) -> Gateway { + Gateway { + addr: "127.0.0.1:49001".parse().unwrap(), + root_url: String::new(), + control_url: String::new(), + control_schema_url: String::new(), + control_schema: actions + .iter() + .map(|action| ((*action).to_owned(), Vec::new())) + .collect::>(), + provider: Tokio, + } + } + + #[test] + fn hostname_support_requires_add_and_delete_actions() { + assert!(!supports_hostname(&gateway_with_actions(&[]))); + assert!(!supports_hostname(&gateway_with_actions(&[ + ADD_HOSTNAME_ACTION, + ]))); + assert!(!supports_hostname(&gateway_with_actions(&[ + DELETE_HOSTNAME_ACTION, + ]))); + assert!(supports_hostname(&gateway_with_actions(&[ + ADD_HOSTNAME_ACTION, + DELETE_HOSTNAME_ACTION, + ]))); + } + #[test] fn private_external_ips_trigger_echoip_fallback() { // Anything not routable on the public Internet must be rejected so the diff --git a/shared-libs/crates/start-core/src/net/transparent.rs b/shared-libs/crates/start-core/src/net/transparent.rs index e60c7e6591..3dbc3db97e 100644 --- a/shared-libs/crates/start-core/src/net/transparent.rs +++ b/shared-libs/crates/start-core/src/net/transparent.rs @@ -1,23 +1,7 @@ -//! Source-preserving transparent egress for the SNI demux (RFC §4.6). +//! Source-preserving transparent egress for the SNI demux. //! -//! A demux proxy reads the TLS ClientHello on a normal listener (the host is the -//! legitimate destination of the inbound flow), then originates the internal leg -//! with the *client's* source address via `IP_TRANSPARENT`, so the backend sees -//! the real peer rather than this host. Backend→client replies (addressed to the -//! client, transiting this host as the backend's gateway) are diverted back into -//! the proxy socket by policy routing. -//! -//! Datapath (`table ip`/`ip6 startos` + iproute2, mirrored per family): -//! - egress socket: `IP_TRANSPARENT`/`IPV6_TRANSPARENT`, bound to the client's -//! `(ip, port)`; the client and backend legs are necessarily one family. -//! - `mangle_prerouting` `sni-divert`: an inbound packet matching a local -//! `IP_TRANSPARENT` socket (i.e. a reply to such an egress) is marked with -//! [`DIVERT_MARK`]. Only the inbound/reply direction is touched, so the -//! proxy's own egress packets route to the backend normally. -//! - `ip [-6] rule fwmark DIVERT_MARK lookup DIVERT_TABLE priority 49` + `ip [-6] -//! route add local 0.0.0.0/0`|`::/0` `dev lo table DIVERT_TABLE`: deliver the -//! marked replies locally, into the transparent socket. v4 and v6 keep -//! separate rule/route tables, so one number serves both. +//! Upstream sockets bind the client address. Policy routing returns backend +//! replies to the local transparent socket without terminating TLS. use std::net::SocketAddr; @@ -32,35 +16,21 @@ use crate::net::utils::default_keepalive; use crate::prelude::*; use crate::util::Invoke; -/// Firewall mark for transparent-egress reply diversion. Outside the gateway's -/// per-interface `1000 + ifindex` mark space and its priority-50 rule. The mark -/// value is fixed across hosts (the StartWRT fw4 include hardcodes it); only -/// how it is matched varies ([`DivertConfig::masked_fwmark`]). +/// Firewall mark for transparent reply diversion. pub const DIVERT_MARK: u32 = 0x0054_0001; -/// Default routing table holding the local-delivery default for diverted -/// replies. Outside the gateway's `1000 + ifindex` table space. Overridable via -/// [`DivertConfig::route_table`] where the number collides with a host's own -/// table namespace (StartWRT keys per-profile tables by VLAN tag, 1-4094). +/// Default policy-routing table for diverted replies. pub const DIVERT_TABLE: u32 = 1344; -/// Host-specific parameters for the reply-path divert. The defaults are the -/// StartOS/StartTunnel values; a host with a different routing/firewall layout -/// (StartWRT) installs its own via [`set_divert_config`] at startup. +/// Host-specific reply-diversion policy. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DivertConfig { /// Routing table for the local-delivery default route. pub route_table: u32, /// Priority of the fwmark policy rule. pub rule_priority: u32, - /// Match the fwmark with a mask (`mark/mark`) instead of exactly — required - /// on hosts whose mark rule sets the divert bits with `or` (preserving - /// unrelated mark bits, e.g. StartWRT's `0x80` DNAT-return bit), so a - /// diverted packet's mark may carry more than [`DIVERT_MARK`] alone. + /// Whether matching preserves unrelated firewall-mark bits. pub masked_fwmark: bool, - /// Whether [`ensure_divert_infra`] owns the nft mark rule. `false` on a - /// host whose firewall framework ships the rule declaratively (StartWRT's - /// fw4 auto-included `/etc/nftables.d` file), leaving only the iproute2 - /// half managed here. + /// Whether this process manages the nftables marking rule. pub manage_nft: bool, } @@ -77,9 +47,8 @@ impl Default for DivertConfig { static DIVERT_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); -/// Install host-specific divert parameters. Call before anything runs the SNI -/// demux — the first [`ensure_divert_infra`] latches whatever is set at that -/// point. `Err` returns the rejected value when a config was already installed. +/// Installs diversion policy before first use. +/// Returns the rejected value when policy was already installed. pub fn set_divert_config(cfg: DivertConfig) -> Result<(), DivertConfig> { DIVERT_CONFIG.set(cfg) } @@ -88,7 +57,6 @@ fn divert_config() -> &'static DivertConfig { DIVERT_CONFIG.get_or_init(DivertConfig::default) } -/// The fwmark match for the divert policy rule under `cfg`. fn fwmark_arg(cfg: &DivertConfig) -> String { if cfg.masked_fwmark { format!("{DIVERT_MARK:#x}/{DIVERT_MARK:#x}") @@ -97,14 +65,7 @@ fn fwmark_arg(cfg: &DivertConfig) -> String { } } -/// `mangle_prerouting` rule that marks inbound packets belonging to a local -/// `IP_TRANSPARENT` (SNI-demux) socket — the replies to a source-preserving -/// egress connection — so the priority-49 `ip rule` diverts them to the local -/// table and they reach the proxy socket instead of being forwarded back out. -/// Touches only the reply direction (the egress leg is in `output`, not here), -/// so it cannot misroute the proxy's own outbound packets. Spliced into the -/// gateway mangle reconcile so it survives that chain's flush; on hosts without -/// that reconcile (e.g. the tunnel) [`ensure_divert_infra`] adds it directly. +/// Nftables rules marking transparent-socket replies for local delivery. pub fn divert_mark_rule() -> String { [ divert_mark_rule_family("ip"), @@ -119,11 +80,8 @@ fn divert_mark_rule_family(family: &str) -> String { ) } -/// Open the internal leg of a demuxed connection from the client's own source -/// address, so the backend sees the real peer. Requires `CAP_NET_ADMIN` (startd -/// runs as root). The reply path is set up by [`ensure_divert_infra`]. `client` -/// and `target` must be the same family — the source address is bound on the -/// socket that dials the target. +/// Opens an upstream connection bound to the client source address. +/// Both addresses must use the same IP family. #[cfg(target_os = "linux")] pub async fn transparent_connect( client: SocketAddr, @@ -132,7 +90,7 @@ pub async fn transparent_connect( let sock = match (client, target) { (SocketAddr::V4(_), SocketAddr::V4(_)) => { let sock = TcpSocket::new_v4()?; - // Must precede bind: permits binding a non-local (client) address. + // IP_TRANSPARENT must precede bind. socket2::SockRef::from(&sock).set_ip_transparent_v4(true)?; sock } @@ -159,9 +117,7 @@ pub async fn transparent_connect( sock.connect(target).await } -/// `IP_TRANSPARENT` is Linux-only and the SNI demux runs only on the Linux -/// gateway, so this stub never executes off-Linux; it exists to keep the -/// cross-platform build (apple-darwin) compiling. +/// Unsupported non-Linux implementation for cross-platform builds. #[cfg(not(target_os = "linux"))] pub async fn transparent_connect( _client: SocketAddr, @@ -175,10 +131,7 @@ pub async fn transparent_connect( static DIVERT_INFRA: OnceCell<()> = OnceCell::const_new(); -/// [`ensure_divert_infra`] serialized and run at most once per process (cached -/// only on success, so a transient failure is retried on the next call). Cheap -/// to call per connection from the local passthrough path. Serialization keeps -/// concurrent callers from racing the check-then-add commands (EEXIST). +/// Initializes diversion once, retrying after failures. pub async fn ensure_divert_infra_once() -> Result<(), Error> { DIVERT_INFRA .get_or_try_init(|| async { ensure_divert_infra().await.map(|_| ()) }) @@ -186,16 +139,10 @@ pub async fn ensure_divert_infra_once() -> Result<(), Error> { .map(|_| ()) } -/// Serializes [`ensure_divert_infra`]'s check-then-add sequences: concurrent -/// callers (listener startup vs the periodic re-assert) would otherwise race -/// into EEXIST. static DIVERT_ASSERT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -/// Install the reply-path divert (idempotent): the iproute2 half (rule + table) -/// always, plus the nft `sni-divert` mark rule when absent — so hosts that run the -/// SNI demux but not the gateway mangle reconcile (e.g. the tunnel) still mark and -/// divert replies. Safe to call repeatedly; returns whether a missing piece had -/// to be (re-)added, so periodic callers can surface external flushes. +/// Reconciles policy-routing and optional nftables rules. +/// Returns whether any missing rule was restored. pub async fn ensure_divert_infra() -> Result { let _guard = DIVERT_ASSERT.lock().await; let cfg = divert_config(); @@ -204,11 +151,7 @@ pub async fn ensure_divert_infra() -> Result { let priority = cfg.rule_priority.to_string(); let fwmark = fwmark_arg(cfg); - // Both families, and independently: `ip` and `ip -6` keep separate rule and - // route tables, so the same DIVERT_TABLE/DIVERT_MARK numbers serve each. for (flag, default_route, family) in [("-4", "0.0.0.0/0", "ip"), ("-6", "::/0", "ip6")] { - // Local-delivery default in the divert table: marked replies are delivered - // to the local transparent socket instead of being forwarded back out. Command::new("ip") .args([ flag, @@ -224,9 +167,7 @@ pub async fn ensure_divert_infra() -> Result { .invoke(ErrorKind::Network) .await?; - // Policy rule at the configured priority (default 49 — above the - // gateway's per-interface symmetric-return rules at 50, and below - // StartWRT's 100/150/200 ladder) so diverted replies win. + // Divert replies before per-interface symmetric-return rules. let rules = Command::new("ip") .args([flag, "rule", "list"]) .invoke(ErrorKind::Network) @@ -242,10 +183,7 @@ pub async fn ensure_divert_infra() -> Result { repaired = true; } - // nft `sni-divert` mark rule. The gateway reconcile owns (and re-adds) it on - // hosts that run it; install it directly where nothing else does (the tunnel), - // skipping if already present so we never duplicate or fight the reconcile. - // A host whose firewall framework ships the rule itself opts out entirely. + // Install marking only when the host firewall does not own it. if !cfg.manage_nft { continue; } @@ -281,9 +219,7 @@ pub async fn ensure_divert_infra() -> Result { } } - // rp_filter needs no loosening: a diverted reply's source is the backend, - // routable via its own ingress interface, so even strict RPF (1) accepts it - // (verified in a netns harness under both strict and loose). + // Strict reverse-path filtering accepts backend-routable reply sources. Ok(repaired) } diff --git a/shared-libs/crates/start-core/src/tunnel/context.rs b/shared-libs/crates/start-core/src/tunnel/context.rs index 341820a6aa..4d4ee6f5ec 100644 --- a/shared-libs/crates/start-core/src/tunnel/context.rs +++ b/shared-libs/crates/start-core/src/tunnel/context.rs @@ -195,12 +195,7 @@ pub struct TunnelContextSeed { /// Wakes the lease reaper when a stamp may have moved the soonest expiry /// earlier, so it can pull its next wake-up forward. pub lease_wake: tokio::sync::Notify, - /// The wg interface's current ifindex, republished after every `wg-quick` - /// bounce. The PCP/IGD listeners are `SO_BINDTODEVICE`-bound, which pins the - /// ifindex at bind time, so a recreated interface leaves them deaf until - /// they rebind. Level-triggered on the value (not an edge notify) so a - /// listener re-subscribing after a bounce still observes the new index, and - /// an unchanged index skips a needless rebind. + /// Current WireGuard ifindex; listeners rebind after interface recreation. pub forward_ifindex: tokio::sync::watch::Sender, /// Serializes `resync_egress`; its read-DB → install → prune isn't atomic, /// so a concurrent reconcile could prune a rule another call just installed. @@ -606,9 +601,7 @@ impl TunnelContext { Ok(()) } - /// Re-key forwards to follow their target's WAN. A forward's external IP - /// equals its target's WAN, so a WAN change must re-key it old IP → new IP. - /// Idempotent: unchanged keys match in the per-key diffs and are left alone. + /// Moves forwards to each target's active WAN address. pub async fn resync_forward_keys(&self) -> Result<(), Error> { let old = self.db.peek().await.as_port_forwards().de()?.0; @@ -680,65 +673,7 @@ impl TunnelContext { } } - let sni_routes = |map: &BTreeMap| { - let mut out: BTreeMap<(SocketAddrV4, String), SocketAddrV4> = BTreeMap::new(); - for (src, entry) in map { - if let PortForward::Sni { routes, .. } = entry { - for (host, route) in routes { - out.insert((*src, host.clone()), route.target); - } - } - } - out - }; - let old_sni = sni_routes(&old); - let new_sni = sni_routes(&want); - for ((src, host), target) in &old_sni { - if new_sni.get(&(*src, host.clone())) != Some(target) { - self.sni - .unregister(*src.ip(), src.port(), std::slice::from_ref(host), *target); - } - } - for ((src, host), target) in &new_sni { - if old_sni.get(&(*src, host.clone())) != Some(target) { - if let Err(code) = self.sni.register( - *src.ip(), - src.port(), - std::slice::from_ref(host), - *target, - None, - ) { - tracing::warn!("failed to register SNI route {host} on {src}: code {code}"); - } - } - } - - let sni_fallbacks = |map: &BTreeMap| { - let mut out: BTreeMap = BTreeMap::new(); - for (src, entry) in map { - if let PortForward::Sni { - fallback: Some(f), .. - } = entry - { - out.insert(*src, f.target); - } - } - out - }; - let old_fb = sni_fallbacks(&old); - let new_fb = sni_fallbacks(&want); - for (src, target) in &old_fb { - if new_fb.get(src) != Some(target) { - self.sni.unregister_fallback(*src.ip(), src.port(), *target); - } - } - for (src, target) in &new_fb { - if old_fb.get(src) != Some(target) { - if let Err(code) = self.sni.register_fallback(*src.ip(), src.port(), *target) { - tracing::warn!("failed to register SNI fallback on {src}: code {code}"); - } - } - } + reconcile_sni_demux(&self.sni, &old, &want); let old_dnat: BTreeSet = old .iter() @@ -788,6 +723,77 @@ impl TunnelContext { Ok(()) } } +fn reconcile_sni_demux( + sni: &Arc, + old: &BTreeMap, + want: &BTreeMap, +) { + let routes = |map: &BTreeMap, enabled_only: bool| { + let mut out: BTreeMap<(SocketAddrV4, String), SocketAddrV4> = BTreeMap::new(); + for (source, entry) in map { + if let PortForward::Sni { routes, .. } = entry { + for (hostname, route) in routes { + if !enabled_only || route.enabled { + out.insert((*source, hostname.clone()), route.target); + } + } + } + } + out + }; + let old_routes = routes(old, false); + let desired_routes = routes(want, true); + for ((source, hostname), target) in &old_routes { + if desired_routes.get(&(*source, hostname.clone())) != Some(target) { + sni.unregister( + *source.ip(), + source.port(), + std::slice::from_ref(hostname), + *target, + ); + } + } + for ((source, hostname), target) in &desired_routes { + if let Err(code) = sni.register( + *source.ip(), + source.port(), + std::slice::from_ref(hostname), + *target, + None, + ) { + tracing::warn!("failed to register SNI route {hostname} on {source}: code {code}"); + } + } + + let fallbacks = |map: &BTreeMap, enabled_only: bool| { + let mut out = BTreeMap::new(); + for (source, entry) in map { + if let PortForward::Sni { + fallback: Some(fallback), + .. + } = entry + { + if !enabled_only || fallback.enabled { + out.insert(*source, fallback.target); + } + } + } + out + }; + let old_fallbacks = fallbacks(old, false); + let desired_fallbacks = fallbacks(want, true); + for (source, target) in &old_fallbacks { + if desired_fallbacks.get(source) != Some(target) { + sni.unregister_fallback(*source.ip(), source.port(), *target); + } + } + for (source, target) in &desired_fallbacks { + if let Err(code) = sni.register_fallback(*source.ip(), source.port(), *target) { + tracing::warn!("failed to register SNI fallback on {source}: code {code}"); + } + } +} + impl AsRef for TunnelContext { fn as_ref(&self) -> &RpcContinuations { &self.rpc_continuations @@ -964,4 +970,41 @@ mod tests { assert_eq!(allowed_injectors(&server).len(), 1); assert_eq!(injector_keys(&server).len(), 1); } + + #[tokio::test] + async fn unchanged_sni_route_retries_after_bind_failure() { + let blocked = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let source = match blocked.local_addr().unwrap() { + SocketAddr::V4(source) => source, + SocketAddr::V6(_) => unreachable!(), + }; + let target = "10.59.0.2:443".parse().unwrap(); + let hostname = "retry.example.com".to_string(); + let mut routes = BTreeMap::new(); + routes.insert( + hostname.clone(), + crate::tunnel::db::SniRoute { + target, + label: None, + enabled: true, + auto: false, + }, + ); + let forwards = BTreeMap::from([( + source, + PortForward::Sni { + routes, + fallback: None, + }, + )]); + let sni = crate::tunnel::forward::sni::SniDemux::new(); + + reconcile_sni_demux(&sni, &forwards, &forwards); + assert!(sni.snapshot().is_empty()); + + drop(blocked); + reconcile_sni_demux(&sni, &forwards, &forwards); + assert_eq!(sni.snapshot().len(), 1); + assert_eq!(sni.snapshot()[0].hostname, hostname); + } } diff --git a/shared-libs/crates/start-core/src/tunnel/forward/pcp.rs b/shared-libs/crates/start-core/src/tunnel/forward/pcp.rs index 9d9f457aae..8b69d2241a 100644 --- a/shared-libs/crates/start-core/src/tunnel/forward/pcp.rs +++ b/shared-libs/crates/start-core/src/tunnel/forward/pcp.rs @@ -24,9 +24,7 @@ use crate::tunnel::forward::lease::{self, LeaseKey}; use crate::tunnel::forward::sni::SniDemux; use crate::tunnel::wg::WIREGUARD_INTERFACE_NAME; -/// Run the PCP server (IPv4 DNAT forwards + IPv6 GUA pinholes) for the life of -/// the tunnel, each family self-restarting on error and rebinding when the wg -/// interface's ifindex changes (an `Ok` return from the serve loop). +/// Runs IPv4 and IPv6 PCP listeners, rebinding after WireGuard recreation. pub async fn run(ctx: TunnelContext) { let started = Instant::now(); let v4 = async { @@ -65,15 +63,13 @@ fn socket() -> Result { UdpSocket::from_std(socket.into()).with_kind(ErrorKind::Network) } -/// The v6 counterpart of [`socket`]: an IPv6-only UDP socket on the WireGuard -/// interface, so a client's PCP MAP for its own GUA reaches us over the tunnel. +/// A WireGuard-bound IPv6-only PCP socket. fn socket6() -> Result { let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP)) .with_kind(ErrorKind::Network)?; socket .set_reuse_address(true) .with_kind(ErrorKind::Network)?; - // v4 requests go to the v4 socket; keep this one v6-only so both can bind :5351. socket.set_only_v6(true).with_kind(ErrorKind::Network)?; bind_to_wireguard(&socket)?; socket @@ -89,7 +85,7 @@ fn socket6() -> Result { } async fn serve(ctx: &TunnelContext, started: Instant) -> Result<(), Error> { - // Subscribe before binding so a bounce during setup still triggers a rebind. + // Subscribe before binding to close the setup race. let mut ifindex = ctx.forward_ifindex.subscribe(); ifindex.borrow_and_update(); let socket = socket()?; @@ -148,9 +144,7 @@ impl GatewayBackend for TunnelContext { _peer: Ipv4Addr, lifetime: Option, ) -> Result<(), u16> { - // `apply_peer_forward_range` stamps the lease itself (Dnat on the DNAT - // path, SniFallback when the port is SNI-demuxed and this becomes its - // fallback), so a still-renewing client is never reaped whichever it is. + // The forward helper stamps the selected DNAT or SNI-fallback lease. apply_peer_forward_range(self, source, target, count, "PCP", lifetime).await } @@ -177,8 +171,7 @@ impl GatewayBackend for TunnelContext { lease::forget(self, &LeaseKey::Dnat(source)); true } - // A bare UPnP map on an SNI port became the port's fallback; its - // delete clears the fallback (routes belong to hostnames, not UPnP). + // Plain mappings on SNI ports own only the fallback. Some(PortForward::Sni { fallback: Some(fallback), .. @@ -263,7 +256,6 @@ impl GatewayBackend for TunnelContext { hostnames: &[String], lifetime: Option, ) -> Result<(), u8> { - // The PCP path owns its routes: mark them automatic, default label `PCP`. self.persist_sni_forward(source, target, hostnames, lifetime, true, None) .await } @@ -308,11 +300,7 @@ impl GatewayBackend for TunnelContext { } impl TunnelContext { - /// Persist + register SNI-demuxed hostname routes on `source` to `target`. - /// `auto` records who owns the route for the UI Manual/Automatic split: the - /// PCP path passes `true`; a manual add passes `false` and its own `label`. - /// `lifetime` leases the routes (auto only). Shared by both so the two paths - /// can't drift; only the ownership/label inputs differ. + /// Persists and registers SNI-demuxed hostname routes. pub async fn persist_sni_forward( &self, source: SocketAddrV4, @@ -322,52 +310,47 @@ impl TunnelContext { auto: bool, label: Option, ) -> Result<(), u8> { - // A fresh route with no explicit label defaults to `PCP` only when auto. let default_label = if auto { Some("PCP".to_string()) } else { label }; - // Persist first (DB is source of truth): reject a DNAT-occupied port or a - // foreign-owned hostname before touching the dataplane. Registering first - // risked a rollback on a transient DB error tearing down a valid binding. + // Reject conflicts before displacing a working DNAT. let hostnames_owned = hostnames.to_vec(); let persisted = self .db .mutate(|db| { db.as_port_forwards_mut().mutate(|pf| { use crate::tunnel::db::{PortForward, SniRoute}; - // SNI routes may share a source (demux by hostname), but the - // source must not fall inside a different DNAT range. + let previous = pf.0.get(&source).cloned(); if let Some(conflict) = pf.overlapping(source, 1) { return Err(Error::new( eyre!("{source} overlaps an existing forward at {conflict}"), ErrorKind::InvalidRequest, )); } - // A lone same-owner hostname-less DNAT on this exact port is - // promoted to this shared SNI port's fallback, so a bare public - // IP and named domains can coexist. A range or another client's - // DNAT is rejected (see `plan_dnat_conversion`). let mut converted = None; if plan_dnat_conversion(pf.0.get(&source), source, target)? { - if let Some(PortForward::Dnat { - target: dt, - label, - enabled, - auto, - .. - }) = pf.0.remove(&source) - { - converted = Some(dt); + if let Some(dnat @ PortForward::Dnat { .. }) = pf.0.remove(&source) { + let PortForward::Dnat { + target, + label, + enabled, + auto, + .. + } = &dnat + else { + unreachable!() + }; pf.0.insert( source, PortForward::Sni { routes: std::collections::BTreeMap::new(), fallback: Some(SniRoute { - target: dt, - label, - enabled, - auto, + target: *target, + label: label.clone(), + enabled: *enabled, + auto: *auto, }), }, ); + converted = Some(dnat); } } let entry = pf.0.entry(source).or_insert_with(|| PortForward::Sni { @@ -399,9 +382,8 @@ impl TunnelContext { }, ); } - Ok(converted) + Ok((converted, previous)) } - // Unreachable: a lone DNAT was converted to Sni just above. PortForward::Dnat { .. } => Err(Error::new( eyre!("{source} is already a DNAT forward"), ErrorKind::InvalidRequest, @@ -411,21 +393,28 @@ impl TunnelContext { }) .await .result; - let converted = match persisted { + let (converted, previous) = match persisted { Ok(c) => c, Err(_) => return Err(crate::net::port_map::pcp::hostname::RESULT_HOSTNAME_TAKEN), }; - // If a lone DNAT was promoted to this port's fallback, bind the fallback in - // the demux, tear down the now-superseded kernel DNAT (the SNI listener - // takes over the port), and carry its lease to the fallback so a still- - // renewing bare-IP client isn't reaped before its next MAP. - if let Some(dnat_target) = converted { - if let Err(code) = self - .sni - .register_fallback(*source.ip(), source.port(), dnat_target) - { - tracing::warn!("failed to register fallback converting DNAT on {source}: {code}"); - } + let converted_target = converted.as_ref().and_then(|forward| match forward { + PortForward::Dnat { target, .. } => Some(*target), + PortForward::Sni { .. } => None, + }); + if let Some(dnat_target) = converted_target { + let dnat = converted.unwrap(); + register_converted_sni(&self.sni, source, target, hostnames, dnat_target, || { + self.restore_persisted_forward(source, Some(dnat)) + }) + .await?; + } else if let Err(code) = + self.sni + .register(*source.ip(), source.port(), hostnames, target, None) + { + self.restore_persisted_forward(source, previous).await; + return Err(code); + } + if converted_target.is_some() { if let Some(rc) = self.active_forwards.mutate(|m| m.remove(&source)) { drop(rc); self.forward.gc().await.log_err(); @@ -437,16 +426,6 @@ impl TunnelContext { }); } } - // Mirror into the dataplane; on the unexpected register failure undo the - // DB routes we just added. - if self - .sni - .register(*source.ip(), source.port(), hostnames, target, None) - .is_err() - { - self.remove_sni_forward(source, target, hostnames).await; - return Err(crate::net::port_map::pcp::hostname::RESULT_HOSTNAME_TAKEN); - } if let Some(lt) = lifetime { for h in hostnames { lease::stamp( @@ -462,11 +441,26 @@ impl TunnelContext { Ok(()) } - /// Persist + register the hostname-less fallback on `source -> target`. The - /// port must already be SNI-demuxed (a lone hostname-less forward stays a - /// kernel DNAT); on a StartTunnel this is how a bare public IP shares a port - /// with named domains. `auto`/`label` mirror [`persist_sni_forward`]; the - /// same target reclaims, a different one is rejected (one fallback per port). + async fn restore_persisted_forward(&self, source: SocketAddrV4, previous: Option) { + self.db + .mutate(|db| { + db.as_port_forwards_mut().mutate(|forwards| { + match previous.clone() { + Some(forward) => restore_forward_entry(forwards, source, forward), + None => { + forwards.0.remove(&source); + } + } + Ok(()) + }) + }) + .await + .result + .log_err(); + } + + /// Persists a hostname-less fallback on an existing SNI port. + /// The same target reclaims it; a different target is rejected. pub async fn persist_fallback_forward( &self, source: SocketAddrV4, @@ -554,13 +548,34 @@ impl TunnelContext { } } -/// Whether a hostname MAP arriving on `source` may promote an existing -/// hostname-less DNAT there into this SNI port's fallback. `Ok(true)` converts (a -/// lone, same-owner DNAT); `Ok(false)` means nothing to convert (empty or already -/// SNI). `Err` rejects an incompatible claim: a DNAT range (can't host SNI) or a -/// *different* client's DNAT — a whole-port claim another peer must not carve up. -/// Each peer's forward target is forced to its own tunnel IP, so the target IP -/// identifies the owner. +async fn register_converted_sni( + sni: &Arc, + source: SocketAddrV4, + target: SocketAddrV4, + hostnames: &[String], + dnat_target: SocketAddrV4, + restore: F, +) -> Result<(), u8> +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + if let Err(code) = sni.register_fallback(*source.ip(), source.port(), dnat_target) { + restore().await; + return Err(code); + } + if let Err(code) = sni.register(*source.ip(), source.port(), hostnames, target, None) { + sni.unregister_fallback(*source.ip(), source.port(), dnat_target); + restore().await; + return Err(code); + } + Ok(()) +} + +fn restore_forward_entry(forwards: &mut PortForwards, source: SocketAddrV4, forward: PortForward) { + forwards.0.insert(source, forward); +} + fn plan_dnat_conversion( existing: Option<&PortForward>, source: SocketAddrV4, @@ -709,7 +724,10 @@ fn mapping_entries(forwards: &PortForwards, peer: Ipv4Addr) -> Vec mod tests { use std::net::SocketAddrV4; - use super::{mapping_entries, peer_forward_matches, plan_dnat_conversion, sni_route_fields}; + use super::{ + mapping_entries, peer_forward_matches, plan_dnat_conversion, register_converted_sni, + restore_forward_entry, sni_route_fields, + }; use crate::tunnel::db::{PortForward, PortForwards, SniRoute}; fn route(label: Option<&str>, enabled: bool, auto: bool) -> SniRoute { @@ -789,6 +807,51 @@ mod tests { assert!(!plan_dnat_conversion(Some(&sni), src, theirs).unwrap()); } + #[tokio::test] + async fn blocked_sni_listener_restores_converted_dnat() { + let blocked = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + let source = match blocked.local_addr().unwrap() { + std::net::SocketAddr::V4(source) => source, + std::net::SocketAddr::V6(_) => unreachable!(), + }; + let target: SocketAddrV4 = "10.59.0.2:443".parse().unwrap(); + let dnat = dnat("10.59.0.2:443", 1); + let mut forwards = PortForwards(Default::default()); + forwards.0.insert( + source, + PortForward::Sni { + routes: Default::default(), + fallback: Some(SniRoute { + target, + label: None, + enabled: true, + auto: true, + }), + }, + ); + let forwards = std::sync::Arc::new(std::sync::Mutex::new(forwards)); + let restore_forwards = forwards.clone(); + let sni = crate::tunnel::forward::sni::SniDemux::new(); + + let code = register_converted_sni( + &sni, + source, + target, + &["blocked.example.com".to_string()], + target, + move || async move { + restore_forward_entry(&mut restore_forwards.lock().unwrap(), source, dnat); + }, + ) + .await + .unwrap_err(); + assert_eq!(code, crate::net::port_map::pcp::RESULT_NO_RESOURCES); + assert!(matches!( + forwards.lock().unwrap().0.get(&source), + Some(PortForward::Dnat { target: restored, .. }) if *restored == target + )); + } + // A manually-added hostname on a fresh source is stored as manual, with the // user's own label — never as an automatic `PCP` route (the reported bug). #[test] diff --git a/shared-libs/crates/start-core/src/tunnel/forward/sni.rs b/shared-libs/crates/start-core/src/tunnel/forward/sni.rs index f70d28da32..fe5ba379e6 100644 --- a/shared-libs/crates/start-core/src/tunnel/forward/sni.rs +++ b/shared-libs/crates/start-core/src/tunnel/forward/sni.rs @@ -8,7 +8,7 @@ //! //! QUIC (§4.5) and wildcards beyond a single leading `*` label are out of scope. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -38,11 +38,7 @@ struct Binding { expiry: Option, } -/// Which sources a port's fallback admits. Hostname routes are never -/// source-scoped (an SNI client can come from anywhere); the fallback can be, -/// because it may stand in for a firewall rule that was source-scoped — e.g. -/// StartWRT remote access in "behind NAT" mode admits only private sources, -/// and the demux taking its port must not widen that. +/// Sources permitted to use a port fallback. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FallbackSource { Any, @@ -54,15 +50,12 @@ pub enum FallbackSource { struct Fallback { target: SocketAddrV4, source: FallbackSource, - /// Open the internal leg source-preserving (`transparent_connect`). False - /// for a gateway-self fallback: a spoofed-source dial to the gateway's own - /// listener would be martian-dropped on the loopback reply path. + /// Whether upstream connections preserve the client source address. transparent: bool, } #[derive(Default)] struct PortBindings { - /// hostname (lowercase) -> binding; a `*.suffix` key is a wildcard. hostnames: BTreeMap, fallback: Option, } @@ -75,10 +68,8 @@ impl PortBindings { fn is_empty(&self) -> bool { self.hostnames.is_empty() && self.fallback.is_none() } - /// exact match, then a `*.suffix` wildcard on the parent, then fallback. - /// Returns the target and whether to open the internal leg - /// source-preserving. `peer` gates a source-scoped fallback only — hostname - /// routes match regardless of source. + /// Selects exact, wildcard, then admissible fallback targets. + /// Returns the target and source-preservation policy. fn select(&self, sni: Option<&str>, peer: Ipv4Addr) -> Option<(SocketAddrV4, bool)> { if let Some(name) = sni { if let Some(b) = self.hostnames.get(name) { @@ -93,8 +84,6 @@ impl PortBindings { let f = self.fallback?; match f.source { FallbackSource::Any => {} - // std's `is_private` is exactly RFC1918, matching the firewall - // source scoping this fallback stands in for. FallbackSource::PrivateOnly if peer.is_private() => {} FallbackSource::PrivateOnly => return None, } @@ -117,15 +106,8 @@ pub struct SniRoute { /// can open/close inbound access (e.g. a StartWRT firewall ACCEPT rule). type OnChange = Box; -/// Resolves the prefix length of the local subnet holding an address, or `None` -/// where the host has no such subnet — the demux's equivalent of the DNAT -/// path's `target_prefix` (`crate::net::forward::add_forward`). -/// -/// A host supplies this when a client and a target can share a broadcast -/// segment, because a hairpinned connection between two such addresses returns -/// over that segment and never reaches this host again. Leave it unset where -/// every internal path routes through the host regardless (the tunnel's -/// hub-and-spoke WireGuard mesh), since source preservation works there. +/// Resolves a target's local subnet prefix for hairpin detection. +/// `None` preserves the client source address. pub type LocalPrefix = Arc BoxFuture<'static, Option> + Send + Sync>; pub struct SniDemux { @@ -140,8 +122,7 @@ impl SniDemux { Self::build(None, None) } - /// Like [`new`](Self::new) but invokes `on_change` on listener - /// create/teardown and consults `local_prefix` to spot a hairpinned client. + /// Installs listener lifecycle and local-subnet hooks. pub fn with_on_change( on_change: impl Fn(u16, bool) + Send + Sync + 'static, local_prefix: Option, @@ -163,9 +144,7 @@ impl SniDemux { tokio::time::sleep(Duration::from_secs(30)).await; let Some(this) = weak.upgrade() else { break }; this.prune(); - // Re-assert the reply-path divert while any listener is active: - // heals external flushes (networkd restart, nft flush) that - // would otherwise silently hang all demuxed traffic. + // Repair external divert-rule flushes while listeners are active. if this.listeners.peek(|l| !l.is_empty()) { match crate::net::transparent::ensure_divert_infra().await { Ok(repaired) => { @@ -191,12 +170,9 @@ impl SniDemux { this } - /// Register hostname bindings for `(ext_ip, ext_port) -> target` and ensure - /// the listener runs. `Err(RESULT_HOSTNAME_TAKEN)` if any name is held by a - /// different target — all-or-nothing; the same target reclaims. - /// `Err(RESULT_NO_RESOURCES)` if the listener cannot bind: a grant must - /// never outrun its socket, since the gateway opens firewall access on the - /// strength of it. + /// Registers all hostnames atomically and starts their shared listener. + /// Returns `RESULT_HOSTNAME_TAKEN` for an occupied name or + /// `RESULT_NO_RESOURCES` when the listener cannot bind. pub fn register( self: &Arc, ext_ip: Ipv4Addr, @@ -208,7 +184,7 @@ impl SniDemux { let now = Instant::now(); let expiry = lifetime_secs.map(|s| now + Duration::from_secs(s as u64)); let key = (ext_ip, ext_port); - self.ports.mutate(|ports| { + let previous = self.ports.mutate(|ports| { let entry = ports.entry(key).or_default(); entry.prune(now); for name in hostnames { @@ -218,12 +194,16 @@ impl SniDemux { } } } + let previous = hostnames + .iter() + .map(|name| entry.hostnames.get(name).cloned()) + .collect::>(); for name in hostnames { entry .hostnames .insert(name.clone(), Binding { target, expiry }); } - Ok(()) + Ok(previous) })?; if let Err(e) = self.ensure_listener(key) { tracing::warn!( @@ -231,19 +211,16 @@ impl SniDemux { key.0, key.1 ); - // Roll back this call's insertions so snapshot/auto-list never - // report a route with no listener behind it. Same-target removal is - // safe: a distinct pre-existing same-target binding implies a live - // listener, in which case the bind was never attempted. self.ports.mutate(|ports| { if let Some(entry) = ports.get_mut(&key) { - for name in hostnames { - if entry - .hostnames - .get(name) - .is_some_and(|b| b.target == target) - { - entry.hostnames.remove(name); + for (name, previous) in hostnames.iter().zip(previous) { + match previous { + Some(binding) => { + entry.hostnames.insert(name.clone(), binding); + } + None => { + entry.hostnames.remove(name); + } } } } @@ -279,12 +256,7 @@ impl SniDemux { self.reap_if_empty(key); } - /// Set the hostname-less fallback for `(ext_ip, ext_port) -> target` and - /// ensure the listener runs. Traffic matching no hostname route (or sending - /// no SNI) is spliced here, source-preserving, from any source. - /// `Err(RESULT_HOSTNAME_TAKEN)` if a different target already holds the - /// fallback; the same target reclaims (idempotent). - /// `Err(RESULT_NO_RESOURCES)` if the listener cannot bind. + /// Registers a source-preserving fallback for unmatched traffic. pub fn register_fallback( self: &Arc, ext_ip: Ipv4Addr, @@ -302,11 +274,7 @@ impl SniDemux { ) } - /// Like [`register_fallback`](Self::register_fallback), but for the - /// gateway's *own* listener (e.g. StartWRT's web UI behind a shared 443): - /// the internal leg is a plain connect — a source-preserving dial to - /// ourselves would be martian-dropped — and `source` scopes who may reach - /// it, mirroring the firewall rule the demux displaced. + /// Registers a source-scoped fallback to a gateway-local listener. pub fn register_local_fallback( self: &Arc, ext_ip: Ipv4Addr, @@ -332,15 +300,14 @@ impl SniDemux { fallback: Fallback, ) -> Result<(), u8> { let key = (ext_ip, ext_port); - self.ports.mutate(|ports| { + let previous = self.ports.mutate(|ports| { let entry = ports.entry(key).or_default(); if entry.fallback.is_some_and(|f| f.target != fallback.target) { return Err(RESULT_HOSTNAME_TAKEN); } - // Same-target re-register also refreshes source/transparency, so a - // policy change (e.g. a remote-access mode switch) applies in place. + let previous = entry.fallback; entry.fallback = Some(fallback); - Ok(()) + Ok(previous) })?; if let Err(e) = self.ensure_listener(key) { tracing::warn!( @@ -350,9 +317,7 @@ impl SniDemux { ); self.ports.mutate(|ports| { if let Some(entry) = ports.get_mut(&key) { - if entry.fallback.is_some_and(|f| f.target == fallback.target) { - entry.fallback = None; - } + entry.fallback = previous; } }); self.reap_if_empty(key); @@ -374,8 +339,7 @@ impl SniDemux { self.reap_if_empty(key); } - /// The live hostname routes, for gateway UIs (StartWRT's Automatic table). - /// Fallbacks are not reported — they are port-level, not hostname routes. + /// Live hostname routes, excluding port-level fallbacks. pub fn snapshot(&self) -> Vec { let now = Instant::now(); self.ports.peek(|ports| { @@ -400,43 +364,40 @@ impl SniDemux { }) } - /// Move every binding keyed to another external IPv4 onto `new_ip` — the - /// gateway's WAN address changed, but the routes (and their ports) live on. - /// On hostname collision the binding already at the new key wins; a fallback - /// already at the new key likewise. Stranded listeners are dropped without - /// firing `on_change(port, false)` — the port set is unchanged, and a - /// spawned teardown could race the re-add and close a live port — - /// then re-ensured on the new key (`on_change(port, true)` is an idempotent - /// upsert for the gateway). No-op when everything is already on `new_ip`. - /// If a re-bind on the new key fails, that port's routes are dropped and - /// `on_change(port, false)` *does* fire — dead routes must not hold the - /// gateway's port open; clients re-assert within their lease. + /// Move all bindings to `new_ip`. Existing destination bindings win + /// collisions. A failed destination listener drops its merged bindings. pub fn rekey_ipv4(self: &Arc, new_ip: Ipv4Addr) { - let moved: Vec = self.ports.mutate(|ports| { - let old_keys: Vec = ports.keys().filter(|k| k.0 != new_ip).copied().collect(); + let (moved, destinations): (Vec, BTreeSet) = self.ports.mutate(|ports| { + let old_keys: Vec = ports + .keys() + .filter(|key| key.0 != new_ip) + .copied() + .collect(); let mut moved = Vec::new(); + let mut destinations = BTreeSet::new(); for old in old_keys { let Some(bindings) = ports.remove(&old) else { continue; }; - let entry = ports.entry((new_ip, old.1)).or_default(); - for (name, b) in bindings.hostnames { - entry.hostnames.entry(name).or_insert(b); + let destination = (new_ip, old.1); + let entry = ports.entry(destination).or_default(); + for (name, binding) in bindings.hostnames { + entry.hostnames.entry(name).or_insert(binding); } if entry.fallback.is_none() { entry.fallback = bindings.fallback; } moved.push(old); + destinations.insert(destination); } - moved + (moved, destinations) }); - for old in &moved { - if let Some(handle) = self.listeners.mutate(|l| l.remove(old)) { - drop(handle); // aborts the stranded listener; no on_change + for old in moved { + if let Some(handle) = self.listeners.mutate(|listeners| listeners.remove(&old)) { + drop(handle); } } - for old in moved { - let key = (new_ip, old.1); + for key in destinations { if let Err(e) = self.ensure_listener(key) { tracing::error!( "SNI demux re-key bind on {}:{} failed; dropping the port's routes: {e}", @@ -487,19 +448,23 @@ impl SniDemux { } } - /// Ensure a listener for `key`, binding inline so a failure is observable - /// to the caller — never grant first and bind later, or traffic the - /// gateway admits for the grant falls through to whatever wildcard socket - /// shares the port (on StartWRT, the router's own web UI). - /// `SO_REUSEPORT` lets this specific `(ext_ip, port)` socket coexist with a - /// same-process wildcard listener on the same port: TCP delivery prefers - /// the most-specific bound address, so the demux receives only traffic to - /// its external IP and the wildcard keeps the rest. + /// Starts the address-specific listener before callers grant a route. fn ensure_listener(self: &Arc, key: PortKey) -> std::io::Result<()> { - let already = self.listeners.mutate(|l| l.contains_key(&key)); + let already = self + .listeners + .mutate(|listeners| listeners.contains_key(&key)); if already { return Ok(()); } + if self + .ports + .peek(|ports| ports.get(&key).is_none_or(PortBindings::is_empty)) + { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "no bindings for SNI listener", + )); + } let listener = crate::net::utils::bind_tokio_listener_reuse_port( SocketAddrV4::new(key.0, key.1).into(), )?; @@ -541,7 +506,6 @@ async fn run_listener( handle_conn(conn, peer, key, ports, local_prefix).await; }); } - // Transient (EMFILE, ECONNABORTED): never tear down the listener. Err(e) => { tracing::warn!("SNI demux accept on {}:{}: {e}", key.0, key.1); tokio::time::sleep(ACCEPT_RETRY_DELAY).await; @@ -557,7 +521,7 @@ async fn handle_conn( ports: Arc>>, local_prefix: Option, ) { - // Reap silently-vanished peers, else copy_bidirectional pins the fd pair forever. + // Bound abandoned connections after peer disappearance. if let Err(e) = socket2::SockRef::from(&conn).set_tcp_keepalive(&crate::net::utils::default_keepalive()) { @@ -573,7 +537,6 @@ async fn handle_conn( if let Some(name) = extract_sni(&buf) { break Some(name); } - // Complete-but-SNI-less, non-TLS, or capped: stop and use fallback. if record_complete(&buf) || buf.len() >= CLIENTHELLO_CAP { break extract_sni(&buf); } @@ -583,28 +546,19 @@ async fn handle_conn( }; let SocketAddr::V4(peer) = peer else { - return; // IPv4-only listener; should not occur + return; }; let selected = ports.peek(|p| { p.get(&key) .and_then(|e| e.select(sni.as_deref(), *peer.ip())) }); let Some((target, transparent)) = selected else { - return; // no match and no admissible fallback: close + return; }; - // A client on the target's own segment is hairpinning — it dialed our - // external address from inside — and the target answers it directly over - // that segment, so a source-preserved leg leaves the client waiting on a - // reply it discards as coming from the wrong address. Dial from our own - // address there instead, the same trade the DNAT path makes with its - // hairpin masquerade (`build/lib/scripts/forward-port`). + // Same-subnet clients need the gateway source address for return traffic. let transparent = transparent && !is_hairpin(&local_prefix, *peer.ip(), *target.ip()).await; let mut upstream = if transparent { - // Open the internal leg from the client's own source address (RFC - // §4.6). No plain-connect fallback on failure: the backend gates - // LAN-only addresses on the source being private, and this server's - // own wg address is private — a fallback would present every WAN - // client as LAN-local. + // A failed source-preserving connection must not fall back to gateway source. match crate::net::transparent::transparent_connect( SocketAddr::V4(peer), SocketAddr::V4(target), @@ -618,7 +572,6 @@ async fn handle_conn( } } } else { - // A gateway-self fallback or a hairpinned client: dial as ourselves. match TcpStream::connect(SocketAddr::V4(target)).await { Ok(upstream) => upstream, Err(e) => { @@ -633,11 +586,7 @@ async fn handle_conn( let _ = copy_bidirectional(&mut conn, &mut upstream).await; } -/// Whether `peer` reaches `target` without traversing this host, because both -/// sit in the same local subnet. `false` whenever the host declines to say -/// (no [`LocalPrefix`], or no local subnet holds `target`) — source -/// preservation is the safe answer, since losing it hands every client the -/// gateway's own address. +/// Whether the peer and target share a known local subnet. async fn is_hairpin(local_prefix: &Option, peer: Ipv4Addr, target: Ipv4Addr) -> bool { let Some(resolve) = local_prefix else { return false; @@ -688,8 +637,7 @@ impl Default for SniDemux { mod tests { use super::*; - /// A real ClientHello produced by rustls, carrying `sni` in the SNI - /// extension — so the parser is exercised against genuine wire bytes. + /// A rustls-generated ClientHello carrying SNI. fn real_client_hello(sni: &str) -> Vec { use tokio_rustls::rustls::pki_types::ServerName; use tokio_rustls::rustls::{ClientConfig, ClientConnection, RootCertStore}; @@ -728,7 +676,6 @@ mod tests { let fb = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 9), 443); let host_target = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443); - // A fallback can be set; a different target can't steal it, same reclaims. demux.register_fallback(ip, port, fb).unwrap(); assert!( demux @@ -737,8 +684,6 @@ mod tests { ); assert!(demux.register_fallback(ip, port, fb).is_ok()); - // A named route coexists with the fallback: exact SNI hits the route, - // no/unmatched SNI hits the fallback. let anywhere = Ipv4Addr::new(203, 0, 113, 50); demux .register(ip, port, &["a.example.com".to_string()], host_target, None) @@ -756,8 +701,6 @@ mod tests { assert_eq!(pb.select(None, anywhere), Some((fb, true))); }); - // Unregister with the wrong target is a no-op; the right target clears it, - // leaving the named route intact. demux.unregister_fallback(ip, port, SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 8), 443)); demux.ports.peek(|p| { assert_eq!( @@ -777,9 +720,6 @@ mod tests { }); } - // A local (gateway-self) fallback: plain-connect leg, and PrivateOnly - // scoping admits RFC1918 sources while public sources fall through to a - // close — never to the gateway's own listener. #[tokio::test] async fn local_fallback_source_policy() { let demux = SniDemux::new(); @@ -797,16 +737,13 @@ mod tests { let public = Ipv4Addr::new(203, 0, 113, 50); demux.ports.peek(|p| { let pb = p.get(&(ip, port)).unwrap(); - // Hostname routes are never source-scoped. assert_eq!( pb.select(Some("a.example.com"), public), Some((host_target, true)) ); - // The local fallback is plain-connect and private-only. assert_eq!(pb.select(None, private), Some((ui, false))); assert_eq!(pb.select(None, public), None); }); - // Re-registering with a new policy updates in place (mode switch). demux .register_local_fallback(ip, port, ui, FallbackSource::Any) .unwrap(); @@ -816,10 +753,6 @@ mod tests { }); } - // A grant must never outrun its socket: with the port held by a non- - // SO_REUSEPORT listener the bind fails, the register is refused with - // NO_RESOURCES, nothing is recorded, and on_change never fires — so a - // gateway never opens firewall access for a route with no listener. #[tokio::test] async fn bind_failure_refuses_grant_and_rolls_back() { let events = Arc::new(SyncMutex::new(Vec::<(u16, bool)>::new())); @@ -828,7 +761,6 @@ mod tests { move |port, active| recorded.mutate(|e| e.push((port, active))), None, ); - // Plain bind (no SO_REUSEPORT) — the demux's reuseport bind cannot join. let blocker = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); let port = blocker.local_addr().unwrap().port(); let target = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443); @@ -856,7 +788,6 @@ mod tests { .ports .peek(|p| assert!(!p.contains_key(&(Ipv4Addr::LOCALHOST, port)))); - // Blocker gone: the same register now succeeds and on_change fires. drop(blocker); demux .register( @@ -871,9 +802,6 @@ mod tests { assert_eq!(events.peek(|e| e.clone()), vec![(port, true)]); } - // The coexistence the SO_REUSEPORT bind exists for: a same-process - // wildcard listener (StartWRT's web UI) shares the port with the demux's - // specific bind. #[tokio::test] async fn reuseport_bind_coexists_with_wildcard_listener() { let wildcard = @@ -930,7 +858,6 @@ mod tests { demux .register(ip, 44311, &["b.example.com".to_string()], t2, None) .unwrap(); - // Fallbacks are port-level, not hostname routes: not reported. demux.register_fallback(ip, 44312, t1).unwrap(); let mut snap = demux.snapshot(); @@ -944,6 +871,42 @@ mod tests { assert_eq!(snap[1].remaining_secs, None); } + #[tokio::test] + async fn rekey_failure_does_not_create_an_empty_listener() { + let blocker = Arc::new(SyncMutex::new(Some( + std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(), + ))); + let port = blocker.peek(|listener| listener.as_ref().unwrap().local_addr().unwrap().port()); + let release_blocker = blocker.clone(); + let demux = SniDemux::with_on_change( + move |_, active| { + if !active { + release_blocker.mutate(Option::take); + } + }, + None, + ); + let target = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443); + for old_ip in [Ipv4Addr::new(127, 0, 0, 2), Ipv4Addr::new(127, 0, 0, 3)] { + demux + .register( + old_ip, + port, + &[format!("{old_ip}.example.com")], + target, + None, + ) + .unwrap(); + } + + demux.rekey_ipv4(Ipv4Addr::LOCALHOST); + + demux.ports.peek(|ports| assert!(ports.is_empty())); + demux + .listeners + .peek(|listeners| assert!(listeners.is_empty())); + } + #[tokio::test] async fn rekey_moves_bindings_and_never_fires_teardown() { let events = Arc::new(SyncMutex::new(Vec::<(u16, bool)>::new())); @@ -967,8 +930,6 @@ mod tests { ) .unwrap(); demux.register_fallback(old_ip, 44313, t1).unwrap(); - // A binding already on the new key: survives the merge and wins any - // hostname collision. demux .register(new_ip, 44313, &["a.example.com".to_string()], t2, None) .unwrap(); @@ -1001,16 +962,11 @@ mod tests { "rekey must never fire on_change(port, false)" ); - // Already keyed to new_ip: a second rekey is a no-op (no new events). let before = events.peek(|e| e.len()); demux.rekey_ipv4(new_ip); assert_eq!(events.peek(|e| e.len()), before); } - // Only a client sharing the target's subnet is hairpinning. A client on - // another of the gateway's subnets still routes its replies through the - // gateway, so it keeps source preservation, and so does everyone when the - // host supplies no prefix or knows no subnet holding the target. #[tokio::test] async fn hairpin_is_scoped_to_the_targets_own_subnet() { let lan = Ipv4Net::new(Ipv4Addr::new(192, 168, 1, 0), 24).unwrap(); @@ -1022,10 +978,8 @@ mod tests { let target = Ipv4Addr::new(192, 168, 1, 10); assert!(is_hairpin(&host, Ipv4Addr::new(192, 168, 1, 50), target).await); - // Another VLAN, and the WAN: both reach the target through us. assert!(!is_hairpin(&host, Ipv4Addr::new(192, 168, 9, 50), target).await); assert!(!is_hairpin(&host, Ipv4Addr::new(203, 0, 113, 50), target).await); - // No local subnet holds the target — the host cannot say, so preserve. assert!( !is_hairpin( &host, @@ -1034,7 +988,6 @@ mod tests { ) .await ); - // A host that supplies no prefix at all preserves unconditionally. assert!(!is_hairpin(&None, Ipv4Addr::new(192, 168, 1, 50), target).await); } } From 3a30e527a50a65f39fb827851f250f9f4a84c8e3 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:49:30 +0000 Subject: [PATCH 10/14] fix: make SNI route grants transactional --- .../backend/ctrl/src/port_control.rs | 73 +++++---- .../backend/ctrl/src/published_ports.rs | 47 +++--- .../src/net/port_map/pcp/hostname.rs | 9 +- .../start-core/src/net/port_map/server/igd.rs | 45 +++++- .../start-core/src/net/port_map/server/mod.rs | 1 + .../crates/start-core/src/tunnel/api.rs | 7 + .../crates/start-core/src/tunnel/context.rs | 2 + .../crates/start-core/src/tunnel/db.rs | 2 +- .../start-core/src/tunnel/forward/igd.rs | 3 +- .../start-core/src/tunnel/forward/pcp.rs | 47 +++++- .../start-core/src/tunnel/forward/sni.rs | 141 ++++++++++++++---- 11 files changed, 278 insertions(+), 99 deletions(-) diff --git a/projects/start-wrt/backend/ctrl/src/port_control.rs b/projects/start-wrt/backend/ctrl/src/port_control.rs index 14eb2b1393..e787febf11 100644 --- a/projects/start-wrt/backend/ctrl/src/port_control.rs +++ b/projects/start-wrt/backend/ctrl/src/port_control.rs @@ -241,6 +241,20 @@ impl PortControl { crate::published_ports::reload_firewall(); } + async fn reload_firewall_wait(&self) -> Result<(), Error> { + #[cfg(test)] + return Ok(()); + #[cfg(not(test))] + crate::published_ports::reload_firewall_wait().await + } + + async fn prepare_sni(&self) -> Result<(), u8> { + #[cfg(test)] + return Ok(()); + #[cfg(not(test))] + self.sni.prepare().await + } + pub(crate) async fn lock_writes(&self) -> tokio::sync::MutexGuard<'_, ()> { self.write_serial.lock().await } @@ -719,6 +733,9 @@ impl PortControl { lifetime: Option, ) -> Result<(), u8> { let _serial = self.write_serial.lock().await; + self.prepare_sni().await?; + let source = + SocketAddrV4::new(self.wan_ipv4().await.unwrap_or(*source.ip()), source.port()); let port = source.port(); let uci_root = self.uci_root.clone(); let conflicts = uci_task(move || async move { @@ -735,30 +752,12 @@ impl PortControl { return Err(RESULT_HOSTNAME_TAKEN); } - let existing: std::collections::BTreeSet<_> = self - .sni - .snapshot() - .into_iter() - .filter(|route| { - route.ext_ip == *source.ip() - && route.ext_port == port - && route.target == target - && hostnames.contains(&route.hostname) - }) - .map(|route| route.hostname) - .collect(); - self.sni - .register(*source.ip(), port, hostnames, target, lifetime)?; + let registration = + self.sni + .register_transaction(*source.ip(), port, hostnames, target, lifetime)?; if let Err(e) = self.sync_sni_rules().await { - let added: Vec<_> = hostnames - .iter() - .filter(|hostname| !existing.contains(*hostname)) - .cloned() - .collect(); - if !added.is_empty() { - self.sni.unregister(*source.ip(), port, &added, target); - } + self.sni.rollback(registration); if let Err(rollback) = self.sync_sni_rules().await { tracing::warn!("port-control: rolling back SNI admission failed: {rollback}"); } @@ -781,8 +780,24 @@ impl PortControl { hostnames: &[String], ) { let _serial = self.write_serial.lock().await; - self.sni - .unregister(*source.ip(), source.port(), hostnames, target); + let routes: Vec<_> = self + .sni + .snapshot() + .into_iter() + .filter(|route| { + route.ext_port == source.port() + && route.target == target + && hostnames.contains(&route.hostname) + }) + .collect(); + for route in routes { + self.sni.unregister( + route.ext_ip, + route.ext_port, + std::slice::from_ref(&route.hostname), + target, + ); + } if let Err(e) = self.sync_sni_rules().await { tracing::warn!("port-control: reconciling SNI admission failed: {e}"); } @@ -902,7 +917,7 @@ impl PortControl { async fn sync_sni_rules_to(&self, want: std::collections::BTreeSet) -> Result<(), Error> { let uci_root = self.uci_root.clone(); if uci_task(move || async move { reconcile_sni_rules_uci(&uci_root, want).await }).await? { - self.reload_firewall(); + self.reload_firewall_wait().await?; } Ok(()) } @@ -911,17 +926,15 @@ impl PortControl { async fn sni_maintain(&self) { self.reap_unauthorized_sni_routes().await; let wan = self.wan_ipv4().await; + let _serial = self.write_serial.lock().await; if let Some(ip) = wan { if self.sni.snapshot().iter().any(|r| r.ext_ip != ip) { tracing::info!("port-control: re-keying SNI routes onto WAN address {ip}"); self.sni.rekey_ipv4(ip); } } - { - let _serial = self.write_serial.lock().await; - if let Err(e) = self.sync_sni_rules().await { - tracing::warn!("port-control: reconciling SNI admission failed: {e}"); - } + if let Err(e) = self.sync_sni_rules().await { + tracing::warn!("port-control: reconciling SNI admission failed: {e}"); } if let Some(ip) = wan { self.sync_sni_fallback(ip).await; diff --git a/projects/start-wrt/backend/ctrl/src/published_ports.rs b/projects/start-wrt/backend/ctrl/src/published_ports.rs index 2ebd4cb338..59ad3728a5 100644 --- a/projects/start-wrt/backend/ctrl/src/published_ports.rs +++ b/projects/start-wrt/backend/ctrl/src/published_ports.rs @@ -1752,32 +1752,33 @@ async fn resolve_device_zones( /// so fall back to it there, and only there. pub(crate) fn reload_firewall() { tokio::spawn(async { - // `run_quiet_async` reports the exit status rather than failing on it, - // so a non-zero reload has to be checked explicitly — otherwise the - // fallback below would never run. - match crate::run_quiet_async( - tokio::process::Command::new("/etc/init.d/firewall").arg("reload"), - ) - .await - { - Ok(status) if status.success() => return, - Ok(status) => tracing::warn!( - "firewall reload exited {status} (not loaded?); falling back to restart" - ), - Err(e) => { - tracing::warn!("could not run firewall reload ({e}); falling back to restart") - } + if let Err(e) = reload_firewall_wait().await { + tracing::error!("failed to activate firewall changes: {e}"); } - match crate::run_quiet_async( - tokio::process::Command::new("/etc/init.d/firewall").arg("restart"), - ) + }); +} + +pub(crate) async fn reload_firewall_wait() -> Result<(), Error> { + match crate::run_quiet_async(tokio::process::Command::new("/etc/init.d/firewall").arg("reload")) .await - { - Ok(status) if status.success() => {} - Ok(status) => tracing::error!("firewall restart exited {status}"), - Err(e) => tracing::error!("failed to restart firewall: {e}"), + { + Ok(status) if status.success() => return Ok(()), + Ok(status) => { + tracing::warn!("firewall reload exited {status}; falling back to restart") } - }); + Err(e) => tracing::warn!("could not reload firewall ({e}); falling back to restart"), + } + let status = + crate::run_quiet_async(tokio::process::Command::new("/etc/init.d/firewall").arg("restart")) + .await?; + if status.success() { + Ok(()) + } else { + Err(Error::new( + eyre!("firewall restart exited {status}"), + ErrorKind::Network, + )) + } } pub(crate) fn reload_dnsmasq() { diff --git a/shared-libs/crates/start-core/src/net/port_map/pcp/hostname.rs b/shared-libs/crates/start-core/src/net/port_map/pcp/hostname.rs index c6d2a1d6b7..ca279511b0 100644 --- a/shared-libs/crates/start-core/src/net/port_map/pcp/hostname.rs +++ b/shared-libs/crates/start-core/src/net/port_map/pcp/hostname.rs @@ -18,7 +18,12 @@ pub const RESULT_UNSUPP_HOSTNAME: u8 = 193; /// Valid as an SNI demux key: 1-255 octets, ASCII labels of `[A-Za-z0-9-]` /// (leading `*` label allowed), no leading/trailing dot, no empty labels. pub fn validate_hostname(name: &str) -> bool { - if name.is_empty() || name.len() > 255 || name.starts_with('.') || name.ends_with('.') { + if name.is_empty() + || name.len() > 255 + || name.starts_with('.') + || name.ends_with('.') + || name.parse::().is_ok() + { return false; } name.split('.').enumerate().all(|(i, label)| { @@ -70,6 +75,8 @@ mod tests { assert!(!validate_hostname("ex ample.com")); assert!(!validate_hostname("ex*ample.com")); assert!(!validate_hostname("foo..bar")); + assert!(!validate_hostname("192.168.1.1")); + assert!(!validate_hostname("2001:db8::1")); assert!(!validate_hostname(&"a".repeat(256))); } diff --git a/shared-libs/crates/start-core/src/net/port_map/server/igd.rs b/shared-libs/crates/start-core/src/net/port_map/server/igd.rs index 643e3cddda..16d070995d 100644 --- a/shared-libs/crates/start-core/src/net/port_map/server/igd.rs +++ b/shared-libs/crates/start-core/src/net/port_map/server/igd.rs @@ -158,7 +158,7 @@ fn soap_u32(body: &str, arg: &str) -> Option { soap_arg(body, arg) } -fn soap_text(body: &str, arg: &str) -> Option { +fn soap_raw_text(body: &str, arg: &str) -> Option { let root = xmltree::Element::parse(body.as_bytes()).ok()?; let action = root .get_child("Body")? @@ -166,14 +166,32 @@ fn soap_text(body: &str, arg: &str) -> Option { .iter() .find_map(|n| n.as_element())?; let element = action.get_child(arg)?; + let open = format!("<{arg}>"); + let close = format!(""); + if let Some(start) = body.find(&open) { + let raw = &body[start + open.len()..]; + let raw = &raw[..raw.find(&close)?]; + if raw.trim() != raw { + return Some(raw.to_string()); + } + } else if ![format!("<{arg}/>"), format!("<{arg} />")] + .iter() + .any(|tag| body.contains(tag)) + { + return None; + } Some( element .get_text() - .map(|text| text.trim().to_string()) + .map(|text| text.to_string()) .unwrap_or_default(), ) } +fn soap_text(body: &str, arg: &str) -> Option { + Some(soap_raw_text(body, arg)?.trim().to_string()) +} + fn soap_arg(body: &str, arg: &str) -> Option { soap_text(body, arg)?.parse().ok() } @@ -541,7 +559,7 @@ async fn add_hostname_mapping( return fault(402, "Invalid Args"); }; let (Some(remote_host), Some(_internal_client), Some(enabled), Some(_description)) = ( - soap_text(body, "NewRemoteHost"), + soap_raw_text(body, "NewRemoteHost"), soap_text(body, "NewInternalClient"), soap_text(body, "NewEnabled"), soap_text(body, "NewPortMappingDescription"), @@ -558,7 +576,7 @@ async fn add_hostname_mapping( { return fault(402, "Invalid Args"); } - let Some(hostname) = soap_arg::(body, "NewHostname") + let Some(hostname) = soap_raw_text(body, "NewHostname") .filter(|h| validate_hostname(h)) .map(|h| h.to_ascii_lowercase()) else { @@ -606,7 +624,7 @@ async fn delete_hostname_mapping( let (Some(external_port), Some(internal_port), Some(remote_host)) = ( soap_u16(body, "NewExternalPort"), soap_u16(body, "NewInternalPort"), - soap_text(body, "NewRemoteHost"), + soap_raw_text(body, "NewRemoteHost"), ) else { return fault(402, "Invalid Args"); }; @@ -616,7 +634,7 @@ async fn delete_hostname_mapping( if external_port == 0 || internal_port == 0 || soap_protocol(body) != Some("TCP") { return fault(402, "Invalid Args"); } - let Some(hostname) = soap_arg::(body, "NewHostname") + let Some(hostname) = soap_raw_text(body, "NewHostname") .filter(|h| validate_hostname(h)) .map(|h| h.to_ascii_lowercase()) else { @@ -1412,6 +1430,13 @@ mod tests { ), 801, ), + ( + valid.replace( + "", + " ", + ), + 801, + ), ( valid.replace("1", "0"), 402, @@ -1459,7 +1484,13 @@ mod tests { #[tokio::test] async fn hostname_mapping_rejects_malformed_hostname() { - for bad in ["ex ample.com", ".example.com", ""] { + for bad in [ + "ex ample.com", + ".example.com", + "192.168.1.1", + " git.example.com ", + "", + ] { let stub = HostnameStub::new(true); let resp = control(&stub, PEER, &add_hostname_body("0", bad)).await; assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR, "{bad:?}"); diff --git a/shared-libs/crates/start-core/src/net/port_map/server/mod.rs b/shared-libs/crates/start-core/src/net/port_map/server/mod.rs index 05827d9ee9..c802cf8242 100644 --- a/shared-libs/crates/start-core/src/net/port_map/server/mod.rs +++ b/shared-libs/crates/start-core/src/net/port_map/server/mod.rs @@ -167,6 +167,7 @@ pub trait GatewayBackend: Send + Sync { let Some(sni) = self.sni() else { return Err(RESULT_UNSUPP_HOSTNAME); }; + sni.prepare().await?; sni.register(*source.ip(), source.port(), hostnames, target, lifetime) } } diff --git a/shared-libs/crates/start-core/src/tunnel/api.rs b/shared-libs/crates/start-core/src/tunnel/api.rs index 38e479bb1f..debfe8d939 100644 --- a/shared-libs/crates/start-core/src/tunnel/api.rs +++ b/shared-libs/crates/start-core/src/tunnel/api.rs @@ -1523,6 +1523,7 @@ pub async fn set_forward_enabled( hostname, }: SetPortForwardEnabledParams, ) -> Result<(), Error> { + let _guard = ctx.forward_write_lock.lock().await; let toggle = ctx .db .mutate(|db| { @@ -1583,6 +1584,12 @@ pub async fn set_forward_enabled( } ForwardToggle::Sni { hostname, target } => { if enabled { + ctx.sni.prepare().await.map_err(|code| { + Error::new( + eyre!("SNI diversion setup failed (code {code})"), + ErrorKind::Network, + ) + })?; ctx.sni .register(*source.ip(), source.port(), &[hostname], target, None) .map_err(|code| { diff --git a/shared-libs/crates/start-core/src/tunnel/context.rs b/shared-libs/crates/start-core/src/tunnel/context.rs index 4d4ee6f5ec..a57b529f88 100644 --- a/shared-libs/crates/start-core/src/tunnel/context.rs +++ b/shared-libs/crates/start-core/src/tunnel/context.rs @@ -189,6 +189,7 @@ pub struct TunnelContextSeed { /// Per-injector TSIG keys, read live so the injector can verify UPDATEs. pub dns_keys: Arc>>, pub active_forwards: SyncMutex>>, + pub forward_write_lock: tokio::sync::Mutex<()>, /// In-memory leases for auto (PCP-created) forwards/pinholes/SNI routes, /// reaped by [`crate::tunnel::forward::lease`] when a client stops renewing. pub leases: SyncMutex, @@ -390,6 +391,7 @@ impl TunnelContext { dns_allowed, dns_keys, active_forwards: SyncMutex::new(active_forwards), + forward_write_lock: tokio::sync::Mutex::new(()), leases: SyncMutex::new(BTreeMap::new()), lease_wake: tokio::sync::Notify::new(), forward_ifindex: tokio::sync::watch::channel(current_ifindex()).0, diff --git a/shared-libs/crates/start-core/src/tunnel/db.rs b/shared-libs/crates/start-core/src/tunnel/db.rs index 917e6a69a5..d33f36a831 100644 --- a/shared-libs/crates/start-core/src/tunnel/db.rs +++ b/shared-libs/crates/start-core/src/tunnel/db.rs @@ -208,7 +208,7 @@ pub struct SniRoute { pub label: Option, #[serde(default = "default_true")] pub enabled: bool, - /// Gateway-created (PCP) vs user-added. Drives the UI Manual/Automatic split. + /// Gateway-created vs user-added. Drives the UI Manual/Automatic split. #[serde(default)] pub auto: bool, } diff --git a/shared-libs/crates/start-core/src/tunnel/forward/igd.rs b/shared-libs/crates/start-core/src/tunnel/forward/igd.rs index aa14c31635..040c77a802 100644 --- a/shared-libs/crates/start-core/src/tunnel/forward/igd.rs +++ b/shared-libs/crates/start-core/src/tunnel/forward/igd.rs @@ -262,6 +262,7 @@ pub(super) async fn apply_peer_forward_range( protocol_label: &str, lifetime: Option, ) -> Result<(), u16> { + let _guard = ctx.forward_write_lock.lock().await; // Port 80 is reserved for the tunnel's HTTP→HTTPS redirect; never // automatically create a forward that would take it (PCP/UPnP alike). let lo = source.port(); @@ -285,7 +286,7 @@ pub(super) async fn apply_peer_forward_range( return Err(718); // ConflictInMappingEntry } return ctx - .persist_fallback_forward(source, target, lifetime, true, None) + .persist_fallback_forward_locked(source, target, lifetime, true, None) .await .map_err(|_| 718u16); } diff --git a/shared-libs/crates/start-core/src/tunnel/forward/pcp.rs b/shared-libs/crates/start-core/src/tunnel/forward/pcp.rs index 8b69d2241a..b66e8b7ade 100644 --- a/shared-libs/crates/start-core/src/tunnel/forward/pcp.rs +++ b/shared-libs/crates/start-core/src/tunnel/forward/pcp.rs @@ -153,6 +153,7 @@ impl GatewayBackend for TunnelContext { } async fn remove_forward_by_source(&self, source: SocketAddrV4, peer: Ipv4Addr) -> bool { + let _guard = self.forward_write_lock.lock().await; match crate::tunnel::forward::igd::current_forward(self, source).await { Some(PortForward::Dnat { target, .. }) if *target.ip() == peer => { if self @@ -176,7 +177,8 @@ impl GatewayBackend for TunnelContext { fallback: Some(fallback), .. }) if *fallback.target.ip() == peer => { - self.remove_sni_fallback(source, fallback.target).await; + self.remove_sni_fallback_locked(source, fallback.target) + .await; true } _ => false, @@ -266,6 +268,7 @@ impl GatewayBackend for TunnelContext { target: SocketAddrV4, hostnames: &[String], ) { + let _guard = self.forward_write_lock.lock().await; self.sni .unregister(*source.ip(), source.port(), hostnames, target); for h in hostnames { @@ -310,7 +313,13 @@ impl TunnelContext { auto: bool, label: Option, ) -> Result<(), u8> { - let default_label = if auto { Some("PCP".to_string()) } else { label }; + let _guard = self.forward_write_lock.lock().await; + self.sni.prepare().await?; + let default_label = if auto { + Some("Automatic".to_string()) + } else { + label + }; // Reject conflicts before displacing a working DNAT. let hostnames_owned = hostnames.to_vec(); let persisted = self @@ -469,7 +478,25 @@ impl TunnelContext { auto: bool, label: Option, ) -> Result<(), u8> { - let default_label = if auto { Some("PCP".to_string()) } else { label }; + let _guard = self.forward_write_lock.lock().await; + self.persist_fallback_forward_locked(source, target, lifetime, auto, label) + .await + } + + pub(super) async fn persist_fallback_forward_locked( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + lifetime: Option, + auto: bool, + label: Option, + ) -> Result<(), u8> { + self.sni.prepare().await?; + let default_label = if auto { + Some("Automatic".to_string()) + } else { + label + }; let persisted = self .db .mutate(|db| { @@ -510,7 +537,7 @@ impl TunnelContext { .register_fallback(*source.ip(), source.port(), target) .is_err() { - self.remove_sni_fallback(source, target).await; + self.remove_sni_fallback_locked(source, target).await; return Err(crate::net::port_map::pcp::hostname::RESULT_HOSTNAME_TAKEN); } if let Some(lt) = lifetime { @@ -522,6 +549,15 @@ impl TunnelContext { /// Remove the hostname-less fallback on `source`, only if held by `target`. /// Drops the shared port entirely if no SNI routes remain either. pub async fn remove_sni_fallback(&self, source: SocketAddrV4, target: SocketAddrV4) { + let _guard = self.forward_write_lock.lock().await; + self.remove_sni_fallback_locked(source, target).await; + } + + pub(super) async fn remove_sni_fallback_locked( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + ) { self.sni .unregister_fallback(*source.ip(), source.port(), target); lease::forget(self, &LeaseKey::SniFallback(source)); @@ -634,6 +670,7 @@ fn peer_forward_matches(entry: &PortForward, target: &SocketAddrV4) -> bool { /// Remove the peer's forward to `(peer, internal_port)`, if any. We forward both /// protocols on one entry, so match by target rather than PCP's (proto, port, client). async fn remove_peer_forward(ctx: &TunnelContext, peer: Ipv4Addr, internal_port: u16) { + let _guard = ctx.forward_write_lock.lock().await; let target = SocketAddrV4::new(peer, internal_port); let source = ctx .db @@ -651,7 +688,7 @@ async fn remove_peer_forward(ctx: &TunnelContext, peer: Ipv4Addr, internal_port: return; }; if is_sni { - ctx.remove_sni_fallback(source, target).await; + ctx.remove_sni_fallback_locked(source, target).await; return; } ctx.db diff --git a/shared-libs/crates/start-core/src/tunnel/forward/sni.rs b/shared-libs/crates/start-core/src/tunnel/forward/sni.rs index fe5ba379e6..b778cdf77f 100644 --- a/shared-libs/crates/start-core/src/tunnel/forward/sni.rs +++ b/shared-libs/crates/start-core/src/tunnel/forward/sni.rs @@ -1,12 +1,7 @@ -//! SNI demultiplexer for the PCP HOSTNAME extension: a per-port TCP listener -//! reads the TLS ClientHello, selects a binding (exact → wildcard → fallback), -//! and splices to the internal host. TLS is never terminated; the ClientHello -//! bytes are forwarded verbatim. The internal leg is opened from the client's -//! own source address (source-address preservation, RFC §4.6) via -//! [`crate::net::transparent`], except where the reply would never come back -//! through this host (see [`LocalPrefix`]). +//! SNI demultiplexing for hostname-based shared-port mappings. //! -//! QUIC (§4.5) and wildcards beyond a single leading `*` label are out of scope. +//! Exact names take precedence over leading-label wildcards. An optional +//! fallback receives unmatched SNI, no-SNI TLS, and non-TLS traffic. use std::collections::{BTreeMap, BTreeSet}; use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; @@ -31,7 +26,7 @@ const CLIENTHELLO_CAP: usize = 16384; const CLIENTHELLO_TIMEOUT: Duration = Duration::from_secs(5); const ACCEPT_RETRY_DELAY: Duration = Duration::from_millis(100); -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] struct Binding { target: SocketAddrV4, /// `None` for a permanent (DB-backed/manual) binding that never expires. @@ -110,6 +105,13 @@ type OnChange = Box; /// `None` preserves the client source address. pub type LocalPrefix = Arc BoxFuture<'static, Option> + Send + Sync>; +/// A registration that can restore the exact prior bindings. +pub struct SniRegistration { + key: PortKey, + previous: Vec<(String, Option)>, + applied: Binding, +} + pub struct SniDemux { ports: Arc>>, listeners: SyncMutex>>, @@ -170,6 +172,19 @@ impl SniDemux { this } + /// Installs reply-path diversion before a route is granted. + pub async fn prepare(&self) -> Result<(), u8> { + #[cfg(test)] + return Ok(()); + #[cfg(not(test))] + crate::net::transparent::ensure_divert_infra_once() + .await + .map_err(|e| { + tracing::warn!("SNI demux reply-path diversion failed: {e}"); + RESULT_NO_RESOURCES + }) + } + /// Registers all hostnames atomically and starts their shared listener. /// Returns `RESULT_HOSTNAME_TAKEN` for an occupied name or /// `RESULT_NO_RESOURCES` when the listener cannot bind. @@ -181,8 +196,24 @@ impl SniDemux { target: SocketAddrV4, lifetime_secs: Option, ) -> Result<(), u8> { + self.register_transaction(ext_ip, ext_port, hostnames, target, lifetime_secs) + .map(|_| ()) + } + + /// Registers hostnames and returns their prior state. + pub fn register_transaction( + self: &Arc, + ext_ip: Ipv4Addr, + ext_port: u16, + hostnames: &[String], + target: SocketAddrV4, + lifetime_secs: Option, + ) -> Result { let now = Instant::now(); - let expiry = lifetime_secs.map(|s| now + Duration::from_secs(s as u64)); + let applied = Binding { + target, + expiry: lifetime_secs.map(|s| now + Duration::from_secs(s as u64)), + }; let key = (ext_ip, ext_port); let previous = self.ports.mutate(|ports| { let entry = ports.entry(key).or_default(); @@ -196,42 +227,54 @@ impl SniDemux { } let previous = hostnames .iter() - .map(|name| entry.hostnames.get(name).cloned()) + .map(|name| (name.clone(), entry.hostnames.get(name).cloned())) .collect::>(); for name in hostnames { - entry - .hostnames - .insert(name.clone(), Binding { target, expiry }); + entry.hostnames.insert(name.clone(), applied.clone()); } Ok(previous) })?; + let registration = SniRegistration { + key, + previous, + applied, + }; if let Err(e) = self.ensure_listener(key) { tracing::warn!( "SNI demux bind on {}:{} failed; refusing the grant: {e}", key.0, key.1 ); - self.ports.mutate(|ports| { - if let Some(entry) = ports.get_mut(&key) { - for (name, previous) in hostnames.iter().zip(previous) { - match previous { - Some(binding) => { - entry.hostnames.insert(name.clone(), binding); - } - None => { - entry.hostnames.remove(name); - } - } - } - } - }); - self.reap_if_empty(key); + self.rollback(registration); return Err(RESULT_NO_RESOURCES); } - Ok(()) + Ok(registration) + } + + /// Restores bindings unchanged since the registration. + pub fn rollback(&self, registration: SniRegistration) { + self.ports.mutate(|ports| { + let Some(entry) = ports.get_mut(®istration.key) else { + return; + }; + for (name, previous) in registration.previous { + if entry.hostnames.get(&name) != Some(®istration.applied) { + continue; + } + match previous { + Some(binding) => { + entry.hostnames.insert(name, binding); + } + None => { + entry.hostnames.remove(&name); + } + } + } + }); + self.reap_if_empty(registration.key); } - /// Delete the named bindings (lifetime-0 MAP), only those held by `target`. + /// Removes bindings held by the target. pub fn unregister( &self, ext_ip: Ipv4Addr, @@ -823,6 +866,42 @@ mod tests { .peek(|l| assert!(l.contains_key(&(Ipv4Addr::LOCALHOST, port)))); } + #[tokio::test] + async fn rollback_restores_a_renewed_binding() { + let probe = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = probe.local_addr().unwrap().port(); + drop(probe); + let demux = SniDemux::new(); + let hostname = "a.example.com".to_string(); + let target = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443); + demux + .register( + Ipv4Addr::LOCALHOST, + port, + std::slice::from_ref(&hostname), + target, + Some(30), + ) + .unwrap(); + let before = demux + .ports + .peek(|ports| ports[&(Ipv4Addr::LOCALHOST, port)].hostnames[&hostname].clone()); + let registration = demux + .register_transaction( + Ipv4Addr::LOCALHOST, + port, + std::slice::from_ref(&hostname), + target, + Some(3600), + ) + .unwrap(); + demux.rollback(registration); + let after = demux + .ports + .peek(|ports| ports[&(Ipv4Addr::LOCALHOST, port)].hostnames[&hostname].clone()); + assert_eq!(after, before); + } + #[test] fn select_exact_wildcard_fallback() { let mut pb = PortBindings::default(); From 4d38074e3e16f0e198b7b276af76b28e9058a6c9 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:51:51 +0000 Subject: [PATCH 11/14] fix(start-wrt): inline divert setup error mapping --- .../start-wrt/backend/ctrl/src/bins/daemon.rs | 47 +++++-------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/projects/start-wrt/backend/ctrl/src/bins/daemon.rs b/projects/start-wrt/backend/ctrl/src/bins/daemon.rs index a831259a3d..c99172ba89 100644 --- a/projects/start-wrt/backend/ctrl/src/bins/daemon.rs +++ b/projects/start-wrt/backend/ctrl/src/bins/daemon.rs @@ -197,17 +197,6 @@ impl Visit for WebserverListener { } } -fn require_divert_config( - result: Result<(), startos::net::transparent::DivertConfig>, -) -> Result<(), Error> { - result.map_err(|config| { - Error::new( - eyre!("SNI divert config rejected: {config:?}"), - ErrorKind::Network, - ) - }) -} - #[instrument(skip_all)] async fn inner_main() -> Result<(), Error> { // Generate local auth cookie so CLI commands over SSH bypass session auth @@ -359,14 +348,18 @@ async fn inner_main() -> Result<(), Error> { if !setup_mode { // The IGD UUID derives from the initialized root CA. // Configure reply diversion before constructing the SNI demux. - require_divert_config(startos::net::transparent::set_divert_config( - startos::net::transparent::DivertConfig { - route_table: 5344, - rule_priority: 49, - masked_fwmark: true, - manage_nft: false, - }, - ))?; + startos::net::transparent::set_divert_config(startos::net::transparent::DivertConfig { + route_table: 5344, + rule_priority: 49, + masked_fwmark: true, + manage_nft: false, + }) + .map_err(|config| { + Error::new( + eyre!("SNI divert config rejected: {config:?}"), + ErrorKind::Network, + ) + })?; let pc = crate::port_control::PortControl::new("/etc/config".into()); if crate::port_control::PORT_CONTROL.set(pc.clone()).is_ok() { tokio::spawn(crate::port_control::run(pc)); @@ -488,22 +481,6 @@ async fn inner_main() -> Result<(), Error> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejected_divert_config_fails_startup() { - let rejected = startos::net::transparent::DivertConfig { - route_table: 5344, - rule_priority: 49, - masked_fwmark: true, - manage_nft: false, - }; - assert!(require_divert_config(Err(rejected)).is_err()); - } -} - pub fn main(_args: VecDeque) { init_logging("startwrt-ctrld"); tracing::info!("startwrt-ctrld starting (luci proxy v10)"); From 3c7e647ee92aeb7c2b201773c2cf1489d60e9720 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:27:28 +0000 Subject: [PATCH 12/14] fix(start-os): restore mapped external IP handling --- .../crates/start-core/src/net/port_map/client.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/shared-libs/crates/start-core/src/net/port_map/client.rs b/shared-libs/crates/start-core/src/net/port_map/client.rs index abff40ae04..2da727fb3d 100644 --- a/shared-libs/crates/start-core/src/net/port_map/client.rs +++ b/shared-libs/crates/start-core/src/net/port_map/client.rs @@ -547,18 +547,6 @@ struct State { } impl State { - fn external_ip(&self, external_port: u16) -> Option { - self.active - .iter() - .find(|(key, _)| { - key.1 == external_port && key.2.is_none() && key.3 == TransportProtocol::Tcp - }) - .and_then(|(_, active)| match active { - Active::Pcp(mapping) => mapping.external_ip(), - Active::Upnp { external_ip, .. } => external_ip.map(IpAddr::V4), - }) - } - async fn ensure( &mut self, interfaces: &Watch>, From c442bc2d3900ec360547e99d0df4cf8be86f171d Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:27:28 +0000 Subject: [PATCH 13/14] chore: regenerate start-core TypeScript bindings --- .../ts-modules/start-core/lib/osBindings/tunnel/SniRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/tunnel/SniRoute.ts b/shared-libs/ts-modules/start-core/lib/osBindings/tunnel/SniRoute.ts index 6ca91aa2fc..c5d0fd45d3 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/tunnel/SniRoute.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/tunnel/SniRoute.ts @@ -8,7 +8,7 @@ export type SniRoute = { label: string | null enabled: boolean /** - * Gateway-created (PCP) vs user-added. Drives the UI Manual/Automatic split. + * Gateway-created vs user-added. Drives the UI Manual/Automatic split. */ auto: boolean } From 8a534ea67fc042f809ca5655eb20d95e79393a00 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:22:14 +0000 Subject: [PATCH 14/14] style(start-os): format port-map tests --- shared-libs/crates/start-core/src/net/port_map/client.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/shared-libs/crates/start-core/src/net/port_map/client.rs b/shared-libs/crates/start-core/src/net/port_map/client.rs index 2da727fb3d..f50f885352 100644 --- a/shared-libs/crates/start-core/src/net/port_map/client.rs +++ b/shared-libs/crates/start-core/src/net/port_map/client.rs @@ -1147,12 +1147,7 @@ mod tests { let public = Ipv4Addr::new(1, 2, 3, 4); let mut active = BTreeMap::new(); active.insert( - ( - ip, - 443, - Some("example.com".into()), - TransportProtocol::Tcp, - ), + (ip, 443, Some("example.com".into()), TransportProtocol::Tcp), Active::Upnp { external_ip: Some(public), internal_port: 443,