fix(start-os): give the StartOS UI back port 80 - #3638
Open
helix-nine wants to merge 5 commits into
Open
Conversation
The direct forward for a binding's plaintext port is unconditional, but `os_bindings` runs the OS's own bindings at 127.0.0.1 — so it emits `<gateway>:<external> -> 127.0.0.1:80`, and the kernel drops a DNAT to loopback that arrives on the bridge unless route_localnet is set, which StartOS never sets. Measured on 0.4.0: adding that rule for 10.0.3.1:80 takes a container's request from 200 to no response, and removing it restores 200. There was nothing for it to do either way. StartOS binds a wildcard [::]:80 and one listener per address it answers on, so the forward could only shadow a port already served.
`Public::init` plants the admin binding already holding `assignedSslPort` but not `assignedPort`, so `os_bindings` only ever reaches it through `BindInfo::update` — and until ports below 1024 became claimable (#3558) that could only fall through to a random port at or above 49152. `update` prefers the port it already holds, so the drift then survived every reboot and upgrade: measured on a released 0.4.0 server, the UI's plaintext leg sits on 55543 with an unheld 80 still in `availablePorts`. Fresh installs have been correct since 0.4.0.1, so this is a migration. It writes 80 back, returns the drifted port to the pool, and claims 80 — which also clears the unheld entry that installs before #3558 were seeded with. Nothing else can hold 80: it was unclaimable for everyone until #3558 and privileged-only after it. `down` is a no-op because every earlier version wants 80 here and keeps the port it finds. `the_os_ui_keeps_its_well_known_ports_across_rebinds` started from `BindInfo::new` on an empty map, a state production never reaches, which is how this shipped green; the new tests start from the seeded binding and from the drifted one.
VERSION_BUMP.md listed the docs' GitHub release link among the files a version bump updates. The docs site deploys from master, so moving it with the bump publishes a link to a tag that does not exist yet — 0.4.0.1 bumped it on the 25th and released on the 27th, so that link 404'd for two days. It belongs to the cut, which is where pre-check already gates it. Noted there too that pre-check's docs check is expected to report the previous version until then, so the next bump doesn't 'fix' it back.
helix-nine
force-pushed
the
fix/admin-ui-plaintext-port
branch
from
August 7, 2026 16:24
c71573b to
0ccbd7d
Compare
`update` frees both ports and then reclaims them, carrying the number to
the other field when the binding holds just one — so an address the user
has saved survives a change in how its port is served. But `carried` was
consulted even when the binding ends up serving both legs, where nothing
is moving and each leg should take its own preferred port.
So the second leg took the first leg's number. A plaintext binding on
8080 that gains addSsl(8443) came out {assigned_port: 52981,
assigned_ssl_port: 8080}: the ssl leg claimed 8080 because it was freed a
statement earlier, the plaintext leg found its own number gone, and 8443
was never used at all.
The OS admin binding is the same shape from the other side. It holds 443
from the seed and no plaintext port, so its plaintext leg wanted 443 — it
landed on 80 only via the fallback, because the ssl leg had re-taken 443
microseconds earlier in the same closure. It now wants 80 outright.
`update` carried a binding's number to the other field when it held just one, so a saved address survived a change in how the port is served. That only arises when a binding flips between plaintext-only and TLS-only, which no service does — while in the shape that does occur, a binding gaining a second leg, the carry handed the first leg's number to the new one. Guarding it there left a branch with nothing behind it. Each leg now reclaims the port it already holds, else takes its preferred one, else a fresh one.
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.
Fixes the StartOS UI's plaintext port sitting in the ephemeral range on every server installed before 0.4.0.1, and stops StartOS DNAT-ing its own plaintext port.
Why the port drifts
Public::initplants the admin binding already holdingassignedSslPortbut notassignedPort(db/model/public.rs:86-89), andclear_bindingsonly disables entries rather than removing them — soos_bindingsonly ever reaches this binding throughBindInfo::update, neverBindInfo::new.updateshort-circuits on a held port, so the seeded 443 never reaches the allocator; that is why only the plaintext leg moved.assignedPortwasNone, so it asked for 80, andis_restricted(port) = port <= 1024 || …refused it outright, leavingalloc()— a random port at or above 49152.updatethen prefers the port it already holds, so the drift survived every reboot and upgrade.Measured on a released-0.4.0 VM (
514af0c):The unheld
80there is the seed the pre-#3558Database::initwrote. It was never the cause —try_alloc's||short-circuits onis_restrictedbeforecontains_key— but it does block a latertry_alloc(80), so the migration has to clear it too.git tag --contains 1e8dbeeedis onlystart-os/v0.4.0.1, so shipped 0.4.0 is affected. Fresh installs have been correct since 0.4.0.1 (may_claim(port, privileged)+privileged = pkg_id.is_start_os()), which is why this is a migration rather than a change to the allocator.Why it doesn't self-heal
BindInfo::updatefrees both held ports and then doeslet want = held.or(carried).unwrap_or(preferred).try_alloc(55543)succeeds — it was freed one statement earlier — sopreferredis never reached. That is deliberate and test-locked bya_rebind_does_not_migrate_onto_a_freed_preferred_port;a_drifted_os_ui_port_does_not_heal_on_rebindnow pins it for the privileged case too, so the reason this needs a migration is visible in the tests.The plaintext leg now wants 80
updatefrees both ports and reclaims them, carrying the number to the other field when the binding holds just one — that is what keeps a saved address working when a binding changes how its port is served. Butcarriedwas consulted even when the binding ends up serving both legs, where nothing is moving and each leg should take its own preferred port.So the second leg took the first leg's number. Measured:
The ssl leg claimed 8080 because it had been freed a statement earlier, the plaintext leg found its own number gone and fell through to
alloc(), and 8443 was never used.The admin binding is that same shape from the other side: it holds 443 from the seed and no plaintext port, so
want = held.or(carried).unwrap_or(preferred)evaluated to 443, not 80. It reached 80 only through the.or_else(try_alloc(preferred))fallback, and only because the ssl leg had re-taken 443 a few lines earlier in the same closure — reverse that order and the plaintext leg would have taken 443. It now wants 80 outright, whichthe_os_ui_plaintext_leg_wants_80_even_when_443_is_freepins by running the same rebind with 443 already held.This does not replace the migration:
wantstill prefersheld, so a server already holding an ephemeral port keeps it (a_drifted_os_ui_port_does_not_heal_on_rebind).Why the DNAT goes away
assignedPortnever becomes a socket for the OS: plain HTTP is a hardcodedWildcardListener::new(80)(bins/startd.rs:162). It drives advertised URLs and one nftables DNAT.os_bindingsruns atip: [127,0,0,1], sonet_controller.rs:577emits<gateway>:<external> -> 127.0.0.1:80, and the kernel drops a DNAT to loopback arriving on the bridge unlessroute_localnetis set — which appears nowhere in the tree. From a netns onlxcbr0on that VM:Setting
net.ipv4.conf.lxcbr0.route_localnet=1made 55543 answer 200 and setting it back to 0 broke it again, confirming the drop.So re-homing to 80 on its own would not have been enough — it would have moved the dead address rather than removed it, and by the same code path a fresh 0.4.0.1/master install is already installing a
10.0.3.1:80 -> 127.0.0.1:80rule today. Hand-adding exactly that rule on the 0.4.0 box took a container's request to10.0.3.1:80from 200 to no response; deleting it restored 200. The forward had nothing to do in the first place — StartOS binds[::]:80plus one listener per address it answers on, so it could only shadow a port already served.route_localnet=1would be the wrong fix; it exposes every loopback-only service on the box.The version node
A migration needs a version node, and 0.4.0.1 is a cut release, so this adds
v0_4_0_2and the manifest bump thatversion::tests::current_matches_manifestrequires:package.json, the0.4.0-rev.2label,Cargo.lock, and the regenerated man page.projects/start-os/CHANGELOG.mdalready carried a prospective## [0.4.0.2]heading with two entries, so this lands under it. If you would rather the cut be its own chore commit, say so and I will split it out.The docs' GitHub release link is deliberately not bumped: the docs site deploys from
master, so moving it here would publish a link to a tag that does not exist yet.VERSION_BUMP.mdhad it listed among the files a version bump updates, which is how 0.4.0.1's link came to 404 for the two days between its bump and its release; the last commit moves it to the release cut, wherepre-checkalready gates it. Somanage-release.sh pre-check start-osnow reports the docs link as still on 0.4.0.1 — expected until the cut — and passes its other source-of-truth checks (changelog heading, crate label, tag free), failing only on release machinery unavailable here (registry promotion, GPG key, s3cmd).downis a no-op: every earlier version wants 80 for this binding and keeps whatever port it finds, so a rollback needs nothing undone.Verification
cargo test -p start-core --features test --lib— 559 passed, including 5 new migration tests and 2 new binding testscargo test -p start-core --features test version::— incl.current_matches_manifestmake manpages-check,make start-core-ts-bindings-check— cleannpm ci— lockfile in sync;cargo fmt --checkand prettier clean./scripts/manage-release.sh pre-check start-os— as aboveNot verified on hardware: a fresh master install was not measured, because the local master VM template predates #3558. The fresh-install path is covered by
the_os_ui_claims_80_from_the_seeded_binding, which starts from the exact shapePublic::initproduces.