Files
punktfunk/packaging/windows/README.md
T
enricobuehlerandClaude Opus 5 622afc2c32 docs(packaging/windows): fix the driver-cert runbook's RNG and secret scope
Get-Random is System.Random — not a cryptographic RNG, and it had no business
generating the passphrase protecting a signing key. Uses RandomNumberGenerator
instead (.Create()/.GetBytes() spelling works on both PS 5.1 and PS 7).

Scope corrected to repo-level, matching MSIX_CERT_PFX_B64 next door: only
unom/punktfunk builds drivers, so there is nothing for an org-level secret to
reach. RPM_GPG_PRIVATE_KEY is org-level because other repos publish RPMs.

Also records where to run it — interactive logon only (New-SelfSignedCertificate
fails NTE_PERM over SSH, no key container on a network logon), and not on the CI
runner, which is the one machine that executes build code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00

255 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Windows host packaging — signed Inno Setup installer
A one-file, signed `setup.exe` for the punktfunk streaming **host** on Windows, published to Gitea's
generic package registry (`punktfunk-host-windows`) by `.gitea/workflows/windows-host.yml`.
> Full picture (drivers-from-source, toolchain, CI, dev loop): **punktfunk-planning: `windows-build-and-packaging.md`** (internal planning repo). This README is the `packaging/windows/` file index.
## Windows 11 22H2+ only (no Windows 10)
The installer refuses anything below **Windows 11 22H2 (build 22621)**`MinVersion=10.0.22621` in
`punktfunk-host.iss`, with a `[Messages]` override naming the requirement. The floor comes from the
**pf-vdisplay** driver: it is built against the **IddCx 1.10** class extension (the HDR `*2` DDIs +
the FP16 adapter cap, linked via the 1.10 `IddCxStub`, no runtime `IddCxGetVersion` downgrade), and
IddCx 1.10 first shipped in Windows 11 22H2. On older Windows — **all of Windows 10 including LTSC,
and Windows 11 21H2** — the driver *package* installs fine, but the device then fails to start with
**Code 10 `STATUS_DEVICE_POWER_FAILURE`** in Device Manager and every session dies with "pf-vdisplay
driver interface not found". Gating the installer turns that late, confusing failure into an upfront
message. (Down-level SDR-only support would need a runtime IddCx version check in the driver —
tracked as a possible future feature, not planned.)
## x64 only (no ARM64)
Unlike the client (which ships x64 + ARM64 MSIX), the host is **x64-only by design**. It is coupled to
an NVIDIA GPU (NVENC, via `nvEncodeAPI64.dll` from the driver) and the **pf-vdisplay** virtual-display
driver — neither exists on Windows ARM64 (no ARM64 NVIDIA driver; the driver builds x64-only). An
ARM64 host would install but couldn't encode or create a virtual display, so we don't build one.
Revisit if NVIDIA-ARM Windows PCs ever ship.
## Why not MSIX (like the client)
The host installs a **`LocalSystem` SCM service** that `CreateProcessAsUserW`'s from Session 0 into the
interactive session for secure-desktop (UAC / lock screen) capture, adds firewall rules, and depends
on the **pf-vdisplay** UMDF/IDD virtual-display driver. MSIX's sandbox can install **neither** a SYSTEM
service of this kind **nor** a driver. So the host ships as a classic elevated installer.
The installer is deliberately thin: the real install logic lives in `punktfunk-host` subcommands, not
in PowerShell — `service install` (SCM registration, firewall rules, the default `host.env`, the
SYSTEM→interactive-session supervisor; `service.rs`), `driver install [--gamepad]` and `web setup`
(driver/console provisioning; `windows/install.rs`). The installer lays the exe into
`C:\Program Files\punktfunk\` and calls those subcommands elevated. Keeping the logic in the compiled
exe — not a `.ps1` *file* PowerShell reads in the machine codepage — is the fix for the ANSI-codepage
parse breakage that silently failed installs on non-English boxes.
## What the installer does
- Installs `punktfunk-host.exe` (+ `host.env.example`, this README) to `{app}` (`C:\Program Files\punktfunk`).
- **Optional task** *Install the pf-vdisplay virtual display driver*`punktfunk-host.exe driver install`
imports the driver's self-signed cert (machine `Root` + `TrustedPublisher`), creates the
`root\pf_vdisplay` device node (only if absent, via nefconc — never devgen), and stages the driver with
`pnputil /add-driver /install`.
Best-effort: a driver failure warns but never aborts the install (the host degrades to a physical
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`/`-BunExe`, which the CI always is):
lays down the built **self-contained** `.output` server (Nitro `noExternals` — deps bundled +
tree-shaken, ~75 files, no `node_modules`) + a portable **bun**, prompts for a console login
password (pre-filled with a secure random default, shown again on the final page; kept on upgrade),
then `punktfunk-host.exe web setup` writes the ACL'd `%ProgramData%\punktfunk\web-password`, registers the
**`PunktfunkWeb`** scheduled task (boot, SYSTEM, restart-on-failure → `web-run.cmd``bun` on
`:47992`), opens TCP 47992, and starts it. It proxies the host's loopback mgmt API with the host's
own `%ProgramData%\punktfunk\mgmt-token`.
- **GameStream (Moonlight) compatibility is a wizard task** (**unchecked** by default — it pairs over
plain HTTP, so it is opt-in like the Public-firewall task): the choice is passed to
`service install --gamestream=on|off`, which writes `PUNKTFUNK_HOST_CMD=serve --gamestream` (or
`serve`, the secure native-only host) into `host.env`. Unattended, add it with
`/MERGETASKS=gamestream`. Upgrade-safe: a hand-customized `PUNKTFUNK_HOST_CMD` is never
overwritten, and on an upgrade the task is inert entirely (the flag is omitted, so `host.env`
keeps whatever it already says) — change an existing host with
`punktfunk-host service install --gamestream=on|off` plus a service restart.
- **Branded, modern wizard**: `WizardStyle=modern dynamic windows11` (Inno ≥ 6.6 — Windows-11-style
controls following the system light/dark theme; pre-6.6 compilers fall back to plain `modern`), with
the punktfunk lens mark on the side panel / header tile and a multi-size `punktfunk.ico`
(`SetupIconFile` + the Apps & features entry). Assets are generated **and committed** by
`branding/gen-branding.ps1` from the canonical brand geometry (`web/src/components/brand-mark.tsx`);
re-run it only when the brand changes.
- **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;
the existing console password is kept (the wizard page is skipped).
- **Uninstall** (Add/Remove Programs): runs `service uninstall` (stop + delete service + remove
firewall rules), removes the `PunktfunkWeb` task + its firewall rule, then `driver uninstall` (+
`--gamepad`) removes the punktfunk virtual-device drivers — the pf-vdisplay device node(s) and the
pf-vdisplay / pf-gamepad / pf-xusb driver-store packages (the field report was that they survived
uninstall). **VB-CABLE is intentionally NOT removed** (a third-party shared component the user may
use elsewhere — its own uninstaller is `VBCABLE_Setup_x64.exe -u -h`); the `%ProgramData%\punktfunk`
config (incl. `web-password`) is also left in place.
Silent install: `punktfunk-host-setup-<ver>.exe /VERYSILENT` (omit the driver with
`/MERGETASKS="!installdriver"`; disable Moonlight compat with `/MERGETASKS="!gamestream"`). A silent
fresh install uses the generated random console password — read it from
`%ProgramData%\punktfunk\web-password`.
## Prerequisites on the target box
- A **GPU for hardware encode**: an NVIDIA GPU + driver (NVENC), an AMD GPU (native AMF), or an
Intel GPU (native QSV via the statically linked VPL dispatcher; the runtime ships in the Intel
driver) — the CI exe is built `--features nvenc,amf-qsv,qsv`. Software H.264 is the GPU-less
fallback.
- **Virtual gamepads need no prerequisite.** The DualSense / DualShock 4 / Xbox 360 (XUSB) UMDF drivers
are **bundled** in the installer (the *Install the virtual gamepad drivers* task) and
`pnputil`-installed. **ViGEmBus is no longer used.**
- **The streaming microphone uses VB-CABLE**, bundled + silently installed by the installer (the *Install
VB-CABLE virtual audio* task). The host writes the client's mic into VB-CABLE's input; its `CABLE
Output` capture endpoint surfaces as a host mic. A Windows audio device can only be created by a
**kernel-mode** driver (no UMDF path exists), so unlike our self-signed UMDF drivers we cannot ship our
own — VB-CABLE is a vendor-signed cable that loads with no test-signing. It is **donationware** by
VB-Audio, redistributed under VB-Audio's bundling grant (only the single base cable) — the grant
requires the end user to see VB-CABLE's origin + donationware status, which the wizard task text and
`licenses/VB-CABLE-NOTICE.txt` surface. The package binary is **not** in the repo — CI provisions the
**pinned, SHA-256-verified official package** onto the runner (`scripts/ci/provision-windows-punktfunk-extras.ps1`
`C:\Users\Public\vbcable`) and `windows-host.yml` passes it via `$env:VBCABLE_DIR`, so **published
installers always bundle it**; locally supply `-VbCableDir` / `$env:VBCABLE_DIR` (the extracted
official package, containing `VBCABLE_Setup_x64.exe`). Unset → the installer is built without it and
the host falls back to auto-installing the Steam Streaming pair; set-but-invalid → the pack **fails**
(a broken provisioning must not silently ship a mic-less installer again). *(Endgame:
attestation-sign our own MIT virtual-audio driver to drop this dependency.)*
## Files here
| File | Role |
|------|------|
| `punktfunk-host.iss` | Inno Setup script (the installer definition). |
| `branding/` | Wizard branding: `gen-branding.ps1` renders the brand mark into the committed `wizard-image-*.bmp` / `wizard-small-*.bmp` (100200% DPI) + `punktfunk.ico`. Re-run only on a brand change. |
| `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + VB-CABLE + the **web console** (`.output` + bun) + the HDR layer + branding, run ISCC, sign setup.exe. |
| `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. |
| `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-gamepad` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. |
| `install-vbcable.ps1` | On-target: seed VB-Audio's cert into `TrustedPublisher`, silently install the bundled VB-CABLE (`-i -h`). Run by the installer's *Install VB-CABLE virtual audio* task; idempotent + always exits 0 (non-fatal). |
| `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). |
| `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. |
| `../../scripts/windows/web-run.cmd` | The `PunktfunkWeb` task action: loads the mgmt token + login password env, runs the bundled `bun` on the Nitro server (`:47992`). |
| `drivers/` | The all-Rust IddCx **driver source** workspace: the `pf-vdisplay` crate on `wdk-sys` / windows-drivers-rs + the owned `pf-driver-proto` ABI + `wdk-iddcx` / `wdk-probe`, plus `deploy-dev.ps1` (build/sign/install for dev). |
| `reset-pf-vdisplay.ps1` | **Dev:** recover a wedged driver — stop host → reap ghost monitor nodes → reload the adapter → start host (no reboot). See *Dev iteration* below. |
| `redeploy-pf-vdisplay.ps1` | **Dev:** one-shot redeploy — (optional) build → stop host → `deploy-dev.ps1 -Install` → reload adapter → start host. |
| `pf-vkhdr-layer/` | **HDR Vulkan layer** (standalone `cdylib`): lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the virtual display by advertising the HDR surface formats the NVIDIA/AMD ICDs hide on an indirect display. Built by the packer, laid into `{app}\vklayer`, registered under `HKLM64\…\Khronos\Vulkan\ImplicitLayers` (opt-out *Install the HDR Vulkan layer* task). Self-gated on the display's HDR state. See its README. |
> **Drivers are built from source, not vendored.** All three (pf-vdisplay + the gamepad pf-gamepad /
> pf-xusb) are members of the all-Rust `drivers/` workspace (windows-drivers-rs / IddCx) and are
> **rebuilt + signed every release** by `build-pf-vdisplay.ps1` + `build-gamepad-drivers.ps1` - the
> checked-in prebuilt binaries were deleted (a stale `.cat` once stopped covering its `.inf` →
> `SPAPI_E_FILE_HASH_NOT_IN_CATALOG` on every box, and a frozen binary predated a driver IOCTL the host
> needed). Building from source keeps `.dll`/`.inf`/`.cat` in lockstep. nefcon (the device-node tool -
> the install creates the `root\pf_vdisplay` node with it, **never** `devgen`, which leaves persistent
> phantom devices) is fetched + SHA-256-verified from its pinned release in `stage-pf-vdisplay.ps1`. See
> punktfunk-planning: `windows-build-and-packaging.md` (internal planning repo) for the toolchain
> + signing details.
## Driver signing (`DRIVER_CERT_PFX_B64`)
Our three UMDF drivers are signed with a **stable self-signed code-signing cert**, subject
`CN=punktfunk-driver`, supplied to `build-pf-vdisplay.ps1` / `build-gamepad-drivers.ps1` as the
`DRIVER_CERT_PFX_B64` + `DRIVER_CERT_PASSWORD` Actions secrets. On a `v*` tag build a missing cert
is a **hard failure** (`-RequireSignedCert`, default `auto` off `GITHUB_REF`); canary and local
builds still fall back to a per-build throwaway.
**Current fingerprint (SHA-1 thumbprint):** `<fill in after generating — see below>`
Why stable matters here. The installer trusts the `.cer` that ships in the bundle
(`certutil -addstore -f Root` + `TrustedPublisher`, `crates/punktfunk-host/src/windows/install.rs`),
which is unavoidable for a self-signed cert — a self-signed leaf is its own root, so the chain only
validates if the root is present. That means the signature does **not** authenticate the download:
anyone who can alter the bundle can put their own cert next to their own driver. What a stable cert
buys is everything downstream of that: one anchor imported once instead of two more roots per
upgrade, a fingerprint we can publish out-of-band so a substituted driver is *detectable*, a
publisher an admin can allowlist, and continuity across releases. `driver install` purges stale
`CN=punktfunk-driver` certs before adding the current one, and `driver uninstall` removes them
entirely — including the pile left by the per-build-cert era.
> ⚠️ **The private key is now worth stealing.** It is trusted as a machine **root** on every
> punktfunk box, with code-signing EKU and no practical revocation path (nobody removes a stale
> root, and self-signed roots aren't in any CRL users honour). Keep it in the CI secret and nowhere
> else — not on a dev laptop. This is the trade for stability, and the reason attestation signing
> (which chains to Microsoft and needs no root import at all) remains the real fix.
Generating it — **run this yourself**, on a trusted Windows box, elevated:
```powershell
# 1. Create the cert. 10-year lifetime: an expired driver cert breaks INSTALLS on new machines, and
# rotating means every user picks up a new root anyway, so churn buys nothing here.
$c = New-SelfSignedCertificate -Type CodeSigningCert -Subject 'CN=punktfunk-driver' `
-CertStoreLocation Cert:\CurrentUser\My -KeyExportPolicy Exportable `
-NotAfter (Get-Date).AddYears(10)
$c.Thumbprint # <- publish this: paste into the "Current fingerprint" line above + SECURITY.md
# 2. Export it, protected by a strong random password. RandomNumberGenerator, NOT Get-Random —
# Get-Random is System.Random, which is not a cryptographic RNG and has no business generating
# the passphrase on a signing key. (.Create()/.GetBytes() works on both PS 5.1 and PS 7.)
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$bytes = [byte[]]::new(24); $rng.GetBytes($bytes); $pw = [Convert]::ToBase64String($bytes)
$sec = ConvertTo-SecureString $pw -AsPlainText -Force
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($c.Thumbprint)" -FilePath .\driver.pfx -Password $sec | Out-Null
[Convert]::ToBase64String([IO.File]::ReadAllBytes('.\driver.pfx')) | Set-Clipboard # -> DRIVER_CERT_PFX_B64
$pw # -> DRIVER_CERT_PASSWORD
# 3. Add BOTH as **repo**-level Gitea Actions secrets on unom/punktfunk — same scope as the
# MSIX_CERT_PFX_B64 signing cert next door, and only this repo builds drivers. (RPM_GPG_PRIVATE_KEY
# is org-level because other repos publish RPMs; nothing else needs this one.) Then destroy the
# local copies — the .pfx must not linger on the machine that generated it:
Remove-Item .\driver.pfx -Force
Remove-Item "Cert:\CurrentUser\My\$($c.Thumbprint)" -Force
```
Generate it on a machine you would trust with a signing key, at an **interactive** logon (physical
console or RDP). Over SSH, `New-SelfSignedCertificate` fails with `NTE_PERM 0x80090010`: a network
logon has no access to the user's key container. Avoid generating it on the CI runner — that box
executes build code, which is the one place a signing key should not be sitting.
Keep an offline backup of the .pfx + password somewhere you'd keep a signing key. Losing it means
the next release ships a cert nobody has trusted before, and every user's installer adds a second
root — recoverable, but only by re-running the install.
## Dev iteration on the test box (driver)
Two helpers wrap the painful manual steps of iterating on the pf-vdisplay driver against a live host
service. Run **elevated**; both default to the `PunktfunkHost` service. (The `C:\t-goal1\...` probe
path below is the maintainer's test box — substitute your own `punktfunk-probe.exe` build.)
```powershell
# Recover a WEDGED driver. Symptom: every session fails with
# create virtual output: pf-vdisplay ADD ...: DeviceIoControl(0x222400): Element nicht gefunden (0x80070490)
# i.e. ERROR_NOT_FOUND — sustained ADD/REMOVE churn exhausted the IddCx monitor slots (ghost
# "Generic Monitor (punktfunk)" nodes pile up, target_ids climb). A host restart's CLEAR_ALL does NOT
# fix it; the driver instance must be reloaded. This clears the ghosts + cycles the adapter (no reboot —
# this box boots to Proxmox).
powershell -ExecutionPolicy Bypass -File reset-pf-vdisplay.ps1 -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe
# Redeploy a driver build cleanly (stop host → install with a strictly-increasing DriverVer → reload
# adapter → start host). -Build runs `cargo build` first, but ONLY from an MSVC dev shell
# (LIBCLANG_PATH + Version_Number=10.0.26100.0); otherwise build separately and omit -Build.
powershell -ExecutionPolicy Bypass -File redeploy-pf-vdisplay.ps1 -Build -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe
```
The driver should reclaim monitor slots on REMOVE so churn can't wedge it; until it does, `reset` is
the recovery. From a Linux box drive either over SSH, e.g.
`ssh user@box 'powershell -ExecutionPolicy Bypass -File C:\...\reset-pf-vdisplay.ps1'`.
## Build locally (Windows, MSVC + Windows SDK + Inno Setup)
```powershell
# 1. build the host (NVENC needs no import lib — its entry points are runtime-loaded; `qsv`
# statically links the vendored VPL dispatcher — needs cmake + a libclang, no FFmpeg)
cargo build --release -p punktfunk-host --features nvenc,qsv
# 2. pack (self-signed unless MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD are set; -NoDriver to skip pf-vdisplay)
pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetDir C:\t\release -OutDir C:\t\out
```
## Release
Push a `vX.Y.Z` tag — one tag releases every platform (see
[Release Channels](https://punktfunk.unom.io/docs/channels)). The workflow builds, signs, and
publishes `punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, refreshes the stable `latest/`
alias, and attaches the installer to the unified Gitea Release. Main pushes publish rolling
`<next-minor>.<run>` **canary** builds (base derived from the latest stable tag by
`scripts/ci/pf-version.ps1`) to the `canary/` alias.