feat(start-os): add net gateway set-secure/unset-secure - #3658
Open
helix-nine wants to merge 3 commits into
Open
feat(start-os): add net gateway set-secure/unset-secure#3658helix-nine wants to merge 3 commits into
helix-nine wants to merge 3 commits into
Conversation
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
force-pushed
the
feat/gateway-set-secure
branch
from
August 12, 2026 18:29
18d9aa4 to
aaeaf84
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3649 by taking option 1 — give the field a writer.
Modelled on
set-public/unset-public(removed in #3117), which is the command the field'sOption<bool>+unwrap_or_elseshape was clearly cut from.net gateway listgains aSECUREcolumn that distinguishes an operator override from an inferred value (YESvsYES (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-713—public = reqs.public_gateways.contains(gw_id) && reqs.secure, wherereqs.secureisbind.options.secure.is_some()(net_controller.rs:585). The gateway'ssecure()appears only on the next line, and only to admit a forward. A plaintext binding can never bepublic, sosrc_filterisSome(subnet)or the iterationcontinues, and the upstream port-map request lives entirely insideif public.net_controller.rs:604-625(IPv6 GUA) — an unrestrictedsrc_filterneedsa.public && secure_exposure; a plaintext binding falls to theelse, which ends inSome(subnet)orcontinue. The v6 pinhole is opened onlyif 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(), andis_intrinsically_secure()readsip_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). Soset-secure lxcbr0 falseinside alxc-netrestart window was accepted and persisted, and would then suppress the plaintext bridge address (host/mod.rs:162) and its DNATs — which is what everysecure: nulldependency 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:23branches onisSecureContext()). 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-coreclean (no new warnings) ·make start-core-test294 passed ·make manpages-checkandmake start-core-ts-bindings-checkboth exit 0 ·make start-core-format-checkclean · prettier clean on the markdown · mdbook builds and the#secure-gatewaysanchor resolves · no raw i18n keys in the regenerated man pages.Tests:
secure()'sSome(_)branches (never reachable before this PR), the disconnected-gateway precondition the guard turns on, andsecure_gateways. I mutation-checked the last one — an implementation ignoring itscandidatesargument 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_infowatch rather than the db, becauseNetworkInterfaceController::syncre-serializes the whole map on every gateway event, and the samedb.mutatesweepsupdate_addressesover 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
SECUREworth keeping as an argument?Some(false)is a no-op for every gateway that isn't refused — it equals the inferred default everywhere exceptlo/lxcbr0, where it's now rejected. Its only real use is pinning against a future change in the inference. Happy to drop it and makeset-securea bare flag, which would also delete both guards.System > Gatewaysdoesn't show or set this. The field is already inGatewayInfoand the TS bindings if you want a toggle.Rebased onto current master.
a473c02c9landed 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 theforwards.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:322inserts 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:39ends with "Compile-time validation catches missing keys." It doesn't —bins/mod.rs:68resolves them at runtime throught!(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-v6has a committed man page but nocli-reference.mdentry.