A moved mgmt port left every plugin and the tray dialing 47990 in silence; the Windows runner task now also writes a log file #314
Generated
+1
@@ -3702,6 +3702,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
"libc",
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"serde",
|
||||
|
||||
@@ -47,6 +47,30 @@ pub fn config_dir() -> PathBuf {
|
||||
base.join("punktfunk")
|
||||
}
|
||||
|
||||
/// The mgmt port the host actually bound, from `<config_dir>/mgmt-endpoint` — the one
|
||||
/// `PUNKTFUNK_MGMT_URL=https://127.0.0.1:<port>` line `punktfunk-host serve` publishes on every
|
||||
/// start (`mgmt::publish_endpoint`). This is how a `PUNKTFUNK_MGMT_BIND` move reaches a loopback
|
||||
/// consumer that inherits nothing from `host.env` — the tray, which on Windows cannot even read
|
||||
/// `host.env` (DACL-locked to SYSTEM/Administrators) while this file is deliberately Users-readable.
|
||||
/// `None` when the file is absent (an older host, or no host on this box) or unparsable; callers
|
||||
/// fall back to 47990, which is strictly what they did before.
|
||||
pub fn published_mgmt_port() -> Option<u16> {
|
||||
published_mgmt_port_in(&config_dir())
|
||||
}
|
||||
|
||||
/// The IO half of [`published_mgmt_port`], taking the directory so it is testable without touching
|
||||
/// `PUNKTFUNK_CONFIG_DIR` (this crate forbids the `unsafe` that `set_var` now needs).
|
||||
pub fn published_mgmt_port_in(dir: &std::path::Path) -> Option<u16> {
|
||||
let raw = std::fs::read_to_string(dir.join("mgmt-endpoint")).ok()?;
|
||||
let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
|
||||
let value = line.split_once('=').map_or(line, |(_, v)| v).trim();
|
||||
// `https://127.0.0.1:47995` → the last `:`-separated field, tolerating a trailing `/`.
|
||||
value
|
||||
.trim_end_matches('/')
|
||||
.rsplit_once(':')
|
||||
.and_then(|(_, port)| port.parse().ok())
|
||||
}
|
||||
|
||||
/// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable
|
||||
/// by other local users via a traversable config path). On Windows, applies a restrictive DACL
|
||||
/// ([`restrict_dir_to_system_admins`]) so a local unprivileged user can't pre-create / plant files in
|
||||
@@ -260,3 +284,41 @@ fn restrict_to_system_admins(path: &std::path::Path) {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn published_mgmt_port_follows_the_endpoint_file_and_is_absent_without_it() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-paths-endpoint-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
published_mgmt_port_in(&dir),
|
||||
None,
|
||||
"no file → fall back to the default"
|
||||
);
|
||||
|
||||
// exactly what `mgmt::endpoint_line` writes
|
||||
std::fs::write(
|
||||
dir.join("mgmt-endpoint"),
|
||||
"PUNKTFUNK_MGMT_URL=https://127.0.0.1:47995\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(published_mgmt_port_in(&dir), Some(47995));
|
||||
|
||||
std::fs::write(dir.join("mgmt-endpoint"), "\n").unwrap();
|
||||
assert_eq!(
|
||||
published_mgmt_port_in(&dir),
|
||||
None,
|
||||
"blank reads as unset, not port 0"
|
||||
);
|
||||
|
||||
std::fs::write(dir.join("mgmt-endpoint"), "PUNKTFUNK_MGMT_URL=\n").unwrap();
|
||||
assert_eq!(published_mgmt_port_in(&dir), None);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,10 @@ pub fn effective_port() -> u16 {
|
||||
/// console's own default. Moving the listener therefore silently broke the console, because nothing
|
||||
/// downstream had any way to learn the new port. Now the host is the single source of truth and
|
||||
/// publishes what it actually bound; consumers keep a 47990 fallback purely so an OLD host with a
|
||||
/// NEW console still works.
|
||||
/// NEW console still works. The plugin runner / SDK (`sdk/src/config.ts::publishedMgmtUrl`) and
|
||||
/// the tray (`pf_paths::published_mgmt_port`) read the same file — both used to be a sixth and
|
||||
/// seventh literal 47990, and a moved port left every plugin dialing the old one in silence
|
||||
/// (field report 2026-08-18).
|
||||
///
|
||||
/// Always loopback, never `bind`'s own address: the console proxies over loopback by design (see
|
||||
/// the module docs — the bearer-token admin surface is confined to loopback peers), so a wide
|
||||
|
||||
@@ -18,6 +18,9 @@ path = "src/main.rs"
|
||||
# stub main (same pattern as the platform-gated clients).
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
# `config_dir()` + `published_mgmt_port()`: the tray follows the mgmt port the host actually bound
|
||||
# (`<config_dir>/mgmt-endpoint`) instead of assuming 47990. Std-only leaf, no I/O stack.
|
||||
pf-paths = { path = "../pf-paths" }
|
||||
|
||||
[target.'cfg(any(windows, target_os = "linux"))'.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -22,10 +22,13 @@ mod win;
|
||||
#[cfg(windows)]
|
||||
mod win_theme;
|
||||
|
||||
/// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the
|
||||
/// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is
|
||||
/// DACL-locked to SYSTEM/Administrators), so an operator who moved `--mgmt-bind` adjusts the
|
||||
/// autostart command line instead.
|
||||
/// CLI configuration (hand-rolled parse, house style). The mgmt address defaults to loopback; the
|
||||
/// port, when not given, follows what the host PUBLISHED (`<config_dir>/mgmt-endpoint`, rewritten
|
||||
/// on every host start — see `pf_paths::published_mgmt_port`), falling back to 47990. That file is
|
||||
/// how a moved `PUNKTFUNK_MGMT_BIND` reaches the tray: it cannot read `host.env` on Windows (DACL-
|
||||
/// locked to SYSTEM/Administrators), and before this an operator who moved the port had to know to
|
||||
/// edit the autostart command line — nobody did, and the tray reported a running host as
|
||||
/// unreachable (field report 2026-08-18). `--mgmt-port` still pins it explicitly.
|
||||
pub struct Args {
|
||||
/// Ask an already-running tray instance to exit (Windows; used by the uninstaller).
|
||||
pub quit: bool,
|
||||
@@ -34,7 +37,9 @@ pub struct Args {
|
||||
pub autostart: bool,
|
||||
/// Management API address to poll (loopback only; the summary route rejects anything else).
|
||||
pub mgmt_addr: String,
|
||||
pub mgmt_port: u16,
|
||||
/// `None` = follow the published endpoint (re-read on every poll, so a host restarted on a new
|
||||
/// port is picked up without relaunching the tray).
|
||||
pub mgmt_port: Option<u16>,
|
||||
/// Web console port for the "Open web console" action.
|
||||
pub web_port: u16,
|
||||
}
|
||||
@@ -45,7 +50,7 @@ impl Default for Args {
|
||||
quit: false,
|
||||
autostart: false,
|
||||
mgmt_addr: "127.0.0.1".into(),
|
||||
mgmt_port: 47990,
|
||||
mgmt_port: None,
|
||||
web_port: 47992,
|
||||
}
|
||||
}
|
||||
@@ -63,7 +68,7 @@ fn parse_args() -> anyhow::Result<Args> {
|
||||
"--quit" => args.quit = true,
|
||||
"--autostart" => args.autostart = true,
|
||||
"--mgmt-addr" => args.mgmt_addr = value("--mgmt-addr")?,
|
||||
"--mgmt-port" => args.mgmt_port = value("--mgmt-port")?.parse()?,
|
||||
"--mgmt-port" => args.mgmt_port = Some(value("--mgmt-port")?.parse()?),
|
||||
"--web-port" => args.web_port = value("--web-port")?.parse()?,
|
||||
"--version" | "-V" => {
|
||||
println!("punktfunk-tray {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
@@ -158,7 +158,7 @@ impl Poller {
|
||||
/// had one.
|
||||
pub fn spawn(
|
||||
mgmt_addr: String,
|
||||
mgmt_port: u16,
|
||||
mgmt_port: Option<u16>,
|
||||
web_port: u16,
|
||||
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
|
||||
) -> Poller {
|
||||
@@ -184,15 +184,23 @@ impl Poller {
|
||||
fn poll_loop(
|
||||
shared: &Shared,
|
||||
mgmt_addr: &str,
|
||||
mgmt_port: u16,
|
||||
mgmt_port: Option<u16>,
|
||||
web_port: u16,
|
||||
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
|
||||
) {
|
||||
// IPv6 literals bracketed, like the Linux client's `base_url`.
|
||||
let url = if mgmt_addr.contains(':') {
|
||||
format!("https://[{mgmt_addr}]:{mgmt_port}/api/v1/local/summary")
|
||||
} else {
|
||||
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
|
||||
// Resolved PER TICK, not once: with no `--mgmt-port` the port is whatever the host last
|
||||
// published, and a host restarted on a moved `PUNKTFUNK_MGMT_BIND` must not leave the tray
|
||||
// polling the old one until the next login. One tiny file read every 3 s is nothing.
|
||||
let summary_url = || {
|
||||
let port = mgmt_port
|
||||
.or_else(pf_paths::published_mgmt_port)
|
||||
.unwrap_or(47990);
|
||||
// IPv6 literals bracketed, like the Linux client's `base_url`.
|
||||
if mgmt_addr.contains(':') {
|
||||
format!("https://[{mgmt_addr}]:{port}/api/v1/local/summary")
|
||||
} else {
|
||||
format!("https://{mgmt_addr}:{port}/api/v1/local/summary")
|
||||
}
|
||||
};
|
||||
// `/login`, not `/`: `/` is auth-gated and 302s to `/login`, and ureq follows redirects by
|
||||
// default — so probing `/` spent TLS + `/` + a full cold `/login` SSR render inside one 2 s
|
||||
@@ -212,7 +220,7 @@ fn poll_loop(
|
||||
loop {
|
||||
let svc = probe_service();
|
||||
let summary = if svc == ServiceState::Running {
|
||||
let s = fetch_summary(&agent, &url);
|
||||
let s = fetch_summary(&agent, &summary_url());
|
||||
match s {
|
||||
Some(_) => unreachable_since = None,
|
||||
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
|
||||
|
||||
@@ -222,7 +222,7 @@ it — leave it or delete it, it makes no difference.
|
||||
| `PUNKTFUNK_MGMT_TOKEN` | token | Bearer token for the management API. If unset it's auto-generated and persisted to `~/.config/punktfunk/mgmt-token` (the bundled web console sources it). Set only to pin a specific token. |
|
||||
| `PUNKTFUNK_UI_PASSWORD` | password | Web-console login password. Normally generated on first start and stored in `~/.config/punktfunk/web-password` — see [Forgot your Password?](/docs/forgot-password). |
|
||||
| `PUNKTFUNK_PLUGIN_TOKEN` | token | The scoped token the [plugin/scripting runner](/docs/plugins) uses — a narrower credential than `PUNKTFUNK_MGMT_TOKEN`, never full admin. Same precedence: if unset it's generated and persisted to `~/.config/punktfunk/plugin-token`. Set only to pin a specific token. |
|
||||
| `PUNKTFUNK_MGMT_BIND` | `IP:PORT` *(default: `0.0.0.0:47990`)* | Where the management API listens. The `--mgmt-bind` flag overrides it. Two reasons to set it: pin `127.0.0.1:47990` to keep the API off the LAN entirely (paired clients then can't browse your library), or **move the port to share the machine with Sunshine, Apollo or Vibeshine** — 47990 is their web UI as well as our management API, and it's the only port the two still share once GameStream compat is off. Everything downstream follows the port you pick: native clients learn it from discovery, and the web console reads it from `~/.config/punktfunk/mgmt-endpoint`, which the host writes on every start. See [another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed). |
|
||||
| `PUNKTFUNK_MGMT_BIND` | `IP:PORT` *(default: `0.0.0.0:47990`)* | Where the management API listens. The `--mgmt-bind` flag overrides it. Two reasons to set it: pin `127.0.0.1:47990` to keep the API off the LAN entirely (paired clients then can't browse your library), or **move the port to share the machine with Sunshine, Apollo or Vibeshine** — 47990 is their web UI as well as our management API, and it's the only port the two still share once GameStream compat is off. Everything downstream follows the port you pick: native clients learn it from discovery, and the web console, the plugin runner (and so every library plugin) and the status tray read it from `~/.config/punktfunk/mgmt-endpoint` (`%ProgramData%\punktfunk\mgmt-endpoint` on Windows), which the host writes on every start. See [another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed). |
|
||||
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
|
||||
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam install, wherever it is. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
|
||||
|
||||
@@ -344,8 +344,19 @@ journalctl --user -u punktfunk-scripting -f
|
||||
</Tab>
|
||||
<Tab value="Windows">
|
||||
|
||||
The runner task doesn't write a log file, so run it in the foreground to watch it start your
|
||||
plugins (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
|
||||
The runner task writes its output to `%ProgramData%\punktfunk\plugin-state\runner.log` (the
|
||||
previous run is kept as `runner.log.1`). This is the file to read — or send — when the console's
|
||||
Plugins view stays empty although the runner is running: everything the runner and its plugins
|
||||
printed lands here even when they can't reach the host.
|
||||
|
||||
```powershell
|
||||
Get-Content "$env:ProgramData\punktfunk\plugin-state\runner.log" -Tail 100
|
||||
```
|
||||
|
||||
If the file doesn't exist, the task started before `punktfunk-host plugins enable` ever ran (which
|
||||
is what makes `plugin-state` writable for the runner's `LocalService` account) — run it from an
|
||||
elevated prompt, then read the file. To watch a start live instead, run the runner in the
|
||||
foreground (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
|
||||
|
||||
```powershell
|
||||
& "$env:ProgramFiles\punktfunk\bun\bun.exe" "$env:ProgramFiles\punktfunk\scripting\runner-cli.js"
|
||||
|
||||
@@ -46,8 +46,10 @@ GameStream compat **off** (the default), the overlap narrows to two things you c
|
||||
PUNKTFUNK_MGMT_BIND=0.0.0.0:47991
|
||||
```
|
||||
|
||||
Nothing else needs changing: clients learn the port from discovery, and the web console reads it
|
||||
from `~/.config/punktfunk/mgmt-endpoint`, which the host rewrites on every start. A host added
|
||||
Nothing else needs changing: clients learn the port from discovery, and the web console, the
|
||||
plugin runner (so every library plugin) and the status tray read it from
|
||||
`~/.config/punktfunk/mgmt-endpoint` (`%ProgramData%\punktfunk\mgmt-endpoint` on Windows), which
|
||||
the host rewrites on every start. A host added
|
||||
manually **by IP address** is the exception — it assumes 47990 and its library will stop loading,
|
||||
so re-add it from discovery. (You can move the other host instead: Sunshine and its forks derive
|
||||
every port from one base setting.)
|
||||
|
||||
@@ -241,7 +241,8 @@ Type: files; Name: "{app}\web\web-run.cmd"
|
||||
|
||||
[Registry]
|
||||
; Auto-start the status tray at sign-in (all users of this host box; uninsdeletevalue removes it
|
||||
; with the app). Operators who moved --mgmt-bind can append --mgmt-addr/--mgmt-port here.
|
||||
; with the app). No --mgmt-port needed for a moved --mgmt-bind: the tray follows the port the host
|
||||
; publishes in %ProgramData%\punktfunk\mgmt-endpoint (pf_paths::published_mgmt_port); the flag pins it.
|
||||
Root: HKLM64; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
|
||||
ValueName: "PunktfunkTray"; ValueData: """{app}\punktfunk-tray.exe"""; Flags: uninsdeletevalue; Tasks: trayicon
|
||||
; Toast identity for the tray's notifications ("client connected"). The tray process tags itself
|
||||
|
||||
@@ -25,8 +25,9 @@
|
||||
# * 127.0.0.1:47990 keeps it off the LAN — at the cost of paired clients browsing your library.
|
||||
# * MOVING THE PORT is how you share a machine with Sunshine/Apollo/Vibeshine: 47990 is their web
|
||||
# UI as well as our management API, and with PUNKTFUNK_GAMESTREAM off it is the ONLY port the
|
||||
# two still share. Nothing else needs editing — clients learn the port from discovery and the
|
||||
# web console reads it from ~/.config/punktfunk/mgmt-endpoint, which the host rewrites on start.
|
||||
# two still share. Nothing else needs editing — clients learn the port from discovery; the web
|
||||
# console, the plugin runner (so every plugin) and the tray read it from
|
||||
# ~/.config/punktfunk/mgmt-endpoint, which the host rewrites on start.
|
||||
# Running two Moonlight-compatible hosts at once is still unsupported; see the troubleshooting
|
||||
# page. On Windows also see PUNKTFUNK_NO_ISOLATE — the display topology is the second conflict.
|
||||
#PUNKTFUNK_MGMT_BIND=0.0.0.0:47991
|
||||
|
||||
@@ -26,4 +26,22 @@ if not exist "%BUN%" (
|
||||
|
||||
rem The runner import()s the operator's .ts plugin files, so it runs on the bundled bun. SIGTERM (task
|
||||
rem End) interrupts the whole unit tree structurally so plugin finalizers run before exit.
|
||||
"%BUN%" "%RUNNER%"
|
||||
rem
|
||||
rem Its stdout/stderr go to a file: a scheduled task has no console, and the runner's other log door
|
||||
rem (shipping lines to the host's Logs page) needs the very connection whose failure is what you'd
|
||||
rem be trying to read about - a runner that can't reach the host was silent everywhere (field report
|
||||
rem 2026-08-18: task Running, plugins installed, "no logs at all"). plugin-state is the one dir
|
||||
rem `plugins enable` makes writable for LocalService; the file inherits Users-read from the config
|
||||
rem dir, so `type` it from any prompt. One previous run is kept as runner.log.1. If the dir isn't
|
||||
rem writable (task started before `plugins enable` ever ran), start unlogged rather than not at all.
|
||||
rem ponytail: no size cap within one run - rotate on size if a chatty plugin ever fills a disk.
|
||||
set "LOG=%ProgramData%\punktfunk\plugin-state\runner.log"
|
||||
set "LOGGED="
|
||||
if exist "%LOG%" move /y "%LOG%" "%LOG%.1" >nul 2>&1
|
||||
copy /y nul "%LOG%" >nul 2>&1 && set "LOGGED=1"
|
||||
if defined LOGGED (
|
||||
>> "%LOG%" echo [punktfunk-scripting] %DATE% %TIME% starting "%BUN%" "%RUNNER%" as %USERNAME%
|
||||
"%BUN%" "%RUNNER%" >> "%LOG%" 2>&1
|
||||
) else (
|
||||
"%BUN%" "%RUNNER%"
|
||||
)
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ Plus a real-world recipe:
|
||||
|
||||
| What | Source |
|
||||
|---|---|
|
||||
| URL | `{ url }` → `PUNKTFUNK_MGMT_URL` → `https://127.0.0.1:47990` |
|
||||
| URL | `{ url }` → `PUNKTFUNK_MGMT_URL` → `<config_dir>/mgmt-endpoint` (the URL the host actually bound, rewritten on every start — a moved `PUNKTFUNK_MGMT_BIND` is followed here) → `https://127.0.0.1:47990` |
|
||||
| Token | `{ token }` → `PUNKTFUNK_MGMT_TOKEN` → `PUNKTFUNK_PLUGIN_TOKEN` → `<config_dir>/plugin-token` → `<config_dir>/mgmt-token` |
|
||||
| TLS pin | `{ ca }` → `PUNKTFUNK_MGMT_CA` (path) → `<config_dir>/cert.pem` |
|
||||
|
||||
|
||||
+16
-1
@@ -2,7 +2,8 @@
|
||||
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
|
||||
// needs zero configuration.
|
||||
//
|
||||
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
|
||||
// PUNKTFUNK_MGMT_URL else <config_dir>/mgmt-endpoint (the URL the host actually bound,
|
||||
// rewritten on every start), else https://127.0.0.1:47990
|
||||
// PUNKTFUNK_MGMT_TOKEN (admin override), else PUNKTFUNK_PLUGIN_TOKEN,
|
||||
// else <config_dir>/plugin-token, else <config_dir>/mgmt-token
|
||||
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/native-cert.pem, else cert.pem when present)
|
||||
@@ -111,12 +112,26 @@ const parseTokenFile = (raw: string): string | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The mgmt URL the host published: `<config_dir>/mgmt-endpoint`, one
|
||||
* `PUNKTFUNK_MGMT_URL=https://127.0.0.1:<port>` line the host rewrites on every start with the port
|
||||
* it REALLY bound. This is how a `PUNKTFUNK_MGMT_BIND` move (the supported way to share a box with
|
||||
* Sunshine/Apollo, whose web UI owns 47990) reaches a plugin: the runner is a scheduled task /
|
||||
* systemd unit that inherits nothing from `host.env` (which on Windows it can't even read), so
|
||||
* before this a moved port left every plugin — and the runner's own log shipper — dialing
|
||||
* `127.0.0.1:47990` forever, silently. Field report 2026-08-18. `undefined` when the file is absent
|
||||
* (an old host, or a plugin CLI run on another machine); the caller falls back to the default.
|
||||
*/
|
||||
export const publishedMgmtUrl = (): string | undefined =>
|
||||
parseTokenFile(readIfExists(path.join(configDir(), "mgmt-endpoint")) ?? "");
|
||||
|
||||
export const resolveConfig = async (
|
||||
options?: ConnectOptions,
|
||||
): Promise<ResolvedConfig> => {
|
||||
const url = (
|
||||
options?.url ??
|
||||
process.env.PUNKTFUNK_MGMT_URL ??
|
||||
publishedMgmtUrl() ??
|
||||
"https://127.0.0.1:47990"
|
||||
).replace(/\/+$/, "");
|
||||
const token =
|
||||
|
||||
+5
-4
@@ -4,10 +4,11 @@
|
||||
// `import()`s each plugin in-process, so a plugin's output is THIS process's stdout and the host's
|
||||
// `tracing` ring — the thing `GET /api/v1/logs` and the console's Logs page serve — never sees a
|
||||
// byte of it. On Linux the fallback was `journalctl --user -u punktfunk-scripting`; on Windows the
|
||||
// runner scheduled task writes no log file AT ALL, so a failing plugin could only be diagnosed by
|
||||
// stopping the task and re-running the runner by hand. Both need shell access on the host box,
|
||||
// which is the exact thing the console exists to avoid. A user hitting a plugin misconfiguration
|
||||
// therefore had no way to see the error explaining it.
|
||||
// runner scheduled task wrote no log file at all (it does now — `scripting-run.cmd` tees to
|
||||
// `%ProgramData%\punktfunk\plugin-state\runner.log`, because THIS door needs the very connection
|
||||
// whose failure you'd be reading about; field report 2026-08-18). Both need shell access on the
|
||||
// host box, which is the exact thing the console exists to avoid. A user hitting a plugin
|
||||
// misconfiguration therefore had no way to see the error explaining it.
|
||||
//
|
||||
// So: tee every console line to `POST /api/v1/plugins/logs`, which lands it in the host's ring
|
||||
// alongside the host's own lines under the target `plugin:<source>`.
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
// plugin store (crates/punktfunk-host/src/store), which installs one reviewed version of a
|
||||
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
|
||||
import { Effect, Fiber } from "effect";
|
||||
import { publishedMgmtUrl } from "./config.js";
|
||||
import { installLogShipper } from "./log-ship.js";
|
||||
import {
|
||||
addPlugins,
|
||||
@@ -164,6 +165,16 @@ if (process.argv.includes("--list")) {
|
||||
// showing a bare `clock_gettime` loop and nothing else. One idle handle is the whole fix.
|
||||
const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
|
||||
|
||||
// Follow the host's REAL mgmt port before anything dials it. Plugins run in this process and
|
||||
// resolve their connection from `process.env` first, so setting it here reaches every plugin —
|
||||
// including one whose vendored `@punktfunk/host` predates `publishedMgmtUrl` (on Windows
|
||||
// `reconcileSharedSdk` cannot refresh a read-only tree, so an old copy can outlive several host
|
||||
// upgrades). An explicit PUNKTFUNK_MGMT_URL from the operator still wins.
|
||||
if (!process.env.PUNKTFUNK_MGMT_URL) {
|
||||
const published = publishedMgmtUrl();
|
||||
if (published) process.env.PUNKTFUNK_MGMT_URL = published;
|
||||
}
|
||||
|
||||
// Tee this process's output to the host so the console's Logs page can show it. Installed HERE and
|
||||
// not in `runner.ts`, so it covers the supervised run only: a plugin's own CLI builds the same
|
||||
// layer graph, and an operator running `punktfunk-plugin-x doctor` in their terminal is not asking
|
||||
|
||||
+60
-1
@@ -1,8 +1,15 @@
|
||||
// Connection/config resolution helpers. `pluginStateDir` is the writable location a supervised
|
||||
// plugin persists into — the one dir the de-privileged Windows runner may write.
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { pluginIngestDir, pluginStateDir } from "../src/config.js";
|
||||
import {
|
||||
pluginIngestDir,
|
||||
pluginStateDir,
|
||||
publishedMgmtUrl,
|
||||
resolveConfig,
|
||||
} from "../src/config.js";
|
||||
|
||||
describe("pluginStateDir", () => {
|
||||
let saved: string | undefined;
|
||||
@@ -48,3 +55,55 @@ describe("pluginIngestDir", () => {
|
||||
expect(pluginIngestDir("playnite")).not.toBe(pluginStateDir("playnite"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishedMgmtUrl / resolveConfig url", () => {
|
||||
let saved: Record<string, string | undefined>;
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
saved = {
|
||||
PUNKTFUNK_CONFIG_DIR: process.env.PUNKTFUNK_CONFIG_DIR,
|
||||
PUNKTFUNK_MGMT_URL: process.env.PUNKTFUNK_MGMT_URL,
|
||||
PUNKTFUNK_MGMT_TOKEN: process.env.PUNKTFUNK_MGMT_TOKEN,
|
||||
};
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-endpoint-"));
|
||||
process.env.PUNKTFUNK_CONFIG_DIR = dir;
|
||||
delete process.env.PUNKTFUNK_MGMT_URL;
|
||||
process.env.PUNKTFUNK_MGMT_TOKEN = "t"; // resolveConfig needs SOME token
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("absent file → undefined, and resolveConfig keeps the 47990 default", async () => {
|
||||
expect(publishedMgmtUrl()).toBeUndefined();
|
||||
expect((await resolveConfig()).url).toBe("https://127.0.0.1:47990");
|
||||
});
|
||||
|
||||
test("the host's mgmt-endpoint line is followed — a moved port reaches every plugin", async () => {
|
||||
// exactly what `mgmt::endpoint_line` writes (KEY=VALUE, one line)
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "mgmt-endpoint"),
|
||||
"PUNKTFUNK_MGMT_URL=https://127.0.0.1:47995\n",
|
||||
);
|
||||
expect(publishedMgmtUrl()).toBe("https://127.0.0.1:47995");
|
||||
expect((await resolveConfig()).url).toBe("https://127.0.0.1:47995");
|
||||
});
|
||||
|
||||
test("an explicit PUNKTFUNK_MGMT_URL still wins over the published file", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "mgmt-endpoint"),
|
||||
"PUNKTFUNK_MGMT_URL=https://127.0.0.1:47995\n",
|
||||
);
|
||||
process.env.PUNKTFUNK_MGMT_URL = "https://127.0.0.1:50000/";
|
||||
expect((await resolveConfig()).url).toBe("https://127.0.0.1:50000");
|
||||
});
|
||||
|
||||
test("a blank file reads as unset, not as an empty URL", () => {
|
||||
fs.writeFileSync(path.join(dir, "mgmt-endpoint"), "\n");
|
||||
expect(publishedMgmtUrl()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -447,7 +447,7 @@
|
||||
"logs_export_all": "Alles exportieren",
|
||||
"logs_export_all_working": "Wird gesammelt…",
|
||||
"logs_export_all_failed": "Export konnte nicht erstellt werden",
|
||||
"logs_empty_plugins": "Noch keine Plugin-Ausgabe. Plugins loggen hier, sobald der Plugin-Runner läuft — prüfe `punktfunk-host plugins status`.",
|
||||
"logs_empty_plugins": "Noch keine Plugin-Ausgabe. Plugins loggen hier, sobald der Plugin-Runner läuft — prüfe `punktfunk-host plugins status`. Läuft er und bleibt trotzdem stumm, erreicht er den Host nicht; warum, steht im eigenen Log des Runners: `journalctl --user -u punktfunk-scripting` unter Linux, `%ProgramData%\\punktfunk\\plugin-state\\runner.log` unter Windows.",
|
||||
"logs_follow": "Folgen",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Leeren",
|
||||
|
||||
@@ -447,7 +447,7 @@
|
||||
"logs_export_all": "Export all",
|
||||
"logs_export_all_working": "Collecting…",
|
||||
"logs_export_all_failed": "Couldn't assemble the export",
|
||||
"logs_empty_plugins": "No plugin output yet. Plugins log here once the plugin runner is running — check `punktfunk-host plugins status`.",
|
||||
"logs_empty_plugins": "No plugin output yet. Plugins log here once the plugin runner is running — check `punktfunk-host plugins status`. If it is running and still silent, it can't reach the host; the runner's own log says why: `journalctl --user -u punktfunk-scripting` on Linux, `%ProgramData%\\punktfunk\\plugin-state\\runner.log` on Windows.",
|
||||
"logs_follow": "Follow",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Clear",
|
||||
|
||||
Reference in New Issue
Block a user