switch to emqx
check / flake-check (push) Successful in 25s
deploy / deploy (push) Successful in 48s

This commit is contained in:
Lars Nolden
2026-07-02 15:19:59 +02:00
parent 08d9fc594e
commit 58155dc737
11 changed files with 366 additions and 512 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ keys:
creation_rules: creation_rules:
# Supabase compose stack → app-host only. # Supabase compose stack → app-host only.
- path_regex: nix/secrets/secrets\.yaml$ - path_regex: nix/secrets/secrets\.yaml$
encrypted_regex: ^(supabase_|ingester_|postgres_admin_|hivemq_) encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_)
key_groups: key_groups:
- age: - age:
- *dev_lars - *dev_lars
+27 -31
View File
@@ -11,7 +11,7 @@ Three hosts (see issue #21 for the full design discussion):
| Host | Role | Public hostname | Private IP | | 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` | | `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` | | `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. 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 device ──MQTTS:8883──▶ EMQX (native TLS) ──MQTT:1883 loopback──▶ ingester ──▶ Postgres
acrios/<IMSI>/<metric> loopback file-RBAC IMSI→tenant acrios/<IMSI>/<metric> 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 EMQX runs as the official `emqx/emqx` image via `oci-containers` with host
subscribe to anything. We replaced it with the vendored networking (`nix/modules/gebos-emqx.nix`). Authentication uses the built-in
[`hivemq-file-rbac-extension`](https://github.com/hivemq/hivemq-file-rbac-extension) database, seeded from a `users.csv` rendered by sops-nix
(`nix/modules/gebos-hivemq.nix`), which adds username/password auth and (`services.gebos.secrets.emqx`) and bootstrapped on first start; the bootstrap
topic-level authorization. `gebos-hivemq.preStart` reconciles the writable only inserts users that don't exist yet, so a password rotation means deleting
extensions dir on every start: it installs the pinned extension and **removes the user (dashboard or `emqx ctl`) and restarting to re-import. Authorization
allow-all**, so a redeploy fixes already-provisioned boxes too. Credentials is a static `acl.conf` with `no_match = deny`. Passwords are plaintext in the
(`credentials.xml`) are rendered by sops-nix (`services.gebos.secrets.hivemq`) CSV because the file is already an encrypted secret at rest and `0400` at
and symlinked from the `/run/secrets` tmpfs into the extension's `conf/` — they runtime.
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.
There are three broker users: `ingester` (subscribes the device tree), `admin` 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/<IMSI>/…` is the source of truth ### Topic scheme: the device's `acrios/<IMSI>/…` 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 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. 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 is one broker user per device with `username == IMSI`, scoped to
`acrios/${{username}}/#` so the broker itself prevents a device from spoofing `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 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-<IMSI>`) carries a prefix the topic doesn't, so firmware's client-id (`acrcv-<IMSI>`) carries a prefix the topic doesn't, so
per-device scoping must key on username, not `${{clientid}}`. 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 EMQX terminates TLS itself on `:8883`; the plain MQTT listener stays on
MQTTS on `:8883`, and Caddy — rebuilt with the loopback for the co-located ingester. Certificates come from `security.acme`
[`caddy-l4`](https://github.com/mholt/caddy-l4) layer4 module — terminates TLS using the **HTTP-01** challenge: lego's built-in standalone server answers on
and proxies cleartext to the broker (`nix/modules/gebos-caddy.nix`). We reuse `:80` (nothing else listens there — no Caddy on this host), so no DNS API
Caddy here (rather than a native HiveMQ TLS listener + keystore) so there's one secrets are needed. `mqtt-ingest` opens **80 + 8883** only. The cert's
ACME story for the whole fleet. The wrinkle: caddy-l4's `tls` handler only `postRun` hook installs `fullchain.pem`/`privkey.pem` at fixed paths in the
*terminates* — it never obtains certs — so each `tcpProxy` site also emits a broker state dir, and EMQX re-reads the PEMs from disk (~every 120 s), so
companion HTTPS block purely to make Caddy's automatic HTTPS provision the cert renewals hot-reload without a restart or dropped connections. The key is RSA
that the layer4 handler then serves by SNI. That's why `mqtt-ingest` opens (`keyType = "rsa2048"`) because embedded sender TLS stacks often can't do
**80/443** (ACME challenge + renewal) alongside **8883**. The custom Caddy build ECDSA. Caddy remains only on app-host (stock build, HTTP sites).
is only used on hosts that actually have a `tcpProxy` site; app-host's
static/reverse-proxy sites stay on stock Caddy.
## Repo layout ## Repo layout
+1 -1
View File
@@ -22,7 +22,7 @@ let
modules.gebos-postgres modules.gebos-postgres
modules.gebos-supabase modules.gebos-supabase
modules.gebos-caddy modules.gebos-caddy
modules.gebos-hivemq modules.gebos-emqx
modules.gebos-ingester modules.gebos-ingester
modules.gebos-frontend modules.gebos-frontend
./${name}.nix ./${name}.nix
+11 -16
View File
@@ -4,11 +4,15 @@
networking.hostName = "mqtt-ingest"; networking.hostName = "mqtt-ingest";
services.gebos.secrets.ingester = true; services.gebos.secrets.ingester = true;
# Broker authn/authz credentials (file-RBAC: ingester + admin MQTT users). # Broker authn users (users.csv).
services.gebos.secrets.hivemq = true; 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; enable = true;
domain = "ingest.gebos.online";
# Persistent sessions on disk — single-node, devices reconnect on restart. # Persistent sessions on disk — single-node, devices reconnect on restart.
persistentSessions = true; persistentSessions = true;
tlsPort = 8883; tlsPort = 8883;
@@ -16,21 +20,12 @@
services.gebos.ingester = { services.gebos.ingester = {
enable = true; 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"; postgresUrl = "postgres://gebos_ingest@10.0.0.2:5432/postgres?sslmode=require";
# GEBOS_POSTGRES_PASSWORD sourced from /run/secrets/gebos-env (sops-nix). # GEBOS_POSTGRES_PASSWORD sourced from /run/secrets/gebos-env (sops-nix).
}; };
# Caddy (layer4) terminates TLS on :8883 and forwards cleartext MQTT to # 8883: MQTTS for senders. 80: ACME HTTP-01 challenge (lego's standalone
# HiveMQ's loopback listener on :1883. tcpProxy is the upstream backend. # solver binds it during issuance/renewal; idle otherwise).
services.gebos.caddy = { networking.firewall.allowedTCPPorts = [ 80 8883 ];
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 ];
} }
+1 -1
View File
@@ -4,6 +4,6 @@
gebos-supabase = import ./gebos-supabase.nix; gebos-supabase = import ./gebos-supabase.nix;
gebos-caddy = import ./gebos-caddy.nix; gebos-caddy = import ./gebos-caddy.nix;
gebos-postgres = import ./gebos-postgres.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; gebos-frontend = import ./gebos-frontend.nix;
} }
+3 -57
View File
@@ -3,24 +3,8 @@
let let
cfg = config.services.gebos.caddy; 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. # HTTP-style sites: static file server or reverse proxy.
httpSiteBlock = host: site: siteBlock = host: site:
if site ? staticRoot then '' if site ? staticRoot then ''
${host} { ${host} {
root * ${site.staticRoot} root * ${site.staticRoot}
@@ -35,40 +19,7 @@ let
encode zstd gzip encode zstd gzip
} }
'' ''
else throw "site `${host}` needs one of: staticRoot, upstream, tcpProxy"; else throw "site `${host}` needs one of: staticRoot, upstream";
# 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)} }
'';
in in
{ {
options.services.gebos.caddy = { options.services.gebos.caddy = {
@@ -82,13 +33,8 @@ in
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
services.caddy = { services.caddy = {
enable = true; enable = true;
package = if needsLayer4 then caddyWithL4 else pkgs.caddy;
email = "ops@gebos.online"; # TODO: confirm ACME contact email = "ops@gebos.online"; # TODO: confirm ACME contact
globalConfig = layer4Global; extraConfig = lib.concatStringsSep "\n" (lib.mapAttrsToList siteBlock cfg.sites);
extraConfig = lib.concatStringsSep "\n" (
lib.mapAttrsToList httpSiteBlock httpSites
++ lib.mapAttrsToList certSiteBlock tcpProxySites
);
}; };
}; };
} }
+238
View File
@@ -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/<IMSI>/<metric>` (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
'';
};
};
};
}
-276
View File
@@ -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" ''
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<extension-configuration>
<credentials-reload-interval>${toString cfg.rbac.reloadInterval}</credentials-reload-interval>
<password-type>${cfg.rbac.passwordType}</password-type>
</extension-configuration>
'';
# 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" ''
<?xml version="1.0"?>
<hivemq xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="config.xsd">
<listeners>
<tcp-listener>
<port>${toString cfg.port}</port>
<bind-address>${cfg.bindAddress}</bind-address>
</tcp-listener>
</listeners>
<persistence>
<mode>${if cfg.persistentSessions then "file" else "in-memory"}</mode>
</persistence>
<anonymous-usage-statistics>
<enabled>false</enabled>
</anonymous-usage-statistics>
</hivemq>
'';
# 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;
};
};
};
}
+42 -88
View File
@@ -17,75 +17,30 @@ let
]; ];
ingesterKeys = [ "ingester_postgres_password" ]; ingesterKeys = [ "ingester_postgres_password" ];
postgresAdminKeys = [ "postgres_admin_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/ # EMQX authn bootstrap CSV (bootstrap_type = plain). Seeds the broker's
# topic policy is structural. Devices publish under `acrios/<IMSI>/<metric>` # built-in auth database on first start. Broker users:
# (set by the sender firmware's mqtt_topic_base), so that is the namespace the # ingester — the Go ingester; SUBSCRIBE the whole device tree (see the ACL
# ingester consumes and the device role is scoped to. # 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: # Plaintext passwords are acceptable here because the file is sops-encrypted
# ingest — the Go ingester; SUBSCRIBE the whole device tree. # at rest and rendered 0400 into a tmpfs at runtime; passwords must not
# device — physical senders; full access under acrios/ (publish telemetry # contain commas (CSV).
# + the LWT/status and any downlink topics the firmware uses). emqxUsersCsv = ingesterPassword: adminPassword: devicePassword: ''
# superuser — operational break-glass. user_id,password,is_superuser
# ingester,${ingesterPassword},false
# Option A (current): one shared `device` user (`bender`) for the whole fleet. admin,${adminPassword},true
# Tenant isolation is enforced downstream by the ingester's IMSI→tenant bender,${devicePassword},false
# 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. # 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.supabase supabaseKeys
++ lib.optionals cfg.ingester ingesterKeys ++ lib.optionals cfg.ingester ingesterKeys
++ lib.optionals cfg.postgresAdmin postgresAdminKeys ++ lib.optionals cfg.postgresAdmin postgresAdminKeys
++ lib.optionals cfg.hivemq hivemqKeys; ++ lib.optionals cfg.emqx emqxKeys;
in in
{ {
options.services.gebos.secrets = { options.services.gebos.secrets = {
@@ -126,24 +81,24 @@ in
postgresAdmin = lib.mkEnableOption '' postgresAdmin = lib.mkEnableOption ''
Postgres bootstrap admin password applied via a one-shot ALTER ROLE unit.''; Postgres bootstrap admin password applied via a one-shot ALTER ROLE unit.'';
hivemq = lib.mkEnableOption '' emqx = lib.mkEnableOption ''
HiveMQ file-RBAC credentials.xml (ingester + admin MQTT users), rendered EMQX broker secrets: the authn bootstrap users.csv (ingester + admin +
0400/gebos-hivemq for the broker's file-RBAC extension.''; device MQTT users).'';
hivemqCredentialsFile = lib.mkOption { emqxBootstrapUsersFile = lib.mkOption {
type = lib.types.path; type = lib.types.path;
readOnly = true; readOnly = true;
description = '' description = ''
Path of the rendered HiveMQ file-RBAC credentials.xml consumed by the Path of the rendered EMQX authn bootstrap users.csv consumed by the
gebos-hivemq broker. Resolves to the sops-templated secret when sops gebos-emqx broker. Resolves to the sops-templated secret when sops
material is present and the hivemq toggle is on, else to a baked-in 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). dev-defaults file (so first-boot eval and local VMs work).
''; '';
default = default =
if hasSecrets && cfg.hivemq if hasSecrets && cfg.emqx
then config.sops.templates."hivemq-credentials".path then config.sops.templates."emqx-users.csv".path
else "${pkgs.writeText "hivemq-credentials-dev.xml" else "${pkgs.writeText "emqx-users-dev.csv"
(hivemqCredentialsXml "dev-ingester" "dev-admin" "dev-device")}"; (emqxUsersCsv "dev-ingester" "dev-admin" "dev-device")}";
}; };
envFile = lib.mkOption { envFile = lib.mkOption {
@@ -182,16 +137,15 @@ in
owner = "root"; owner = "root";
}; };
# Rendered as a standalone file (not env) — the file-RBAC extension reads # Rendered as a standalone file — the gebos-emqx preStart copies it into
# credentials.xml directly. Owned by the broker user so preStart can link # the broker state dir, chowned to the container's emqx uid.
# it into the extension's conf/ dir. templates."emqx-users.csv" = lib.mkIf cfg.emqx {
templates."hivemq-credentials" = lib.mkIf cfg.hivemq { content = emqxUsersCsv
content = hivemqCredentialsXml config.sops.placeholder.emqx_ingester_password
config.sops.placeholder.hivemq_ingester_password config.sops.placeholder.emqx_admin_password
config.sops.placeholder.hivemq_admin_password config.sops.placeholder.emqx_device_password;
config.sops.placeholder.hivemq_device_password;
mode = "0400"; mode = "0400";
owner = "gebos-hivemq"; owner = "root";
}; };
}; };
}; };
+34 -34
View File
@@ -15,62 +15,62 @@
# nix/secrets/secrets.yaml is absent. This file is only consumed at activation # nix/secrets/secrets.yaml is absent. This file is only consumed at activation
# time on real hosts. # time on real hosts.
# Supabase compose stack (consumed on app-host by gebos-supabase.service) # 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_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:j17p4R3JiLWlO4C8iYE2SXthzqB4kGbkypFMPTI30J2bI9c=,iv:AAamwue0Vs1Ns7OlujNtjU5IY43Z1NVJxJW5S9ftGbk=,tag:LWX3h0QMRBNNzUPWLxe3HQ==,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:zpeMG5L5GHlhGrHETnSY3cG+0rgC52HmSTDfdr0Il6cuEb0/KiUA9iyQOybT7Oo=,iv:NIMbKi14WlrTkLUt0ybBOFCklpBZ8TdFq+LkvqeElqA=,tag:rOQ1ETG13pt00kkCQQRJbQ==,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:MDacdgnZhC3mx/YUoQT8j2LGHyCYGmW2TMBdcSp91WmoWh4/WuYy5BtpBfgDLC1S+6taTPmfjA==,iv:a7mSPzr/JYs6036NGlI75NqFm0Y1WMTJJGp8YX1PIOs=,tag:Q3ygfgexT6Ju9GHIf+jHjw==,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:i9Pl87BtOICcKwJxDp9QH1CtPaUbTb3IObrdPg/xcsqSMim9+RyO+g==,iv:qDQH1//2ZKNXAgAmFir2TG6auUEh6AFE00a/gP/1+Aw=,tag:llYW7o9XK5vmAIs45QQJpQ==,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 (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 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] postgres_admin_password: ENC[AES256_GCM,data:j5XHA2ZepUq6rJMmIrKr88xjNLiMsJ+ng7CeQxNO3fVwydQhMW0hl0KB,iv:ZXilU07B3aJRQeIM0GVypFcGe4K7y5BsEOa2cIYzAOo=,tag:CEdzYR7NElODPYmTWdVccQ==,type:str]
# HiveMQ file-RBAC broker users (consumed on mqtt-ingest by gebos-hivemq). # 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- # PLAIN passwords (password-type defaults to PLAIN; the whole file is sops-
# encrypted). The ingester will authenticate as `ingester`; `admin` is for ops. # 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] emqx_ingester_password: ENC[AES256_GCM,data:pV1FIY5uNeZ2zkjB9dv3C5DP2AmeyaV55lqgizQoKGKq0w3PU1gV+xev9g==,iv:E+anGu88+fhBQRLp55tYghx5SvHVniolXaj6kE/3FrQ=,tag:iJpH+0zAowKq+t/xXR+NjA==,type:str]
hivemq_admin_password: ENC[AES256_GCM,data:I9g7/PcZb9TMf81SKNppMDf/8Je/9V+TSG932KGi+BXZjU7IsocM8HuY,iv:AlyAI44Ryl60W3HFxExC3i92WqMKA1myW+sLsBD50mo=,tag:JYO7YtDTouCZ5frTlDofBw==,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 # 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. # 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: sops:
age: age:
- enc: | - enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRL1VHOWVreFdsQ0JWK3o0 YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzaVAwall2bTE0Y09ZeUJn
aFZYd0JITHU0RHg2a09DeVdFRXFiUC95OHlFCnZzaDVGeFdJa2l6S1Zxbng1K2tr SFR2cHBDaFZtRmpodGV0ZHJsUzVuWGdNb213ClFFZ04xV0pTVzR5eFh0NjNsYWpZ
ZkEzQzdHeXRWWi9OUTdDYXJBeTZUTWcKLS0tIDBKdEZTSlZFeFRFSGtXclV0MkxR SU9IUzBoZWJaNjZmUW1Tc3NER2hpRDAKLS0tIG9BVnAzYW1jL1VZOHRLeTJvQzh0
RVFNbnQxbDdGSnpNendBbmJnditLMDAKNrn1/8gIngi0pM+DEf6ov/SGNQrXnFby Y3dtSnp5eGNnWnN0SWVYa0laNExtUWsKhiZOiy1otoGW+Y5HOvFz1nc549LTyvvl
1a2iAqBP+EDG0z52dcZns53Mj6XDgeh5Z2W5hDpITxDNhSVXEKmdHQ== hP0McG7/p0Awh9bQNzhvgQO88M4S0wIrDJ3P0Gu5/HKzKZXQ8EhSOA==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1cx8ul285kjkzmnhw6skdstnzrxnnme4xkflknzn7yhv52fgxqevqkd66cn recipient: age1cx8ul285kjkzmnhw6skdstnzrxnnme4xkflknzn7yhv52fgxqevqkd66cn
- enc: | - enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1Z3JpRnZIUG90b0xkdnU0 YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzRHpzVWtpQnFrWFBNV1c5
aHpBQm5BMjF1YVNhOHVQcXJEWnhxbU5FQUM0Cml3SUJvWjAydWFwcGVHQlo0SjhU UitrRVdQWGUyb0Z6WHVlMFhibjRhWXhFMmdzCnpyMW1QVUErMjBBUHJvMnN6RWJP
bzM4N3J0YjdBWURJbER5OENsR3VMYWsKLS0tIElQWTZqL1lscUZoOXArR014ZDVJ b0JselpJQkoxNDFtdU5TYlRBRmpUOGMKLS0tIDdQNGY3TUxhZGtkdzVZSlIwRXZK
Z0JidnBZSWt0QmdBb1l0MlUxSngyUjQK6EuSmh77Gcc9aow16fgeZV+/GlJw1Zaz NGJqWmJoSkFXMTVKOGNWMGlUN0o1czQKw3/naLb0zt5yHeITSJp9GKEOJfW5bSlL
aH3GqG7lw6hvn0HWSqJj8shTROqj+sWi1fUCIgMo1kcTBhKQC+ErEQ== CtQLx6Nn1WFOTcuoxgaf/xtUCBiSYSRg7pM9KwX/Pfu9gpvWjScUZA==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1wpl3vz60tlt880p5fmfedr8c59dm4kf0czsacv0w5my706twuf5q9p0x43 recipient: age1wpl3vz60tlt880p5fmfedr8c59dm4kf0czsacv0w5my706twuf5q9p0x43
- enc: | - enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjZEw0UC82azY2c3NBVk42 YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHL3hhWG5WL2N5THRaUFBz
M1RORGszMDVWbG5UVkRZOFVEL05hcVpzSlVvCklhOThsMDRGelBtTlFSZ3hWT01G SEo0SE0vQUZIZ05ad0ZUN3lrU3hHSTRISm1zCmVEdWxRSU8zSE1vODNwbVVub2VZ
ZkErVTdhT2t2dFpzOHI2Z1p3a2c0NTgKLS0tIFNpNE53VEVPczVSRTJNWlEyMU5X eWZqU3FiOG9mNHpBZjBQeEdhYkF6SmcKLS0tIEh1WkJIOXdDRmNMMHR2VE5jdXpU
RFp0VzZaeG5hcTNmZ21GTENBM09zZTQK1QswQul0EbX1UnuF1iL4WXXZN3nOSBjp WHRqTUFJQTJQTVFBSzBqVlVWY2ROU28KToql5OybiwITIS9GHieUGfgRILlhmUyM
inIv5inJhjcZ+Vo82NDeQcWnk+5Ns43sKqQjHf0XpfgyzRyjV6SHmw== abkD57fWwMzh1h5UNdUt/VMQEuYX9pK/QhLOrWNgZumk7JesbKRL3A==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1g57pznep69nlyv3sltz6k0sml47v68m03gh68jkqwcq4jdx68vtsvnefq0 recipient: age1g57pznep69nlyv3sltz6k0sml47v68m03gh68jkqwcq4jdx68vtsvnefq0
- enc: | - enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTRE4wSUxMMjA2cUNEQXFL YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqUTN5NENOcm1WRnltbzBE
ZE9ha2dsL0tKK2tTTTJpd25EUUFlQnJiQ1RBCjNTcUk2cGxvUWF5b2MrVi81bVZr RFgwY3l5cXVYeVptZG9UenBqc25GbCs2NFFFCkNvSytuOVUzRkV2R0doMFpvNndu
dDBoSFhRdDR0UnZsMWdRYnJhNGtMYjQKLS0tIFBiM3pweFgzRWNCSHozV01XbCs3 a1NyYmVVRE9LRmpwdis4Sk5lcnNoMkEKLS0tIFhTZjFhZVhFZ2FIKzg4dzJ1a25y
elhHMFE0dnZHU2pFaEJ2ekJ0U2FhQTAKNZGdxEMPf7pG11zS233wFYfDKySMqL/m cW84SytPUGZmMkVxdmFhMmQ2dy9XZlEK/S1drmtv8te8m2Zgpkm2l+RdOTTbhKCs
7syeofAs00911pOubBhRa1G6zvwblevDnZuAqhHBTiKfKtGrpCG4Jg== ziyUG3WkxBzihV7WpVyfKOu8XMJfLUsgiusjCeCsmkdq+UN+VBlZ/g==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d recipient: age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d
encrypted_regex: ^(supabase_|ingester_|postgres_admin_|hivemq_) encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_|porkbun_)
lastmodified: "2026-06-29T15:20:36Z" lastmodified: "2026-07-02T13:11:42Z"
mac: ENC[AES256_GCM,data:ds4eFZC9fjU+0NjvpaRKWq8HSbslFSPVGLlPtVGlvLm1kpZ8OtC6D6H4VtZZdIKvie/SahcA/3oMGMC66UDADrem7OGKWX5UFRrtuwrctenxteu6Q16sNqlmYBUSkd1YNAH3K4m4TR1DyvMNVC6hTbpCP2hVv7k9fy0o5UZT7bQ=,iv:vRBvBsypuurce0yq3Nu4zvqpzL7+84O3SkbOPoHllt8=,tag:ANZ7TYppVgX36SU1WvLlow==,type:str] mac: ENC[AES256_GCM,data:eJRwD6eGsmca+Gv3TP7JLSoCXlH34boilkxxEqRkbz8vNsCpEANZufet4Oes8vpNubU8yx509nrGo3SoC+MihvRaw/hfWqYOlXhlB0j6RsQuuG3LRQFVLJ1m1Q4zw5PYEcB/JHc4ORSIDrMb6nlemBc1dsDItIqyxlOqFnesUec=,iv:EBmK7XZ9ZDQT68FiItivu3QglhX0LfvMgw6n6qUhXUM=,tag:IqvQ4CVOrnWpQ3cE/mC/2w==,type:str]
version: 3.13.1 version: 3.13.1
+8 -7
View File
@@ -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 bootstrap (consumed on db-host by a one-shot ALTER ROLE unit)
postgres_admin_password: "replace-me-openssl-rand-hex-32" postgres_admin_password: "replace-me-openssl-rand-hex-32"
# HiveMQ file-RBAC broker users (consumed on mqtt-ingest by gebos-hivemq). # EMQX broker users (consumed on mqtt-ingest by gebos-emqx). Plaintext
# PLAIN passwords (password-type defaults to PLAIN; the whole file is sops- # passwords in a bootstrap users.csv (the whole file is sops-encrypted at
# encrypted). The ingester will authenticate as `ingester`; `admin` is for ops. # rest); no commas allowed. The ingester will authenticate as `ingester`;
hivemq_ingester_password: "replace-me-openssl-rand-hex-32" # `admin` is the operational break-glass superuser.
hivemq_admin_password: "replace-me-openssl-rand-hex-32" 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 # 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. # senders. Per-device users (Option B) are a TODO in gebos-emqx.nix.
hivemq_device_password: "replace-me-to-match-the-sender-mqtt_password" emqx_device_password: "replace-me-to-match-the-sender-mqtt_password"