The stats charts drew every sample as an evenly-spaced slot, because recharts defaults to a category axis. A capture that idled for two minutes rendered that gap as a single step — so the one view you open specifically to find where the time went was the view least able to show it. All three charts use a numeric time axis now, so the spacing is the elapsed time. They also joined samples across a session boundary into one continuous line, implying a continuity that never existed: the stream stopped and somebody else started a new one. A capture is split at each `session_id` change now. And the live card plotted the whole capture-so-far every 2 s, re-serialising and re-plotting an unbounded series for a capture left running all evening; it plots a bounded tail and says so, with the full series still in the saved recording. Logging out only deleted the browser's copy of the cookie. The session is stateless, so a value captured beforehand — a shared machine, a shell history, a TLS-inspecting proxy — stayed valid for its full 7-day TTL and there was nothing the operator could do about it. Sessions carry an epoch now and logging out bumps it, which invalidates every cookie issued so far. Verified end to end: a captured cookie works, survives nothing across a logout, and a fresh login still works. The rest: - The update card could not show its own timeout warning. It was suppressed by a `job` field read from the last snapshot — which, when the host has gone away mid-job, is exactly the case the warning exists for. Nothing ever cleared the applying state either, so the card waited forever with no way out; there is a button now. "Check now" also surfaces the host's 429 instead of looking dead. - A running install survives a reload: the job id lived only in component state, so refreshing lost sight of an install that was still running while the Install buttons stayed armed against a host that answers 409. The host keeps the list — ask it. - An all-sources-failed catalog said "no plugins available". That is a successful request carrying nothing, not an empty store; it names the sources that failed. - The Installed tab rendered "vundefined" for a plugin with no recorded version (nullable in the contract, typed required here). - The Displays "In effect" badges were computed from the local draft, so they restated the operator's unsaved edits back to them as though the host had adopted them. They read the API's `effective` now. A failed background poll no longer replaces a form someone is editing, and leaving the page with unsaved edits prompts — the old `beforeunload` guard never fired for in-app navigation, which is how you actually leave. - Enter or Space on a preset's rename/update/delete icon applied the preset instead of running the action: keydown bubbled to the card. - Dates follow the console's locale, not the browser's. The dashboard's PIN-pending tile says "Waiting"/"None" instead of "●"/"—". - The Bun entry warns when TLS is half-configured, or when PUNKTFUNK_UI_SECURE is set without it — both of which silently break login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
106 lines
5.2 KiB
JavaScript
106 lines
5.2 KiB
JavaScript
// Custom Nitro server entry for the punktfunk web console.
|
|
//
|
|
// It is the stock Nitro `bun` preset entry
|
|
// (node_modules/nitropack/dist/presets/bun/runtime/bun.mjs) plus **TLS**, so the console is served
|
|
// over **HTTPS (HTTP/1.1 over TLS)** using the HOST's own identity cert (the cert native clients
|
|
// already pin). One trust anchor across the data plane, the management API, and this console. Wired
|
|
// in via `entry:` in vite.config.ts on top of Nitro's `bun` preset (which bundles the handler in).
|
|
//
|
|
// 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
|
|
// 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.
|
|
//
|
|
// 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).
|
|
// PORT / HOST standard Nitro bind (3000 / 0.0.0.0).
|
|
import "#nitro-internal-pollyfills";
|
|
import wsAdapter from "crossws/adapters/bun";
|
|
import { useNitroApp } from "nitropack/runtime";
|
|
import { startScheduleRunner } from "nitropack/runtime/internal";
|
|
|
|
const nitroApp = useNitroApp();
|
|
const ws = import.meta._websocket
|
|
? wsAdapter(nitroApp.h3App.websocket)
|
|
: undefined;
|
|
|
|
// The socket peer, handed to the app as a trusted header.
|
|
//
|
|
// Nitro's `localFetch` (below) hands the app a SYNTHETIC request whose socket has no
|
|
// `remoteAddress`, so h3's `getRequestIP()` returns undefined *inside* the app and every
|
|
// per-peer decision collapses onto one shared bucket. That silently defeated the login
|
|
// throttle: five wrong passwords from anywhere locked out everyone, including the operator
|
|
// (and, since the update-apply route shares that budget, locked out host updates too).
|
|
// `server.requestIP(req)` is the only place the real peer is knowable, so we stamp it here.
|
|
// Any inbound copy is deleted first, so a client cannot forge it.
|
|
// Read back by `peerAddress()` in server/util/auth.ts — keep the two names in sync.
|
|
const PEER_IP_HEADER = "x-pf-peer-ip";
|
|
|
|
// 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;
|
|
const tls =
|
|
certPath && keyPath
|
|
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
|
|
: undefined;
|
|
|
|
// Half-configured TLS is the dangerous shape: one path set and the other missing silently drops to
|
|
// plain HTTP, and if PUNKTFUNK_UI_SECURE is also set the session cookie is marked Secure — which a
|
|
// browser then refuses to store over http://, so login appears to succeed and every next request is
|
|
// unauthenticated. Both failure modes are silent, so say something.
|
|
if (Boolean(certPath) !== Boolean(keyPath)) {
|
|
console.error(
|
|
`punktfunk web console: only ${certPath ? "PUNKTFUNK_UI_TLS_CERT" : "PUNKTFUNK_UI_TLS_KEY"} is set — ` +
|
|
"TLS needs BOTH. Serving plain HTTP.",
|
|
);
|
|
}
|
|
if (!tls && /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? "")) {
|
|
console.error(
|
|
"punktfunk web console: PUNKTFUNK_UI_SECURE is set but TLS is not configured. The session " +
|
|
"cookie will be marked Secure and dropped by the browser over http:// — login will not stick.",
|
|
);
|
|
}
|
|
|
|
const server = Bun.serve({
|
|
port: process.env.NITRO_PORT || process.env.PORT || 3000,
|
|
host: process.env.NITRO_HOST || process.env.HOST,
|
|
// Bun defaults this to 10 s, which is SHORTER than the host's 15 s SSE keep-alive comment — so a
|
|
// proxied `/api/v1/events` stream (or any other quiet long-lived response) gets cut by us and
|
|
// reconnects on a loop. 120 s is comfortably above any keep-alive we forward; still overridable.
|
|
idleTimeout: Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || 120,
|
|
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
|
|
tls,
|
|
websocket: import.meta._websocket ? ws.websocket : undefined,
|
|
async fetch(req, server) {
|
|
if (import.meta._websocket && req.headers.get("upgrade") === "websocket") {
|
|
return ws.handleUpgrade(req, server);
|
|
}
|
|
const url = new URL(req.url);
|
|
let body;
|
|
if (req.body) {
|
|
body = await req.arrayBuffer();
|
|
}
|
|
// Strip any client-supplied value BEFORE stamping the real one (see PEER_IP_HEADER).
|
|
const headers = new Headers(req.headers);
|
|
headers.delete(PEER_IP_HEADER);
|
|
const peer = server.requestIP(req)?.address;
|
|
if (peer) headers.set(PEER_IP_HEADER, peer);
|
|
return nitroApp.localFetch(url.pathname + url.search, {
|
|
host: url.hostname,
|
|
protocol: url.protocol,
|
|
headers,
|
|
method: req.method,
|
|
redirect: req.redirect,
|
|
body,
|
|
});
|
|
},
|
|
});
|
|
console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`);
|
|
if (import.meta._tasks) {
|
|
startScheduleRunner();
|
|
}
|