diff --git a/crates/punktfunk-host/src/windows/install.rs b/crates/punktfunk-host/src/windows/install.rs index a52749b1..40925567 100644 --- a/crates/punktfunk-host/src/windows/install.rs +++ b/crates/punktfunk-host/src/windows/install.rs @@ -431,16 +431,69 @@ fn web_setup(args: &[String]) -> Result<()> { ) { eprintln!("warning: could not add the firewall rule for TCP 47992"); } - // 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise) - for _ in 0..30 { - if token_path.exists() { - break; + // 5. Wait for EVERY file the launcher needs, then start the task and VERIFY it came up. + // + // `web-run.cmd` refuses to serve without the mgmt token AND the host identity cert, and the + // host writes them in that order: the token during argument parsing (`main::parse_serve`, + // milliseconds after `serve` starts), the cert only once `serve` reaches + // `gamestream::cert::ServerIdentity::load_or_create` - behind a pure-Rust RSA-2048 keygen + // whose prime search has a multi-second tail. Gating on the token alone therefore fired + // `schtasks /run` while that keygen was still running: the launcher exited 1, and since the + // task carries no trigger other than boot (and Task Scheduler's restart-on-failure does not + // reliably fire for a plain non-zero exit code), a freshly installed console stayed down + // until the next reboot. Gate on `cert.pem` - written LAST, after `key.pem` - so all three + // files are on disk before the first start. + let cert_path = data_dir.join("cert.pem"); + if !wait_for_files(&[token_path.as_path(), cert_path.as_path()], 90) { + eprintln!( + "warning: the host service has not written {} + {} yet - not starting the console now; \ + it will start at the next boot (or run: schtasks /run /tn {WEB_TASK})", + token_path.display(), + cert_path.display() + ); + println!("web console set up (https://:47992)"); + return Ok(()); + } + if start_web_task() { + println!("web console set up + started (https://:47992)"); + } else { + // Never claim "started" when it isn't: the launcher's own diagnostics go to a task with no + // console, so this warning is the only trace an operator gets at install time. + eprintln!( + "warning: the {WEB_TASK} task did not bring up a listener on :47992 - check its Last Run \ + Result in Task Scheduler; the console will be retried at the next boot" + ); + println!("web console set up (https://:47992)"); + } + Ok(()) +} + +/// Poll until every path exists, or `secs` elapse. Returns whether they all showed up. +fn wait_for_files(paths: &[&Path], secs: u64) -> bool { + for _ in 0..secs { + if paths.iter().all(|p| p.exists()) { + return true; } std::thread::sleep(std::time::Duration::from_secs(1)); } - run_quiet("schtasks", &["/run", "/tn", WEB_TASK]); - println!("web console set up + started (https://:47992)"); - Ok(()) + paths.iter().all(|p| p.exists()) +} + +/// `schtasks /run` + verify: the command reports only that a start was *attempted*, so poll for the +/// console's own listener and retry a couple of times before giving up. Returns whether :47992 ended +/// up listening. +fn start_web_task() -> bool { + for _ in 0..3 { + run_quiet("schtasks", &["/run", "/tn", WEB_TASK]); + // Bun binds the port a second or two after launch on a warm box; allow for a cold one. + for _ in 0..10 { + std::thread::sleep(std::time::Duration::from_secs(1)); + if !web_listener_pids().is_empty() { + return true; + } + } + } + false } /// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback. @@ -505,30 +558,57 @@ fn random_password() -> String { /// ("LISTENING"/"ABHOEREN"/...) is never parsed. fn stop_web_console() { run_quiet("schtasks", &["/end", "/tn", WEB_TASK]); - for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() { - let toks: Vec<&str> = line.split_whitespace().collect(); - if toks.len() >= 5 - && toks[0].eq_ignore_ascii_case("tcp") - && toks[1].ends_with(":47992") - && (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0") - { - let pid = toks[toks.len() - 1]; - if !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()) { - run_quiet("taskkill", &["/PID", pid, "/F"]); - } - } + for pid in web_listener_pids() { + run_quiet("taskkill", &["/PID", &pid, "/F"]); } std::thread::sleep(std::time::Duration::from_secs(1)); } -/// Register the boot/SYSTEM/restart-on-failure task via a generated Task Scheduler XML (`schtasks /xml`, -/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM. +/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`). +fn web_listener_pids() -> Vec { + run_capture("netstat", &["-ano", "-p", "tcp"]) + .lines() + .filter_map(|line| { + let toks: Vec<&str> = line.split_whitespace().collect(); + let listening = toks.len() >= 5 + && toks[0].eq_ignore_ascii_case("tcp") + && toks[1].ends_with(":47992") + && (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0"); + let pid = *toks.last()?; + (listening && !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit())) + .then(|| pid.to_string()) + }) + .collect() +} + +/// Register the boot+logon/SYSTEM/restart-on-failure task via a generated Task Scheduler XML +/// (`schtasks /xml`, no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM. +/// +/// Two triggers, because boot alone left too many holes: a console that lost the install-time start +/// (or a box where the host service was started only later) had to wait for a full reboot. Logon is +/// free insurance - `MultipleInstancesPolicy=IgnoreNew` makes a redundant start a no-op. If a +/// Task Scheduler build rejects the two-trigger XML we fall back to the boot-only form rather than +/// fail registration outright (no task at all is strictly worse than a boot-only task). fn register_web_task(cmd: &Path) -> Result<()> { + let cmd_xml = xml_escape(&cmd.to_string_lossy()); + let boot = "true"; + let boot_and_logon = "true\ + true"; + if try_register_web_task(&cmd_xml, boot_and_logon) || try_register_web_task(&cmd_xml, boot) { + println!("registered scheduled task {WEB_TASK} -> {}", cmd.display()); + Ok(()) + } else { + bail!("schtasks /create {WEB_TASK} failed") + } +} + +/// One `schtasks /create /xml` attempt with the given `` body. Returns whether it took. +fn try_register_web_task(cmd_xml: &str, triggers: &str) -> bool { let xml = format!( "\n\ \n\ punktfunk web management console (Nitro SSR on bun, :47992)\n\ - true\n\ + {triggers}\n\ S-1-5-18HighestAvailable\n\ \n\ IgnoreNew\n\ @@ -538,12 +618,13 @@ fn register_web_task(cmd: &Path) -> Result<()> { PT0S\n\ PT1M10\n\ \n\ - {}\n\ -", - xml_escape(&cmd.to_string_lossy()) + {cmd_xml}\n\ +" ); let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml"); - write_utf16le_bom(&xml_path, &xml)?; + if write_utf16le_bom(&xml_path, &xml).is_err() { + return false; + } let ok = run_quiet( "schtasks", &[ @@ -556,12 +637,7 @@ fn register_web_task(cmd: &Path) -> Result<()> { ], ); let _ = std::fs::remove_file(&xml_path); - if ok { - println!("registered scheduled task {WEB_TASK} -> {}", cmd.display()); - Ok(()) - } else { - bail!("schtasks /create {WEB_TASK} failed") - } + ok } fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> { diff --git a/docs-site/content/docs/windows-host.md b/docs-site/content/docs/windows-host.md index efec1878..4ad9fd74 100644 --- a/docs-site/content/docs/windows-host.md +++ b/docs-site/content/docs/windows-host.md @@ -66,7 +66,7 @@ the Windows specifics follow. The installer also sets up the **web management console** (status, paired devices, the PIN pairing flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on -**`https://:47992`**, starting at boot. +**`https://:47992`**, starting at boot and at sign-in. #### Console login password diff --git a/scripts/punktfunk-web.service b/scripts/punktfunk-web.service index cf382839..9843baac 100644 --- a/scripts/punktfunk-web.service +++ b/scripts/punktfunk-web.service @@ -12,6 +12,11 @@ Description=punktfunk management web console # web-init generates the login password; the host writes the mgmt token. Order after both. After=punktfunk-web-init.service punktfunk-host.service Wants=punktfunk-web-init.service +# Retry indefinitely while the host is still writing the mgmt token + identity cert. Without this, +# systemd's default rate limit (5 starts / 10 s) plus RestartSec=2 gives up permanently after ~10 s +# - so a console enabled before the host's first run stayed dead until someone restarted it by hand +# (the same defect the Windows PunktfunkWeb task had). +StartLimitIntervalSec=0 [Service] Type=simple diff --git a/scripts/windows/web-run.cmd b/scripts/windows/web-run.cmd index aea16919..93c5f8a8 100644 --- a/scripts/windows/web-run.cmd +++ b/scripts/windows/web-run.cmd @@ -16,17 +16,26 @@ set "CERTFILE=%PFDATA%\cert.pem" set "KEYFILE=%PFDATA%\key.pem" rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist -rem we have no credential and no TLS material, so fail and let the task's restart-on-failure retry -rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than -rem silently downgrading to plain HTTP. -if not exist "%TOKENFILE%" ( - echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service. - exit /b 1 -) -if not exist "%CERTFILE%" ( - echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service. +rem we have no credential and no TLS material, so WAIT rather than silently downgrading to plain HTTP. +rem +rem Wait in-process instead of exiting 1 and hoping the task's restart-on-failure retries: Task +rem Scheduler does not reliably restart on a plain non-zero exit code, so a console that started +rem before the host finished writing those files (the token lands at argument parse, the cert only +rem after the RSA-2048 keygen) used to stay down until the next reboot. ~5 min at 2 s, then give up +rem so a genuinely broken install still surfaces as a failed task rather than one that runs forever. +rem `timeout` needs a console this task does not have, so `ping -n 3` is the 2-second sleep. +set /a PFWAITS=0 +:pfwait +if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready +if %PFWAITS% GEQ 150 ( + echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host service running? exit /b 1 ) +if %PFWAITS%==0 echo [punktfunk-web] waiting for the host service to write the mgmt token + identity cert... +set /a PFWAITS+=1 +ping -n 3 127.0.0.1 >nul 2>&1 +goto pfwait +:pfready rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment. diff --git a/web/web-run.cmd b/web/web-run.cmd index 410e7215..ec81bbb6 100644 --- a/web/web-run.cmd +++ b/web/web-run.cmd @@ -18,16 +18,21 @@ set "CERTFILE=%PFDATA%\cert.pem" set "KEYFILE=%PFDATA%\key.pem" rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy -rem has no credential and no TLS material, so fail and let restart-on-failure retry (mirrors the -rem installed launcher / Linux unit) rather than silently serving plain HTTP. -if not exist "%TOKENFILE%" ( - echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service. - exit /b 1 -) -if not exist "%CERTFILE%" ( - echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service. +rem has no credential and no TLS material, so WAIT for them (mirrors the installed launcher) rather +rem than silently serving plain HTTP - see scripts\windows\web-run.cmd for why waiting here beats +rem exiting 1 and relying on the task's restart-on-failure. ~5 min at 2 s, then give up. +set /a PFWAITS=0 +:pfwait +if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready +if %PFWAITS% GEQ 150 ( + echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host running? exit /b 1 ) +if %PFWAITS%==0 echo [punktfunk-web] waiting for the host to write the mgmt token + identity cert... +set /a PFWAITS+=1 +ping -n 3 127.0.0.1 >nul 2>&1 +goto pfwait +:pfready rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... . rem Split on the first '=' and import each into the environment.