diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index dcec44b..6d9b325 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,12 +1,24 @@ name: Release desktop # Build the signed Tauri desktop installers on a version tag and publish them as -# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) -# fetches these; latest.json + each installer + its .sig are what it needs. +# a Gitea Release — TWICE: once on this (private, source) repo for our own +# records/history, and once mirrored to mca/public_releases, which is what the +# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at. +# +# WHY a separate public repo: the updater runs on offline-first field appliances +# with no Gitea credentials, so its endpoint + installer downloads must be +# reachable unauthenticated. Mirroring compiled installers to a public +# releases-only repo avoids embedding any read token in the shipped app (which +# would leak the moment a booth PC is compromised — this box's threat model +# names the operator/booth as the primary adversary, see CLAUDE.md). Source +# stays private; only signed installers become public, same as most desktop +# software. mca/public_releases is shared across apps in the org, not +# parking-specific — namespace release tags/asset names accordingly if another +# app starts publishing there too. # # Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them -# with the updater key (Gitea secrets), assembles latest.json, and uploads -# everything to the Release for that tag. +# with the updater key (Gitea secrets), assembles latest.json pointing at the +# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets. on: push: @@ -85,18 +97,20 @@ jobs: - name: Assemble latest.json # The Tauri updater fetches a manifest describing the newest version, its - # notes, and per-target {signature, url}. We point the AppImage target at - # this release's asset URL. Adjust the platform keys you actually ship. + # notes, and per-target {signature, url}. The URL points at the MIRROR + # repo (mca/public_releases) — that's the unauthenticated endpoint field + # appliances actually reach; see the workflow header for why. Adjust the + # platform keys you actually ship. env: SERVER_URL: ${{ github.server_url }} - REPO: ${{ github.repository }} + MIRROR_REPO: mca/public_releases TAG: ${{ github.ref_name }} run: | set -e VERSION="${TAG#v}" APPIMAGE=$(cd dist && ls *.AppImage | head -1) SIG=$(cat "dist/${APPIMAGE}.sig") - ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}" + ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}" cat > dist/latest.json </dev/null done echo "done" + + - name: Mirror release to mca/public_releases (Gitea API) + # This is the release the updater and any human downloader actually use — + # public_releases has no source, only installers, so it can be public + # without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository + # token scoped for pushing releases into that repo (Gitea's org secrets, + # not exposed to any deployed client). + # + # Publishes to TWO tags there, since public_releases is shared across + # apps in the org and Gitea's "latest release" redirect resolves by + # newest tag on the WHOLE repo (would break the moment another app + # publishes something newer): + # - desktop- versioned, permanent — audit trail / rollback. + # - desktop-latest moving — assets deleted + re-uploaded each release. + # This is the fixed URL tauri.conf.json's updater endpoint points at + # (a stable name every appliance can always resolve, regardless of + # what else gets released in this repo meanwhile). + env: + TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }} + API: ${{ github.api_url }} + MIRROR_REPO: mca/public_releases + TAG: ${{ github.ref_name }} + run: | + set -e + create_or_get_release() { + local mirror_tag="$1" prerelease="$2" + REL=$(curl -sS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \ + "${API}/repos/${MIRROR_REPO}/releases" || true) + REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + if [ -z "$REL_ID" ]; then + REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \ + "${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}" \ + | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + fi + } + upload_assets() { + local rel_id="$1" + for f in dist/*; do + name=$(basename "$f") + echo "mirroring ${name} -> release ${rel_id}" + curl -sS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"${f}" \ + "${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}" >/dev/null + done + } + + # 1. Versioned, permanent. + create_or_get_release "desktop-${TAG}" false + echo "versioned mirror release id: ${REL_ID}" + upload_assets "${REL_ID}" + + # 2. Moving desktop-latest — delete existing assets first (re-upload + # with the same name 409s otherwise), then re-upload. + create_or_get_release "desktop-latest" false + LATEST_REL_ID="${REL_ID}" + echo "latest mirror release id: ${LATEST_REL_ID}" + EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \ + "${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets") + printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do + curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null + done + upload_assets "${LATEST_REL_ID}" + echo "done" diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1ef748e..315b19c 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -35,8 +35,16 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the window needs a display (WSLg or an X server). +## Auto-update + +Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored +to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first +field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated — +see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and +signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never +committed. + ## Not here (deliberately) -Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from -the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and -this shell connects to it. +Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for +the scaffold — on the appliance Fastify runs as its own service and this shell connects to it. diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index dccc5a0..ece1c32 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -41,9 +41,9 @@ }, "plugins": { "updater": { - "//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.", + "//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.", "endpoints": [ - "https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json" + "https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json" ], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK" } diff --git a/wiki/decisions/appliance-provisioning.md b/wiki/decisions/appliance-provisioning.md index f948833..46b5e7c 100644 --- a/wiki/decisions/appliance-provisioning.md +++ b/wiki/decisions/appliance-provisioning.md @@ -2,7 +2,7 @@ type: reference tags: [parking, deployment, appliance, hardening, runbook, offline-first] sources: [] -updated: 2026-08-30 +updated: 2026-09-02 status: settled --- @@ -290,19 +290,30 @@ sudo loginctl enable-linger admin # so the user service starts at boot witho NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through the proxy. (Gotcha #7 below.) - Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`** - (singular); `root_directory` must be a path `admin` can write. **⚠ VERIFY THIS after install — - Periphery v2.2.0's installer writes `root_directory = "/etc/komodo"` even with `--user`** - (bit the lab box 2026-07-07: panic `Failed to write private key pem to "/etc/komodo/keys/ - periphery.key" … Permission denied`, crash-loop until systemd gives up). Fix + restart: - ```bash - sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml - systemctl --user reset-failed periphery && systemctl --user restart periphery - ``` - NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always - `systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial). + (singular). -Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes -**OK/green** in Core → Servers. Then **delete the onboarding key**. +> ⚠ **ALWAYS CHECK THIS — every install so far has hit it (lab box 2026-07-07, booth `park-2` +> 2026-09-02).** `root_directory` must be a path `admin` can write, but **Periphery's installer +> writes `root_directory = "/etc/komodo"` even with `--user`** (still true as of v2.3.3). Result: +> panic `Failed to write private key pem to "/etc/komodo/keys/periphery.key" … Permission denied`, +> crash-loop until systemd gives up (`Start request repeated too quickly`). +> +> **Fix + restart:** +> ```bash +> sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml +> systemctl --user reset-failed periphery && systemctl --user restart periphery +> ``` +> NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always +> `systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial). +> +> **➜ Do not stop here once it's green.** This fix only gets Periphery *running* — the Stack still +> isn't deployed. Immediately continue to **verify below, then §7b**. + +**Verify:** `systemctl --user status periphery` → active; the server **`park-buzi`** appears and +goes **OK/green** in Core → Servers. Then **delete the onboarding key**. + +**➜ Next step is §7b below — the Stack itself is not deployed yet.** A green Server in Core just +means the agent connected; it runs nothing until you add the Registry/Git accounts and deploy. ### 7b. Deploy the Stack (in Core — by hand once, then code) @@ -485,7 +496,11 @@ works; the desktop app is a separate workstream. separate Komodo credentials. A blank registry account on the Stack → anonymous pull → `no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`). 9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent - key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`. + key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`. Hit + on every install so far (lab box 2026-07-07, booth `park-2` 2026-09-02, still on v2.3.3) — + **check this first** whenever a fresh Periphery install crash-loops; see the boxed callout in + §7a for the fix. Easy to fix-and-move-on without realizing the Stack still isn't deployed — + §7a's fix only starts the agent, §7b deploys the Stack. 10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from `https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`). 11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync** diff --git a/wiki/decisions/desktop-shell-tauri.md b/wiki/decisions/desktop-shell-tauri.md index e2d8bba..7e2e5b8 100644 --- a/wiki/decisions/desktop-shell-tauri.md +++ b/wiki/decisions/desktop-shell-tauri.md @@ -2,7 +2,7 @@ type: decision tags: [parking, decisions, desktop, frontend] sources: [] -updated: 2026-06-21 +updated: 2026-09-03 status: settled --- @@ -149,11 +149,7 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`. Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only when an update is wanted — consistent with [[offline-first]] (no network dependency in *core* - operation; updates are out-of-band). Endpoint is the **self-hosted Gitea** "latest release" - path — `https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json` - — which redirects to the newest tag's `latest.json` (published by `.gitea/workflows/release.yml`). - The updater GETs it (200 + manifest, or 204 = up-to-date), reads `platforms.linux-x86_64. - {signature,url}`, and downloads the signed installer. **WS origin:** the desktop window's origin + operation; updates are out-of-band). **WS origin:** the desktop window's origin is `tauri://localhost` (Linux may also send `http://tauri.localhost`), so the backend's `WS_ALLOWED_ORIGINS` must include both or the live feed won't connect (documented in `apps/server/.env.example`). @@ -165,8 +161,27 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd produced `.deb`/`.rpm`/`.AppImage` **plus their `.sig` updater signatures**; full `turbo run build lint` 14/14 green. *(This is the **updater** signing — distinct from OS-installer signing for Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)* -- **Still deferred:** the actual update-hosting URL, OS-level installer signing - (Windows/macOS publisher trust), and the Windows kiosk-browser fallback path. +- **Update-hosting endpoint (found broken, fixed 2026-09-03):** the endpoint originally pointed at + the **source repo's own** Gitea "latest release" redirect + (`.../mca/parking_solution/releases/latest/download/latest.json`) — but `mca/parking_solution` is + **private**, and the updater runs on offline-first field appliances with **no Gitea credentials**. + Every deployed update check was silently failing (swallowed by a `try/catch` in + `desktop-updater.ts`) — this was never field-verified, and it couldn't have worked as configured. + **Fix:** signed installers are now mirrored to a separate **public**, releases-only repo, + `mca/public_releases` (shared across apps in the org — see [[fleet-deployment-komodo]] sibling + infra), holding **only compiled installers, no source**. `tauri.conf.json`'s endpoint now points + there at a fixed `desktop-latest` tag (NOT that repo's generic "latest release" redirect, since + other apps publishing there would shadow ours — see the `desktop-latest` vs `desktop-` + split below). `.gitea/workflows/release.yml` pushes to both repos: the private source repo (own + record) and the public mirror (what the updater and any human downloader actually use). + **Rejected alternative:** embedding a `read:repository` Gitea token in `tauri.conf.json`'s + updater `headers` so it could read the private repo directly — ruled out because that token would + ship inside every installed binary in the field, and this appliance's own threat model names the + **booth operator as the primary adversary** (see root `CLAUDE.md`); a leaked token scoped to the + whole private repo, with no cheap way to rotate it across appliances already in the field, was + judged worse than publishing installers-only. +- **Still deferred:** OS-level installer signing (Windows/macOS publisher trust) and the Windows + kiosk-browser fallback path. ### Desktop in CI — two workflows, two purposes (added 2026-06-24) @@ -174,7 +189,15 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the - **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds `.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`, - and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged. + and publishes a Gitea Release **on `mca/parking_solution` (source, own record) AND mirrors it to + `mca/public_releases`** (public, installers-only — see the update-hosting-endpoint entry above for + why). The mirror step uses a second token, `RELEASES_MIRROR_TOKEN` + (`write:repository`, scoped for pushing into `public_releases` only — a CI-side secret, never + shipped to any client, distinct from the embedded updater *pubkey*). It publishes two tags there: + `desktop-` (versioned, permanent, for audit/rollback) and `desktop-latest` (moving — existing + assets deleted then re-uploaded each release, since Gitea has no per-app "latest" concept and this + repo is shared across apps). `latest.json`'s asset URL and `tauri.conf.json`'s updater endpoint + both point at `desktop-latest`. This is what the auto-updater actually consumes. - **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**: compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`) and publishes them to a **rolling per-branch pre-release** (tag `desktop-`). **Unsigned** — diff --git a/wiki/index.md b/wiki/index.md index 316db1c..6fd75e8 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -134,7 +134,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records. - [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception. - [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python. - [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events). -- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. +- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds). - [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot. - [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev- (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code. - [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works. diff --git a/wiki/log.md b/wiki/log.md index c358a04..ae3ff9a 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2725,3 +2725,18 @@ re-running the same installer with unchanged `--connect-as` is config-preserving skips rewriting an existing config) and safe; verified dry-run on `art-docker-station` (lab) then applied to `park-buzi` (live booth) with no disruption to the running app containers. Full detail + exact commands on [[appliance-provisioning]]. + +## [2026-09-03] fix | Desktop updater endpoint was unreachable — pointed at a private repo + +The Tauri auto-updater ([[desktop-shell-tauri]]) was fully implemented — signed builds, keypair, +`latest.json`, `release.yml` — but its endpoint pointed at `mca/parking_solution`'s own Gitea +"latest release" redirect, and that repo is **private**. Field appliances have no Gitea +credentials, so every update check was silently failing (caught by a `try/catch`); this was never +actually field-verified end to end. Fix: signed installers now mirror to a new public, +installers-only repo `mca/public_releases` (org-shared, not parking-specific), published to a fixed +`desktop-latest` tag so other apps releasing there later can't shadow ours. Considered and rejected +embedding a `read:repository` token in the app instead — ruled out given the appliance's own threat +model (booth operator as primary adversary) makes an extractable, hard-to-rotate credential in every +deployed binary worse than just publishing installers publicly. `release.yml`, +`apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/README.md` updated; full detail on +[[desktop-shell-tauri]].