From 58155dc737a5603ecbade77efc92f4164a5cd158 Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Thu, 2 Jul 2026 15:19:59 +0200 Subject: [PATCH] switch to emqx --- .sops.yaml | 2 +- README.md | 58 +++---- nix/hosts/default.nix | 2 +- nix/hosts/mqtt-ingest.nix | 27 ++- nix/modules/default.nix | 2 +- nix/modules/gebos-caddy.nix | 60 +------ nix/modules/gebos-emqx.nix | 238 ++++++++++++++++++++++++++ nix/modules/gebos-hivemq.nix | 276 ------------------------------- nix/modules/gebos-secrets.nix | 130 +++++---------- nix/secrets/secrets.yaml | 68 ++++---- nix/secrets/secrets.yaml.example | 15 +- 11 files changed, 366 insertions(+), 512 deletions(-) create mode 100644 nix/modules/gebos-emqx.nix delete mode 100644 nix/modules/gebos-hivemq.nix diff --git a/.sops.yaml b/.sops.yaml index 85df367..228a6ce 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -20,7 +20,7 @@ keys: creation_rules: # Supabase compose stack → app-host only. - path_regex: nix/secrets/secrets\.yaml$ - encrypted_regex: ^(supabase_|ingester_|postgres_admin_|hivemq_) + encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_) key_groups: - age: - *dev_lars diff --git a/README.md b/README.md index 5885159..225a521 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Three hosts (see issue #21 for the full design discussion): | Host | Role | Public hostname | Private IP | | ------------- | ----------------------------------------- | ---------------------- | ---------- | -| `mqtt-ingest` | HiveMQ CE + Go MQTT→Postgres ingester | `ingest.gebos.online` | `10.0.0.4` | +| `mqtt-ingest` | EMQX broker + Go MQTT→Postgres ingester | `ingest.gebos.online` | `10.0.0.4` | | `db-host` | Postgres 17 + TimescaleDB (telemetry+auth)| `db.gebos.online` (SSH only) | `10.0.0.2` | | `app-host` | Caddy + Kong + Supabase (compose) + SPA | `app.gebos.online`, `api.gebos.online` | `10.0.0.3` | @@ -38,27 +38,25 @@ choice. The device firmware is the fixed end of this contract, so the rest of the stack is built to match it rather than the other way around. ``` -device ──MQTTS:8883──▶ Caddy (layer4, TLS term) ──MQTT:1883──▶ HiveMQ CE ──▶ ingester ──▶ Postgres - acrios// loopback file-RBAC IMSI→tenant +device ──MQTTS:8883──▶ EMQX (native TLS) ──MQTT:1883 loopback──▶ ingester ──▶ Postgres + acrios// authn + ACL IMSI→tenant ``` -### Broker auth: file-RBAC, not allow-all +### Broker: EMQX in the official container, deny-by-default auth -HiveMQ CE ships the `hivemq-allow-all` extension — any client can publish or -subscribe to anything. We replaced it with the vendored -[`hivemq-file-rbac-extension`](https://github.com/hivemq/hivemq-file-rbac-extension) -(`nix/modules/gebos-hivemq.nix`), which adds username/password auth and -topic-level authorization. `gebos-hivemq.preStart` reconciles the writable -extensions dir on every start: it installs the pinned extension and **removes -allow-all**, so a redeploy fixes already-provisioned boxes too. Credentials -(`credentials.xml`) are rendered by sops-nix (`services.gebos.secrets.hivemq`) -and symlinked from the `/run/secrets` tmpfs into the extension's `conf/` — they -never touch the Nix store or the state dir. `password-type` is `PLAIN` because -the file is already an encrypted secret at rest and `0400`/`gebos-hivemq` at -runtime; flip to `HASHED` for defence-in-depth. +EMQX runs as the official `emqx/emqx` image via `oci-containers` with host +networking (`nix/modules/gebos-emqx.nix`). Authentication uses the built-in +database, seeded from a `users.csv` rendered by sops-nix +(`services.gebos.secrets.emqx`) and bootstrapped on first start; the bootstrap +only inserts users that don't exist yet, so a password rotation means deleting +the user (dashboard or `emqx ctl`) and restarting to re-import. Authorization +is a static `acl.conf` with `no_match = deny`. Passwords are plaintext in the +CSV because the file is already an encrypted secret at rest and `0400` at +runtime. There are three broker users: `ingester` (subscribes the device tree), `admin` -(break-glass superuser), and `bender` (the shared device account, below). +(break-glass, `is_superuser` — bypasses the ACL), and `bender` (the shared +device account, below). ### Topic scheme: the device's `acrios//…` is the source of truth @@ -80,7 +78,7 @@ publish/subscribe under `acrios/#`. Tenant isolation is therefore enforced could publish under any IMSI. That's an accepted trade-off for a small trusted fleet, and it gets data flowing without per-device provisioning. -The production answer (**Option B**, a TODO in `nix/modules/gebos-secrets.nix`) +The production answer (**Option B**, a TODO in `nix/modules/gebos-emqx.nix`) is one broker user per device with `username == IMSI`, scoped to `acrios/${{username}}/#` so the broker itself prevents a device from spoofing another's IMSI. It's not wired up because it needs a firmware change @@ -88,20 +86,18 @@ another's IMSI. It's not wired up because it needs a firmware change firmware's client-id (`acrcv-`) carries a prefix the topic doesn't, so per-device scoping must key on username, not `${{clientid}}`. -### TLS: caddy-l4 terminates, HiveMQ stays loopback +### TLS: EMQX terminates natively, certs via ACME HTTP-01 -HiveMQ binds `127.0.0.1:1883` only; it never faces the network. Senders connect -MQTTS on `:8883`, and Caddy — rebuilt with the -[`caddy-l4`](https://github.com/mholt/caddy-l4) layer4 module — terminates TLS -and proxies cleartext to the broker (`nix/modules/gebos-caddy.nix`). We reuse -Caddy here (rather than a native HiveMQ TLS listener + keystore) so there's one -ACME story for the whole fleet. The wrinkle: caddy-l4's `tls` handler only -*terminates* — it never obtains certs — so each `tcpProxy` site also emits a -companion HTTPS block purely to make Caddy's automatic HTTPS provision the cert -that the layer4 handler then serves by SNI. That's why `mqtt-ingest` opens -**80/443** (ACME challenge + renewal) alongside **8883**. The custom Caddy build -is only used on hosts that actually have a `tcpProxy` site; app-host's -static/reverse-proxy sites stay on stock Caddy. +EMQX terminates TLS itself on `:8883`; the plain MQTT listener stays on +loopback for the co-located ingester. Certificates come from `security.acme` +using the **HTTP-01** challenge: lego's built-in standalone server answers on +`:80` (nothing else listens there — no Caddy on this host), so no DNS API +secrets are needed. `mqtt-ingest` opens **80 + 8883** only. The cert's +`postRun` hook installs `fullchain.pem`/`privkey.pem` at fixed paths in the +broker state dir, and EMQX re-reads the PEMs from disk (~every 120 s), so +renewals hot-reload without a restart or dropped connections. The key is RSA +(`keyType = "rsa2048"`) because embedded sender TLS stacks often can't do +ECDSA. Caddy remains only on app-host (stock build, HTTP sites). ## Repo layout diff --git a/nix/hosts/default.nix b/nix/hosts/default.nix index 998a73d..9e3b2cc 100644 --- a/nix/hosts/default.nix +++ b/nix/hosts/default.nix @@ -22,7 +22,7 @@ let modules.gebos-postgres modules.gebos-supabase modules.gebos-caddy - modules.gebos-hivemq + modules.gebos-emqx modules.gebos-ingester modules.gebos-frontend ./${name}.nix diff --git a/nix/hosts/mqtt-ingest.nix b/nix/hosts/mqtt-ingest.nix index 4042644..be092d6 100644 --- a/nix/hosts/mqtt-ingest.nix +++ b/nix/hosts/mqtt-ingest.nix @@ -4,11 +4,15 @@ networking.hostName = "mqtt-ingest"; services.gebos.secrets.ingester = true; - # Broker authn/authz credentials (file-RBAC: ingester + admin MQTT users). - services.gebos.secrets.hivemq = true; + # Broker authn users (users.csv). + services.gebos.secrets.emqx = true; - services.gebos.hivemq = { + # EMQX (official container image) terminates TLS on :8883 itself and serves + # the ingester on loopback :1883. Certs come from security.acme via HTTP-01 + # (lego standalone on :80) — no Caddy on this host anymore. + services.gebos.emqx = { enable = true; + domain = "ingest.gebos.online"; # Persistent sessions on disk — single-node, devices reconnect on restart. persistentSessions = true; tlsPort = 8883; @@ -16,21 +20,12 @@ services.gebos.ingester = { enable = true; - # mqttBroker defaults to tcp://127.0.0.1:1883 (the local HiveMQ). + # mqttBroker defaults to tcp://127.0.0.1:1883 (the local EMQX). postgresUrl = "postgres://gebos_ingest@10.0.0.2:5432/postgres?sslmode=require"; # GEBOS_POSTGRES_PASSWORD sourced from /run/secrets/gebos-env (sops-nix). }; - # Caddy (layer4) terminates TLS on :8883 and forwards cleartext MQTT to - # HiveMQ's loopback listener on :1883. tcpProxy is the upstream backend. - services.gebos.caddy = { - enable = true; - sites = { - "ingest.gebos.online" = { tcpProxy = "127.0.0.1:1883"; }; - }; - }; - - # 8883: MQTTS for senders. 80/443: ACME challenge so Caddy can obtain/renew - # the ingest.gebos.online cert that the layer4 TLS handler serves. - networking.firewall.allowedTCPPorts = [ 80 443 8883 ]; + # 8883: MQTTS for senders. 80: ACME HTTP-01 challenge (lego's standalone + # solver binds it during issuance/renewal; idle otherwise). + networking.firewall.allowedTCPPorts = [ 80 8883 ]; } diff --git a/nix/modules/default.nix b/nix/modules/default.nix index 8ec34d7..4b2ed69 100644 --- a/nix/modules/default.nix +++ b/nix/modules/default.nix @@ -4,6 +4,6 @@ gebos-supabase = import ./gebos-supabase.nix; gebos-caddy = import ./gebos-caddy.nix; gebos-postgres = import ./gebos-postgres.nix; - gebos-hivemq = import ./gebos-hivemq.nix; + gebos-emqx = import ./gebos-emqx.nix; gebos-frontend = import ./gebos-frontend.nix; } diff --git a/nix/modules/gebos-caddy.nix b/nix/modules/gebos-caddy.nix index bfa18d7..663c70b 100644 --- a/nix/modules/gebos-caddy.nix +++ b/nix/modules/gebos-caddy.nix @@ -3,24 +3,8 @@ let cfg = config.services.gebos.caddy; - tcpProxySites = lib.filterAttrs (_: s: s ? tcpProxy) cfg.sites; - httpSites = lib.filterAttrs (_: s: !(s ? tcpProxy)) cfg.sites; - needsLayer4 = tcpProxySites != { }; - - # External MQTTS port the senders connect to (mqtt_port = 8883, SSL). - tlsListenPort = 8883; - - # Caddy built with the layer4 module (caddy-l4) — needed to TLS-terminate raw - # MQTT and proxy it to HiveMQ, which Caddy's HTTP-only core can't do. Only - # built when a host actually has a tcpProxy site; app-host's static/upstream - # sites run on stock Caddy so they don't pay for the custom build. - caddyWithL4 = pkgs.caddy.withPlugins { - plugins = [ "github.com/mholt/caddy-l4@v0.1.1" ]; - hash = "sha256-O6GuC2q1mA/Fa0utb2Yg7ZE73iq13oVYhJI1IVyOvog="; - }; - # HTTP-style sites: static file server or reverse proxy. - httpSiteBlock = host: site: + siteBlock = host: site: if site ? staticRoot then '' ${host} { root * ${site.staticRoot} @@ -35,40 +19,7 @@ let encode zstd gzip } '' - else throw "site `${host}` needs one of: staticRoot, upstream, tcpProxy"; - - # A tcpProxy site needs TWO Caddyfile pieces: - # 1. a layer4 listener (in the global options block) that terminates TLS on - # :8883 and proxies cleartext to the upstream (HiveMQ on 127.0.0.1:1883); - # 2. a normal HTTPS site block for the host, purely so Caddy's automatic - # HTTPS OBTAINS the ACME cert that the layer4 `tls` handler then serves by - # SNI — caddy-l4's `tls` handler only terminates, it never provisions - # certs. This is why ports 80/443 must stay open for the ACME challenge. - layer4Listener = _host: site: '' - :${toString tlsListenPort} { - route { - tls - proxy ${site.tcpProxy} - } - } -''; - - certSiteBlock = host: _site: '' - ${host} { - respond "gebos MQTT ingest — connect MQTTS on :${toString tlsListenPort}" 200 - } - ''; - - # caddy-l4 listeners, folded into one `layer4` directive that lives INSIDE the - # Caddyfile global options block. We must NOT emit our own `{ }` block here: - # the NixOS caddy module already generates the single global block (for - # `email`), and a second keyless block is invalid ("server block without any - # key is global configuration, and if used, it must be first"). So this goes to - # services.caddy.globalConfig, which the module places inside that one block. - layer4Global = lib.optionalString needsLayer4 '' - layer4 { - ${lib.concatStrings (lib.mapAttrsToList layer4Listener tcpProxySites)} } - ''; + else throw "site `${host}` needs one of: staticRoot, upstream"; in { options.services.gebos.caddy = { @@ -82,13 +33,8 @@ in config = lib.mkIf cfg.enable { services.caddy = { enable = true; - package = if needsLayer4 then caddyWithL4 else pkgs.caddy; email = "ops@gebos.online"; # TODO: confirm ACME contact - globalConfig = layer4Global; - extraConfig = lib.concatStringsSep "\n" ( - lib.mapAttrsToList httpSiteBlock httpSites - ++ lib.mapAttrsToList certSiteBlock tcpProxySites - ); + extraConfig = lib.concatStringsSep "\n" (lib.mapAttrsToList siteBlock cfg.sites); }; }; } diff --git a/nix/modules/gebos-emqx.nix b/nix/modules/gebos-emqx.nix new file mode 100644 index 0000000..d04387c --- /dev/null +++ b/nix/modules/gebos-emqx.nix @@ -0,0 +1,238 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.gebos.emqx; + + stateDir = "/var/lib/gebos-emqx"; + + # UID/GID of the `emqx` user baked into the official image. Host-side files + # the container must read or write (certs, bootstrap users, data dir) are + # chowned to this numeric id. On these hosts uid 1000 is the `deploy` user, + # which already has passwordless sudo, so this grants it nothing new. + emqxUid = 1000; + emqxGid = 1000; + + # Full emqx.conf, replacing the image default. EMQX reads HOCON natively; + # container paths below are bind mounts declared on the oci-container. + emqxConf = pkgs.writeText "emqx.conf" '' + node { + name = "emqx@127.0.0.1" + # Erlang distribution cookie. Not treated as a secret: the dist/epmd + # ports are never opened in the firewall and the only local users are + # root and deploy (wheel). + cookie = "gebos-emqx" + data_dir = "data" + } + + cluster { + name = gebos + # Single node, and required to be `singleton` for durable sessions on + # the open-source builtin_local storage backend. + discovery_strategy = singleton + } + + # Dashboard on loopback only (the container uses host networking) — reach + # it via `ssh -L 18083:127.0.0.1:18083`. First login is admin/public with a + # forced password change; the changed password persists in the data dir. + dashboard { + listeners.http.bind = "127.0.0.1:18083" + } + + # Plain MQTT for the co-located ingester only. + listeners.tcp.default { + bind = "${cfg.bindAddress}:${toString cfg.port}" + } + + # MQTTS for the senders. EMQX terminates TLS itself; certs are managed by + # security.acme on the host and installed into ${stateDir}/certs (mounted + # at /etc/emqx/certs). EMQX re-reads the PEM files from disk periodically + # (~120 s), so renewals need no restart — paths stay fixed, contents swap. + listeners.ssl.default { + bind = "0.0.0.0:${toString cfg.tlsPort}" + ssl_options { + certfile = "/etc/emqx/certs/fullchain.pem" + keyfile = "/etc/emqx/certs/privkey.pem" + versions = ["tlsv1.2", "tlsv1.3"] + } + } + + # The schema defines websocket listeners on 8083/8084 by default — unused. + listeners.ws.default.enable = false + listeners.wss.default.enable = false + + # Sessions and queued messages survive a broker restart (QoS > 0 with + # clean_start = false), mirroring HiveMQ's file persistence mode. + durable_sessions.enable = ${lib.boolToString cfg.persistentSessions} + + # Username/password auth against the built-in database, seeded from the + # sops-rendered users.csv on first start (bootstrap only inserts users that + # do not exist yet — to rotate a password, delete the user in the dashboard + # or via `emqx ctl`, then restart to re-import). + authentication = [ + { + mechanism = password_based + backend = built_in_database + user_id_type = username + password_hash_algorithm { name = plain, salt_position = disable } + bootstrap_file = "/etc/emqx/auth/users.csv" + bootstrap_type = plain + } + ] + + # Topic-level authorization from the static acl.conf; anything not + # explicitly allowed is denied. + authorization { + no_match = deny + sources = [ + { + type = file + enable = true + path = "/etc/emqx/auth/acl.conf" + } + ] + } + ''; + + # Topic policy (non-secret — usernames and topics only, no credentials). + # Devices publish under `acrios//` (the sender firmware's + # mqtt_topic_base), so that is the namespace the ingester consumes and the + # device user is scoped to. `admin` is is_superuser in users.csv and bypasses + # this ACL entirely (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 users with username == IMSI and an ACL + # rule scoped to `acrios/''${username}/#` for real anti-spoofing. + aclConf = pkgs.writeText "emqx-acl.conf" '' + %% ingester: consume the whole device tree. + {allow, {username, "ingester"}, subscribe, ["acrios/#"]}. + %% device fleet: publish telemetry + LWT/status, subscribe downlinks. + {allow, {username, "bender"}, all, ["acrios/#"]}. + {deny, all}. + ''; +in +{ + options.services.gebos.emqx = { + enable = lib.mkEnableOption "EMQX broker (official container image) with native TLS"; + + image = lib.mkOption { + type = lib.types.str; + default = "emqx/emqx:5.8.6"; + description = "Official EMQX image reference to run."; + }; + + domain = lib.mkOption { + type = lib.types.str; + example = "ingest.gebos.online"; + description = "Public hostname the senders connect to; ACME cert is issued for it."; + }; + + tlsPort = lib.mkOption { + type = lib.types.port; + default = 8883; + description = "MQTTS listener port (TLS terminated by EMQX itself)."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 1883; + description = "Plain MQTT TCP listener port (what the ingester connects to)."; + }; + + bindAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = '' + Address the plain MQTT listener binds to. Defaults to loopback: + external clients only ever reach the TLS listener. + ''; + }; + + persistentSessions = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether client sessions and queued messages survive a restart + (EMQX durable sessions) or are kept only in memory. + ''; + }; + + acmeEmail = lib.mkOption { + type = lib.types.str; + default = "ops@gebos.online"; + description = "ACME account contact for the broker certificate."; + }; + + bootstrapUsersFile = lib.mkOption { + type = lib.types.path; + default = config.services.gebos.secrets.emqxBootstrapUsersFile; + defaultText = lib.literalExpression "config.services.gebos.secrets.emqxBootstrapUsersFile"; + description = '' + CSV (user_id,password,is_superuser) seeding EMQX's built-in auth + database. Defaults to the sops-rendered secret; copied into the state + dir on service start so the container user can read it. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + virtualisation.oci-containers = { + backend = "docker"; + containers.emqx = { + image = cfg.image; + # Host networking: EMQX binds host ports directly, so loopback-only + # listeners (plain MQTT, dashboard) really are loopback-only and the + # TLS listener sees real client source addresses. + extraOptions = [ "--network=host" ]; + volumes = [ + "${emqxConf}:/opt/emqx/etc/emqx.conf:ro" + "${aclConf}:/etc/emqx/auth/acl.conf:ro" + "${stateDir}/auth/users.csv:/etc/emqx/auth/users.csv:ro" + "${stateDir}/certs:/etc/emqx/certs:ro" + "${stateDir}/data:/opt/emqx/data" + ]; + }; + }; + + # The oci-containers unit is docker-emqx.service; extend it to stage the + # writable state the container mounts. The bootstrap users file is copied + # (not symlinked) out of the sops tmpfs so it can be owned by the + # container's emqx uid. + systemd.services.docker-emqx = { + # Don't start before the first cert issuance has been attempted. If ACME + # fails (e.g. Porkbun creds not provisioned yet), EMQX crash-loops on the + # missing certfile until the cert lands — Restart=always retries. + after = [ "acme-finished-${cfg.domain}.target" ]; + wants = [ "acme-finished-${cfg.domain}.target" ]; + preStart = '' + install -d -m 750 -o ${toString emqxUid} -g ${toString emqxGid} ${stateDir}/data + install -d -m 755 ${stateDir}/auth ${stateDir}/certs + install -m 400 -o ${toString emqxUid} -g ${toString emqxGid} \ + ${cfg.bootstrapUsersFile} ${stateDir}/auth/users.csv + ''; + }; + + # Cert issuance + renewal via HTTP-01: lego's built-in standalone server + # answers the challenge on :80 (nothing else listens there — Caddy is gone + # from this host), so no DNS API secrets are needed; the host just keeps + # port 80 open alongside 8883. postRun is the deploy hook: install the + # PEMs at the fixed paths EMQX watches, owned by the container user. EMQX + # hot-reloads them; no restart or `emqx ctl` needed. RSA keys because + # embedded sender TLS stacks often lack ECDSA. + security.acme = { + acceptTerms = true; + certs.${cfg.domain} = { + email = cfg.acmeEmail; + listenHTTP = ":80"; + keyType = "rsa2048"; + postRun = '' + install -D -m 644 -o ${toString emqxUid} -g ${toString emqxGid} \ + fullchain.pem ${stateDir}/certs/fullchain.pem + install -D -m 600 -o ${toString emqxUid} -g ${toString emqxGid} \ + key.pem ${stateDir}/certs/privkey.pem + ''; + }; + }; + }; +} diff --git a/nix/modules/gebos-hivemq.nix b/nix/modules/gebos-hivemq.nix deleted file mode 100644 index 3c8df80..0000000 --- a/nix/modules/gebos-hivemq.nix +++ /dev/null @@ -1,276 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - cfg = config.services.gebos.hivemq; - - # HiveMQ CE — fetched from the upstream GitHub release and unpacked into the - # Nix store. The store tree is read-only; the runtime data/log/extension - # folders are redirected to the writable state dir (see ExecStart below). - hivemq-ce = pkgs.stdenvNoCC.mkDerivation rec { - pname = "hivemq-ce"; - version = "2026.5"; - - src = pkgs.fetchurl { - url = "https://github.com/hivemq/hivemq-community-edition/releases/download/${version}/hivemq-ce-${version}.zip"; - hash = "sha256-/QBeU17KC6/7CF4RsVEohdBs4AeG+WFwHHZEmBb33sU="; - }; - - nativeBuildInputs = [ pkgs.unzip ]; - dontConfigure = true; - dontBuild = true; - - installPhase = '' - runHook preInstall - mkdir -p "$out" - cp -r . "$out/" - runHook postInstall - ''; - - meta = with lib; { - description = "HiveMQ Community Edition — open-source MQTT 3.x / 5 broker"; - homepage = "https://github.com/hivemq/hivemq-community-edition"; - license = licenses.asl20; - platforms = platforms.linux; - }; - }; - - # HiveMQ file-RBAC extension — adds username/password authentication and - # topic-level authorization, replacing the insecure hivemq-allow-all that - # ships in the broker package. The zip unpacks to a single top-level - # `hivemq-file-rbac-extension/` folder which we keep verbatim in $out. - hivemq-file-rbac = pkgs.stdenvNoCC.mkDerivation rec { - pname = "hivemq-file-rbac-extension"; - version = "4.6.15"; - - src = pkgs.fetchurl { - url = "https://github.com/hivemq/hivemq-file-rbac-extension/releases/download/${version}/hivemq-file-rbac-extension-${version}.zip"; - hash = "sha256-R6PM+qAvEKMHdX3FTR0CfR8yarJrc2WeFAEu4eXH3Ps="; - }; - - nativeBuildInputs = [ pkgs.unzip ]; - dontConfigure = true; - dontBuild = true; - - # stdenv unpacks the single top-level archive dir and cds into it, so $PWD - # already holds the extension's files (jar, conf/, hivemq-extension.xml). - installPhase = '' - runHook preInstall - mkdir -p "$out/hivemq-file-rbac-extension" - cp -r . "$out/hivemq-file-rbac-extension/" - runHook postInstall - ''; - - meta = with lib; { - description = "HiveMQ file-based role-based access control extension"; - homepage = "https://github.com/hivemq/hivemq-file-rbac-extension"; - license = licenses.asl20; - platforms = platforms.linux; - }; - }; - - stateDir = "/var/lib/gebos-hivemq"; - - # Non-secret RBAC extension config (reload interval + password format). The - # users/roles live in the secret credentials.xml, symlinked in at runtime. - rbacConfigXml = pkgs.writeText "hivemq-file-rbac-config.xml" '' - - - ${toString cfg.rbac.reloadInterval} - ${cfg.rbac.passwordType} - - ''; - - # config.xml rendered from the module options. The local TCP listener is what - # the ingester (and, eventually, the Caddy TLS frontend) connects to. - configXml = pkgs.writeText "hivemq-config.xml" '' - - - - - ${toString cfg.port} - ${cfg.bindAddress} - - - - ${if cfg.persistentSessions then "file" else "in-memory"} - - - false - - - ''; - - # Read-only conf folder: our generated config.xml plus the upstream - # logback.xml / config.xsd so HiveMQ logs and validates as shipped. - confDir = pkgs.runCommand "hivemq-conf" { } '' - mkdir -p "$out" - cp ${configXml} "$out/config.xml" - cp ${hivemq-ce}/conf/logback.xml "$out/logback.xml" - cp ${hivemq-ce}/conf/config.xsd "$out/config.xsd" - ''; - - # run.sh's JVM flags, reproduced so we can invoke java directly and point - # HiveMQ at the writable state dir via -Dhivemq.*.folder. - javaOpts = lib.concatStringsSep " " [ - "-Djava.net.preferIPv4Stack=true" - "--add-opens java.base/java.lang=ALL-UNNAMED" - "--add-opens java.base/java.nio=ALL-UNNAMED" - "--add-opens java.base/sun.nio.ch=ALL-UNNAMED" - "--add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED" - "--add-exports java.base/jdk.internal.misc=ALL-UNNAMED" - "-Djava.security.egd=file:/dev/./urandom" - "-Duser.language=en -Duser.region=US" - "-XX:+CrashOnOutOfMemoryError" - "-XX:+HeapDumpOnOutOfMemoryError" - "-XX:HeapDumpPath=${stateDir}/log/heap-dump.hprof" - "-XX:ErrorFile=${stateDir}/log/hs_err_pid%p.log" - ]; -in -{ - options.services.gebos.hivemq = { - enable = lib.mkEnableOption "HiveMQ CE single-node broker"; - - package = lib.mkOption { - type = lib.types.package; - default = hivemq-ce; - defaultText = lib.literalExpression "pkgs.hivemq-ce (vendored)"; - description = "HiveMQ CE package to run."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 1883; - description = "Plain MQTT TCP listener port (what the ingester connects to)."; - }; - - bindAddress = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = '' - Address the MQTT listener binds to. Defaults to loopback: external - clients reach the broker through the Caddy TLS frontend, not directly. - ''; - }; - - persistentSessions = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Whether client sessions and queued messages survive a restart - (file persistence) or are kept only in memory. - ''; - }; - - tlsPort = lib.mkOption { - type = lib.types.port; - default = 8883; - description = '' - Externally advertised MQTTS port. TLS is currently terminated by the - Caddy frontend (see gebos-caddy), not by HiveMQ directly, so this value - is informational until a keystore is wired into the broker. - ''; - }; - - rbac = { - package = lib.mkOption { - type = lib.types.package; - default = hivemq-file-rbac; - defaultText = lib.literalExpression "pkgs.hivemq-file-rbac (vendored)"; - description = "HiveMQ file-RBAC authn/authz extension package."; - }; - - passwordType = lib.mkOption { - type = lib.types.enum [ "HASHED" "PLAIN" ]; - default = "PLAIN"; - description = '' - Whether passwords in the credentials file are PBKDF2 HASHED or PLAIN - text. PLAIN is acceptable here because the credentials file is a - sops-encrypted secret at rest and rendered 0400/gebos-hivemq into a - tmpfs at runtime; switch to HASHED for defence-in-depth. - ''; - }; - - reloadInterval = lib.mkOption { - type = lib.types.ints.positive; - default = 60; - description = "Seconds between credentials.xml reloads by the extension."; - }; - - credentialsFile = lib.mkOption { - type = lib.types.path; - default = config.services.gebos.secrets.hivemqCredentialsFile; - defaultText = lib.literalExpression "config.services.gebos.secrets.hivemqCredentialsFile"; - description = '' - Path to the file-RBAC credentials.xml (users, roles, topic - permissions). Defaults to the sops-rendered secret; symlinked into - the extension's conf/ dir so it never lands in the writable state dir. - ''; - }; - }; - }; - - config = lib.mkIf cfg.enable { - users.users.gebos-hivemq = { - isSystemUser = true; - group = "gebos-hivemq"; - description = "Gebos HiveMQ CE service user"; - }; - users.groups.gebos-hivemq = { }; - - systemd.services.gebos-hivemq = { - description = "HiveMQ CE"; - after = [ "network.target" ]; - wantedBy = [ "multi-user.target" ]; - - # HiveMQ wants the extensions folder writable (it manages enable/disable - # markers there), so the extension lives under the state dir. We reconcile - # it on every start: (re)install the vendored file-RBAC extension when its - # store path changes, refresh its non-secret config, link the secret - # credentials from the sops tmpfs path, and remove the insecure allow-all - # extension that earlier deploys seeded from the broker package. - preStart = '' - set -eu - mkdir -p ${stateDir}/{data,log,extensions} - - extDir=${stateDir}/extensions/hivemq-file-rbac-extension - marker=${stateDir}/extensions/.rbac-version - - if [ "$(cat "$marker" 2>/dev/null || true)" != "${cfg.rbac.package}" ]; then - rm -rf "$extDir" - cp -r --no-preserve=mode ${cfg.rbac.package}/hivemq-file-rbac-extension "$extDir" - echo "${cfg.rbac.package}" > "$marker" - fi - - cp --no-preserve=mode ${rbacConfigXml} "$extDir/conf/config.xml" - ln -sf ${cfg.rbac.credentialsFile} "$extDir/conf/credentials.xml" - - rm -rf ${stateDir}/extensions/hivemq-allow-all-extension - ''; - - serviceConfig = { - Type = "simple"; - User = "gebos-hivemq"; - Group = "gebos-hivemq"; - StateDirectory = "gebos-hivemq"; - WorkingDirectory = stateDir; - ExecStart = '' - ${pkgs.jre_headless}/bin/java \ - -Dhivemq.home=${cfg.package} \ - -Dhivemq.config.folder=${confDir} \ - -Dhivemq.log.folder=${stateDir}/log \ - -Dhivemq.data.folder=${stateDir}/data \ - -Dhivemq.extensions.folder=${stateDir}/extensions \ - ${javaOpts} \ - -jar ${cfg.package}/bin/hivemq.jar - ''; - Restart = "on-failure"; - RestartSec = "5s"; - # HiveMQ exits 143 on SIGTERM shutdown — treat that as success. - SuccessExitStatus = "143"; - KillSignal = "SIGTERM"; - LimitNOFILE = 65536; - }; - }; - }; -} diff --git a/nix/modules/gebos-secrets.nix b/nix/modules/gebos-secrets.nix index 6afa806..c4b27c2 100644 --- a/nix/modules/gebos-secrets.nix +++ b/nix/modules/gebos-secrets.nix @@ -17,75 +17,30 @@ let ]; ingesterKeys = [ "ingester_postgres_password" ]; postgresAdminKeys = [ "postgres_admin_password" ]; - hivemqKeys = [ "hivemq_ingester_password" "hivemq_admin_password" "hivemq_device_password" ]; + emqxKeys = [ + "emqx_ingester_password" + "emqx_admin_password" + "emqx_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. + # EMQX authn bootstrap CSV (bootstrap_type = plain). Seeds the broker's + # built-in auth database on first start. Broker users: + # ingester — the Go ingester; SUBSCRIBE the whole device tree (see the ACL + # in gebos-emqx.nix). + # admin — operational break-glass; is_superuser bypasses the ACL. + # bender — shared device user for the whole fleet (Option A). Tenant + # isolation is enforced downstream by the ingester's IMSI→tenant + # registry, NOT at the broker. TODO(Option B): per-device users + # (username == IMSI) — see the ACL comment in gebos-emqx.nix. # - # 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 - - # - - - - + # Plaintext passwords are acceptable here because the file is sops-encrypted + # at rest and rendered 0400 into a tmpfs at runtime; passwords must not + # contain commas (CSV). + emqxUsersCsv = ingesterPassword: adminPassword: devicePassword: '' + user_id,password,is_superuser + ingester,${ingesterPassword},false + admin,${adminPassword},true + bender,${devicePassword},false ''; # Render an env file body from a list of (key, envVarName) pairs. @@ -112,7 +67,7 @@ let lib.optionals cfg.supabase supabaseKeys ++ lib.optionals cfg.ingester ingesterKeys ++ lib.optionals cfg.postgresAdmin postgresAdminKeys - ++ lib.optionals cfg.hivemq hivemqKeys; + ++ lib.optionals cfg.emqx emqxKeys; in { options.services.gebos.secrets = { @@ -126,24 +81,24 @@ in 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.''; + emqx = lib.mkEnableOption '' + EMQX broker secrets: the authn bootstrap users.csv (ingester + admin + + device MQTT users).''; - hivemqCredentialsFile = lib.mkOption { + emqxBootstrapUsersFile = 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 + Path of the rendered EMQX authn bootstrap users.csv consumed by the + gebos-emqx broker. Resolves to the sops-templated secret when sops + material is present and the emqx 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")}"; + if hasSecrets && cfg.emqx + then config.sops.templates."emqx-users.csv".path + else "${pkgs.writeText "emqx-users-dev.csv" + (emqxUsersCsv "dev-ingester" "dev-admin" "dev-device")}"; }; envFile = lib.mkOption { @@ -182,16 +137,15 @@ in 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; + # Rendered as a standalone file — the gebos-emqx preStart copies it into + # the broker state dir, chowned to the container's emqx uid. + templates."emqx-users.csv" = lib.mkIf cfg.emqx { + content = emqxUsersCsv + config.sops.placeholder.emqx_ingester_password + config.sops.placeholder.emqx_admin_password + config.sops.placeholder.emqx_device_password; mode = "0400"; - owner = "gebos-hivemq"; + owner = "root"; }; }; }; diff --git a/nix/secrets/secrets.yaml b/nix/secrets/secrets.yaml index d1a5fa2..a595294 100644 --- a/nix/secrets/secrets.yaml +++ b/nix/secrets/secrets.yaml @@ -15,62 +15,62 @@ # 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: ENC[AES256_GCM,data:jXXxqvd15zPe5gDwnrFohsl8qrPny0IFV8UE2oyN9hGOWQ7KheltWm2r0IOL,iv:ZYBfmtduP2rSTezFwwv/NAavaF6/fvXBrdrF0MM0jY4=,tag:TiodVCwbCXDGafeaOsLURg==,type:str] -supabase_jwt_secret: ENC[AES256_GCM,data:j17p4R3JiLWlO4C8iYE2SXthzqB4kGbkypFMPTI30J2bI9c=,iv:AAamwue0Vs1Ns7OlujNtjU5IY43Z1NVJxJW5S9ftGbk=,tag:LWX3h0QMRBNNzUPWLxe3HQ==,type:str] -supabase_anon_key: ENC[AES256_GCM,data:zpeMG5L5GHlhGrHETnSY3cG+0rgC52HmSTDfdr0Il6cuEb0/KiUA9iyQOybT7Oo=,iv:NIMbKi14WlrTkLUt0ybBOFCklpBZ8TdFq+LkvqeElqA=,tag:rOQ1ETG13pt00kkCQQRJbQ==,type:str] -supabase_service_role_key: ENC[AES256_GCM,data:MDacdgnZhC3mx/YUoQT8j2LGHyCYGmW2TMBdcSp91WmoWh4/WuYy5BtpBfgDLC1S+6taTPmfjA==,iv:a7mSPzr/JYs6036NGlI75NqFm0Y1WMTJJGp8YX1PIOs=,tag:Q3ygfgexT6Ju9GHIf+jHjw==,type:str] -supabase_dashboard_password: ENC[AES256_GCM,data:i9Pl87BtOICcKwJxDp9QH1CtPaUbTb3IObrdPg/xcsqSMim9+RyO+g==,iv:qDQH1//2ZKNXAgAmFir2TG6auUEh6AFE00a/gP/1+Aw=,tag:llYW7o9XK5vmAIs45QQJpQ==,type:str] +supabase_postgres_password: ENC[AES256_GCM,data:OrjkfiumbB3uMD0tuDkgzaiLDgnt20wYOP9tBAyVwVUXQJxLiDypJoUb4wTY,iv:b4/Lep0Jzb6UtAhyOCpcetXgppul+V5O43zokMtpM2M=,tag:3fvi5xpverfSG+xc/4/HHg==,type:str] +supabase_jwt_secret: ENC[AES256_GCM,data:csu0Rj/DM07DCtYFieIe6eEca+eUPR6KGfQBqa6cqRe7bX4=,iv:OKYFa7Y1bbEAKh1ueM7K8JDLldrTJL/r83AGTtMx/IU=,tag:y6OGVofm4cUFPc0BBmkY+A==,type:str] +supabase_anon_key: ENC[AES256_GCM,data:ReE4g5pNUHqi9M9ejnR88XUjwgaibcgZRM5GCMl+Ds7pzKUY442nqEtc2xVO2vQ=,iv:+lp/a/JshLZfdkxm4uQrgOKxBFmIduLtu3aUAhWrKlE=,tag:ft9swD2SeFJ/jMyL07kmGQ==,type:str] +supabase_service_role_key: ENC[AES256_GCM,data:QZQbvJ6rkyoHIpxIvjVDFwxeZ4oIb7Bt2h2vgtW8/sp6swSxtW/oAeVfLkdeduir+2BC5mFAhA==,iv:/F5F3MRC5Yebn68g45F1sj4d18+60JZupneHa3ipExs=,tag:fuzDuSj2npxKQxP2Pz+cjQ==,type:str] +supabase_dashboard_password: ENC[AES256_GCM,data:6KHJCPJ66dW9n/uzb3mpGIYqGZABHeEew1H5kx3I8kqgABqXcnHi6w==,iv:87NBfRoNOwtEKgGC0CacY/0xyVE5DQJpuN0X+7j8dfQ=,tag:TcMmIS2GgUlBXlH0elC7Rg==,type:str] # Ingester (consumed on mqtt-ingest by gebos-ingester.service) -ingester_postgres_password: ENC[AES256_GCM,data:Aq5gMK4RaYloOoYURXaGbb3jVVfSl8ZiXaabKkcrs+/iaHm1KBWtrw==,iv:07b7SF8khDBXVTVZGoqEuDsD+k9sOsvnunJAtKpfchg=,tag:DnFLwyB5Mt3NRo/Jklcntg==,type:str] +ingester_postgres_password: ENC[AES256_GCM,data:6jy1G6zG4r0z3c9B6s7jPUtvn/Nr/J2HQN23ch6AGFigiVbefY3hCw==,iv:X/PWQjTW/++MVGsi+hcxbkOJZQMOUH0a4mRH5CbOw4A=,tag:TBKFIdbXZ2YzDLbjdnsyxg==,type:str] # Postgres bootstrap (consumed on db-host by a one-shot ALTER ROLE unit) -postgres_admin_password: ENC[AES256_GCM,data:Ien5PaD37D7c6EAq3dgh5+cpDANIA7j+V2SLiUJeIUXpqi1EjtvjTIm4,iv:PZa0Of3c2ozZ/ZE57sw6TMV7ze19A3eHKYcf6hc0kKo=,tag:YDVbdMr7gRmbp1ma2VWrmw==,type:str] -# HiveMQ file-RBAC broker users (consumed on mqtt-ingest by gebos-hivemq). +postgres_admin_password: ENC[AES256_GCM,data:j5XHA2ZepUq6rJMmIrKr88xjNLiMsJ+ng7CeQxNO3fVwydQhMW0hl0KB,iv:ZXilU07B3aJRQeIM0GVypFcGe4K7y5BsEOa2cIYzAOo=,tag:CEdzYR7NElODPYmTWdVccQ==,type:str] +# EMQX broker users (consumed on mqtt-ingest by gebos-emqx as bootstrap users.csv). # PLAIN passwords (password-type defaults to PLAIN; the whole file is sops- # encrypted). The ingester will authenticate as `ingester`; `admin` is for ops. -hivemq_ingester_password: ENC[AES256_GCM,data:0Kfqp5V03fyZU6ulSlCmIbV5x0Wrot8wRxtut8rxFzxsygprLkEV462R+g==,iv:6Lb7PhmJoa2JbS1g/VaPCq3HOXBpX3/8eaww/A6EJqY=,tag:Ny4pygCw3wfWObccWfOBxQ==,type:str] -hivemq_admin_password: ENC[AES256_GCM,data:I9g7/PcZb9TMf81SKNppMDf/8Je/9V+TSG932KGi+BXZjU7IsocM8HuY,iv:AlyAI44Ryl60W3HFxExC3i92WqMKA1myW+sLsBD50mo=,tag:JYO7YtDTouCZ5frTlDofBw==,type:str] +emqx_ingester_password: ENC[AES256_GCM,data:pV1FIY5uNeZ2zkjB9dv3C5DP2AmeyaV55lqgizQoKGKq0w3PU1gV+xev9g==,iv:E+anGu88+fhBQRLp55tYghx5SvHVniolXaj6kE/3FrQ=,tag:iJpH+0zAowKq+t/xXR+NjA==,type:str] +emqx_admin_password: ENC[AES256_GCM,data:lrOUYSCkYFkXlelAoMUp9WFxo7COJWYkDSBVEr2RT0B2lfMnF3MzqhqJ,iv:uZi+oCw6esNuweIAFTGn3A6Ix5ctwn1Oj4GBchalXmA=,tag:84QigtqxRr1zddMtCsFu9w==,type:str] # Shared device login (Option A) — must match mqtt_user/mqtt_password on the # senders. Per-device users (Option B) are a TODO in gebos-secrets.nix. -hivemq_device_password: ENC[AES256_GCM,data:27kqmePshQ==,iv:5m2e7Hc583jvsSjiKbl63cY9cTFs0aAY/L6jV5Vh9MU=,tag:nGhgrVpNU8Z2Z7cHTbBSSQ==,type:str] +emqx_device_password: ENC[AES256_GCM,data:pFp3RxJoDw==,iv:1FQzAHSr4VIyuuq2qgFIcuzOq487W4sKV1x8Opoa0jM=,tag:zwn7dOmZArUOtlZBv5G63w==,type:str] sops: age: - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRL1VHOWVreFdsQ0JWK3o0 - aFZYd0JITHU0RHg2a09DeVdFRXFiUC95OHlFCnZzaDVGeFdJa2l6S1Zxbng1K2tr - ZkEzQzdHeXRWWi9OUTdDYXJBeTZUTWcKLS0tIDBKdEZTSlZFeFRFSGtXclV0MkxR - RVFNbnQxbDdGSnpNendBbmJnditLMDAKNrn1/8gIngi0pM+DEf6ov/SGNQrXnFby - 1a2iAqBP+EDG0z52dcZns53Mj6XDgeh5Z2W5hDpITxDNhSVXEKmdHQ== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzaVAwall2bTE0Y09ZeUJn + SFR2cHBDaFZtRmpodGV0ZHJsUzVuWGdNb213ClFFZ04xV0pTVzR5eFh0NjNsYWpZ + SU9IUzBoZWJaNjZmUW1Tc3NER2hpRDAKLS0tIG9BVnAzYW1jL1VZOHRLeTJvQzh0 + Y3dtSnp5eGNnWnN0SWVYa0laNExtUWsKhiZOiy1otoGW+Y5HOvFz1nc549LTyvvl + hP0McG7/p0Awh9bQNzhvgQO88M4S0wIrDJ3P0Gu5/HKzKZXQ8EhSOA== -----END AGE ENCRYPTED FILE----- recipient: age1cx8ul285kjkzmnhw6skdstnzrxnnme4xkflknzn7yhv52fgxqevqkd66cn - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1Z3JpRnZIUG90b0xkdnU0 - aHpBQm5BMjF1YVNhOHVQcXJEWnhxbU5FQUM0Cml3SUJvWjAydWFwcGVHQlo0SjhU - bzM4N3J0YjdBWURJbER5OENsR3VMYWsKLS0tIElQWTZqL1lscUZoOXArR014ZDVJ - Z0JidnBZSWt0QmdBb1l0MlUxSngyUjQK6EuSmh77Gcc9aow16fgeZV+/GlJw1Zaz - aH3GqG7lw6hvn0HWSqJj8shTROqj+sWi1fUCIgMo1kcTBhKQC+ErEQ== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzRHpzVWtpQnFrWFBNV1c5 + UitrRVdQWGUyb0Z6WHVlMFhibjRhWXhFMmdzCnpyMW1QVUErMjBBUHJvMnN6RWJP + b0JselpJQkoxNDFtdU5TYlRBRmpUOGMKLS0tIDdQNGY3TUxhZGtkdzVZSlIwRXZK + NGJqWmJoSkFXMTVKOGNWMGlUN0o1czQKw3/naLb0zt5yHeITSJp9GKEOJfW5bSlL + CtQLx6Nn1WFOTcuoxgaf/xtUCBiSYSRg7pM9KwX/Pfu9gpvWjScUZA== -----END AGE ENCRYPTED FILE----- recipient: age1wpl3vz60tlt880p5fmfedr8c59dm4kf0czsacv0w5my706twuf5q9p0x43 - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjZEw0UC82azY2c3NBVk42 - M1RORGszMDVWbG5UVkRZOFVEL05hcVpzSlVvCklhOThsMDRGelBtTlFSZ3hWT01G - ZkErVTdhT2t2dFpzOHI2Z1p3a2c0NTgKLS0tIFNpNE53VEVPczVSRTJNWlEyMU5X - RFp0VzZaeG5hcTNmZ21GTENBM09zZTQK1QswQul0EbX1UnuF1iL4WXXZN3nOSBjp - inIv5inJhjcZ+Vo82NDeQcWnk+5Ns43sKqQjHf0XpfgyzRyjV6SHmw== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHL3hhWG5WL2N5THRaUFBz + SEo0SE0vQUZIZ05ad0ZUN3lrU3hHSTRISm1zCmVEdWxRSU8zSE1vODNwbVVub2VZ + eWZqU3FiOG9mNHpBZjBQeEdhYkF6SmcKLS0tIEh1WkJIOXdDRmNMMHR2VE5jdXpU + WHRqTUFJQTJQTVFBSzBqVlVWY2ROU28KToql5OybiwITIS9GHieUGfgRILlhmUyM + abkD57fWwMzh1h5UNdUt/VMQEuYX9pK/QhLOrWNgZumk7JesbKRL3A== -----END AGE ENCRYPTED FILE----- recipient: age1g57pznep69nlyv3sltz6k0sml47v68m03gh68jkqwcq4jdx68vtsvnefq0 - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTRE4wSUxMMjA2cUNEQXFL - ZE9ha2dsL0tKK2tTTTJpd25EUUFlQnJiQ1RBCjNTcUk2cGxvUWF5b2MrVi81bVZr - dDBoSFhRdDR0UnZsMWdRYnJhNGtMYjQKLS0tIFBiM3pweFgzRWNCSHozV01XbCs3 - elhHMFE0dnZHU2pFaEJ2ekJ0U2FhQTAKNZGdxEMPf7pG11zS233wFYfDKySMqL/m - 7syeofAs00911pOubBhRa1G6zvwblevDnZuAqhHBTiKfKtGrpCG4Jg== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqUTN5NENOcm1WRnltbzBE + RFgwY3l5cXVYeVptZG9UenBqc25GbCs2NFFFCkNvSytuOVUzRkV2R0doMFpvNndu + a1NyYmVVRE9LRmpwdis4Sk5lcnNoMkEKLS0tIFhTZjFhZVhFZ2FIKzg4dzJ1a25y + cW84SytPUGZmMkVxdmFhMmQ2dy9XZlEK/S1drmtv8te8m2Zgpkm2l+RdOTTbhKCs + ziyUG3WkxBzihV7WpVyfKOu8XMJfLUsgiusjCeCsmkdq+UN+VBlZ/g== -----END AGE ENCRYPTED FILE----- recipient: age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d - encrypted_regex: ^(supabase_|ingester_|postgres_admin_|hivemq_) - lastmodified: "2026-06-29T15:20:36Z" - mac: ENC[AES256_GCM,data:ds4eFZC9fjU+0NjvpaRKWq8HSbslFSPVGLlPtVGlvLm1kpZ8OtC6D6H4VtZZdIKvie/SahcA/3oMGMC66UDADrem7OGKWX5UFRrtuwrctenxteu6Q16sNqlmYBUSkd1YNAH3K4m4TR1DyvMNVC6hTbpCP2hVv7k9fy0o5UZT7bQ=,iv:vRBvBsypuurce0yq3Nu4zvqpzL7+84O3SkbOPoHllt8=,tag:ANZ7TYppVgX36SU1WvLlow==,type:str] + encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_|porkbun_) + lastmodified: "2026-07-02T13:11:42Z" + mac: ENC[AES256_GCM,data:eJRwD6eGsmca+Gv3TP7JLSoCXlH34boilkxxEqRkbz8vNsCpEANZufet4Oes8vpNubU8yx509nrGo3SoC+MihvRaw/hfWqYOlXhlB0j6RsQuuG3LRQFVLJ1m1Q4zw5PYEcB/JHc4ORSIDrMb6nlemBc1dsDItIqyxlOqFnesUec=,iv:EBmK7XZ9ZDQT68FiItivu3QglhX0LfvMgw6n6qUhXUM=,tag:IqvQ4CVOrnWpQ3cE/mC/2w==,type:str] version: 3.13.1 diff --git a/nix/secrets/secrets.yaml.example b/nix/secrets/secrets.yaml.example index ce53b68..0912c06 100644 --- a/nix/secrets/secrets.yaml.example +++ b/nix/secrets/secrets.yaml.example @@ -28,11 +28,12 @@ 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" -# HiveMQ file-RBAC broker users (consumed on mqtt-ingest by gebos-hivemq). -# PLAIN passwords (password-type defaults to PLAIN; the whole file is sops- -# encrypted). The ingester will authenticate as `ingester`; `admin` is for ops. -hivemq_ingester_password: "replace-me-openssl-rand-hex-32" -hivemq_admin_password: "replace-me-openssl-rand-hex-32" +# EMQX broker users (consumed on mqtt-ingest by gebos-emqx). Plaintext +# passwords in a bootstrap users.csv (the whole file is sops-encrypted at +# rest); no commas allowed. The ingester will authenticate as `ingester`; +# `admin` is the operational break-glass superuser. +emqx_ingester_password: "replace-me-openssl-rand-hex-32" +emqx_admin_password: "replace-me-openssl-rand-hex-32" # Shared device login (Option A) — must match mqtt_user/mqtt_password on the -# senders. Per-device users (Option B) are a TODO in gebos-secrets.nix. -hivemq_device_password: "replace-me-to-match-the-sender-mqtt_password" +# senders. Per-device users (Option B) are a TODO in gebos-emqx.nix. +emqx_device_password: "replace-me-to-match-the-sender-mqtt_password"