{ 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//` # (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 = # 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: '' ingester ${ingesterPassword} ingest admin ${adminPassword} superuser bender ${devicePassword} device ingest acrios/# SUBSCRIBE device acrios/# superuser # ''; # 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"; }; }; }; }