47990 is the management API's port and also Sunshine's (and Apollo's, and Vibeshine's) web UI port. With the GameStream planes off it is the ONLY port the two still share, so moving it is the whole of what "run both on one box" needs — except moving it was barely possible: * `--mgmt-bind` was the sole route, and it lives in a unit file / service registration that a package upgrade rewrites. There was no `host.env` key, so the change did not survive. * The literal 47990 appeared in SIX places — mgmt::DEFAULT_PORT, the Windows service's console launch, scripts/punktfunk-web.service, the NixOS module, web/web-run.cmd, and the console's own default. Nothing downstream could learn a different port, so moving the listener silently left the console proxying to a port nothing was listening on. Now there is one source of truth. `PUNKTFUNK_MGMT_BIND` joins `host.env` (the `--gamestream` / PUNKTFUNK_GAMESTREAM shape: either source works, the CLI flag wins), and `serve` publishes the port it ACTUALLY bound to ~/.config/punktfunk/mgmt-endpoint, in the same KEY=VALUE form mgmt-token already uses so it is sourceable as a systemd EnvironmentFile and readable by the Windows service's existing read_env_file_value. Every consumer derives from that; the 47990 literals survive only as the fallback that keeps an OLD host working with a NEW console. The two unit files drop their hardcoded `Environment=PUNKTFUNK_MGMT_URL=` rather than layering a default beneath the file: whether Environment= or EnvironmentFile= wins is a directive-ordering question, and the hand-written unit and the Nix-generated one do not order the same way. No default, no precedence puzzle — the server's own built-in fallback covers a host that never wrote the file. Two robustness details worth naming, because both fail in the same direction: * mgmt-endpoint is written write-then-rename. A torn read would set PUNKTFUNK_MGMT_URL to EMPTY, which is worse than a missing file — a built-in default only rescues an *unset* variable. * mgmtUrl() now treats blank as unset, which `??` alone does not. The publish happens in parse_serve next to the token persistence, so both files appear together; the console's unit gates on mgmt-token, and its Restart=always picks up a lost race anyway. What this does NOT change: a lost 47990 bind is still fatal to the whole host (the bind sits in tokio::try_join! with the native plane), and running two Moonlight-compatible hosts at once is still unsupported — on Windows the exclusive display topology is a second, independent conflict. Both are documented rather than altered. Verified on Linux in punktfunk-rust-ci (amd64): cargo check --all-targets clean for punktfunk-host and pf-host-config with the "Checking punktfunk-host" marker confirmed present (a first run exited 0 having compiled nothing — the warm shared target dir judged it fresh), 40/40 mgmt tests pass including the new one pinning the published line against both parsers that consume it. Console: tsc --noEmit clean, bun test server/ 9/9. cargo fmt --all --check clean.
punktfunk web — management console
The browser UI for the punktfunk host's management REST API (crates/punktfunk-host/src/mgmt.rs,
OpenAPI at api/openapi.json). It shows live status, host capabilities, paired
clients, the pairing-PIN flow, and session controls.
Stack: TanStack Start (full SSR) on Bun via Nitro v2 (bun preset) · React
Query through orval codegen from the OpenAPI spec · @unom/ui
— the shared punktfunk/unom design system the marketing site + docs are built on (Tailwind v4,
animated components on the violet brand over dark chrome) ·
Paraglide i18n (en/de). Package manager + runtime: Bun.
The @unom registry mapping lives in .npmrc; the auth token comes from
~/.npmrc (or a CI secret).
Develop
# from web/ — Bun is the toolchain (https://bun.sh)
bun install # runs `prepare` → codegen (orval + paraglide)
bun run dev # http://localhost:47992
# The dev server proxies /api → https://127.0.0.1:47990 (the host's mgmt API; it serves HTTPS
# with the host's self-signed identity cert — the dev proxy uses `secure: false`).
# Point it elsewhere: PUNKTFUNK_MGMT_URL=https://<host>:47990 bun run dev
Start a host with the management API up:
# from the repo root — `serve` brings up the native punktfunk/1 plane + the mgmt API (the console
# only needs the mgmt API; add --gamestream too if you also want the Moonlight surface):
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \
cargo run -rp punktfunk-host -- serve
# loopback :47990, no token (a token is mandatory for non-loopback binds).
The management token is server-side only — set PUNKTFUNK_MGMT_TOKEN in the console's
environment and the BFF injects it when proxying (server/routes/api/[...].ts). It never reaches
the browser, so there is no token field in the UI; the browser only ever holds the session cookie.
Build & run (Nitro + Bun)
The console runs on bun (Bun.serve is a Bun API — node can't run it): Nitro's bun preset
plus a custom entry (nitro-entry/bun-https.mjs) that calls Bun.serve({ tls }), so it serves
HTTPS (HTTP/1.1 over TLS) with the host's own identity cert (the cert native clients already
pin). One trust anchor across the data plane, the mgmt API, and this console. (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; a browser-trusted, SAN-matching cert + a fronting server would be needed, out of scope for a
LAN console.)
bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry)
PORT=47992 HOST=0.0.0.0 \
PUNKTFUNK_UI_PASSWORD=… PUNKTFUNK_MGMT_TOKEN=… \
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 \
PUNKTFUNK_UI_TLS_CERT=~/.config/punktfunk/cert.pem \
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).
# 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.
bun run lint # tsc --noEmit
The built Nitro bun server SSR-renders the app and is the only thing exposed on the LAN.
Run it on the same box as the host; it serves the console over HTTPS on :47992 (or $PORT).
Auth (backend-for-frontend)
Single-user, login-gated. Config via env (see .env.example):
- The console requires a login (
PUNKTFUNK_UI_PASSWORD). On success the server sets a sealed session cookie (h3useSession, AES-GCM).server/middleware/auth.tsgates every request — pages redirect to/login,/apireturns 401 — and fails closed (503) ifPUNKTFUNK_UI_PASSWORDis unset, so a misconfigured LAN server admits no one. - The bearer-token admin surface of the management API is loopback-only — the host honors a
bearer token only from a loopback peer, so the admin API is never LAN-exposed. The web server
holds
PUNKTFUNK_MGMT_TOKENserver-side and injects it when proxying/api/**→PUNKTFUNK_MGMT_URL(loopback;server/routes/api/[...].ts). The token never reaches the browser; the browser only ever holds the session cookie. (The host also binds the read-only surface — host status + the game library — to the LAN so paired native clients can fetch it directly over mTLS; that path uses client certs, not the token, and never touches this console.)
So: browser ──password──▶ web server (session cookie) ──mgmt token, server-side──▶ mgmt API.
Run the host with a matching token: cargo run -rp punktfunk-host -- serve +
PUNKTFUNK_MGMT_TOKEN=… (or --mgmt-token …). vite dev has no gate (localhost-only) and
proxies straight to the loopback mgmt API.
Toolchain notes (load-bearing): TanStack Start's
start-plugin-corepeer-requires Vite ≥ 7 — on Vite 6 the build's prerender/post-build hook silently doesn't run.@vitejs/plugin-reactmust match Vite (v5 ↔ Vite 7, v6 ↔ Vite 8); it's required even for dev (TanStack Start's dev mode needs the React Refresh runtime, else a blank screen). Nitro is the server target — without itvite buildonly emits client+SSR bundles, no deployable server. The Nitrobunpreset makes.output/server/index.mjsBun-runnable.
Codegen
Generated code is not committed (gitignored) — reproduced from sources:
bun run codegen— regenerate the API client (orval) + i18n runtime (paraglide). Runs onbun install(prepare) and beforedev/build(pre*for orval; the Vite plugin compiles paraglide on dev/build).- After a management-API change, regenerate the spec on the Rust side first:
cargo run -p punktfunk-host -- openapi > api/openapi.json, thenbun run api:gen.
Layout
src/
routes/ file-based routes (index=dashboard, host, clients, pairing, settings)
components/
app-shell.tsx sidebar nav (brand lens + wordmark) + language switcher
brand-mark/wordmark/logo.tsx punktfunk lens mark + wordmark (shared with the site/docs)
ui/ @unom/ui-backed primitives (button, input, label, card; badge/table/skeleton)
query-state.tsx loading/error wrapper (401 → the session is gone, re-login)
api/
fetcher.ts orval mutator: base URL, bearer token, JSON, throwing ApiError
gen/ GENERATED react-query hooks + models (orval)
lib/i18n.ts reactive Paraglide locale hook
paraglide/ GENERATED i18n runtime (paraglide)
messages/{en,de}.json translation sources