switch to emqx
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 ];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user