diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index ec92d67..0c00a48 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -1,6 +1,7 @@ # Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic # package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled -# SudoVDA virtual-display driver) from one signed setup.exe. Runs on the self-hosted Windows runner +# SudoVDA virtual-display driver + the web management console, run by a scheduled task on a bundled +# Node) from one signed setup.exe. Runs on the self-hosted Windows runner # (host mode; scripts/ci/setup-windows-runner.ps1) — same MSVC/Windows-SDK/LLVM env as windows.yml. # # Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that @@ -35,7 +36,8 @@ on: - 'crates/punktfunk-host/**' - 'crates/punktfunk-core/**' - 'packaging/windows/**' - - 'scripts/windows/host.env.example' + - 'scripts/windows/**' + - 'web/**' - 'Cargo.lock' - 'Cargo.toml' - '.gitea/workflows/windows-host.yml' @@ -102,6 +104,54 @@ jobs: choco install innosetup -y --no-progress } + - name: Fetch portable Node runtime (bundled to run the console) + shell: pwsh + run: | + # The installer ships a self-contained node.exe so the web console runs with no system-Node + # prerequisite. Pinned LTS (>= 20, matching the punktfunk-web .deb's `nodejs (>= 20)`); the + # smoke test below validates the .output bundle runs under exactly this node before shipping. + $ver = 'v22.11.0' + $url = "https://nodejs.org/dist/$ver/node-$ver-win-x64.zip" + New-Item -ItemType Directory -Force -Path C:\t | Out-Null + $zip = 'C:\t\node.zip'; $dst = 'C:\t\nodedist' + Invoke-WebRequest -Uri $url -OutFile $zip + if (Test-Path $dst) { Remove-Item $dst -Recurse -Force } + Expand-Archive -Path $zip -DestinationPath $dst -Force + $node = (Get-ChildItem -Path $dst -Recurse -Filter node.exe | Select-Object -First 1).FullName + if (-not $node) { throw "node.exe not found in $url" } + "NODE_EXE=$node" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + & $node --version + + - name: Build + smoke-boot web console (node-server preset) + shell: pwsh + # Same shape as deb.yml: bun builds the Nitro node-server bundle, node runs it. The installer + # then bundles web\.output (handed over via WEB_OUTPUT_DIR) + the node above. bun is on the + # runner (per build-web.ps1); @unom packages resolve via bun.lock + web\.npmrc against the + # runner's Gitea access. + run: | + $bun = if (Get-Command bun -ErrorAction SilentlyContinue) { (Get-Command bun).Source } else { 'C:\Users\Public\bun\bin\bun.exe' } + if (-not (Test-Path $bun) -and -not (Get-Command $bun -ErrorAction SilentlyContinue)) { throw "bun not found (needed to build the web console)" } + Push-Location web + & $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" } + & $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" } + if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) { + throw "web build is a bun bundle (Bun.serve) - need the node-server preset" + } + # Externalized @unom SSR deps must be installed inside .output\server (with the registry .npmrc). + Copy-Item .npmrc .output\server\.npmrc -Force + Push-Location .output\server; & $bun install; if ($LASTEXITCODE) { throw ".output/server dep install failed ($LASTEXITCODE)" }; Pop-Location + Pop-Location + # Gate the installer on a real node boot serving /login (the runtime the installer ships). + $env:PORT = '3009'; $env:HOST = '127.0.0.1'; $env:PUNKTFUNK_UI_PASSWORD = 'ci' + $server = (Resolve-Path 'web\.output\server\index.mjs').Path + $p = Start-Process -FilePath $env:NODE_EXE -ArgumentList $server -PassThru -WindowStyle Hidden + Start-Sleep -Seconds 4 + try { $code = (Invoke-WebRequest -Uri 'http://127.0.0.1:3009/login' -UseBasicParsing -TimeoutSec 10).StatusCode } catch { $code = 0 } + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + Write-Output "web console smoke: /login -> $code" + if ($code -ne 200) { throw "web console failed to boot under node" } + "WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - name: Pack + sign installer shell: pwsh env: diff --git a/docs-site/content/docs/windows-host.md b/docs-site/content/docs/windows-host.md index c0e5109..1d271f1 100644 --- a/docs-site/content/docs/windows-host.md +++ b/docs-site/content/docs/windows-host.md @@ -32,6 +32,13 @@ and the CLI `punktfunk-host service install` path) are in [Running as a Service → Windows](/docs/running-as-a-service#windows); packaging internals live in [`packaging/windows`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/windows/README.md). +The installer also sets up the **web management console** (status, paired devices, the PIN pairing +flow): it bundles the console plus its own Node runtime and runs it as the **`PunktfunkWeb`** service +on **`http://:3000`**, starting at boot. During setup you choose the console **login +password** (pre-filled with a secure random default and shown again on the final page); change it +later in `%ProgramData%\punktfunk\web-password`. Open the console from any browser on the LAN and log +in — no extra install, and the host's management API stays loopback-only behind it. + ## How it works The host installs a **`LocalSystem` SCM service** that runs from Session 0 and launches a worker into diff --git a/packaging/windows/README.md b/packaging/windows/README.md index fce703f..f537ef2 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -33,12 +33,23 @@ exe into `C:\Program Files\punktfunk\` and calls that subcommand, elevated. display without it). - Runs `punktfunk-host service install` (idempotent; writes a default `host.env` only if absent, so user config survives upgrades) and, by the *Start service now* task, `service start`. +- **Web management console** (bundled when packed with `-WebDir`/`-NodeExe`, which the CI always is): + lays down the built `.output` server + a portable Node, prompts for a console login password + (pre-filled with a secure random default, shown again on the final page; kept on upgrade), then + `web-setup.ps1` writes the ACL'd `%ProgramData%\punktfunk\web-password`, registers the + **`PunktfunkWeb`** scheduled task (boot, SYSTEM, restart-on-failure → `web-run.cmd` → `node` on + `:3000`), opens TCP 3000, and starts it. It proxies the host's loopback mgmt API with the host's + own `%ProgramData%\punktfunk\mgmt-token`. - **Upgrade:** stops a running `PunktfunkHost` service and waits for `STOPPED` before replacing files - (otherwise the locked exe / respawning supervisor would block the copy), then re-points the service. + (otherwise the locked exe / respawning supervisor would block the copy), then re-points the service; + the existing console password is kept (the wizard page is skipped). - **Uninstall** (Add/Remove Programs): runs `service uninstall` (stop + delete service + remove - firewall rules). The SudoVDA driver is intentionally left installed. + firewall rules) and removes the `PunktfunkWeb` task + its firewall rule. The SudoVDA driver and the + `%ProgramData%\punktfunk` config (incl. `web-password`) are intentionally left in place. -Silent install: `punktfunk-host-setup-.exe /VERYSILENT` (omit the driver with `/MERGETASKS="!installdriver"`). +Silent install: `punktfunk-host-setup-.exe /VERYSILENT` (omit the driver with +`/MERGETASKS="!installdriver"`). A silent fresh install uses the generated random console password — +read it from `%ProgramData%\punktfunk\web-password`. ## Prerequisites on the target box @@ -52,9 +63,11 @@ Silent install: `punktfunk-host-setup-.exe /VERYSILENT` (omit the driver wi | File | Role | |------|------| | `punktfunk-host.iss` | Inno Setup script (the installer definition). | -| `pack-host-installer.ps1` | Orchestrator: cert + sign, stage the driver bundle, run ISCC, sign setup.exe, emit registry paths. | +| `pack-host-installer.ps1` | Orchestrator: cert + sign, stage the driver + FFmpeg + **web console** (`.output` + node) bundles, run ISCC, sign setup.exe, emit registry paths. | | `stage-sudovda.ps1` | Stage the **vendored** SudoVDA driver + fetch/verify the **pinned** nefcon release into the bundle. | | `install-sudovda.ps1` | Runs at install time (elevated): trust cert → gated device-node create → `pnputil` install. | +| `../../scripts/windows/web-run.cmd` | The `PunktfunkWeb` task action: loads the mgmt token + login password env, runs the bundled `node` on the Nitro server (`:3000`). | +| `../../scripts/windows/web-setup.ps1` | Install-time (elevated): write the ACL'd console password, register the `PunktfunkWeb` task + firewall rule, start it. | | `sudovda/` | **Vendored** prebuilt SudoVDA driver: `SudoVDA.inf` / `sudovda.cat` / `SudoVDA.dll` / `sudovda.cer`. | | `nvenc/nvenc.def`, `nvenc/gen-nvenc-importlib.ps1` | Synthesise `nvencodeapi.lib` for the `--features nvenc` link (llvm-dlltool / lib.exe). | diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 index 25ad115..d06b087 100644 --- a/packaging/windows/pack-host-installer.ps1 +++ b/packaging/windows/pack-host-installer.ps1 @@ -26,6 +26,8 @@ param( [string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret [string]$PfxPassword = $env:MSIX_CERT_PASSWORD, [string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build) + [string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console + [string]$NodeExe = $env:NODE_EXE, # portable node.exe (>= 20) runtime for the console [switch]$NoDriver, # build without the bundled SudoVDA driver [switch]$NoSign # skip signing (local debug) ) @@ -182,6 +184,31 @@ if ($ffmpegBinSrc -and (Test-Path $ffmpegBinSrc)) { } else { Write-Host "no FFMPEG_DIR\bin -> installer built WITHOUT FFmpeg DLLs (nvenc/software-only host)" } +# --- stage the web management console (the built .output tree + a portable node + the launcher) --- +# The console runs as the PunktfunkWeb scheduled task (`node {app}\web\.output\server\index.mjs`), +# auto-wired to the host's loopback mgmt API. Stage everything ISCC reads into $OutDir (the +# non-WOW64-redirected C:\t area, same reason as the .iss/host.env staging above). Built upstream +# (windows-host.yml mirrors deb.yml: bun build -> node-server preset + the .output/server deps); +# omitted when -WebDir/-NodeExe are unset (host-only installer, e.g. a local debug pack). +if ($WebDir -and (Test-Path $WebDir) -and $NodeExe -and (Test-Path $NodeExe)) { + $webStage = Join-Path $OutDir 'web' + if (Test-Path $webStage) { Remove-Item $webStage -Recurse -Force } + New-Item -ItemType Directory -Force -Path $webStage | Out-Null + Copy-Item (Join-Path $WebDir '*') -Destination $webStage -Recurse -Force + $nodeStage = Join-Path $OutDir 'node.exe' + Copy-Item -LiteralPath $NodeExe -Destination $nodeStage -Force + $webRun = Join-Path $OutDir 'web-run.cmd' + $webSetup = Join-Path $OutDir 'web-setup.ps1' + Copy-Item (Join-Path $repoRoot 'scripts\windows\web-run.cmd') -Destination $webRun -Force + Copy-Item (Join-Path $repoRoot 'scripts\windows\web-setup.ps1') -Destination $webSetup -Force + $defines += "/DWebDir=$webStage" + $defines += "/DNodeExe=$nodeStage" + $defines += "/DWebRunCmd=$webRun" + $defines += "/DWebSetup=$webSetup" + Write-Host "bundling the web console from $WebDir (+ node $NodeExe)" +} +else { Write-Host "no -WebDir/-NodeExe -> installer built WITHOUT the web console" } + # --- build the installer (from the non-redirected copy under C:\t) ----------------------------- Write-Host "==> ISCC $($defines -join ' ') $issLocal" & $iscc @defines $issLocal diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index aabf102..e76d4f5 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -28,6 +28,14 @@ #ifndef Readme #define Readme "README.md" #endif +; The web console launcher (the PunktfunkWeb task action) + its post-install provisioner — committed +; scripts staged next to the .iss by pack-host-installer.ps1 (absolute paths passed in). +#ifndef WebRunCmd + #define WebRunCmd "..\..\scripts\windows\web-run.cmd" +#endif +#ifndef WebSetup + #define WebSetup "..\..\scripts\windows\web-setup.ps1" +#endif ; StageDir (the staged SudoVDA payload + nefconc.exe + install-sudovda.ps1) is optional. #ifdef StageDir #define WithDriver @@ -41,6 +49,13 @@ #ifdef FfmpegBin #define WithFfmpeg #endif +; WebDir (the built web .output tree) + NodeExe (a portable node.exe) are passed together by +; pack-host-installer.ps1 to bundle the management console. Both required → WithWeb. +#ifdef WebDir + #ifdef NodeExe + #define WithWeb + #endif +#endif [Setup] AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93} @@ -86,6 +101,16 @@ Source: "{#Readme}"; DestDir: "{app}"; DestName: "README.txt"; Flags: ignorevers ; only builds simply omit this block. Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion #endif +#ifdef WithWeb +; The web management console: the built Nitro/Node SSR bundle (.output = server + public assets) → +; {app}\web\.output, a portable Node runtime → {app}\node\node.exe, and the launcher the +; PunktfunkWeb task runs → {app}\web\web-run.cmd. web-setup.ps1 (the provisioner) goes to {tmp} and +; is removed after install. +Source: "{#WebDir}\*"; DestDir: "{app}\web\.output"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#NodeExe}"; DestDir: "{app}\node"; DestName: "node.exe"; Flags: ignoreversion +Source: "{#WebRunCmd}"; DestDir: "{app}\web"; DestName: "web-run.cmd"; Flags: ignoreversion +Source: "{#WebSetup}"; DestDir: "{tmp}"; DestName: "web-setup.ps1"; Flags: deleteafterinstall +#endif #ifdef WithDriver ; The driver payload + nefconc.exe + install-sudovda.ps1, extracted to {tmp} and removed after install. Source: "{#StageDir}\*"; DestDir: "{tmp}\sudovda"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installdriver @@ -114,11 +139,112 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service install"; WorkingDir: StatusMsg: "Registering the punktfunk host service..."; Flags: runhidden waituntilterminated Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "{app}"; \ StatusMsg: "Starting the punktfunk host service..."; Flags: runhidden waituntilterminated; Tasks: startservice +#ifdef WithWeb +; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd +; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure), +; open TCP 3000, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install. +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\web-setup.ps1"" {code:WebSetupParams}"; \ + StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated +#endif [UninstallRun] Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkHostServiceUninstall" +#ifdef WithWeb +; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config, +; like the host uninstall does). +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -Name 'PunktfunkWeb-TCP-3000' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \ + Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup" +#endif [Code] +#ifdef WithWeb +var + WebPwPage: TInputQueryWizardPage; + FreshWebInstall: Boolean; { captured at start — web-setup creates the file mid-run } + +function WebPasswordPath: String; +begin + Result := ExpandConstant('{commonappdata}\punktfunk\web-password'); +end; + +{ Pre-fill the console password field with a crypto-strong default (Inno has no RNG): a one-shot + PowerShell writes 12 random bytes as dashed hex; strip the dashes → a 24-char hex password. } +procedure GenerateRandomWebPassword(var Pw: String); +var + ResultCode: Integer; + TmpOut: String; + Lines: TArrayOfString; +begin + Pw := ''; + TmpOut := ExpandConstant('{tmp}\webpwgen.txt'); + if Exec('powershell.exe', + '-NoProfile -ExecutionPolicy Bypass -Command "' + + '$b=New-Object byte[] 12;' + + '([System.Security.Cryptography.RandomNumberGenerator]::Create()).GetBytes($b);' + + '[IO.File]::WriteAllText(' + '''' + TmpOut + '''' + ',[System.BitConverter]::ToString($b))"', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + if (ResultCode = 0) and LoadStringsFromFile(TmpOut, Lines) and (GetArrayLength(Lines) > 0) then + begin + Pw := Trim(Lines[0]); + StringChangeEx(Pw, '-', '', True); + end; + DeleteFile(TmpOut); + end; +end; + +procedure InitializeWizard; +var + DefaultPw: String; +begin + FreshWebInstall := not FileExists(WebPasswordPath); + WebPwPage := CreateInputQueryPage(wpSelectTasks, + 'Web console', 'Set the punktfunk web console login password', + 'The management console is served on http://this-computer:3000 and is login-gated. Keep the ' + + 'secure password generated below (it is shown again on the final page) or enter your own — you ' + + 'can change it later in %ProgramData%\punktfunk\web-password.'); + WebPwPage.Add('Console password:', False); { visible, so the admin can read the generated default } + DefaultPw := ''; + GenerateRandomWebPassword(DefaultPw); + WebPwPage.Values[0] := DefaultPw; +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + { On upgrade the password already exists — keep it, don't re-prompt. } + Result := (PageID = WebPwPage.ID) and (not FreshWebInstall); +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + if (CurPageID = WebPwPage.ID) and (Trim(WebPwPage.Values[0]) = '') then + begin + MsgBox('Please enter a web console password (it cannot be empty).', mbError, MB_OK); + Result := False; + end; +end; + +procedure CurPageChanged(CurPageID: Integer); +begin + if (CurPageID = wpFinished) and FreshWebInstall then + WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + #13#10#13#10 + + 'Web console: http://:3000' + #13#10 + + 'Login password: ' + Trim(WebPwPage.Values[0]); +end; + +function WebSetupParams(Param: String): String; +begin + { Pass the password to web-setup.ps1 via a temp file, not the cmdline (which lands in the install + log). Only on a fresh install — on upgrade web-setup keeps the existing file. } + Result := '-AppDir "' + ExpandConstant('{app}') + '"'; + if FreshWebInstall then + Result := Result + ' -PasswordFile "' + ExpandConstant('{tmp}\webpw.txt') + '"'; +end; +#endif + { On upgrade the running service locks punktfunk-host.exe (and the supervisor would respawn it from the OLD binary), so stop it and WAIT for STOPPED before files are copied. Best-effort; a fresh install is a no-op (the service doesn't exist yet). } @@ -138,5 +264,12 @@ end; procedure CurStepChanged(CurStep: TSetupStep); begin if CurStep = ssInstall then + begin StopHostServiceAndWait; +#ifdef WithWeb + { Stash the chosen password for web-setup.ps1 (fresh install only); {tmp} is auto-cleaned. } + if FreshWebInstall then + SaveStringToFile(ExpandConstant('{tmp}\webpw.txt'), Trim(WebPwPage.Values[0]), False); +#endif + end; end; diff --git a/scripts/windows/README.md b/scripts/windows/README.md index dda7518..0e5e381 100644 --- a/scripts/windows/README.md +++ b/scripts/windows/README.md @@ -32,14 +32,28 @@ Stops `PunktfunkHost`, backs up the current binary (`punktfunk-host.exe.bak`), b service on the new binary — **with automatic rollback** if the build fails or the new binary won't start. The service is down only for the build duration. -## Rebuild + restart the web console +## Web management console + +On an **installed** host (the `setup.exe`) the console is set up automatically — no manual steps. +The installer bundles the built `.output` server + a portable Node and runs +`scripts\windows\web-setup.ps1`, which registers the **`PunktfunkWeb`** scheduled task (at boot, as +SYSTEM, restart-on-failure) running `{app}\web\web-run.cmd` → `node …\.output\server\index.mjs` on +`:3000`, opens inbound TCP 3000, and writes the login password to +`%ProgramData%\punktfunk\web-password` (ACL'd to Administrators + SYSTEM). The mgmt bearer token it +proxies with is the host's own `%ProgramData%\punktfunk\mgmt-token`. Browse `http://:3000` +and log in with the password the installer shows on its final page. To change it, edit +`web-password` and re-run the task: `schtasks /run /tn PunktfunkWeb`. + +### Rebuild + restart the console (dev box) ```powershell powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1 ``` `bun install && bun run build`, installs the externalized server deps into `.output/server` -(with the `@unom` `.npmrc`), then restarts the `PunktfunkWeb` task and checks `:3000/login`. +(with the `@unom` `.npmrc`), then restarts the `PunktfunkWeb` task and checks `:3000/login`. Use +this to iterate on the console against an installed host — `web-setup.ps1` (or a fresh install) is +what creates the task in the first place. ## Typical flow after pulling new code diff --git a/scripts/windows/web-run.cmd b/scripts/windows/web-run.cmd new file mode 100644 index 0000000..f6fc262 --- /dev/null +++ b/scripts/windows/web-run.cmd @@ -0,0 +1,40 @@ +@echo off +rem punktfunk web console launcher — the action the PunktfunkWeb scheduled task runs at boot. +rem +rem Lays out next to the installed payload: {app}\web\web-run.cmd, {app}\web\.output\... and +rem {app}\node\node.exe (so %~dp0 = {app}\web\). Auto-wires the console the same way the Linux +rem systemd unit does: it sources the host's mgmt bearer token + the console login password from +rem %ProgramData%\punktfunk\, points the /api proxy at the host's loopback HTTPS mgmt API, and runs +rem the Nitro/Node server on :3000. No env editing on a packaged install. +setlocal EnableExtensions + +set "PFDATA=%ProgramData%\punktfunk" +set "TOKENFILE=%PFDATA%\mgmt-token" +set "PWFILE=%PFDATA%\web-password" + +rem The host's `serve` writes the mgmt token on first run. Until it exists the proxy has no +rem credential, so fail and let the task's restart-on-failure retry (mirrors the Linux unit's +rem Restart=on-failure waiting for the host to create it). +if not exist "%TOKENFILE%" ( + echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service. + exit /b 1 +) + +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. +for /f "usebackq tokens=1* delims==" %%A in ("%TOKENFILE%") do set "%%A=%%B" +if exist "%PWFILE%" for /f "usebackq tokens=1* delims==" %%A in ("%PWFILE%") do set "%%A=%%B" + +rem Fixed deployment wiring (the Windows analogue of scripts/punktfunk-web.service). +set "PORT=3000" +set "HOST=0.0.0.0" +set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990" +set "NODE_TLS_REJECT_UNAUTHORIZED=0" + +set "NODE=%~dp0..\node\node.exe" +set "SERVER=%~dp0.output\server\index.mjs" +if not exist "%NODE%" ( + echo [punktfunk-web] bundled node runtime missing at "%NODE%". + exit /b 1 +) +"%NODE%" "%SERVER%" diff --git a/scripts/windows/web-setup.ps1 b/scripts/windows/web-setup.ps1 new file mode 100644 index 0000000..74416f7 --- /dev/null +++ b/scripts/windows/web-setup.ps1 @@ -0,0 +1,93 @@ +<# + Provision the punktfunk web console after the host installer has laid down its payload + ({app}\web\.output, {app}\node\node.exe, {app}\web\web-run.cmd). Invoked elevated from the + installer's [Run] section; idempotent (safe to re-run on upgrade). + + 1. Sets the console login password file %ProgramData%\punktfunk\web-password + (PUNKTFUNK_UI_PASSWORD=...), ACL'd to Administrators + SYSTEM only: + - if -PasswordFile points at a non-empty temp file (a FRESH install collected one on the + wizard page), use that; + - else if the file already exists (UPGRADE), keep it untouched; + - else generate a random one (fallback, so the console never boots auth-misconfigured). + 2. Registers the PunktfunkWeb scheduled task: at boot, as SYSTEM/Highest, restart-on-failure, + no execution time limit (a long-running server), running {app}\web\web-run.cmd. + 3. Opens inbound TCP 3000 (the console port) on all profiles. + 4. Waits briefly for the host's mgmt token, then starts the task. + + The mgmt bearer token is NOT managed here — the host owns %ProgramData%\punktfunk\mgmt-token + (crates/punktfunk-host/src/mgmt_token.rs writes it on `serve`); web-run.cmd sources it. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$AppDir, # the installer's {app} + [string]$PasswordFile # temp file with the chosen password (fresh install) +) +$ErrorActionPreference = 'Stop' + +$TaskName = 'PunktfunkWeb' +$dataDir = Join-Path $env:ProgramData 'punktfunk' +$pwFile = Join-Path $dataDir 'web-password' +$tokenFile = Join-Path $dataDir 'mgmt-token' +New-Item -ItemType Directory -Force -Path $dataDir | Out-Null + +function New-RandomPassword { + # URL/shell-safe (no /+=) so it's a clean env-file value and cmd-token, like scripts/web-init.sh. + $bytes = New-Object byte[] 24 + ([System.Security.Cryptography.RandomNumberGenerator]::Create()).GetBytes($bytes) + $s = [Convert]::ToBase64String($bytes) -replace '[/+=]', '' + return $s.Substring(0, [Math]::Min(20, $s.Length)) +} + +# --- 1. login password ----------------------------------------------------------------------- +$password = $null +if ($PasswordFile -and (Test-Path -LiteralPath $PasswordFile)) { + $password = (Get-Content -LiteralPath $PasswordFile -Raw).Trim() +} +if (-not $password) { + if (Test-Path -LiteralPath $pwFile) { + Write-Host "keeping existing web console password ($pwFile)" + } + else { + $password = New-RandomPassword + Write-Host "no password supplied - generated a random web console password" + } +} +if ($password) { + # LF, no BOM (UTF8) so web-run.cmd's `for /f` reads a clean value. + [IO.File]::WriteAllText($pwFile, "PUNKTFUNK_UI_PASSWORD=$password`n") + # Lock it down: drop inheritance, grant only Administrators (S-1-5-32-544) + SYSTEM (S-1-5-18). + & icacls $pwFile /inheritance:r /grant:r '*S-1-5-32-544:F' '*S-1-5-18:F' | Out-Null +} + +# --- 2. PunktfunkWeb scheduled task ---------------------------------------------------------- +$cmd = Join-Path $AppDir 'web\web-run.cmd' +if (-not (Test-Path -LiteralPath $cmd)) { throw "web launcher missing: $cmd" } +$action = New-ScheduledTaskAction -Execute $cmd +$trigger = New-ScheduledTaskTrigger -AtStartup +$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +# RestartCount/Interval cover transient crashes + the brief post-install race before the host has +# written the mgmt token (web-run.cmd exits non-zero until then). No time limit: it's a server. +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -StartWhenAvailable -RestartInterval (New-TimeSpan -Minutes 1) -RestartCount 10 ` + -ExecutionTimeLimit (New-TimeSpan -Seconds 0) +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal ` + -Settings $settings -Description 'punktfunk web management console (Nitro/Node SSR on :3000)' ` + -Force | Out-Null +Write-Host "registered scheduled task $TaskName -> $cmd" + +# --- 3. firewall: inbound TCP 3000 ----------------------------------------------------------- +try { + $fwName = 'PunktfunkWeb-TCP-3000' + Get-NetFirewallRule -Name $fwName -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue + New-NetFirewallRule -Name $fwName -DisplayName 'punktfunk web console (TCP 3000)' ` + -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 -Profile Any | Out-Null + Write-Host "firewall: allowed inbound TCP 3000" +} +catch { Write-Warning "could not add the firewall rule for TCP 3000: $($_.Exception.Message)" } + +# --- 4. wait for the host's mgmt token, then start ------------------------------------------- +# The host service was installed+started just before this; give it a moment to write the token so +# the first start serves immediately (otherwise restart-on-failure picks it up within a minute). +for ($i = 0; $i -lt 30 -and -not (Test-Path -LiteralPath $tokenFile); $i++) { Start-Sleep -Seconds 1 } +Start-ScheduledTask -TaskName $TaskName +Write-Host "started $TaskName (console on http://:3000)"