The console served the identity nothing pins, and the tray called it dead (#393)
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m12s
ci / docs-site (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m3s
ci / docs-drift (push) Successful in 2m8s
deb / build-publish-gamescope (push) Successful in 1m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 1m45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 26s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 28s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 25s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m53s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m17s
deb / build-publish-host (push) Successful in 5m42s
ci / rust (push) Successful in 7m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
docker / builders-arm64cross (push) Successful in 19s
docker / deploy-docs (push) Successful in 52s
android / android (push) Successful in 8m54s
arch / build-publish (push) Successful in 9m32s
deb / smoke-install (push) Successful in 4m10s
windows-host / package (push) Successful in 19m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
nix / flake (push) Successful in 20m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m30s
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m12s
ci / docs-site (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m3s
ci / docs-drift (push) Successful in 2m8s
deb / build-publish-gamescope (push) Successful in 1m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 1m45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 26s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 28s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 25s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m53s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m17s
deb / build-publish-host (push) Successful in 5m42s
ci / rust (push) Successful in 7m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
docker / builders-arm64cross (push) Successful in 19s
docker / deploy-docs (push) Successful in 52s
android / android (push) Successful in 8m54s
arch / build-publish (push) Successful in 9m32s
deb / smoke-install (push) Successful in 4m10s
windows-host / package (push) Successful in 19m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
nix / flake (push) Successful in 20m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m30s
The web console never followed the identity split: every launcher names the legacy cert.pem/key.pem, so it served a CN-only, SAN-less RSA cert. Browsers reject that outright, and the tray's liveness probe — pinned to the native identity the mgmt API serves — could never complete the handshake, labelling a healthy console "not responding" beside a tooltip reading "idle".
Resolved at the Nitro entry, the one place every launcher routes through: prefer the native pair when both halves are present, usable and in the same directory; fall back to the legacy pair otherwise. The tray's console probe drops its pin rather than gaining a second one, and `serve` now mints the native identity before the legacy one.
Reviewed adversarially (no blockers; the one real finding — a pair that could be assembled from two different directories — fixed in 1e2b956d). All 8 checks green, including the native-only clippy gate. Verified end to end against a built server: both pairs → P-256 with SANs; legacy only → RSA as before; 0-byte native cert → clean fallback rather than a restart loop.
This commit was merged in pull request #393.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -419,6 +419,29 @@ pub fn serve(
|
||||
// The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
|
||||
// (via `AppState`), and the native punktfunk/1 loops (passed to `native::serve`).
|
||||
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
|
||||
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
||||
// and the management API) always exists.
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(None, None, false)
|
||||
.context("native pairing store")?,
|
||||
);
|
||||
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
|
||||
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
|
||||
// the two planes cannot race the first-run adoption. See `crate::identity`.
|
||||
//
|
||||
// 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. 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 = {
|
||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||
@@ -426,20 +449,10 @@ pub fn serve(
|
||||
};
|
||||
#[cfg(not(feature = "gamestream"))]
|
||||
let state = Arc::new(AppState::new(host, stats.clone()));
|
||||
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
||||
// and the management API) always exists.
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(None, None, false)
|
||||
.context("native pairing store")?,
|
||||
);
|
||||
// WP13: hand the GameStream planes the grants registry — the nvhttp launch surface and the
|
||||
// ENet control thread resolve a Moonlight fingerprint's mask against the same registry the
|
||||
// native plane enforces (design §8: it keys on fingerprint hex and serves both stores).
|
||||
let _ = state.access.set(np.clone());
|
||||
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
|
||||
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
|
||||
// the two planes cannot race the first-run adoption. See `crate::identity`.
|
||||
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
|
||||
tracing::info!(
|
||||
hostname = %state.host.hostname,
|
||||
uniqueid = %state.host.uniqueid,
|
||||
|
||||
@@ -1091,7 +1091,10 @@ fn spawn_web(cfg: &WebConfig, data: &Path, job: HANDLE) -> Result<Child> {
|
||||
// The /api proxy hop to the host's loopback HTTPS mgmt API. The host's self-signed cert is
|
||||
// accepted only inside the proxy code (per-request TLS), never process-wide.
|
||||
("PUNKTFUNK_MGMT_URL", mgmt_url),
|
||||
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure.
|
||||
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure. Names the
|
||||
// LEGACY pair — the console prefers the native sibling when it exists
|
||||
// (web/nitro-entry/tls-paths.mjs), which is also what the gate above ends up waiting for:
|
||||
// `serve` resolves the native identity before minting this one.
|
||||
(
|
||||
"PUNKTFUNK_UI_TLS_CERT",
|
||||
data.join("cert.pem").to_string_lossy().into_owned(),
|
||||
|
||||
@@ -208,7 +208,24 @@ fn poll_loop(
|
||||
// that proves the server is answering, and the agent below refuses redirects so the probe is
|
||||
// exactly one round trip. (A 302 still counts as up via the `Status` arm in `probe_console`.)
|
||||
let console_url = format!("https://127.0.0.1:{web_port}/login");
|
||||
let agent = agent(load_pin());
|
||||
// Named, not `agent`: shadowing the fn (as this did while there was only one agent) would make
|
||||
// the second call below resolve to this binding instead.
|
||||
let mgmt_agent = agent(load_pin());
|
||||
// The console probe gets its OWN, UNPINNED agent. It is a different server from the mgmt API
|
||||
// and there is no rule that it presents the same certificate: it served the legacy `cert.pem`
|
||||
// while mgmt served the native one (the identity split), so the pinned agent refused the
|
||||
// handshake and every identity-split host showed "Open web console (not responding)" over a
|
||||
// perfectly healthy console — next to a tooltip reading "idle", because the same agent reached
|
||||
// mgmt fine (field report 2026-08-24). An operator fronting the console with their own LAN-CA
|
||||
// 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 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).
|
||||
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
|
||||
@@ -220,7 +237,7 @@ fn poll_loop(
|
||||
loop {
|
||||
let svc = probe_service();
|
||||
let summary = if svc == ServiceState::Running {
|
||||
let s = fetch_summary(&agent, &summary_url());
|
||||
let s = fetch_summary(&mgmt_agent, &summary_url());
|
||||
match s {
|
||||
Some(_) => unreachable_since = None,
|
||||
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
|
||||
@@ -233,7 +250,7 @@ fn poll_loop(
|
||||
};
|
||||
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
|
||||
let status = map_status(&svc, summary, grace_expired);
|
||||
let console_up = if probe_console(&agent, &console_url) {
|
||||
let console_up = if probe_console(&console_agent, &console_url) {
|
||||
console_misses = 0;
|
||||
true
|
||||
} else {
|
||||
|
||||
@@ -667,6 +667,11 @@ in
|
||||
HOST = "0.0.0.0";
|
||||
# Serve HTTPS with the host's own identity cert (the anchor native clients already pin) and
|
||||
# mark the session cookie Secure. The host's `serve` writes these PEMs.
|
||||
#
|
||||
# These name the LEGACY pair; the server prefers the native sibling
|
||||
# (native-cert.pem/native-key.pem) when it exists, because a generated unit cannot express
|
||||
# "this file, else that one" any more than the hand-written one can. The choice is made in
|
||||
# web/nitro-entry/tls-paths.mjs — keep this in step with scripts/punktfunk-web.service.
|
||||
PUNKTFUNK_UI_TLS_CERT = "%h/.config/punktfunk/cert.pem";
|
||||
PUNKTFUNK_UI_TLS_KEY = "%h/.config/punktfunk/key.pem";
|
||||
PUNKTFUNK_UI_SECURE = "1";
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
#
|
||||
# Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing:
|
||||
# it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS)
|
||||
# with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy
|
||||
# with the host's own identity cert (~/.config/punktfunk/native-{cert,key}.pem, falling back to the
|
||||
# legacy {cert,key}.pem — see the PUNKTFUNK_UI_TLS_CERT note below), and points the /api proxy
|
||||
# at the host's loopback HTTPS mgmt API. The self-signed cert is accepted only for that loopback hop,
|
||||
# scoped inside the proxy code (Bun per-request TLS) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
|
||||
# Enable per user:
|
||||
@@ -39,6 +40,12 @@ Environment=HOST=0.0.0.0
|
||||
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
|
||||
# session cookie Secure. The host's `serve` writes these PEMs; if absent at start the unit fails and
|
||||
# Restart retries (same as the mgmt-token wait above) rather than silently serving plain HTTP.
|
||||
#
|
||||
# These name the LEGACY pair and the server prefers the native sibling
|
||||
# (native-cert.pem/native-key.pem) whenever it exists — `Environment=` cannot express "this file,
|
||||
# else that one", so the choice is made in web/nitro-entry/tls-paths.mjs, which is the one place
|
||||
# every launcher routes through. Don't "fix" these to the native names: a host that never took the
|
||||
# identity split has no native pair, and the fallback lives on the other side of this handoff.
|
||||
Environment=PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
|
||||
Environment=PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
|
||||
Environment=PUNKTFUNK_UI_SECURE=1
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
//
|
||||
// NOTE on HTTP/2 + HTTP/3: NOT offered here, on purpose. `Bun.serve` has no HTTP/2 server, and
|
||||
// HTTP/3 (which Bun *can* do) is useless to a browser against this cert: QUIC refuses any cert error,
|
||||
// and the host identity cert is a CN-only, no-SAN, self-signed cert (correct for native fingerprint
|
||||
// PINNING, rejected by browsers). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
|
||||
// and the host identity is SELF-SIGNED whichever pair we serve — the native one carries real SANs, so
|
||||
// a browser gets past the name check, but never past the untrusted issuer (and the legacy fallback is
|
||||
// CN-only with no SAN, which fails both). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
|
||||
// dangle an `Alt-Svc` no browser can use. Real h2/h3 would need a browser-TRUSTED, SAN-matching cert
|
||||
// (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately
|
||||
// out of scope for a LAN console; TLS (no cleartext login/session) is the win.
|
||||
@@ -17,14 +18,16 @@
|
||||
// TWO LISTENERS, on purpose — see `PLUGIN ORIGIN` below.
|
||||
//
|
||||
// Env (set by the launchers / the systemd unit — see web.env.example):
|
||||
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS.
|
||||
// Unset ⇒ plain HTTP (local dev only).
|
||||
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem — the native
|
||||
// sibling pair is preferred when present, see tls-paths.mjs).
|
||||
// BOTH set ⇒ HTTPS. Unset ⇒ plain HTTP (local dev only).
|
||||
// PORT / HOST standard Nitro bind (3000 / 0.0.0.0).
|
||||
// PUNKTFUNK_UI_PLUGIN_PORT the plugin-UI origin's port (default: console port + 1).
|
||||
import "#nitro-internal-pollyfills";
|
||||
import wsAdapter from "crossws/adapters/bun";
|
||||
import { useNitroApp } from "nitropack/runtime";
|
||||
import { startScheduleRunner } from "nitropack/runtime/internal";
|
||||
import { resolveUiTlsPaths } from "./tls-paths.mjs";
|
||||
|
||||
const nitroApp = useNitroApp();
|
||||
const ws = import.meta._websocket
|
||||
@@ -75,8 +78,15 @@ const PEER_IP_HEADER = "x-pf-peer-ip";
|
||||
const LISTENER_HEADER = "x-pf-listener";
|
||||
|
||||
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
|
||||
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
|
||||
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
|
||||
//
|
||||
// The launchers all name the LEGACY cert.pem/key.pem pair and cannot express a fallback, so the
|
||||
// choice between the host's two identities is made here — see tls-paths.mjs for why the native
|
||||
// pair is the right one to serve (SANs a browser accepts; the cert the tray and native clients
|
||||
// already pin).
|
||||
const { cert: certPath, key: keyPath } = resolveUiTlsPaths(
|
||||
process.env.PUNKTFUNK_UI_TLS_CERT,
|
||||
process.env.PUNKTFUNK_UI_TLS_KEY,
|
||||
);
|
||||
const tls =
|
||||
certPath && keyPath
|
||||
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
|
||||
@@ -126,7 +136,8 @@ const listenerOptions = (lane) => ({
|
||||
// is a hooks/library JSON edit, kilobytes. 4 MiB leaves several orders of headroom and still
|
||||
// makes the memory cost of an unauthenticated request negligible.
|
||||
maxRequestBodySize:
|
||||
Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) || 4 * 1024 * 1024,
|
||||
Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) ||
|
||||
4 * 1024 * 1024,
|
||||
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
|
||||
tls,
|
||||
websocket: import.meta._websocket ? ws.websocket : undefined,
|
||||
@@ -167,7 +178,9 @@ console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`);
|
||||
// this exists to close, and a security boundary that disappears when a port is busy is not one. It
|
||||
// degrades to "plugin UIs unavailable": the console reads the state below and renders an
|
||||
// explanation instead of a frame, and everything else about the console keeps working.
|
||||
const pluginPort = Number(process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1);
|
||||
const pluginPort = Number(
|
||||
process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1,
|
||||
);
|
||||
let pluginServer;
|
||||
try {
|
||||
pluginServer = Bun.serve({ ...listenerOptions("plugin"), port: pluginPort });
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Which of the host's two identities the console serves — resolved HERE because this entry is the
|
||||
// one place every launcher routes through.
|
||||
//
|
||||
// The host keeps two identities side by side (crate::identity, the "identity split"):
|
||||
//
|
||||
// native-cert.pem / native-key.pem ECDSA P-256, with real SANs (the machine hostname,
|
||||
// localhost, 127.0.0.1, ::1). This is what the native QUIC
|
||||
// plane and the management API present, and what native
|
||||
// clients pin.
|
||||
// cert.pem / key.pem the legacy RSA GameStream identity: CN=punktfunk and NO SAN
|
||||
// at all (gamestream::cert::generate passes rcgen an empty SAN
|
||||
// list), kept byte-stable because Moonlight pins it and the
|
||||
// pairing hashes bind its X.509 signature bytes.
|
||||
//
|
||||
// Every launcher names the LEGACY pair — scripts/punktfunk-web.service, the NixOS module, the
|
||||
// Windows service supervisor, web-run.cmd, the Steam Deck installer — because they were written
|
||||
// before the split, and none of them CAN choose: systemd `Environment=` has no "this file, else
|
||||
// that one". Serving the legacy pair costs twice:
|
||||
//
|
||||
// * a CN-only, SAN-less cert is rejected outright by every current browser
|
||||
// (ERR_CERT_COMMON_NAME_INVALID / SSL_ERROR_BAD_CERT_DOMAIN), so the console the operator was
|
||||
// told to open does not load;
|
||||
// * the tray's loopback liveness probe pins whatever the mgmt API serves — the NATIVE cert — so
|
||||
// the handshake is refused and a perfectly healthy console is labelled "Open web console (not
|
||||
// responding)" while the host beside it reads "idle" (field report 2026-08-24).
|
||||
//
|
||||
// So prefer the native sibling. It is also the smaller secret to hand a bundled bun: on a default
|
||||
// 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, 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.
|
||||
* @param {string | undefined} key PUNKTFUNK_UI_TLS_KEY, verbatim.
|
||||
* @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 = 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 };
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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 — 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 usable files on disk. */
|
||||
const on =
|
||||
(...files: string[]) =>
|
||||
(p: string) =>
|
||||
files.includes(p);
|
||||
|
||||
describe("resolveUiTlsPaths", () => {
|
||||
it("prefers the native pair when both files are there", () => {
|
||||
expect(resolveUiTlsPaths(...legacy, on(...legacy, ...native))).toEqual({
|
||||
cert: native[0],
|
||||
key: native[1],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the legacy pair on a host that never took the identity split", () => {
|
||||
expect(resolveUiTlsPaths(...legacy, on(...legacy))).toEqual({
|
||||
cert: legacy[0],
|
||||
key: legacy[1],
|
||||
});
|
||||
});
|
||||
|
||||
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],
|
||||
key: legacy[1],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
// 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],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes a half-configured pair through for the entry to refuse", () => {
|
||||
expect(resolveUiTlsPaths(legacy[0], undefined, on(...native))).toEqual({
|
||||
cert: legacy[0],
|
||||
key: undefined,
|
||||
});
|
||||
expect(resolveUiTlsPaths(undefined, undefined, on(...native))).toEqual({
|
||||
cert: undefined,
|
||||
key: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
"start": "bun run .output/server/index.mjs",
|
||||
"api:gen": "orval --config orval.config.ts",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "bun test server/",
|
||||
"test": "bun test server/ nitro-entry/",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"screenshots": "node tools/screenshots.mjs",
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ export default defineConfig({
|
||||
// stock self-listening entry for ours (`nitro-entry/bun-https.mjs`), which calls
|
||||
// `Bun.serve({ tls })` so the console is served over HTTPS (HTTP/1.1 over TLS) with the
|
||||
// host's own identity cert. (No HTTP/2 — Bun.serve has no h2 server — and no HTTP/3, which a
|
||||
// browser won't speak against this self-signed, no-SAN host cert.) Bun is the runtime
|
||||
// browser won't speak against a self-signed host cert.) Bun is the runtime
|
||||
// everywhere now — the Windows installer already bundles it, and the punktfunk-web .deb
|
||||
// vendors it (it can't be `node`: `Bun.serve` is a bun API). (dev `vite dev` is unaffected.)
|
||||
preset: "bun",
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user