Add sops-nix secrets + dev defaults so local stack runs with zero setup
check / flake-check (pull_request) Has been cancelled

- 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).
This commit is contained in:
Klaus
2026-05-30 00:17:13 +00:00
parent 2d57db8182
commit 2dd5ed5877
17 changed files with 375 additions and 28 deletions
+51 -6
View File
@@ -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/)
+2
View File
@@ -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.
+2
View File
@@ -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 ];
+10 -6
View File
@@ -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;
};
+4 -2
View File
@@ -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.
+1
View File
@@ -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;
+4 -1
View File
@@ -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";
};
+95
View File
@@ -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";
};
};
};
}
+3 -1
View File
@@ -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";
};
+71
View File
@@ -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`.
+29
View File
@@ -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"