The console served the identity nothing pins, and the tray called it dead #393

Merged
enricobuehler merged 2 commits from worktree-web-console-native-identity into main 2026-08-24 20:55:11 +00:00
Owner

Field report (2026-08-24, Linux): the tray showed "Open web console (not responding)" next to a tooltip reading "idle", and the console would not load in a browser either. No other streaming host on the box.

What was actually wrong

The host has kept two identities since the identity split (crate::identity):

pair what it is who pins it
native-cert.pem / native-key.pem ECDSA P-256, real SANs (hostname, localhost, 127.0.0.1, ::1) native QUIC plane, mgmt API, native clients, the tray
cert.pem / key.pem legacy RSA, CN=punktfunk, no SAN at all Moonlight (byte-stable, pairing hashes bind its signature)

The web console never followed the split. Every launcher — scripts/punktfunk-web.service, the NixOS module, the Windows service supervisor, web-run.cmd, the Steam Deck installer — still names the legacy pair, and none of them can choose: systemd Environment= has no "this file, else that one".

So the console served a cert with no SAN, which costs twice:

  1. Browsers reject a CN-only cert outright (ERR_CERT_COMMON_NAME_INVALID / SSL_ERROR_BAD_CERT_DOMAIN) — the console the operator was told to open does not load.
  2. The tray's liveness probe reused the agent pinned to the mgmt identity — the native cert — so rustls refused the handshake and a perfectly healthy console got labelled "not responding".

The reporter's "idle" tooltip is the proof of (2): the same agent reached the mgmt API fine on the very same tick. Anything that fetched the summary successfully and then called the console dead was a pin mismatch, nothing else. This reproduces on every identity-split host, i.e. every fresh install since the split.

The fix

web/nitro-entry/tls-paths.mjs (new) — the entry is the one place every launcher routes through, so the choice is made there: prefer the native sibling pair when both files are present and usable, as a pair or not at all (a native cert with the legacy key completes no handshake with anyone). Both halves must resolve to the same directory. A host that never took the split has no native pair on disk and falls through unchanged, as does an operator-supplied cert under any other name.

Deliberately not built on node:path: that resolves per-runtime, so a POSIX CI runner reads C:\ProgramData\punktfunk\cert.pem as one long filename and never swaps — and Windows, where windows/service.rs hands us exactly that shape, is the platform CI can never exercise. A suffix test gives the same answer everywhere (and leaves the prefix verbatim, where join(dirname(p), …) would normalise /a/b/../cert.pem into a different directory the moment b is a symlink).

The check is a non-empty stat, not mere existence: pf_paths::write_secret_file is create+truncate+write rather than temp+rename, so a console starting mid-write could otherwise adopt a 0-byte cert and leave Bun.serve throwing on every restart — and not every launcher retries forever (the Steam Deck unit is Restart=on-failure under the default rate limit, i.e. permanently dead).

This also hands the bundled bun the smaller secret: on a default build key.pem is the Moonlight pairing signing key; native-key.pem is only a TLS key.

crates/punktfunk-tray/src/status.rs — the console probe loses its pin rather than gaining a second one. It is a different server and there is no rule that it presents the mgmt certificate; an operator fronting the console with their own LAN-CA cert would have hit this just as squarely. The probe sends no credentials, reads no body, and decides only presentation, so there is nothing for a pin to protect. (On Windows it was never pinned to begin with — punktfunk_config_dir returns None off Linux, so load_pin was already None.)

crates/punktfunk-host/src/gamestream/mod.rsserve now resolves the native identity before minting the legacy one. Two things fall out:

  • whenever that call writes a native pair, it does so before cert.pem appears — closing a first-run window where the console (which waits on cert.pem) could start between the two writes and serve the SAN-less cert for the rest of that boot;
  • it fixes a latent fault: with cert.pem missing but native clients paired, the old order let load_or_create mint a brand-new cert.pem that load_or_adopt then adopted while logging that it was preserving their pins — stranding them silently. Reading the dir first routes that case to the branch written for it.

The rest is comment accuracy across the launchers (they keep naming the legacy pair on purpose — the fallback lives on the other side of the handoff).

Verification

End-to-end against the built server, configured exactly as the shipped unit does (PUNKTFUNK_UI_TLS_CERT=…/cert.pem):

  • both pairs on disk → serves id-ecPublicKey / NIST P-256 with DNS:localhost, IP Address:127.0.0.1
  • native pair removed → serves rsaEncryption, Subject: CN=punktfunk, no SAN — legacy hosts unchanged
  • native-cert.pem truncated to 0 bytes → falls back to the legacy pair, console still serving (with a bare existence check this was a permanent restart loop)

18/18 web tests, biome and rustfmt clean, and all 8 CI checks green — including ci / rust, which carries cargo clippy --workspace -- -D warnings and the native-only --no-default-features --features pyrowave gate, so both feature configurations compile and lint.

Reviewed

An adversarial review pass found no blockers. Its one real finding — the resolver could return a mismatched pair from two different directories, the exact invariant the module claimed to hold — is fixed in 1e2b956d, along with the Windows/normalisation/zero-byte hardening above and two comments of mine that overclaimed.

Known follow-up, not taken here: the console resolves its pair once at start and the unit has no PartOf=/BindsTo=, so the documented migration in identity.rs ("unpair ALL native clients, restart the host, re-pair") mints native-*.pem while the running console keeps serving the legacy cert until something restarts it. Adding PartOf= would bounce the console on every host restart, which is a trade worth making deliberately rather than as a side effect of this fix.

Note for the reporter

Worth confirming their console is actually enabled — the .deb postinst only prints the command, it cannot run it for a user:

systemctl --user status punktfunk-web
systemctl --user enable --now punktfunk-web

If it is running, this PR is their bug: the tray label was a false alarm, and the browser was refusing a SAN-less cert.

Field report (2026-08-24, Linux): the tray showed **"Open web console (not responding)"** next to a tooltip reading **"idle"**, and the console would not load in a browser either. No other streaming host on the box. ## What was actually wrong The host has kept **two** identities since the identity split (`crate::identity`): | pair | what it is | who pins it | |---|---|---| | `native-cert.pem` / `native-key.pem` | ECDSA P-256, real SANs (hostname, `localhost`, `127.0.0.1`, `::1`) | native QUIC plane, mgmt API, native clients, **the tray** | | `cert.pem` / `key.pem` | legacy RSA, `CN=punktfunk`, **no SAN at all** | Moonlight (byte-stable, pairing hashes bind its signature) | **The web console never followed the split.** Every launcher — `scripts/punktfunk-web.service`, the NixOS module, the Windows service supervisor, `web-run.cmd`, the Steam Deck installer — still names the *legacy* pair, and none of them **can** choose: systemd `Environment=` has no "this file, else that one". So the console served a cert with no SAN, which costs twice: 1. **Browsers reject a CN-only cert outright** (`ERR_CERT_COMMON_NAME_INVALID` / `SSL_ERROR_BAD_CERT_DOMAIN`) — the console the operator was told to open does not load. 2. **The tray's liveness probe reused the agent pinned to the *mgmt* identity** — the native cert — so rustls refused the handshake and a perfectly healthy console got labelled "not responding". The reporter's "idle" tooltip is the proof of (2): the *same* agent reached the mgmt API fine on the very same tick. Anything that fetched the summary successfully and then called the console dead was a pin mismatch, nothing else. This reproduces on **every identity-split host**, i.e. every fresh install since the split. ## The fix **`web/nitro-entry/tls-paths.mjs`** (new) — the entry is the one place every launcher routes through, so the choice is made there: prefer the native sibling pair when both files are present and usable, as a **pair or not at all** (a native cert with the legacy key completes no handshake with anyone). Both halves must resolve to the same directory. A host that never took the split has no native pair on disk and falls through unchanged, as does an operator-supplied cert under any other name. Deliberately **not** built on `node:path`: that resolves per-*runtime*, so a POSIX CI runner reads `C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and Windows, where `windows/service.rs` hands us exactly that shape, is the platform CI can never exercise. A suffix test gives the same answer everywhere (and leaves the prefix verbatim, where `join(dirname(p), …)` would normalise `/a/b/../cert.pem` into a different directory the moment `b` is a symlink). The check is a **non-empty stat**, not mere existence: `pf_paths::write_secret_file` is create+truncate+write rather than temp+rename, so a console starting mid-write could otherwise adopt a 0-byte cert and leave `Bun.serve` throwing on every restart — and not every launcher retries forever (the Steam Deck unit is `Restart=on-failure` under the default rate limit, i.e. permanently dead). This also hands the bundled bun the *smaller* secret: on a default build `key.pem` is the Moonlight pairing **signing** key; `native-key.pem` is only a TLS key. **`crates/punktfunk-tray/src/status.rs`** — the console probe loses its pin rather than gaining a second one. It is a different server and there is no rule that it presents the mgmt certificate; an operator fronting the console with their own LAN-CA cert would have hit this just as squarely. The probe sends no credentials, reads no body, and decides only presentation, so there is nothing for a pin to protect. (On Windows it was never pinned to begin with — `punktfunk_config_dir` returns `None` off Linux, so `load_pin` was already `None`.) **`crates/punktfunk-host/src/gamestream/mod.rs`** — `serve` now resolves the native identity **before** minting the legacy one. Two things fall out: - whenever that call *writes* a native pair, it does so before `cert.pem` appears — closing a first-run window where the console (which waits on `cert.pem`) could start between the two writes and serve the SAN-less cert for the rest of that boot; - it fixes a latent fault: with `cert.pem` missing but native clients paired, the old order let `load_or_create` mint a **brand-new** `cert.pem` that `load_or_adopt` then adopted *while logging that it was preserving their pins* — stranding them silently. Reading the dir first routes that case to the branch written for it. The rest is comment accuracy across the launchers (they keep naming the legacy pair on purpose — the fallback lives on the other side of the handoff). ## Verification End-to-end against the **built** server, configured exactly as the shipped unit does (`PUNKTFUNK_UI_TLS_CERT=…/cert.pem`): - both pairs on disk → serves `id-ecPublicKey` / NIST P-256 with `DNS:localhost, IP Address:127.0.0.1` ✅ - native pair removed → serves `rsaEncryption`, `Subject: CN=punktfunk`, no SAN — legacy hosts unchanged ✅ - `native-cert.pem` truncated to 0 bytes → falls back to the legacy pair, console still serving (with a bare existence check this was a permanent restart loop) ✅ **18/18** web tests, biome and rustfmt clean, and all 8 CI checks green — including `ci / rust`, which carries `cargo clippy --workspace -- -D warnings` **and** the native-only `--no-default-features --features pyrowave` gate, so both feature configurations compile and lint. ## Reviewed An adversarial review pass found **no blockers**. Its one real finding — the resolver could return a mismatched pair from two different directories, the exact invariant the module claimed to hold — is fixed in `1e2b956d`, along with the Windows/normalisation/zero-byte hardening above and two comments of mine that overclaimed. **Known follow-up, not taken here:** the console resolves its pair once at start and the unit has no `PartOf=`/`BindsTo=`, so the documented migration in `identity.rs` ("unpair ALL native clients, restart the host, re-pair") mints `native-*.pem` while the running console keeps serving the legacy cert until something restarts it. Adding `PartOf=` would bounce the console on every host restart, which is a trade worth making deliberately rather than as a side effect of this fix. ## Note for the reporter Worth confirming their console is actually enabled — the `.deb` postinst only *prints* the command, it cannot run it for a user: ``` systemctl --user status punktfunk-web systemctl --user enable --now punktfunk-web ``` If it is running, this PR is their bug: the tray label was a false alarm, and the browser was refusing a SAN-less cert.
enricobuehler added 1 commit 2026-08-24 18:31:06 +00:00
fix(web,tray,host): the console served the identity nothing pins, and the tray called it dead
android / android (pull_request) Successful in 4m33s
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 2m54s
nix / flake (pull_request) Successful in 6m58s
ci / rust (pull_request) Successful in 7m2s
49b5ffa2d8
A Linux operator saw "Open web console (not responding)" in the tray next to a
tooltip reading "idle", and the console would not load in a browser either.

The host has kept two identities since the identity split (crate::identity):
native-cert.pem/native-key.pem (P-256, real SANs — what the native QUIC plane,
the mgmt API and every native client pin) and the legacy cert.pem/key.pem (RSA,
CN=punktfunk, NO SAN, kept byte-stable for Moonlight). The web console never
followed the split. Every launcher — the systemd unit, the NixOS module, the
Windows service supervisor, web-run.cmd, the Steam Deck installer — still names
the LEGACY pair, and none of them CAN choose: `Environment=` has no "this file,
else that one". So the console served a certificate with no SAN at all, which
costs twice over:

  * browsers reject a CN-only cert outright (ERR_CERT_COMMON_NAME_INVALID), so
    the console the operator was told to open does not load;
  * the tray's loopback liveness probe reused the agent PINNED to the mgmt
    identity — the native cert — so rustls refused the handshake and a perfectly
    healthy console was labelled "not responding". The "idle" tooltip beside it
    is the proof: the same agent reached mgmt fine on the very same tick.

The entry is the one place every launcher routes through, so the choice is made
there: prefer the native sibling pair when both files exist, as a PAIR or not at
all (a native cert with the legacy key completes no handshake with anyone). A
host that never took the split has no native pair on disk and falls through
unchanged, as does an operator-supplied cert under any other name. This also
hands the bundled bun the smaller secret: on a default build key.pem is the
Moonlight pairing SIGNING key, native-key.pem is only a TLS key.

The tray's console probe loses its pin rather than gaining a second one. It is a
different server and there is no rule that it presents the mgmt certificate — an
operator fronting the console with their own LAN-CA cert would have hit this just
as squarely. The probe sends no credentials, reads no body, and decides only a
menu label, so there is nothing for a pin to protect.

`serve` now resolves the native identity BEFORE minting the legacy one. That
closes a first-run window where the console (which waits on cert.pem) could start
between the two writes and serve the SAN-less cert for the rest of the boot, and
it fixes a second latent fault: with cert.pem missing but native clients paired,
the old order let load_or_create mint a brand-new cert.pem that load_or_adopt
then adopted while logging that it was preserving their pins.

Verified against the built server: configured exactly as the shipped unit does
(PUNKTFUNK_UI_TLS_CERT=.../cert.pem), it now serves the P-256 cert with
DNS:localhost/IP:127.0.0.1; with the native pair removed it serves the RSA cert
as before. 14/14 web tests pass, biome and rustfmt clean.
enricobuehler added 1 commit 2026-08-24 20:45:58 +00:00
fix(web,tray,host): review follow-ups — pair the halves, and stop two comments overclaiming
ci / docs-drift (pull_request) Successful in 25s
ci / bun-nix (pull_request) Successful in 26s
ci / docs-site (pull_request) Successful in 1m11s
ci / web (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 1m52s
android / android (pull_request) Successful in 5m43s
ci / rust (pull_request) Successful in 5m43s
nix / flake (pull_request) Successful in 6m34s
1e2b956de6
Review of the parent commit. One real defect, the rest accuracy.

The resolver could hand back a MISMATCHED pair, which is the one invariant its
own header promised it never would: `nativeCert` came from `dirname(cert)` and
`nativeKey` from `dirname(key)`, two independent directories, so
("/a/cert.pem", "/b/key.pem") resolved to /a/native-cert.pem + /b/native-key.pem
— two unrelated files presented as a pair. No shipped launcher splits them, but
the guard is one comparison and it is the whole point of the module.

Rewritten off a suffix test instead of `node:path`, which fixes two more things
in passing. `node:path` resolves per-RUNTIME, so a POSIX CI runner reads
`C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and
Windows, where windows/service.rs hands us exactly that, is the platform the CI
job can never exercise. The suffix test gives the same answer everywhere and is
now covered by a win32 case. It also leaves the prefix VERBATIM, where
`join(dirname(p), …)` normalised /a/b/../cert.pem into a different directory the
moment `b` was a symlink.

Existence is no longer enough: `pf_paths::write_secret_file` is
create+truncate+write rather than temp+rename, so a console starting mid-write
could adopt a 0-byte cert and leave `Bun.serve` throwing on every restart. Not
every launcher retries forever — the Steam Deck unit is `Restart=on-failure`
under the default rate limit, i.e. permanently dead. The check is now a
non-empty stat, mirroring the host's own `!c.trim().is_empty()`. Verified: with
native-cert.pem truncated to 0 bytes the console starts and serves the legacy
pair.

Two comments of mine overclaimed and are corrected rather than left to mislead:

  * serve() said "cert.pem existing implies the native pair does too". False on
    an upgraded host whose native clients pinned the legacy cert — load_or_adopt
    returns it and writes no native files at all. The ordering claim that IS
    true is narrower: whenever that call writes a native pair, it does so before
    cert.pem appears.
  * the tray said the console entry "always opens the same URL regardless of the
    probe". True of the menu entry, but win.rs gates the tray-icon single-click
    on console_up. Also notes that the Windows probe was never pinned to begin
    with (punktfunk_config_dir is None off Linux), so that half is a no-op.

Rest is doc drift the parent commit annotated in two launchers but not the other
four: web.env.example, README, web-run.cmd, and the ci.yml comment that still
said the web test step was "Scoped to server/".

18/18 web tests (was 14), biome and rustfmt clean, and the runtime check re-run
against a fresh build: both pairs -> P-256 with SANs; 0-byte native cert ->
legacy RSA, console still serving.
enricobuehler marked the pull request as ready for review 2026-08-24 20:54:32 +00:00
enricobuehler merged commit c407f6a6d9 into main 2026-08-24 20:55:11 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#393