Compare commits
21 Commits
19027e3b48
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f4b5926e1f | |||
| 1047968946 | |||
| 2ce68cb098 | |||
| b2a1056255 | |||
| d530f0f3e5 | |||
| 2d0ac1bc53 | |||
| 58155dc737 | |||
| 08d9fc594e | |||
| 5e2454f622 | |||
| 16fd12173f | |||
| e1b3017fb7 | |||
| d5a0be9e40 | |||
| 649dd737c7 | |||
| bf8b25f374 | |||
| 70c2ada986 | |||
| 4f9c6086bd | |||
| c526cf671f | |||
| 30b325393a | |||
| 7579d4dcf5 | |||
| 9a6a901dfe | |||
| b10d22bd57 |
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
flake-check:
|
||||
runs-on: nix
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: nix flake check
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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 (1–6).
|
||||
|
||||
| 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
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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 TCP→unix-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).
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 ];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
v2.109.1
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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
|
||||
-- 01–08: heating, apartments 1–8 (apartment 4 has the real heat meter)
|
||||
-- 09–16: hot water, apartments 1–8 (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 5–10 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 17–18 °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;
|
||||
Reference in New Issue
Block a user