Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbd3ecd84a |
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/mosquitto
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
|
||||
+24
-38
@@ -20,8 +20,6 @@ import { readFileSync } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
export interface MqttConn {
|
||||
@@ -143,19 +141,14 @@ export class MosquittoClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or reset to a known state) a client granted exactly these topic filters, idempotently.
|
||||
* The grant is a same-named role carrying, for every filter, publish, receive and subscribe — and
|
||||
* nothing else: an ACL the role carries that the filters no longer name is removed, so narrowing a
|
||||
* consumer's `topics` narrows what it may do. By default the filters are the consumer's own
|
||||
* subtree, `<as>/#` (see topics.ts). Called again for an existing client, it resets the password
|
||||
* and re-asserts the ACLs.
|
||||
*
|
||||
* Only the role named for this client is ever changed. A client or role the mesh did not make —
|
||||
* a device carried from the predecessor's password file, its `legacy-full-access` role — is never
|
||||
* read, changed or removed here.
|
||||
* Create (or reset to a known state) a client scoped to one topic namespace, idempotently. The
|
||||
* client is confined to `<prefix>/#` by a same-named role: it may publish to, subscribe to and
|
||||
* receive on exactly its own subtree and nothing else — the MQTT analog of redis's keyspace-scoped
|
||||
* ACL user. Called again for an existing client, it resets the password and re-asserts the ACLs.
|
||||
*/
|
||||
async createScopedClient(username: string, password: string, filters: readonly string[]): Promise<void> {
|
||||
async createScopedClient(username: string, password: string, topicPrefix: string): Promise<void> {
|
||||
const role = username; // one role per client, named for it
|
||||
const pattern = `${topicPrefix}/#`;
|
||||
|
||||
if (await this.clientExists(username)) {
|
||||
await this.ctl("setClientPassword", username, password);
|
||||
@@ -168,18 +161,17 @@ export class MosquittoClient {
|
||||
await this.ctl("createClient", username, "-p", password);
|
||||
}
|
||||
|
||||
// createRole and addRoleACL are one-shot: each rejects with an "already exists" when re-run
|
||||
// against a role/ACL it created on a previous reconcile. That rejection is the intended terminal
|
||||
// state, so it is swallowed.
|
||||
// A role carrying exactly this client's topic ACLs. createRole, addRoleACL and addClientRole are
|
||||
// all one-shot: each rejects with an "already exists" when re-run against a role/ACL/binding it
|
||||
// created on a previous reconcile. That rejection is the intended terminal state — the ACL is
|
||||
// deterministic (`<prefix>/#`, allow), so re-adding the identical entry is a no-op — so it is
|
||||
// swallowed. (Until the exit code was fixed this was invisible: the tool returned 0 and the
|
||||
// rejection was lost; now it surfaces, and each of these adds must tolerate its own idempotent
|
||||
// re-run explicitly.)
|
||||
await ignoreExisting(this.ctl("createRole", role));
|
||||
const wanted = wantedAcls(filters);
|
||||
const current = parseRoleAcls(await this.ctl("getRole", role));
|
||||
for (const acl of missingAcls(current, wanted)) {
|
||||
await ignoreExisting(this.ctl("addRoleACL", role, acl.type, acl.topic, "allow"));
|
||||
}
|
||||
// What the consumer no longer asks for — added before it narrowed its topics — is taken away.
|
||||
for (const acl of staleAcls(current, wanted)) {
|
||||
await ignoreMissing(this.ctl("removeRoleACL", role, acl.type, acl.topic));
|
||||
for (const acl of ["publishClientSend", "publishClientReceive", "subscribePattern"]) {
|
||||
// allow (1) this client to send to, receive on, and subscribe under its own subtree.
|
||||
await ignoreExisting(this.ctl("addRoleACL", role, acl, pattern, "allow"));
|
||||
}
|
||||
// Bind the role only when it is not already bound — addClientRole is the one call whose
|
||||
// idempotent re-run cannot be recognised by message (see clientHasRole).
|
||||
@@ -189,31 +181,25 @@ export class MosquittoClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a consumer's client accepts exactly this password, still carries its own role, and that
|
||||
* role grants exactly these filters. Read-only. The password is checked the way the consumer is
|
||||
* checked, by an MQTT CONNECT as it, and the broker's CONNACK code is the answer: 0 accepted,
|
||||
* 4 bad credentials, 5 not authorised. Nothing rides on argv. An unreachable broker rejects
|
||||
* (novox/hq issue 120).
|
||||
* Whether a consumer's client accepts exactly this password and still carries its own role.
|
||||
* Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it,
|
||||
* and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised.
|
||||
* Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120).
|
||||
*/
|
||||
async holdsClient(username: string, password: string, filters: readonly string[]): Promise<boolean> {
|
||||
async holdsClient(username: string, password: string): Promise<boolean> {
|
||||
const code = await mqttConnack(this.conn.host, this.conn.port, username, password);
|
||||
if (code === 4 || code === 5) return false;
|
||||
if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`);
|
||||
// The role, asked directly: only "not found" means absent. Any other failure to ask rejects,
|
||||
// unlike clientHasRole, which reads every failure as "no role".
|
||||
let client: string;
|
||||
let role: string;
|
||||
let out: string;
|
||||
try {
|
||||
client = await this.ctl("getClient", username);
|
||||
role = await this.ctl("getRole", username);
|
||||
out = await this.ctl("getClient", username);
|
||||
} catch (err) {
|
||||
if (/not\s*found|does not exist|no such/i.test(String(err))) return false;
|
||||
throw err;
|
||||
}
|
||||
if (!new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(client)) return false;
|
||||
const current = parseRoleAcls(role);
|
||||
const wanted = wantedAcls(filters);
|
||||
return missingAcls(current, wanted).length === 0 && staleAcls(current, wanted).length === 0;
|
||||
return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out);
|
||||
}
|
||||
|
||||
/** Remove a client and the per-client role created for it, idempotently. */
|
||||
|
||||
@@ -20,19 +20,16 @@
|
||||
"mosquitto.topic.deprovisioned"
|
||||
],
|
||||
"serves": {
|
||||
"mqtt-topic": {
|
||||
"scheme": "mqtt",
|
||||
"port": 1883
|
||||
}
|
||||
"mqtt-topic": {}
|
||||
},
|
||||
"receives": {
|
||||
"mqtt-topic": "${dir:grants}/mesh.json"
|
||||
"mqtt-topic": "/var/lib/mosquitto-module/grants/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"mqtt-topic": "${dir:grants}"
|
||||
"mqtt-topic": "/var/lib/mosquitto-module/grants"
|
||||
},
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/mesh/mosquitto/admin",
|
||||
"admin": "/var/lib/mosquitto-module/admin.secret",
|
||||
"broker": "/var/lib/mesh/mosquitto/broker"
|
||||
},
|
||||
"listens": [
|
||||
@@ -61,24 +58,26 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
"path": "/var/lib/mosquitto-module",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"id": "grants-dir",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mosquitto-module/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/mosquitto/data",
|
||||
"mode": "0700",
|
||||
"owner": "1883:1883"
|
||||
},
|
||||
{
|
||||
"id": "server-conf",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/mosquitto.conf",
|
||||
"path": "/var/lib/mosquitto-module/mosquitto.conf",
|
||||
"mode": "0600",
|
||||
"owner": "1883:1883",
|
||||
"content": "persistence true\npersistence_location /mosquitto/data\n\nlog_dest stdout\nlog_type warning\nlog_type error\nlog_type notice\n\n# Every client authenticates; identities and their per-topic ACLs are managed\n# at runtime by the dynamic security plugin, whose store the plugin itself owns.\nallow_anonymous false\nplugin /usr/lib/mosquitto_dynamic_security.so\nplugin_opt_config_file /mosquitto/data/dynamic-security.json\n\n# MQTT listener\nlistener 1883\n\n# MQTT-over-WebSockets listener\nlistener 8081\nprotocol websockets\n"
|
||||
@@ -94,8 +93,8 @@
|
||||
"name": "mosquitto-bootstrap",
|
||||
"run-once": true,
|
||||
"volumes": [
|
||||
"${dir:data}:/mosquitto/data",
|
||||
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro"
|
||||
"/services/mosquitto/data:/mosquitto/data",
|
||||
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_MQTT": "mosquitto:1883",
|
||||
@@ -113,15 +112,15 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "mosquitto",
|
||||
"image": "eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408",
|
||||
"image": "eclipse-mosquitto@sha256:6f8d8a947c506f8a2290ec65cd4bd2bc7cb4d43fb5f6271f861cb013e2ef9797",
|
||||
"network": "mosquitto",
|
||||
"ports": [
|
||||
"1883",
|
||||
"8081"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:data}:/mosquitto/data",
|
||||
"${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
|
||||
"/services/mosquitto/data:/mosquitto/data",
|
||||
"/var/lib/mosquitto-module/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -131,8 +130,8 @@
|
||||
"network": "mosquitto",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro",
|
||||
"${dir:grants}:/var/lib/mosquitto-module/grants:ro",
|
||||
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro"
|
||||
"/var/lib/mosquitto-module/grants:/var/lib/mosquitto-module/grants:ro",
|
||||
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
{
|
||||
"name": "@novox/module-mosquitto",
|
||||
"version": "0.1.0",
|
||||
"description": "mosquitto \u2014 provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).",
|
||||
"description": "mosquitto — provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
|
||||
@@ -5,14 +5,7 @@
|
||||
//
|
||||
// The `mqtt-topic` interface: a consumer connects as `as` with the password the mesh minted, and
|
||||
// publishes and subscribes under `<as>/#`, isolated from every other consumer by a Dynamic Security
|
||||
// role scoped to exactly that subtree — unless it contributed `topics`, the MQTT topic filters its
|
||||
// work needs (a home-automation hub needs the devices' topics); then the role grants exactly those
|
||||
// (topics.ts). A list that is not valid topic filters is refused, and the consumer is not created
|
||||
// or changed until it is fixed.
|
||||
//
|
||||
// What a consumer is told (its binding): `at` — the broker's machine — and `port`, the machine port
|
||||
// of the MQTT listener (the manifest's `serves`); `as` is its login, and its copy of the password is
|
||||
// the pair credential the mesh delivers to it.
|
||||
// role scoped to exactly that subtree.
|
||||
//
|
||||
// **The login and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives the
|
||||
// login and hands it to both ends so they agree, and mints the password and delivers a copy to each.
|
||||
@@ -22,7 +15,6 @@
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { MosquittoClient } from "../client.js";
|
||||
import { topicFilters } from "../topics.js";
|
||||
|
||||
const mosquitto = MosquittoClient.fromEnv();
|
||||
|
||||
@@ -37,20 +29,13 @@ async function announce(type: string, body: Record<string, string>): Promise<voi
|
||||
|
||||
runProvisioner("mqtt-topic", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
// By default the consumer's own subtree, so one cannot read another's topics; what it
|
||||
// contributed as `topics` otherwise.
|
||||
const granted = topicFilters(p.values, p.as);
|
||||
if ("problem" in granted) {
|
||||
// Thrown, so the harness logs it and retries: the consumer stays as it was (or absent) until
|
||||
// its contribution is valid, rather than being given a grant it did not ask for.
|
||||
throw new Error(`${p.as}: ${granted.problem}`);
|
||||
}
|
||||
await mosquitto.createScopedClient(p.as, p.password, granted.filters);
|
||||
// The topic subtree is scoped to the consumer's own login, so one cannot read another's topics.
|
||||
const topicPrefix = p.as;
|
||||
await mosquitto.createScopedClient(p.as, p.password, topicPrefix);
|
||||
await announce("topic.provisioned", {
|
||||
consumer: p.consumer ?? "",
|
||||
username: p.as,
|
||||
topicPrefix: granted.own ? p.as : "",
|
||||
topics: granted.filters.join(" "),
|
||||
topicPrefix,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -61,9 +46,6 @@ runProvisioner("mqtt-topic", {
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
const granted = topicFilters(p.values, p.as);
|
||||
// An invalid list was never applied; create refuses it again, loudly, on every pass.
|
||||
if ("problem" in granted) return false;
|
||||
return mosquitto.holdsClient(p.as, p.password, granted.filters);
|
||||
return mosquitto.holdsClient(p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
// What a consumer of mqtt-topic is granted (topics.ts): its own subtree unless it contributed
|
||||
// `topics`; a contributed list is granted exactly, refused whole when it is not topic filters; and
|
||||
// the role is brought to exactly the wanted ACLs — missing ones added, stale ones removed — read from
|
||||
// `mosquitto_ctrl dynsec getRole` as eclipse-mosquitto 2.1.2 prints it.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { filterProblem, missingAcls, parseRoleAcls, staleAcls, topicFilters, wantedAcls } from "../topics.ts";
|
||||
|
||||
test("a consumer that contributed nothing gets its own subtree", () => {
|
||||
assert.deepEqual(topicFilters({}, "mesh_ace_hass"), { ok: true, filters: ["mesh_ace_hass/#"], own: true });
|
||||
assert.deepEqual(topicFilters(undefined, "x"), { ok: true, filters: ["x/#"], own: true });
|
||||
// Settings merge into every contribution: keys that are not `topics` change nothing.
|
||||
assert.deepEqual(topicFilters({ endpoints: { web: {} } }, "x"), { ok: true, filters: ["x/#"], own: true });
|
||||
});
|
||||
|
||||
test("a contributed list is granted exactly, duplicates once", () => {
|
||||
assert.deepEqual(topicFilters({ topics: ["#"] }, "x"), { ok: true, filters: ["#"], own: false });
|
||||
assert.deepEqual(topicFilters({ topics: ["stat/+/POWER", "tele/#", "tele/#", "/octoprint/x"] }, "x"), {
|
||||
ok: true,
|
||||
filters: ["stat/+/POWER", "tele/#", "/octoprint/x"],
|
||||
own: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("a list that is not topic filters is refused whole", () => {
|
||||
for (const topics of [[], "#", [""], ["a/#/b"], ["a#"], ["a/b+"], [42], ["a\u0000b"], {}]) {
|
||||
const out = topicFilters({ topics } as Record<string, unknown>, "x");
|
||||
assert.equal(out.ok, false, JSON.stringify(topics));
|
||||
}
|
||||
assert.equal(filterProblem("+/+/#"), undefined);
|
||||
assert.equal(filterProblem("#"), undefined);
|
||||
});
|
||||
|
||||
const GET_ROLE = `Warning: You are running mosquitto_ctrl without encryption.
|
||||
This means all of the configuration changes you are making are visible on the network, including passwords.
|
||||
|
||||
Rolename: u1
|
||||
ACLs: publishClientSend : allow : # (priority: 0)
|
||||
subscribePattern : allow : u1/# (priority: 0)
|
||||
publishClientReceive : deny : secret topic/with space (priority: -1)
|
||||
`;
|
||||
|
||||
test("getRole's ACL lines are read, the warning and headings are not", () => {
|
||||
assert.deepEqual(parseRoleAcls(GET_ROLE), [
|
||||
{ type: "publishClientSend", allow: true, topic: "#" },
|
||||
{ type: "subscribePattern", allow: true, topic: "u1/#" },
|
||||
{ type: "publishClientReceive", allow: false, topic: "secret topic/with space" },
|
||||
]);
|
||||
assert.deepEqual(parseRoleAcls("Rolename: empty\nACLs:\n"), []);
|
||||
});
|
||||
|
||||
test("the role is brought to exactly the wanted ACLs", () => {
|
||||
const current = parseRoleAcls(GET_ROLE);
|
||||
const wanted = wantedAcls(["u1/#"]);
|
||||
assert.deepEqual(wanted, [
|
||||
{ type: "publishClientSend", allow: true, topic: "u1/#" },
|
||||
{ type: "publishClientReceive", allow: true, topic: "u1/#" },
|
||||
{ type: "subscribePattern", allow: true, topic: "u1/#" },
|
||||
]);
|
||||
assert.deepEqual(missingAcls(current, wanted), [
|
||||
{ type: "publishClientSend", allow: true, topic: "u1/#" },
|
||||
{ type: "publishClientReceive", allow: true, topic: "u1/#" },
|
||||
]);
|
||||
assert.deepEqual(staleAcls(current, wanted), [
|
||||
{ type: "publishClientSend", allow: true, topic: "#" },
|
||||
{ type: "publishClientReceive", allow: false, topic: "secret topic/with space" },
|
||||
]);
|
||||
assert.deepEqual(staleAcls(wanted, wanted), []);
|
||||
assert.deepEqual(missingAcls(wanted, wanted), []);
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
// Which topics a consumer of `mqtt-topic` may use — the one choice a consumer makes about its grant.
|
||||
//
|
||||
// **By default, its own subtree and nothing else.** A consumer connects as the login the mesh derived
|
||||
// (`as`) and may publish, receive and subscribe under `<as>/#` — isolated from every other consumer,
|
||||
// which is the point of a per-consumer client (novox/hq ADR 0039/0048).
|
||||
//
|
||||
// **A consumer whose work IS the shared topic space says so.** Home Assistant discovers devices
|
||||
// under `homeassistant/#` and `tasmota/discovery/#` and follows whatever state topics they announce;
|
||||
// Node-RED's flows subscribe to the topics devices publish on (`stat/<device>/POWER`, …). Confined
|
||||
// to `<as>/#` neither could do its job. So a consumer contributes `topics` to its `mqtt-topic`
|
||||
// requirement — a list of MQTT topic filters — and the provisioner grants exactly those, both ways.
|
||||
// Because assignment settings merge into every contribution, an operator narrows (or widens) the
|
||||
// list per machine with the same key, without editing a manifest.
|
||||
//
|
||||
// Pure, so it is tested without a broker (test/topics.test.ts).
|
||||
|
||||
/** The dynsec ACL types a granted filter carries: send to it, receive from it, subscribe to it. */
|
||||
export const GRANTED_ACL_TYPES = ["publishClientSend", "publishClientReceive", "subscribePattern"] as const;
|
||||
|
||||
/** One ACL on a role, as `mosquitto_ctrl dynsec getRole` reports it. */
|
||||
export interface Acl {
|
||||
type: string;
|
||||
allow: boolean;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export type Filters = { ok: true; filters: string[]; own: boolean } | { ok: false; problem: string };
|
||||
|
||||
/**
|
||||
* The topic filters a consumer is granted: what it contributed as `topics`, or its own subtree when
|
||||
* it contributed nothing. Refused — never silently narrowed or widened — when the list is not a
|
||||
* list of valid MQTT topic filters: a grant that quietly differs from what was asked is a consumer
|
||||
* that fails somewhere far from the cause.
|
||||
*/
|
||||
export function topicFilters(values: Readonly<Record<string, unknown>> | undefined, as: string): Filters {
|
||||
const given = values?.topics;
|
||||
if (given === undefined || given === null) {
|
||||
return { ok: true, filters: [`${as}/#`], own: true };
|
||||
}
|
||||
if (!Array.isArray(given) || given.length === 0) {
|
||||
return { ok: false, problem: `topics must be a non-empty list of MQTT topic filters, not ${JSON.stringify(given)}` };
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const f of given) {
|
||||
if (typeof f !== "string") {
|
||||
return { ok: false, problem: `topics holds ${JSON.stringify(f)}, which is not a topic filter` };
|
||||
}
|
||||
const problem = filterProblem(f);
|
||||
if (problem) return { ok: false, problem: `topic filter ${JSON.stringify(f)}: ${problem}` };
|
||||
if (!out.includes(f)) out.push(f);
|
||||
}
|
||||
return { ok: true, filters: out, own: out.length === 1 && out[0] === `${as}/#` };
|
||||
}
|
||||
|
||||
/** Why a string is not a valid MQTT topic filter (MQTT 3.1.1 §4.7), or undefined when it is one. */
|
||||
export function filterProblem(filter: string): string | undefined {
|
||||
if (filter.length === 0) return "it is empty";
|
||||
if (Buffer.byteLength(filter, "utf8") > 65535) return "it is longer than MQTT allows";
|
||||
if (filter.includes("\u0000")) return "it contains a NUL character";
|
||||
const levels = filter.split("/");
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const level = levels[i];
|
||||
if (level.includes("#") && (level !== "#" || i !== levels.length - 1)) {
|
||||
return "'#' must be a whole level, and the last one";
|
||||
}
|
||||
if (level.includes("+") && level !== "+") return "'+' must be a whole level";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The ACLs a role must carry to grant these filters: every granted type, allowed, on every filter. */
|
||||
export function wantedAcls(filters: readonly string[]): Acl[] {
|
||||
const out: Acl[] = [];
|
||||
for (const topic of filters) {
|
||||
for (const type of GRANTED_ACL_TYPES) out.push({ type, allow: true, topic });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACLs `mosquitto_ctrl dynsec getRole` lists, one per line under its "ACLs:" heading:
|
||||
* `ACLs: publishClientSend : allow : # (priority: 0)`
|
||||
* ` subscribePattern : allow : u1/# (priority: 0)`
|
||||
*/
|
||||
export function parseRoleAcls(output: string): Acl[] {
|
||||
const out: Acl[] = [];
|
||||
const line = /^(?:ACLs:)?\s*([A-Za-z]+)\s*:\s*(allow|deny)\s*:\s*(.*?)\s+\(priority:\s*-?\d+\)\s*$/;
|
||||
for (const raw of output.split(/\r?\n/)) {
|
||||
const m = raw.match(line);
|
||||
if (m) out.push({ type: m[1], allow: m[2] === "allow", topic: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const key = (a: Acl): string => `${a.type}\u0000${a.allow ? "allow" : "deny"}\u0000${a.topic}`;
|
||||
|
||||
/** ACLs a role carries that it should not: in `current` and not in `wanted`. */
|
||||
export function staleAcls(current: readonly Acl[], wanted: readonly Acl[]): Acl[] {
|
||||
const want = new Set(wanted.map(key));
|
||||
return current.filter((a) => !want.has(key(a)));
|
||||
}
|
||||
|
||||
/** ACLs a role should carry and does not. */
|
||||
export function missingAcls(current: readonly Acl[], wanted: readonly Acl[]): Acl[] {
|
||||
const have = new Set(current.map(key));
|
||||
return wanted.filter((a) => !have.has(key(a)));
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["topics.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
|
||||
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# n8n with what its workflows reach for beyond the upstream image.
|
||||
#
|
||||
# The base is named, not pinned here (novox/hq issue 044): module.json's `build.on` declares N8N_BASE
|
||||
# as the upstream image by digest, and the mesh hands the build its own copy (ADR 0097).
|
||||
ARG N8N_BASE
|
||||
FROM ${N8N_BASE}
|
||||
|
||||
USER root
|
||||
# - `media` (GID 2000), with `node` in it: the shared media library is group-writable by the
|
||||
# operator's media group, and a workflow files downloads into it. A container resource cannot add
|
||||
# a supplementary group, so the image's own /etc/group carries it. 2000 is the operator's media
|
||||
# group today; novox/hq 153 proposes reading it from the accessed data (${access:<id>:gid}).
|
||||
# - uuid, pinned to the version the workflows were written against: Code nodes require() it
|
||||
# (NODE_FUNCTION_ALLOW_EXTERNAL=*), and a Code node can only require what is installed.
|
||||
RUN apk add --no-cache shadow \
|
||||
&& groupadd -g 2000 media \
|
||||
&& usermod -aG media node \
|
||||
&& npm install -g uuid@14.0.1
|
||||
|
||||
USER node
|
||||
+77
-18
@@ -18,44 +18,60 @@
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "/var/lib/n8n/database.json",
|
||||
"route": "/var/lib/n8n/route.json"
|
||||
"postgres-database": "${dir:state}/database.json",
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "/var/lib/n8n/database.secret"
|
||||
},
|
||||
"own-secrets": {
|
||||
"basic-auth": "/var/lib/n8n/basic-auth.secret"
|
||||
"postgres-database": "${dir:state}/database.secret"
|
||||
},
|
||||
"accesses": [
|
||||
{
|
||||
"path": "/services/media",
|
||||
"mode": "read-write"
|
||||
}
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 5682,
|
||||
"port": 5678,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the n8n editor and webhook endpoints over http; the public name n8n.novox.be is a route grant, and route-proxy reaches it on this published port"
|
||||
"why": "the n8n editor, its REST API and the webhook endpoints workflows are triggered through; a public name is the route's"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/n8n",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/n8n/n8n-data",
|
||||
"mode": "0700",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
{
|
||||
"id": "cache",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "999:999"
|
||||
},
|
||||
{
|
||||
"id": "database-secret",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/n8n-database.secret",
|
||||
"mode": "0400",
|
||||
"owner": "1000:1000",
|
||||
"content": "${secret:postgres-database}"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/n8n/server.env",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "N8N_HOST=n8n.novox.be\nN8N_PORT=5678\nN8N_PROTOCOL=https\nWEBHOOK_URL=https://n8n.novox.be/\nN8N_BASIC_AUTH_ACTIVE=true\nN8N_BASIC_AUTH_USER=admin\nN8N_BASIC_AUTH_PASSWORD=${secret:basic-auth}\nNODE_FUNCTION_ALLOW_BUILTIN=*\nNODE_FUNCTION_ALLOW_EXTERNAL=*\nDB_TYPE=postgresdb\nDB_POSTGRESDB_HOST=${bound:postgres-database:at}\nDB_POSTGRESDB_PORT=${bound:postgres-database:port}\nDB_POSTGRESDB_DATABASE=${bound:postgres-database:as}\nDB_POSTGRESDB_USER=${bound:postgres-database:as}\nDB_POSTGRESDB_PASSWORD=${secret:postgres-database}\n"
|
||||
"content": "N8N_HOST=${bound:route:name}\nN8N_PORT=5678\nN8N_PROTOCOL=https\nWEBHOOK_URL=https://${bound:route:name}/\nNODE_FUNCTION_ALLOW_BUILTIN=*\nNODE_FUNCTION_ALLOW_EXTERNAL=*\nDB_TYPE=postgresdb\nDB_POSTGRESDB_HOST=${bound:postgres-database:at}\nDB_POSTGRESDB_PORT=${bound:postgres-database:port}\nDB_POSTGRESDB_DATABASE=${bound:postgres-database:as}\nDB_POSTGRESDB_USER=${bound:postgres-database:as}\nDB_POSTGRESDB_PASSWORD_FILE=/run/secrets/database\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
@@ -66,18 +82,61 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "n8n",
|
||||
"image": "n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0",
|
||||
"artifact": "server",
|
||||
"network": "n8n",
|
||||
"env-file": [
|
||||
"/var/lib/n8n/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"5678"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/n8n/n8n-data:/home/node/.n8n"
|
||||
"${dir:data}:/home/node/.n8n",
|
||||
"${dir:state}/n8n-database.secret:/run/secrets/database:ro",
|
||||
"/services/media:/media-library"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cache-server",
|
||||
"type": "container",
|
||||
"name": "n8n-redis",
|
||||
"image": "redis@sha256:8a1efc5f479551822b47424ccae982026b633f28818eab0387348120a61e10e2",
|
||||
"network": "n8n",
|
||||
"args": [
|
||||
"redis-server",
|
||||
"--appendonly",
|
||||
"yes"
|
||||
],
|
||||
"secrets-in-environment": "n8n's loader honours <VAR>_FILE for every setting; convertible, awaiting a bed that proves it (N8N_BASIC_AUTH_* was removed in n8n 1.0 and is likely dead)"
|
||||
"volumes": [
|
||||
"${dir:cache}:/data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "browser",
|
||||
"type": "container",
|
||||
"name": "n8n-selenium",
|
||||
"image": "selenium/standalone-chrome@sha256:9ae1c78e9b2ca9fe4b22e57873b5ee34aeb8e814e3122293eef4c3abe4c5f448",
|
||||
"network": "n8n",
|
||||
"env": {
|
||||
"SE_ENABLE_TRACING": "false",
|
||||
"SE_NODE_MAX_SESSIONS": "5",
|
||||
"SE_NODE_OVERRIDE_MAX_SESSIONS": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "N8N_BASE",
|
||||
"image": "n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "server",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user