Files
Gebos/README.md
T
Lars Nolden 649dd737c7
deploy / deploy (push) Failing after 3m20s
check / flake-check (push) Successful in 17s
Readme update
2026-06-29 17:23:08 +02:00

223 lines
10 KiB
Markdown

# Gebos
IoT telemetry stack on bare-metal NixOS.
This repo is a monorepo containing all code, NixOS modules, host configurations,
and CI/CD pipelines for the project. Everything is one `flake.nix`.
## Architecture
Three hosts (see issue #21 for the full design discussion):
| Host | Role | Public hostname | Private IP |
| ------------- | ----------------------------------------- | ---------------------- | ---------- |
| `mqtt-ingest` | HiveMQ CE + Go MQTT→Postgres ingester | `ingest.gebos.online` | `10.0.0.4` |
| `db-host` | Postgres 17 + TimescaleDB (telemetry+auth)| `db.gebos.online` (SSH only) | `10.0.0.2` |
| `app-host` | Caddy + Kong + Supabase (compose) + SPA | `app.gebos.online`, `api.gebos.online` | `10.0.0.3` |
Public REST surface is PostgREST + SQL `/rpc/` functions, fronted by Kong, TLS-terminated by Caddy.
Supabase Studio is bound to `127.0.0.1` on `app-host` — reach it with `ssh -L 3000:127.0.0.1:3000 app-host`.
### Networking
There is no private DNS. Each host has a public hostname (used for `deploy-rs`
SSH access from the Gitea runner, which is **not** on the private network, plus
TLS ingress where applicable), and a static `10.0.0.0/8` IP used for all
host-to-host traffic:
- `db.gebos.online` is for SSH/deploy only — Postgres is never exposed publicly.
- `app-host` and `mqtt-ingest` reach Postgres at `10.0.0.2:5432` over the private
network.
- `db-host` only accepts Postgres (port 5432) from `10.0.0.0/8` (firewall rule
in `nix/hosts/db-host.nix`).
## MQTT ingest path
How a sender device's telemetry reaches Postgres, and the reasoning behind each
choice. The device firmware is the fixed end of this contract, so the rest of
the stack is built to match it rather than the other way around.
```
device ──MQTTS:8883──▶ Caddy (layer4, TLS term) ──MQTT:1883──▶ HiveMQ CE ──▶ ingester ──▶ Postgres
acrios/<IMSI>/<metric> loopback file-RBAC IMSI→tenant
```
### Broker auth: file-RBAC, not allow-all
HiveMQ CE ships the `hivemq-allow-all` extension — any client can publish or
subscribe to anything. We replaced it with the vendored
[`hivemq-file-rbac-extension`](https://github.com/hivemq/hivemq-file-rbac-extension)
(`nix/modules/gebos-hivemq.nix`), which adds username/password auth and
topic-level authorization. `gebos-hivemq.preStart` reconciles the writable
extensions dir on every start: it installs the pinned extension and **removes
allow-all**, so a redeploy fixes already-provisioned boxes too. Credentials
(`credentials.xml`) are rendered by sops-nix (`services.gebos.secrets.hivemq`)
and symlinked from the `/run/secrets` tmpfs into the extension's `conf/` — they
never touch the Nix store or the state dir. `password-type` is `PLAIN` because
the file is already an encrypted secret at rest and `0400`/`gebos-hivemq` at
runtime; flip to `HASHED` for defence-in-depth.
There are three broker users: `ingester` (subscribes the device tree), `admin`
(break-glass superuser), and `bender` (the shared device account, below).
### Topic scheme: the device's `acrios/<IMSI>/…` is the source of truth
Senders publish to `acrios/<IMSI>/<metric>``mqtt_topic_base` plus the SIM's
IMSI plus the metric name. We adopted that namespace verbatim rather than
reshaping it into the `t/<tenant>/d/<device>/…` form the schema originally
imagined, because the firmware can't emit our `tenant_id`/`device_id` **UUIDs**
— it only knows its IMSI. So the IMSI is the natural device key, and the
ingester (subscribed to `acrios/#`) will resolve `IMSI → (tenant_id,
device_id)` via a **device registry** before inserting into `public.telemetry`.
That registry table and the ingester's topic parsing are still TODO — the
ingester is currently a stub.
### Device auth: shared user now (Option A), per-device later (Option B)
The sender logs in with a single shared account (`bender`), authorized to
publish/subscribe under `acrios/#`. Tenant isolation is therefore enforced
**downstream** by the ingester's IMSI registry, not at the broker — any device
could publish under any IMSI. That's an accepted trade-off for a small trusted
fleet, and it gets data flowing without per-device provisioning.
The production answer (**Option B**, a TODO in `nix/modules/gebos-secrets.nix`)
is one broker user per device with `username == IMSI`, scoped to
`acrios/${{username}}/#` so the broker itself prevents a device from spoofing
another's IMSI. It's not wired up because it needs a firmware change
(`mqtt_user = <IMSI>`) and a per-device password provisioning flow. Note the
firmware's client-id (`acrcv-<IMSI>`) carries a prefix the topic doesn't, so
per-device scoping must key on username, not `${{clientid}}`.
### TLS: caddy-l4 terminates, HiveMQ stays loopback
HiveMQ binds `127.0.0.1:1883` only; it never faces the network. Senders connect
MQTTS on `:8883`, and Caddy — rebuilt with the
[`caddy-l4`](https://github.com/mholt/caddy-l4) layer4 module — terminates TLS
and proxies cleartext to the broker (`nix/modules/gebos-caddy.nix`). We reuse
Caddy here (rather than a native HiveMQ TLS listener + keystore) so there's one
ACME story for the whole fleet. The wrinkle: caddy-l4's `tls` handler only
*terminates* — it never obtains certs — so each `tcpProxy` site also emits a
companion HTTPS block purely to make Caddy's automatic HTTPS provision the cert
that the layer4 handler then serves by SNI. That's why `mqtt-ingest` opens
**80/443** (ACME challenge + renewal) alongside **8883**. The custom Caddy build
is only used on hosts that actually have a `tcpProxy` site; app-host's
static/reverse-proxy sites stay on stock Caddy.
## Repo layout
```
flake.nix
frontend/ # Vite + React SPA
ingester/ # Go MQTT → Postgres
nix/
modules/ # NixOS modules (one per service)
hosts/ # nixosConfigurations: mqtt-ingest, db-host, app-host
supabase/ # vendored Supabase docker-compose, db init SQL
dev/ # process-compose for local development
deploy.nix # deploy-rs node map
.gitea/workflows/ # CI + CD
```
## Local development
```
nix run .#dev
```
Brings the full stack up on one machine via process-compose-flake (Postgres,
Supabase compose, Kong, Caddy, ingester, Vite dev server). NixOS required.
## Deployment
There are two distinct phases. **Initial provisioning** turns a blank box into a
NixOS host (`nixos-anywhere`, run once per machine). **Updates** push new
closures to a host that already runs NixOS (`deploy-rs`, run on every change).
### SSH key setup (do this first)
Both phases authenticate over SSH with `~/.ssh/larsnolden`, which is
passphrase-protected. Load it into an `ssh-agent` once so the deploy tools can
reuse it without prompting:
```fish
eval (ssh-agent -c) # bash/zsh: eval "$(ssh-agent -s)"
ssh-add ~/.ssh/larsnolden # enter the passphrase once
ssh-add -l # confirm the key is loaded
```
This is **required** for `deploy-rs`, not just a convenience: with
`magicRollback = true` (see `nix/deploy.nix`) activation opens two concurrent SSH
connections — the activation command and a rollback waiter. Without an agent,
both race to read the passphrase from the terminal, one loses, and the deploy
fails with `Permission denied (publickey,keyboard-interactive)` even though
manual SSH and the copy step work. The agent serves the key to every connection,
so no prompt is needed.
### Initial provisioning (`nixos-anywhere`)
`deploy-rs` only *updates* a machine that already runs NixOS — it copies a
prebuilt closure and activates it. A fresh box (e.g. a stock Debian image with
only a `root` user) has no Nix store and no NixOS generation to switch to, so
`deploy-rs` fails with `nix-store: command not found`. Use
[`nixos-anywhere`](https://github.com/nix-community/nixos-anywhere) to install
NixOS over SSH first; after that, `deploy-rs` takes over for all subsequent
deploys.
`nixos-anywhere` SSHes in as `root`, kexecs into an in-memory NixOS installer,
partitions and formats the disk per the host's [`disko`](https://github.com/nix-community/disko)
config, installs `nixosConfigurations.<host>`, and reboots into NixOS. **This
wipes the target disk.**
Prerequisites, per host, before running it:
1. **A real disk layout.** Hosts currently import the fictional
`nix/hosts/placeholder-hardware.nix` (it only exists so `nix flake check`
evaluates). Replace that import with a `disko` config describing the actual
disk device (`/dev/sda` vs `/dev/vda`/nvme) and firmware (UEFI vs legacy
BIOS). `disko` replaces the hand-generated `hardware-configuration.nix`.
2. **Root SSH access** to the box. The `deploy` user and its authorized keys are
created by `nix/hosts/common.nix` during the install, so deploy-rs access
works automatically once NixOS is up.
3. **Host secrets key** present so sops-nix can decrypt at first boot — see
[`nix/secrets/README.md`](nix/secrets/README.md). Otherwise services that
read `/run/secrets/*` (e.g. the ingester) fail to start after reboot.
Then, from the repo root:
```
# installs NixOS onto the target, wiping its disk
nix run github:nix-community/nixos-anywhere -- \
--flake .#mqtt-ingest root@ingest.gebos.online
```
Repeat with `.#db-host root@db.gebos.online` and `.#app-host root@app.gebos.online`.
Provision `db-host` first if you intend to deploy updates immediately afterward
(see the ordering note below). Once a host has rebooted into NixOS, never run
`nixos-anywhere` against it again — use `deploy-rs`.
### Updates (`deploy-rs`)
`deploy-rs` from a Gitea Actions runner on push to `main`. Closures are built
once, copied to each host, activated with auto-rollback. Order: `db-host`
`app-host``mqtt-ingest`.
Running it by hand needs the key loaded into an `ssh-agent` first — see
[SSH key setup](#ssh-key-setup-do-this-first) above.
```
nix run github:serokell/deploy-rs -- .#db-host # one host
nix run github:serokell/deploy-rs -- . # all hosts
```
## Secrets
[sops-nix](https://github.com/Mic92/sops-nix) + [age](https://github.com/FiloSottile/age).
Single encrypted file at `nix/secrets/secrets.yaml`; each host decrypts only the
keys it needs at activation, rendered into a tmpfs env file consumed by systemd
`EnvironmentFile=`. Plaintext never enters the Nix store. See
[`nix/secrets/README.md`](nix/secrets/README.md) for bootstrap and rotation.
Local dev needs **no** secrets bootstrap — `nix run .#dev`, `go run ./ingester`,
and `npm --prefix frontend run dev` all default to the local dev stack values
defined in `nix/dev/process-compose.nix`.