Skip to content

fix(start-os): give the StartOS UI back port 80 - #3638

Open
helix-nine wants to merge 5 commits into
masterfrom
fix/admin-ui-plaintext-port
Open

fix(start-os): give the StartOS UI back port 80#3638
helix-nine wants to merge 5 commits into
masterfrom
fix/admin-ui-plaintext-port

Conversation

@helix-nine

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

Copy link
Copy Markdown
Contributor

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::init plants the admin binding already holding assignedSslPort but not assignedPort (db/model/public.rs:86-89), and clear_bindings only disables entries rather than removing them — so os_bindings only ever reaches this binding through BindInfo::update, never BindInfo::new. update short-circuits on a held port, so the seeded 443 never reaches the allocator; that is why only the plaintext leg moved. assignedPort was None, so it asked for 80, and is_restricted(port) = port <= 1024 || … refused it outright, leaving alloc() — a random port at or above 49152. update then prefers the port it already holds, so the drift survived every reboot and upgrade.

Measured on a released-0.4.0 VM (514af0c):

admin binding   net = {assignedPort: 55543, assignedSslPort: 443}
availablePorts  {80: false, 443: true, 55543: false}

The unheld 80 there is the seed the pre-#3558 Database::init wrote. It was never the cause — try_alloc's || short-circuits on is_restricted before contains_key — but it does block a later try_alloc(80), so the migration has to clear it too.

git tag --contains 1e8dbeeed is only start-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::update frees both held ports and then does let want = held.or(carried).unwrap_or(preferred). try_alloc(55543) succeeds — it was freed one statement earlier — so preferred is never reached. That is deliberate and test-locked by a_rebind_does_not_migrate_onto_a_freed_preferred_port; a_drifted_os_ui_port_does_not_heal_on_rebind now 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

update frees 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. 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. Measured:

plaintext binding on 8080 gains addSsl(8443)
  before: {assigned_port: 52981, assigned_ssl_port: 8080}
  after:  {assigned_port: 8080,  assigned_ssl_port: 8443}

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, which the_os_ui_plaintext_leg_wants_80_even_when_443_is_free pins by running the same rebind with 443 already held.

This does not replace the migration: want still prefers held, 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

assignedPort never becomes a socket for the OS: plain HTTP is a hardcoded WildcardListener::new(80) (bins/startd.rs:162). It drives advertised URLs and one nftables DNAT. os_bindings runs at ip: [127,0,0,1], so net_controller.rs:577 emits <gateway>:<external> -> 127.0.0.1:80, and the kernel drops a DNAT to loopback arriving on the bridge unless route_localnet is set — which appears nowhere in the tree. From a netns on lxcbr0 on that VM:

http://10.0.3.1:80/     -> 200      (the wildcard listener)
http://10.0.3.1:55543/  -> no response
https://10.0.3.1:443/   -> 200

Setting net.ipv4.conf.lxcbr0.route_localnet=1 made 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:80 rule today. Hand-adding exactly that rule on the 0.4.0 box took a container's request to 10.0.3.1:80 from 200 to no response; deleting it restored 200. The forward had nothing to do in the first place — StartOS binds [::]:80 plus one listener per address it answers on, so it could only shadow a port already served. route_localnet=1 would 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_2 and the manifest bump that version::tests::current_matches_manifest requires: package.json, the 0.4.0-rev.2 label, Cargo.lock, and the regenerated man page. projects/start-os/CHANGELOG.md already 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.md had 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, where pre-check already gates it. So manage-release.sh pre-check start-os now 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).

down is 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 tests
  • cargo test -p start-core --features test version:: — incl. current_matches_manifest
  • make manpages-check, make start-core-ts-bindings-check — clean
  • npm ci — lockfile in sync; cargo fmt --check and prettier clean
  • ./scripts/manage-release.sh pre-check start-os — as above

Not 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 shape Public::init produces.

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
helix-nine force-pushed the fix/admin-ui-plaintext-port branch from c71573b to 0ccbd7d Compare August 7, 2026 16:24
helix-nine and others added 2 commits August 7, 2026 17:27
`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants