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

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.
This commit is contained in:
2026-08-24 22:45:47 +02:00
parent 49b5ffa2d8
commit 1e2b956de6
8 changed files with 128 additions and 32 deletions
+5 -3
View File
@@ -370,9 +370,11 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
# that only a browser rejects, which nothing else here would catch.
# Scoped to server/ and nitro-entry/: the console's browser code has no test runner, but two
# gates here do — the one keeping a plugin's origin apart from the console's, whose failure
# mode is a well-formed header that only a browser rejects, and the one picking which of the
# host's two identities the console serves, whose failure mode is a cert no browser accepts.
# Neither would be caught anywhere else.
- name: Test
run: bun run test
+10 -7
View File
@@ -431,13 +431,16 @@ pub fn serve(
//
// Resolved BEFORE the legacy GameStream identity below, and that order is load-bearing twice
// over. (1) The web console gates its start on `cert.pem` existing and then serves the native
// pair sitting next to it (web/nitro-entry/tls-paths.mjs); minting the legacy pair first leaves
// a first-run window where the console starts, finds no native pair, and serves the SAN-less
// RSA cert no browser accepts — for the rest of that boot. This way `cert.pem` existing implies
// the native pair does too. (2) In the degenerate case (native clients paired, but the cert
// they pinned is gone from disk) 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 means that case reaches the branch written for it.
// pair sitting next to it (web/nitro-entry/tls-paths.mjs); minting the legacy pair first
// leaves a first-run window where the console starts, finds no native pair, and serves the
// SAN-less RSA cert no browser accepts — for the rest of that boot. Running first closes that
// window: whenever this call WRITES a native pair, it has done so before `cert.pem` appears.
// (It does not write one on an upgraded host whose native clients pinned the legacy cert
// there the console correctly falls back to that same legacy pair.) (2) In the degenerate case
// (native clients paired, but the cert they pinned is gone from disk) 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 means
// that case reaches the branch written for it.
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
#[cfg(feature = "gamestream")]
let state = {
+5 -3
View File
@@ -220,9 +220,11 @@ fn poll_loop(
// cert would have hit it just as squarely, so the coupling goes rather than the symptom.
//
// Nothing is lost by dropping the pin: this probe sends no credentials, reads no body, and
// decides only a menu LABEL. A local port-squatter could make that label read "up" — but the
// entry is always present and always opens the same URL regardless of the probe, so it gains
// nothing it did not already have.
// decides only presentation — the menu entry's label, plus whether a tray-icon click opens
// the console or the menu (win.rs). A port-squatter could flip that, but the entry itself is
// unconditional and opens the same URL either way, and no browser ever pinned this cert. On
// Windows the probe was never pinned to begin with: `punktfunk_config_dir` returns None there,
// so `load_pin` was already None.
let console_agent = agent(None);
let mut last: Option<(TrayStatus, bool)> = None;
// When the summary became unreachable while the service was running (grace anchor).
+2
View File
@@ -58,6 +58,8 @@ PORT=47992 HOST=0.0.0.0 \
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
bun run start # = bun run .output/server/index.mjs
# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
# Naming cert.pem/key.pem serves native-cert.pem/native-key.pem instead when both sit beside them
# (the identity split — nitro-entry/tls-paths.mjs); the legacy pair is the fallback, not the target.
# The host's self-signed mgmt cert is accepted only for the proxy's loopback hop, scoped in code
# (Bun per-request TLS: server/routes/api/[...].ts) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
# See .env.example.
+48 -11
View File
@@ -28,11 +28,48 @@
// build key.pem is the Moonlight PAIRING SIGNING key, native-key.pem is only a TLS key.
//
// Swapped as a PAIR or not at all — a native cert with the legacy key is a server that cannot
// complete a handshake with anyone. A host that never took the split (upgraded, native clients
// still pinning the RSA cert, so `load_or_adopt` keeps serving it) has no native pair on disk and
// falls through unchanged, as does a cert an operator supplied under any other name.
import { existsSync } from "node:fs";
import { basename, dirname, join } from "node:path";
// complete a handshake with anyone, so both halves must be present AND must come from the same
// directory. A host that never took the split (upgraded, native clients still pinning the RSA cert,
// so `load_or_adopt` keeps serving it) has no native pair on disk and falls through unchanged, as
// does a cert an operator supplied under any other name.
import { statSync } from "node:fs";
/**
* The directory prefix (separator included) of a path ending in `base`, or null if it does not.
*
* Deliberately NOT `node:path`: that resolves per-RUNTIME, so a POSIX build reads
* `C:\ProgramData\punktfunk\cert.pem` as one long filename — and Windows, where the service
* supervisor hands us exactly that (windows/service.rs), is the platform CI can never exercise.
* A suffix test gives the same answer everywhere. It also leaves the prefix VERBATIM, where
* `join(dirname(p), …)` would normalise `/a/b/../cert.pem` to a different directory than the one
* the operator named — which matters the moment `b` is a symlink.
*
* @param {string} p
* @param {string} base
* @returns {string | null}
*/
function dirPrefix(p, base) {
if (p === base) return ""; // bare relative name
if (!p.endsWith(base)) return null;
const sep = p[p.length - base.length - 1];
return sep === "/" || sep === "\\" ? p.slice(0, -base.length) : null;
}
/**
* A readable, NON-EMPTY file. Emptiness matters: `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).
*
* @param {string} p
*/
function usable(p) {
try {
return statSync(p).size > 0;
} catch {
return false;
}
}
/**
* @param {string | undefined} cert PUNKTFUNK_UI_TLS_CERT, verbatim.
@@ -40,15 +77,15 @@ import { basename, dirname, join } from "node:path";
* @param {(p: string) => boolean} [exists] injected by the test; defaults to a real stat.
* @returns {{cert: string | undefined, key: string | undefined}}
*/
export function resolveUiTlsPaths(cert, key, exists = existsSync) {
export function resolveUiTlsPaths(cert, key, exists = usable) {
// Half-configured TLS is the caller's error to report (it refuses to start); don't mask it by
// resolving one half of a pair that isn't there.
if (!cert || !key) return { cert, key };
if (basename(cert) !== "cert.pem" || basename(key) !== "key.pem") {
return { cert, key };
}
const nativeCert = join(dirname(cert), "native-cert.pem");
const nativeKey = join(dirname(key), "native-key.pem");
const dir = dirPrefix(cert, "cert.pem");
// Same directory, or we are not looking at a pair — see the PAIR note above.
if (dir === null || dir !== dirPrefix(key, "key.pem")) return { cert, key };
const nativeCert = `${dir}native-cert.pem`;
const nativeKey = `${dir}native-key.pem`;
return exists(nativeCert) && exists(nativeKey)
? { cert: nativeCert, key: nativeKey }
: { cert, key };
+52 -7
View File
@@ -1,13 +1,14 @@
// The pair swap is all-or-nothing, and the fallbacks are what keep legacy and custom-cert hosts
// serving. A native cert with the legacy key would be a console nobody can handshake with, so the
// mixed cases are the ones worth pinning down.
// mixed cases are the ones worth pinning down — including the Windows shape, which the resolver
// must get right without a win32 runtime to ask (see dirPrefix in tls-paths.mjs).
import { describe, expect, it } from "bun:test";
import { resolveUiTlsPaths } from "./tls-paths.mjs";
const DIR = "/home/you/.config/punktfunk";
const legacy = [`${DIR}/cert.pem`, `${DIR}/key.pem`] as const;
const native = [`${DIR}/native-cert.pem`, `${DIR}/native-key.pem`] as const;
/** `exists` over a fixed set of files on disk. */
/** `exists` over a fixed set of usable files on disk. */
const on =
(...files: string[]) =>
(p: string) =>
@@ -28,7 +29,7 @@ describe("resolveUiTlsPaths", () => {
});
});
it("never mixes halves when only one native file exists", () => {
it("never mixes halves when only one native file is usable", () => {
for (const half of native) {
expect(resolveUiTlsPaths(...legacy, on(...legacy, half))).toEqual({
cert: legacy[0],
@@ -37,11 +38,55 @@ describe("resolveUiTlsPaths", () => {
}
});
// The Windows service supervisor hands us backslash paths (windows/service.rs); node:path on a
// POSIX CI runner would read the whole thing as one filename and silently never swap.
it("resolves Windows paths without a win32 runtime", () => {
const win = ["C:\\ProgramData\\punktfunk", "D:\\pf"] as const;
for (const d of win) {
expect(
resolveUiTlsPaths(`${d}\\cert.pem`, `${d}\\key.pem`, () => true),
).toEqual({
cert: `${d}\\native-cert.pem`,
key: `${d}\\native-key.pem`,
});
}
});
it("refuses to pair halves from two different directories", () => {
expect(resolveUiTlsPaths("/a/cert.pem", "/b/key.pem", () => true)).toEqual({
cert: "/a/cert.pem",
key: "/b/key.pem",
});
});
it("leaves the prefix verbatim rather than normalising it away", () => {
// `join(dirname(p), …)` would collapse this to /a/native-cert.pem — a different directory
// the moment `b` is a symlink.
expect(
resolveUiTlsPaths("/a/b/../cert.pem", "/a/b/../key.pem", () => true),
).toEqual({
cert: "/a/b/../native-cert.pem",
key: "/a/b/../native-key.pem",
});
});
it("leaves an operator's own cert alone, native pair present or not", () => {
const own = [`${DIR}/lan-ca.pem`, `${DIR}/lan-ca.key`] as const;
expect(resolveUiTlsPaths(...own, on(...own, ...native))).toEqual({
cert: own[0],
key: own[1],
// Also covers the endsWith trap: "mycert.pem" ends with "cert.pem" but is not one.
for (const own of [
[`${DIR}/lan-ca.pem`, `${DIR}/lan-ca.key`],
[`${DIR}/mycert.pem`, `${DIR}/mykey.pem`],
] as const) {
expect(resolveUiTlsPaths(...own, on(...own, ...native))).toEqual({
cert: own[0],
key: own[1],
});
}
});
it("does not re-swap a pair that already names the native files", () => {
expect(resolveUiTlsPaths(...native, () => true)).toEqual({
cert: native[0],
key: native[1],
});
});
+3
View File
@@ -51,6 +51,9 @@ if exist "%ENDPOINTFILE%" for /f "usebackq tokens=1* delims==" %%A in ("%ENDPOIN
rem No NODE_TLS_REJECT_UNAUTHORIZED: the host's self-signed cert is accepted only for the loopback
rem proxy hop, scoped inside the proxy code (Bun per-request TLS), not process-wide.
rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure.
rem These name the LEGACY pair; the server prefers native-cert.pem/native-key.pem beside them when
rem both exist (the identity split - web\nitro-entry\tls-paths.mjs). Don't "fix" them to the native
rem names: a host that never took the split has no native pair, and the fallback lives in there.
set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%"
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
set "PUNKTFUNK_UI_SECURE=1"
+3 -1
View File
@@ -18,7 +18,9 @@ HOST=0.0.0.0
# Serve the console over HTTPS (HTTP/1.1 over TLS) with the host's own identity cert. BOTH paths
# set ⇒ HTTPS. (No HTTP/2 or HTTP/3: Bun.serve has no HTTP/2 server, and a browser won't speak
# HTTP/3/QUIC against this self-signed, no-SAN host cert — so HTTP/1.1 over TLS is what's offered.)
# HTTP/3/QUIC against a self-signed host cert — so HTTP/1.1 over TLS is what's offered.)
# Name the LEGACY pair below: the server prefers native-cert.pem/native-key.pem beside it when both
# exist (nitro-entry/tls-paths.mjs), and falls back to these on a host that never took the split.
PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
# Mark the session cookie Secure (required once served over TLS):