Add sops-nix secrets + dev defaults so local stack runs with zero setup
check / flake-check (pull_request) Has been cancelled
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:
@@ -10,3 +10,7 @@ secrets.env
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.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.
|
||||||
@@ -55,6 +55,12 @@ nix run github:serokell/deploy-rs -- . # all hosts
|
|||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
Out-of-band file at `/etc/gebos/secrets.env` on each host, mode `0400`, loaded
|
[sops-nix](https://github.com/Mic92/sops-nix) + [age](https://github.com/FiloSottile/age).
|
||||||
by systemd `EnvironmentFile=`. Not in the repo, not in the Nix store. Rotation
|
Single encrypted file at `nix/secrets/secrets.yaml`; each host decrypts only the
|
||||||
is manual (ssh + edit + restart unit).
|
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`.
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
deploy-rs.url = "github:serokell/deploy-rs";
|
deploy-rs.url = "github:serokell/deploy-rs";
|
||||||
process-compose-flake.url = "github:Platonic-Systems/process-compose-flake";
|
process-compose-flake.url = "github:Platonic-Systems/process-compose-flake";
|
||||||
services-flake.url = "github:juspay/services-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, ... }:
|
outputs = inputs@{ self, flake-parts, nixpkgs, deploy-rs, ... }:
|
||||||
@@ -42,6 +46,9 @@
|
|||||||
postgresql_17
|
postgresql_17
|
||||||
supabase-cli
|
supabase-cli
|
||||||
deploy-rs.packages.${system}.default
|
deploy-rs.packages.${system}.default
|
||||||
|
sops
|
||||||
|
age
|
||||||
|
ssh-to-age
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
// Single subdomain for everything Supabase — Kong fans out to /auth/v1, /rest/v1, /rpc.
|
// 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);
|
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||||||
|
|||||||
+35
-7
@@ -15,13 +15,34 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
// Defaults match the local dev stack in nix/dev/process-compose.nix so
|
||||||
broker := os.Getenv("GEBOS_MQTT_BROKER")
|
// `go run ./ingester` works without any env setup. Prod always sets these
|
||||||
pgURL := os.Getenv("GEBOS_POSTGRES_URL")
|
// explicitly via the systemd unit's EnvironmentFile.
|
||||||
pgPassword := os.Getenv("GEBOS_POSTGRES_PASSWORD")
|
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 == "" {
|
func envOr(key, fallback string) (string, bool) {
|
||||||
log.Fatal("GEBOS_MQTT_BROKER, GEBOS_POSTGRES_URL and GEBOS_POSTGRES_PASSWORD must be set")
|
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)
|
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
|
// 4. batch with a short flush interval (50–100ms) for throughput
|
||||||
// 5. shutdown on ctx.Done
|
// 5. shutdown on ctx.Done
|
||||||
|
|
||||||
log.Printf("gebos-ingester: broker=%s db=<redacted> (stub)", broker)
|
log.Printf("gebos-ingester: broker=%s db=%s (stub)", broker, pgURL)
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
log.Println("gebos-ingester: shutting down")
|
log.Println("gebos-ingester: shutting down")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ifStr(cond bool, a, b string) string {
|
||||||
|
if cond {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,72 @@
|
|||||||
{ pkgs, services-flake }:
|
{ 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 ];
|
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 = {
|
services = {
|
||||||
postgres."db" = {
|
postgres."db" = {
|
||||||
enable = true;
|
enable = true;
|
||||||
port = 5432;
|
port = dev.pgPort;
|
||||||
initialDatabases = [{ name = "gebos"; }];
|
initialDatabases = [{ name = dev.pgDB; }];
|
||||||
|
};
|
||||||
|
|
||||||
|
mosquitto."broker" = {
|
||||||
|
enable = true;
|
||||||
|
port = dev.mqttPort;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
settings.processes = {
|
settings.processes = {
|
||||||
ingester = {
|
ingester = {
|
||||||
command = "${pkgs.go}/bin/go run ./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 = {
|
frontend = {
|
||||||
command = "${pkgs.nodejs_20}/bin/npm --prefix frontend run dev";
|
command = "${pkgs.nodejs_20}/bin/npm --prefix frontend run dev";
|
||||||
|
environment = frontendEnv;
|
||||||
};
|
};
|
||||||
# TODO: supabase compose (point POSTGRES_HOST at the services-flake db)
|
# TODO: supabase compose (point POSTGRES_HOST at the services-flake db)
|
||||||
# TODO: kong (use the vendored kong.yml from nix/supabase/)
|
# TODO: kong (use the vendored kong.yml from nix/supabase/)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
{
|
{
|
||||||
networking.hostName = "app-host";
|
networking.hostName = "app-host";
|
||||||
|
|
||||||
|
services.gebos.secrets.supabase = true;
|
||||||
|
|
||||||
services.gebos.supabase = {
|
services.gebos.supabase = {
|
||||||
enable = true;
|
enable = true;
|
||||||
# Compose stack points at external db-host instead of the bundled `db` service.
|
# Compose stack points at external db-host instead of the bundled `db` service.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
{
|
{
|
||||||
networking.hostName = "db-host";
|
networking.hostName = "db-host";
|
||||||
|
|
||||||
|
services.gebos.secrets.postgresAdmin = true;
|
||||||
|
|
||||||
# Skeleton: real hardware-configuration.nix + boot/fs lives next to this file
|
# 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.
|
# once we have the actual box. Marked here so the structure is visible.
|
||||||
# imports = [ ./db-host.hardware.nix ];
|
# imports = [ ./db-host.hardware.nix ];
|
||||||
|
|||||||
+10
-6
@@ -1,18 +1,22 @@
|
|||||||
{ inputs }:
|
{ inputs }:
|
||||||
|
|
||||||
let
|
let
|
||||||
|
modules = import ../modules;
|
||||||
|
|
||||||
mkHost = name: extraModules:
|
mkHost = name: extraModules:
|
||||||
inputs.nixpkgs.lib.nixosSystem {
|
inputs.nixpkgs.lib.nixosSystem {
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
specialArgs = { inherit inputs; };
|
specialArgs = { inherit inputs; };
|
||||||
modules = [
|
modules = [
|
||||||
./common.nix
|
./common.nix
|
||||||
(import ../modules).gebos-postgres
|
inputs.sops-nix.nixosModules.sops
|
||||||
(import ../modules).gebos-supabase
|
modules.gebos-secrets
|
||||||
(import ../modules).gebos-caddy
|
modules.gebos-postgres
|
||||||
(import ../modules).gebos-hivemq
|
modules.gebos-supabase
|
||||||
(import ../modules).gebos-ingester
|
modules.gebos-caddy
|
||||||
(import ../modules).gebos-frontend
|
modules.gebos-hivemq
|
||||||
|
modules.gebos-ingester
|
||||||
|
modules.gebos-frontend
|
||||||
./${name}.nix
|
./${name}.nix
|
||||||
] ++ extraModules;
|
] ++ extraModules;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
{
|
{
|
||||||
networking.hostName = "mqtt-ingest";
|
networking.hostName = "mqtt-ingest";
|
||||||
|
|
||||||
|
services.gebos.secrets.ingester = true;
|
||||||
|
|
||||||
services.gebos.hivemq = {
|
services.gebos.hivemq = {
|
||||||
enable = true;
|
enable = true;
|
||||||
# Persistent sessions on disk — single-node, devices reconnect on restart.
|
# Persistent sessions on disk — single-node, devices reconnect on restart.
|
||||||
@@ -12,9 +14,9 @@
|
|||||||
|
|
||||||
services.gebos.ingester = {
|
services.gebos.ingester = {
|
||||||
enable = true;
|
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";
|
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.
|
# Caddy fronts TLS for `ingest.ge-bos.de` and forwards to HiveMQ.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
gebos-secrets = import ./gebos-secrets.nix;
|
||||||
gebos-ingester = import ./gebos-ingester.nix;
|
gebos-ingester = import ./gebos-ingester.nix;
|
||||||
gebos-supabase = import ./gebos-supabase.nix;
|
gebos-supabase = import ./gebos-supabase.nix;
|
||||||
gebos-caddy = import ./gebos-caddy.nix;
|
gebos-caddy = import ./gebos-caddy.nix;
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ in
|
|||||||
enable = lib.mkEnableOption "Gebos MQTT → Postgres ingester";
|
enable = lib.mkEnableOption "Gebos MQTT → Postgres ingester";
|
||||||
mqttBroker = lib.mkOption {
|
mqttBroker = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
|
default = "tcp://127.0.0.1:1883";
|
||||||
example = "tcp://127.0.0.1:1883";
|
example = "tcp://127.0.0.1:1883";
|
||||||
};
|
};
|
||||||
postgresUrl = lib.mkOption {
|
postgresUrl = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
|
default = "postgres://gebos_ingest@127.0.0.1:5432/postgres?sslmode=require";
|
||||||
description = "DSN without password — password comes from EnvironmentFile.";
|
description = "DSN without password — password comes from EnvironmentFile.";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -32,7 +34,8 @@ in
|
|||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${ingester}/bin/gebos-ingester";
|
ExecStart = "${ingester}/bin/gebos-ingester";
|
||||||
DynamicUser = true;
|
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";
|
Restart = "on-failure";
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -52,7 +52,9 @@ in
|
|||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
WorkingDirectory = "/etc/gebos/supabase";
|
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";
|
ExecStart = "${pkgs.docker}/bin/docker compose up -d --remove-orphans";
|
||||||
ExecStop = "${pkgs.docker}/bin/docker compose down";
|
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"
|
||||||
Reference in New Issue
Block a user