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
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
sources: []
updated: 2026-06-23
updated: 2026-06-27
status: settled
---
# Appliance provisioning runbook (booth PC)
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
below — every command here was run and verified on that machine, including the firmware-specific
workaround. Companion to [[disk-os-hardening]] (the *why*), [[tpm]] (TPM analysis), and
[[container-deployment]] (the images this runs).
parking appliance. Written from the **first real provisioning, 2026-06-23** (hardening) +
**first Komodo deploy, 2026-06-27** (the runtime) — every command here was run and verified on the
actual hardware, including the firmware-specific workaround. Companion to [[disk-os-hardening]] (the
*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
> 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)
- **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
([[desktop-shell-tauri]]).
- 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.
## 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
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).
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`:
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env`
(chmod 600). The `.env` (driven into the containers by the base compose):
This gives the appliance the engine. **How the stack gets ONTO it is step 7** — and as of
2026-06-27 the primary path is **Komodo (remote, no-SSH)**, not a hand-copied dir. The manual
`docker compose` flow survives as a **break-glass fallback** (§7c).
```
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32>
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes)
VISION_ENABLED=1
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
```
## 7. Deploy the stack — Komodo Periphery (PRIMARY, 2026-06-27)
The booth is driven by a central **Komodo Core** over the **NetBird** mesh. The appliance runs a
small **Periphery** agent that *dials out* to Core; Core then deploys the same compose files. No
inbound port on the booth, no SSH for routine ops. Full rationale + threat model:
[[fleet-deployment-komodo]]. Verified end-to-end on the first booth (`park-buzi`) 2026-06-27.
### 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
docker login git.infra.msai.al # a read-only package token, not the account password
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent):
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
curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | python3 - --user \
--core-address="https://komodo.infra.msai.al" \
--connect-as="park-buzi" \
--onboarding-key="O-…"
sudo loginctl enable-linger admin # so the user service starts at boot without a login
```
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
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
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"):**
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
`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
@@ -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).
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.
### 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`).