Files
Gebos/nix/modules/gebos-secrets.nix
T
2026-06-29 17:23:02 +02:00

199 lines
7.8 KiB
Nix

{ 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" ];
hivemqKeys = [ "hivemq_ingester_password" "hivemq_admin_password" "hivemq_device_password" ];
# HiveMQ file-RBAC credentials.xml. Only the passwords are secret; the role/
# topic policy is structural. Devices publish under `acrios/<IMSI>/<metric>`
# (set by the sender firmware's mqtt_topic_base), so that is the namespace the
# ingester consumes and the device role is scoped to.
#
# Roles:
# ingest — the Go ingester; SUBSCRIBE the whole device tree.
# device — physical senders; full access under acrios/ (publish telemetry
# + the LWT/status and any downlink topics the firmware uses).
# superuser — operational break-glass.
#
# Option A (current): one shared `device` user (`bender`) for the whole fleet.
# Tenant isolation is enforced downstream by the ingester's IMSI→tenant
# registry lookup, NOT at the broker — any device could publish under any IMSI.
#
# TODO(Option B / per-device auth): give each sender its own RBAC user with
# username == IMSI and scope its role to `acrios/${{username}}/#`, so the
# broker enforces that a device can only publish under its own IMSI (real
# anti-spoofing + per-device revocation). Requires setting mqtt_user = <IMSI>
# on each device and a per-device password provisioning flow (generate → sops
# → push to device). file-RBAC hot-reloads, so added users need no restart.
hivemqCredentialsXml = ingesterPassword: adminPassword: devicePassword: ''
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<file-rbac>
<users>
<user>
<name>ingester</name>
<password>${ingesterPassword}</password>
<roles><id>ingest</id></roles>
</user>
<user>
<name>admin</name>
<password>${adminPassword}</password>
<roles><id>superuser</id></roles>
</user>
<user>
<name>bender</name>
<password>${devicePassword}</password>
<roles><id>device</id></roles>
</user>
</users>
<roles>
<role>
<id>ingest</id>
<permissions>
<permission>
<topic>acrios/#</topic>
<activity>SUBSCRIBE</activity>
</permission>
</permissions>
</role>
<role>
<id>device</id>
<permissions>
<!-- No <activity> = PUBLISH and SUBSCRIBE; covers telemetry,
the LWT/status topic, and any downlink under acrios/. -->
<permission><topic>acrios/#</topic></permission>
</permissions>
</role>
<role>
<id>superuser</id>
<permissions>
<permission><topic>#</topic></permission>
</permissions>
</role>
</roles>
</file-rbac>
'';
# 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
++ lib.optionals cfg.hivemq hivemqKeys;
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.'';
hivemq = lib.mkEnableOption ''
HiveMQ file-RBAC credentials.xml (ingester + admin MQTT users), rendered
0400/gebos-hivemq for the broker's file-RBAC extension.'';
hivemqCredentialsFile = lib.mkOption {
type = lib.types.path;
readOnly = true;
description = ''
Path of the rendered HiveMQ file-RBAC credentials.xml consumed by the
gebos-hivemq broker. Resolves to the sops-templated secret when sops
material is present and the hivemq toggle is on, else to a baked-in
dev-defaults file (so first-boot eval and local VMs work).
'';
default =
if hasSecrets && cfg.hivemq
then config.sops.templates."hivemq-credentials".path
else "${pkgs.writeText "hivemq-credentials-dev.xml"
(hivemqCredentialsXml "dev-ingester" "dev-admin" "dev-device")}";
};
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";
};
# Rendered as a standalone file (not env) — the file-RBAC extension reads
# credentials.xml directly. Owned by the broker user so preStart can link
# it into the extension's conf/ dir.
templates."hivemq-credentials" = lib.mkIf cfg.hivemq {
content = hivemqCredentialsXml
config.sops.placeholder.hivemq_ingester_password
config.sops.placeholder.hivemq_admin_password
config.sops.placeholder.hivemq_device_password;
mode = "0400";
owner = "gebos-hivemq";
};
};
};
}