Skip to content

feat(start-os): add net gateway set-secure/unset-secure - #3658

Open
helix-nine wants to merge 3 commits into
masterfrom
feat/gateway-set-secure
Open

feat(start-os): add net gateway set-secure/unset-secure#3658
helix-nine wants to merge 3 commits into
masterfrom
feat/gateway-set-secure

Conversation

@helix-nine

@helix-nine helix-nine commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Closes #3649 by taking option 1 — give the field a writer.

start-cli net gateway set-secure <GATEWAY> [SECURE]
start-cli net gateway unset-secure <GATEWAY>

Modelled on set-public/unset-public (removed in #3117), which is the command the field's Option<bool> + unwrap_or_else shape was clearly cut from. net gateway list gains a SECURE column that distinguishes an operator override from an inferred value (YES vs YES (auto)), since otherwise the writer has no reader.

Three commits: the i18n orphans #3117 left behind, the feature, and a fix for a hole I found reviewing my own writer before opening this.

The WAN invariant is untouched

Marking a gateway secure buys LAN reachability and nothing else. Both forward paths gate public exposure on the binding's security option, never the gateway's:

  • forward.rs:704-713public = reqs.public_gateways.contains(gw_id) && reqs.secure, where reqs.secure is bind.options.secure.is_some() (net_controller.rs:585). The gateway's secure() appears only on the next line, and only to admit a forward. A plaintext binding can never be public, so src_filter is Some(subnet) or the iteration continues, and the upstream port-map request lives entirely inside if public.
  • net_controller.rs:604-625 (IPv6 GUA) — an unrestricted src_filter needs a.public && secure_exposure; a plaintext binding falls to the else, which ends in Some(subnet) or continue. The v6 pinhole is opened only if src_filter.is_none().

So I added no guard rail there — the invariant is enforced a level up and this change can't reach it.

What the review caught

The guard failed open. I'd written it as secure == Some(false) && info.is_intrinsically_secure(), and is_intrinsically_secure() reads ip_info.device_type — which the controller itself nulls on every boot seed (gateway.rs:2962-2966) and on any NM rescan that drops the device (:1411-1416). So set-secure lxcbr0 false inside a lxc-net restart window was accepted and persisted, and would then suppress the plaintext bridge address (host/mod.rs:162) and its DNATs — which is what every secure: null dependency binding resolves through. An unknown device type now fails closed with its own message.

What an operator is actually turning on

The first draft of the docs undersold this and I've rewritten them. It's one switch for the whole server: every installed service's non-SSL addresses appear on that network at once, enabled immediately (private addresses are enabled-by-default — binding.rs:74-93), with no per-service confirmation. The docs now say that, and replace "the public internet is never secure" with the mechanism that actually holds — traffic is restricted to the gateway's own network segment, which on a VPS or a bridged modem is the provider's network.

It does not expose the StartOS UI. An earlier draft of these docs claimed it did; that was wrong, and @dr-bonez caught it. WildcardListener::new(80) (startd.rs:162) is unconditional and independent of any gateway setting, so the server UI has always answered plain HTTP on every interface — and in an insecure context the SPA serves the certificate wizard rather than a login form (login.page.ts:23 branches on isSecureContext()). What this setting changes for the OS host is only whether its plaintext address is listed among the server's addresses.

Verification

cargo check -p start-core clean (no new warnings) · make start-core-test 294 passed · make manpages-check and make start-core-ts-bindings-check both exit 0 · make start-core-format-check clean · prettier clean on the markdown · mdbook builds and the #secure-gateways anchor resolves · no raw i18n keys in the regenerated man pages.

Tests: secure()'s Some(_) branches (never reachable before this PR), the disconnected-gateway precondition the guard turns on, and secure_gateways. I mutation-checked the last one — an implementation ignoring its candidates argument passed the first version, so it now includes a secure gateway absent from the candidate list.

Not run: anything on a box. The propagation argument is source-level — writes go through the ip_info watch rather than the db, because NetworkInterfaceController::sync re-serializes the whole map on every gateway event, and the same db.mutate sweeps update_addresses over all hosts (gateway.rs:2725-2726), which is what carries the change to the address readers. Worth confirming on a VM before merge.

Open questions

  1. Is SECURE worth keeping as an argument? Some(false) is a no-op for every gateway that isn't refused — it equals the inferred default everywhere except lo/lxcbr0, where it's now rejected. Its only real use is pinning against a future change in the inference. Happy to drop it and make set-secure a bare flag, which would also delete both guards.
  2. No UI. You asked for the RPC, so System > Gateways doesn't show or set this. The field is already in GatewayInfo and the TS bindings if you want a toggle.

Rebased onto current master. a473c02c9 landed the same loopback-DNAT guard I had written for the OS host, so that half of my fix is gone — master's version is narrower (it guards only the forwards.insert, where mine also skipped the IPv6 GUA block) and I dropped mine in favour of it.

Adjacent, deliberately not fixed here

  • host/mod.rs:322 inserts a private-domain address even when its gateway set is empty, unlike its mDNS sibling at :243 — a pre-existing dead UI row, in the block this PR revives.
  • start-core/AGENTS.md:39 ends with "Compile-time validation catches missing keys." It doesn't — bins/mod.rs:68 resolves them at runtime through t!(s.to_string()) with a silent {locale}.{key} fallback, and nothing in CI checks coverage. The only detector is a raw key surfacing in a regenerated man page, which is why I grepped for it above.
  • check-port-v6 has a committed man page but no cli-reference.md entry.

help.arg.is-public, about.allow-gateway-infer-inbound-access-from-wan and
about.indicate-gateway-inbound-access-from-wan lost their last references when
3320391 removed the set-public/unset-public subcommands.
`NetworkInterfaceInfo::secure` has been persisted and TS-exported since #3001,
and read at five sites, but nothing could ever write it — so `secure()` was in
practice the constant `is_intrinsically_secure()` and only `lo` and `lxcbr0` were
ever trusted with plaintext. Give the field the writer its `Option<bool>` shape
implies:

    start-cli net gateway set-secure <GATEWAY> [SECURE]
    start-cli net gateway unset-secure <GATEWAY>

Marking a gateway secure offers a service's non-SSL addresses on that network —
LAN IPs, `<hostname>.local`, private domains — and installs the matching
plaintext forwards. `net gateway list` gains a SECURE column that distinguishes
an operator override from an inferred value.

The WAN stays out of reach: both forward paths gate public exposure on the
*binding's* security option, never the gateway's (forward.rs:704-713,
net_controller.rs:604-625), so an insecure exposure is only ever source-filtered
to the on-link subnet.

Refuses `secure false` on a loopback or bridge gateway. An explicit `false`
overrides the intrinsic default, which would stop `10.0.3.1:<port>` being
published for every plaintext binding on the box and tear down the bridge DNATs
that every dependency address resolves through.

Writes go through the ip_info watch rather than the db directly:
NetworkInterfaceController::sync re-serializes the whole map on every gateway
event, and the same mutation sweeps update_addresses over all hosts, which is
what carries the change to the address readers.

Closes #3649.
…nknown

The `secure false` guard read `ip_info.device_type`, which the controller nulls
on every boot seed (gateway.rs) and whenever NetworkManager drops a device, so
it failed open exactly when it mattered: `set-secure lxcbr0 false` inside an
`lxc-net` restart window was accepted, persisted, and would then suppress the
plaintext bridge address and DNATs that every `secure: null` dependency binding
resolves through. An unknown device type is now refused with its own message.

Also corrects the docs, which overstated in one direction and understated in the
other. Marking a gateway secure is one switch for the whole server: every
installed service's non-SSL addresses appear at once, enabled by default. It
does not expose the StartOS UI, which has always answered plain HTTP on every
interface — `WildcardListener::new(80)` in startd.rs is unconditional — and
serves the certificate wizard rather than a login form in an insecure context
(login.page.ts branches on `isSecureContext()`). And "the public internet is
never secure" is replaced by the mechanism that actually holds: the traffic is
restricted to the gateway's own network segment, which on a VPS is the
provider's network.

`secure_gateways`'s test now includes a secure gateway absent from the candidate
list; without it an implementation ignoring `candidates` passed.
@helix-nine
helix-nine force-pushed the feat/gateway-set-secure branch from 18d9aa4 to aaeaf84 Compare August 12, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant