diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..fa43703 --- /dev/null +++ b/.gitea/workflows/check.yml @@ -0,0 +1,13 @@ +name: check +on: + pull_request: + push: + branches: [main] + +jobs: + flake-check: + runs-on: nix + steps: + - uses: actions/checkout@v4 + - run: nix flake check + - run: nix build .#ingester .#frontend diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..dee3807 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,31 @@ +name: deploy +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: nix + 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 + + - name: Build all closures + run: nix flake check --no-build + + # Order matters: db schema migrations are part of db-host's activation, + # 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 + + - name: Deploy app-host + run: nix run github:serokell/deploy-rs -- .#app-host --skip-checks + + - name: Deploy mqtt-ingest + run: nix run github:serokell/deploy-rs -- .#mqtt-ingest --skip-checks diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8bdb7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +result +result-* +.direnv/ +node_modules/ +dist/ +*.log + +# local secrets — never commit +secrets.env +.env +.env.* +!.env.example diff --git a/README.md b/README.md index e69de29..5bd85ce 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,60 @@ +# Gebos + +IoT telemetry stack on bare-metal NixOS. + +This repo is a monorepo containing all code, NixOS modules, host configurations, +and CI/CD pipelines for the project. Everything is one `flake.nix`. + +## Architecture + +Three hosts (see issue #21 for the full design discussion): + +| Host | Role | Public hostname | +| ------------- | ----------------------------------------- | ---------------------- | +| `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` | + +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`. + +## Repo layout + +``` +flake.nix +frontend/ # Vite + React SPA +ingester/ # Go MQTT → Postgres +nix/ + modules/ # NixOS modules (one per service) + hosts/ # nixosConfigurations: mqtt-ingest, db-host, app-host + supabase/ # vendored Supabase docker-compose, db init SQL + dev/ # process-compose for local development + deploy.nix # deploy-rs node map +.gitea/workflows/ # CI + CD +``` + +## Local development + +``` +nix run .#dev +``` + +Brings the full stack up on one machine via process-compose-flake (Postgres, +Supabase compose, Kong, Caddy, ingester, Vite dev server). NixOS required. + +## Deployment + +`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`. + +``` +nix run github:serokell/deploy-rs -- .#db-host # one host +nix run github:serokell/deploy-rs -- . # all hosts +``` + +## Secrets + +Out-of-band file at `/etc/gebos/secrets.env` on each host, mode `0400`, loaded +by systemd `EnvironmentFile=`. Not in the repo, not in the Nix store. Rotation +is manual (ssh + edit + restart unit). diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3f0c9f7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,56 @@ +{ + description = "Gebos — IoT telemetry stack on bare-metal NixOS"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + 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"; + }; + + outputs = inputs@{ self, flake-parts, nixpkgs, deploy-rs, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ "x86_64-linux" "aarch64-linux" ]; + + imports = [ + inputs.process-compose-flake.flakeModule + ]; + + flake = { + nixosModules = import ./nix/modules; + nixosConfigurations = import ./nix/hosts { inherit inputs; }; + deploy = import ./nix/deploy.nix { inherit inputs; }; + + # `nix flake check` runs deploy-rs's schema check on every node + checks = builtins.mapAttrs + (system: deployLib: deployLib.deployChecks self.deploy) + deploy-rs.lib; + }; + + perSystem = { config, pkgs, system, ... }: { + packages = { + ingester = pkgs.callPackage ./ingester { }; + frontend = pkgs.callPackage ./frontend { }; + }; + + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + go + gopls + nodejs_20 + postgresql_17 + supabase-cli + deploy-rs.packages.${system}.default + ]; + }; + + # `nix run .#dev` — full stack locally via process-compose-flake. + # Real service wiring lives in nix/dev/process-compose.nix. + process-compose.dev = import ./nix/dev/process-compose.nix { + inherit pkgs; + inherit (inputs) services-flake; + }; + }; + }; +} diff --git a/frontend/default.nix b/frontend/default.nix new file mode 100644 index 0000000..177259e --- /dev/null +++ b/frontend/default.nix @@ -0,0 +1,21 @@ +{ buildNpmPackage, lib }: + +buildNpmPackage { + pname = "gebos-frontend"; + version = "0.0.0"; + src = ./.; + + # Filled in once package-lock.json exists. + npmDepsHash = lib.fakeHash; + + installPhase = '' + runHook preInstall + mkdir -p $out/share/frontend + cp -r dist/* $out/share/frontend/ + runHook postInstall + ''; + + meta = { + description = "Gebos static SPA"; + }; +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3a48ef7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Gebos + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b2b9140 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "gebos-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@supabase/supabase-js": "^2.45.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.2" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2a95e5d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,8 @@ +export function App() { + return ( +
+

Gebos

+

Skeleton SPA. Real UI lands once the architecture PR is approved.

+
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..1c711a7 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/src/supabase.ts b/frontend/src/supabase.ts new file mode 100644 index 0000000..fe1d54d --- /dev/null +++ b/frontend/src/supabase.ts @@ -0,0 +1,7 @@ +import { createClient } from "@supabase/supabase-js"; + +// Single subdomain for everything Supabase — Kong fans out to /auth/v1, /rest/v1, /rpc. +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL ?? "https://api.ge-bos.de"; +const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ""; + +export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..08ff715 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..425c778 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { outDir: "dist" }, +}); diff --git a/ingester/default.nix b/ingester/default.nix new file mode 100644 index 0000000..03a5e9e --- /dev/null +++ b/ingester/default.nix @@ -0,0 +1,15 @@ +{ buildGoModule, lib }: + +buildGoModule { + pname = "gebos-ingester"; + version = "0.0.0"; + src = ./.; + + # Set once `go mod tidy` has produced a real go.sum. + vendorHash = null; + + meta = { + description = "MQTT → Postgres telemetry ingester for Gebos"; + mainProgram = "gebos-ingester"; + }; +} diff --git a/ingester/go.mod b/ingester/go.mod new file mode 100644 index 0000000..bbeb981 --- /dev/null +++ b/ingester/go.mod @@ -0,0 +1,7 @@ +module github.com/gebos/ingester + +go 1.22 + +// TODO after first `go mod tidy`: +// - github.com/eclipse/paho.mqtt.golang +// - github.com/jackc/pgx/v5 diff --git a/ingester/main.go b/ingester/main.go new file mode 100644 index 0000000..72cdef8 --- /dev/null +++ b/ingester/main.go @@ -0,0 +1,41 @@ +// Package main — Gebos MQTT → Postgres ingester. +// +// Skeleton. Target: < 200 lines. +// +// Subscribes to topics of the form `t//d//`, +// extracts tenant_id and device_id from the topic, writes rows into +// public.telemetry on db-host as the gebos_ingest role (BYPASSRLS). +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" +) + +func main() { + broker := os.Getenv("GEBOS_MQTT_BROKER") + pgURL := os.Getenv("GEBOS_POSTGRES_URL") + pgPassword := os.Getenv("GEBOS_POSTGRES_PASSWORD") + + if broker == "" || pgURL == "" || pgPassword == "" { + log.Fatal("GEBOS_MQTT_BROKER, GEBOS_POSTGRES_URL and GEBOS_POSTGRES_PASSWORD must be set") + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // TODO: + // 1. pgxpool.New with password injected into the DSN + // 2. paho MQTT client.Connect, Subscribe("t/+/d/+/+") + // 3. on message: parse topic → (tenant_id, device_id, metric), parse + // payload (JSON for now), INSERT INTO public.telemetry + // 4. batch with a short flush interval (50–100ms) for throughput + // 5. shutdown on ctx.Done + + log.Printf("gebos-ingester: broker=%s db= (stub)", broker) + <-ctx.Done() + log.Println("gebos-ingester: shutting down") +} diff --git a/nix/deploy.nix b/nix/deploy.nix new file mode 100644 index 0000000..530b973 --- /dev/null +++ b/nix/deploy.nix @@ -0,0 +1,25 @@ +{ inputs }: + +let + mkNode = name: hostname: { + inherit hostname; + sshUser = "deploy"; + user = "root"; + profiles.system = { + path = inputs.deploy-rs.lib.x86_64-linux.activate.nixos + inputs.self.nixosConfigurations.${name}; + }; + }; +in +{ + sshOpts = [ "-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"; + }; +} diff --git a/nix/dev/process-compose.nix b/nix/dev/process-compose.nix new file mode 100644 index 0000000..a0d546e --- /dev/null +++ b/nix/dev/process-compose.nix @@ -0,0 +1,30 @@ +{ pkgs, services-flake }: + +{ ... }: +{ + imports = [ services-flake.processComposeModules.default ]; + + # Skeleton — services-flake gives us postgres, plus we'd add Supabase via + # docker-compose run, Caddy, Kong (or skip Kong locally and hit services + # directly), the ingester, and `vite dev`. Fill in as we go. + services = { + postgres."db" = { + enable = true; + port = 5432; + initialDatabases = [{ name = "gebos"; }]; + }; + }; + + settings.processes = { + ingester = { + command = "${pkgs.go}/bin/go run ./ingester"; + depends_on."db".condition = "process_healthy"; + }; + frontend = { + command = "${pkgs.nodejs_20}/bin/npm --prefix frontend run dev"; + }; + # 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) + }; +} diff --git a/nix/hosts/app-host.nix b/nix/hosts/app-host.nix new file mode 100644 index 0000000..bc44122 --- /dev/null +++ b/nix/hosts/app-host.nix @@ -0,0 +1,27 @@ +{ config, lib, pkgs, ... }: + +{ + networking.hostName = "app-host"; + + 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 + postgresPort = 5432; + # Studio bound to loopback only — reach via `ssh -L 3000:127.0.0.1:3000 app-host`. + studioBindAddress = "127.0.0.1"; + }; + + services.gebos.caddy = { + 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"; }; + }; + }; + + services.gebos.frontend.enable = true; + + networking.firewall.allowedTCPPorts = [ 80 443 ]; +} diff --git a/nix/hosts/common.nix b/nix/hosts/common.nix new file mode 100644 index 0000000..7a29e7d --- /dev/null +++ b/nix/hosts/common.nix @@ -0,0 +1,31 @@ +{ config, lib, pkgs, ... }: + +{ + system.stateVersion = "25.05"; + + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + trusted-users = [ "@wheel" "deploy" ]; + }; + + # deploy-rs SSHes in as `deploy` and uses sudo to activate. + users.users.deploy = { + isNormalUser = true; + extraGroups = [ "wheel" ]; + openssh.authorizedKeys.keys = [ + # TODO: paste the Gitea runner's deploy ed25519 pubkey here + ]; + }; + security.sudo.wheelNeedsPassword = false; + + services.openssh = { + enable = true; + settings.PasswordAuthentication = false; + }; + + networking.firewall.enable = true; + + time.timeZone = "UTC"; + + environment.systemPackages = with pkgs; [ vim curl jq ]; +} diff --git a/nix/hosts/db-host.nix b/nix/hosts/db-host.nix new file mode 100644 index 0000000..5a8c2ab --- /dev/null +++ b/nix/hosts/db-host.nix @@ -0,0 +1,21 @@ +{ config, lib, pkgs, ... }: + +{ + networking.hostName = "db-host"; + + # 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 ]; + + services.gebos.postgres = { + enable = true; + listenAddresses = [ "0.0.0.0" ]; + # Telemetry, auth, _supavisor, _analytics live in the same cluster. + # See nix/supabase/init.sql for schema/role bootstrap. + }; + + # Postgres exposed only to the app-host and mqtt-ingest private addresses. + networking.firewall.extraInputRules = '' + ip saddr { 10.0.0.0/8 } tcp dport 5432 accept + ''; +} diff --git a/nix/hosts/default.nix b/nix/hosts/default.nix new file mode 100644 index 0000000..2a22222 --- /dev/null +++ b/nix/hosts/default.nix @@ -0,0 +1,24 @@ +{ inputs }: + +let + mkHost = name: extraModules: + inputs.nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + specialArgs = { inherit inputs; }; + modules = [ + ./common.nix + (import ../modules).gebos-postgres + (import ../modules).gebos-supabase + (import ../modules).gebos-caddy + (import ../modules).gebos-hivemq + (import ../modules).gebos-ingester + (import ../modules).gebos-frontend + ./${name}.nix + ] ++ extraModules; + }; +in +{ + db-host = mkHost "db-host" [ ]; + app-host = mkHost "app-host" [ ]; + mqtt-ingest = mkHost "mqtt-ingest" [ ]; +} diff --git a/nix/hosts/mqtt-ingest.nix b/nix/hosts/mqtt-ingest.nix new file mode 100644 index 0000000..fb808fb --- /dev/null +++ b/nix/hosts/mqtt-ingest.nix @@ -0,0 +1,29 @@ +{ config, lib, pkgs, ... }: + +{ + networking.hostName = "mqtt-ingest"; + + services.gebos.hivemq = { + enable = true; + # Persistent sessions on disk — single-node, devices reconnect on restart. + persistentSessions = true; + tlsPort = 8883; + }; + + services.gebos.ingester = { + enable = true; + mqttBroker = "tcp://127.0.0.1:1883"; # local broker + postgresUrl = "postgres://gebos_ingest@db-host.gebos.internal:5432/postgres?sslmode=require"; + # Password sourced from /etc/gebos/secrets.env via EnvironmentFile. + }; + + # 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 ]; +} diff --git a/nix/modules/default.nix b/nix/modules/default.nix new file mode 100644 index 0000000..baf037b --- /dev/null +++ b/nix/modules/default.nix @@ -0,0 +1,8 @@ +{ + gebos-ingester = import ./gebos-ingester.nix; + 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-frontend = import ./gebos-frontend.nix; +} diff --git a/nix/modules/gebos-caddy.nix b/nix/modules/gebos-caddy.nix new file mode 100644 index 0000000..56d4790 --- /dev/null +++ b/nix/modules/gebos-caddy.nix @@ -0,0 +1,47 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.gebos.caddy; + + siteBlock = host: site: + if site ? staticRoot then '' + ${host} { + root * ${site.staticRoot} + file_server + try_files {path} /index.html + encode zstd gzip + } + '' + else if site ? upstream then '' + ${host} { + reverse_proxy ${site.upstream} + 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"; +in +{ + options.services.gebos.caddy = { + enable = lib.mkEnableOption "Gebos Caddy reverse proxy / TLS terminator"; + sites = lib.mkOption { + type = lib.types.attrsOf lib.types.attrs; + default = { }; + }; + }; + + 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); + }; + }; +} diff --git a/nix/modules/gebos-frontend.nix b/nix/modules/gebos-frontend.nix new file mode 100644 index 0000000..71c8d90 --- /dev/null +++ b/nix/modules/gebos-frontend.nix @@ -0,0 +1,19 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.gebos.frontend; +in +{ + options.services.gebos.frontend = { + enable = lib.mkEnableOption "Gebos static SPA"; + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ../../frontend { }; + }; + }; + + config = lib.mkIf cfg.enable { + # Nothing to install at the system level — Caddy serves the package's + # `share/frontend` directory directly. See nix/hosts/app-host.nix. + }; +} diff --git a/nix/modules/gebos-hivemq.nix b/nix/modules/gebos-hivemq.nix new file mode 100644 index 0000000..43584f5 --- /dev/null +++ b/nix/modules/gebos-hivemq.nix @@ -0,0 +1,33 @@ +{ 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"; + }; + }; + }; +} diff --git a/nix/modules/gebos-ingester.nix b/nix/modules/gebos-ingester.nix new file mode 100644 index 0000000..573b142 --- /dev/null +++ b/nix/modules/gebos-ingester.nix @@ -0,0 +1,41 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.gebos.ingester; + ingester = pkgs.callPackage ../../ingester { }; +in +{ + options.services.gebos.ingester = { + enable = lib.mkEnableOption "Gebos MQTT → Postgres ingester"; + mqttBroker = lib.mkOption { + type = lib.types.str; + example = "tcp://127.0.0.1:1883"; + }; + postgresUrl = lib.mkOption { + type = lib.types.str; + description = "DSN without password — password comes from EnvironmentFile."; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.gebos-ingester = { + description = "Gebos MQTT → Postgres ingester"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = { + GEBOS_MQTT_BROKER = cfg.mqttBroker; + GEBOS_POSTGRES_URL = cfg.postgresUrl; + }; + + serviceConfig = { + ExecStart = "${ingester}/bin/gebos-ingester"; + DynamicUser = true; + EnvironmentFile = "/etc/gebos/secrets.env"; # GEBOS_POSTGRES_PASSWORD + Restart = "on-failure"; + RestartSec = "5s"; + }; + }; + }; +} diff --git a/nix/modules/gebos-postgres.nix b/nix/modules/gebos-postgres.nix new file mode 100644 index 0000000..e36eba4 --- /dev/null +++ b/nix/modules/gebos-postgres.nix @@ -0,0 +1,36 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.gebos.postgres; +in +{ + options.services.gebos.postgres = { + enable = lib.mkEnableOption "Gebos Postgres 17 + TimescaleDB"; + listenAddresses = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "127.0.0.1" ]; + }; + }; + + config = lib.mkIf cfg.enable { + services.postgresql = { + enable = true; + package = pkgs.postgresql_17; + enableTCPIP = true; + settings = { + listen_addresses = lib.concatStringsSep "," cfg.listenAddresses; + shared_preload_libraries = "timescaledb,pg_stat_statements"; + }; + extensions = ps: with ps; [ + timescaledb + pgjwt + pg_graphql + pgsodium + # supabase_vault — TODO: package or vendor + ]; + # Init script creates Supabase's `auth`, `storage`, `_analytics`, `_supavisor` + # schemas, the gebos_ingest role (BYPASSRLS), and telemetry hypertables. + initialScript = ../supabase/init.sql; + }; + }; +} diff --git a/nix/modules/gebos-supabase.nix b/nix/modules/gebos-supabase.nix new file mode 100644 index 0000000..c285408 --- /dev/null +++ b/nix/modules/gebos-supabase.nix @@ -0,0 +1,61 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.gebos.supabase; + composeDir = ../supabase; +in +{ + options.services.gebos.supabase = { + enable = lib.mkEnableOption "Supabase stack via vanilla docker-compose"; + + postgresHost = lib.mkOption { + type = lib.types.str; + description = "External Postgres host (db-host)."; + }; + postgresPort = lib.mkOption { + type = lib.types.port; + default = 5432; + }; + studioBindAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Studio is intentionally never exposed publicly."; + }; + }; + + config = lib.mkIf cfg.enable { + virtualisation.docker.enable = true; + + systemd.tmpfiles.rules = [ + "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. + environment.etc."gebos/supabase/docker-compose.yml".source = + "${composeDir}/docker-compose.yml"; + + systemd.services.gebos-supabase = { + description = "Gebos — Supabase compose stack"; + after = [ "docker.service" "network-online.target" ]; + wants = [ "docker.service" "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = { + POSTGRES_HOST = cfg.postgresHost; + POSTGRES_PORT = toString cfg.postgresPort; + POSTGRES_DB = "postgres"; + STUDIO_BIND = cfg.studioBindAddress; + }; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + WorkingDirectory = "/etc/gebos/supabase"; + EnvironmentFile = "/etc/gebos/secrets.env"; # POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, DASHBOARD_PASSWORD + ExecStart = "${pkgs.docker}/bin/docker compose up -d --remove-orphans"; + ExecStop = "${pkgs.docker}/bin/docker compose down"; + }; + }; + }; +} diff --git a/nix/supabase/README.md b/nix/supabase/README.md new file mode 100644 index 0000000..e94d471 --- /dev/null +++ b/nix/supabase/README.md @@ -0,0 +1,40 @@ +# Supabase compose stack + +Vendored from the official Supabase self-hosting bundle, with two 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`. + +2. Studio is bound to `127.0.0.1` only. There is **no public route** to it. + To use it from your laptop: + + ``` + ssh -L 3000:127.0.0.1:3000 app-host + 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. + +## 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). + +## 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. diff --git a/nix/supabase/docker-compose.yml b/nix/supabase/docker-compose.yml new file mode 100644 index 0000000..c9cba13 --- /dev/null +++ b/nix/supabase/docker-compose.yml @@ -0,0 +1,67 @@ +# Gebos — vendored Supabase compose stack. +# +# TODO: copy the full official compose from +# 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) +# +# 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. + +services: + kong: + image: kong:2.8.1 + restart: unless-stopped + ports: + - "127.0.0.1:8000:8000/tcp" # Caddy proxies api.ge-bos.de here + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml + volumes: + - ./kong.yml:/home/kong/kong.yml:ro + + auth: + image: supabase/gotrue:v2.158.1 + restart: unless-stopped + environment: + GOTRUE_DB_DRIVER: postgres + 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_JWT_EXP: "3600" + API_EXTERNAL_URL: https://api.ge-bos.de + + rest: + image: postgrest/postgrest:v12.0.2 + restart: unless-stopped + 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_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} + + meta: + image: supabase/postgres-meta:v0.83.2 + restart: unless-stopped + environment: + PG_META_DB_HOST: ${POSTGRES_HOST} + PG_META_DB_PORT: ${POSTGRES_PORT} + PG_META_DB_NAME: ${POSTGRES_DB} + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} diff --git a/nix/supabase/init.sql b/nix/supabase/init.sql new file mode 100644 index 0000000..339ba21 --- /dev/null +++ b/nix/supabase/init.sql @@ -0,0 +1,81 @@ +-- Gebos Postgres bootstrap. +-- Runs once via services.postgresql.initialScript on db-host. +-- +-- Sets up: +-- * Extensions Supabase + TimescaleDB need +-- * Supabase's expected schemas and admin roles +-- * The gebos_ingest role (BYPASSRLS) used by the MQTT ingester +-- * A telemetry hypertable with tenant_id column for RLS +-- +-- TODO: split into ordered files once it grows. For the skeleton, one file is fine. + +create extension if not exists timescaledb; +create extension if not exists pgcrypto; +create extension if not exists pgjwt; +create extension if not exists pg_graphql; +create extension if not exists pgsodium; +-- create extension if not exists supabase_vault; -- TODO: vendor or package + +-- Supabase schemas +create schema if not exists auth; +create schema if not exists storage; +create schema if not exists _analytics; +create schema if not exists _supavisor; + +-- Roles Supabase services connect as. Passwords come from secrets.env, not here. +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 noinherit login; + grant anon, authenticated, service_role to authenticator; +exception when duplicate_object then null; end $$; + +do $$ begin + create role supabase_admin login createrole createdb replication bypassrls; +exception when duplicate_object then null; end $$; + +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; diff --git a/nix/supabase/kong.yml b/nix/supabase/kong.yml new file mode 100644 index 0000000..188b58e --- /dev/null +++ b/nix/supabase/kong.yml @@ -0,0 +1,48 @@ +_format_version: "2.1" + +# Declarative Kong config — vanilla Supabase routing, no surprises. +# Lives at /home/kong/kong.yml inside the container. + +consumers: + - username: anon + keyauth_credentials: + - key: ${ANON_KEY} + - username: service_role + keyauth_credentials: + - key: ${SERVICE_ROLE_KEY} + +services: + - name: auth-v1 + url: http://auth:9999/ + routes: + - name: auth-v1-route + strip_path: true + paths: [ /auth/v1/ ] + plugins: + - name: cors + + - name: rest-v1 + url: http://rest:3000/ + routes: + - name: rest-v1-route + strip_path: true + paths: [ /rest/v1/ ] + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + key_names: [ apikey ] + + - name: rpc + url: http://rest:3000/rpc/ + routes: + - name: rpc-route + strip_path: true + paths: [ /rpc/ ] + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + key_names: [ apikey ]