Compare commits

..
Author SHA1 Message Date
jschoubben 0fce3ebf5d mssql: place its directories and name the software's port, not a machine's
The manifest stated /var/lib/mssql, its grants and its SA file by path, and
declared it listens on 4848 - the port one machine's predecessor published,
which is an assignment's fact (ADR 0112, 0138). ace is moving its own
SQL Server (80 GB of work databases) onto the mesh, so the module has to be
the same on every machine.

- state is the assignment's root (place "."), grants is placed, and every
  reference (sa.env, the env-file, the SA and grants mounts, receives,
  grants, own-secrets) names them as ${dir:...}.
- the database endpoint listens on 1433, the port SQL Server uses; a machine
  that must keep an older number pins it in its assignment.
- the image pin is unchanged: it is the digest ace runs today (CU27,
  16.0.4295), the same as novox.

novox is untouched: rendered with novox's own setting ({"ports":{"1433":4848}})
through the controller's Declaration and Rules, every resource - paths,
container names, volumes, env-file, the 4848:1433 mapping, owners, modes - is
byte-identical to what main renders; the only difference is the firewall
rule's comment text (still port 4848, from the mesh).

Verified: catalogue tests pass with MESH_CATALOGUE on this tree. The pinned
image ran as a throwaway on a 0700 10001:0 data dir with a root-owned 0600
env-file (dummy SA), answered sqlcmd as sa; a scratch database stopped,
copied with cp -a, checksummed and started on the copy kept its rows and
CHECKSUM_AGG.
2026-09-30 11:57:45 +02:00
9 changed files with 61 additions and 279 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/mosquitto WORKDIR /app/modules/mosquitto
COPY . . 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 --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
+24 -38
View File
@@ -20,8 +20,6 @@ import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
const run = promisify(execFile); const run = promisify(execFile);
export interface MqttConn { 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. * Create (or reset to a known state) a client scoped to one topic namespace, idempotently. The
* The grant is a same-named role carrying, for every filter, publish, receive and subscribe — and * client is confined to `<prefix>/#` by a same-named role: it may publish to, subscribe to and
* nothing else: an ACL the role carries that the filters no longer name is removed, so narrowing a * receive on exactly its own subtree and nothing else — the MQTT analog of redis's keyspace-scoped
* consumer's `topics` narrows what it may do. By default the filters are the consumer's own * ACL user. Called again for an existing client, it resets the password and re-asserts the ACLs.
* 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.
*/ */
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 role = username; // one role per client, named for it
const pattern = `${topicPrefix}/#`;
if (await this.clientExists(username)) { if (await this.clientExists(username)) {
await this.ctl("setClientPassword", username, password); await this.ctl("setClientPassword", username, password);
@@ -168,18 +161,17 @@ export class MosquittoClient {
await this.ctl("createClient", username, "-p", password); await this.ctl("createClient", username, "-p", password);
} }
// createRole and addRoleACL are one-shot: each rejects with an "already exists" when re-run // A role carrying exactly this client's topic ACLs. createRole, addRoleACL and addClientRole are
// against a role/ACL it created on a previous reconcile. That rejection is the intended terminal // all one-shot: each rejects with an "already exists" when re-run against a role/ACL/binding it
// state, so it is swallowed. // 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)); await ignoreExisting(this.ctl("createRole", role));
const wanted = wantedAcls(filters); for (const acl of ["publishClientSend", "publishClientReceive", "subscribePattern"]) {
const current = parseRoleAcls(await this.ctl("getRole", role)); // allow (1) this client to send to, receive on, and subscribe under its own subtree.
for (const acl of missingAcls(current, wanted)) { await ignoreExisting(this.ctl("addRoleACL", role, acl, pattern, "allow"));
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));
} }
// Bind the role only when it is not already bound — addClientRole is the one call whose // 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). // 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 * Whether a consumer's client accepts exactly this password and still carries its own role.
* role grants exactly these filters. Read-only. The password is checked the way the consumer is * Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it,
* checked, by an MQTT CONNECT as it, and the broker's CONNACK code is the answer: 0 accepted, * and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised.
* 4 bad credentials, 5 not authorised. Nothing rides on argv. An unreachable broker rejects * Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120).
* (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); const code = await mqttConnack(this.conn.host, this.conn.port, username, password);
if (code === 4 || code === 5) return false; if (code === 4 || code === 5) return false;
if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`); 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, // The role, asked directly: only "not found" means absent. Any other failure to ask rejects,
// unlike clientHasRole, which reads every failure as "no role". // unlike clientHasRole, which reads every failure as "no role".
let client: string; let out: string;
let role: string;
try { try {
client = await this.ctl("getClient", username); out = await this.ctl("getClient", username);
role = await this.ctl("getRole", username);
} catch (err) { } catch (err) {
if (/not\s*found|does not exist|no such/i.test(String(err))) return false; if (/not\s*found|does not exist|no such/i.test(String(err))) return false;
throw err; throw err;
} }
if (!new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(client)) return false; return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out);
const current = parseRoleAcls(role);
const wanted = wantedAcls(filters);
return missingAcls(current, wanted).length === 0 && staleAcls(current, wanted).length === 0;
} }
/** Remove a client and the per-client role created for it, idempotently. */ /** Remove a client and the per-client role created for it, idempotently. */
+17 -18
View File
@@ -20,19 +20,16 @@
"mosquitto.topic.deprovisioned" "mosquitto.topic.deprovisioned"
], ],
"serves": { "serves": {
"mqtt-topic": { "mqtt-topic": {}
"scheme": "mqtt",
"port": 1883
}
}, },
"receives": { "receives": {
"mqtt-topic": "${dir:grants}/mesh.json" "mqtt-topic": "/var/lib/mosquitto-module/grants/mesh.json"
}, },
"grants": { "grants": {
"mqtt-topic": "${dir:grants}" "mqtt-topic": "/var/lib/mosquitto-module/grants"
}, },
"own-secrets": { "own-secrets": {
"admin": "/var/lib/mesh/mosquitto/admin", "admin": "/var/lib/mosquitto-module/admin.secret",
"broker": "/var/lib/mesh/mosquitto/broker" "broker": "/var/lib/mesh/mosquitto/broker"
}, },
"listens": [ "listens": [
@@ -61,24 +58,26 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"mode": "0700", "path": "/var/lib/mosquitto-module",
"place": "." "mode": "0700"
}, },
{ {
"id": "grants", "id": "grants-dir",
"type": "directory", "type": "directory",
"path": "/var/lib/mosquitto-module/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/mosquitto/data",
"mode": "0700", "mode": "0700",
"owner": "1883:1883" "owner": "1883:1883"
}, },
{ {
"id": "server-conf", "id": "server-conf",
"type": "file", "type": "file",
"path": "${dir:state}/mosquitto.conf", "path": "/var/lib/mosquitto-module/mosquitto.conf",
"mode": "0600", "mode": "0600",
"owner": "1883:1883", "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" "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", "name": "mosquitto-bootstrap",
"run-once": true, "run-once": true,
"volumes": [ "volumes": [
"${dir:data}:/mosquitto/data", "/services/mosquitto/data:/mosquitto/data",
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro" "/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
], ],
"env": { "env": {
"MESH_PROVISION_MQTT": "mosquitto:1883", "MESH_PROVISION_MQTT": "mosquitto:1883",
@@ -113,15 +112,15 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "mosquitto", "name": "mosquitto",
"image": "eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408", "image": "eclipse-mosquitto@sha256:6f8d8a947c506f8a2290ec65cd4bd2bc7cb4d43fb5f6271f861cb013e2ef9797",
"network": "mosquitto", "network": "mosquitto",
"ports": [ "ports": [
"1883", "1883",
"8081" "8081"
], ],
"volumes": [ "volumes": [
"${dir:data}:/mosquitto/data", "/services/mosquitto/data:/mosquitto/data",
"${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" "/var/lib/mosquitto-module/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
] ]
}, },
{ {
@@ -131,8 +130,8 @@
"network": "mosquitto", "network": "mosquitto",
"volumes": [ "volumes": [
"/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro", "/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro",
"${dir:grants}:/var/lib/mosquitto-module/grants:ro", "/var/lib/mosquitto-module/grants:/var/lib/mosquitto-module/grants:ro",
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro" "/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+1 -6
View File
@@ -1,14 +1,9 @@
{ {
"name": "@novox/module-mosquitto", "name": "@novox/module-mosquitto",
"version": "0.1.0", "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", "type": "module",
"private": true, "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": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.1"
}, },
+6 -24
View File
@@ -5,14 +5,7 @@
// //
// The `mqtt-topic` interface: a consumer connects as `as` with the password the mesh minted, and // 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 // 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 // role scoped to exactly that subtree.
// 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.
// //
// **The login and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives the // **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. // 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 { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { MosquittoClient } from "../client.js"; import { MosquittoClient } from "../client.js";
import { topicFilters } from "../topics.js";
const mosquitto = MosquittoClient.fromEnv(); const mosquitto = MosquittoClient.fromEnv();
@@ -37,20 +29,13 @@ async function announce(type: string, body: Record<string, string>): Promise<voi
runProvisioner("mqtt-topic", { runProvisioner("mqtt-topic", {
async create(p: Provision): Promise<void> { async create(p: Provision): Promise<void> {
// By default the consumer's own subtree, so one cannot read another's topics; what it // The topic subtree is scoped to the consumer's own login, so one cannot read another's topics.
// contributed as `topics` otherwise. const topicPrefix = p.as;
const granted = topicFilters(p.values, p.as); await mosquitto.createScopedClient(p.as, p.password, topicPrefix);
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);
await announce("topic.provisioned", { await announce("topic.provisioned", {
consumer: p.consumer ?? "", consumer: p.consumer ?? "",
username: p.as, username: p.as,
topicPrefix: granted.own ? p.as : "", topicPrefix,
topics: granted.filters.join(" "),
}); });
}, },
@@ -61,9 +46,6 @@ runProvisioner("mqtt-topic", {
// Asked every minute by the harness: whether the backend still holds this consumer exactly as // 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). // 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> { async holds(p: Provision): Promise<boolean> {
const granted = topicFilters(p.values, p.as); return mosquitto.holdsClient(p.as, p.password);
// 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);
}, },
}); });
-72
View File
@@ -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), []);
});
-107
View File
@@ -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)));
}
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": 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"]
} }
+11 -12
View File
@@ -21,23 +21,23 @@
"listens": [ "listens": [
{ {
"name": "database", "name": "database",
"port": 4848, "port": 1433,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "modules on any machine that were granted a database. 4848, not 1433: the machine this replaces has served it there since it was installed, and every consumer was handed that number" "why": "modules on any machine that were granted a database, and the people who were given its address; the machine port is the assignment's to pin"
} }
], ],
"serves": { "serves": {
"mssql-database": {} "mssql-database": {}
}, },
"receives": { "receives": {
"mssql-database": "/var/lib/mssql/grants/mesh.json" "mssql-database": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"mssql-database": "/var/lib/mssql/grants" "mssql-database": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"sa": "/var/lib/mssql/sa.secret", "sa": "${dir:state}/sa.secret",
"broker": "/var/lib/mesh/mssql/broker" "broker": "/var/lib/mesh/mssql/broker"
}, },
"resources": [ "resources": [
@@ -50,19 +50,18 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/mssql", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/mssql/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "sa-env", "id": "sa-env",
"type": "file", "type": "file",
"path": "/var/lib/mssql/sa.env", "path": "${dir:state}/sa.env",
"mode": "0600", "mode": "0600",
"content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n" "content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n"
}, },
@@ -84,7 +83,7 @@
"image": "mcr.microsoft.com/mssql/server@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090", "image": "mcr.microsoft.com/mssql/server@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090",
"network": "mssql", "network": "mssql",
"env-file": [ "env-file": [
"/var/lib/mssql/sa.env" "${dir:state}/sa.env"
], ],
"ports": [ "ports": [
"1433" "1433"
@@ -101,8 +100,8 @@
"network": "mssql", "network": "mssql",
"volumes": [ "volumes": [
"/var/lib/mesh/mssql/broker:/run/secrets/broker:ro", "/var/lib/mesh/mssql/broker:/run/secrets/broker:ro",
"/var/lib/mssql/grants:/var/lib/mssql/grants:ro", "${dir:grants}:/var/lib/mssql/grants:ro",
"/var/lib/mssql/sa.secret:/run/secrets/sa:ro" "${dir:state}/sa.secret:/run/secrets/sa:ro"
], ],
"env": { "env": {
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master", "MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",