Files
punktfunk/packaging/winget/server
enricobuehlerandClaude Opus 5 e726542f96
ci / rust-arm64 (push) Successful in 1m23s
ci / web (push) Successful in 1m42s
ci / rust (push) Successful in 4m45s
ci / docs-site (push) Successful in 1m30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 28s
docs(winget): the vhost belongs in unom/infra, not on the box
Step 2 told you to add the Caddy vhost by hand on unom-1. That instruction is
what broke the source: ~/caddy/Caddyfile is a copy that deploy-all.sh rsyncs
over from unom/infra, with no .git there to warn you, so the hand-added block
survived until the 2026-07-31 hardening commit rewrote the file from the repo's
own copy and deleted it.

Point step 2 at unom/infra and record how the failure presents, since it does
not look like an ingress problem from the client side: no vhost means no
certificate for that SNI, so Caddy answers with TLS internal_error (alert 80)
before sending one, and winget surfaces that as
WINHTTP_CALLBACK_STATUS_FLAG_SECURITY_CHANNEL_ERROR / 0x8a15003b.

Also note that port 80 is useless for diagnosing it — Caddy 308s every Host to
https including names it has never heard of — and give the SNI probe that does
work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:43:39 +02:00
..

Punktfunk winget REST source

A self-hosted winget REST source serving the Punktfunk Windows host, so users can install and upgrade with winget instead of downloading a setup.exe by hand.

# 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, and the host installer is currently signed with a self-signed cert (CN=unom). That is a pre-existing condition — winget does not sign anything, so SmartScreen behaves identically whether the installer arrives by browser or by winget — but it does block that route until a publicly trusted cert is in place. A self-hosted source 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

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. DNSwinget.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

    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:

    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:

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:

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.