docs(wiki): appliance-provisioning — Komodo deploy is now the primary flow

§6 split: §6 = Docker engine only; new §7 = the Komodo Periphery deploy (PRIMARY,
verified end-to-end on park-buzi 2026-06-27):
- 7a install Periphery (onboarding key, user-mode/outbound, runs as admin, no
  inbound port; core_address = Core's proxy URL)
- 7b deploy the Stack in Core (registry+git accounts, per-booth [[..]] secrets,
  env incl. COOKIE_SECURE=0; seed admin via Komodo's container terminal — no SSH)
- 7b-bis fleet-as-code via komodo/resources.toml + ResourceSync (empty diff =
  in sync)
- 7c break-glass: manual booth.sh when mesh/Core is down

Added Komodo deploy gotchas 7-11 (core_address is the proxy URL not :9120;
git-auth ≠ registry-auth; user-mode vs /etc/komodo root_directory; core_address
singular; empty-diff/disabled-Execute = success). §5b SSH TODO reframed (Komodo
removes SSH from routine ops). Header + date updated; log entry added. Fixed a
stale [[atecc608-secure-element]] alias in the prior log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-27 12:30:08 +02:00
parent 9918f278b2
commit 7eadf71a0b
2 changed files with 153 additions and 29 deletions
+135 -28
View File
@@ -2,17 +2,18 @@
type: reference type: reference
tags: [parking, deployment, appliance, hardening, runbook, offline-first] tags: [parking, deployment, appliance, hardening, runbook, offline-first]
sources: [] sources: []
updated: 2026-06-23 updated: 2026-06-27
status: settled status: settled
--- ---
# Appliance provisioning runbook (booth PC) # Appliance provisioning runbook (booth PC)
Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running
parking appliance. Written from the **first real provisioning, 2026-06-23**, on the actual hardware parking appliance. Written from the **first real provisioning, 2026-06-23** (hardening) +
below — every command here was run and verified on that machine, including the firmware-specific **first Komodo deploy, 2026-06-27** (the runtime) — every command here was run and verified on the
workaround. Companion to [[disk-os-hardening]] (the *why*), [[tpm]] (TPM analysis), and actual hardware, including the firmware-specific workaround. Companion to [[disk-os-hardening]] (the
[[container-deployment]] (the images this runs). *why*), [[tpm]] (TPM analysis), [[container-deployment]] (the images), and
[[fleet-deployment-komodo]] (the deploy control plane this runbook's §7 uses).
> ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud > ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud
> control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk > control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk
@@ -171,50 +172,140 @@ default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
## 5b. Further hardening (TODO — not yet done) ## 5b. Further hardening (TODO — not yet done)
- **Key-based SSH only** (disable password auth) if SSH is enabled at all. - **Key-based SSH only** (disable password auth) if SSH is enabled at all. Routine ops no longer
need SSH — Komodo Periphery (§7) drives deploys + gives a container terminal over the mesh — so
SSH can be locked down hard or disabled, leaving the mesh + Komodo as the management path.
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell - **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
([[desktop-shell-tauri]]). ([[desktop-shell-tauri]]).
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12. - Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
- `sudo apt autoremove` the leftover old kernel once the new one is proven. - `sudo apt autoremove` the leftover old kernel once the new one is proven.
## 6. Runtime — Docker stack (VERIFIED 2026-06-23) ## 6. Runtime — Docker engine (VERIFIED 2026-06-23)
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`. download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator). Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`: This gives the appliance the engine. **How the stack gets ONTO it is step 7** — and as of
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env` 2026-06-27 the primary path is **Komodo (remote, no-SSH)**, not a hand-copied dir. The manual
(chmod 600). The `.env` (driven into the containers by the base compose): `docker compose` flow survives as a **break-glass fallback** (§7c).
``` ## 7. Deploy the stack — Komodo Periphery (PRIMARY, 2026-06-27)
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32> The booth is driven by a central **Komodo Core** over the **NetBird** mesh. The appliance runs a
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login small **Periphery** agent that *dials out* to Core; Core then deploys the same compose files. No
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes) inbound port on the booth, no SSH for routine ops. Full rationale + threat model:
VISION_ENABLED=1 [[fleet-deployment-komodo]]. Verified end-to-end on the first booth (`park-buzi`) 2026-06-27.
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
``` ### 7a. Install Periphery (on the booth, as `admin`)
Prereq: the booth is on the **NetBird** mesh and can reach Core's reverse-proxy URL
(`https://komodo.infra.msai.al`).
1. In Core: **Settings → Onboarding → + New Onboarding Key** (Name = the booth, e.g. `park-buzi`;
Expiry ~1 day; Pre-Existing Key empty). Copy the one-time `O-…` key. **Single-use** — delete it
after the agent connects.
2. On the booth, install Periphery in **user mode** (runs as `admin`, who is in `docker`; NO root
daemon; **outbound** → opens no inbound port):
```bash ```bash
docker login git.infra.msai.al # a read-only package token, not the account password curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | python3 - --user \
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env --core-address="https://komodo.infra.msai.al" \
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull --connect-as="park-buzi" \
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --onboarding-key="O-…"
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent): sudo loginctl enable-linger admin # so the user service starts at boot without a login
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
-e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' server node scripts/seed-admin.mjs
``` ```
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked - `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one.
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
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 (user-mode default is fine — a
`/etc/komodo` default from a system install would `Permission denied` for the user service).
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
**OK/green** in Core → Servers. Then **delete the onboarding key**.
### 7b. Deploy the Stack (in Core — by hand once, then code)
Add **Registry Account** + **Git Account** for `git.infra.msai.al` (user `komodo`, tokens) in Core
so Periphery can clone the repo AND pull the private images. Two distinct credential types — the
git clone working does NOT imply the image pull is authed (gotcha #8). Per-booth secrets
(`park_<booth>_jwt_secret`, `park_<booth>_event_signing_key` — distinct values, `openssl rand -hex
32`) live in Core's **Variables/Secrets** store, referenced from the Stack as `[[…]]`.
Create a **Stack** (UI → Stacks → New), name = the booth (`park-buzi`):
- **Server:** `park-buzi` · **Source:** repo `mca/parking_solution`, branch `dev`, files
`docker-compose.yml` + `docker-compose.prod.yml` · **Registry account:** `komodo` (else the pull
is anonymous → `no basic auth credentials`).
- **Environment** (Komodo writes this to a `.env` on the booth at deploy, substituting `[[…]]`):
```
REGISTRY=git.infra.msai.al/mca/parking_solution
TAG=dev # moving tag (staging). PIN to dev-<sha> for a live booth.
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
VISION_ENABLED=1
WS_ALLOWED_ORIGINS= # browser at the booth URL is same-origin; leave empty (the
# Tauri desktop app needs its origin here — separate task)
JWT_SECRET=[[park_buzi_jwt_secret]]
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
```
Deploy → Periphery pulls + `compose up`s. All containers (`proxy`/Caddy, `server`, `vision`) green.
Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent) **via
Komodo's terminal on the `server` container** (no SSH):
```bash
docker exec -it -e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' \
park-buzi-server-1 node scripts/seed-admin.mjs
```
> **Secrets-on-disk note.** The generated `.env` lands on the booth with **cleartext** secrets
> (compose needs real values). That's why the disk is LUKS-encrypted (§3–4) and keys are per-booth
> — the encryption is the control, and a single-booth compromise leaks only that booth's key. See
> [[fleet-deployment-komodo]] (the `EVENT_SIGNING_KEY`-in-Core blast-radius caveat; ATECC608 is the
> intended long-term signer).
### 7b-bis. Fleet-as-code (`resources.toml`) — optional but recommended
The repo's `komodo/resources.toml` mirrors the working Stack. Pointing a Core **ResourceSync** at
it makes the fleet **git-managed**: booth #N is a copy-pasted `[[stack]]` block; an image bump is a
one-line `TAG=` edit + push + Execute; every change is an auditable commit; a rebuilt Core
re-creates everything from the file. Keep the sync **Unmanaged** + **Delete-Unmatched OFF** until
trusted. An **empty diff / disabled Execute = the file already matches the live Stack** (success,
not an error). See `komodo/README.md` and [[fleet-deployment-komodo]].
### 7c. Break-glass — manual compose (mesh/Core down)
When the mesh or Core is unreachable, the same compose files run locally via `scripts/booth.sh`
(or raw `docker compose`). Needs a local `.env` and a `docker login git.infra.msai.al` (a
read-only package token). This is the FALLBACK, not the routine path:
```bash
docker login git.infra.msai.al
ENV=prod ./booth.sh config # dry-run the merged env
ENV=prod ./booth.sh up
```
`booth.sh` runs from wherever it sits next to the compose files (the booth deploys them flat, e.g.
`/opt/parking_systems/`). See [[container-deployment]].
### Healthy startup + web-access
Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights),
server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls `vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80). before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):** **Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
`hosts`/DNS ON-SITE, never an image rebuild. `hosts`/DNS ON-SITE, never an image rebuild. The **Tauri desktop app** is hardcoded to
`localhost:3000` (CSP + endpoints) and can't reach a remote booth without code changes — a browser
works; the desktop app is a separate workstream.
## Quick-reference: the gotchas, in order they bit us ## Quick-reference: the gotchas, in order they bit us
@@ -225,3 +316,19 @@ the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the boot
5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key). 5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key).
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot → 6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting. breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
### Komodo deploy gotchas (2026-06-27)
7. Periphery `core_address` is **Core's reverse-proxy URL** (`https://komodo.infra.msai.al`), NOT
`100.x:9120`. Core's `9120` is exposed-not-published (`docker ps` shows `9120/tcp` with no `->`)
→ a direct dial gets `Connection refused`. Ping/SSH working over the mesh does NOT mean `:9120`
is reachable.
8. **Git auth ≠ registry auth.** The repo cloning fine does not mean image pull is authed — they're
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`.
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**
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
+18 -1
View File
@@ -1695,8 +1695,25 @@ break-glass. Three settled choices: many/growing fleet · deploys **manual + pin
`dev-<sha>` (no webhook — preserves the determinism we chose by pinning) · secrets `dev-<sha>` (no webhook — preserves the determinism we chose by pinning) · secrets
**Komodo-managed, per-booth + unique**. Threat-model caveats recorded: Periphery is a root agent **Komodo-managed, per-booth + unique**. Threat-model caveats recorded: Periphery is a root agent
(bind to NetBird interface only, passkey+TLS, part of the TCB); `EVENT_SIGNING_KEY` in Core is a (bind to NetBird interface only, passkey+TLS, part of the TCB); `EVENT_SIGNING_KEY` in Core is a
fraud-root blast radius → per-booth keys + [[atecc608-secure-element|ATECC608]] as the real fraud-root blast radius → per-booth keys + [[atecc608|ATECC608]] as the real
long-term signer; Core becomes Tier-0. GPL-3.0 OK (external ops tooling, not a shipped dep — same long-term signer; Core becomes Tier-0. GPL-3.0 OK (external ops tooling, not a shipped dep — same
boundary logic as the AGPL vision exception). New page [[fleet-deployment-komodo]]; infra-as-code boundary logic as the AGPL vision exception). New page [[fleet-deployment-komodo]]; infra-as-code
sketch in `komodo/` (`resources.toml` + README + `.env.komodo.example`). Catalogued in `index.md`; sketch in `komodo/` (`resources.toml` + README + `.env.komodo.example`). Catalogued in `index.md`;
`container-deployment` cross-linked + reframed (booth.sh = fallback). `container-deployment` cross-linked + reframed (booth.sh = fallback).
## [2026-06-27] deploy | First Komodo booth deploy VERIFIED end-to-end (park-buzi)
Took the first booth through the whole Komodo flow on real hardware (Core v2.1.2 → agent reported
v2.2): onboarding key → Periphery installed **user-mode** (runs as `admin`, no root daemon,
**outbound** so the booth opens no inbound port) → server `park-buzi` **OK** in Core → Stack
(repo `mca/parking_solution`@`dev`, base+prod compose, registry account `komodo`, per-booth
`[[…]]` secrets) → all containers green → admin seeded via Komodo's container terminal (no SSH).
Then `komodo/resources.toml` rewritten to mirror the **working** Stack (exported from Core, v2.2
field shape, **Stack-only — no `[[server]]`** since onboarding owns the server), committed + pushed
(`dev` 9918f27); a ResourceSync reads it clean — **empty diff / Execute disabled = already in
sync** (success, not error). `booth.sh` fixed for the flat `/opt` layout earlier (83298bc).
Gotchas that bit us (now in [[appliance-provisioning]] §7 + gotchas 7–11): `core_address` is Core's
**proxy URL** not `:9120` (exposed-not-published → Connection refused); **git-auth ≠ registry-auth**
(blank registry account → `no basic auth credentials`); user-mode + `/etc/komodo` root_directory →
`Permission denied`; config key is **`core_address`** singular. [[appliance-provisioning]] §6 split:
§6 = engine, §7 = Komodo deploy (PRIMARY) with §7c manual `booth.sh` break-glass.