Files
punktfunk/packaging/winget/server/README.md
enricobuehler b79ff45bd1 feat(windows): sign via Azure Artifact Signing — a 3-day leaf makes timestamping mandatory
Releases move from the self-signed CN=unom cert to Azure Artifact Signing (formerly Trusted
Signing): account `unomsigning`, profile `unom-io`, signed by the `punktfunk-ci-signing` service
principal, which holds only the Artifact Signing Certificate Profile Signer role scoped to that one
profile. Both pack scripts gain the backend ahead of the existing .pfx and ephemeral fallbacks, so
canary and fork builds are unaffected.

Three things that are easy to get wrong, and are handled here rather than discovered in the field:

Azure mints a leaf certificate per signing request that expires in about three days. Both scripts
previously retried WITHOUT a timestamp when a timestamped sign failed — under Azure that ships an
artifact which verifies on the runner and goes untrusted days later, on every user's machine at
once. The retry is now gated on the mode: still lenient for a .pfx whose cert outlives the release,
a hard failure for Azure.

The MSIX manifest Publisher must equal the signer subject byte-for-byte, because package identity is
Name + Publisher. The default is now the profile's verified subject, written with `[char]0xFC`
escapes rather than literal umlauts so this UTF-8-without-BOM file cannot silently mojibake the DN
into one that no longer matches. pack-msix.ps1 now also reads the signature back off the packed
.msix and fails on drift — asymmetric on purpose: a subject that disagrees is fatal, a subject that
cannot be read is only a warning, since Get-AuthenticodeSignature's .msix support varies by Windows
version and signtool has already reported success by then. NOTE this changes package identity, so
existing installs need an uninstall, not an upgrade.

The updater's leaf-pinning note was wrong and is corrected: update/windows.rs claimed the
AUTHENTICODE_SHA256 field made Trusted Signing "a manifest edit", but a per-request leaf is exactly
what a leaf pin cannot track — a pin would go stale within days and reject every release after it.

Drivers are deliberately untouched: their catalogs keep the DRIVER_CERT_* cert and the installer
still plants it as a machine root. The two signatures were always independent (SmartScreen/UAC vs
PnP), which is why the installer could move without them. Whether a publicly-trusted catalog would
let us drop that root plant is recorded as an unverified follow-up, not assumed.

Verified: both scripts parse under the PowerShell 7 AST parser, both workflows are valid YAML, the
evaluated Publisher default matches the subject Azure reports for the profile (86 chars, ordinal),
rustfmt clean. NOT verified on Windows — the sign path itself needs an on-glass run on .133.
2026-08-14 18:24:27 +02:00

157 lines
7.6 KiB
Markdown

# Punktfunk winget REST source
A self-hosted [winget REST source](https://github.com/microsoft/winget-cli-restsource) serving the
Punktfunk Windows host, so users can install and upgrade with `winget` instead of downloading a
`setup.exe` by hand.
```powershell
# one-time, from an ELEVATED prompt (winget requires admin to add a source)
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest
winget install unom.PunktfunkHost # silent, wizard defaults
winget install unom.PunktfunkHost --interactive # the full wizard
winget upgrade unom.PunktfunkHost
```
## Why self-hosted rather than the community repo
`microsoft/winget-pkgs` gates submissions on its `Binary-Validation-Error` /
`Validation-Defender-Error` checks, which need a publicly trusted signing cert. That blocker is
gone — the host installer is now signed with Azure Artifact Signing (see `packaging/windows/README.md`)
— but a self-hosted source is still the right call: it has no such gate, can carry `Agreements`
(verified-developers-only upstream), and can serve channels the community repo would never accept.
## What it implements
The winget client consumes exactly three endpoints. The reference implementation's other twenty
(`/packages/**`) are its **admin** API for mutating a CosmosDB; a catalogue generated at release
time has nothing to mutate, so they are not implemented.
| Endpoint | Notes |
| --- | --- |
| `GET /information` | Source identity, `ServerSupportedVersions`, declared capabilities. |
| `POST /manifestSearch` | `Query` / `Inclusions` (OR) / `Filters` (AND) / `FetchAllManifests`. **204** on no match — not an empty 200. |
| `GET /packageManifests/{id}` | Full manifest; honours `?Version=`. Identifier match is case-insensitive. |
| `GET /healthz` | Not part of the spec — liveness for compose. |
`NormalizedPackageNameAndPublisher` is deliberately declared **unsupported**. winget derives it
client-side from ARP entries with its own normalization (case folding plus stripping legal suffixes,
punctuation and version-like tails); claiming support would mean reimplementing that exactly, and a
near-miss silently mis-correlates an installed host. Correlation rides on `ProductCode` instead,
which Inno gives us exactly (`<AppId>_is1`).
## Layout
| File | Role |
| --- | --- |
| `handler.mjs` | The three endpoints. Pure `(Request, catalogue) -> Response`, no I/O, no deps. |
| `server.mjs` | node:http front. Loads the catalogue, reloads on mtime change. |
| `build-data.mjs` | Generates `data/data.json` from the published release manifests. Build-time only. |
| `test.mjs` | 28 checks driving `handler.mjs` directly. |
| `compose.production.yml` | The unom-1 service. |
## How it runs
```
Gitea releases (manifest trio per stable tag)
│ build-data.mjs ← walks ALL releases, no local state [CI, Linux job]
data/data.json (~8 KB) ← rsynced to unom-1 on each stable tag
bun + server.mjs on unom-1:3240 ← edge Caddy TLS ← winget.punktfunk.unom.io
```
Stock `oven/bun` image with the two `.mjs` files bind-mounted — the same shape as the flatpak server
(stock `caddy:2-alpine` + a bind-mounted Caddyfile). There is no image to build, publish or pull, so
a code change deploys by scp plus `docker compose up -d`.
The server has **zero dependencies**: it reads a generated JSON file. Parsing YAML and talking to
Gitea is `build-data.mjs`'s job, and that runs in CI. Only that build step needs `node_modules`.
**Content and config deploy separately**, matching the flatpak repo:
- `deploy-services.yml` (`winget` job) ships `compose.production.yml` + the two `.mjs` files.
- `windows-host.yml` (`winget-source` job) builds and ships `data/data.json` on each stable tag.
`server.mjs` reloads the catalogue when its mtime changes, so publishing a release needs no restart.
A half-written file mid-rsync keeps the previous catalogue rather than taking the source down.
### Why the catalogue is derived from releases
`build-data.mjs` walks the Gitea releases rather than reading local files. That is what makes
`winget install --version` and `winget upgrade` work: winget resolves both against the version
*list*, so a source that only knew the newest release could neither pin an older version nor show an
upgrade path from one.
It also means the source holds no state — re-running the build reproduces the same `data.json`, so a
lost deployment is one command away and nothing can drift.
## Local development
```bash
npm install
npm run build:local # data/data.json from ../*.yaml (single version — fine for dev)
npm test # 28 checks, no network, no server
npm start # http://localhost:3240
```
`npm test` is the gate that matters. A malformed response does not fail loudly — winget reports "no
package found", so a shape regression looks like a missing package rather than a broken source. CI
runs it before anything reaches the box.
Note a real `winget source add` will not accept a local instance: it requires HTTPS with a publicly
trusted certificate, which is what the edge vhost provides in production.
## First-time setup
1. **DNS**`winget.punktfunk.unom.io` → the unom-1 hcloud box, in the `unom.io` Cloudflare zone
(DNS-only, same as `docs`). ✅ *done*
2. **Caddy vhost** — in **`unom/infra`, `caddy/Caddyfile`**, next to the existing
`docs.punktfunk.unom.io` block. ✅ *done*
```caddyfile
winget.punktfunk.unom.io {
import security_headers
reverse_proxy localhost:3240
}
```
⚠ **Not by hand on the box.** `~/caddy/Caddyfile` on unom-1 looks like the config but is a copy
that `deploy-all.sh` rsyncs over from `unom/infra`, and there is no `.git` there to warn you. A
vhost added only on the box survives until the next deploy and no longer: this one was
hand-added on 2026-07-26, and the 2026-07-31 hardening commit rewrote the file from the repo's
own copy and deleted it — its message says "all 7 vhosts" when there were 8 live.
Until the vhost exists the hostname resolves but the TLS handshake fails — Caddy has no
certificate for a name it does not serve. That is the expected state on first setup, not a
broken deploy, but it is also exactly how the clobber presents later: an `internal_error`
(alert 80) with no peer certificate, which winget reports as
`WINHTTP_CALLBACK_STATUS_FLAG_SECURITY_CHANNEL_ERROR` / `0x8a15003b` and which looks like a
client bug. Diagnose by SNI, not by port 80 — Caddy 308s *every* Host to https, including names
it has never heard of, so a redirect there proves nothing:
```bash
openssl s_client -connect winget.punktfunk.unom.io:443 \
-servername winget.punktfunk.unom.io </dev/null 2>&1 | grep -E '^subject=|alert'
```
3. Run `deploy-services.yml` to place the compose file and start the container. It serves 503 until
a catalogue exists — expected, and visible on `/healthz`.
4. Publish a stable tag, or build and ship the catalogue by hand:
```bash
cd packaging/winget/server && npm install && npm run build && npm test
scp data/data.json <deploy-user>@<unom-1>:~/unom-winget/data/data.json
```
Then verify from anywhere:
```bash
curl -s https://winget.punktfunk.unom.io/information | jq .
curl -s -X POST https://winget.punktfunk.unom.io/manifestSearch \
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' | jq '.Data[].PackageIdentifier'
```
> Releases published before winget support shipped have no manifests attached, so `npm run build`
> skips them. Until the first stable tag lands with them, build with `--local ..` to serve the
> checked-in manifests, or backfill the manifest trio onto an existing release.