Compare commits

..

21 Commits

Author SHA1 Message Date
Lars Nolden f4b5926e1f finish local dev setup
check / flake-check (push) Successful in 35s
deploy / deploy (push) Successful in 39s
2026-07-10 15:59:03 +02:00
Lars Nolden 1047968946 add requirements file 2026-07-09 16:07:16 +02:00
Lars Nolden 2ce68cb098 Wire schema v1 into a manual Supabase migration and vendor the real stack
deploy / deploy (push) Successful in 5m57s
check / flake-check (push) Failing after 35m52s
- Move db/schema.sql to supabase/migrations/ as the first supabase CLI
  migration (manual `db push` only, no automated runner); re-add the
  gebos_ingest role + grants there since init.sql never re-runs
- Add gebos-postgres-passwords oneshot on db-host: syncs role passwords
  from sops (LoadCredential, journal-safe), makes supabase_admin
  SUPERUSER and hands the auth schema to supabase_auth_admin to match
  the upstream supabase/postgres image; add pg_hba rule for 10.0.0.0/8
- Vendor the official docker-compose (studio/kong/auth/rest/meta only,
  external Postgres, loopback Studio with no Kong dashboard route) plus
  kong.yml (trimmed) and kong-entrypoint.sh (verbatim); tested: compose
  config, Kong config parse, migration applied on TimescaleDB pg17
- Document decisions as ADRs 0001-0004 (migrations, passwords, vendored
  stack, JWT API keys incl. verify/mint procedure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:33:50 +02:00
Lars Nolden b2a1056255 schema v1
check / flake-check (push) Successful in 32s
deploy / deploy (push) Successful in 37s
2026-07-03 17:05:43 +02:00
Lars Nolden d530f0f3e5 debugging gateway internet access 2026-07-03 16:50:42 +02:00
Lars Nolden 2d0ac1bc53 gateway script 2026-07-02 17:44:24 +02:00
Lars Nolden 58155dc737 switch to emqx
check / flake-check (push) Successful in 25s
deploy / deploy (push) Successful in 48s
2026-07-02 15:19:59 +02:00
Lars Nolden 08d9fc594e Fix invalid Caddyfile: put layer4 in globalConfig, not a 2nd global block
check / flake-check (push) Successful in 22s
deploy / deploy (push) Successful in 37s
The NixOS caddy module already emits one global options block (for `email`)
and appends extraConfig after it. gebos-caddy also prepended its own keyless
`{ layer4 { ... } }` block, so the rendered Caddyfile had two keyless blocks
and caddy rejected it: "server block without any key is global configuration,
and if used, it must be first" -> caddy.service failed -> mqtt-ingest
activation exited 4 and deploy-rs rolled back.

Move the layer4 directive into services.caddy.globalConfig so it lands inside
the module's single global block. Verified the rendered Caddyfile for
mqtt-ingest; app-host (http-only) output is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:09:08 +02:00
Lars Nolden 5e2454f622 Inject runner-absolute SSH opts via deploy-rs --ssh-opts
check / flake-check (push) Successful in 19s
deploy / deploy (push) Failing after 56s
The gitea-runner is a systemd DynamicUser whose passwd home is /, so ssh
expands ~ to /.ssh/... (not $HOME). deploy-rs passes ssh opts to ssh with
no shell, so a tilde in deploy.nix sshOpts resolved to a missing key and
nix copy failed with 'failed to start SSH connection'.

Keep deploy.nix sshOpts path-free; build absolute -i/known_hosts paths in
deploy.yml from $HOME (bash-expanded) and append them via --ssh-opts, which
deploy-rs merges into NIX_SSHOPTS for the copy and the activation ssh.
Also removes the temporary SSH debug step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:06:12 +02:00
Lars Nolden 16fd12173f Add temporary SSH debug step to diagnose db-host deploy auth
check / flake-check (push) Successful in 17s
deploy / deploy (push) Failing after 20s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:21:54 +02:00
Lars Nolden e1b3017fb7 test deploy
check / flake-check (push) Successful in 18s
deploy / deploy (push) Failing after 18s
2026-06-30 18:11:23 +02:00
Lars Nolden d5a0be9e40 dont overwrite SSH key
check / flake-check (push) Successful in 16s
deploy / deploy (push) Failing after 19s
2026-06-29 18:04:48 +02:00
Lars Nolden 649dd737c7 Readme update
deploy / deploy (push) Failing after 3m20s
check / flake-check (push) Successful in 17s
2026-06-29 17:23:08 +02:00
Lars Nolden bf8b25f374 make caddy a L4 proxy to terminate TLS for HiveMQ 2026-06-29 17:23:02 +02:00
Lars Nolden 70c2ada986 add secrets and age pubkeys to sops 2026-06-29 17:22:43 +02:00
Lars Nolden 4f9c6086bd hivemq setup 2026-06-26 18:36:44 +02:00
Lars Nolden c526cf671f Add nixos-anywhere deploy instructions to README 2026-06-26 17:32:07 +02:00
Lars Nolden 30b325393a replace with real hardware configs 2026-06-26 17:01:28 +02:00
Lars Nolden 7579d4dcf5 change domains, ips and make flake check work 2026-06-26 16:12:57 +02:00
Lars Nolden 9a6a901dfe bump to nix 26.05 2026-06-26 14:26:54 +02:00
Lars Nolden b10d22bd57 change runner names
check / flake-check (push) Failing after 25s
deploy / deploy (push) Failing after 4s
2026-06-25 18:55:06 +02:00
48 changed files with 4343 additions and 226 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
flake-check:
runs-on: nix
runs-on: nixos
steps:
- uses: actions/checkout@v4
- run: nix flake check
+13 -6
View File
@@ -5,15 +5,22 @@ on:
jobs:
deploy:
runs-on: nix
runs-on: nixos
steps:
- uses: actions/checkout@v4
- name: Setup deploy SSH key
run: |
mkdir -p ~/.ssh
install -m 600 /dev/stdin ~/.ssh/id_ed25519 <<< "${{ secrets.DEPLOY_SSH_KEY }}"
ssh-keyscan -H db-host.gebos.internal app-host.gebos.internal ingest.ge-bos.de >> ~/.ssh/known_hosts 2>/dev/null || true
# Dedicated key file so we never overwrite the runner user's own key.
install -m 600 /dev/stdin ~/.ssh/gebos_deploy <<< "${{ secrets.DEPLOY_SSH_KEY }}"
ssh-keyscan -H db.gebos.online app.gebos.online ingest.gebos.online >> ~/.ssh/known_hosts 2>/dev/null || true
# The gitea-runner is a systemd DynamicUser whose passwd home is `/`,
# so ssh expands `~` to `/.ssh/...` rather than $HOME. deploy-rs passes
# ssh opts with no shell, so it can't expand $HOME itself. Build the
# absolute paths here (bash expands $HOME) and hand them to deploy-rs
# via --ssh-opts; they append to nix/deploy.nix's path-free sshOpts.
echo "DEPLOY_SSH_OPTS=-i $HOME/.ssh/gebos_deploy -o UserKnownHostsFile=$HOME/.ssh/known_hosts" >> "$GITHUB_ENV"
- name: Build all closures
run: nix flake check --no-build
@@ -22,10 +29,10 @@ jobs:
# so it must land first. App-host's Supabase compose stack expects the
# schemas to exist. mqtt-ingest only needs the network paths to db-host.
- name: Deploy db-host
run: nix run github:serokell/deploy-rs -- .#db-host --skip-checks
run: nix run github:serokell/deploy-rs -- --ssh-opts "$DEPLOY_SSH_OPTS" .#db-host --skip-checks
- name: Deploy app-host
run: nix run github:serokell/deploy-rs -- .#app-host --skip-checks
run: nix run github:serokell/deploy-rs -- --ssh-opts "$DEPLOY_SSH_OPTS" .#app-host --skip-checks
- name: Deploy mqtt-ingest
run: nix run github:serokell/deploy-rs -- .#mqtt-ingest --skip-checks
run: nix run github:serokell/deploy-rs -- --ssh-opts "$DEPLOY_SSH_OPTS" .#mqtt-ingest --skip-checks
+5 -5
View File
@@ -10,17 +10,17 @@
keys:
# ---- developers (laptops, hardware keys) ----
- &dev_lars age1TODO_lars_personal_age_pubkey_replace_me_before_first_real_secret
- &dev_lars age1cx8ul285kjkzmnhw6skdstnzrxnnme4xkflknzn7yhv52fgxqevqkd66cn
# ---- hosts (derived from each host's ssh_host_ed25519_key.pub via ssh-to-age) ----
- &host_db age1TODO_db_host_age_pubkey_replace_me
- &host_app age1TODO_app_host_age_pubkey_replace_me
- &host_ingest age1TODO_mqtt_ingest_host_age_pubkey_replace_me
- &host_db age1wpl3vz60tlt880p5fmfedr8c59dm4kf0czsacv0w5my706twuf5q9p0x43
- &host_app age1g57pznep69nlyv3sltz6k0sml47v68m03gh68jkqwcq4jdx68vtsvnefq0
- &host_ingest age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d
creation_rules:
# Supabase compose stack → app-host only.
- path_regex: nix/secrets/secrets\.yaml$
encrypted_regex: ^(supabase_|ingester_|postgres_admin_)
encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_)
key_groups:
- age:
- *dev_lars
+157 -5
View File
@@ -9,15 +9,96 @@ and CI/CD pipelines for the project. Everything is one `flake.nix`.
Three hosts (see issue #21 for the full design discussion):
| Host | Role | Public hostname |
| ------------- | ----------------------------------------- | ---------------------- |
| `mqtt-ingest` | HiveMQ CE + Go MQTT→Postgres ingester | `ingest.ge-bos.de` |
| `db-host` | Postgres 17 + TimescaleDB (telemetry+auth)| internal only |
| `app-host` | Caddy + Kong + Supabase (compose) + SPA | `app.ge-bos.de`, `api.ge-bos.de` |
| Host | Role | Public hostname | Private IP |
| ------------- | ----------------------------------------- | ---------------------- | ---------- |
| `mqtt-ingest` | EMQX broker + 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──▶ EMQX (native TLS) ──MQTT:1883 loopback──▶ ingester ──▶ Postgres
acrios/<IMSI>/<metric> authn + ACL IMSI→tenant
```
### Broker: EMQX in the official container, deny-by-default auth
EMQX runs as the official `emqx/emqx` image via `oci-containers` with host
networking (`nix/modules/gebos-emqx.nix`). Authentication uses the built-in
database, seeded from a `users.csv` rendered by sops-nix
(`services.gebos.secrets.emqx`) and bootstrapped on first start; the bootstrap
only inserts users that don't exist yet, so a password rotation means deleting
the user (dashboard or `emqx ctl`) and restarting to re-import. Authorization
is a static `acl.conf` with `no_match = deny`. Passwords are plaintext in the
CSV because the file is already an encrypted secret at rest and `0400` at
runtime.
There are three broker users: `ingester` (subscribes the device tree), `admin`
(break-glass, `is_superuser` — bypasses the ACL), 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-emqx.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: EMQX terminates natively, certs via ACME HTTP-01
EMQX terminates TLS itself on `:8883`; the plain MQTT listener stays on
loopback for the co-located ingester. Certificates come from `security.acme`
using the **HTTP-01** challenge: lego's built-in standalone server answers on
`:80` (nothing else listens there — no Caddy on this host), so no DNS API
secrets are needed. `mqtt-ingest` opens **80 + 8883** only. The cert's
`postRun` hook installs `fullchain.pem`/`privkey.pem` at fixed paths in the
broker state dir, and EMQX re-reads the PEMs from disk (~every 120 s), so
renewals hot-reload without a restart or dropped connections. The key is RSA
(`keyType = "rsa2048"`) because embedded sender TLS stacks often can't do
ECDSA. Caddy remains only on app-host (stock build, HTTP sites).
## Repo layout
```
@@ -44,10 +125,81 @@ 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
@@ -0,0 +1,51 @@
# ADR-0001: Database schema changes are manual Supabase CLI migrations
Date: 2026-07-08
Status: Accepted
## Context
Schema v1 (`db/schema.sql`, ~600 lines) was committed but wired to nothing.
The only SQL that ran automatically was `nix/supabase/init.sql` via
`services.postgresql.initialScript` — which executes **only at first initdb**,
so on the already-initialized db-host cluster neither it nor any schema change
would ever apply again. A delivery mechanism was needed.
Options considered:
1. **Automated runner** — a systemd oneshot on db-host applying migrations on
every deploy (dbmate/atlas or plain psql with a tracking table).
2. **Manual Supabase CLI migrations** — plain-SQL files in
`supabase/migrations/`, applied by a human with `supabase db push`.
3. **Extending initialScript** — rejected outright: never re-runs on an
existing cluster.
## Decision
Option 2. Schema changes are rare, deliberate, admin-level operations; a
human applies them on purpose, and no automation exists to break or to apply
a half-reviewed migration as a side effect of a deploy.
* Migrations live in `supabase/migrations/<timestamp>_<name>.sql`; schema v1
moved there as the first one. `supabase/config.toml` is the minimal CLI
project marker.
* Applied via SSH tunnel to db-host as the `postgres` superuser
(see `supabase/README.md` for the exact commands). The CLI records applied
files in `supabase_migrations.schema_migrations`, so pushes are incremental
and re-running is safe. Individual migrations need **not** be idempotent.
* Layering rule: `nix/supabase/init.sql` holds only cluster bootstrap
(Supabase schemas, admin/API roles, extensions needing
`shared_preload_libraries`); everything else — including the `gebos_ingest`
role and its grants, which are coupled to application tables and must reach
existing clusters — lives in migrations. Passwords live in neither
(see ADR-0002).
## Consequences
* Deploys (`deploy-rs`) never touch the schema; a deploy and a migration are
two separate, independently reversible acts.
* A human must remember to push after merging a migration. Accepted for a
pilot with one operator.
* The local `nix run .#dev` Postgres has no TimescaleDB yet, so the v1
migration's `create_hypertable` fails there until the dev stack gains the
extension.
@@ -0,0 +1,61 @@
# ADR-0002: Postgres role passwords are synced by a one-shot systemd unit
Date: 2026-07-08
Status: Accepted
## Context
The Supabase services on app-host and the ingester on mqtt-ingest connect to
db-host over TCP and need password auth, but every login role
(`supabase_admin`, `supabase_auth_admin`, `authenticator`, `gebos_ingest`)
was created passwordless.
NixOS deliberately offers **no declarative option** for Postgres passwords:
anything the configuration references lands world-readable in `/nix/store`,
and `initialScript` — the only built-in hook that could set one — runs solely
at first initdb. Passwords are data, not configuration.
Two supporting gaps surfaced at the same time:
* NixOS's default `pg_hba.conf` covers only local sockets and loopback, so
connections from 10.0.0.0/8 were rejected before password auth even
started, firewall rule notwithstanding.
* The upstream `supabase/postgres` image ships role *attributes* our vanilla
Postgres lacked: `supabase_admin` is a SUPERUSER there (Studio/postgres-meta
connect as it), and `supabase_auth_admin` owns the `auth` schema (GoTrue
runs its own migrations in it at startup).
## Decision
A oneshot unit, `gebos-postgres-passwords` (`nix/modules/gebos-postgres.nix`),
runs on db-host after `postgresql.service` on every boot/deploy:
* Runs as the `postgres` OS user (local peer auth); reads the sops-rendered
env file via systemd `LoadCredential`, so the root-owned 0400 file needs no
permission widening.
* Idempotently syncs: `postgres``postgres_admin_password`;
`supabase_admin` / `supabase_auth_admin` / `authenticator` ← the shared
`supabase_postgres_password` (mirroring upstream's single
`POSTGRES_PASSWORD` convention); `gebos_ingest`
`ingester_postgres_password`.
* Enforces the two upstream attributes: `supabase_admin SUPERUSER` and
`ALTER SCHEMA auth OWNER TO supabase_auth_admin`.
* Guards every role for existence (tolerates a cluster the schema-v1
migration hasn't reached) and discards query output
(`--output=/dev/null`) so passwords never reach the journal.
* db-host enables the `supabase` and `ingester` secret toggles solely to get
those keys into its env file; `.sops.yaml`'s single key group already
permits this.
* `pg_hba` gains `host all all 10.0.0.0/8 scram-sha-256`.
## Consequences
* No password ever exists in git, the nix store, or SQL files; rotation is
`sops nix/secrets/secrets.yaml` + deploy + `systemctl restart
gebos-postgres-passwords` (the unit does not auto-rerun on secret-content
changes alone).
* The three Supabase roles share one password by design — per-role passwords
would diverge from the vendored compose, which interpolates a single
`${POSTGRES_PASSWORD}` everywhere.
* db-host can decrypt the Supabase JWT/API-key secrets it doesn't strictly
need; acceptable until `.sops.yaml` moves to per-prefix rules (noted there).
@@ -0,0 +1,66 @@
# ADR-0003: Vendor a trimmed Supabase compose stack against external Postgres
Date: 2026-07-08
Status: Accepted
## Context
The Supabase stack on app-host was a placeholder skeleton. The official
self-hosting bundle assumes its own bundled `db` container and ships nine+
services (realtime, storage, imgproxy, edge functions, supavisor pooler,
Logflare analytics, …), most of which the UVI pilot does not use. Meanwhile
our Postgres lives on a separate NixOS host (db-host) with TimescaleDB.
Kong deserves a note, since "why is it even there" came up: Kong is the
single front door for `api.gebos.online` (Caddy → Kong on loopback :8000).
It routes paths to internal services (`/auth/v1` → GoTrue, `/rest/v1`
PostgREST) and enforces the perimeter (key-auth + ACLs), so unauthenticated
internet traffic never reaches the backends. Replacing it would mean
re-implementing routing/auth/CORS in Caddy by hand instead of inheriting
Supabase's tested config.
## Decision
Vendor the upstream `docker/docker-compose.yml` (and `kong.yml` +
`kong-entrypoint.sh`) into `nix/supabase/`, keeping only
**studio, kong, auth (GoTrue), rest (PostgREST), meta (postgres-meta)**, with
these deviations — each also documented in the files' header comments:
1. **No `db` service.** Everything connects to
`${POSTGRES_HOST}:${POSTGRES_PORT}` (db-host); `depends_on: db` dropped.
2. **No realtime / storage / imgproxy / functions / supavisor / analytics.**
Re-vendor from upstream when actually needed.
3. **Studio binds to loopback only, and Kong's catch-all dashboard route is
removed.** Upstream fronts Studio with Kong basic-auth on `/`; we expose
no route to it at all. Access is `ssh -L 3000:127.0.0.1:3000` — possession
of the SSH key is the credential. `supabase_dashboard_password` is
therefore currently unused. The public API surface is exactly
`/auth/v1`, `/rest/v1`, `/graphql/v1` and two `.well-known` endpoints.
4. **meta connects as `supabase_admin`, not `postgres`.** Upstream's
`postgres` role *is* its cluster superuser; on db-host `postgres` keeps a
separate admin password, so `supabase_admin` (made SUPERUSER by ADR-0002)
fills that role with the shared service password.
5. **`kong-entrypoint.sh` is vendored byte-identical.** Kong's declarative
YAML cannot read env vars, so the entrypoint substitutes `$VARS` (the API
keys) into the config at container start; keeping it unmodified makes
re-vendoring a plain copy.
Variable layering (the answer to "where does `POSTGRES_HOST` come from"):
* non-secret deployment config (`POSTGRES_HOST/PORT/DB`, `STUDIO_BIND`) —
systemd unit `environment` in `gebos-supabase.nix`, fed by module options;
* secrets (`POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`,
`SERVICE_ROLE_KEY`) — sops-rendered `EnvironmentFile`;
* gebos constants (public URLs, org name) — hardcoded in the vendored
compose.
## Consequences
* Deliberately small attack surface and dependency set; Studio reachable only
through SSH.
* Upstream updates are a re-copy plus re-applying the five listed deviations;
all drift is concentrated in `docker-compose.yml` and `kong.yml` headers.
* GoTrue has **no SMTP configured yet** — magic-link login cannot send email
until the `SMTP_*` secrets are added (TODO marked in the compose file).
* `anon`/`authenticated` still hold zero grants; PostgREST serves nothing
until the RLS/grants migration lands (future ADR).
+103
View File
@@ -0,0 +1,103 @@
# ADR-0004: Legacy JWT-based API keys, one signing secret
Date: 2026-07-08
Status: Accepted
## Context
Supabase's API tier knows two credentials, and they are easy to confuse
because in the legacy scheme **both are JWTs signed with the same
`JWT_SECRET`**:
| | `apikey` header | `Authorization: Bearer …` header |
|---|---|---|
| identifies | the application | a logged-in person |
| minted by | a human, once, at project setup | GoTrue, at every login |
| payload | `{"role":"anon"}` / `{"role":"service_role"}` | `{"role":"authenticated","sub":"<user uuid>",…}` |
| lifetime | ~10 years | 1 h (`GOTRUE_JWT_EXP`), refreshed |
| checked by | Kong: exact string match against its consumer list | PostgREST: signature verification, then `SET ROLE` on the claim |
The `role` claim is what binds tokens to the database: PostgREST executes
each request as the Postgres role the (verified) `Authorization` JWT names —
`anon` for pre-login traffic, `authenticated` for sessions (with `auth.uid()`
= the `sub` claim, feeding RLS), `service_role` for server-side admin work.
`service_role` exists because some work legitimately spans all tenants
(admin invites, cross-apartment aggregation jobs, backfills); it carries
`BYPASSRLS` and its key must never leave the server side, while the anon key
is public by design (it ships in the frontend bundle).
Supabase is migrating to opaque `sb_publishable_*`/`sb_secret_*` keys that
separate the two concepts; the vendored `kong-entrypoint.sh` contains the
translation shim for that scheme.
## Decision
Stay on the **legacy scheme** for the pilot: `supabase_anon_key` and
`supabase_service_role_key` are long-lived HS256 JWTs signed with
`supabase_jwt_secret`. The opaque-key shim stays dormant (its env vars are
empty, which switches the Kong entrypoint to plain apikey pass-through).
This has a non-obvious integrity requirement: **the three secrets form one
cryptographic family.** The API keys are only valid if they were signed with
the exact `JWT_SECRET` stored alongside them. They are minted offline by us —
no service issues them — and rotating `JWT_SECRET` silently invalidates both
API keys *and* every active user session; all three must always be rotated
together.
## Verifying / minting the keys
The keys currently in `nix/secrets/secrets.yaml` must be checked once against
the stored secret (they predate this ADR). From the repo root, with sops
access — `openssl` is not in the dev shell, so wrap in
`nix shell nixpkgs#openssl` if needed:
```sh
SECRET=$(sops -d --extract '["supabase_jwt_secret"]' nix/secrets/secrets.yaml)
verify() { # verify <jwt> — checks HS256 signature against $SECRET
local hp=${1%.*} sig=${1##*.}
local expect=$(printf '%s' "$hp" \
| openssl dgst -sha256 -hmac "$SECRET" -binary \
| basenc --base64url -w0 | tr -d '=')
if [ -n "$expect" ] && [ "$sig" = "$expect" ]; then
echo "valid ($(printf '%s' "${hp#*.}" | tr '_-' '/+' | base64 -d 2>/dev/null))"
else
echo "INVALID SIGNATURE"
fi
}
verify "$(sops -d --extract '["supabase_anon_key"]' nix/secrets/secrets.yaml)"
verify "$(sops -d --extract '["supabase_service_role_key"]' nix/secrets/secrets.yaml)"
```
Each should print `valid` with a payload naming the right role. If either
prints `INVALID SIGNATURE`, re-mint and store both:
```sh
mint() { # mint <role> — 10-year HS256 JWT signed with $SECRET
local iat=$(date +%s) b64='basenc --base64url -w0'
local h=$(printf '{"alg":"HS256","typ":"JWT"}' | $b64 | tr -d '=')
local p=$(printf '{"role":"%s","iss":"supabase","iat":%s,"exp":%s}' \
"$1" "$iat" $((iat + 315360000)) | $b64 | tr -d '=')
local s=$(printf '%s.%s' "$h" "$p" \
| openssl dgst -sha256 -hmac "$SECRET" -binary | $b64 | tr -d '=')
printf '%s.%s.%s\n' "$h" "$p" "$s"
}
mint anon # → sops set as supabase_anon_key
mint service_role # → sops set as supabase_service_role_key
```
(Equivalently: the generator on Supabase's self-hosting docs page produces
the same thing — paste in the stored `JWT_SECRET`, don't let it invent a new
one.)
## Consequences
* Matches what supabase-js sends by default; no client-side configuration
beyond URL + anon key.
* One secret to protect (`JWT_SECRET`) — and one blast radius: leak it and an
attacker can mint `service_role` tokens; rotate it and keys + sessions die
together.
* Moving to opaque keys later is config-only: fill the four `sb_*`/asymmetric
env vars and the already-vendored Kong entrypoint starts translating.
+12
View File
@@ -0,0 +1,12 @@
# Architecture Decision Records
Numbered, immutable-once-accepted records of the significant decisions in
this repo. Supersede by adding a new ADR and flipping the old one's status to
`Superseded by ADR-NNNN`, not by editing history.
| # | Title | Status |
|---|-------|--------|
| [0001](0001-manual-supabase-cli-migrations.md) | Database schema changes are manual Supabase CLI migrations | Accepted |
| [0002](0002-role-passwords-via-oneshot-unit.md) | Postgres role passwords are synced by a one-shot systemd unit | Accepted |
| [0003](0003-vendored-supabase-compose-subset.md) | Vendor a trimmed Supabase compose stack against external Postgres | Accepted |
| [0004](0004-legacy-jwt-api-keys.md) | Legacy JWT-based API keys, one signing secret | Accepted |
+189
View File
@@ -0,0 +1,189 @@
# UVI Anforderungen (Pilot)
> Anforderungsdokument. Source of Truth = diese Notion-Seite. Tickets in Linear referenzieren die hier vergebenen IDs.
>
> **Methodische Grundlage:** UBA-Leitfaden „Verständliche monatliche Heizinformation als Schlüssel zur Verbrauchsreduktion" (CLIMATE CHANGE 69/2021), Umweltbundesamt 2021.
>
---
# 1. Goal State
Ein Mieter kann sich jederzeit in das Dashboard seiner Wohneinheit einloggen und seine monatliche Heizinformation einsehen, gestaltet **vollständig nach dem UBA-Leitfaden**. Die Heizinformation umfasst sechs Module:
1. **Verbrauchsentwicklung** für Heizung und Warmwasser mit 13-Monats-Verlauf, Tendenz-Indikator gegenüber Vorjahresmonat und Witterungs-Kontext (Heizgradtage)
2. **In-House-Vergleich** des spezifischen Verbrauchs (kWh/m² Gebäudenutzfläche) mit anderen Wohneinheiten desselben Hauses
3. **Bandtacho-Einordnung** des Hauses in die Gebäudeeffizienzklassen A⁺ bis H
4. **Spartipp des Monats** für Heizen (in der Heizperiode) bzw. Warmwasser (außerhalb)
5. **Kostenschätzung** für den aktuellen Monat und Jahr-zu-Datum
6. **CO2-Emissionen** des Haushalts mit alltagsnahem Vergleichswert
Die Heizinformation erfüllt § 6a Abs. 2 HeizkostenV. Der gesetzlich geforderte Durchschnittsnutzer-Vergleich (§ 6a Abs. 2 Nr. 3) wird durch die Kombination aus Modul 2 und Modul 3 abgedeckt — wie vom UBA-Leitfaden empfohlen.
## In Scope
- Alle sechs Module gemäß UBA-Leitfaden
- Mieter-Dashboard mit Magic-Link-Login pro Wohneinheit
- Datenempfang von Gateways
- Read-Only Admin-Dashboard, gruppiert nach Gebäudeadresse
- Unterscheidung Heizperiode / außerhalb Heizperiode
- Integration externer Referenzdaten: DWD Heizgradtage, GEG-Anlagen 9 und 10
## Out of Scope (für UVI)
- Jährliche Heizkostenabrechnung (eigenes Epic)
- Vermieter-/Hausverwaltungs-Sicht
- Witterungsbereinigung der Verbräuche (gehört zur Jahresabrechnung)
- Self-Service-UI für Geräte-zu-Wohneinheit-Zuordnung (post-Pilot)
- API-Keys pro Wohneinheit (Future)
- Optionale UBA-Leitfaden-Erweiterungen: Lage der Wohnung, energetischer Zustand, Pro-Kopf-Vergleichswerte, tägliche Verbrauchsinformationen, Gamification
- „Energieanalyse aus dem Verbrauch" (DIN TS 12831-1, DIN V 18599)
## Phasenmodell
| Phase | Geräte-Zuordnung | Admin-Dashboard | Mieter-Dashboard |
| --- | --- | --- | --- |
| **Pilot** | Manuell direkt in der Datenbank | Read-Only Übersicht aller Wohneinheiten, gruppiert nach Gebäudeadresse | Vollständig (alle 6 Module) |
| **Post-Pilot** | UI/Mechanismus *(TBD)* | Erweitert um Zuordnungs-Funktionen | Vollständig + ggf. optionale Erweiterungen |
---
# 2. Anforderungen
## 2.1 Compliance — § 6a HeizkostenV
| ID | Anforderung | Quelle | Erfüllung |
| --- | --- | --- | --- |
| UVI-COMP-01 | Verbrauch des Mieters im letzten Monat in kWh wird bereitgestellt | § 6a Abs. 2 Nr. 1 | Modul 1 |
| UVI-COMP-02 | Vergleich mit dem Verbrauch des Vormonats desselben Mieters | § 6a Abs. 2 Nr. 2 | Modul 1 (visuell im 13-Monats-Verlauf) |
| UVI-COMP-03 | Vergleich mit dem entsprechenden Monat des Vorjahres desselben Mieters | § 6a Abs. 2 Nr. 2 | Modul 1 (Tendenz-Indikator + Witterungskontext) |
| UVI-COMP-04 | Vergleich mit Durchschnittsnutzer derselben Nutzerkategorie | § 6a Abs. 2 Nr. 3 | Modul 2 (In-House) + Modul 3 (Bandtacho) |
| UVI-COMP-05 | Bereitstellung mindestens monatlich | § 6a Abs. 1 Nr. 2 | — |
## 2.2 Funktional
Spalte „Modul" referenziert die Module des UBA-Leitfadens (16).
| ID | Anforderung | Modul |
| --- | --- | --- |
| UVI-FUN-01 | Empfang von Verbrauchsdaten von Gateways | — |
| UVI-FUN-02 | *(Platzhalter)* Geräte-zu-Wohneinheit-Zuordnung — Pilot manuell in DB; Mechanismus post-Pilot | — |
| UVI-FUN-03 | Mieter-Login pro Wohneinheit (Magic Link) | — |
| UVI-FUN-04 | Anzeige des aktuellen Monatsverbrauchs in kWh, getrennt für Heizung und Warmwasser. **Anteil ist nicht nach verbrauchs-/flächenabhängigem Schlüssel gewichtet** (Unterschied zur Jahresabrechnung). | 1 |
| UVI-FUN-05 | Verlaufsdiagramm der monatlichen Verbräuche der letzten 13 Monate, getrennt für Heizung und Warmwasser; Vorjahresmonat zusätzlich beziffert | 1 |
| UVI-FUN-06 | Tendenz-Indikator zum Vorjahresmonat: gesunken (grüner Pfeil), gestiegen (roter Pfeil), gleich (grauer Pfeil). „Gleich" wenn auf Zehnerstelle gerundete kWh identisch sind. | 1 |
| UVI-FUN-07 | Witterungs-Kontext als Fußnote: Heizgradtage-Differenz Vorjahresmonat ↔ aktueller Monat in % | 1 |
| UVI-FUN-08 | In-House-Vergleich: Sparpotenzial-Pfeil zeigt Spektrum der spezifischen Gesamtverbräuche (Heizung+Warmwasser) aller Wohneinheiten des Hauses in kWh/m² Gebäudenutzfläche | 2 |
| UVI-FUN-09 | Bei kleinen Häusern (≤ 4 Wohneinheiten) wird zur Wahrung der Anonymität statt des Einzelwerts der Mittelwert der drei sparsamsten Haushalte ausgewiesen | 2 |
| UVI-FUN-10 | Bandtacho-Einordnung des Hauses in Effizienzklassen A⁺ bis H, basierend auf monatlichen Schwellenwerten (Tabelle 1 UBA-Leitfaden, abgeleitet aus Anlage 10 GEG) | 3 |
| UVI-FUN-11 | Anzeige eines Spartipps des Monats (Heizen/Lüften in der Heizperiode, Warmwasser außerhalb), inkl. Link zu weiterführenden Beratungsangeboten | 4 |
| UVI-FUN-12 | Kostenschätzung für aktuellen Monat und Jahr-zu-Datum, berechnet aus Endenergieverbrauch × aktueller Energiepreis pro Brennstoffart | 5 |
| UVI-FUN-13 | Anzeige der monatlichen CO2-Emissionen in kg, berechnet mit Emissionsfaktor aus Anlage 9 GEG; ergänzt um alltagsnahen Vergleichswert | 6 |
| UVI-FUN-14 | Außerhalb der Heizperiode: Bandtacho entfällt; Sparpotenzial-Pfeil zeigt nur Warmwasser-Spektrum (rot→blau); Heizungs-Verlaufsdiagramm bleibt sichtbar mit aktuellem Wert 0 kWh | — |
| UVI-FUN-15 | Fehlende Daten werden im UI klar als „nicht verfügbar" gekennzeichnet (keine stille 0) | — |
| UVI-FUN-16 | Verbrauchsdaten der letzten 13 Monate sind im Dashboard einsehbar | — |
| UVI-FUN-17 | Admin-Dashboard zeigt UVI-Daten aller Wohneinheiten als Read-Only-Übersicht, gruppiert nach Gebäudeadresse | — |
| UVI-FUN-18 | Admin-Login getrennt vom Mieter-Login (Magic Link) | — |
## 2.3 Nicht-funktional
| ID | Anforderung |
| --- | --- |
| UVI-NFR-01 | Datenresolution: mindestens monatlich |
| UVI-NFR-02 | DSGVO-konforme Speicherung und Verarbeitung |
| UVI-NFR-03 | Strikte Mandantentrennung: kein wohneinheitsübergreifender Datenzugriff für Mieter |
| UVI-NFR-04 | Datenübertragung von Gateway zum System verschlüsselt (TLS) |
| UVI-NFR-05 | Daten der UVI sind spätestens am 5. Werktag des Folgemonats im Dashboard sichtbar |
| UVI-NFR-06 | Verfügbarkeit Dashboard: *(zu definieren Vorschlag: 99 % im Monatsmittel)* |
| UVI-NFR-07 | Admin-Authentifizierung getrennt vom Mieter-Login; Admin-Zugriff protokolliert |
| UVI-NFR-08 | Authentifizierung erfolgt für Mieter und Admin **ausschließlich via Magic Link** (passwortlos) |
| UVI-NFR-09 | Anonymitätsschutz im In-House-Vergleich (siehe UVI-FUN-09) |
## 2.4 Daten / Domäne
| ID | Anforderung |
| --- | --- |
| UVI-DAT-01 | Entität *Wohneinheit* mit eindeutiger ID und **Wohnfläche in m²** |
| UVI-DAT-02 | *(Platzhalter)* Entität *Gateway/Sensor* und Verknüpfung zu Wohneinheit — Granularität noch offen |
| UVI-DAT-03 | Entität *Messwert* mit Zeitstempel, Wert in kWh, Typ (Heizung/Warmwasser), Geräte-Referenz |
| UVI-DAT-04 | Entität *Mieter* mit Zuordnung zu Wohneinheit; Nutzungs-Zeiträume zur Erkennung von Mieterwechseln |
| UVI-DAT-05 | Entität *Gebäude* mit Adresse, **Brennstoffart**, optional Faktor Wohnfläche → Gebäudenutzfläche (Default 1,2) |
| UVI-DAT-06 | Aggregate werden **on-the-fly aus Roh-Messwerten berechnet**; keine Persistierung von Aggregaten |
| UVI-DAT-07 | Referenzdaten: **Bandtacho-Schwellenwerte** der Gebäudeeffizienzklassen aus Anlage 10 GEG, monatlich aufgeteilt gemäß Tabelle 1 UBA-Leitfaden |
| UVI-DAT-08 | Referenzdaten: **CO2-Emissionsfaktoren** pro Brennstoffart aus Anlage 9 GEG |
| UVI-DAT-09 | Referenzdaten: **Energiepreise** pro Brennstoffart, regelmäßig aktualisierbar |
| UVI-DAT-10 | Referenzdaten: **Heizgradtage** pro Monat aus DWD Open Data nach VDI 3807 |
| UVI-DAT-11 | **Spartipps-Bibliothek**: kategorisiert nach Heizen / Warmwasser, mit optionalen Links |
## 2.5 Methodische Festlegungen
| ID | Anforderung |
| --- | --- |
| UVI-MET-01 | Die UVI folgt dem **UBA-Leitfaden** (CLIMATE CHANGE 69/2021) als kanonische Vorlage. Abweichungen werden explizit begründet. |
| UVI-MET-02 | Der Durchschnittsnutzer-Vergleich gemäß § 6a Abs. 2 Nr. 3 wird durch **Kombination aus In-House-Vergleich (Modul 2) und Bandtacho-Einordnung (Modul 3)** erfüllt. Eine externe Vergleichswertquelle (z.B. BMWK-Bekanntmachung) wird **nicht** zusätzlich verwendet. |
| UVI-MET-03 | Spezifische Verbräuche werden auf die **Gebäudenutzfläche** bezogen (= Wohnfläche × 1,2, falls keine direkte Angabe vorliegt). |
---
# 3. Mapping Notion ↔ Tickets
- Anforderungen (UVI-COMP-*, UVI-FUN-*, UVI-NFR-*, UVI-DAT-*, UVI-MET-*) bleiben Single Source of Truth in Notion**.
- User Stories werden in Linear angelegt; Stories enthalten in der Beschreibung die `Erfüllt:`-Zeile als Backlink in Notion.
- Definition of Done eines Tickets = die Akzeptanzkriterien der Story.
---
# 4. Offene Punkte (zu klären)
- **Geräte-zu-Wohneinheit-Zuordnung (post-Pilot):** Granularität, Pflege, UI vs. Bulk-Import, Validierung. Pilot manuell in DB.
- **Energiepreis-Pflege (Modul 5):** Wer pflegt die Preise, in welcher Frequenz, aus welcher Quelle?
- **Mieter-Mail-Erfassung:** Wer hinterlegt die E-Mail-Adresse des Mieters?
- **Spartipps-Quelle:** Eigene Kuratierung vs. Verlinken auf [verbraucherzentrale.de](http://verbraucherzentrale.de) / co2online?
- **Datenaufbewahrung:** Wie lange werden Roh-Messwerte gespeichert?
- **Wetterstationszuordnung pro Gebäude:** Automatisch nach Postleitzahl oder manuell?
- **Verfügbarkeits-SLA:** Konkreter Wert für UVI-NFR-06.
- **Sprache/Mehrsprachigkeit:** Nur DE oder auch EN?
- **Brennstoff-Mix bei Fernwärme:** § 6a Abs. 3 — gehört zur Jahresabrechnung, könnte aber Datenmodell beeinflussen.
## Geklärt seit Beginn
- ✅ Authentifizierung: Magic Link (passwortlos) für Mieter und Admin
- ✅ Aggregation: on-the-fly, keine Persistierung von Aggregaten
- ✅ Methodik: Vollständig nach UBA-Leitfaden — alle 6 Module im Pilot
- ✅ Durchschnittsnutzer-Vergleich: über In-House + Bandtacho (nicht über externe Vergleichswert-Datenbank)
---
# Anhang A — Methodische Grundlage
## A.1 Warum UBA-Leitfaden statt BMWK-Bekanntmachung?
Eine frühere Version dieses Dokuments hatte die BMWK-Bekanntmachung „Regeln für Energieverbrauchswerte im Wohngebäudebestand" (BAnz AT 16.04.2021 B1) als Quelle vorgesehen. Diese Entscheidung wurde nach Auswertung des UBA-Leitfadens revidiert.
Begründung:
1. **Zweck der Quellen unterscheidet sich:** Die BMWK-Bekanntmachung ist für **Energieausweise nach GEG** konzipiert. Der UBA-Leitfaden adressiert exakt unseren Use Case: **monatliche Heizinformation** nach § 6a HeizkostenV.
2. **Praktische Argumentation des UBA-Leitfadens (S. 23):** Ein gebäudeübergreifender Vergleich anonymisierter Verbräuche setzt voraus, dass die Gebäude einen ähnlichen energetischen Standard haben. Diese Information liegt Messdienstleistern typischerweise nicht vor.
3. **Der Leitfaden liefert konkrete Vergleichswerte** (Tabelle 1, S. 26) und ein vollständiges Gestaltungsmuster.
4. **Methodischer Ansatz:** § 6a Abs. 2 Nr. 3 wird erfüllt durch In-House-Vergleich (Modul 2) + Bandtacho (Modul 3).
## A.2 Edge Case: Mieterwechsel
Bei einem Mieterwechsel ist der Vergleich zum Vormonat oder Vorjahresmonat nach § 6a Abs. 2 Nr. 2 nicht möglich. In diesem Fall bleibt nur Modul 2 als verpflichtende Information. Datenmodell: Nutzungs-Zeiträume pro Wohneinheit müssen erfasst sein (UVI-DAT-04).
## A.3 Sanktion bei Nicht-Erfüllung
§ 7 HeizkostenV: Wird die UVI nicht oder unvollständig mitgeteilt, hat der Mieter ein Kürzungsrecht von **3 %** auf den Kostenanteil.
## A.4 Quellen
- **UBA-Leitfaden:** „Verständliche monatliche Heizinformation als Schlüssel zur Verbrauchsreduktion" (CLIMATE CHANGE 69/2021), Umweltbundesamt 2021, Brischke et al. (ifeu)
- **HeizkostenV § 6a** — Inhalte der UVI
- **HeizkostenV § 7** — Kürzungsrecht
- **EU-Richtlinie 2012/27/EU** Anhang VIIa, in der Fassung der RL 2018/2002 (EED)
- **GEG Anlage 9** — CO2-Emissionsfaktoren
- **GEG Anlage 10** — Schwellenwerte der Gebäudeeffizienzklassen
- **VDI 3807** — Heizgradtage und Verbrauchskennwerte (Backup-Referenz)
- **DWD Open Data** — [opendata.dwd.de/.../hdd_3807/recent/](http://opendata.dwd.de/.../hdd_3807/recent/)
- **UBA-CO2-Rechner** — [uba.co2-rechner.de](http://uba.co2-rechner.de)
Generated
+47 -5
View File
@@ -20,6 +20,26 @@
"type": "github"
}
},
"disko": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1781152676,
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
"owner": "nix-community",
"repo": "disko",
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "disko",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
@@ -87,16 +107,16 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"lastModified": 1782233679,
"narHash": "sha256-QyuGP5+QOtmXpy4i2X4DhBVBaySBdDKQEhqKcphcp34=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"rev": "667d5cf1c59585031d743c78b394b0a647537c35",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
@@ -119,10 +139,12 @@
"root": {
"inputs": {
"deploy-rs": "deploy-rs",
"disko": "disko",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_2",
"process-compose-flake": "process-compose-flake",
"services-flake": "services-flake"
"services-flake": "services-flake",
"sops-nix": "sops-nix"
}
},
"services-flake": {
@@ -140,6 +162,26 @@
"type": "github"
}
},
"sops-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1782165805,
"narHash": "sha256-478kKQBvK6SYTOdN2h9jhKJv94nbXRbFMfuL1WshErg=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "56b24064fdcaedca53553b1a6d607fd23b613a24",
"type": "github"
},
"original": {
"owner": "Mic92",
"repo": "sops-nix",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
+5 -1
View File
@@ -2,11 +2,15 @@
description = "Gebos IoT telemetry stack on bare-metal NixOS";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
flake-parts.url = "github:hercules-ci/flake-parts";
deploy-rs.url = "github:serokell/deploy-rs";
process-compose-flake.url = "github:Platonic-Systems/process-compose-flake";
services-flake.url = "github:juspay/services-flake";
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
+2 -3
View File
@@ -1,12 +1,11 @@
{ buildNpmPackage, lib }:
{ buildNpmPackage }:
buildNpmPackage {
pname = "gebos-frontend";
version = "0.0.0";
src = ./.;
# Filled in once package-lock.json exists.
npmDepsHash = lib.fakeHash;
npmDepsHash = "sha256-GeXgNbf94QRLNaZP8ma2vx19Vzm8jDWPeOTvE9exwm0=";
installPhase = ''
runHook preInstall
+2 -2
View File
@@ -7,11 +7,11 @@ import { createClient } from "@supabase/supabase-js";
// and VITE_SUPABASE_ANON_KEY set at `nix build .#frontend` time.
const DEV_SUPABASE_URL = "http://127.0.0.1:8000";
const DEV_SUPABASE_ANON_KEY =
"dev-anon-key-placeholder-regenerate-with-supabase-self-host-script";
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc1MTMyODAwMCwiZXhwIjoyMDgyNzU4NDAwfQ.M6U-nZq9dySoUJEfWHsiB0qabhATWobGaTM1LF86HHU";
const SUPABASE_URL =
import.meta.env.VITE_SUPABASE_URL ??
(import.meta.env.DEV ? DEV_SUPABASE_URL : "https://api.ge-bos.de");
(import.meta.env.DEV ? DEV_SUPABASE_URL : "https://api.gebos.online");
const SUPABASE_ANON_KEY =
import.meta.env.VITE_SUPABASE_ANON_KEY ??
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+4
View File
@@ -8,6 +8,10 @@ buildGoModule {
# Set once `go mod tidy` has produced a real go.sum.
vendorHash = null;
# The main package lives in cmd/gebos-ingester/, so `go install` names the
# binary after that directory — matching mainProgram and the systemd unit.
subPackages = [ "cmd/gebos-ingester" ];
meta = {
description = "MQTT Postgres telemetry ingester for Gebos";
mainProgram = "gebos-ingester";
+106
View File
@@ -0,0 +1,106 @@
# Gateway MQTT connectivity — debugging findings (July 2026)
Why the ACRIOS `ACR_CV_101N_W_D2` gateway (SIM7022 NB-IoT modem) could not
connect to `ingest.gebos.online:8883`, how we proved it, and what to do next.
**TL;DR: the bundled SIM lives in a private ACRIOS APN (`acrios.iot`) with no
public-internet breakout. The broker, TLS setup, and gateway firmware are all
fine.** No packet from the gateway ever reached our host; every failure
happened inside the carrier/APN network.
## Root cause
The modem's own PDP-context diagnostics (see step 6) show:
```
+CGDCONT: 0,"IP","acrios.iot","10.10.111.184"
+CGPADDR: 0,"10.10.111.184"
+CGCONTRDP: 0,5,"acrios.iot","10.10.111.184.255.255.255.0",,"10.65.0.1","10.65.0.2",,,,,1430
```
- APN `acrios.iot` — a private APN; the SIM (IMSI prefix 901-28, a global
IoT-roaming range) is provisioned for ACRIOS's walled garden only.
- Private device IP (`10.10.111.184`) and private DNS servers (`10.65.0.x`).
- Result: DNS for public names fails (`+CMQTTCONNECT: 0,25`) and raw TCP to a
public IP fails (`+CMQTTCONNECT: 0,3`). Signalling-plane features (network
registration, NITZ time sync, CSQ) all work, which made the SIM *look* fine.
- `APN = "auto"` in the Lua config is not the bug: `acrios.iot` is almost
certainly the only APN this SIM subscription allows, so overriding the APN
string alone cannot fix it.
This also explains the firmware's `acrios/` topic prefix — these gateways are
normally sold talking to ACRIOS's own MQTT cloud inside that APN.
## Debugging steps (in order, with what each eliminated)
1. **Verified the broker from the outside.** From a dev machine:
`openssl s_client -connect ingest.gebos.online:8883` (valid Let's Encrypt
RSA-2048 chain, TLS 1.2 + 1.3 OK) and
`mosquitto_pub ... -u bender -P <device pw>``CONNACK 0`, publish
accepted. ⇒ EMQX, its ACME cert, and the device credentials all work; the
problem is specific to the gateway's path.
2. **Read the serial log symptom precisely.** `AT+CMQTTCONNECT` returned `OK`
(command accepted) but the result URC never arrived before the script's
20 s timeout — indistinguishable from a TLS stall at that point.
3. **Checked EMQX for handshake errors.** `docker logs` showed nothing — but a
deliberately failed cipher probe from the dev machine *also* logged nothing,
proving EMQX's default log level swallows TLS failures. Inconclusive, not
exonerating. (`emqx ctl listeners` shutdown counters were similarly
ambiguous; `emqx ctl log set-level debug` enables visibility.)
4. **tcpdump on the ingest host** (`tcpdump -i any 'tcp port 8883'`) while the
gateway retried: zero packets from the gateway, ever. ⇒ nothing above the
IP layer (TLS version, ciphers, cert chain, EMQX config) could be the cause.
5. **Script changes to force better diagnostics** (v1.8 → v1.10, see below):
- `mqtt_timeout` 20 s → 60 s: the modem finally had time to report real
result codes instead of being abandoned mid-attempt.
⇒ surfaced `+CMQTTCONNECT: 0,25` = **DNS error**.
- `mqtt_server` hostname → IP literal `178.104.178.108`: bypassed DNS.
⇒ error changed to `+CMQTTCONNECT: 0,3` = **socket connect fail**.
DNS *and* TCP both dead, registration fine → no data breakout.
- `authmode 1 → 0` (skip server-cert verification): ruled out the modem
rejecting our CA chain; made no difference (never reached TLS). Note the
stock ACRIOS script enables cert verification whenever an MQTT username
is set, yet configures no CA — with this SIM it never got far enough to
matter, but it could never have verified successfully anyway.
- Added PDP-context dump (`AT+CGDCONT?`, `AT+CGPADDR`, `AT+CGCONTRDP`)
before every connect attempt. ⇒ produced the root-cause evidence above.
6. **SIMCom CMQTT result codes seen** (for future reference):
`+CMQTTCONNECT: 0,25` = DNS error · `0,3` = socket connect fail ·
`+CMQTTPUB: 0,26` = socket closed / not connected (follow-on noise, not a
distinct fault). No URC before timeout usually means the command was still
waiting — raise the wait, don't guess.
## Current state of `mbus_mqtt_gebos.lua` (v1.10) — temporary changes to revert
| Change | Why it's there | Revert when |
| --- | --- | --- |
| `mqtt_server = "178.104.178.108"` | bypass private-APN DNS | breakout exists **and** public DNS resolves |
| `AT+CSSLCFG="authmode",0,0` | no CA provisioned on modem | ISRG Root X1 loaded (`AT+CCERTDOWN`) + `cacert` configured → set authmode 1 |
| `mqtt_timeout = 60000` | see real modem result codes | after measuring real handshake time on a working network |
| PDP-context dump in `MQTT_SSL_Start()` | root-cause evidence | once connectivity is stable (costs battery/airtime per connect) |
## Options to get data flowing
1. **Preferred: ask ACRIOS** (SIM contract holder) to enable internet breakout
on the APN, or whitelist `ingest.gebos.online` / `178.104.178.108:8883`
for our SIMs. Hardware untouched.
2. **Own SIM:** any IoT SIM with public breakout (1NCE, EMnify, local carrier
NB-IoT, …); set `APN = "<provider apn>"` explicitly in the Lua config.
Check provider NB-IoT coverage on bands 3/5/8/20 (what the firmware
configures).
3. Fallback: ACRIOS cloud bridge/forwarding — reintroduces the vendor
dependency the self-hosted ingest stack exists to avoid.
## Verified-good along the way
- EMQX 5.8.6 (oci-container, host network) serving MQTTS :8883 with the ACME
(HTTP-01) RSA-2048 cert; hot-reloads renewed PEMs.
- Broker auth: `bender` device login + ACL, external end-to-end publish test.
- Note for MQTTX/dashboard tests: EMQX default log level logs neither TLS
handshake failures nor connects; use `emqx ctl log set-level debug`
temporarily, and `emqx ctl listeners` for connection/shutdown counters.
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -12,14 +12,28 @@ let
};
in
{
sshOpts = [ "-o" "StrictHostKeyChecking=accept-new" ];
# Path-free ssh options only. The key path and known_hosts are runner-specific
# and injected by the deploy.yml via deploy-rs `--ssh-opts` (which appends to
# this list for both `nix copy` and the activation SSH). They must be absolute:
# the gitea-runner is a systemd DynamicUser whose passwd home is `/`, so ssh
# expands `~` to `/.ssh/...` (NOT $HOME) and a tilde here finds no key. The
# workflow builds the absolute paths from $HOME, where bash expands correctly.
# `-F none` ignores the runner's ~/.ssh/config; IdentitiesOnly pins the key.
sshOpts = [
"-F" "none"
"-o" "IdentitiesOnly=yes"
"-o" "StrictHostKeyChecking=accept-new"
];
autoRollback = true;
magicRollback = true;
nodes = {
# Order matters: the Gitea Actions workflow invokes them in this sequence.
db-host = mkNode "db-host" "db-host.gebos.internal"; # TODO: real address
app-host = mkNode "app-host" "app-host.gebos.internal"; # TODO: real address
mqtt-ingest = mkNode "mqtt-ingest" "ingest.ge-bos.de";
# SSH/deploy targets use public hostnames because the Gitea runner is not on
# the private network. Host-to-host traffic still uses the 10.0.0.0/8 IPs.
# See the host address table in README.md.
db-host = mkNode "db-host" "db.gebos.online";
app-host = mkNode "app-host" "app.gebos.online";
mqtt-ingest = mkNode "mqtt-ingest" "ingest.gebos.online";
};
}
+206 -15
View File
@@ -15,17 +15,25 @@ let
pgPassword = "dev";
pgHost = "127.0.0.1";
pgPort = 5432;
# Unix socket dir, bind-mounted into the compose network's `db` proxy
# (see composeDevOverride below). Relative to the process-compose cwd,
# like services-flake's ./data/db data dir.
pgSocketDir = "./data/db-socket";
mqttHost = "127.0.0.1";
mqttPort = 1883;
# Pre-generated dev JWT pair, signed with `jwt_secret = "dev-jwt-secret-32chars-minimum-xxxxx"`.
# Anyone with this token can read/write the local dev stack only — not prod.
jwtSecret = "dev-jwt-secret-32chars-minimum-xxxxx";
anonKey = "dev-anon-key-placeholder-regenerate-with-supabase-self-host-script";
serviceRoleKey = "dev-service-role-key-placeholder-regenerate-with-supabase-self-host-script";
# Pre-generated dev JWT pair, signed HS256 with jwtSecret. Regenerate
# with any JWT tool if jwtSecret ever changes; payload is
# {"role":"<anon|service_role>","iss":"supabase-dev","iat":1751328000,"exp":2082758400}.
# Anyone with these tokens can read/write the local dev stack only — not prod.
jwtSecret = "dev-jwt-secret-32chars-minimum-xxxxx";
anonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc1MTMyODAwMCwiZXhwIjoyMDgyNzU4NDAwfQ.M6U-nZq9dySoUJEfWHsiB0qabhATWobGaTM1LF86HHU";
serviceRoleKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UtZGV2IiwiaWF0IjoxNzUxMzI4MDAwLCJleHAiOjIwODI3NTg0MDB9.dNeWNshQ9e1pIjxTDYseURlbSPPArVIKa9OuTRZyIH8";
supabaseUrl = "http://127.0.0.1:8000";
frontendPort = 5173; # vite default
};
ingesterEnv = {
@@ -38,6 +46,140 @@ let
VITE_SUPABASE_URL = dev.supabaseUrl;
VITE_SUPABASE_ANON_KEY = dev.anonKey;
};
# services-flake ships no MQTT broker, so run mosquitto as a plain
# process-compose process. Anonymous access on the dev listener only.
mosquittoConf = pkgs.writeText "mosquitto-dev.conf" ''
listener ${toString dev.mqttPort} ${dev.mqttHost}
allow_anonymous true
'';
# ---------------------------------------------------------------------------
# Supabase — the vendored prod compose bundle (nix/supabase/), unchanged,
# plus this dev overlay.
#
# The overlay's job is wiring the containers to the services-flake postgres.
# Bridge→host TCP is a dead end on a typical NixOS dev machine (the host
# firewall's INPUT chain rejects it), so instead a socat `db` service
# forwards TCP 5432 inside the compose network onto the host postgres unix
# socket, bind-mounted from pgSocketDir. Conveniently that restores the
# upstream compose convention of a service literally named `db`, so the
# vendored file works with just POSTGRES_HOST=db. Socket connections match
# pg_hba's `local … trust`, so the dev password is never actually checked.
# ---------------------------------------------------------------------------
composeDevOverride = pkgs.writeText "docker-compose.dev-override.yml" ''
services:
db:
container_name: supabase-dev-db
# Unpinned minor: dev-only TCPunix-socket shim, nothing depends on
# socat internals.
image: alpine/socat:1.8.0.3
restart: unless-stopped
command: TCP-LISTEN:${toString dev.pgPort},fork,reuseaddr UNIX-CONNECT:/host-postgres/.s.PGSQL.${toString dev.pgPort}
volumes:
- ''${PG_SOCKET_DIR:?set by gebos-dev-supabase-compose}:/host-postgres
studio:
environment:
SUPABASE_PUBLIC_URL: ${dev.supabaseUrl}
auth:
environment:
API_EXTERNAL_URL: ${dev.supabaseUrl}
GOTRUE_SITE_URL: http://localhost:${toString dev.frontendPort}
GOTRUE_JWT_ISSUER: ${dev.supabaseUrl}
# No SMTP locally, so magic-link invites can't send. Allow plain
# self-signup with auto-confirm instead; prod keeps signup disabled.
GOTRUE_DISABLE_SIGNUP: "false"
GOTRUE_MAILER_AUTOCONFIRM: "true"
'';
# One wrapper for `up`/`down` so both resolve the socket-dir bind mount the
# same way (compose needs an absolute path).
supabaseCompose = pkgs.writeShellApplication {
name = "gebos-dev-supabase-compose";
runtimeInputs = [ pkgs.docker pkgs.coreutils ];
text = ''
PG_SOCKET_DIR="$(readlink -f ${dev.pgSocketDir})"
export PG_SOCKET_DIR
exec docker compose \
--project-name gebos-supabase-dev \
-f nix/supabase/docker-compose.yml \
-f ${composeDevOverride} \
"$@"
'';
};
supabaseEnv = {
POSTGRES_HOST = "db"; # the socat proxy above
POSTGRES_PORT = toString dev.pgPort;
POSTGRES_DB = dev.pgDB;
POSTGRES_PASSWORD = dev.pgPassword; # unchecked — see overlay comment
JWT_SECRET = dev.jwtSecret;
ANON_KEY = dev.anonKey;
SERVICE_ROLE_KEY = dev.serviceRoleKey;
STUDIO_BIND = "127.0.0.1";
};
# Everything prod splits across nix/supabase/init.sql (first initdb),
# the gebos-postgres-passwords oneshot, and a manual `supabase db push`,
# collapsed into one idempotent script that runs on every stack start.
dbMigrate = pkgs.writeShellApplication {
name = "gebos-dev-db-migrate";
runtimeInputs = [ pkgs.supabase-cli pkgs.postgresql_17 ];
text = ''
export PGHOST=${dev.pgHost} PGPORT=${toString dev.pgPort} PGDATABASE=${dev.pgDB} PGSSLMODE=disable
# 1. Cluster bootstrap: Supabase roles/schemas the compose services
# expect (mirrors nix/supabase/init.sql, but idempotent because the
# dev data dir may predate this script). Password 'dev' everywhere.
psql -v ON_ERROR_STOP=1 --quiet <<'SQL'
DO $$ BEGIN CREATE ROLE anon NOLOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE ROLE authenticated NOLOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE ROLE authenticator LOGIN NOINHERIT; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
GRANT anon, authenticated, service_role TO authenticator;
DO $$ BEGIN CREATE ROLE supabase_admin LOGIN CREATEROLE CREATEDB BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE ROLE supabase_auth_admin LOGIN CREATEROLE; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- On db-host the NixOS postgresql module creates the `postgres`
-- superuser; the services-flake cluster's superuser is the OS user
-- instead, and GoTrue's own migrations GRANT to `postgres` by name.
DO $$ BEGIN CREATE ROLE postgres LOGIN SUPERUSER; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
ALTER ROLE postgres PASSWORD 'dev';
ALTER ROLE supabase_admin SUPERUSER PASSWORD 'dev';
ALTER ROLE supabase_auth_admin PASSWORD 'dev';
ALTER ROLE authenticator PASSWORD 'dev';
-- GoTrue owns `auth` and runs its own migrations there on startup. Its
-- migrator creates tables in the role's default schema, so without the
-- search_path it lands in `public` and dies on PG15+'s revoked CREATE
-- (upstream's supabase/postgres image sets the same role search_path).
CREATE SCHEMA IF NOT EXISTS auth;
ALTER SCHEMA auth OWNER TO supabase_auth_admin;
ALTER ROLE supabase_auth_admin SET search_path TO auth, extensions;
-- The upstream supabase/postgres image keeps shared extensions in an
-- `extensions` schema on the search path; the services assume that.
CREATE SCHEMA IF NOT EXISTS extensions;
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
GRANT USAGE ON SCHEMA extensions TO anon, authenticated, service_role, authenticator, supabase_auth_admin;
ALTER DATABASE gebos SET search_path TO "$user", public, extensions;
SQL
# 2. Schema migrations same mechanism as prod (supabase/README.md),
# non-interactive, as the local superuser ($USER).
supabase db push --yes --db-url "postgres://$USER@${dev.pgHost}:${toString dev.pgPort}/${dev.pgDB}"
# 3. The schema-v1 migration created gebos_ingest; in prod its password
# comes from the gebos-postgres-passwords oneshot.
psql -v ON_ERROR_STOP=1 --quiet -c "ALTER ROLE ${dev.pgUser} PASSWORD '${dev.pgPassword}'"
# 4. Fixture data idempotent, dev/tests only (supabase/README.md).
psql -v ON_ERROR_STOP=1 --quiet -f supabase/seed.sql
'';
};
in
{
imports = [ services-flake.processComposeModules.default ];
@@ -46,30 +188,79 @@ in
postgres."db" = {
enable = true;
port = dev.pgPort;
# TCP stays loopback-only; the compose containers come in through the
# unix socket instead (see composeDevOverride).
socketDir = dev.pgSocketDir;
initialDatabases = [{ name = dev.pgDB; }];
};
mosquitto."broker" = {
enable = true;
port = dev.mqttPort;
# The schema-v1 migration calls create_hypertable; without the
# extension `supabase db push` rolls back entirely on the dev db.
# The apache variant lacks only TSL features (compression, CAggs
# policies) — hypertables are enough here, and it keeps dev free
# of unfree packages.
extensions = extensions: [ extensions.timescaledb-apache ];
settings.shared_preload_libraries = "timescaledb";
};
};
settings.processes = {
broker = {
command = "${pkgs.mosquitto}/bin/mosquitto -c ${mosquittoConf}";
readiness_probe = {
exec.command = "${pkgs.mosquitto}/bin/mosquitto_sub -h ${dev.mqttHost} -p ${toString dev.mqttPort} -t '$$SYS/#' -C 1 -W 2";
initial_delay_seconds = 1;
period_seconds = 2;
};
};
# Oneshot: bootstrap roles/schemas, apply migrations, seed. Idempotent,
# so it simply re-runs on every stack start.
db-migrate = {
command = "${dbMigrate}/bin/gebos-dev-db-migrate";
depends_on."db".condition = "process_healthy";
};
# Auth (GoTrue) + REST (PostgREST) + Studio + postgres-meta behind Kong
# on ${dev.supabaseUrl} — the vendored prod bundle, see composeDevOverride.
# Needs a running docker daemon on the dev machine.
supabase = {
command = "${supabaseCompose}/bin/gebos-dev-supabase-compose up --remove-orphans";
environment = supabaseEnv;
# `restart: unless-stopped` containers outlive a hard-killed
# process-compose; an explicit `down` covers the clean path.
shutdown.command = "${supabaseCompose}/bin/gebos-dev-supabase-compose down --remove-orphans";
depends_on = {
# GoTrue's own migrations need the auth schema + roles from db-migrate.
"db".condition = "process_healthy";
"db-migrate".condition = "process_completed_successfully";
};
readiness_probe = {
# Kong → key-auth → GoTrue → Postgres: healthy = the whole API path works.
exec.command = "${pkgs.curl}/bin/curl -fsS -H 'apikey: ${dev.anonKey}' ${dev.supabaseUrl}/auth/v1/health";
initial_delay_seconds = 5;
period_seconds = 5;
failure_threshold = 60; # first start pulls the images
};
};
ingester = {
command = "${pkgs.go}/bin/go run ./ingester";
# -C: the go module root is ingester/, not the repo root.
command = "${pkgs.go}/bin/go run -C ingester ./cmd/gebos-ingester";
environment = ingesterEnv;
depends_on = {
"db".condition = "process_healthy";
# db-migrate (implies db healthy) creates the gebos_ingest role and
# the telemetry tables.
"db-migrate".condition = "process_completed_successfully";
"broker".condition = "process_started";
};
};
frontend = {
command = "${pkgs.nodejs_24}/bin/npm --prefix frontend install && ${pkgs.nodejs_24}/bin/npm --prefix frontend run dev";
environment = frontendEnv;
};
# TODO: supabase compose (point POSTGRES_HOST at the services-flake db)
# TODO: kong (use the vendored kong.yml from nix/supabase/)
# TODO: caddy (mirror prod routes locally, no TLS)
# No caddy locally: prod's Caddy config is generated by the gebos-caddy
# NixOS module, so a hand-mirrored dev caddyfile wouldn't test it — the
# frontend talks to Kong directly (dev.supabaseUrl).
};
}
+3 -3
View File
@@ -8,7 +8,7 @@
services.gebos.supabase = {
enable = true;
# Compose stack points at external db-host instead of the bundled `db` service.
postgresHost = "db-host.gebos.internal"; # TODO: real internal hostname
postgresHost = "10.0.0.2"; # db-host on the private network
postgresPort = 5432;
# Studio bound to loopback only — reach via `ssh -L 3000:127.0.0.1:3000 app-host`.
studioBindAddress = "127.0.0.1";
@@ -18,8 +18,8 @@
enable = true;
# Caddy terminates TLS, proxies to Kong on 127.0.0.1:8000.
sites = {
"api.ge-bos.de" = { upstream = "127.0.0.1:8000"; }; # → Kong → PostgREST/GoTrue
"app.ge-bos.de" = { staticRoot = "${config.services.gebos.frontend.package}/share/frontend"; };
"api.gebos.online" = { upstream = "127.0.0.1:8000"; }; # → Kong → PostgREST/GoTrue
"app.gebos.online" = { staticRoot = "${config.services.gebos.frontend.package}/share/frontend"; };
};
};
+15 -1
View File
@@ -1,7 +1,17 @@
{ config, lib, pkgs, ... }:
{
system.stateVersion = "25.05";
system.stateVersion = "26.05";
# Bootloader. The disko layout (hardware-configurations/disk-config.nix) lays
# down both a BIOS-boot (EF02) partition and an ESP mounted at /boot, so grub
# works whether the VM firmware is legacy BIOS or UEFI. disko fills in
# boot.loader.grub.devices from the EF02 partition; we just enable grub here.
boot.loader.grub = {
enable = true;
efiSupport = true;
efiInstallAsRemovable = true;
};
nix.settings = {
experimental-features = [ "nix-command" "flakes" ];
@@ -23,6 +33,10 @@
enable = true;
settings.PasswordAuthentication = false;
};
# TODO: restrict inbound SSH (port 22) to the Gitea deployment runner's IP
# only, instead of leaving it open to the world. Likely via
# services.openssh.openFirewall = false + a firewall.extraInputRules rule
# accepting tcp dport 22 from the runner's source address.
networking.firewall.enable = true;
+9 -4
View File
@@ -1,13 +1,18 @@
{ config, lib, pkgs, ... }:
{
# Postgres data lives on the dedicated second disk (/dev/sdb), not the OS disk.
# The shared hardware-configuration.nix + sda disk layout come from ./default.nix.
imports = [ ./hardware-configurations/db-data-disk.nix ];
networking.hostName = "db-host";
services.gebos.secrets.postgresAdmin = true;
# Skeleton: real hardware-configuration.nix + boot/fs lives next to this file
# once we have the actual box. Marked here so the structure is visible.
# imports = [ ./db-host.hardware.nix ];
# The gebos-postgres-passwords oneshot (gebos-postgres.nix) also syncs the
# shared Supabase service-role password and the ingester's password, so
# db-host needs those keys rendered into its env file too.
services.gebos.secrets.supabase = true;
services.gebos.secrets.ingester = true;
services.gebos.postgres = {
enable = true;
+9 -1
View File
@@ -10,11 +10,19 @@ let
modules = [
./common.nix
inputs.sops-nix.nixosModules.sops
inputs.disko.nixosModules.disko
# All three boxes are identical qemu guests, so they share one
# hardware-configuration.nix and one disko disk layout (single
# /dev/sda → GPT → LVM → ext4 root). disko derives fileSystems +
# bootloader devices from disk-config.nix. db-host additionally
# imports the /dev/sdb data disk from ./db-host.nix.
./hardware-configurations/hardware-configuration.nix
./hardware-configurations/disk-config.nix
modules.gebos-secrets
modules.gebos-postgres
modules.gebos-supabase
modules.gebos-caddy
modules.gebos-hivemq
modules.gebos-emqx
modules.gebos-ingester
modules.gebos-frontend
./${name}.nix
@@ -0,0 +1,28 @@
{ lib, ... }:
# Second disk for db-host: the Postgres data directory lives on /dev/sdb, kept
# separate from the OS disk (/dev/sda). The whole disk is one GPT partition,
# ext4, mounted at /var/lib/postgresql — so the cluster data dir
# (/var/lib/postgresql/17, the upstream NixOS default) sits on this drive and
# survives an OS-disk rebuild.
{
disko.devices.disk.disk2 = {
device = lib.mkDefault "/dev/sdb";
type = "disk";
content = {
type = "gpt";
partitions = {
data = {
name = "data";
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/var/lib/postgresql";
mountOptions = [ "defaults" ];
};
};
};
};
};
}
@@ -0,0 +1,55 @@
{ lib, ... }:
{
disko.devices = {
disk.disk1 = {
device = lib.mkDefault "/dev/sda";
type = "disk";
content = {
type = "gpt";
partitions = {
boot = {
name = "boot";
size = "1M";
type = "EF02";
};
esp = {
name = "ESP";
size = "500M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
root = {
name = "root";
size = "100%";
content = {
type = "lvm_pv";
vg = "pool";
};
};
};
};
};
lvm_vg = {
pool = {
type = "lvm_vg";
lvs = {
root = {
size = "100%FREE";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
mountOptions = [
"defaults"
];
};
};
};
};
};
};
}
@@ -0,0 +1,14 @@
{ config, lib, pkgs, modulesPath, ... }:
{
imports =
[ (modulesPath + "/profiles/qemu-guest.nix")
];
boot.initrd.availableKernelModules = [ "ahci" "xhci_pci" "virtio_pci" "virtio_scsi" "sd_mod" "sr_mod" ];
boot.initrd.kernelModules = [ "dm-snapshot" ];
boot.kernelModules = [ ];
boot.extraModulePackages = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}
+12 -12
View File
@@ -4,9 +4,15 @@
networking.hostName = "mqtt-ingest";
services.gebos.secrets.ingester = true;
# Broker authn users (users.csv).
services.gebos.secrets.emqx = true;
services.gebos.hivemq = {
# EMQX (official container image) terminates TLS on :8883 itself and serves
# the ingester on loopback :1883. Certs come from security.acme via HTTP-01
# (lego standalone on :80) — no Caddy on this host anymore.
services.gebos.emqx = {
enable = true;
domain = "ingest.gebos.online";
# Persistent sessions on disk — single-node, devices reconnect on restart.
persistentSessions = true;
tlsPort = 8883;
@@ -14,18 +20,12 @@
services.gebos.ingester = {
enable = true;
# mqttBroker defaults to tcp://127.0.0.1:1883 (the local HiveMQ).
postgresUrl = "postgres://gebos_ingest@db-host.gebos.internal:5432/postgres?sslmode=require";
# mqttBroker defaults to tcp://127.0.0.1:1883 (the local EMQX).
postgresUrl = "postgres://gebos_ingest@10.0.0.2:5432/postgres?sslmode=require";
# GEBOS_POSTGRES_PASSWORD sourced from /run/secrets/gebos-env (sops-nix).
};
# Caddy fronts TLS for `ingest.ge-bos.de` and forwards to HiveMQ.
services.gebos.caddy = {
enable = true;
sites = {
"ingest.ge-bos.de" = { tcpProxy = "127.0.0.1:8883"; };
};
};
networking.firewall.allowedTCPPorts = [ 8883 ];
# 8883: MQTTS for senders. 80: ACME HTTP-01 challenge (lego's standalone
# solver binds it during issuance/renewal; idle otherwise).
networking.firewall.allowedTCPPorts = [ 80 8883 ];
}
+1 -1
View File
@@ -4,6 +4,6 @@
gebos-supabase = import ./gebos-supabase.nix;
gebos-caddy = import ./gebos-caddy.nix;
gebos-postgres = import ./gebos-postgres.nix;
gebos-hivemq = import ./gebos-hivemq.nix;
gebos-emqx = import ./gebos-emqx.nix;
gebos-frontend = import ./gebos-frontend.nix;
}
+4 -11
View File
@@ -3,6 +3,7 @@
let
cfg = config.services.gebos.caddy;
# HTTP-style sites: static file server or reverse proxy.
siteBlock = host: site:
if site ? staticRoot then ''
${host} {
@@ -18,14 +19,7 @@ let
encode zstd gzip
}
''
else if site ? tcpProxy then ''
${host}:8883 {
# TLS termination for MQTT Caddy's `layer4` app would be ideal here;
# for now, document that the upstream HiveMQ port is ${site.tcpProxy}.
# TODO: switch to the caddy-l4 module or terminate TLS in HiveMQ directly.
}
''
else throw "site `${host}` needs one of: staticRoot, upstream, tcpProxy";
else throw "site `${host}` needs one of: staticRoot, upstream";
in
{
options.services.gebos.caddy = {
@@ -39,9 +33,8 @@ in
config = lib.mkIf cfg.enable {
services.caddy = {
enable = true;
email = "ops@ge-bos.de"; # TODO: confirm ACME contact
extraConfig = lib.concatStringsSep "\n"
(lib.mapAttrsToList siteBlock cfg.sites);
email = "ops@gebos.online"; # TODO: confirm ACME contact
extraConfig = lib.concatStringsSep "\n" (lib.mapAttrsToList siteBlock cfg.sites);
};
};
}
+238
View File
@@ -0,0 +1,238 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.gebos.emqx;
stateDir = "/var/lib/gebos-emqx";
# UID/GID of the `emqx` user baked into the official image. Host-side files
# the container must read or write (certs, bootstrap users, data dir) are
# chowned to this numeric id. On these hosts uid 1000 is the `deploy` user,
# which already has passwordless sudo, so this grants it nothing new.
emqxUid = 1000;
emqxGid = 1000;
# Full emqx.conf, replacing the image default. EMQX reads HOCON natively;
# container paths below are bind mounts declared on the oci-container.
emqxConf = pkgs.writeText "emqx.conf" ''
node {
name = "emqx@127.0.0.1"
# Erlang distribution cookie. Not treated as a secret: the dist/epmd
# ports are never opened in the firewall and the only local users are
# root and deploy (wheel).
cookie = "gebos-emqx"
data_dir = "data"
}
cluster {
name = gebos
# Single node, and required to be `singleton` for durable sessions on
# the open-source builtin_local storage backend.
discovery_strategy = singleton
}
# Dashboard on loopback only (the container uses host networking) reach
# it via `ssh -L 18083:127.0.0.1:18083`. First login is admin/public with a
# forced password change; the changed password persists in the data dir.
dashboard {
listeners.http.bind = "127.0.0.1:18083"
}
# Plain MQTT for the co-located ingester only.
listeners.tcp.default {
bind = "${cfg.bindAddress}:${toString cfg.port}"
}
# MQTTS for the senders. EMQX terminates TLS itself; certs are managed by
# security.acme on the host and installed into ${stateDir}/certs (mounted
# at /etc/emqx/certs). EMQX re-reads the PEM files from disk periodically
# (~120 s), so renewals need no restart paths stay fixed, contents swap.
listeners.ssl.default {
bind = "0.0.0.0:${toString cfg.tlsPort}"
ssl_options {
certfile = "/etc/emqx/certs/fullchain.pem"
keyfile = "/etc/emqx/certs/privkey.pem"
versions = ["tlsv1.2", "tlsv1.3"]
}
}
# The schema defines websocket listeners on 8083/8084 by default unused.
listeners.ws.default.enable = false
listeners.wss.default.enable = false
# Sessions and queued messages survive a broker restart (QoS > 0 with
# clean_start = false), mirroring HiveMQ's file persistence mode.
durable_sessions.enable = ${lib.boolToString cfg.persistentSessions}
# Username/password auth against the built-in database, seeded from the
# sops-rendered users.csv on first start (bootstrap only inserts users that
# do not exist yet to rotate a password, delete the user in the dashboard
# or via `emqx ctl`, then restart to re-import).
authentication = [
{
mechanism = password_based
backend = built_in_database
user_id_type = username
password_hash_algorithm { name = plain, salt_position = disable }
bootstrap_file = "/etc/emqx/auth/users.csv"
bootstrap_type = plain
}
]
# Topic-level authorization from the static acl.conf; anything not
# explicitly allowed is denied.
authorization {
no_match = deny
sources = [
{
type = file
enable = true
path = "/etc/emqx/auth/acl.conf"
}
]
}
'';
# Topic policy (non-secret — usernames and topics only, no credentials).
# Devices publish under `acrios/<IMSI>/<metric>` (the sender firmware's
# mqtt_topic_base), so that is the namespace the ingester consumes and the
# device user is scoped to. `admin` is is_superuser in users.csv and bypasses
# this ACL entirely (break-glass).
#
# Option A (current): one shared device user (`bender`) for the whole fleet.
# Tenant isolation is enforced downstream by the ingester's IMSI→tenant
# registry lookup, NOT at the broker — any device could publish under any
# IMSI. TODO(Option B): per-device users with username == IMSI and an ACL
# rule scoped to `acrios/''${username}/#` for real anti-spoofing.
aclConf = pkgs.writeText "emqx-acl.conf" ''
%% ingester: consume the whole device tree.
{allow, {username, "ingester"}, subscribe, ["acrios/#"]}.
%% device fleet: publish telemetry + LWT/status, subscribe downlinks.
{allow, {username, "bender"}, all, ["acrios/#"]}.
{deny, all}.
'';
in
{
options.services.gebos.emqx = {
enable = lib.mkEnableOption "EMQX broker (official container image) with native TLS";
image = lib.mkOption {
type = lib.types.str;
default = "emqx/emqx:5.8.6";
description = "Official EMQX image reference to run.";
};
domain = lib.mkOption {
type = lib.types.str;
example = "ingest.gebos.online";
description = "Public hostname the senders connect to; ACME cert is issued for it.";
};
tlsPort = lib.mkOption {
type = lib.types.port;
default = 8883;
description = "MQTTS listener port (TLS terminated by EMQX itself).";
};
port = lib.mkOption {
type = lib.types.port;
default = 1883;
description = "Plain MQTT TCP listener port (what the ingester connects to).";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = ''
Address the plain MQTT listener binds to. Defaults to loopback:
external clients only ever reach the TLS listener.
'';
};
persistentSessions = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether client sessions and queued messages survive a restart
(EMQX durable sessions) or are kept only in memory.
'';
};
acmeEmail = lib.mkOption {
type = lib.types.str;
default = "ops@gebos.online";
description = "ACME account contact for the broker certificate.";
};
bootstrapUsersFile = lib.mkOption {
type = lib.types.path;
default = config.services.gebos.secrets.emqxBootstrapUsersFile;
defaultText = lib.literalExpression "config.services.gebos.secrets.emqxBootstrapUsersFile";
description = ''
CSV (user_id,password,is_superuser) seeding EMQX's built-in auth
database. Defaults to the sops-rendered secret; copied into the state
dir on service start so the container user can read it.
'';
};
};
config = lib.mkIf cfg.enable {
virtualisation.oci-containers = {
backend = "docker";
containers.emqx = {
image = cfg.image;
# Host networking: EMQX binds host ports directly, so loopback-only
# listeners (plain MQTT, dashboard) really are loopback-only and the
# TLS listener sees real client source addresses.
extraOptions = [ "--network=host" ];
volumes = [
"${emqxConf}:/opt/emqx/etc/emqx.conf:ro"
"${aclConf}:/etc/emqx/auth/acl.conf:ro"
"${stateDir}/auth/users.csv:/etc/emqx/auth/users.csv:ro"
"${stateDir}/certs:/etc/emqx/certs:ro"
"${stateDir}/data:/opt/emqx/data"
];
};
};
# The oci-containers unit is docker-emqx.service; extend it to stage the
# writable state the container mounts. The bootstrap users file is copied
# (not symlinked) out of the sops tmpfs so it can be owned by the
# container's emqx uid.
systemd.services.docker-emqx = {
# Don't start before the first cert issuance has been attempted. If ACME
# fails (e.g. Porkbun creds not provisioned yet), EMQX crash-loops on the
# missing certfile until the cert lands — Restart=always retries.
after = [ "acme-finished-${cfg.domain}.target" ];
wants = [ "acme-finished-${cfg.domain}.target" ];
preStart = ''
install -d -m 750 -o ${toString emqxUid} -g ${toString emqxGid} ${stateDir}/data
install -d -m 755 ${stateDir}/auth ${stateDir}/certs
install -m 400 -o ${toString emqxUid} -g ${toString emqxGid} \
${cfg.bootstrapUsersFile} ${stateDir}/auth/users.csv
'';
};
# Cert issuance + renewal via HTTP-01: lego's built-in standalone server
# answers the challenge on :80 (nothing else listens there — Caddy is gone
# from this host), so no DNS API secrets are needed; the host just keeps
# port 80 open alongside 8883. postRun is the deploy hook: install the
# PEMs at the fixed paths EMQX watches, owned by the container user. EMQX
# hot-reloads them; no restart or `emqx ctl` needed. RSA keys because
# embedded sender TLS stacks often lack ECDSA.
security.acme = {
acceptTerms = true;
certs.${cfg.domain} = {
email = cfg.acmeEmail;
listenHTTP = ":80";
keyType = "rsa2048";
postRun = ''
install -D -m 644 -o ${toString emqxUid} -g ${toString emqxGid} \
fullchain.pem ${stateDir}/certs/fullchain.pem
install -D -m 600 -o ${toString emqxUid} -g ${toString emqxGid} \
key.pem ${stateDir}/certs/privkey.pem
'';
};
};
};
}
-33
View File
@@ -1,33 +0,0 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.gebos.hivemq;
in
{
options.services.gebos.hivemq = {
enable = lib.mkEnableOption "HiveMQ CE single-node broker";
persistentSessions = lib.mkOption {
type = lib.types.bool;
default = true;
};
tlsPort = lib.mkOption {
type = lib.types.port;
default = 8883;
};
};
config = lib.mkIf cfg.enable {
# TODO: package HiveMQ CE (download tarball + JRE wrapper) and wire as a
# systemd unit. Until then this module only declares options so dependent
# hosts can be evaluated.
systemd.services.gebos-hivemq = {
description = "HiveMQ CE";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.coreutils}/bin/true"; # TODO: real HiveMQ command
Restart = "on-failure";
};
};
};
}
+101 -3
View File
@@ -13,12 +13,20 @@ in
};
config = lib.mkIf cfg.enable {
# TimescaleDB ships under the non-OSI "Timescale License" (TSL), which
# nixpkgs flags as unfree. Allow just this package rather than opening the
# whole config to unfree.
nixpkgs.config.allowUnfreePredicate = pkg:
lib.elem (lib.getName pkg) [ "timescaledb" ];
services.postgresql = {
enable = true;
package = pkgs.postgresql_17;
enableTCPIP = true;
# listen_addresses is managed explicitly below. The upstream postgresql
# module also defines it (via its own default / enableTCPIP), so mkForce
# makes this module the single source of truth and avoids the collision.
settings = {
listen_addresses = lib.concatStringsSep "," cfg.listenAddresses;
listen_addresses = lib.mkForce (lib.concatStringsSep "," cfg.listenAddresses);
shared_preload_libraries = "timescaledb,pg_stat_statements";
};
extensions = ps: with ps; [
@@ -29,8 +37,98 @@ in
# supabase_vault — TODO: package or vendor
];
# Init script creates Supabase's `auth`, `storage`, `_analytics`, `_supavisor`
# schemas, the gebos_ingest role (BYPASSRLS), and telemetry hypertables.
# schemas and the Supabase admin/API roles. It only runs at first initdb;
# everything else arrives via manual supabase migrations (see
# supabase/README.md) and the password oneshot below.
initialScript = ../supabase/init.sql;
# The firewall limits 5432 to 10.0.0.0/8, but NixOS's default pg_hba
# only covers local sockets and loopback — without this line every
# connection from app-host / mqtt-ingest is rejected before password
# auth even starts.
authentication = ''
host all all 10.0.0.0/8 scram-sha-256
'';
};
# Role passwords are data, not configuration: NixOS has no declarative
# option for them (anything reachable from the config lands world-readable
# in /nix/store, and initialScript only runs at first initdb). This
# oneshot re-applies them from the sops-rendered env file on every boot /
# deploy, idempotently. It also enforces two attributes the Supabase
# services expect because the upstream supabase/postgres image ships them:
# supabase_admin is SUPERUSER (Studio/postgres-meta connect as it) and
# supabase_auth_admin owns the auth schema (GoTrue runs its own migrations
# there on startup).
#
# After rotating a password in secrets.yaml, re-run with
# `systemctl restart gebos-postgres-passwords` (deploys re-run it only
# when the unit definition itself changed).
systemd.services.gebos-postgres-passwords =
lib.mkIf config.services.gebos.secrets.postgresAdmin {
description = "Gebos sync Postgres role passwords from sops secrets";
after = [ "postgresql.service" ];
requires = [ "postgresql.service" ];
wantedBy = [ "multi-user.target" ];
path = [ config.services.postgresql.package ];
serviceConfig = {
Type = "oneshot";
User = "postgres";
# LoadCredential hands the root-owned 0400 env file to the postgres
# user without widening its permissions.
LoadCredential = "gebos-env:${config.services.gebos.secrets.envFile}";
};
script = ''
set -euo pipefail
set -a; . "$CREDENTIALS_DIRECTORY/gebos-env"; set +a
# All three come from the gebos-env template; the toggles in
# db-host.nix (postgresAdmin + supabase + ingester) must be on.
# --output=/dev/null: the set_config() SELECTs would otherwise echo
# the passwords into the journal.
psql -v ON_ERROR_STOP=1 --output=/dev/null \
-v admin_pw="''${POSTGRES_ADMIN_PASSWORD:?}" \
-v supabase_pw="''${POSTGRES_PASSWORD:?}" \
-v ingest_pw="''${GEBOS_POSTGRES_PASSWORD:?}" \
--dbname postgres <<'SQL'
-- psql :'var' interpolation does not reach inside DO bodies, so
-- stash the values in session GUCs first.
SELECT set_config('gebos.supabase_pw', :'supabase_pw', false);
SELECT set_config('gebos.ingest_pw', :'ingest_pw', false);
ALTER ROLE postgres PASSWORD :'admin_pw';
DO $do$
DECLARE r text;
BEGIN
-- One shared password for the roles the compose services log in
-- as, mirroring upstream's POSTGRES_PASSWORD convention.
FOREACH r IN ARRAY ARRAY['supabase_admin','supabase_auth_admin','authenticator'] LOOP
IF EXISTS (SELECT FROM pg_roles WHERE rolname = r) THEN
EXECUTE format('ALTER ROLE %I PASSWORD %L',
r, current_setting('gebos.supabase_pw'));
END IF;
END LOOP;
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_admin') THEN
EXECUTE 'ALTER ROLE supabase_admin SUPERUSER';
END IF;
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_auth_admin')
AND EXISTS (SELECT FROM pg_namespace WHERE nspname = 'auth') THEN
EXECUTE 'ALTER SCHEMA auth OWNER TO supabase_auth_admin';
END IF;
-- Created by the schema-v1 migration, so tolerate its absence on
-- a cluster the migration has not reached yet.
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'gebos_ingest') THEN
EXECUTE format('ALTER ROLE gebos_ingest PASSWORD %L',
current_setting('gebos.ingest_pw'));
END IF;
END
$do$;
SQL
'';
};
};
}
+58 -1
View File
@@ -17,6 +17,31 @@ let
];
ingesterKeys = [ "ingester_postgres_password" ];
postgresAdminKeys = [ "postgres_admin_password" ];
emqxKeys = [
"emqx_ingester_password"
"emqx_admin_password"
"emqx_device_password"
];
# EMQX authn bootstrap CSV (bootstrap_type = plain). Seeds the broker's
# built-in auth database on first start. Broker users:
# ingester — the Go ingester; SUBSCRIBE the whole device tree (see the ACL
# in gebos-emqx.nix).
# admin — operational break-glass; is_superuser bypasses the ACL.
# bender — shared device user for the whole fleet (Option A). Tenant
# isolation is enforced downstream by the ingester's IMSI→tenant
# registry, NOT at the broker. TODO(Option B): per-device users
# (username == IMSI) — see the ACL comment in gebos-emqx.nix.
#
# Plaintext passwords are acceptable here because the file is sops-encrypted
# at rest and rendered 0400 into a tmpfs at runtime; passwords must not
# contain commas (CSV).
emqxUsersCsv = ingesterPassword: adminPassword: devicePassword: ''
user_id,password,is_superuser
ingester,${ingesterPassword},false
admin,${adminPassword},true
bender,${devicePassword},false
'';
# Render an env file body from a list of (key, envVarName) pairs.
envBody = pairs: lib.concatStringsSep "\n" (map
@@ -41,7 +66,8 @@ let
enabledKeys =
lib.optionals cfg.supabase supabaseKeys
++ lib.optionals cfg.ingester ingesterKeys
++ lib.optionals cfg.postgresAdmin postgresAdminKeys;
++ lib.optionals cfg.postgresAdmin postgresAdminKeys
++ lib.optionals cfg.emqx emqxKeys;
in
{
options.services.gebos.secrets = {
@@ -55,6 +81,26 @@ in
postgresAdmin = lib.mkEnableOption ''
Postgres bootstrap admin password applied via a one-shot ALTER ROLE unit.'';
emqx = lib.mkEnableOption ''
EMQX broker secrets: the authn bootstrap users.csv (ingester + admin +
device MQTT users).'';
emqxBootstrapUsersFile = lib.mkOption {
type = lib.types.path;
readOnly = true;
description = ''
Path of the rendered EMQX authn bootstrap users.csv consumed by the
gebos-emqx broker. Resolves to the sops-templated secret when sops
material is present and the emqx toggle is on, else to a baked-in
dev-defaults file (so first-boot eval and local VMs work).
'';
default =
if hasSecrets && cfg.emqx
then config.sops.templates."emqx-users.csv".path
else "${pkgs.writeText "emqx-users-dev.csv"
(emqxUsersCsv "dev-ingester" "dev-admin" "dev-device")}";
};
envFile = lib.mkOption {
type = lib.types.path;
readOnly = true;
@@ -90,6 +136,17 @@ in
mode = "0400";
owner = "root";
};
# Rendered as a standalone file — the gebos-emqx preStart copies it into
# the broker state dir, chowned to the container's emqx uid.
templates."emqx-users.csv" = lib.mkIf cfg.emqx {
content = emqxUsersCsv
config.sops.placeholder.emqx_ingester_password
config.sops.placeholder.emqx_admin_password
config.sops.placeholder.emqx_device_password;
mode = "0400";
owner = "root";
};
};
};
}
+16 -2
View File
@@ -30,10 +30,16 @@ in
"d /var/lib/gebos-supabase 0750 root root - -"
];
# Copy the vendored compose file into place at activation time so changes
# to nix/supabase/docker-compose.yml are deployed atomically.
# Copy the vendored compose bundle into place at activation time so
# changes under nix/supabase/ are deployed atomically. Kong's declarative
# config is mounted from here by the compose file (relative ./kong.yml
# resolves against WorkingDirectory).
environment.etc."gebos/supabase/docker-compose.yml".source =
"${composeDir}/docker-compose.yml";
environment.etc."gebos/supabase/kong.yml".source =
"${composeDir}/kong.yml";
environment.etc."gebos/supabase/kong-entrypoint.sh".source =
"${composeDir}/kong-entrypoint.sh";
systemd.services.gebos-supabase = {
description = "Gebos Supabase compose stack";
@@ -41,6 +47,14 @@ in
wants = [ "docker.service" "network-online.target" ];
wantedBy = [ "multi-user.target" ];
# `docker compose up -d` only recreates containers whose definition
# changed, but the unit must re-run for it to notice at all.
restartTriggers = [
"${composeDir}/docker-compose.yml"
"${composeDir}/kong.yml"
"${composeDir}/kong-entrypoint.sh"
];
environment = {
POSTGRES_HOST = cfg.postgresHost;
POSTGRES_PORT = toString cfg.postgresPort;
+76
View File
@@ -0,0 +1,76 @@
# Template for nix/secrets/secrets.yaml — the sops-encrypted store.
#
# To bootstrap:
# 1. cp nix/secrets/secrets.yaml.example nix/secrets/secrets.yaml
# 2. Replace the placeholder values below with real ones.
# (See nix/secrets/README.md for how to generate JWT_SECRET / ANON_KEY /
# SERVICE_ROLE_KEY etc.)
# 3. sops -e -i nix/secrets/secrets.yaml
# (requires sops + age + the recipients in .sops.yaml to be real keys)
#
# Once encrypted, edit in place with: sops nix/secrets/secrets.yaml
#
# Local dev does NOT need this file at all — process-compose.nix bakes in
# dev defaults and the NixOS modules fall back to a writeText env file when
# nix/secrets/secrets.yaml is absent. This file is only consumed at activation
# time on real hosts.
# Supabase compose stack (consumed on app-host by gebos-supabase.service)
supabase_postgres_password: ENC[AES256_GCM,data:OrjkfiumbB3uMD0tuDkgzaiLDgnt20wYOP9tBAyVwVUXQJxLiDypJoUb4wTY,iv:b4/Lep0Jzb6UtAhyOCpcetXgppul+V5O43zokMtpM2M=,tag:3fvi5xpverfSG+xc/4/HHg==,type:str]
supabase_jwt_secret: ENC[AES256_GCM,data:csu0Rj/DM07DCtYFieIe6eEca+eUPR6KGfQBqa6cqRe7bX4=,iv:OKYFa7Y1bbEAKh1ueM7K8JDLldrTJL/r83AGTtMx/IU=,tag:y6OGVofm4cUFPc0BBmkY+A==,type:str]
supabase_anon_key: ENC[AES256_GCM,data:piGlqQFvF2pVeL/IedL+/PS+5WO2k90zQooNTk9Q9RPUWtkyIYkZxe5kU8OqmharPO6RJbm6Ez5ly2Gk5NyzodQiEUW58wyCK+iXkduUThvtihQS1fh63ijuIPLwzZAdAjRDTY6vYrWWBnUpRELo5A0QCY4SF7KZUkZhCFN0KUHie2PHHzOPVb0koaacqc+K7A2L6Q/Ay0miLG7MXKNDkHsXdXDh9fhP6A==,iv:kq9wti3d1tugHj2p9BPKG42ZIRvvtvU6eGZhh9Ywq1w=,tag:qBMJbFJ1v95uUDWQ9kLb7g==,type:str]
supabase_service_role_key: ENC[AES256_GCM,data:jGIHQVni8Cc7trwl89mQzN2p2g3ry3mNHxYtXHBcmSkpHp7AjsuypxS3W4yEdq96nNLCkbFeFdw+0X4vC8JIhHhl0NHbVb1eKOM/k9fL7q/nEWGqJGdMxoOytF/2L/NjQgzIFfK2L+YDZYUPaf6Ve0FKJ/awnRi8Csrof73PY5rtBrgrndbobYHEBlmzjv25l28kT3lwD4kaKW6ThnxUFmSAUFMwUd9maAdWm+47Vb1G4rTN,iv:N+XbGLnWqiDUfRl53KrFkY37kyyLCuU5sSU9aNHBYXI=,tag:qu9koOkum0m0F83/OgGC2g==,type:str]
supabase_dashboard_password: ENC[AES256_GCM,data:6KHJCPJ66dW9n/uzb3mpGIYqGZABHeEew1H5kx3I8kqgABqXcnHi6w==,iv:87NBfRoNOwtEKgGC0CacY/0xyVE5DQJpuN0X+7j8dfQ=,tag:TcMmIS2GgUlBXlH0elC7Rg==,type:str]
# Ingester (consumed on mqtt-ingest by gebos-ingester.service)
ingester_postgres_password: ENC[AES256_GCM,data:6jy1G6zG4r0z3c9B6s7jPUtvn/Nr/J2HQN23ch6AGFigiVbefY3hCw==,iv:X/PWQjTW/++MVGsi+hcxbkOJZQMOUH0a4mRH5CbOw4A=,tag:TBKFIdbXZ2YzDLbjdnsyxg==,type:str]
# Postgres bootstrap (consumed on db-host by a one-shot ALTER ROLE unit)
postgres_admin_password: ENC[AES256_GCM,data:j5XHA2ZepUq6rJMmIrKr88xjNLiMsJ+ng7CeQxNO3fVwydQhMW0hl0KB,iv:ZXilU07B3aJRQeIM0GVypFcGe4K7y5BsEOa2cIYzAOo=,tag:CEdzYR7NElODPYmTWdVccQ==,type:str]
# EMQX broker users (consumed on mqtt-ingest by gebos-emqx as bootstrap users.csv).
# PLAIN passwords (password-type defaults to PLAIN; the whole file is sops-
# encrypted). The ingester will authenticate as `ingester`; `admin` is for ops.
emqx_ingester_password: ENC[AES256_GCM,data:pV1FIY5uNeZ2zkjB9dv3C5DP2AmeyaV55lqgizQoKGKq0w3PU1gV+xev9g==,iv:E+anGu88+fhBQRLp55tYghx5SvHVniolXaj6kE/3FrQ=,tag:iJpH+0zAowKq+t/xXR+NjA==,type:str]
emqx_admin_password: ENC[AES256_GCM,data:lrOUYSCkYFkXlelAoMUp9WFxo7COJWYkDSBVEr2RT0B2lfMnF3MzqhqJ,iv:uZi+oCw6esNuweIAFTGn3A6Ix5ctwn1Oj4GBchalXmA=,tag:84QigtqxRr1zddMtCsFu9w==,type:str]
# Shared device login (Option A) — must match mqtt_user/mqtt_password on the
# senders. Per-device users (Option B) are a TODO in gebos-secrets.nix.
emqx_device_password: ENC[AES256_GCM,data:pFp3RxJoDw==,iv:1FQzAHSr4VIyuuq2qgFIcuzOq487W4sKV1x8Opoa0jM=,tag:zwn7dOmZArUOtlZBv5G63w==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzaVAwall2bTE0Y09ZeUJn
SFR2cHBDaFZtRmpodGV0ZHJsUzVuWGdNb213ClFFZ04xV0pTVzR5eFh0NjNsYWpZ
SU9IUzBoZWJaNjZmUW1Tc3NER2hpRDAKLS0tIG9BVnAzYW1jL1VZOHRLeTJvQzh0
Y3dtSnp5eGNnWnN0SWVYa0laNExtUWsKhiZOiy1otoGW+Y5HOvFz1nc549LTyvvl
hP0McG7/p0Awh9bQNzhvgQO88M4S0wIrDJ3P0Gu5/HKzKZXQ8EhSOA==
-----END AGE ENCRYPTED FILE-----
recipient: age1cx8ul285kjkzmnhw6skdstnzrxnnme4xkflknzn7yhv52fgxqevqkd66cn
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzRHpzVWtpQnFrWFBNV1c5
UitrRVdQWGUyb0Z6WHVlMFhibjRhWXhFMmdzCnpyMW1QVUErMjBBUHJvMnN6RWJP
b0JselpJQkoxNDFtdU5TYlRBRmpUOGMKLS0tIDdQNGY3TUxhZGtkdzVZSlIwRXZK
NGJqWmJoSkFXMTVKOGNWMGlUN0o1czQKw3/naLb0zt5yHeITSJp9GKEOJfW5bSlL
CtQLx6Nn1WFOTcuoxgaf/xtUCBiSYSRg7pM9KwX/Pfu9gpvWjScUZA==
-----END AGE ENCRYPTED FILE-----
recipient: age1wpl3vz60tlt880p5fmfedr8c59dm4kf0czsacv0w5my706twuf5q9p0x43
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHL3hhWG5WL2N5THRaUFBz
SEo0SE0vQUZIZ05ad0ZUN3lrU3hHSTRISm1zCmVEdWxRSU8zSE1vODNwbVVub2VZ
eWZqU3FiOG9mNHpBZjBQeEdhYkF6SmcKLS0tIEh1WkJIOXdDRmNMMHR2VE5jdXpU
WHRqTUFJQTJQTVFBSzBqVlVWY2ROU28KToql5OybiwITIS9GHieUGfgRILlhmUyM
abkD57fWwMzh1h5UNdUt/VMQEuYX9pK/QhLOrWNgZumk7JesbKRL3A==
-----END AGE ENCRYPTED FILE-----
recipient: age1g57pznep69nlyv3sltz6k0sml47v68m03gh68jkqwcq4jdx68vtsvnefq0
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqUTN5NENOcm1WRnltbzBE
RFgwY3l5cXVYeVptZG9UenBqc25GbCs2NFFFCkNvSytuOVUzRkV2R0doMFpvNndu
a1NyYmVVRE9LRmpwdis4Sk5lcnNoMkEKLS0tIFhTZjFhZVhFZ2FIKzg4dzJ1a25y
cW84SytPUGZmMkVxdmFhMmQ2dy9XZlEK/S1drmtv8te8m2Zgpkm2l+RdOTTbhKCs
ziyUG3WkxBzihV7WpVyfKOu8XMJfLUsgiusjCeCsmkdq+UN+VBlZ/g==
-----END AGE ENCRYPTED FILE-----
recipient: age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d
encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_|porkbun_)
lastmodified: "2026-07-08T20:32:18Z"
mac: ENC[AES256_GCM,data:56m8mOIai7eRKBXwcvWa0SLH6pH2LkL0zrRQ3nbfqe2qqn2qiji5dh4fzVVHmAKnXyob9HaRgRGBTnoi0L357MX+rzefSb0RTDXItsHkYSURYDZnRegEtszPpE5tlff/AlxtLXjs43P6nErkE263gzoFD2EFBwVlH6aufcX3fRo=,iv:eeGYKLRJqDq/ODHeyb6Ucn/K/drFNEJzKZGuAMEGGAU=,tag:qNo4uY4PV7C4MsYThvol3w==,type:str]
version: 3.13.1
+10
View File
@@ -27,3 +27,13 @@ ingester_postgres_password: "replace-me-openssl-rand-hex-32"
# Postgres bootstrap (consumed on db-host by a one-shot ALTER ROLE unit)
postgres_admin_password: "replace-me-openssl-rand-hex-32"
# EMQX broker users (consumed on mqtt-ingest by gebos-emqx). Plaintext
# passwords in a bootstrap users.csv (the whole file is sops-encrypted at
# rest); no commas allowed. The ingester will authenticate as `ingester`;
# `admin` is the operational break-glass superuser.
emqx_ingester_password: "replace-me-openssl-rand-hex-32"
emqx_admin_password: "replace-me-openssl-rand-hex-32"
# Shared device login (Option A) — must match mqtt_user/mqtt_password on the
# senders. Per-device users (Option B) are a TODO in gebos-emqx.nix.
emqx_device_password: "replace-me-to-match-the-sender-mqtt_password"
+50 -24
View File
@@ -1,40 +1,66 @@
# Supabase compose stack
Vendored from the official Supabase self-hosting bundle, with two deliberate
changes:
Vendored from the official self-hosting bundle
(`docker/docker-compose.yml` in supabase/supabase), with deliberate changes:
1. The bundled `db` service is removed. Every service that talks to Postgres
reads `POSTGRES_HOST` / `POSTGRES_PORT` from the environment and connects to
the external `db-host`. The systemd unit (`nix/modules/gebos-supabase.nix`)
injects these from `/etc/gebos/secrets.env`.
1. **No `db` service.** Every service reads `POSTGRES_HOST` / `POSTGRES_PORT`
from the environment and connects to the external `db-host`. The systemd
unit (`nix/modules/gebos-supabase.nix`) injects these; secrets come from
the sops-rendered env file (see `gebos-secrets.nix`).
2. Studio is bound to `127.0.0.1` only. There is **no public route** to it.
To use it from your laptop:
2. **Only auth + rest + studio (+ kong, meta).** Realtime, storage, imgproxy,
edge functions, supavisor and the Logflare analytics stack are not
vendored — re-add from upstream when actually needed.
3. **Studio is bound to `127.0.0.1` only, with no Kong dashboard route.**
Upstream protects Studio with Kong basic-auth on a catch-all `/` route; we
drop that route entirely (`kong.yml` here), so there is **no public route**
to Studio and the api.gebos.online surface is only `/auth/v1`, `/rest/v1`,
`/graphql/v1` and the two `.well-known` endpoints. To use Studio:
```
ssh -L 3000:127.0.0.1:3000 app-host
ssh -L 3000:127.0.0.1:3000 deploy@app.gebos.online
open http://localhost:3000
```
This is intentional — Studio runs with the `service_role` JWT and bypasses
RLS. Putting it on the public internet behind only HTTP basic auth (the
default) is too thin.
Studio itself has no login in this topology — possession of the SSH key is
the credential. (`supabase_dashboard_password` in secrets.yaml is currently
unused; it becomes relevant only if Studio ever gets fronted by basic
auth again.)
4. **meta connects as `supabase_admin`, not `postgres`.** In the upstream
image `postgres`/`supabase_admin` is the superuser; on db-host the
`postgres` superuser keeps its own password (`postgres_admin_password`),
and the `gebos-postgres-passwords` oneshot makes `supabase_admin`
SUPERUSER with the shared `POSTGRES_PASSWORD` to match upstream semantics.
## What's in here
- `docker-compose.yml` — the stack itself
- `init.sql` — schemas, roles, and extensions Postgres needs before the
compose services come up. Loaded by `nix/modules/gebos-postgres.nix`.
- `kong.yml` — Kong's declarative routing (to be vendored alongside the compose
file when we copy it in).
- `init.sql` — Supabase schemas, roles, and extensions Postgres needs before
the compose services come up. Runs once at first initdb via
`nix/modules/gebos-postgres.nix`. Application schema lives in
`supabase/migrations/` (applied manually — see `supabase/README.md`).
- `kong.yml` — Kong's declarative routing, trimmed to the services we run
- `kong-entrypoint.sh` — upstream helper, verbatim: substitutes `$VARS` into
kong.yml (Kong has no native env interpolation) and builds the
request-transformer Lua expressions
## Passwords
No service role has a password in any SQL file. The
`gebos-postgres-passwords` oneshot on db-host (see `gebos-postgres.nix`)
syncs them from sops on every boot/deploy:
| role | secret |
|-----------------------------------------------------|--------|
| `postgres` | `postgres_admin_password` |
| `supabase_admin`, `supabase_auth_admin`, `authenticator` | `supabase_postgres_password` (= `POSTGRES_PASSWORD` in the compose env) |
| `gebos_ingest` | `ingester_postgres_password` |
## Updating the vendored compose
When upstream Supabase ships a new compose layout, re-vendor by:
1. Copy upstream `docker/docker-compose.yml` over `docker-compose.yml`.
2. Remove the `db:` service block.
3. Replace every `db:5432` / `postgres:5432` reference with
`${POSTGRES_HOST}:${POSTGRES_PORT}`.
4. Bind Studio to `${STUDIO_BIND}:3000` instead of `0.0.0.0:3000`.
5. Commit, deploy, smoke-test through the SSH tunnel.
When upstream ships a new layout, re-vendor by copying the upstream files and
re-applying the deviations listed at the top of `docker-compose.yml` and
`kong.yml` (both carry the list in their header comments). Then commit,
deploy, and smoke-test Studio through the SSH tunnel.
+177 -33
View File
@@ -1,65 +1,209 @@
# Gebos — vendored Supabase compose stack.
#
# TODO: copy the full official compose from
# Vendored from the official self-hosting bundle
# https://github.com/supabase/supabase/blob/master/docker/docker-compose.yml
# and apply the three modifications described in ./README.md:
# - drop the `db` service
# - replace `db:5432` / `postgres:5432` with ${POSTGRES_HOST}:${POSTGRES_PORT}
# - bind Studio to ${STUDIO_BIND}:3000 (default 127.0.0.1)
# with the deliberate deviations documented in ./README.md:
#
# The skeleton below names the services we'll keep and the env vars each one
# reads, so reviewers can sanity-check the topology before the real compose
# lands.
# * no `db` service — Postgres runs on db-host; every service connects to
# ${POSTGRES_HOST}:${POSTGRES_PORT} and the `depends_on: db` blocks are
# dropped
# * no realtime / storage / imgproxy / functions / supavisor / analytics —
# the pilot needs auth + rest + studio only; re-vendor from upstream when
# one of them becomes needed
# * Studio binds to ${STUDIO_BIND}:3000 (loopback; reach it via SSH tunnel)
# * meta connects as supabase_admin, not `postgres`: upstream's `postgres`
# is its cluster superuser, but on db-host that role carries a different
# password. gebos-postgres-passwords makes supabase_admin SUPERUSER to
# match the upstream image's semantics.
#
# Where variables come from (see nix/modules/gebos-supabase.nix):
# * POSTGRES_HOST / POSTGRES_PORT / POSTGRES_DB / STUDIO_BIND — systemd
# unit `environment` (non-secret deployment config, nix-managed)
# * POSTGRES_PASSWORD / JWT_SECRET / ANON_KEY / SERVICE_ROLE_KEY —
# sops-rendered EnvironmentFile (secrets)
# * gebos constants (public URLs, org name) — hardcoded right here
name: gebos-supabase
services:
kong:
image: kong:2.8.1
studio:
container_name: supabase-studio
image: supabase/studio:2026.07.07-sha-a6a04f2
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000/tcp" # Caddy proxies api.ge-bos.de here
# Never 0.0.0.0 — Studio performs no authentication of its own in this
# topology (the upstream basic-auth lives on Kong's dashboard route,
# which we do not expose). Access = SSH tunnel to app-host.
- ${STUDIO_BIND}:3000:3000/tcp
healthcheck:
test:
[
"CMD-SHELL",
"node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\""
]
timeout: 10s
interval: 5s
retries: 3
start_period: 20s
environment:
HOSTNAME: "0.0.0.0"
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER_READ_WRITE: supabase_admin
PGRST_DB_SCHEMAS: public
DEFAULT_ORGANIZATION_NAME: Gebos
DEFAULT_PROJECT_NAME: Gebos
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: https://api.gebos.online
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
# No Logflare/analytics stack in this deployment.
ENABLED_FEATURES_LOGS_ALL: "false"
kong:
container_name: supabase-kong
image: kong/kong:3.9.1
restart: unless-stopped
ports:
- 127.0.0.1:8000:8000/tcp # Caddy proxies api.gebos.online here
healthcheck:
test: ["CMD", "kong", "health"]
interval: 5s
timeout: 5s
retries: 5
volumes:
# The entrypoint substitutes $ENV_VARS in temp.yml and writes the result
# to KONG_DECLARATIVE_CONFIG (Kong has no native env interpolation).
- ./kong.yml:/home/kong/temp.yml:ro
- ./kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
volumes:
- ./kong.yml:/home/kong/kong.yml:ro
KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
KONG_ROUTER_FLAVOR: expressions
# https://github.com/supabase/cli/issues/14
KONG_DNS_ORDER: LAST,A,CNAME
KONG_DNS_NOT_FOUND_TTL: 1
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
KONG_PROXY_ACCESS_LOG: /dev/stdout combined
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
# Opaque sb_* API keys — not used yet. Empty values make the entrypoint
# fall back to legacy apikey pass-through and strip the empty
# credentials from the declarative config.
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-}
SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-}
entrypoint: ["/bin/sh", "/home/kong/kong-entrypoint.sh"]
auth:
image: supabase/gotrue:v2.158.1
container_name: supabase-auth
image: supabase/gotrue:v2.189.0
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:9999/health"
]
timeout: 5s
interval: 5s
retries: 3
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: https://api.gebos.online
GOTRUE_DB_DRIVER: postgres
# GoTrue owns the auth schema and runs its own migrations in it on
# startup — supabase_auth_admin's ownership of that schema is enforced
# by the gebos-postgres-passwords oneshot on db-host.
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
GOTRUE_SITE_URL: https://app.ge-bos.de
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_SITE_URL: https://app.gebos.online
GOTRUE_URI_ALLOW_LIST: ""
# Tenants are invited by an admin (magic link), never self-signed-up.
GOTRUE_DISABLE_SIGNUP: "true"
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: "3600"
API_EXTERNAL_URL: https://api.ge-bos.de
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_ISSUER: https://api.gebos.online
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "false"
GOTRUE_MAILER_AUTOCONFIRM: "false"
# TODO: no SMTP provider configured yet. Magic links (UVI-NFR-08,
# passwordless-only) cannot send until these are filled in — add the
# SMTP secrets to nix/secrets/secrets.yaml and wire them through
# gebos-secrets.nix like the other supabase keys.
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-}
GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
GOTRUE_SMTP_USER: ${SMTP_USER:-}
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Gebos}
GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
GOTRUE_SMS_AUTOCONFIRM: "false"
rest:
image: postgrest/postgrest:v12.0.2
container_name: supabase-rest
image: postgrest/postgrest:v14.12
restart: unless-stopped
healthcheck:
test: [ "CMD", "postgrest", "--ready" ]
interval: 5s
timeout: 5s
retries: 3
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_DB_SCHEMAS: public
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_MAX_ROWS: 1000
PGRST_DB_EXTRA_SEARCH_PATH: public
PGRST_DB_ANON_ROLE: anon
studio:
image: supabase/studio:20240326-5e5586d
restart: unless-stopped
ports:
- "${STUDIO_BIND}:3000:3000/tcp" # never bind to 0.0.0.0
environment:
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
DEFAULT_ORGANIZATION_NAME: Gebos
SUPABASE_URL: https://api.ge-bos.de
DASHBOARD_USERNAME: gebos
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
PGRST_ADMIN_SERVER_PORT: 3001
PGRST_ADMIN_SERVER_HOST: localhost
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: "3600"
command:
[
"postgrest"
]
meta:
image: supabase/postgres-meta:v0.83.2
container_name: supabase-meta
image: supabase/postgres-meta:v0.96.6
restart: unless-stopped
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: ${POSTGRES_HOST}
PG_META_DB_PORT: ${POSTGRES_PORT}
PG_META_DB_NAME: ${POSTGRES_DB}
-31
View File
@@ -48,34 +48,3 @@ do $$ begin
create role supabase_auth_admin login createrole;
grant usage on schema auth to supabase_auth_admin;
exception when duplicate_object then null; end $$;
-- Ingester writes telemetry; bypasses RLS because at write time no user is
-- authenticated. Trust boundary is the broker ACL ("who can publish to what
-- tenant's topic"), not the database.
do $$ begin
create role gebos_ingest login bypassrls;
exception when duplicate_object then null; end $$;
-- Telemetry hypertable. Tenant lives on the row so standard RLS works.
create table if not exists public.telemetry (
ts timestamptz not null,
tenant_id uuid not null,
device_id uuid not null,
metric text not null,
value double precision,
payload jsonb
);
select create_hypertable('public.telemetry', 'ts', if_not_exists => true);
create index if not exists telemetry_tenant_device_ts_idx
on public.telemetry (tenant_id, device_id, ts desc);
alter table public.telemetry enable row level security;
create policy telemetry_tenant_isolation on public.telemetry
for select
using (tenant_id = current_setting('app.tenant_id', true)::uuid);
grant select on public.telemetry to authenticated;
grant insert on public.telemetry to gebos_ingest;
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
# Custom entrypoint for Kong that builds Lua expressions for request-transformer
# and performs environment variable substitution in the declarative config.
# Build Lua expressions for translating opaque API keys to asymmetric JWTs.
# When opaque keys are not configured (empty env vars), expressions fall through
# to legacy-only behavior - just passing apikey as-is.
#
# Full expression logic (when opaque keys are configured):
# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT)
# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key"
# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key"
# 4. Fallback: pass apikey as-is (legacy HS256 JWT)
if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
# Opaque keys configured -> full translation expressions
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)"
# Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey
# via query string), outputs to x-api-key header which Realtime checks first.
export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)"
else
# Legacy API keys, not sb_ API keys -> pass apikey through unchanged
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)"
export LUA_RT_WS_EXPR="\$(query_params.apikey)"
fi
# Substitute environment variables in the Kong declarative config.
# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double
# quotes, breaking "Header: value" patterns that YAML parses as mappings).
awk '{
result = ""
rest = $0
while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) {
varname = substr(rest, RSTART + 1, RLENGTH - 1)
if (varname in ENVIRON) {
result = result substr(rest, 1, RSTART - 1) ENVIRON[varname]
} else {
result = result substr(rest, 1, RSTART + RLENGTH - 1)
}
rest = substr(rest, RSTART + RLENGTH)
}
print result rest
}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG"
# Remove empty key-auth credentials (unconfigured opaque keys)
sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG"
exec /entrypoint.sh kong docker-start
+176 -18
View File
@@ -1,48 +1,206 @@
_format_version: "2.1"
_format_version: '2.1'
_transform: true
# Declarative Kong config — vanilla Supabase routing, no surprises.
# Lives at /home/kong/kong.yml inside the container.
# Vendored from the official bundle (docker/volumes/api/kong.yml), with the
# routes for services we don't run removed: realtime, storage, functions,
# analytics, pg-meta (/pg/), MCP, and — deliberately — the catch-all
# dashboard route + DASHBOARD basic-auth consumer. Studio is loopback-only
# behind an SSH tunnel and must never be reachable via api.gebos.online.
#
# $VARS are substituted by kong-entrypoint.sh at container start; empty
# credential lines (unconfigured opaque keys) are stripped there too.
###
### Consumers / Users
###
consumers:
- username: anon
keyauth_credentials:
- key: ${ANON_KEY}
- key: $SUPABASE_ANON_KEY
- key: $SUPABASE_PUBLISHABLE_KEY
- username: service_role
keyauth_credentials:
- key: ${SERVICE_ROLE_KEY}
- key: $SUPABASE_SERVICE_KEY
- key: $SUPABASE_SECRET_KEY
###
### Access Control List
###
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
###
### API Routes
###
services:
## Open Auth routes
- name: auth-v1-open
_comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*'
url: http://auth:9999/verify
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
plugins:
- name: cors
- name: auth-v1-open-callback
_comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*'
url: http://auth:9999/callback
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
plugins:
- name: cors
- name: auth-v1-open-authorize
_comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*'
url: http://auth:9999/authorize
routes:
- name: auth-v1-open-authorize
strip_path: true
paths:
- /auth/v1/authorize
plugins:
- name: cors
- name: auth-v1-open-jwks
_comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json'
url: http://auth:9999/.well-known/jwks.json
routes:
- name: auth-v1-open-jwks
strip_path: true
paths:
- /auth/v1/.well-known/jwks.json
plugins:
- name: cors
## Secure Auth routes
- name: auth-v1
_comment: 'Auth: /auth/v1/* -> http://auth:9999/*'
url: http://auth:9999/
routes:
- name: auth-v1-route
- name: auth-v1-all
strip_path: true
paths: [ /auth/v1/ ]
paths:
- /auth/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: rest-v1
## OpenAPI root - admin only
- name: rest-v1-openapi
_comment: 'PostgREST OpenAPI root: /rest/v1/ -> <http://rest:3000/> (admin only). See <https://github.com/orgs/supabase/discussions/42949>'
url: http://rest:3000/
routes:
- name: rest-v1-route
- name: rest-v1-openapi-root
strip_path: true
paths: [ /rest/v1/ ]
expression: 'http.path == "/rest/v1/"'
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
key_names: [ apikey ]
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- name: rpc
url: http://rest:3000/rpc/
## Secure PostgREST routes
- name: rest-v1
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
url: http://rest:3000/
routes:
- name: rpc-route
- name: rest-v1-all
strip_path: true
paths: [ /rpc/ ]
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
key_names: [ apikey ]
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure GraphQL routes
- name: graphql-v1
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
url: http://rest:3000/rpc/graphql
routes:
- name: graphql-v1-all
strip_path: true
paths:
- /graphql/v1
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Content-Profile: graphql_public"
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## OAuth 2.0 Authorization Server Metadata (RFC 8414)
- name: well-known-oauth
_comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server'
url: http://auth:9999/.well-known/oauth-authorization-server
routes:
- name: well-known-oauth
strip_path: true
paths:
- /.well-known/oauth-authorization-server
plugins:
- name: cors
+1
View File
@@ -0,0 +1 @@
v2.109.1
+84
View File
@@ -0,0 +1,84 @@
# Database migrations
Plain-SQL migrations in `migrations/`, applied **manually** with the supabase
CLI (available in the dev shell). There is deliberately no automated runner:
schema changes are rare, deliberate, admin-level operations.
The CLI records what has been applied in
`supabase_migrations.schema_migrations` on the target database, so pushing is
incremental and re-running it is safe.
## Applying to prod (db-host)
Postgres on db-host only accepts connections from the private network, so go
through an SSH tunnel:
```sh
# terminal 1 — tunnel to db-host's loopback
ssh -L 15432:127.0.0.1:5432 deploy@db.gebos.online
# terminal 2 — from the repo root, inside `nix develop`
PGPASS=$(sops -d --extract '["postgres_admin_password"]' nix/secrets/secrets.yaml)
supabase db push --db-url "postgres://postgres:${PGPASS}@127.0.0.1:15432/postgres"
```
The `postgres` superuser password is set from the same sops secret by the
`gebos-postgres-passwords` oneshot on db-host (see
`nix/modules/gebos-postgres.nix`), so it only works after the first deploy of
that unit.
## Adding a migration
```sh
supabase migration new <short_name> # creates migrations/<timestamp>_<short_name>.sql
```
Write plain SQL. Conventions:
* Migrations are ordered and applied exactly once — they do NOT need to be
idempotent (the schema-v1 file's `CREATE TYPE`s aren't).
* Cluster bootstrap (Supabase schemas/roles, extensions that need
`shared_preload_libraries`) belongs in `nix/supabase/init.sql`, not here.
* Role passwords never go in migrations — they are synced from sops by the
`gebos-postgres-passwords` unit.
## Local dev
`nix run .#dev` applies everything automatically: its `db-migrate` process
bootstraps the Supabase roles/schemas, runs `supabase db push`, and loads
`seed.sql` on every start (all idempotent — see
`nix/dev/process-compose.nix`). To re-apply by hand against the running dev
Postgres, do it as the local superuser (your OS user — `gebos_ingest` is only
*created* by the migration and has no DDL rights). The CLI ignores `sslmode`
in the URL, so TLS must be disabled via the environment:
```sh
PGSSLMODE=disable supabase db push --db-url "postgres://$USER@127.0.0.1:5432/gebos"
```
(The dev postgres ships TimescaleDB — the Apache edition, which covers
hypertables; TSL-only features like compression are absent locally.)
## Seed / fixture data (dev and tests only)
`seed.sql` fills the local database with a self-consistent fixture world:
2 buildings (gas / district heating), 8 apartments, tenants incl. a
Mieterwechsel and a vacancy, gateways, sensors (incl. one unassigned), ~13
months of daily cumulative measurements with realistic seasonality, and
approximate reference data (thresholds, CO2 factors, prices, HDD). The header
comment in the file documents every scenario and the fixed-UUID namespaces.
Apply it after the migrations, with a superuser (the seed writes tables the
`gebos_ingest` role may not):
```sh
psql -h 127.0.0.1 -p 5432 -d gebos -f supabase/seed.sql
```
It is idempotent (fixed UUIDs + `ON CONFLICT DO NOTHING`, deterministic
measurement generation) — re-running on a later day only appends the new
days of measurements.
**Never apply it to prod**: the tenants and devices are fake, and the
regulatory numbers are development approximations, not verified values from
the legal sources. Verified reference data belongs in its own migration.
+4
View File
@@ -0,0 +1,4 @@
# Minimal supabase CLI project marker. Only `supabase db push --db-url ...`
# is used here (manual migrations against db-host); local `supabase start`
# is not — the local dev stack is `nix run .#dev` (process-compose).
project_id = "gebos"
@@ -0,0 +1,642 @@
-- ============================================================================
-- GebOS Schema V1 (initial Supabase migration)
-- Target: Postgres 17 + TimescaleDB
--
-- Applied MANUALLY via `supabase db push` — see supabase/README.md. There is
-- deliberately no automated migration runner. The CLI records applied
-- migrations in supabase_migrations.schema_migrations, so re-running push is
-- safe; the file itself is NOT idempotent (CREATE TYPE has no IF NOT EXISTS).
--
-- Cluster-level bootstrap (Supabase schemas/roles, extensions requiring
-- shared_preload_libraries) lives in nix/supabase/init.sql, not here.
--
-- Scope: UVI pilot per § 6a HeizkostenV, methodology per UBA-Leitfaden
-- CLIMATE CHANGE 69/2021 (all six modules). Annual billing is a separate
-- epic and deliberately not modeled here.
--
-- Requirement IDs (UVI-DAT-*, UVI-FUN-*, UVI-NFR-*, UVI-MET-*) refer to the
-- Notion page "UVI Anforderungen (Pilot)", which is the source of truth.
--
-- Conventions
-- * lowercase snake_case everywhere — no quoted identifiers, ever.
-- * Surrogate UUID primary keys, except time-series tables where the
-- composite natural key carries meaning (see measurement).
-- * timestamptz for all instants; date for civil dates (tenancy, prices).
-- * jsonb, never json (binary, indexable, deduplicated keys).
-- * created_at on every table for ops forensics.
-- * ON DELETE RESTRICT as default policy: telemetry and tenancy data must
-- never disappear as a side effect of deleting a parent row. Deletions
-- are rare, deliberate, admin-level operations.
-- ============================================================================
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS btree_gist; -- exclusion constraint on occupancy
-- ---------------------------------------------------------------------------
-- Enums
--
-- Enums are used where the value set is fixed by law or by the product
-- itself and changes require a deliberate migration (which is a feature:
-- a new fuel type SHOULD force someone to also add its CO2 factor and
-- price rows). Where the value set is operational content that admins
-- curate at runtime (saving tips), a table is used instead of an enum.
-- ---------------------------------------------------------------------------
-- Drives CO2 emission factors (GEG Anlage 9) and cost estimation (Module 5).
-- One fuel per building is the pilot assumption. District heating fuel-mix
-- disclosure (§ 6a Abs. 3) belongs to annual billing; if it later requires
-- per-network fuel compositions, promote this enum to a table with a
-- composition child table — the FK sites below are the only touch points.
CREATE TYPE fuel_type AS ENUM (
'natural_gas',
'heating_oil',
'district_heating',
'heat_pump_electricity',
'wood_pellets',
'other'
);
-- The SEMANTIC kind of a reading. This is deliberately separate from the
-- physical unit: heating energy and hot-water energy are both kWh, yet the
-- UVI must never mix them (Modules 1 and 2 render them separately, and
-- UVI-FUN-14 treats them differently outside the heating period). The unit
-- says how a number is measured; the kind says what it means.
--
-- Diagnostic kinds (temperatures, error flags) are included because OMS
-- telegrams carry them anyway and they are the raw material for device
-- health monitoring — cheap to store now, expensive to backfill later.
CREATE TYPE measurement_kind AS ENUM (
'heating_energy', -- kWh, heat cost allocator / heat meter
'hot_water_energy', -- kWh
'hot_water_volume', -- m3; some installations meter volume and
-- convert to energy downstream
'cold_water_volume', -- m3; not needed for the UVI but commonly on
-- the same radio and trivially captured
'flow_temperature', -- °C, diagnostics
'return_temperature', -- °C, diagnostics
'error_flags', -- OMS status/error register
'other'
);
-- Two roles are enough for the pilot: tenants see exactly one apartment
-- dashboard (UVI-NFR-03), admins see the read-only overview of everything
-- (UVI-FUN-17). A role enum rather than a boolean because the role set is
-- known to grow (landlord read-only view is an announced post-pilot epic)
-- and because UVI-NFR-07 already treats "admin" as a first-class concept
-- with its own audit requirements.
CREATE TYPE user_role AS ENUM ('tenant', 'admin');
CREATE TYPE saving_tip_category AS ENUM ('heating', 'hot_water');
-- ===========================================================================
-- 1. BUILDING & TENANCY
--
-- The physical and legal world: buildings contain apartments, apartments
-- are occupied by tenants over time periods. Everything the UVI displays
-- is ultimately scoped by this layer — a measurement only becomes a
-- "tenant's consumption" through sensor -> apartment -> occupancy.
-- ===========================================================================
CREATE TABLE building (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Address is structured (not one text blob) because the admin
-- dashboard groups by building address (UVI-FUN-17) and because
-- postal_code is the likely input for automatic weather-station
-- assignment if that open decision lands on "auto".
street text NOT NULL,
house_number text NOT NULL,
postal_code text NOT NULL,
city text NOT NULL,
-- Coordinates exist for exactly one consumer: nearest-weather-station
-- resolution. They are NOT used to locate devices — device placement
-- is human-readable installer text on the device rows themselves.
lat numeric(9,6),
lng numeric(9,6),
-- The building's energy carrier. Chosen at building level (not per
-- apartment, not per measurement) because the boiler / district
-- heating connection is a building-level fact, and both the CO2
-- module and the cost module resolve their factors through it.
fuel_type fuel_type NOT NULL,
-- UVI-MET-03. The UBA/GEG comparison metrics (Bandtacho thresholds,
-- in-house kWh/m²) are defined against Gebäudenutzfläche (AN), a
-- technical energy-balance quantity nobody in this business has on
-- file. The sanctioned approximation is AN ≈ Wohnfläche × 1.2.
-- Stored per building, not hardcoded, so that a building with a
-- known real AN (e.g. from an energy certificate) can override it:
-- set area_factor = real_AN / sum(living_area_m2). Every specific
-- consumption in the app divides by (living_area_m2 * area_factor);
-- omitting the factor would inflate every building's apparent
-- consumption ~20% and shift Bandtacho classes.
area_factor numeric(4,2) NOT NULL DEFAULT 1.20
CHECK (area_factor > 0),
-- Which DWD station's heating degree days contextualize this
-- building's consumption (Module 1 footnote, UVI-FUN-07). Nullable
-- and manually assigned in the pilot: the auto-vs-manual assignment
-- question is an explicitly open decision, and the schema must not
-- presume its answer. A NULL here means the weather footnote renders
-- as "not available" (UVI-FUN-15) — never as a silent default station.
weather_station_id uuid, -- FK added after weather_station is defined
-- Building-level operational remarks: key-box code, basement access,
-- contact person. Device mounting locations do NOT belong here —
-- they live on the device rows (see gateway/sensor.installation_note).
note text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE apartment (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
building_id uuid NOT NULL REFERENCES building(id),
-- Human-readable unit designation ("2. OG links", "WE 04"). Unique
-- within a building so admin views and installer workflows have an
-- unambiguous handle.
label text NOT NULL,
-- UVI-DAT-01, and NOT NULL on purpose: the in-house comparison
-- (Module 2) is kWh per m² — an apartment without an area cannot
-- participate in the product's core comparison at all, so it is
-- invalid data, not missing data.
living_area_m2 numeric(7,2) NOT NULL CHECK (living_area_m2 > 0),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (building_id, label)
);
-- Application-level user profile. Identity — email address, magic-link
-- issuance, session state — is owned entirely by Supabase Auth (GoTrue)
-- in the auth schema (UVI-NFR-08: passwordless only). This table stores
-- only what the application adds on top. The PK IS the auth.users id
-- (same uuid, no separate surrogate) so that auth.uid() in RLS policies
-- joins directly without a mapping table.
CREATE TABLE app_user (
id uuid PRIMARY KEY, -- = auth.users.id
role user_role NOT NULL DEFAULT 'tenant',
display_name text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Tenancy periods (UVI-DAT-04). This table is the legal heart of the
-- data model, for two reasons:
--
-- 1. Mieterwechsel correctness: § 6a comparisons to the previous month /
-- previous-year month are only permitted against the SAME tenant's
-- consumption. The dashboard decides whether to render a comparison
-- by checking that both months fall inside one occupancy row. Without
-- periods, a new tenant would be shown (and judged against) their
-- predecessor's behavior.
--
-- 2. Privacy scope: a tenant may only ever see measurements taken during
-- their own tenancy (UVI-NFR-03). The RLS sketch at the bottom of this
-- file enforces that by joining measurement timestamps against this
-- table's date range — history before move-in is structurally
-- invisible, not just filtered in application code.
--
-- valid_to IS NULL means "current tenant". The exclusion constraint makes
-- overlapping tenancies for one apartment a constraint violation rather
-- than a bug class: the database itself guarantees that any timestamp
-- maps to at most one responsible tenant.
CREATE TABLE occupancy (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
apartment_id uuid NOT NULL REFERENCES apartment(id),
user_id uuid NOT NULL REFERENCES app_user(id),
valid_from date NOT NULL,
valid_to date,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (valid_to IS NULL OR valid_to > valid_from),
EXCLUDE USING gist (
apartment_id WITH =,
daterange(valid_from, COALESCE(valid_to, 'infinity'::date), '[)') WITH &&
)
);
-- ===========================================================================
-- 2. DEVICES & INGEST
--
-- The radio world: gateways listen, meters broadcast, frames arrive.
-- Design stance: wM-Bus at 868 MHz is a BROADCAST medium. A gateway does
-- not "own" the sensors it hears — two gateways in one building may both
-- receive the same meter, and every gateway will receive meters that are
-- not ours (neighbors, unprovisioned devices). The schema therefore:
-- * ties gateways to buildings (where they are installed), never to
-- sensors;
-- * records which gateway heard a frame on the frame itself, because
-- that is a fact about the reception event, not about the meter;
-- * accepts and stores frames from unknown senders instead of
-- dropping them at the door.
-- ===========================================================================
CREATE TABLE gateway (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- IMEI identifies the cellular modem and is unique per device, but it
-- is a hardware serial, not an identity: a defective gateway swapped
-- in the field is, to the business, "the same listening post" with a
-- new IMEI. A surrogate PK keeps history intact across swaps; the
-- unique index still supports lookup-by-IMEI at ingest time.
imei varchar(15) NOT NULL UNIQUE,
imsi varchar(15), -- SIM identity; changes with
-- provider swaps, so informative
-- only, never a key
building_id uuid NOT NULL REFERENCES building(id),
label text, -- short admin handle, "GW Keller"
-- Free-text installer note answering "where exactly is this thing?":
-- e.g. "mounted above the first ceiling panel on level 1". Kept per
-- device (not on a shared location entity) so that editing one
-- device's note can never silently rewrite another's.
installation_note text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- A device profile: one row per (manufacturer, model) of meter we know
-- how to decode. Exists so that parsing knowledge attaches to the KIND
-- of device rather than to each physical unit — provisioning meter #500
-- of a known model requires zero new parser configuration. This is the
-- schema-level expression of the product strategy "works with any open
-- OMS sensor": supporting a new vendor means inserting rows here and in
-- value_parser, not shipping code.
CREATE TABLE device_type (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
manufacturer text NOT NULL, -- OMS M-field, e.g. 'QDS'
model text NOT NULL,
medium text, -- OMS medium byte, informative
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (manufacturer, model)
);
-- Extraction rules: how to pull ONE value out of a decoded telegram of a
-- given device type. A telegram routinely carries several values we care
-- about (energy total, volume, temperatures, error register), hence
-- 1 device_type : N parsers. Each parser declares:
-- * where the value sits (json_path into the decoded telegram),
-- * how to normalize it (parser_instructions: scaling, offsets),
-- * what it MEANS (kind) and in what unit it is expressed.
-- The kind declared here is what stamps every measurement row with its
-- heating/hot-water semantics — the parser is the single place where
-- raw vendor bytes acquire domain meaning.
--
-- unit is plain text ('kWh', 'm3', '°C') rather than a lookup table:
-- a three-row table adds a join and an id without adding information.
-- Reintroduce a unit table only if units ever need behavior or metadata
-- (conversion factors, display localization).
CREATE TABLE value_parser (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_type_id uuid NOT NULL REFERENCES device_type(id),
json_path text NOT NULL,
parser_instructions jsonb,
kind measurement_kind NOT NULL,
unit text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (device_type_id, kind, json_path)
);
CREATE TABLE sensor (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- The meter's own identity as broadcast in its telegrams (OMS
-- secondary address / serial). This is the value ingest matches on
-- to attribute an incoming frame to a known sensor.
oms_id text NOT NULL UNIQUE,
device_type_id uuid NOT NULL REFERENCES device_type(id),
-- The device-to-apartment assignment (UVI-FUN-02). Direction matters:
-- an apartment has MANY devices (a heat cost allocator per radiator
-- plus a hot-water meter is the normal case), a device sits in at
-- most one apartment. NULLable by design — in the pilot this
-- assignment is performed manually in the database after physical
-- installation, so a sensor legitimately exists in a provisioned-
-- but-unassigned state. Its measurements are stored regardless and
-- become tenant-visible once the assignment is made.
apartment_id uuid REFERENCES apartment(id),
-- AES-128 key for OMS telegram decryption. Plaintext here is a
-- pilot-grade decision to keep moving; before production, move key
-- material to a dedicated mechanism (pgsodium / Vault) — the column
-- is the interface, the storage hardening is an ops task.
decrypt_key text,
installed_at date,
-- Same rationale as gateway.installation_note: "which radiator,
-- which room, how to reach it" is per-device installer knowledge.
installation_note text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Note what sensor deliberately does NOT have: a gateway reference (the
-- radio is broadcast; reception routing lives on received_payload) and a
-- location/building reference (derived via apartment -> building; a
-- second, independent path would allow the two to contradict each other).
-- Raw reception log: every frame any of our gateways heard, verbatim.
-- This table exists for auditability and reprocessing — if a parser had
-- a bug, or a new device_type is added for meters we were already
-- hearing, history can be re-parsed from here. Storage is the price of
-- never losing data to a software mistake.
CREATE TABLE received_payload (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
gateway_id uuid NOT NULL REFERENCES gateway(id),
-- Attribution to a known sensor happens at parse time and is
-- NULLABLE: on a shared radio band we routinely receive frames from
-- meters that are not ours. Those are stored (they may become ours
-- — e.g. a meter provisioned after installation) and flagged via
-- parse_status rather than rejected at ingest.
sensor_id uuid REFERENCES sensor(id),
-- Both ends of the transport hop. sent = gateway clock at upload,
-- received = server clock at arrival; their divergence is the
-- cheapest possible gateway-health signal (clock drift, buffering
-- backlog after connectivity loss).
timestamp_sent timestamptz,
timestamp_received timestamptz NOT NULL DEFAULT now(),
raw_payload jsonb NOT NULL,
-- Ingest pipeline state machine. 'unknown_sensor' is a normal,
-- expected state, not an error — see attribution note above.
parse_status text NOT NULL DEFAULT 'pending'
CHECK (parse_status IN ('pending','parsed','unknown_sensor','error')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON received_payload (sensor_id, timestamp_received DESC);
-- Partial index: the parser worker's queue query ("give me unprocessed
-- frames") stays fast no matter how large the historical log grows.
CREATE INDEX ON received_payload (parse_status)
WHERE parse_status IN ('pending','error');
-- Raw-frame retention is an open decision. When made, implement it as a
-- scheduled purge (or convert this table to a hypertable and attach a
-- Timescale retention policy) — do NOT cascade-delete from measurement;
-- measurement.raw_payload_id is nullable precisely so lineage can be
-- severed by retention without touching the measurements themselves.
-- ===========================================================================
-- 3. MEASUREMENT (time series)
--
-- The product's ground truth: one row per (meter, kind, instant) reading.
-- Everything the UVI shows is computed from these rows at query time —
-- no persisted aggregates (UVI-DAT-06). That rule keeps a single source
-- of truth and makes bug fixes retroactive for free: correct a parser,
-- re-parse, and every dashboard number is correct.
-- ===========================================================================
CREATE TABLE measurement (
sensor_id uuid NOT NULL REFERENCES sensor(id),
-- The METER's own reading time, extracted from the telegram — not
-- the reception time. wM-Bus meters repeat frames and gateways
-- buffer during connectivity loss, so arrival time can trail reading
-- time by minutes to days; all consumption math (monthly windows,
-- year-over-year) must bind to when the meter measured.
measured_at timestamptz NOT NULL,
kind measurement_kind NOT NULL,
-- NOT NULL: a reading without a value is not a reading. Missing data
-- is represented by the ABSENCE of rows for a period, and the UI
-- renders that as "not available" (UVI-FUN-15) — never by storing 0
-- or NULL, both of which would poison aggregations silently.
value numeric NOT NULL,
-- Denormalized from value_parser at write time so a row is
-- self-describing even if parser configuration later changes.
unit text NOT NULL,
-- Lineage to the exact raw frame this value was parsed from.
-- Nullable so raw-frame retention can delete old frames without
-- invalidating measurements (see received_payload).
raw_payload_id uuid REFERENCES received_payload(id),
created_at timestamptz NOT NULL DEFAULT now(),
-- Composite natural key, doing three jobs at once:
-- 1. TimescaleDB requires the partitioning column in any unique
-- constraint — a bare uuid PK is incompatible with a hypertable.
-- 2. Dedup: meters broadcast the same reading repeatedly by design.
-- Ingest uses INSERT .. ON CONFLICT DO NOTHING against this key,
-- so repeats collapse to no-ops instead of duplicate rows that
-- would double-count consumption.
-- 3. It IS the dominant access path (this sensor, this kind, this
-- time range), so the PK index serves the dashboard queries and
-- no secondary index is needed for the pilot.
PRIMARY KEY (sensor_id, kind, measured_at)
);
-- Monthly chunks match the product's natural query grain (the UVI is a
-- monthly report over a 13-month window).
SELECT create_hypertable('measurement', by_range('measured_at', INTERVAL '1 month'));
-- If the 13-month dashboard aggregations ever get slow at scale, the
-- sanctioned escape hatch is a Timescale continuous aggregate: it is
-- machine-maintained derived state over this table, which arguably
-- honors the intent of "no persisted aggregates" (no hand-maintained
-- second truth). Adopt it when measured, not preemptively.
-- ===========================================================================
-- 4. REFERENCE DATA
--
-- External regulatory and environmental facts the UVI computes against
-- (UVI-DAT-07..11). Two shared design rules:
--
-- * Versioning by validity range wherever the source can change
-- (CO2 factors, prices): the question "what number was shown to the
-- tenant in March?" must remain answerable after an update, both for
-- tenant trust and for the § 7 Kürzungsrecht audit scenario.
-- * source columns record provenance, because every one of these
-- numbers is a claim about a legal document or an external dataset.
-- ===========================================================================
-- Bandtacho class boundaries (Module 3). GEG Anlage 10 defines ANNUAL
-- kWh/m² class limits; the UBA-Leitfaden (Tabelle 1) splits them into
-- per-calendar-month thresholds so a single month's consumption can be
-- classified. Hence one row per (class, month): 9 classes × 12 months.
CREATE TABLE efficiency_class_threshold (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
class text NOT NULL CHECK (class IN
('A+','A','B','C','D','E','F','G','H')),
month smallint NOT NULL CHECK (month BETWEEN 1 AND 12),
-- Upper bound of the class for that month. 'H' is open-ended in the
-- source; store a sentinel high value rather than NULL so that
-- classification is a simple "first class whose bound >= value"
-- scan with no NULL special-casing.
max_kwh_per_m2 numeric(8,3) NOT NULL,
source text NOT NULL DEFAULT 'UBA CC 69/2021 Tab. 1 / GEG Anl. 10',
UNIQUE (class, month)
);
-- CO2 factors per fuel (Module 6), GEG Anlage 9. Versioned because the
-- annex gets amended; emissions shown for a given month use the factor
-- valid in that month.
CREATE TABLE co2_emission_factor (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
fuel_type fuel_type NOT NULL,
g_co2_per_kwh numeric(8,2) NOT NULL,
valid_from date NOT NULL,
valid_to date, -- NULL = currently valid
source text NOT NULL DEFAULT 'GEG Anlage 9',
UNIQUE (fuel_type, valid_from)
);
-- Energy prices per fuel (Module 5: cost estimate = kWh × price).
-- Versioned for the same auditability reason, plus created_by: price
-- maintenance is a manual admin process (owner and cadence still an
-- open decision), and attributing each price row makes that process
-- accountable from day one regardless of how the decision lands.
CREATE TABLE energy_price (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
fuel_type fuel_type NOT NULL,
ct_per_kwh numeric(8,3) NOT NULL,
valid_from date NOT NULL,
valid_to date,
source text,
created_by uuid REFERENCES app_user(id),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (fuel_type, valid_from)
);
-- DWD stations we import heating-degree-day data for. Modeled as its own
-- entity (not columns on building) because many buildings share a
-- station and the HDD import job iterates stations, not buildings.
CREATE TABLE weather_station (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
dwd_id text NOT NULL UNIQUE, -- DWD's station identifier
name text NOT NULL,
lat numeric(9,6),
lng numeric(9,6),
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE building
ADD CONSTRAINT building_weather_station_fk
FOREIGN KEY (weather_station_id) REFERENCES weather_station(id);
-- Monthly heating degree days per station (VDI 3807, DWD open data).
-- Consumed ONLY as display context — the Module 1 footnote "this month
-- was N% colder/warmer than the same month last year". Actual weather
-- normalization of consumption values is explicitly out of scope for
-- the UVI (it belongs to annual billing) and measurement values are
-- never adjusted by this data.
-- Operational note: a monthly DWD import fills this table; if a month
-- is missing, the footnote renders "not available" (UVI-FUN-15). The
-- import's timing sits inside the NFR-05 chain (data visible by the
-- 5th working day).
CREATE TABLE heating_degree_days (
station_id uuid NOT NULL REFERENCES weather_station(id),
year smallint NOT NULL,
month smallint NOT NULL CHECK (month BETWEEN 1 AND 12),
hdd numeric(7,1) NOT NULL,
PRIMARY KEY (station_id, year, month)
);
-- Savings tips library (Module 4, UVI-FUN-11). A table, not content in
-- code, because tips are editorial material admins curate. The category
-- drives seasonal selection (UVI-FUN-14): heating tips during the
-- heating period, hot-water tips outside it. active supports retiring
-- tips without deleting them (a tip already shown in a past month
-- should remain resolvable).
CREATE TABLE saving_tip (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
category saving_tip_category NOT NULL,
title text NOT NULL,
body text NOT NULL,
link_url text, -- deep link to advisory content
-- (verbraucherzentrale, co2online)
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ===========================================================================
-- 5. TENANT ISOLATION (UVI-NFR-03) — enforcement sketch
--
-- Isolation is enforced IN the database via row-level security, not only
-- in the Go backend: with Supabase Auth issuing JWTs, auth.uid() is
-- available inside policies, and a backend bug can then never leak a
-- neighbor's data — the worst case becomes an empty result.
--
-- Example policy shape for measurement (enable analogously per table):
--
-- ALTER TABLE measurement ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY tenant_reads_own ON measurement FOR SELECT
-- USING (
-- sensor_id IN (
-- SELECT s.id
-- FROM sensor s
-- JOIN occupancy o ON o.apartment_id = s.apartment_id
-- WHERE o.user_id = auth.uid()
-- AND daterange(o.valid_from,
-- COALESCE(o.valid_to, 'infinity'::date),
-- '[)') @> measured_at::date
-- )
-- );
--
-- The occupancy-window predicate is the important part: a tenant sees
-- only measurements from their own tenancy period, so pre-move-in
-- history is structurally invisible (the Mieterwechsel rule enforced at
-- the storage layer). Admins get a separate permissive policy scoped to
-- role = 'admin'; admin reads are additionally logged at the application
-- layer (UVI-NFR-07).
--
-- Deliberately NOT in the schema:
-- * The small-building anonymity rule (UVI-FUN-09: with ≤ 4 units,
-- show the mean of the three thriftiest households instead of the
-- spectrum) — that is presentation logic in the in-house comparison
-- query, driven by count(apartment) per building at query time.
-- * Heating-period vs. off-season behavior (UVI-FUN-14) — pure display
-- logic; the data model is season-agnostic.
-- ===========================================================================
-- ===========================================================================
-- 6. INGEST ROLE & GRANTS
--
-- gebos_ingest is the MQTT ingester's login role — gebos-specific, NOT a
-- Supabase role. It bypasses RLS because at write time no end user is
-- authenticated; the trust boundary is the broker ACL ("who may publish to
-- which tenant's topic"), not the database. Created here rather than in
-- init.sql because its grants are coupled to the tables above, and because
-- init.sql only runs at first initdb while migrations always apply.
--
-- Its password is set out-of-band by the gebos-postgres-passwords oneshot
-- on db-host (sops secret: ingester_postgres_password).
-- ===========================================================================
DO $$ BEGIN
CREATE ROLE gebos_ingest LOGIN BYPASSRLS;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
GRANT USAGE ON SCHEMA public TO gebos_ingest;
-- Lookups the ingest/parse pipeline performs: gateway by IMEI, sensor by
-- oms_id, parser configuration by device type.
GRANT SELECT ON gateway, sensor, device_type, value_parser TO gebos_ingest;
-- Frame log: INSERT on arrival; SELECT + UPDATE for the parse worker's
-- queue query and its parse_status transitions.
GRANT SELECT, INSERT, UPDATE ON received_payload TO gebos_ingest;
-- Measurements are append-only from the ingester's point of view — dedup is
-- INSERT .. ON CONFLICT DO NOTHING against the composite PK, which needs no
-- UPDATE privilege.
GRANT SELECT, INSERT ON measurement TO gebos_ingest;
+412
View File
@@ -0,0 +1,412 @@
-- ============================================================================
-- GebOS Dev/test fixture data (supabase/seed.sql)
--
-- DEV ONLY. Never apply to prod: it contains fake tenants and fake devices,
-- and the regulatory reference numbers (efficiency class thresholds, CO2
-- factors, prices, HDD) are plausible APPROXIMATIONS for development, not
-- verified values from the legal sources. Real reference data ships as its
-- own migration when the numbers have been verified.
--
-- Apply with psql against the local process-compose stack (see
-- supabase/README.md), AFTER the schema migration:
--
-- psql -h 127.0.0.1 -p 5432 -d gebos -f supabase/seed.sql
--
-- Idempotent by construction: fixture rows carry fixed UUIDs (so tests can
-- reference them) and every INSERT ends in ON CONFLICT DO NOTHING. The
-- measurement series is anchored at a fixed start date (2025-06-01) and
-- generated deterministically (md5-based jitter, no random()), so re-running
-- on a later day recomputes identical values for existing days (skipped via
-- the composite PK) and appends only the new days — cumulative meter totals
-- stay monotonic across re-runs.
--
-- UUID namespaces (first group encodes the table, last digits the row):
-- dd... weather_station b0... building a0... apartment
-- e0... app_user 0c... occupancy 9e... gateway
-- d1... device_type f0... value_parser 5e... sensor
-- 7a... received_payload 5a... saving_tip
--
-- Scenario coverage (what each fixture exists to exercise):
-- * Building 1 (Berlin, gas, 5 apartments) — normal in-house spectrum.
-- * Building 2 (Potsdam, district heating, 3 apartments) — small-building
-- anonymity rule (UVI-FUN-09), second fuel type, and NO weather station
-- (weather footnote must render "not available", UVI-FUN-15).
-- * Apartment 3 — Mieterwechsel on 2026-03-01: comparisons across that
-- date must be suppressed (both months must fall in ONE occupancy row).
-- * Apartment 5 — vacant since 2026-05-01 (occupancy ended, none current).
-- * Apartment 4 — real heat meter (energy + flow/return temperatures +
-- error flags), not a heat cost allocator.
-- * Apartment 1 — hot water metered as ENERGY; all others as VOLUME
-- (both measurement kinds must work, see measurement_kind comment).
-- * Sensor 5e..17 — provisioned but unassigned (apartment_id NULL) with
-- measurements: data stored, tenant-invisible until assigned.
-- * received_payload — one row per parse_status, incl. an unknown sender.
-- * Consumption profiles span thrifty (~45 kWh/m²a) to heavy (~190),
-- so the Bandtacho classes and the in-house comparison spread out.
--
-- app_user note: in prod, app_user.id must equal auth.users.id (GoTrue).
-- The dev stack has no GoTrue yet, so these users have no auth identity —
-- when it lands, create auth users with these fixed UUIDs to log in as them.
-- ============================================================================
BEGIN;
-- ---------------------------------------------------------------------------
-- Reference: weather station (assigned to building 1 only — building 2
-- deliberately has none)
-- ---------------------------------------------------------------------------
INSERT INTO weather_station (id, dwd_id, name, lat, lng) VALUES
('dd000000-0000-0000-0000-000000000001', '00433', 'Berlin-Tempelhof', 52.467500, 13.402100)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Buildings & apartments
-- ---------------------------------------------------------------------------
INSERT INTO building (id, street, house_number, postal_code, city, lat, lng,
fuel_type, weather_station_id, note) VALUES
('b0000000-0000-0000-0000-000000000001',
'Fichtestraße', '12', '10967', 'Berlin', 52.489200, 13.417100,
'natural_gas', 'dd000000-0000-0000-0000-000000000001',
'Schlüsselkasten am Hoftor, Code 4711. Heizungskeller: Zugang über Hof.'),
('b0000000-0000-0000-0000-000000000002',
'Gartenweg', '3', '14482', 'Potsdam', 52.390800, 13.115600,
'district_heating', NULL,
'Kleine Anlage, 3 WE. Übergabestation im Keller rechts.')
ON CONFLICT DO NOTHING;
INSERT INTO apartment (id, building_id, label, living_area_m2) VALUES
('a0000000-0000-0000-0000-000000000001', 'b0000000-0000-0000-0000-000000000001', 'EG links', 54.00),
('a0000000-0000-0000-0000-000000000002', 'b0000000-0000-0000-0000-000000000001', 'EG rechts', 72.00),
('a0000000-0000-0000-0000-000000000003', 'b0000000-0000-0000-0000-000000000001', '1. OG links', 85.00),
('a0000000-0000-0000-0000-000000000004', 'b0000000-0000-0000-0000-000000000001', '1. OG rechts', 96.00),
('a0000000-0000-0000-0000-000000000005', 'b0000000-0000-0000-0000-000000000001', '2. OG', 110.00),
('a0000000-0000-0000-0000-000000000006', 'b0000000-0000-0000-0000-000000000002', 'WE 01', 60.00),
('a0000000-0000-0000-0000-000000000007', 'b0000000-0000-0000-0000-000000000002', 'WE 02', 75.00),
('a0000000-0000-0000-0000-000000000008', 'b0000000-0000-0000-0000-000000000002', 'WE 03', 88.00)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Users & occupancies
-- ---------------------------------------------------------------------------
INSERT INTO app_user (id, role, display_name) VALUES
('e0000000-0000-0000-0000-000000000001', 'admin', 'Anna Admin'),
('e0000000-0000-0000-0000-000000000002', 'tenant', 'Max Mustermann'),
('e0000000-0000-0000-0000-000000000003', 'tenant', 'Erika Beispiel'),
('e0000000-0000-0000-0000-000000000004', 'tenant', 'Otto Vormieter'),
('e0000000-0000-0000-0000-000000000005', 'tenant', 'Nina Neumieterin'),
('e0000000-0000-0000-0000-000000000006', 'tenant', 'Karl Konstant'),
('e0000000-0000-0000-0000-000000000007', 'tenant', 'Ferdinand Fortgezogen'),
('e0000000-0000-0000-0000-000000000008', 'tenant', 'Gerda Gartenweg'),
('e0000000-0000-0000-0000-000000000009', 'tenant', 'Hans Hofmann'),
('e0000000-0000-0000-0000-00000000000a', 'tenant', 'Ines Iser')
ON CONFLICT DO NOTHING;
INSERT INTO occupancy (id, apartment_id, user_id, valid_from, valid_to) VALUES
-- building 1
('0c000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000002', '2023-09-01', NULL),
('0c000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000003', '2024-01-01', NULL),
-- Mieterwechsel in apartment 3: comparisons across 2026-03-01 are forbidden
('0c000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000004', '2022-05-01', '2026-03-01'),
('0c000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000005', '2026-03-01', NULL),
('0c000000-0000-0000-0000-000000000005', 'a0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000006', '2021-11-01', NULL),
-- apartment 5 vacant since 2026-05-01
('0c000000-0000-0000-0000-000000000006', 'a0000000-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000007', '2023-02-01', '2026-05-01'),
-- building 2
('0c000000-0000-0000-0000-000000000007', 'a0000000-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000008', '2020-01-01', NULL),
('0c000000-0000-0000-0000-000000000008', 'a0000000-0000-0000-0000-000000000007', 'e0000000-0000-0000-0000-000000000009', '2024-07-01', NULL),
('0c000000-0000-0000-0000-000000000009', 'a0000000-0000-0000-0000-000000000008', 'e0000000-0000-0000-0000-00000000000a', '2023-03-15', NULL)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Gateways (two in building 1: broadcast radio means overlapping reception)
-- ---------------------------------------------------------------------------
INSERT INTO gateway (id, imei, imsi, building_id, label, installation_note) VALUES
('9e000000-0000-0000-0000-000000000001', '861234050000011', '901405000000011',
'b0000000-0000-0000-0000-000000000001', 'GW Keller',
'Im Heizungskeller, über der Tür zum Anschlussraum.'),
('9e000000-0000-0000-0000-000000000002', '861234050000012', '901405000000012',
'b0000000-0000-0000-0000-000000000001', 'GW Dach',
'Trockenboden, am Kaminschacht montiert.'),
('9e000000-0000-0000-0000-000000000003', '861234050000013', '901405000000013',
'b0000000-0000-0000-0000-000000000002', 'GW Keller',
'Neben der Fernwärme-Übergabestation.')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Device types & value parsers
-- ---------------------------------------------------------------------------
INSERT INTO device_type (id, manufacturer, model, medium) VALUES
('d1000000-0000-0000-0000-000000000001', 'QDS', 'Q caloric 5.5', '08'), -- heat cost allocator
('d1000000-0000-0000-0000-000000000002', 'EFE', 'SensoStar U', '04'), -- heat meter
('d1000000-0000-0000-0000-000000000003', 'EFE', 'SensoStar E', '06'), -- warm-water heat meter
('d1000000-0000-0000-0000-000000000004', 'ZRI', 'Minomess', '06') -- warm-water volume meter
ON CONFLICT DO NOTHING;
INSERT INTO value_parser (id, device_type_id, json_path, parser_instructions, kind, unit) VALUES
('f0000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000001',
'$.records[0].value', '{"scale": 1}', 'heating_energy', 'kWh'),
('f0000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000002',
'$.records[?(@.type=="energy")].value', '{"scale": 1}', 'heating_energy', 'kWh'),
('f0000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000002',
'$.records[?(@.type=="flow_temp")].value', '{"scale": 1}', 'flow_temperature', '°C'),
('f0000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000002',
'$.records[?(@.type=="return_temp")].value', '{"scale": 1}', 'return_temperature', '°C'),
('f0000000-0000-0000-0000-000000000005', 'd1000000-0000-0000-0000-000000000002',
'$.status', NULL, 'error_flags', 'flags'),
('f0000000-0000-0000-0000-000000000006', 'd1000000-0000-0000-0000-000000000003',
'$.records[?(@.type=="energy")].value', '{"scale": 1}', 'hot_water_energy', 'kWh'),
('f0000000-0000-0000-0000-000000000007', 'd1000000-0000-0000-0000-000000000004',
'$.records[0].value', '{"scale": 0.001}', 'hot_water_volume', 'm3')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Sensors
-- 0108: heating, apartments 18 (apartment 4 has the real heat meter)
-- 0916: hot water, apartments 18 (apartment 1 energy, the rest volume)
-- 17: provisioned but unassigned
-- ---------------------------------------------------------------------------
INSERT INTO sensor (id, oms_id, device_type_id, apartment_id, decrypt_key, installed_at, installation_note) VALUES
('5e000000-0000-0000-0000-000000000001', '71000001', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', '000102030405060708090a0b0c0d0e01', '2025-04-14', 'HKV Wohnzimmer, einziger Heizkörper.'),
('5e000000-0000-0000-0000-000000000002', '71000002', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000002', '000102030405060708090a0b0c0d0e02', '2025-04-14', 'HKV Wohnzimmer.'),
('5e000000-0000-0000-0000-000000000003', '71000003', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000003', '000102030405060708090a0b0c0d0e03', '2025-04-14', 'HKV Schlafzimmer.'),
('5e000000-0000-0000-0000-000000000004', '71000004', 'd1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000004', '000102030405060708090a0b0c0d0e04', '2025-04-15', 'Wärmemengenzähler im Flurschacht.'),
('5e000000-0000-0000-0000-000000000005', '71000005', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000005', '000102030405060708090a0b0c0d0e05', '2025-04-15', 'HKV Küche.'),
('5e000000-0000-0000-0000-000000000006', '71000006', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000006', '000102030405060708090a0b0c0d0e06', '2025-05-06', 'HKV Wohnzimmer.'),
('5e000000-0000-0000-0000-000000000007', '71000007', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000007', '000102030405060708090a0b0c0d0e07', '2025-05-06', 'HKV Wohnzimmer.'),
('5e000000-0000-0000-0000-000000000008', '71000008', 'd1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000008', '000102030405060708090a0b0c0d0e08', '2025-05-06', 'HKV Bad.'),
('5e000000-0000-0000-0000-000000000009', '71000009', 'd1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', '000102030405060708090a0b0c0d0e09', '2025-04-14', 'WW-Wärmezähler unter der Spüle.'),
('5e000000-0000-0000-0000-000000000010', '71000010', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000002', '000102030405060708090a0b0c0d0e10', '2025-04-14', 'WW-Zähler Bad, Vorwandinstallation.'),
('5e000000-0000-0000-0000-000000000011', '71000011', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000003', '000102030405060708090a0b0c0d0e11', '2025-04-14', 'WW-Zähler Bad.'),
('5e000000-0000-0000-0000-000000000012', '71000012', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000004', '000102030405060708090a0b0c0d0e12', '2025-04-15', 'WW-Zähler Küche.'),
('5e000000-0000-0000-0000-000000000013', '71000013', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000005', '000102030405060708090a0b0c0d0e13', '2025-04-15', 'WW-Zähler Bad.'),
('5e000000-0000-0000-0000-000000000014', '71000014', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000006', '000102030405060708090a0b0c0d0e14', '2025-05-06', 'WW-Zähler Bad.'),
('5e000000-0000-0000-0000-000000000015', '71000015', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000007', '000102030405060708090a0b0c0d0e15', '2025-05-06', 'WW-Zähler Bad.'),
('5e000000-0000-0000-0000-000000000016', '71000016', 'd1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000008', '000102030405060708090a0b0c0d0e16', '2025-05-06', 'WW-Zähler Bad.'),
-- provisioned but not yet assigned to an apartment (normal pilot state)
('5e000000-0000-0000-0000-000000000017', '71000017', 'd1000000-0000-0000-0000-000000000001', NULL, '000102030405060708090a0b0c0d0e17', '2025-04-15', 'Lager: noch nicht montiert / zugeordnet.')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Raw frame log: one row per parse_status, including a frame from a meter
-- that is not ours (unknown_sensor — normal on a broadcast band).
-- ---------------------------------------------------------------------------
INSERT INTO received_payload (id, gateway_id, sensor_id, timestamp_sent, timestamp_received, raw_payload, parse_status) VALUES
('7a000000-0000-0000-0000-000000000001', '9e000000-0000-0000-0000-000000000001', '5e000000-0000-0000-0000-000000000001',
now() - interval '2 hours', now() - interval '2 hours',
'{"telegram": "2e44685071000001080c7a...", "decoded": {"manufacturer": "QDS", "serial": "71000001", "records": [{"type": "energy", "unit": "kWh", "value": 2841.7}]}}',
'parsed'),
('7a000000-0000-0000-0000-000000000002', '9e000000-0000-0000-0000-000000000002', '5e000000-0000-0000-0000-000000000002',
now() - interval '30 minutes', now() - interval '29 minutes',
'{"telegram": "2e44685071000002080c7a...", "decoded": {"manufacturer": "QDS", "serial": "71000002", "records": [{"type": "energy", "unit": "kWh", "value": 7404.2}]}}',
'pending'),
('7a000000-0000-0000-0000-000000000003', '9e000000-0000-0000-0000-000000000001', NULL,
now() - interval '1 hour', now() - interval '59 minutes',
'{"telegram": "2e44a51199887766060c7a...", "decoded": {"manufacturer": "LSE", "serial": "99887766", "records": [{"type": "volume", "unit": "m3", "value": 481.2}]}}',
'unknown_sensor'),
('7a000000-0000-0000-0000-000000000004', '9e000000-0000-0000-0000-000000000003', '5e000000-0000-0000-0000-000000000014',
now() - interval '3 hours', now() - interval '1 hour', -- buffered upload: sent ≪ received
'{"telegram": "1e44e5c871000014060c7a...", "decoded": null, "error": "decryption failed: wrong key length"}',
'error')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Measurements: daily cumulative meter readings from 2025-06-01 to today.
--
-- Values are cumulative totals (meters broadcast counters, consumption is
-- computed by differencing at query time). Daily increment = annual total
-- × month weight ÷ days-in-month, ± 10% deterministic jitter (md5 of
-- sensor/kind/day — NOT random(), so re-runs are byte-identical). Heating
-- follows a seasonal curve; hot water is flat across the year.
--
-- Annual heating totals encode the per-m² profiles the Bandtacho needs:
-- apt1 45 kWh/m²a (thrifty) … apt4 140 … apt5 190 kWh/m²a (heavy).
-- ---------------------------------------------------------------------------
WITH weights (m, wt) AS (
VALUES (1, 0.170), (2, 0.140), (3, 0.120), (4, 0.080), (5, 0.040), (6, 0.020),
(7, 0.015), (8, 0.015), (9, 0.040), (10, 0.080), (11, 0.120), (12, 0.160)
),
profiles (sensor_id, kind, unit, annual, start_value, seasonal) AS (
VALUES
-- heating (kWh/yr = profile kWh/m²a × living area)
('5e000000-0000-0000-0000-000000000001'::uuid, 'heating_energy'::measurement_kind, 'kWh', 2430.0, 3210.0, true),
('5e000000-0000-0000-0000-000000000002'::uuid, 'heating_energy'::measurement_kind, 'kWh', 6120.0, 5480.0, true),
('5e000000-0000-0000-0000-000000000003'::uuid, 'heating_energy'::measurement_kind, 'kWh', 8500.0, 9120.0, true),
('5e000000-0000-0000-0000-000000000004'::uuid, 'heating_energy'::measurement_kind, 'kWh', 13440.0, 21930.0, true),
('5e000000-0000-0000-0000-000000000005'::uuid, 'heating_energy'::measurement_kind, 'kWh', 20900.0, 15040.0, true),
('5e000000-0000-0000-0000-000000000006'::uuid, 'heating_energy'::measurement_kind, 'kWh', 4200.0, 2660.0, true),
('5e000000-0000-0000-0000-000000000007'::uuid, 'heating_energy'::measurement_kind, 'kWh', 7125.0, 4310.0, true),
('5e000000-0000-0000-0000-000000000008'::uuid, 'heating_energy'::measurement_kind, 'kWh', 10560.0, 7750.0, true),
-- hot water: apartment 1 as energy, the rest as volume
('5e000000-0000-0000-0000-000000000009'::uuid, 'hot_water_energy'::measurement_kind, 'kWh', 1620.0, 1890.0, false),
('5e000000-0000-0000-0000-000000000010'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 28.8, 214.3, false),
('5e000000-0000-0000-0000-000000000011'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 34.0, 131.9, false),
('5e000000-0000-0000-0000-000000000012'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 38.4, 356.0, false),
('5e000000-0000-0000-0000-000000000013'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 44.0, 102.4, false),
('5e000000-0000-0000-0000-000000000014'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 24.0, 188.7, false),
('5e000000-0000-0000-0000-000000000015'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 30.0, 77.5, false),
('5e000000-0000-0000-0000-000000000016'::uuid, 'hot_water_volume'::measurement_kind, 'm3', 35.2, 240.1, false),
-- unassigned sensor: measured and stored regardless
('5e000000-0000-0000-0000-000000000017'::uuid, 'heating_energy'::measurement_kind, 'kWh', 5000.0, 0.0, true)
),
days AS (
SELECT d::date AS d
FROM generate_series(date '2025-06-01', current_date, interval '1 day') AS d
),
daily AS (
SELECT p.sensor_id, p.kind, p.unit, dy.d, p.start_value,
p.annual
* CASE WHEN p.seasonal THEN w.wt ELSE 1.0 / 12 END
/ extract(day FROM date_trunc('month', dy.d) + interval '1 month - 1 day')
* (0.9 + 0.2 * ((('x' || substr(md5(p.sensor_id::text || p.kind::text || dy.d::text), 1, 8))::bit(32)::int & 1023) / 1023.0))
AS increment
FROM profiles p
CROSS JOIN days dy
JOIN weights w ON w.m = extract(month FROM dy.d)::int
)
INSERT INTO measurement (sensor_id, measured_at, kind, value, unit)
SELECT sensor_id,
-- per-sensor stable time-of-day, so series don't all tick in lockstep
d::timestamp + interval '6 hours'
+ make_interval(mins => (('x' || substr(md5(sensor_id::text), 1, 4))::bit(16)::int % 50)),
kind,
round((start_value + sum(increment) OVER (PARTITION BY sensor_id, kind ORDER BY d))::numeric, 3),
unit
FROM daily
ON CONFLICT DO NOTHING;
-- Diagnostics from the apartment-4 heat meter: flow/return temperatures
-- (seasonal: ~69/50 °C in January, ~44/32 °C in July) and an error register
-- that is 0 except for one flagged day.
WITH weights (m, wt) AS (
VALUES (1, 0.170), (2, 0.140), (3, 0.120), (4, 0.080), (5, 0.040), (6, 0.020),
(7, 0.015), (8, 0.015), (9, 0.040), (10, 0.080), (11, 0.120), (12, 0.160)
),
days AS (
SELECT d::date AS d
FROM generate_series(date '2025-06-01', current_date, interval '1 day') AS d
)
INSERT INTO measurement (sensor_id, measured_at, kind, value, unit)
SELECT '5e000000-0000-0000-0000-000000000004'::uuid,
dy.d::timestamp + interval '6 hours 15 minutes',
k.kind,
round((k.base + k.span * w.wt
+ 2 * ((('x' || substr(md5(k.kind::text || dy.d::text), 1, 8))::bit(32)::int & 255) / 255.0))::numeric, 1),
'°C'
FROM days dy
JOIN weights w ON w.m = extract(month FROM dy.d)::int
CROSS JOIN (VALUES ('flow_temperature'::measurement_kind, 42.0, 160.0),
('return_temperature'::measurement_kind, 30.0, 120.0)) AS k (kind, base, span)
ON CONFLICT DO NOTHING;
INSERT INTO measurement (sensor_id, measured_at, kind, value, unit) VALUES
('5e000000-0000-0000-0000-000000000004', date '2026-02-10' + interval '6 hours 15 minutes', 'error_flags', 0, 'flags'),
('5e000000-0000-0000-0000-000000000004', date '2026-02-11' + interval '6 hours 15 minutes', 'error_flags', 16, 'flags'), -- e.g. air in flow sensor
('5e000000-0000-0000-0000-000000000004', date '2026-02-12' + interval '6 hours 15 minutes', 'error_flags', 0, 'flags')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Reference data — DEV APPROXIMATIONS, not verified legal values.
-- ---------------------------------------------------------------------------
-- Efficiency class thresholds: GEG Anlage 10 annual bounds split into monthly
-- bounds using the same seasonal weights as the heating fixtures. The real
-- UBA CC 69/2021 Tabelle 1 values differ — replace via a verified migration.
WITH classes (class, annual_max) AS (
VALUES ('A+', 30.0), ('A', 50.0), ('B', 75.0), ('C', 100.0),
('D', 130.0), ('E', 160.0), ('F', 200.0), ('G', 250.0)
),
weights (m, wt) AS (
VALUES (1, 0.170), (2, 0.140), (3, 0.120), (4, 0.080), (5, 0.040), (6, 0.020),
(7, 0.015), (8, 0.015), (9, 0.040), (10, 0.080), (11, 0.120), (12, 0.160)
)
INSERT INTO efficiency_class_threshold (class, month, max_kwh_per_m2, source)
SELECT c.class, w.m, round((c.annual_max * w.wt)::numeric, 3),
'DEV FIXTURE — approximation of UBA CC 69/2021 Tab. 1 / GEG Anl. 10'
FROM classes c CROSS JOIN weights w
UNION ALL
SELECT 'H', w.m, 999.999, -- open-ended class: sentinel, not a real bound
'DEV FIXTURE — approximation of UBA CC 69/2021 Tab. 1 / GEG Anl. 10'
FROM weights w
ON CONFLICT DO NOTHING;
-- CO2 factors: one superseded natural-gas row to exercise versioning.
INSERT INTO co2_emission_factor (fuel_type, g_co2_per_kwh, valid_from, valid_to, source) VALUES
('natural_gas', 202.00, '2021-01-01', '2023-12-31', 'DEV FIXTURE — approximates GEG Anlage 9'),
('natural_gas', 240.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'),
('heating_oil', 310.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'),
('district_heating', 180.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'),
('heat_pump_electricity', 560.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'),
('wood_pellets', 20.00, '2024-01-01', NULL, 'DEV FIXTURE — approximates GEG Anlage 9'),
('other', 300.00, '2024-01-01', NULL, 'DEV FIXTURE — placeholder')
ON CONFLICT DO NOTHING;
-- Energy prices: one superseded natural-gas row to exercise versioning.
INSERT INTO energy_price (fuel_type, ct_per_kwh, valid_from, valid_to, source, created_by) VALUES
('natural_gas', 11.200, '2024-01-01', '2025-12-31', 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'),
('natural_gas', 12.400, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'),
('heating_oil', 10.900, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'),
('district_heating', 15.800, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'),
('heat_pump_electricity', 27.500, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001'),
('wood_pellets', 8.200, '2026-01-01', NULL, 'DEV FIXTURE', 'e0000000-0000-0000-0000-000000000001')
ON CONFLICT DO NOTHING;
-- Heating degree days: the 15 complete months before the current month.
-- The CURRENT month is deliberately absent — the freshest footnote renders
-- "not available" until the (future) monthly DWD import fills it, exactly
-- like prod. Deterministic per (year, month), so re-runs insert nothing new
-- for existing months and exactly one row when a month completes.
WITH base (m, hdd0) AS (
VALUES (1, 550), (2, 480), (3, 400), (4, 280), (5, 140), (6, 40),
(7, 15), (8, 20), (9, 110), (10, 280), (11, 420), (12, 520)
),
months AS (
SELECT (date_trunc('month', current_date) - make_interval(months => n))::date AS mon
FROM generate_series(1, 15) AS n
)
INSERT INTO heating_degree_days (station_id, year, month, hdd)
SELECT 'dd000000-0000-0000-0000-000000000001',
extract(year FROM mon)::smallint,
extract(month FROM mon)::smallint,
round((b.hdd0 * (0.90 + 0.2 * ((('x' || substr(md5(mon::text), 1, 8))::bit(32)::int & 255) / 255.0)))::numeric, 1)
FROM months
JOIN base b ON b.m = extract(month FROM mon)::int
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Savings tips (Module 4) — one retired tip to exercise active = false.
-- ---------------------------------------------------------------------------
INSERT INTO saving_tip (id, category, title, body, link_url, active) VALUES
('5a000000-0000-0000-0000-000000000001', 'heating', 'Stoßlüften statt Kipplüften',
'Mehrmals täglich 510 Minuten mit weit geöffnetem Fenster lüften, statt das Fenster dauerhaft zu kippen. So bleibt die Wärme in Wänden und Möbeln erhalten.',
'https://www.verbraucherzentrale.de/wissen/energie/heizen-und-warmwasser', true),
('5a000000-0000-0000-0000-000000000002', 'heating', 'Raumtemperatur um 1 °C senken',
'Ein Grad weniger spart rund 6 % Heizenergie. 20 °C im Wohnzimmer und 1718 °C im Schlafzimmer reichen in der Regel aus.',
'https://www.co2online.de/energie-sparen/heizenergie-sparen/', true),
('5a000000-0000-0000-0000-000000000003', 'heating', 'Heizkörper entlüften',
'Gluckert der Heizkörper oder wird er nur teilweise warm, ist Luft im System. Entlüften stellt die volle Heizleistung wieder her.',
NULL, true),
('5a000000-0000-0000-0000-000000000004', 'heating', 'Heizkörper nicht zustellen',
'Möbel und Vorhänge vor dem Heizkörper stauen die Wärme und erhöhen den Verbrauch.',
NULL, false), -- retired tip: must remain resolvable for past months
('5a000000-0000-0000-0000-000000000005', 'hot_water', 'Duschen statt Baden',
'Ein Vollbad benötigt etwa dreimal so viel warmes Wasser wie eine kurze Dusche.',
'https://www.verbraucherzentrale.de/wissen/energie/heizen-und-warmwasser', true),
('5a000000-0000-0000-0000-000000000006', 'hot_water', 'Sparduschkopf einbauen',
'Ein Sparduschkopf halbiert den Warmwasserverbrauch beim Duschen — bei gleichem Komfort.',
'https://www.co2online.de/energie-sparen/strom-sparen/', true),
('5a000000-0000-0000-0000-000000000007', 'hot_water', 'Warmwasser nicht laufen lassen',
'Beim Einseifen, Zähneputzen und Spülen das warme Wasser abstellen.',
NULL, true)
ON CONFLICT DO NOTHING;
COMMIT;