From 2dd5ed5877910c5a5d7b590bc7f34bf19a3286e5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 30 May 2026 00:17:13 +0000 Subject: [PATCH] Add sops-nix secrets + dev defaults so local stack runs with zero setup - sops-nix flake input, age + ssh-to-age in devShell, .sops.yaml with per-host recipients (real pubkeys still TODO until lars bootstraps). - nix/modules/gebos-secrets.nix centralises per-host secret subsets and renders /run/secrets/gebos-env via sops.templates. Service modules now read EnvironmentFile= from services.gebos.secrets.envFile instead of the placeholder /etc/gebos/secrets.env. - Falls back to a writeText env file when nix/secrets/secrets.yaml is absent, so `nix flake check` and first-boot eval work pre-bootstrap. - nix/secrets/README.md walks through age key + sops bootstrap. - Dev defaults: ingester binary falls back to local tcp/postgres URLs, process-compose adds mosquitto and wires GEBOS_MQTT_BROKER / GEBOS_POSTGRES_URL / GEBOS_POSTGRES_PASSWORD + VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY, frontend defaults to 127.0.0.1:8000 in DEV. Refs #23 (comment #110). --- .gitignore | 4 ++ .sops.yaml | 33 +++++++++++ README.md | 12 +++- flake.nix | 7 +++ frontend/src/supabase.ts | 17 +++++- ingester/main.go | 42 +++++++++++--- nix/dev/process-compose.nix | 57 +++++++++++++++++-- nix/hosts/app-host.nix | 2 + nix/hosts/db-host.nix | 2 + nix/hosts/default.nix | 16 ++++-- nix/hosts/mqtt-ingest.nix | 6 +- nix/modules/default.nix | 1 + nix/modules/gebos-ingester.nix | 5 +- nix/modules/gebos-secrets.nix | 95 ++++++++++++++++++++++++++++++++ nix/modules/gebos-supabase.nix | 4 +- nix/secrets/README.md | 71 ++++++++++++++++++++++++ nix/secrets/secrets.yaml.example | 29 ++++++++++ 17 files changed, 375 insertions(+), 28 deletions(-) create mode 100644 .sops.yaml create mode 100644 nix/modules/gebos-secrets.nix create mode 100644 nix/secrets/README.md create mode 100644 nix/secrets/secrets.yaml.example diff --git a/.gitignore b/.gitignore index e8bdb7f..4dfc108 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ secrets.env .env .env.* !.env.example + +# age private keys — never commit (public keys live in .sops.yaml) +*.age +keys.txt diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..5670586 --- /dev/null +++ b/.sops.yaml @@ -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. diff --git a/README.md b/README.md index 5bd85ce..492da85 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ 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). +[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`. diff --git a/flake.nix b/flake.nix index 3f0c9f7..0582fe6 100644 --- a/flake.nix +++ b/flake.nix @@ -7,6 +7,10 @@ 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, ... }: @@ -42,6 +46,9 @@ postgresql_17 supabase-cli deploy-rs.packages.${system}.default + sops + age + ssh-to-age ]; }; diff --git a/frontend/src/supabase.ts b/frontend/src/supabase.ts index fe1d54d..3974838 100644 --- a/frontend/src/supabase.ts +++ b/frontend/src/supabase.ts @@ -1,7 +1,20 @@ 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 ?? ""; +// +// 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); diff --git a/ingester/main.go b/ingester/main.go index 72cdef8..dca0b59 100644 --- a/ingester/main.go +++ b/ingester/main.go @@ -15,13 +15,34 @@ import ( "syscall" ) -func main() { - broker := os.Getenv("GEBOS_MQTT_BROKER") - pgURL := os.Getenv("GEBOS_POSTGRES_URL") - pgPassword := os.Getenv("GEBOS_POSTGRES_PASSWORD") +// 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" +) - if broker == "" || pgURL == "" || pgPassword == "" { - log.Fatal("GEBOS_MQTT_BROKER, GEBOS_POSTGRES_URL and GEBOS_POSTGRES_PASSWORD must be set") +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) @@ -35,7 +56,14 @@ func main() { // 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) + 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 +} diff --git a/nix/dev/process-compose.nix b/nix/dev/process-compose.nix index a0d546e..82a15a0 100644 --- a/nix/dev/process-compose.nix +++ b/nix/dev/process-compose.nix @@ -1,27 +1,72 @@ { 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 ]; - # 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"; }]; + port = dev.pgPort; + initialDatabases = [{ name = dev.pgDB; }]; + }; + + mosquitto."broker" = { + enable = true; + port = dev.mqttPort; }; }; settings.processes = { ingester = { command = "${pkgs.go}/bin/go run ./ingester"; - depends_on."db".condition = "process_healthy"; + environment = ingesterEnv; + depends_on = { + "db".condition = "process_healthy"; + "broker".condition = "process_started"; + }; }; frontend = { command = "${pkgs.nodejs_20}/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/) diff --git a/nix/hosts/app-host.nix b/nix/hosts/app-host.nix index bc44122..7dcc1bc 100644 --- a/nix/hosts/app-host.nix +++ b/nix/hosts/app-host.nix @@ -3,6 +3,8 @@ { 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. diff --git a/nix/hosts/db-host.nix b/nix/hosts/db-host.nix index 5a8c2ab..93f95b0 100644 --- a/nix/hosts/db-host.nix +++ b/nix/hosts/db-host.nix @@ -3,6 +3,8 @@ { 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 ]; diff --git a/nix/hosts/default.nix b/nix/hosts/default.nix index 2a22222..e5bf946 100644 --- a/nix/hosts/default.nix +++ b/nix/hosts/default.nix @@ -1,18 +1,22 @@ { inputs }: let + modules = import ../modules; + 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 + 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; }; diff --git a/nix/hosts/mqtt-ingest.nix b/nix/hosts/mqtt-ingest.nix index fb808fb..c6d39bc 100644 --- a/nix/hosts/mqtt-ingest.nix +++ b/nix/hosts/mqtt-ingest.nix @@ -3,6 +3,8 @@ { networking.hostName = "mqtt-ingest"; + services.gebos.secrets.ingester = true; + services.gebos.hivemq = { enable = true; # Persistent sessions on disk — single-node, devices reconnect on restart. @@ -12,9 +14,9 @@ services.gebos.ingester = { enable = true; - mqttBroker = "tcp://127.0.0.1:1883"; # local broker + # mqttBroker defaults to tcp://127.0.0.1:1883 (the local HiveMQ). postgresUrl = "postgres://gebos_ingest@db-host.gebos.internal:5432/postgres?sslmode=require"; - # Password sourced from /etc/gebos/secrets.env via EnvironmentFile. + # GEBOS_POSTGRES_PASSWORD sourced from /run/secrets/gebos-env (sops-nix). }; # Caddy fronts TLS for `ingest.ge-bos.de` and forwards to HiveMQ. diff --git a/nix/modules/default.nix b/nix/modules/default.nix index baf037b..8ec34d7 100644 --- a/nix/modules/default.nix +++ b/nix/modules/default.nix @@ -1,4 +1,5 @@ { + gebos-secrets = import ./gebos-secrets.nix; gebos-ingester = import ./gebos-ingester.nix; gebos-supabase = import ./gebos-supabase.nix; gebos-caddy = import ./gebos-caddy.nix; diff --git a/nix/modules/gebos-ingester.nix b/nix/modules/gebos-ingester.nix index 573b142..15de954 100644 --- a/nix/modules/gebos-ingester.nix +++ b/nix/modules/gebos-ingester.nix @@ -9,10 +9,12 @@ in 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."; }; }; @@ -32,7 +34,8 @@ in serviceConfig = { ExecStart = "${ingester}/bin/gebos-ingester"; DynamicUser = true; - EnvironmentFile = "/etc/gebos/secrets.env"; # GEBOS_POSTGRES_PASSWORD + # GEBOS_POSTGRES_PASSWORD. Rendered by sops-nix — see gebos-secrets module. + EnvironmentFile = config.services.gebos.secrets.envFile; Restart = "on-failure"; RestartSec = "5s"; }; diff --git a/nix/modules/gebos-secrets.nix b/nix/modules/gebos-secrets.nix new file mode 100644 index 0000000..291c077 --- /dev/null +++ b/nix/modules/gebos-secrets.nix @@ -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"; + }; + }; + }; +} diff --git a/nix/modules/gebos-supabase.nix b/nix/modules/gebos-supabase.nix index c285408..248c52e 100644 --- a/nix/modules/gebos-supabase.nix +++ b/nix/modules/gebos-supabase.nix @@ -52,7 +52,9 @@ in Type = "oneshot"; RemainAfterExit = true; WorkingDirectory = "/etc/gebos/supabase"; - EnvironmentFile = "/etc/gebos/secrets.env"; # POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, DASHBOARD_PASSWORD + # 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"; }; diff --git a/nix/secrets/README.md b/nix/secrets/README.md new file mode 100644 index 0000000..b9f1824 --- /dev/null +++ b/nix/secrets/README.md @@ -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/` 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_ + +# 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_ + +# 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`. diff --git a/nix/secrets/secrets.yaml.example b/nix/secrets/secrets.yaml.example new file mode 100644 index 0000000..f9b7614 --- /dev/null +++ b/nix/secrets/secrets.yaml.example @@ -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"