diff --git a/.sops.yaml b/.sops.yaml
index 5670586..e8415d8 100644
--- a/.sops.yaml
+++ b/.sops.yaml
@@ -20,7 +20,7 @@ keys:
creation_rules:
# Supabase compose stack → app-host only.
- path_regex: nix/secrets/secrets\.yaml$
- encrypted_regex: ^(supabase_|ingester_|postgres_admin_)
+ encrypted_regex: ^(supabase_|ingester_|postgres_admin_|hivemq_)
key_groups:
- age:
- *dev_lars
diff --git a/README.md b/README.md
index b1b412c..1ad5496 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,26 @@ There are two distinct phases. **Initial provisioning** turns a blank box into a
NixOS host (`nixos-anywhere`, run once per machine). **Updates** push new
closures to a host that already runs NixOS (`deploy-rs`, run on every change).
+### SSH key setup (do this first)
+
+Both phases authenticate over SSH with `~/.ssh/larsnolden`, which is
+passphrase-protected. Load it into an `ssh-agent` once so the deploy tools can
+reuse it without prompting:
+
+```fish
+eval (ssh-agent -c) # bash/zsh: eval "$(ssh-agent -s)"
+ssh-add ~/.ssh/larsnolden # enter the passphrase once
+ssh-add -l # confirm the key is loaded
+```
+
+This is **required** for `deploy-rs`, not just a convenience: with
+`magicRollback = true` (see `nix/deploy.nix`) activation opens two concurrent SSH
+connections — the activation command and a rollback waiter. Without an agent,
+both race to read the passphrase from the terminal, one loses, and the deploy
+fails with `Permission denied (publickey,keyboard-interactive)` even though
+manual SSH and the copy step work. The agent serves the key to every connection,
+so no prompt is needed.
+
### Initial provisioning (`nixos-anywhere`)
`deploy-rs` only *updates* a machine that already runs NixOS — it copies a
@@ -109,6 +129,9 @@ Provision `db-host` first if you intend to deploy updates immediately afterward
once, copied to each host, activated with auto-rollback. Order: `db-host` →
`app-host` → `mqtt-ingest`.
+Running it by hand needs the key loaded into an `ssh-agent` first — see
+[SSH key setup](#ssh-key-setup-do-this-first) above.
+
```
nix run github:serokell/deploy-rs -- .#db-host # one host
nix run github:serokell/deploy-rs -- . # all hosts
diff --git a/ingester/main.go b/ingester/cmd/gebos-ingester/main.go
similarity index 100%
rename from ingester/main.go
rename to ingester/cmd/gebos-ingester/main.go
diff --git a/ingester/default.nix b/ingester/default.nix
index 03a5e9e..1a6cc50 100644
--- a/ingester/default.nix
+++ b/ingester/default.nix
@@ -8,6 +8,10 @@ buildGoModule {
# Set once `go mod tidy` has produced a real go.sum.
vendorHash = null;
+ # The main package lives in cmd/gebos-ingester/, so `go install` names the
+ # binary after that directory — matching mainProgram and the systemd unit.
+ subPackages = [ "cmd/gebos-ingester" ];
+
meta = {
description = "MQTT → Postgres telemetry ingester for Gebos";
mainProgram = "gebos-ingester";
diff --git a/nix/dev/process-compose.nix b/nix/dev/process-compose.nix
index d487c54..43f38fe 100644
--- a/nix/dev/process-compose.nix
+++ b/nix/dev/process-compose.nix
@@ -68,7 +68,7 @@ in
};
ingester = {
- command = "${pkgs.go}/bin/go run ./ingester";
+ command = "${pkgs.go}/bin/go run ./ingester/cmd/gebos-ingester";
environment = ingesterEnv;
depends_on = {
"db".condition = "process_healthy";
diff --git a/nix/hosts/mqtt-ingest.nix b/nix/hosts/mqtt-ingest.nix
index 25b27bd..8382bdf 100644
--- a/nix/hosts/mqtt-ingest.nix
+++ b/nix/hosts/mqtt-ingest.nix
@@ -4,6 +4,8 @@
networking.hostName = "mqtt-ingest";
services.gebos.secrets.ingester = true;
+ # Broker authn/authz credentials (file-RBAC: ingester + admin MQTT users).
+ services.gebos.secrets.hivemq = true;
services.gebos.hivemq = {
enable = true;
diff --git a/nix/modules/gebos-hivemq.nix b/nix/modules/gebos-hivemq.nix
index 43584f5..3c8df80 100644
--- a/nix/modules/gebos-hivemq.nix
+++ b/nix/modules/gebos-hivemq.nix
@@ -2,31 +2,274 @@
let
cfg = config.services.gebos.hivemq;
+
+ # HiveMQ CE — fetched from the upstream GitHub release and unpacked into the
+ # Nix store. The store tree is read-only; the runtime data/log/extension
+ # folders are redirected to the writable state dir (see ExecStart below).
+ hivemq-ce = pkgs.stdenvNoCC.mkDerivation rec {
+ pname = "hivemq-ce";
+ version = "2026.5";
+
+ src = pkgs.fetchurl {
+ url = "https://github.com/hivemq/hivemq-community-edition/releases/download/${version}/hivemq-ce-${version}.zip";
+ hash = "sha256-/QBeU17KC6/7CF4RsVEohdBs4AeG+WFwHHZEmBb33sU=";
+ };
+
+ nativeBuildInputs = [ pkgs.unzip ];
+ dontConfigure = true;
+ dontBuild = true;
+
+ installPhase = ''
+ runHook preInstall
+ mkdir -p "$out"
+ cp -r . "$out/"
+ runHook postInstall
+ '';
+
+ meta = with lib; {
+ description = "HiveMQ Community Edition — open-source MQTT 3.x / 5 broker";
+ homepage = "https://github.com/hivemq/hivemq-community-edition";
+ license = licenses.asl20;
+ platforms = platforms.linux;
+ };
+ };
+
+ # HiveMQ file-RBAC extension — adds username/password authentication and
+ # topic-level authorization, replacing the insecure hivemq-allow-all that
+ # ships in the broker package. The zip unpacks to a single top-level
+ # `hivemq-file-rbac-extension/` folder which we keep verbatim in $out.
+ hivemq-file-rbac = pkgs.stdenvNoCC.mkDerivation rec {
+ pname = "hivemq-file-rbac-extension";
+ version = "4.6.15";
+
+ src = pkgs.fetchurl {
+ url = "https://github.com/hivemq/hivemq-file-rbac-extension/releases/download/${version}/hivemq-file-rbac-extension-${version}.zip";
+ hash = "sha256-R6PM+qAvEKMHdX3FTR0CfR8yarJrc2WeFAEu4eXH3Ps=";
+ };
+
+ nativeBuildInputs = [ pkgs.unzip ];
+ dontConfigure = true;
+ dontBuild = true;
+
+ # stdenv unpacks the single top-level archive dir and cds into it, so $PWD
+ # already holds the extension's files (jar, conf/, hivemq-extension.xml).
+ installPhase = ''
+ runHook preInstall
+ mkdir -p "$out/hivemq-file-rbac-extension"
+ cp -r . "$out/hivemq-file-rbac-extension/"
+ runHook postInstall
+ '';
+
+ meta = with lib; {
+ description = "HiveMQ file-based role-based access control extension";
+ homepage = "https://github.com/hivemq/hivemq-file-rbac-extension";
+ license = licenses.asl20;
+ platforms = platforms.linux;
+ };
+ };
+
+ stateDir = "/var/lib/gebos-hivemq";
+
+ # Non-secret RBAC extension config (reload interval + password format). The
+ # users/roles live in the secret credentials.xml, symlinked in at runtime.
+ rbacConfigXml = pkgs.writeText "hivemq-file-rbac-config.xml" ''
+
+
+ ${toString cfg.rbac.reloadInterval}
+ ${cfg.rbac.passwordType}
+
+ '';
+
+ # config.xml rendered from the module options. The local TCP listener is what
+ # the ingester (and, eventually, the Caddy TLS frontend) connects to.
+ configXml = pkgs.writeText "hivemq-config.xml" ''
+
+
+
+
+ ${toString cfg.port}
+ ${cfg.bindAddress}
+
+
+
+ ${if cfg.persistentSessions then "file" else "in-memory"}
+
+
+ false
+
+
+ '';
+
+ # Read-only conf folder: our generated config.xml plus the upstream
+ # logback.xml / config.xsd so HiveMQ logs and validates as shipped.
+ confDir = pkgs.runCommand "hivemq-conf" { } ''
+ mkdir -p "$out"
+ cp ${configXml} "$out/config.xml"
+ cp ${hivemq-ce}/conf/logback.xml "$out/logback.xml"
+ cp ${hivemq-ce}/conf/config.xsd "$out/config.xsd"
+ '';
+
+ # run.sh's JVM flags, reproduced so we can invoke java directly and point
+ # HiveMQ at the writable state dir via -Dhivemq.*.folder.
+ javaOpts = lib.concatStringsSep " " [
+ "-Djava.net.preferIPv4Stack=true"
+ "--add-opens java.base/java.lang=ALL-UNNAMED"
+ "--add-opens java.base/java.nio=ALL-UNNAMED"
+ "--add-opens java.base/sun.nio.ch=ALL-UNNAMED"
+ "--add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED"
+ "--add-exports java.base/jdk.internal.misc=ALL-UNNAMED"
+ "-Djava.security.egd=file:/dev/./urandom"
+ "-Duser.language=en -Duser.region=US"
+ "-XX:+CrashOnOutOfMemoryError"
+ "-XX:+HeapDumpOnOutOfMemoryError"
+ "-XX:HeapDumpPath=${stateDir}/log/heap-dump.hprof"
+ "-XX:ErrorFile=${stateDir}/log/hs_err_pid%p.log"
+ ];
in
{
options.services.gebos.hivemq = {
enable = lib.mkEnableOption "HiveMQ CE single-node broker";
+
+ package = lib.mkOption {
+ type = lib.types.package;
+ default = hivemq-ce;
+ defaultText = lib.literalExpression "pkgs.hivemq-ce (vendored)";
+ description = "HiveMQ CE package to run.";
+ };
+
+ port = lib.mkOption {
+ type = lib.types.port;
+ default = 1883;
+ description = "Plain MQTT TCP listener port (what the ingester connects to).";
+ };
+
+ bindAddress = lib.mkOption {
+ type = lib.types.str;
+ default = "127.0.0.1";
+ description = ''
+ Address the MQTT listener binds to. Defaults to loopback: external
+ clients reach the broker through the Caddy TLS frontend, not directly.
+ '';
+ };
+
persistentSessions = lib.mkOption {
type = lib.types.bool;
default = true;
+ description = ''
+ Whether client sessions and queued messages survive a restart
+ (file persistence) or are kept only in memory.
+ '';
};
+
tlsPort = lib.mkOption {
type = lib.types.port;
default = 8883;
+ description = ''
+ Externally advertised MQTTS port. TLS is currently terminated by the
+ Caddy frontend (see gebos-caddy), not by HiveMQ directly, so this value
+ is informational until a keystore is wired into the broker.
+ '';
+ };
+
+ rbac = {
+ package = lib.mkOption {
+ type = lib.types.package;
+ default = hivemq-file-rbac;
+ defaultText = lib.literalExpression "pkgs.hivemq-file-rbac (vendored)";
+ description = "HiveMQ file-RBAC authn/authz extension package.";
+ };
+
+ passwordType = lib.mkOption {
+ type = lib.types.enum [ "HASHED" "PLAIN" ];
+ default = "PLAIN";
+ description = ''
+ Whether passwords in the credentials file are PBKDF2 HASHED or PLAIN
+ text. PLAIN is acceptable here because the credentials file is a
+ sops-encrypted secret at rest and rendered 0400/gebos-hivemq into a
+ tmpfs at runtime; switch to HASHED for defence-in-depth.
+ '';
+ };
+
+ reloadInterval = lib.mkOption {
+ type = lib.types.ints.positive;
+ default = 60;
+ description = "Seconds between credentials.xml reloads by the extension.";
+ };
+
+ credentialsFile = lib.mkOption {
+ type = lib.types.path;
+ default = config.services.gebos.secrets.hivemqCredentialsFile;
+ defaultText = lib.literalExpression "config.services.gebos.secrets.hivemqCredentialsFile";
+ description = ''
+ Path to the file-RBAC credentials.xml (users, roles, topic
+ permissions). Defaults to the sops-rendered secret; symlinked into
+ the extension's conf/ dir so it never lands in the writable state dir.
+ '';
+ };
};
};
config = lib.mkIf cfg.enable {
- # TODO: package HiveMQ CE (download tarball + JRE wrapper) and wire as a
- # systemd unit. Until then this module only declares options so dependent
- # hosts can be evaluated.
+ 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";
- ExecStart = "${pkgs.coreutils}/bin/true"; # TODO: real HiveMQ command
+ User = "gebos-hivemq";
+ Group = "gebos-hivemq";
+ StateDirectory = "gebos-hivemq";
+ WorkingDirectory = stateDir;
+ ExecStart = ''
+ ${pkgs.jre_headless}/bin/java \
+ -Dhivemq.home=${cfg.package} \
+ -Dhivemq.config.folder=${confDir} \
+ -Dhivemq.log.folder=${stateDir}/log \
+ -Dhivemq.data.folder=${stateDir}/data \
+ -Dhivemq.extensions.folder=${stateDir}/extensions \
+ ${javaOpts} \
+ -jar ${cfg.package}/bin/hivemq.jar
+ '';
Restart = "on-failure";
+ RestartSec = "5s";
+ # HiveMQ exits 143 on SIGTERM shutdown — treat that as success.
+ SuccessExitStatus = "143";
+ KillSignal = "SIGTERM";
+ LimitNOFILE = 65536;
};
};
};
diff --git a/nix/modules/gebos-secrets.nix b/nix/modules/gebos-secrets.nix
index 291c077..753587e 100644
--- a/nix/modules/gebos-secrets.nix
+++ b/nix/modules/gebos-secrets.nix
@@ -17,6 +17,45 @@ let
];
ingesterKeys = [ "ingester_postgres_password" ];
postgresAdminKeys = [ "postgres_admin_password" ];
+ hivemqKeys = [ "hivemq_ingester_password" "hivemq_admin_password" ];
+
+ # HiveMQ file-RBAC credentials.xml. Only the passwords are secret; the role/
+ # topic policy is structural. `ingester` may subscribe to the whole telemetry
+ # tree (t//d//); `admin` is an operational superuser.
+ hivemqCredentialsXml = ingesterPassword: adminPassword: ''
+
+
+
+
+ ingester
+ ${ingesterPassword}
+ ingest
+
+
+ admin
+ ${adminPassword}
+ superuser
+
+
+
+
+ ingest
+
+
+ t/#
+ SUBSCRIBE
+
+
+
+
+ superuser
+
+ #
+
+
+
+
+ '';
# Render an env file body from a list of (key, envVarName) pairs.
envBody = pairs: lib.concatStringsSep "\n" (map
@@ -41,7 +80,8 @@ let
enabledKeys =
lib.optionals cfg.supabase supabaseKeys
++ lib.optionals cfg.ingester ingesterKeys
- ++ lib.optionals cfg.postgresAdmin postgresAdminKeys;
+ ++ lib.optionals cfg.postgresAdmin postgresAdminKeys
+ ++ lib.optionals cfg.hivemq hivemqKeys;
in
{
options.services.gebos.secrets = {
@@ -55,6 +95,26 @@ in
postgresAdmin = lib.mkEnableOption ''
Postgres bootstrap admin password applied via a one-shot ALTER ROLE unit.'';
+ hivemq = lib.mkEnableOption ''
+ HiveMQ file-RBAC credentials.xml (ingester + admin MQTT users), rendered
+ 0400/gebos-hivemq for the broker's file-RBAC extension.'';
+
+ hivemqCredentialsFile = lib.mkOption {
+ type = lib.types.path;
+ readOnly = true;
+ description = ''
+ Path of the rendered HiveMQ file-RBAC credentials.xml consumed by the
+ gebos-hivemq broker. Resolves to the sops-templated secret when sops
+ material is present and the hivemq toggle is on, else to a baked-in
+ dev-defaults file (so first-boot eval and local VMs work).
+ '';
+ default =
+ if hasSecrets && cfg.hivemq
+ then config.sops.templates."hivemq-credentials".path
+ else "${pkgs.writeText "hivemq-credentials-dev.xml"
+ (hivemqCredentialsXml "dev-ingester" "dev-admin")}";
+ };
+
envFile = lib.mkOption {
type = lib.types.path;
readOnly = true;
@@ -90,6 +150,17 @@ in
mode = "0400";
owner = "root";
};
+
+ # Rendered as a standalone file (not env) — the file-RBAC extension reads
+ # credentials.xml directly. Owned by the broker user so preStart can link
+ # it into the extension's conf/ dir.
+ templates."hivemq-credentials" = lib.mkIf cfg.hivemq {
+ content = hivemqCredentialsXml
+ config.sops.placeholder.hivemq_ingester_password
+ config.sops.placeholder.hivemq_admin_password;
+ mode = "0400";
+ owner = "gebos-hivemq";
+ };
};
};
}
diff --git a/nix/secrets/secrets.yaml.example b/nix/secrets/secrets.yaml.example
index f9b7614..4df2543 100644
--- a/nix/secrets/secrets.yaml.example
+++ b/nix/secrets/secrets.yaml.example
@@ -27,3 +27,9 @@ ingester_postgres_password: "replace-me-openssl-rand-hex-32"
# Postgres bootstrap (consumed on db-host by a one-shot ALTER ROLE unit)
postgres_admin_password: "replace-me-openssl-rand-hex-32"
+
+# HiveMQ file-RBAC broker users (consumed on mqtt-ingest by gebos-hivemq).
+# PLAIN passwords (password-type defaults to PLAIN; the whole file is sops-
+# encrypted). The ingester will authenticate as `ingester`; `admin` is for ops.
+hivemq_ingester_password: "replace-me-openssl-rand-hex-32"
+hivemq_admin_password: "replace-me-openssl-rand-hex-32"