fix(web/plugins): a plugin's interface loads again, instead of an empty panel #80
@@ -252,6 +252,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.
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
docs-site:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -259,6 +259,42 @@ Work through
|
||||
[Why the toggle does nothing](/docs/clipboard#why-the-toggle-does-nothing-or-is-greyed-out) — it also
|
||||
names the clients and host sessions where nothing crosses no matter what you set.
|
||||
|
||||
## A plugin's interface doesn't load
|
||||
|
||||
The plugin's page in the console opens — title, version, **Open in new tab** — but the panel below
|
||||
it stays empty.
|
||||
|
||||
Plugin interfaces are served on **TCP 47993**, a separate port from the console's 47992, so that a
|
||||
plugin can't act as you with your logged-in session (see
|
||||
[Two ports, not one](/docs/web-console#two-ports-not-one)). An empty panel means the browser can't
|
||||
load anything from that second port. Two reasons, in order of likelihood:
|
||||
|
||||
- **The port isn't open.** Only the console's port is reachable, so the frame has nothing to show.
|
||||
On a host you *upgraded*, this is the usual answer: an already-open firewall does not pick up a
|
||||
port that a later version added, because the rule it saved lists the ports it knew at the time.
|
||||
|
||||
```sh
|
||||
# ufw (CachyOS, Ubuntu): re-expand the profile, then reload
|
||||
sudo ufw app update punktfunk-web && sudo ufw reload
|
||||
|
||||
# firewalld (Fedora, Bazzite, Nobara): re-read the shipped service definition
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows: re-run the service installer, which re-adds both console rules
|
||||
punktfunk-host service install
|
||||
```
|
||||
|
||||
Check what's actually open with `sudo ufw status verbose` or
|
||||
`sudo firewall-cmd --info-service=punktfunk-web` — you want **47993** listed next to 47992.
|
||||
- **The certificate isn't trusted for that port yet.** Browsers keep a self-signed certificate
|
||||
exception *per port*, and a warning page can't be shown inside a panel. The console detects this
|
||||
and offers a link to open the plugin in its own tab: accept the warning there once and come back.
|
||||
|
||||
If the panel is empty and the console shows *no* explanation at all, the plugin's own port is
|
||||
probably being dropped rather than refused — open 47993 as above.
|
||||
|
||||
## Pairing is rejected / the client can't connect
|
||||
|
||||
- The host **requires pairing** by default. Arm pairing from the web console, then enter the PIN on
|
||||
|
||||
@@ -14,6 +14,27 @@ game-library browsing to paired clients.
|
||||
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||
|
||||
## Two ports, not one
|
||||
|
||||
The console also listens on **TCP 47993**, and plugin interfaces are served from there — same host,
|
||||
same certificate, **different port**.
|
||||
|
||||
That is a deliberate boundary rather than a second console. A plugin's interface is third-party
|
||||
code, and on the console's own port the browser would treat it as part of the console: it could act
|
||||
as you, with your logged-in session, against every admin action the console can reach. A different
|
||||
port is a different *origin*, so the browser itself keeps the two apart — while staying the same
|
||||
*site*, which is what lets your login still carry over so you don't sign in twice.
|
||||
|
||||
What this means in practice:
|
||||
|
||||
- **Open 47993 alongside 47992** on the host's firewall if you browse the console from another
|
||||
device. The packaged firewall profiles already list both.
|
||||
- **Trust the certificate twice.** Browsers store a self-signed certificate exception *per port*.
|
||||
The first time you open a plugin, the console will notice it can't reach 47993 yet and offer a
|
||||
link to open it in a tab — accept the warning there once, come back, and it works from then on.
|
||||
- If a plugin's page is an empty panel, see
|
||||
[A plugin's interface doesn't load](/docs/troubleshooting#a-plugins-interface-doesnt-load).
|
||||
|
||||
## Enable the console
|
||||
|
||||
- **Linux packages (apt / RPM / Bazzite):** on Ubuntu the host package is `punktfunk-host`
|
||||
|
||||
@@ -69,4 +69,48 @@ post_upgrade() {
|
||||
_ensure_update_group
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
|
||||
_warn_stale_firewall_ports
|
||||
}
|
||||
|
||||
# An already-open firewall does NOT pick up a port we added to a profile.
|
||||
#
|
||||
# ufw expands an app profile into concrete rules when you run `ufw allow`, and stores THOSE. Editing
|
||||
# /etc/ufw/applications.d later — which is all a package upgrade does — changes nothing about the
|
||||
# rules already installed. firewalld is friendlier (its permanent config names the service, so a
|
||||
# reload re-reads the XML) but still needs that reload. Either way the operator has an old rule and
|
||||
# no reason to suspect it.
|
||||
#
|
||||
# That is not hypothetical: 47993 (plugin UIs, a separate origin from the console) arrived exactly
|
||||
# this way, and on an upgraded ufw box every plugin interface silently became an empty panel in the
|
||||
# console. So on upgrade, look at what is actually open and say so — still without touching the
|
||||
# running firewall, which stays the operator's call.
|
||||
_warn_stale_firewall_ports() {
|
||||
# `ufw status verbose` prints each rule with its EXPANDED ports — "47992/tcp (punktfunk-web)"
|
||||
# before the refresh, "47992,47993/tcp (punktfunk-web)" after — so one listing answers both "is
|
||||
# the profile allowed at all" and "does that rule know the new port". (Plain `ufw status` prints
|
||||
# the profile NAME instead, which cannot tell the two apart.)
|
||||
if command -v ufw >/dev/null 2>&1 &&
|
||||
ufw status verbose 2>/dev/null | grep -q 'punktfunk-web' &&
|
||||
! ufw status verbose 2>/dev/null | grep -q '47993'; then
|
||||
cat <<'MSG'
|
||||
|
||||
punktfunk: your ufw rule for 'punktfunk-web' predates TCP 47993, the separate origin plugin UIs
|
||||
are served from. Until it is refreshed, plugin interfaces will not load in the web console:
|
||||
sudo ufw app update punktfunk-web && sudo ufw reload
|
||||
MSG
|
||||
fi
|
||||
# `--info-service` asks the DAEMON, which answers from the definition it loaded at its last
|
||||
# (re)start — precisely the stale copy we are warning about. The file on disk already says 47993.
|
||||
if command -v firewall-cmd >/dev/null 2>&1 &&
|
||||
firewall-cmd --state >/dev/null 2>&1 &&
|
||||
firewall-cmd --query-service=punktfunk-web >/dev/null 2>&1 &&
|
||||
! firewall-cmd --info-service=punktfunk-web 2>/dev/null | grep -q '47993'; then
|
||||
cat <<'MSG'
|
||||
|
||||
punktfunk: the punktfunk-web firewalld service now also covers TCP 47993, the separate origin
|
||||
plugin UIs are served from. Reload so the running firewall picks it up, or plugin interfaces will
|
||||
not load in the web console:
|
||||
sudo firewall-cmd --reload
|
||||
MSG
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -314,6 +314,29 @@ if [ "$1" = "configure" ]; then
|
||||
echo " sudo firewall-cmd --permanent --add-service=punktfunk-native && sudo firewall-cmd --reload"
|
||||
echo " (use punktfunk-gamestream for the Moonlight-compat host)"
|
||||
fi
|
||||
# An ALREADY-OPEN firewall does not pick up a port we later added to a profile. ufw expands an
|
||||
# app profile into concrete rules at `ufw allow` time and keeps those, so editing
|
||||
# /etc/ufw/applications.d on upgrade changes nothing; firewalld re-reads its XML, but only on a
|
||||
# reload. 47993 (the separate origin plugin UIs are served from) arrived exactly this way, and
|
||||
# an unrefreshed rule turns every plugin interface in the console into an empty panel.
|
||||
# `ufw status verbose` prints expanded ports, so it can tell "allowed" from "allowed, stale".
|
||||
if command -v ufw >/dev/null 2>&1 &&
|
||||
ufw status verbose 2>/dev/null | grep -q 'punktfunk-web' &&
|
||||
! ufw status verbose 2>/dev/null | grep -q '47993'; then
|
||||
echo ""
|
||||
echo "punktfunk: your ufw rule for 'punktfunk-web' predates TCP 47993 (plugin UIs, served"
|
||||
echo " from their own origin). Plugin interfaces will not load in the console until:"
|
||||
echo " sudo ufw app update punktfunk-web && sudo ufw reload"
|
||||
fi
|
||||
# --info-service answers from the definition the daemon loaded, i.e. the stale one.
|
||||
if command -v firewall-cmd >/dev/null 2>&1 &&
|
||||
firewall-cmd --state >/dev/null 2>&1 &&
|
||||
firewall-cmd --query-service=punktfunk-web >/dev/null 2>&1 &&
|
||||
! firewall-cmd --info-service=punktfunk-web 2>/dev/null | grep -q '47993'; then
|
||||
echo ""
|
||||
echo "punktfunk: the punktfunk-web firewalld service now also covers TCP 47993 (plugin UIs)."
|
||||
echo " Plugin interfaces will not load in the console until: sudo firewall-cmd --reload"
|
||||
fi
|
||||
# Conflicting Moonlight-compatible host (Sunshine/Apollo/...): reuse the host's own detector so
|
||||
# the warning lives in one place. Exit 1 = found; never fail the install on it.
|
||||
if command -v punktfunk-host >/dev/null 2>&1; then
|
||||
|
||||
@@ -234,7 +234,11 @@ in
|
||||
type = types.bool;
|
||||
default = cfg.host.openFirewall;
|
||||
defaultText = literalExpression "config.services.punktfunk.host.openFirewall";
|
||||
description = "Open TCP 47992 so the console is reachable from other devices on the LAN.";
|
||||
description = ''
|
||||
Open TCP 47992 so the console is reachable from other devices on the LAN, and TCP 47993,
|
||||
the separate origin its plugin UIs are served from (without it, plugin interfaces do not
|
||||
load in the console).
|
||||
'';
|
||||
};
|
||||
|
||||
autoStart = mkOption {
|
||||
@@ -388,7 +392,12 @@ in
|
||||
environment.systemPackages = [ cfg.web.package ];
|
||||
|
||||
networking.firewall = mkIf cfg.web.openFirewall {
|
||||
allowedTCPPorts = [ 47992 ]; # console HTTPS (packaging/linux/punktfunk-web.xml)
|
||||
# 47992 = the console itself. 47993 = the SEPARATE ORIGIN its plugin UIs are served from
|
||||
# (console port + 1): same host and certificate, different port, so the browser's same-origin
|
||||
# policy keeps a plugin from acting as the logged-in operator. Leaving it closed does not
|
||||
# degrade gracefully — every plugin interface is simply an empty panel from any other device.
|
||||
# Keep in step with packaging/linux/punktfunk-web.xml and punktfunk.ufw.
|
||||
allowedTCPPorts = [ 47992 47993 ];
|
||||
};
|
||||
|
||||
# First-run setup: generate the console login password once, in the user's config dir, and
|
||||
|
||||
@@ -586,6 +586,18 @@ if command -v firewall-cmd >/dev/null 2>&1; then
|
||||
echo " sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload"
|
||||
echo " (use punktfunk-native for the native-only host)"
|
||||
fi
|
||||
# A RUNNING firewalld keeps serving the service definition it loaded at its last (re)start, so a
|
||||
# port added to the XML by this upgrade — 47993, the separate origin plugin UIs are served from —
|
||||
# is not open until a reload, and the console shows every plugin interface as an empty panel with
|
||||
# nothing to explain it. `--info-service` asks the daemon, i.e. reads that stale copy.
|
||||
if command -v firewall-cmd >/dev/null 2>&1 &&
|
||||
firewall-cmd --state >/dev/null 2>&1 &&
|
||||
firewall-cmd --query-service=punktfunk-web >/dev/null 2>&1 &&
|
||||
! firewall-cmd --info-service=punktfunk-web 2>/dev/null | grep -q '47993'; then
|
||||
echo ""
|
||||
echo "punktfunk: the punktfunk-web firewalld service now also covers TCP 47993 (plugin UIs)."
|
||||
echo " Plugin interfaces will not load in the console until: sudo firewall-cmd --reload"
|
||||
fi
|
||||
# Conflicting Moonlight-compatible host (Sunshine/Apollo/...): reuse the host's own detector so the
|
||||
# warning stays in one place. Exit 1 = something found; never fail the install on it.
|
||||
if command -v punktfunk-host >/dev/null 2>&1; then
|
||||
|
||||
+7
-1
@@ -6,6 +6,12 @@
|
||||
#
|
||||
# Runtime: PORT (default 47992) and PUNKTFUNK_MGMT_URL (upstream management API the Nitro
|
||||
# server proxies /api to; see web/server/routes).
|
||||
#
|
||||
# TWO ports, not one. The console also listens on PUNKTFUNK_UI_PLUGIN_PORT (default PORT + 1 =
|
||||
# 47993) and serves plugin UIs from there — a different origin, so a plugin's own code cannot act
|
||||
# as the logged-in operator on the console's origin. Publish BOTH (`-p 47992:47992 -p
|
||||
# 47993:47993`): the browser loads the frame from the second port directly, so a container that
|
||||
# only publishes 47992 serves a console whose every plugin interface is an empty panel.
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /repo/web
|
||||
|
||||
@@ -25,5 +31,5 @@ WORKDIR /app
|
||||
COPY --from=build /repo/web/.output ./.output
|
||||
USER bun
|
||||
ENV PORT=47992
|
||||
EXPOSE 47992
|
||||
EXPOSE 47992 47993
|
||||
CMD ["bun", "run", ".output/server/index.mjs"]
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "Dieses Plugin läuft nicht",
|
||||
"plugin_origin_untrusted_title": "Port dieses Plugins einmal bestätigen",
|
||||
"plugin_origin_untrusted_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dein Browser vertraut dem Zertifikat dieses Hosts für den Konsolen-Port, aber noch nicht für diesen — und in einem Frame kann er nicht nachfragen. Öffne ihn einmal in einem Tab, bestätige das Zertifikat und komm zurück.",
|
||||
"plugin_origin_untrusted_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dein Browser spricht mit diesem Port noch nicht: entweder hat er das Zertifikat dieses Hosts dafür nicht bestätigt — Zertifikate gelten pro Port, und in einem Frame kann er nicht nachfragen — oder der Port ist in der Firewall des Hosts zu. Öffne ihn einmal in einem Tab: bei einer Zertifikatswarnung bestätigen und zurückkommen; kommt gar keine Verbindung zustande, öffne TCP 47993 auf dem Host.",
|
||||
"plugin_origin_untrusted_open": "In neuem Tab öffnen",
|
||||
"plugin_origin_unavailable_title": "Plugin-Oberflächen sind nicht verfügbar",
|
||||
"plugin_origin_unavailable_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dieser Port konnte nicht geöffnet werden, deshalb bleiben sie deaktiviert. Sieh ins Konsolen-Log, setze dann PUNKTFUNK_UI_PLUGIN_PORT auf einen freien Port und starte neu.",
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"nav_plugins": "Plugins",
|
||||
"plugin_offline_title": "This plugin isn't running",
|
||||
"plugin_origin_untrusted_title": "Trust this plugin's port once",
|
||||
"plugin_origin_untrusted_hint": "Plugin interfaces run on their own port so a plugin can't act as you on the console. Your browser trusts this host's certificate for the console's port but not yet for theirs, and it can't ask you inside a frame. Open it once in a tab, accept the certificate, then come back.",
|
||||
"plugin_origin_untrusted_hint": "Plugin interfaces run on their own port so a plugin can't act as you on the console. Your browser won't talk to that port yet: either it hasn't accepted this host's certificate for it — certificates are trusted per port, and a frame can't ask you — or the port is closed on the host's firewall. Open it once in a tab: if you get a certificate warning, accept it and come back; if it never connects, open TCP 47993 on the host.",
|
||||
"plugin_origin_untrusted_open": "Open in a new tab",
|
||||
"plugin_origin_unavailable_title": "Plugin interfaces are unavailable",
|
||||
"plugin_origin_unavailable_hint": "Plugin interfaces are served on their own port so a plugin can't act as you on the console. That port could not be opened, so they stay switched off. Check the console log, then set PUNKTFUNK_UI_PLUGIN_PORT to a free port and restart.",
|
||||
|
||||
@@ -176,6 +176,12 @@ try {
|
||||
// with (a stale inherited value would otherwise advertise a port nothing is listening on).
|
||||
process.env.PUNKTFUNK_UI_PLUGIN_PORT_ACTIVE = String(pluginPort);
|
||||
process.env.PUNKTFUNK_UI_CONSOLE_PORT_ACTIVE = String(consolePort);
|
||||
// …and the SCHEME, which the app cannot recover from a request: `localFetch` synthesises one with
|
||||
// no TLS socket, so h3 reports `http:` on an HTTPS listener. `frame-ancestors` needs the scheme
|
||||
// the operator's address bar actually shows, or the browser refuses to frame the plugin at all
|
||||
// (see consoleOriginScheme() in server/util/pluginOrigin.ts). Both listeners share this `tls`
|
||||
// object, so one stamp is correct for both.
|
||||
process.env.PUNKTFUNK_UI_SCHEME_ACTIVE = tls ? "https" : "http";
|
||||
console.log(
|
||||
`punktfunk plugin-UI origin listening on ${pluginServer.url} (tls=${!!tls})`,
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"start": "bun run .output/server/index.mjs",
|
||||
"api:gen": "orval --config orval.config.ts",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "bun test server/",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"screenshots": "node tools/screenshots.mjs",
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
} from "../util/auth";
|
||||
import {
|
||||
consoleOriginPort,
|
||||
consoleOriginScheme,
|
||||
frameAncestorSource,
|
||||
isPluginUiPath,
|
||||
listenerOf,
|
||||
} from "../util/pluginOrigin";
|
||||
@@ -127,8 +129,16 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
/**
|
||||
* The console origin, as a `frame-ancestors` source, derived from the request the PLUGIN origin is
|
||||
* answering: same scheme and hostname (whatever name the operator actually browsed to — an IP, an
|
||||
* mDNS name, a hostname — so the policy matches their address bar), the console's port.
|
||||
* answering: the hostname is whatever name the operator actually browsed to (an IP, an mDNS name, a
|
||||
* hostname — so the policy matches their address bar), plus the console's port and scheme.
|
||||
*
|
||||
* ⚠ The scheme must NOT come from the request. `getRequestURL` reports `http:` on an HTTPS listener
|
||||
* here — Nitro hands the app a synthetic request with no TLS socket — so this named
|
||||
* `http://host:47992` as the only permitted ancestor of a console the operator was reading over
|
||||
* HTTPS, and every plugin UI came up as an empty panel with `ERR_BLOCKED_BY_RESPONSE`. It is taken
|
||||
* from the listener's own TLS state instead (`consoleOriginScheme`), with `x-forwarded-proto`
|
||||
* winning when something in front terminated TLS for us — that is the one case where the browser's
|
||||
* scheme differs from this process's.
|
||||
*
|
||||
* Falls back to `'none'` rather than `'self'` or `*` when the console port is unknown: an unframable
|
||||
* plugin page is a visible, harmless failure, and the alternatives are a policy that either does
|
||||
@@ -138,5 +148,11 @@ function consoleFrameAncestor(event: H3Event): string {
|
||||
const port = consoleOriginPort();
|
||||
if (!port) return "'none'";
|
||||
const url = getRequestURL(event);
|
||||
return `${url.protocol}//${url.hostname}:${port}`;
|
||||
return frameAncestorSource({
|
||||
forwardedProto: getRequestHeader(event, "x-forwarded-proto"),
|
||||
listenerScheme: consoleOriginScheme(),
|
||||
requestScheme: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// The `frame-ancestors` source the plugin origin names the console with.
|
||||
//
|
||||
// This exists because getting it wrong is SILENT on the server: the header is well-formed, every
|
||||
// curl of the plugin origin returns 200, and the only symptom is that a browser quietly refuses to
|
||||
// paint the frame (`ERR_BLOCKED_BY_RESPONSE`) — so the console shows an empty panel and the reason
|
||||
// is only in devtools. That is exactly how `http://host:47992` shipped as the permitted ancestor of
|
||||
// an `https://host:47992` console.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { frameAncestorSource, isPluginUiPath } from "./pluginOrigin";
|
||||
|
||||
describe("frameAncestorSource", () => {
|
||||
test("uses the listener's scheme, NOT the request's", () => {
|
||||
// The regression. Nitro's localFetch synthesises a request with no TLS socket, so the request
|
||||
// says `http:` on an HTTPS listener. The listener's own scheme has to win, or the browser
|
||||
// refuses to frame the plugin.
|
||||
expect(
|
||||
frameAncestorSource({
|
||||
requestScheme: "http:",
|
||||
listenerScheme: "https",
|
||||
hostname: "192.168.1.21",
|
||||
port: 47992,
|
||||
}),
|
||||
).toBe("https://192.168.1.21:47992");
|
||||
});
|
||||
|
||||
test("a plain-HTTP console (dev) still gets http", () => {
|
||||
expect(
|
||||
frameAncestorSource({
|
||||
requestScheme: "http:",
|
||||
listenerScheme: "http",
|
||||
hostname: "localhost",
|
||||
port: 3000,
|
||||
}),
|
||||
).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
test("x-forwarded-proto wins — only a proxy knows the browser's scheme", () => {
|
||||
expect(
|
||||
frameAncestorSource({
|
||||
forwardedProto: "https",
|
||||
requestScheme: "http:",
|
||||
listenerScheme: "http",
|
||||
hostname: "console.lan",
|
||||
port: 47992,
|
||||
}),
|
||||
).toBe("https://console.lan:47992");
|
||||
});
|
||||
|
||||
test("a multi-hop x-forwarded-proto uses the first hop", () => {
|
||||
expect(
|
||||
frameAncestorSource({
|
||||
forwardedProto: "https, http",
|
||||
requestScheme: "http:",
|
||||
listenerScheme: "http",
|
||||
hostname: "console.lan",
|
||||
port: 47992,
|
||||
}),
|
||||
).toBe("https://console.lan:47992");
|
||||
});
|
||||
|
||||
test("a junk x-forwarded-proto is ignored rather than echoed", () => {
|
||||
expect(
|
||||
frameAncestorSource({
|
||||
forwardedProto: "javascript:alert(1)",
|
||||
requestScheme: "http:",
|
||||
listenerScheme: "https",
|
||||
hostname: "192.168.1.21",
|
||||
port: 47992,
|
||||
}),
|
||||
).toBe("https://192.168.1.21:47992");
|
||||
});
|
||||
|
||||
test("falls back to the request scheme when nothing is stamped", () => {
|
||||
expect(
|
||||
frameAncestorSource({
|
||||
listenerScheme: null,
|
||||
requestScheme: "https:",
|
||||
hostname: "host",
|
||||
port: 47992,
|
||||
}),
|
||||
).toBe("https://host:47992");
|
||||
});
|
||||
|
||||
test("keeps whatever hostname the operator browsed to", () => {
|
||||
// The policy has to match their address bar, not a name we prefer.
|
||||
for (const hostname of ["192.168.1.21", "punktfunk.local", "deck"]) {
|
||||
expect(
|
||||
frameAncestorSource({
|
||||
requestScheme: "http:",
|
||||
listenerScheme: "https",
|
||||
hostname,
|
||||
port: 47992,
|
||||
}),
|
||||
).toBe(`https://${hostname}:47992`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPluginUiPath", () => {
|
||||
// The two refusals that keep a plugin off the console's origin depend on this split.
|
||||
test("claims the plugin-UI prefix", () => {
|
||||
expect(isPluginUiPath("/plugin-ui")).toBe(true);
|
||||
expect(isPluginUiPath("/plugin-ui/")).toBe(true);
|
||||
expect(isPluginUiPath("/plugin-ui/rom-manager/index.html")).toBe(true);
|
||||
});
|
||||
|
||||
test("claims nothing else — above all not /api", () => {
|
||||
expect(isPluginUiPath("/api/v1/status")).toBe(false);
|
||||
expect(isPluginUiPath("/")).toBe(false);
|
||||
expect(isPluginUiPath("/login")).toBe(false);
|
||||
// A near-miss must not be swept in by a loose startsWith.
|
||||
expect(isPluginUiPath("/plugin-uix")).toBe(false);
|
||||
expect(isPluginUiPath("/plugin-ui-admin")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -49,3 +49,52 @@ export function consoleOriginPort(): number | null {
|
||||
const port = raw ? Number(raw) : Number.NaN;
|
||||
return Number.isInteger(port) && port > 0 ? port : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scheme the console is actually served on (`https` when TLS is configured), or `null` when the
|
||||
* entry has not stamped one — `vite dev`, or a test importing this directly.
|
||||
*
|
||||
* ⚠ This cannot be read off the request. Nitro's `localFetch` hands the app a SYNTHETIC request with
|
||||
* no TLS socket, so `getRequestURL(event).protocol` is `http:` even when the listener is HTTPS. That
|
||||
* is harmless for a relative redirect, and was NOT harmless for `frame-ancestors`: the plugin origin
|
||||
* named `http://host:47992` as its only permitted ancestor while the console the operator was
|
||||
* looking at was `https://host:47992`, and the browser refused to frame the plugin
|
||||
* (`ERR_BLOCKED_BY_RESPONSE`) — an empty panel with the explanation only in the devtools console.
|
||||
* The scheme-part upgrade in CSP3 (an `http` source also matching an `https` URL) does NOT rescue
|
||||
* this: Chromium enforces `frame-ancestors` against the ancestor's origin strictly. Verified on
|
||||
* glass, 2026-08-06.
|
||||
*
|
||||
* Stamped by the entry from the same `tls` option both listeners are built with, so the two can
|
||||
* never disagree, and never read from the environment we inherited.
|
||||
*/
|
||||
export function consoleOriginScheme(): "http" | "https" | null {
|
||||
const raw = process.env.PUNKTFUNK_UI_SCHEME_ACTIVE;
|
||||
return raw === "https" || raw === "http" ? raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `frame-ancestors` source naming the console, as a pure rule over the three things that can
|
||||
* know the scheme — kept separate from the request so it can be tested, because the bug it exists
|
||||
* to prevent is invisible in a header (`http://…` looks perfectly well-formed) and only shows up as
|
||||
* a plugin panel that never fills in.
|
||||
*
|
||||
* Precedence, and why:
|
||||
* 1. `x-forwarded-proto` — something in front terminated TLS, so it, not us, knows what the
|
||||
* browser's address bar says. The only case where the two legitimately differ.
|
||||
* 2. the scheme the listener was built with — the normal path, stamped at bind time.
|
||||
* 3. the request's own scheme — last resort (nothing stamped: `vite dev`, or a direct import).
|
||||
*/
|
||||
export function frameAncestorSource(o: {
|
||||
forwardedProto?: string | null;
|
||||
listenerScheme?: "http" | "https" | null;
|
||||
requestScheme: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
}): string {
|
||||
const forwarded = o.forwardedProto?.split(",")[0]?.trim().toLowerCase();
|
||||
const scheme =
|
||||
forwarded === "https" || forwarded === "http"
|
||||
? forwarded
|
||||
: (o.listenerScheme ?? o.requestScheme.replace(/:$/, ""));
|
||||
return `${scheme}://${o.hostname}:${o.port}`;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,18 @@ export const SectionPlugin: FC = () => {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// The frame must not be mounted until that probe has SETTLED.
|
||||
//
|
||||
// A firewalled port DROPs rather than refuses, so the probe does not fail fast — it hangs for the
|
||||
// browser's whole connect timeout. Mounting the iframe in the meantime puts an empty panel on
|
||||
// screen with nothing to explain it, and that is precisely what a closed 47993 looked like in the
|
||||
// field: a plugin page that "just doesn't load", with the real answer only in devtools. Waiting
|
||||
// costs a placeholder for the milliseconds a reachable origin takes, and buys the explanation.
|
||||
//
|
||||
// Only gate when the probe actually runs: under `vite dev` `pluginOrigin` is "" and the query is
|
||||
// disabled, which leaves it pending forever — gating on that would never render a frame at all.
|
||||
const originProbePending = !!pluginOrigin && reachable.isPending;
|
||||
|
||||
// The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives
|
||||
// the console URL via postMessage (below), never the src — so there's no reload loop.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally pinned to the initial path
|
||||
@@ -169,7 +181,9 @@ export const SectionPlugin: FC = () => {
|
||||
/>
|
||||
) : health.isError ? (
|
||||
<OfflineCard title={title} onRetry={() => health.refetch()} />
|
||||
) : health.isSuccess && pluginOrigin !== undefined ? (
|
||||
) : health.isSuccess &&
|
||||
!originProbePending &&
|
||||
pluginOrigin !== undefined ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={initialSrc}
|
||||
|
||||
+6
-1
@@ -29,5 +29,10 @@
|
||||
"vite.config.ts",
|
||||
"vite.storybook.config.ts",
|
||||
"orval.config.ts"
|
||||
]
|
||||
],
|
||||
// Tests run under `bun test`, which typechecks nothing and needs nothing — but `bun:test` has no
|
||||
// types without pulling in @types/bun, and that package redeclares globals this DOM-targeted
|
||||
// config already owns. Not worth a lockfile change (and the bun.nix regeneration it drags along)
|
||||
// to typecheck assertions that bun executes directly.
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user