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
+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";
};