Infrastructure skeleton — flake, three hosts, Supabase compose, ingester, deploy-rs #23
@@ -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
|
||||||
@@ -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
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
result
|
||||||
|
result-*
|
||||||
|
.direnv/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# local secrets — never commit
|
||||||
|
secrets.env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# age private keys — never commit (public keys live in .sops.yaml)
|
||||||
|
*.age
|
||||||
|
keys.txt
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# sops creation/decryption rules.
|
||||||
|
#
|
||||||
|
# Recipients listed here decide who can decrypt each key in
|
||||||
|
# nix/secrets/secrets.yaml. Each host gets only the keys it actually needs;
|
||||||
|
# every developer who edits secrets is also a recipient on the matching key.
|
||||||
|
#
|
||||||
|
# Hosts derive their age identity from /etc/ssh/ssh_host_ed25519_key via
|
||||||
|
# ssh-to-age. Run `nix run nixpkgs#ssh-to-age -- < /etc/ssh/ssh_host_ed25519_key.pub`
|
||||||
|
# on each host to get its age pubkey, then paste it below.
|
||||||
|
|
||||||
|
keys:
|
||||||
|
# ---- developers (laptops, hardware keys) ----
|
||||||
|
- &dev_lars age1TODO_lars_personal_age_pubkey_replace_me_before_first_real_secret
|
||||||
|
|
||||||
|
# ---- 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
|
||||||
|
|
||||||
|
creation_rules:
|
||||||
|
# Supabase compose stack → app-host only.
|
||||||
|
- path_regex: nix/secrets/secrets\.yaml$
|
||||||
|
encrypted_regex: ^(supabase_|ingester_|postgres_admin_)
|
||||||
|
key_groups:
|
||||||
|
- age:
|
||||||
|
- *dev_lars
|
||||||
|
- *host_db
|
||||||
|
- *host_app
|
||||||
|
- *host_ingest
|
||||||
|
|
||||||
|
# NOTE: a single key_group above lets every recipient decrypt every key.
|
||||||
|
# Per-host filtering will be tightened to one rule per prefix once real age
|
||||||
|
# pubkeys land — see nix/secrets/README.md for the rotation/bootstrap flow.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
[sops-nix](https://github.com/Mic92/sops-nix) + [age](https://github.com/FiloSottile/age).
|
||||||
|
Single encrypted file at `nix/secrets/secrets.yaml`; each host decrypts only the
|
||||||
|
keys it needs at activation, rendered into a tmpfs env file consumed by systemd
|
||||||
|
`EnvironmentFile=`. Plaintext never enters the Nix store. See
|
||||||
|
[`nix/secrets/README.md`](nix/secrets/README.md) for bootstrap and rotation.
|
||||||
|
|
||||||
|
Local dev needs **no** secrets bootstrap — `nix run .#dev`, `go run ./ingester`,
|
||||||
|
and `npm --prefix frontend run dev` all default to the local dev stack values
|
||||||
|
defined in `nix/dev/process-compose.nix`.
|
||||||
|
|||||||
Generated
+179
@@ -0,0 +1,179 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"deploy-rs": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"utils": "utils"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1770019181,
|
||||||
|
"narHash": "sha256-hwsYgDnby50JNVpTRYlF3UR/Rrpt01OrxVuryF40CFY=",
|
||||||
|
"owner": "serokell",
|
||||||
|
"repo": "deploy-rs",
|
||||||
|
"rev": "77c906c0ba56aabdbc72041bf9111b565cdd6171",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "serokell",
|
||||||
|
"repo": "deploy-rs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1733328505,
|
||||||
|
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778716662,
|
||||||
|
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1743014863,
|
||||||
|
"narHash": "sha256-jAIUqsiN2r3hCuHji80U7NNEafpIMBXiwKlSrjWMlpg=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "bd3bac8bfb542dbde7ffffb6987a1a1f9d41699f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-lib": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1777168982,
|
||||||
|
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1779560665,
|
||||||
|
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"process-compose-flake": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1767863885,
|
||||||
|
"narHash": "sha256-XXekPAxzbv1DmHFo3Elmj/vDnvWc1V0jdDUvM0/Wf7k=",
|
||||||
|
"owner": "Platonic-Systems",
|
||||||
|
"repo": "process-compose-flake",
|
||||||
|
"rev": "99bea96cf269cfd235833ebdf645b567069fd398",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "Platonic-Systems",
|
||||||
|
"repo": "process-compose-flake",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"deploy-rs": "deploy-rs",
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"nixpkgs": "nixpkgs_2",
|
||||||
|
"process-compose-flake": "process-compose-flake",
|
||||||
|
"services-flake": "services-flake"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services-flake": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1779042632,
|
||||||
|
"narHash": "sha256-WgQZ1AsDMCTL+0x0yZEd1g3yMQbgEwAq0VUg73YU50M=",
|
||||||
|
"owner": "juspay",
|
||||||
|
"repo": "services-flake",
|
||||||
|
"rev": "b0012dbf955efc3f6e2decd61610da5cea17dce6",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "juspay",
|
||||||
|
"repo": "services-flake",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
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";
|
||||||
|
sops-nix = {
|
||||||
|
url = "github:Mic92/sops-nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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_24
|
||||||
|
postgresql_17
|
||||||
|
supabase-cli
|
||||||
|
deploy-rs.packages.${system}.default
|
||||||
|
sops
|
||||||
|
age
|
||||||
|
ssh-to-age
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# `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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Gebos</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1871
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<main style={{ fontFamily: "system-ui", padding: "2rem" }}>
|
||||||
|
<h1>Gebos</h1>
|
||||||
|
<p>Skeleton SPA. Real UI lands once the architecture PR is approved.</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
// Single subdomain for everything Supabase — Kong fans out to /auth/v1, /rest/v1, /rpc.
|
||||||
|
//
|
||||||
|
// Defaults match the dev stack in nix/dev/process-compose.nix so `npm run dev`
|
||||||
|
// works without an .env file. Production bundles are built with VITE_SUPABASE_URL
|
||||||
|
// 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";
|
||||||
|
|
||||||
|
const SUPABASE_URL =
|
||||||
|
import.meta.env.VITE_SUPABASE_URL ??
|
||||||
|
(import.meta.env.DEV ? DEV_SUPABASE_URL : "https://api.ge-bos.de");
|
||||||
|
|
||||||
|
const SUPABASE_ANON_KEY =
|
||||||
|
import.meta.env.VITE_SUPABASE_ANON_KEY ??
|
||||||
|
(import.meta.env.DEV ? DEV_SUPABASE_ANON_KEY : "");
|
||||||
|
|
||||||
|
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: { outDir: "dist" },
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Package main — Gebos MQTT → Postgres ingester.
|
||||||
|
//
|
||||||
|
// Skeleton. Target: < 200 lines.
|
||||||
|
//
|
||||||
|
// Subscribes to topics of the form `t/<tenant_id>/d/<device_id>/<metric>`,
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defaults match the local dev stack in nix/dev/process-compose.nix so
|
||||||
|
// `go run ./ingester` works without any env setup. Prod always sets these
|
||||||
|
// explicitly via the systemd unit's EnvironmentFile.
|
||||||
|
const (
|
||||||
|
defaultBroker = "tcp://127.0.0.1:1883"
|
||||||
|
defaultPgURL = "postgres://gebos_ingest@127.0.0.1:5432/gebos?sslmode=disable"
|
||||||
|
defaultPgPassword = "dev"
|
||||||
|
)
|
||||||
|
|
||||||
|
func envOr(key, fallback string) (string, bool) {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v, false
|
||||||
|
}
|
||||||
|
return fallback, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
broker, brokerDefault := envOr("GEBOS_MQTT_BROKER", defaultBroker)
|
||||||
|
pgURL, pgURLDefault := envOr("GEBOS_POSTGRES_URL", defaultPgURL)
|
||||||
|
pgPassword, pgPasswordDefault := envOr("GEBOS_POSTGRES_PASSWORD", defaultPgPassword)
|
||||||
|
_ = pgPassword
|
||||||
|
|
||||||
|
if brokerDefault || pgURLDefault || pgPasswordDefault {
|
||||||
|
log.Printf("gebos-ingester: using dev defaults for: %s%s%s — set env vars in prod",
|
||||||
|
ifStr(brokerDefault, "GEBOS_MQTT_BROKER ", ""),
|
||||||
|
ifStr(pgURLDefault, "GEBOS_POSTGRES_URL ", ""),
|
||||||
|
ifStr(pgPasswordDefault, "GEBOS_POSTGRES_PASSWORD ", ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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=%s (stub)", broker, pgURL)
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Println("gebos-ingester: shutting down")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ifStr(cond bool, a, b string) string {
|
||||||
|
if cond {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{ pkgs, services-flake }:
|
||||||
|
|
||||||
|
{ ... }:
|
||||||
|
let
|
||||||
|
# Dev-only credentials. Match the defaults baked into ingester/main.go and
|
||||||
|
# frontend/src/supabase.ts, so `nix run .#dev` and `go run ./ingester` /
|
||||||
|
# `npm --prefix frontend run dev` all interoperate without any setup.
|
||||||
|
#
|
||||||
|
# These values are public on purpose — they ONLY work against the local
|
||||||
|
# process-compose stack. Real prod values come from sops-nix (see
|
||||||
|
# nix/secrets/README.md).
|
||||||
|
dev = {
|
||||||
|
pgUser = "gebos_ingest";
|
||||||
|
pgDB = "gebos";
|
||||||
|
pgPassword = "dev";
|
||||||
|
pgHost = "127.0.0.1";
|
||||||
|
pgPort = 5432;
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
supabaseUrl = "http://127.0.0.1:8000";
|
||||||
|
};
|
||||||
|
|
||||||
|
ingesterEnv = {
|
||||||
|
GEBOS_MQTT_BROKER = "tcp://${dev.mqttHost}:${toString dev.mqttPort}";
|
||||||
|
GEBOS_POSTGRES_URL = "postgres://${dev.pgUser}@${dev.pgHost}:${toString dev.pgPort}/${dev.pgDB}?sslmode=disable";
|
||||||
|
GEBOS_POSTGRES_PASSWORD = dev.pgPassword;
|
||||||
|
};
|
||||||
|
|
||||||
|
frontendEnv = {
|
||||||
|
VITE_SUPABASE_URL = dev.supabaseUrl;
|
||||||
|
VITE_SUPABASE_ANON_KEY = dev.anonKey;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
imports = [ services-flake.processComposeModules.default ];
|
||||||
|
|
||||||
|
services = {
|
||||||
|
postgres."db" = {
|
||||||
|
enable = true;
|
||||||
|
port = dev.pgPort;
|
||||||
|
initialDatabases = [{ name = dev.pgDB; }];
|
||||||
|
};
|
||||||
|
|
||||||
|
mosquitto."broker" = {
|
||||||
|
enable = true;
|
||||||
|
port = dev.mqttPort;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.processes = {
|
||||||
|
ingester = {
|
||||||
|
command = "${pkgs.go}/bin/go run ./ingester";
|
||||||
|
environment = ingesterEnv;
|
||||||
|
depends_on = {
|
||||||
|
"db".condition = "process_healthy";
|
||||||
|
"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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
networking.hostName = "app-host";
|
||||||
|
|
||||||
|
services.gebos.secrets.supabase = true;
|
||||||
|
|
||||||
|
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 ];
|
||||||
|
}
|
||||||
@@ -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 ];
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
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 ];
|
||||||
|
|
||||||
|
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
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{ inputs }:
|
||||||
|
|
||||||
|
let
|
||||||
|
modules = import ../modules;
|
||||||
|
|
||||||
|
mkHost = name: extraModules:
|
||||||
|
inputs.nixpkgs.lib.nixosSystem {
|
||||||
|
system = "x86_64-linux";
|
||||||
|
specialArgs = { inherit inputs; };
|
||||||
|
modules = [
|
||||||
|
./common.nix
|
||||||
|
inputs.sops-nix.nixosModules.sops
|
||||||
|
modules.gebos-secrets
|
||||||
|
modules.gebos-postgres
|
||||||
|
modules.gebos-supabase
|
||||||
|
modules.gebos-caddy
|
||||||
|
modules.gebos-hivemq
|
||||||
|
modules.gebos-ingester
|
||||||
|
modules.gebos-frontend
|
||||||
|
./${name}.nix
|
||||||
|
] ++ extraModules;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
db-host = mkHost "db-host" [ ];
|
||||||
|
app-host = mkHost "app-host" [ ];
|
||||||
|
mqtt-ingest = mkHost "mqtt-ingest" [ ];
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
networking.hostName = "mqtt-ingest";
|
||||||
|
|
||||||
|
services.gebos.secrets.ingester = true;
|
||||||
|
|
||||||
|
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 defaults to tcp://127.0.0.1:1883 (the local HiveMQ).
|
||||||
|
postgresUrl = "postgres://gebos_ingest@db-host.gebos.internal: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 ];
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
gebos-secrets = import ./gebos-secrets.nix;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{ 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;
|
||||||
|
default = "tcp://127.0.0.1:1883";
|
||||||
|
example = "tcp://127.0.0.1:1883";
|
||||||
|
};
|
||||||
|
postgresUrl = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "postgres://gebos_ingest@127.0.0.1:5432/postgres?sslmode=require";
|
||||||
|
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;
|
||||||
|
# GEBOS_POSTGRES_PASSWORD. Rendered by sops-nix — see gebos-secrets module.
|
||||||
|
EnvironmentFile = config.services.gebos.secrets.envFile;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "5s";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.gebos.secrets;
|
||||||
|
secretsFile = ../secrets/secrets.yaml;
|
||||||
|
hasSecrets = builtins.pathExists secretsFile;
|
||||||
|
|
||||||
|
# Map from logical group → list of secret keys in secrets.yaml.
|
||||||
|
# Keys are flat (e.g. "supabase_jwt_secret") so .sops.yaml regex rules can
|
||||||
|
# filter per-host without nested-path footguns.
|
||||||
|
supabaseKeys = [
|
||||||
|
"supabase_postgres_password"
|
||||||
|
"supabase_jwt_secret"
|
||||||
|
"supabase_anon_key"
|
||||||
|
"supabase_service_role_key"
|
||||||
|
"supabase_dashboard_password"
|
||||||
|
];
|
||||||
|
ingesterKeys = [ "ingester_postgres_password" ];
|
||||||
|
postgresAdminKeys = [ "postgres_admin_password" ];
|
||||||
|
|
||||||
|
# Render an env file body from a list of (key, envVarName) pairs.
|
||||||
|
envBody = pairs: lib.concatStringsSep "\n" (map
|
||||||
|
({ key, var }: "${var}=${config.sops.placeholder.${key}}")
|
||||||
|
pairs);
|
||||||
|
|
||||||
|
enabledPairs =
|
||||||
|
lib.optionals cfg.supabase [
|
||||||
|
{ key = "supabase_postgres_password"; var = "POSTGRES_PASSWORD"; }
|
||||||
|
{ key = "supabase_jwt_secret"; var = "JWT_SECRET"; }
|
||||||
|
{ key = "supabase_anon_key"; var = "ANON_KEY"; }
|
||||||
|
{ key = "supabase_service_role_key"; var = "SERVICE_ROLE_KEY"; }
|
||||||
|
{ key = "supabase_dashboard_password"; var = "DASHBOARD_PASSWORD"; }
|
||||||
|
]
|
||||||
|
++ lib.optionals cfg.ingester [
|
||||||
|
{ key = "ingester_postgres_password"; var = "GEBOS_POSTGRES_PASSWORD"; }
|
||||||
|
]
|
||||||
|
++ lib.optionals cfg.postgresAdmin [
|
||||||
|
{ key = "postgres_admin_password"; var = "POSTGRES_ADMIN_PASSWORD"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
enabledKeys =
|
||||||
|
lib.optionals cfg.supabase supabaseKeys
|
||||||
|
++ lib.optionals cfg.ingester ingesterKeys
|
||||||
|
++ lib.optionals cfg.postgresAdmin postgresAdminKeys;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.gebos.secrets = {
|
||||||
|
supabase = lib.mkEnableOption ''
|
||||||
|
Supabase compose stack secrets: POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY,
|
||||||
|
SERVICE_ROLE_KEY, DASHBOARD_PASSWORD.'';
|
||||||
|
|
||||||
|
ingester = lib.mkEnableOption ''
|
||||||
|
Ingester DB password: GEBOS_POSTGRES_PASSWORD (for the gebos_ingest role).'';
|
||||||
|
|
||||||
|
postgresAdmin = lib.mkEnableOption ''
|
||||||
|
Postgres bootstrap admin password applied via a one-shot ALTER ROLE unit.'';
|
||||||
|
|
||||||
|
envFile = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
readOnly = true;
|
||||||
|
description = ''
|
||||||
|
Path of the rendered env file consumed by service units' EnvironmentFile=.
|
||||||
|
Resolves to the sops-templated file under /run/secrets when sops material
|
||||||
|
is present, or to a baked-in dev-defaults file otherwise (so first-boot
|
||||||
|
eval and local NixOS VMs work without any secrets bootstrap).
|
||||||
|
'';
|
||||||
|
default =
|
||||||
|
if hasSecrets
|
||||||
|
then config.sops.templates."gebos-env".path
|
||||||
|
else "${pkgs.writeText "gebos-env-dev-defaults" (
|
||||||
|
# Dev fallback. Never used in prod — overridden once secrets.yaml exists.
|
||||||
|
lib.concatStringsSep "\n" (map ({ var, ... }: "${var}=dev-${var}") enabledPairs)
|
||||||
|
)}";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf hasSecrets {
|
||||||
|
sops = {
|
||||||
|
defaultSopsFile = secretsFile;
|
||||||
|
# Derive each host's age identity from its existing ssh host key.
|
||||||
|
# No extra key material to provision — ssh-to-age handles conversion.
|
||||||
|
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
|
||||||
|
|
||||||
|
secrets = lib.listToAttrs (map
|
||||||
|
(key: { name = key; value = { }; })
|
||||||
|
enabledKeys);
|
||||||
|
|
||||||
|
templates."gebos-env" = lib.mkIf (enabledPairs != []) {
|
||||||
|
content = envBody enabledPairs;
|
||||||
|
mode = "0400";
|
||||||
|
owner = "root";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{ 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";
|
||||||
|
# POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, DASHBOARD_PASSWORD.
|
||||||
|
# Rendered by sops-nix from nix/secrets/secrets.yaml — see gebos-secrets module.
|
||||||
|
EnvironmentFile = config.services.gebos.secrets.envFile;
|
||||||
|
ExecStart = "${pkgs.docker}/bin/docker compose up -d --remove-orphans";
|
||||||
|
ExecStop = "${pkgs.docker}/bin/docker compose down";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Secrets
|
||||||
|
|
||||||
|
Managed with [sops-nix](https://github.com/Mic92/sops-nix) + [age](https://github.com/FiloSottile/age).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `.sops.yaml` (repo root) — recipients and per-file creation rules.
|
||||||
|
- `nix/secrets/secrets.yaml.example` — plaintext template (committed).
|
||||||
|
- `nix/secrets/secrets.yaml` — sops-encrypted real values (committed once created).
|
||||||
|
|
||||||
|
Plaintext values never enter the Nix store or disk outside tmpfs. sops-nix
|
||||||
|
decrypts on activation into `/run/secrets/<name>` with declared owner/mode,
|
||||||
|
then renders `gebos-env` from those placeholders for systemd `EnvironmentFile=`.
|
||||||
|
|
||||||
|
## First-time bootstrap
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Generate your personal age key (keep this private, NEVER commit).
|
||||||
|
mkdir -p ~/.config/sops/age
|
||||||
|
age-keygen -o ~/.config/sops/age/keys.txt
|
||||||
|
# → "Public key: age1abc..." ← paste this into .sops.yaml as &dev_<you>
|
||||||
|
|
||||||
|
# 2. Get each host's age pubkey (one per host).
|
||||||
|
# On each host as root:
|
||||||
|
ssh-to-age < /etc/ssh/ssh_host_ed25519_key.pub
|
||||||
|
# → "age1xyz..." ← paste into .sops.yaml as &host_<name>
|
||||||
|
|
||||||
|
# 3. Edit .sops.yaml — replace every "age1TODO_..." with the real pubkeys above.
|
||||||
|
|
||||||
|
# 4. Create the real secrets file from the template, fill in real values, encrypt.
|
||||||
|
cp nix/secrets/secrets.yaml.example nix/secrets/secrets.yaml
|
||||||
|
$EDITOR nix/secrets/secrets.yaml
|
||||||
|
sops -e -i nix/secrets/secrets.yaml
|
||||||
|
|
||||||
|
# 5. Commit .sops.yaml + the encrypted secrets.yaml.
|
||||||
|
git add .sops.yaml nix/secrets/secrets.yaml
|
||||||
|
git commit -m "secrets: initial sops bootstrap"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Editing later
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sops nix/secrets/secrets.yaml # opens $EDITOR, re-encrypts on save
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rotation
|
||||||
|
|
||||||
|
- **Leaked value** → `sops nix/secrets/secrets.yaml`, change value, redeploy.
|
||||||
|
- **New host** → add its age pubkey to `.sops.yaml`, then `sops updatekeys nix/secrets/secrets.yaml`.
|
||||||
|
- **Compromised dev key** → remove from `.sops.yaml`, `sops updatekeys`, redeploy.
|
||||||
|
|
||||||
|
## Generating value material
|
||||||
|
|
||||||
|
| Field | How |
|
||||||
|
| ------------------------------ | ------------------------------------------------------------ |
|
||||||
|
| `supabase_postgres_password` | `openssl rand -hex 32` |
|
||||||
|
| `supabase_jwt_secret` | `openssl rand -hex 32` (must be ≥32 chars) |
|
||||||
|
| `supabase_anon_key` | JWT signed with `jwt_secret`, `role: anon`, long expiry |
|
||||||
|
| `supabase_service_role_key` | JWT signed with `jwt_secret`, `role: service_role` |
|
||||||
|
| `supabase_dashboard_password` | `openssl rand -hex 16` (Studio basic-auth, username `gebos`) |
|
||||||
|
| `ingester_postgres_password` | `openssl rand -hex 32` |
|
||||||
|
| `postgres_admin_password` | `openssl rand -hex 32` |
|
||||||
|
|
||||||
|
Self-hosted Supabase docs have a one-liner for signing the two JWTs offline.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
You do **not** need to bootstrap secrets for `nix run .#dev`. The dev process-compose
|
||||||
|
stack and the ingester binary both default to local-only values
|
||||||
|
(`GEBOS_POSTGRES_URL`, `GEBOS_MQTT_BROKER`, `GEBOS_POSTGRES_PASSWORD`, plus the
|
||||||
|
JWT/anon-key pair the frontend needs). See `nix/dev/process-compose.nix`.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 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: "replace-me-openssl-rand-hex-32"
|
||||||
|
supabase_jwt_secret: "replace-me-openssl-rand-hex-32-minimum-32-chars"
|
||||||
|
supabase_anon_key: "replace-me-jwt-signed-with-jwt_secret-role-anon"
|
||||||
|
supabase_service_role_key: "replace-me-jwt-signed-with-jwt_secret-role-service_role"
|
||||||
|
supabase_dashboard_password: "replace-me-studio-basic-auth-password"
|
||||||
|
|
||||||
|
# Ingester (consumed on mqtt-ingest by gebos-ingester.service)
|
||||||
|
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"
|
||||||
@@ -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.
|
||||||
@@ -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}
|
||||||
@@ -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;
|
||||||
@@ -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 ]
|
||||||
Reference in New Issue
Block a user