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

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:
2026-08-24 20:55:06 +00:00
14 changed files with 291 additions and 29 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
+23 -10
View File
@@ -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,
+4 -1
View File
@@ -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(),
+20 -3
View File
@@ -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 {
+5
View File
@@ -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";
+8 -1
View File
@@ -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
+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.
+21 -8
View File
@@ -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 });
+92
View File
@@ -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 };
}
+103
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+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):