fix(desktop): mirror signed releases to public repo for the updater
Build desktop / desktop (push) Successful in 4m46s
CI / check (push) Successful in 43s

The updater endpoint pointed at mca/parking_solution's own Gitea "latest
release" redirect, but that repo is private and field appliances have no
Gitea credentials — every update check was silently failing. release.yml
now mirrors signed installers to mca/public_releases (public, installers
only) under a fixed desktop-latest tag; tauri.conf.json points there.
Rejected embedding a read token in the app instead, given the booth-operator
threat model.

Also: make the appliance-provisioning root_directory gotcha impossible to
skim past (boxed callout + explicit next-step pointers), after it caused a
second missed step on the park-2 install.
This commit is contained in:
2026-09-03 09:56:49 +02:00
parent 0fd66b261a
commit a1f3103a76
7 changed files with 181 additions and 37 deletions
+29 -14
View File
@@ -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**
+32 -9
View File
@@ -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-<TAG>`
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-<TAG>` (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-<branch>`). **Unsigned** —
+1 -1
View File
@@ -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-<sha> (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.
+15
View File
@@ -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]].