Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
157fab5dc8 |
File diff suppressed because one or more lines are too long
+22
-47
@@ -1,26 +1,6 @@
|
||||
{
|
||||
"module": "icecast",
|
||||
"version": "1",
|
||||
"requires": [
|
||||
"route",
|
||||
"secret"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "icecast",
|
||||
"endpoint": "stream"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"secrets": {
|
||||
"secret": {
|
||||
"source": "${dir:state}/source.secret",
|
||||
"admin": "${dir:state}/admin.secret",
|
||||
"relay": "${dir:state}/relay.secret"
|
||||
}
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
@@ -37,7 +17,7 @@
|
||||
"port": 8000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "streams in from sources (HTTP PUT) and out to listeners, plus the status and admin pages; a public name is its route"
|
||||
"why": "streams in from sources and out to listeners"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -50,43 +30,28 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
"path": "/var/lib/icecast-module",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "logs",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "100:101"
|
||||
},
|
||||
{
|
||||
"id": "server-conf",
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/icecast.xml",
|
||||
"path": "/var/lib/icecast-module/server.env",
|
||||
"mode": "0600",
|
||||
"content": "<icecast>\n <!-- Written by the mesh (modules/icecast). Passwords arrive as secrets rendered into this file,\n never as environment: the image's entrypoint seds ICECAST_* variables into the file only\n when they are set, and none are. -->\n <location>Earth</location>\n <admin>icemaster@localhost</admin>\n <limits>\n <clients>100</clients>\n <sources>2</sources>\n <queue-size>524288</queue-size>\n <client-timeout>30</client-timeout>\n <header-timeout>15</header-timeout>\n <source-timeout>10</source-timeout>\n <burst-on-connect>1</burst-on-connect>\n <burst-size>65535</burst-size>\n </limits>\n <authentication>\n <source-password>${secret:source}</source-password>\n <relay-password>${secret:relay}</relay-password>\n <admin-user>admin</admin-user>\n <admin-password>${secret:admin}</admin-password>\n </authentication>\n <!-- The name icecast writes into playlists (.m3u/.xspf: http://<hostname>:<port>/<mount>) and\n would announce to YP (none configured). A machine's own name belongs to its assignment, and\n an assignment merges only into JSON; this XML cannot take it, so the neutral default stays. -->\n <hostname>localhost</hostname>\n <listen-socket>\n <port>8000</port>\n </listen-socket>\n <http-headers>\n <header name=\"Access-Control-Allow-Origin\" value=\"*\" />\n </http-headers>\n <fileserve>1</fileserve>\n <paths>\n <basedir>/usr/share/icecast</basedir>\n <logdir>/var/log/icecast</logdir>\n <webroot>/usr/share/icecast/web</webroot>\n <adminroot>/usr/share/icecast/admin</adminroot>\n <alias source=\"/\" destination=\"/status.xsl\"/>\n </paths>\n <logging>\n <accesslog>access.log</accesslog>\n <errorlog>error.log</errorlog>\n <loglevel>3</loglevel>\n <logsize>10000</logsize>\n </logging>\n <security>\n <chroot>0</chroot>\n <!-- Starts as root, reads this 0600 root-owned file, then drops to the image's icecast user\n (uid 100, group icecast 101) before serving. -->\n <changeowner>\n <user>icecast</user>\n <group>icecast</group>\n </changeowner>\n </security>\n</icecast>\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
"name": "icecast"
|
||||
"content": "ICECAST_SOURCE_PASSWORD=${secret:source}\nICECAST_ADMIN_PASSWORD=${secret:admin}\nICECAST_RELAY_PASSWORD=${secret:relay}\nICECAST_ADMIN_USERNAME=admin\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "icecast",
|
||||
"image": "infiniteproject/icecast@sha256:cd506cf3dfe31ce05fd37d7e672dbd1213e7255cc93d28ecf5a3b547af4e162c",
|
||||
"network": "icecast",
|
||||
"env-file": [
|
||||
"/var/lib/icecast-module/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"8000"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:state}/icecast.xml:/etc/icecast.xml:ro",
|
||||
"${dir:logs}:/var/log/icecast"
|
||||
],
|
||||
"restart-on": [
|
||||
"server-conf"
|
||||
]
|
||||
"secrets-in-environment": "the image seds ICECAST_*_PASSWORD into icecast.xml and has no _FILE; convertible by mounting a generated icecast.xml, not yet done"
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
@@ -100,14 +65,14 @@
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-icecast",
|
||||
"network": "icecast",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/icecast/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/icecast/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_ICECAST_URL": "http://icecast:8000",
|
||||
"MESH_ICECAST_URL": "http://127.0.0.1:8000",
|
||||
"MESH_ICECAST_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
@@ -136,5 +101,15 @@
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requires": [
|
||||
"secret"
|
||||
],
|
||||
"secrets": {
|
||||
"secret": {
|
||||
"source": "/var/lib/icecast-module/source.secret",
|
||||
"admin": "/var/lib/icecast-module/admin.secret",
|
||||
"relay": "/var/lib/icecast-module/relay.secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/influxdb
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts grants.ts provisioner/index.ts tools/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
@@ -21,4 +21,4 @@ COPY --from=build /app/modules/influxdb/dist /app/modules/influxdb/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js,/app/modules/influxdb/dist/provisioner/index.js
|
||||
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js
|
||||
|
||||
+3
-118
@@ -17,25 +17,6 @@ export interface InfluxBucket {
|
||||
retentionSeconds?: number;
|
||||
}
|
||||
|
||||
/** One permission of an authorization, as InfluxDB represents it: an action on a resource type,
|
||||
* in one org, optionally narrowed to one resource by id (no id = every resource of that type). */
|
||||
export interface InfluxPermission {
|
||||
action: "read" | "write";
|
||||
resource: { type: string; orgID?: string; id?: string; name?: string; org?: string };
|
||||
}
|
||||
|
||||
/** A v1-compatibility ("legacy") authorization: a username (InfluxDB calls it `token`) and a
|
||||
* password the caller chooses, scoped by permissions. The one credential InfluxDB 2.x lets a
|
||||
* caller set to a value it did not generate — which is what a mesh-minted password needs. */
|
||||
export interface LegacyAuthorization {
|
||||
id: string;
|
||||
token: string;
|
||||
orgID: string;
|
||||
status?: "active" | "inactive";
|
||||
description?: string;
|
||||
permissions: InfluxPermission[];
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
@@ -43,20 +24,13 @@ function meshConfig(file?: string): Record<string, string> {
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
/** A secret delivered as a file, trimmed; undefined when there is none, so the caller can fall back. */
|
||||
function tokenFromFile(file?: string): string | undefined {
|
||||
if (!file) return undefined;
|
||||
try { return readFileSync(file, "utf8").trim() || undefined; }
|
||||
catch { return undefined; }
|
||||
}
|
||||
|
||||
export class InfluxDBClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly token: string,
|
||||
readonly org: string,
|
||||
private readonly org: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
@@ -69,10 +43,8 @@ export class InfluxDBClient {
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient {
|
||||
const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`;
|
||||
// The token reaches the process as a file (novox/hq ADR 0086); the environment variable stays
|
||||
// only for a workstation running the tools by hand.
|
||||
const token = cfg.token ?? tokenFromFile(env.MESH_INFLUXDB_TOKEN_FILE) ?? env.MESH_INFLUXDB_TOKEN;
|
||||
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN_FILE");
|
||||
const token = cfg.token ?? env.MESH_INFLUXDB_TOKEN;
|
||||
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN");
|
||||
const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh";
|
||||
return new InfluxDBClient(url, token, org);
|
||||
}
|
||||
@@ -89,93 +61,6 @@ export class InfluxDBClient {
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Like request, but the answer is returned whatever its status, for the caller to read. */
|
||||
private async raw(path: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Token ${this.token}`, ...(init?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
private async send(path: string, method: string, body?: unknown): Promise<Response> {
|
||||
return this.request(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** The id of the org of this name, or undefined when there is none. */
|
||||
async orgID(name: string): Promise<string | undefined> {
|
||||
const res = await this.raw(`/api/v2/orgs?org=${encodeURIComponent(name)}`);
|
||||
if (res.status === 404) return undefined;
|
||||
if (!res.ok) throw new Error(`InfluxDB API /api/v2/orgs: ${res.status} ${await res.text()}`);
|
||||
const body = (await res.json()) as { orgs?: { id: string; name: string }[] };
|
||||
return body.orgs?.find((o) => o.name === name)?.id;
|
||||
}
|
||||
|
||||
/** The bucket of exactly this name in the org, or undefined. */
|
||||
async findBucket(orgID: string, name: string): Promise<InfluxBucket | undefined> {
|
||||
const res = await this.raw(`/api/v2/buckets?orgID=${encodeURIComponent(orgID)}&name=${encodeURIComponent(name)}`);
|
||||
if (res.status === 404) return undefined;
|
||||
if (!res.ok) throw new Error(`InfluxDB API /api/v2/buckets: ${res.status} ${await res.text()}`);
|
||||
const body = (await res.json()) as { buckets?: { id: string; name: string; orgID?: string }[] };
|
||||
const b = body.buckets?.find((x) => x.name === name);
|
||||
return b ? { id: b.id, name: b.name, orgID: b.orgID } : undefined;
|
||||
}
|
||||
|
||||
/** Create a bucket that keeps its data for ever — retention is the operator's choice, never the mesh's. */
|
||||
async createBucket(orgID: string, name: string, description: string): Promise<InfluxBucket> {
|
||||
const b = (await (await this.send("/api/v2/buckets", "POST", {
|
||||
orgID, name, description, retentionRules: [],
|
||||
})).json()) as { id: string; name: string; orgID?: string };
|
||||
return { id: b.id, name: b.name, orgID: b.orgID };
|
||||
}
|
||||
|
||||
/** The v1 authorization whose username is exactly this, or undefined. */
|
||||
async findLegacy(username: string): Promise<LegacyAuthorization | undefined> {
|
||||
const path = `/private/legacy/authorizations?token=${encodeURIComponent(username)}`;
|
||||
const res = await this.raw(path);
|
||||
// InfluxDB answers a filter matching nothing with 404, not an empty list.
|
||||
if (res.status === 404) return undefined;
|
||||
if (!res.ok) throw new Error(`InfluxDB API ${path}: ${res.status} ${await res.text()}`);
|
||||
const body = (await res.json()) as { authorizations?: LegacyAuthorization[] };
|
||||
return body.authorizations?.find((a) => a.token === username);
|
||||
}
|
||||
|
||||
async createLegacy(a: Omit<LegacyAuthorization, "id">): Promise<LegacyAuthorization> {
|
||||
return (await (await this.send("/private/legacy/authorizations", "POST", a)).json()) as LegacyAuthorization;
|
||||
}
|
||||
|
||||
/** Set a v1 authorization's password. InfluxDB keeps only a hash of it, so it can be set, never read. */
|
||||
async setLegacyPassword(id: string, password: string): Promise<void> {
|
||||
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}/password`, "POST", { password });
|
||||
}
|
||||
|
||||
async updateLegacy(id: string, patch: { status?: "active" | "inactive"; description?: string }): Promise<void> {
|
||||
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}`, "PATCH", patch);
|
||||
}
|
||||
|
||||
async deleteLegacy(id: string): Promise<void> {
|
||||
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}`, "DELETE");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this username and password sign in on the v1 API — the consumer's own view. Asked with
|
||||
* a statement that reads nothing (`SHOW DATABASES` lists only what the credential may read), sent
|
||||
* with Basic auth so the password is never in a URL. 401 is a wrong password or no such user;
|
||||
* anything else that is not a server error means InfluxDB knew who was asking.
|
||||
*/
|
||||
async legacySignsIn(username: string, password: string): Promise<boolean> {
|
||||
const res = await fetch(`${this.baseUrl}/query?q=${encodeURIComponent("SHOW DATABASES")}`, {
|
||||
headers: { Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` },
|
||||
});
|
||||
await res.arrayBuffer();
|
||||
if (res.status === 401) return false;
|
||||
if (res.status >= 500) throw new Error(`InfluxDB v1 /query: ${res.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Server health — the one endpoint that needs no token, but we send it anyway. */
|
||||
async health(): Promise<InfluxHealth> {
|
||||
return (await (await this.request("/health")).json()) as InfluxHealth;
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
// What the `influxdb-api` provision means in InfluxDB: one v1-compatibility authorization per
|
||||
// consumer, in the org this module serves, under the username and password the mesh gave both ends,
|
||||
// allowed exactly the access the consumer contributed. The provisioner (provisioner/index.ts) is the
|
||||
// sdk harness calling these; they are here, apart from it, so they can be exercised against a fake
|
||||
// InfluxDB without a broker or a contributions file.
|
||||
//
|
||||
// **Why a v1 authorization and not a v2 API token.** The mesh mints the consumer's password and
|
||||
// hands it to both ends (novox/hq ADR 0048); the provider sets it, and never hands one back. An
|
||||
// InfluxDB 2.x API token is generated by the server — `POST /api/v2/authorizations` ignores a token
|
||||
// the caller sends — so a token could only ever be the operator's to accept, one per pair, by hand.
|
||||
// A v1 authorization is a username and a password the caller chooses (8–72 characters; the mesh
|
||||
// mints 40), stored hashed, and it reads and writes through InfluxQL (`/query`) and line protocol
|
||||
// (`/write`), which every bucket answers under its own name as a database (InfluxDB maps each
|
||||
// bucket to a database of the same name by itself). That is what grafana's InfluxDB data source
|
||||
// speaks, and what Node-RED's influxdb nodes speak in their 1.x mode — so the mesh can make every
|
||||
// consumer's credential, rotate it and withdraw it, with no person in the loop.
|
||||
//
|
||||
// **What a consumer contributes.** `access`: "read" (the default), "write" or "read-write".
|
||||
// `buckets`: the buckets it may use, by name. A reader that names none may read every bucket of the
|
||||
// org — a dashboard is pointed at data, it does not own it. A writer must name its buckets: writing
|
||||
// everywhere, the org's system buckets included, is never what a consumer means. A named bucket
|
||||
// that does not exist is created, keeping its data for ever; the mesh never deletes a bucket.
|
||||
//
|
||||
// **Only what the mesh made is touched.** An authorization this module creates is named with the
|
||||
// mesh's identity prefix and its description starts with MARK. One with the same username that
|
||||
// lacks the mark is somebody else's: it is refused, never adopted, never updated, never deleted.
|
||||
// Every other authorization, token, user and bucket in the instance is left exactly as it was.
|
||||
|
||||
import type { InfluxDBClient, InfluxPermission, LegacyAuthorization } from "./client.js";
|
||||
|
||||
/** How a description marks an authorization as the mesh's own work. */
|
||||
export const MARK = "[mesh]";
|
||||
|
||||
/** The prefix the mesh gives every consumer identity (novox/hq ADR 0049). */
|
||||
const IDENTITY_PREFIX = "mesh_";
|
||||
|
||||
/** One consumer, as the harness hands it over. */
|
||||
export interface ApiGrant {
|
||||
readonly as: string;
|
||||
readonly password: string;
|
||||
readonly values: Readonly<Record<string, unknown>>;
|
||||
readonly consumer?: string;
|
||||
}
|
||||
|
||||
export type Access = "read" | "write" | "read-write";
|
||||
|
||||
/** What a contribution asks for, checked. Refused when it cannot be served as asked. */
|
||||
export function askedFor(values: Readonly<Record<string, unknown>>): { access: Access; buckets: string[] } {
|
||||
const access = values.access ?? "read";
|
||||
if (access !== "read" && access !== "write" && access !== "read-write") {
|
||||
throw new Error(`contributes an access of ${JSON.stringify(access)} — it is "read", "write" or "read-write"`);
|
||||
}
|
||||
const raw = values.buckets ?? [];
|
||||
if (!Array.isArray(raw) || raw.some((b) => typeof b !== "string" || b.trim() === "")) {
|
||||
throw new Error(`contributes buckets of ${JSON.stringify(raw)} — a list of bucket names`);
|
||||
}
|
||||
const buckets = [...new Set((raw as string[]).map((b) => b.trim()))].sort();
|
||||
if (access !== "read" && buckets.length === 0) {
|
||||
throw new Error(`asks to write and names no bucket (\`buckets\`) — a writer names what it writes to`);
|
||||
}
|
||||
if (buckets.some((b) => b.startsWith("_"))) {
|
||||
throw new Error(`names a system bucket (${buckets.filter((b) => b.startsWith("_")).join(", ")}) — those are InfluxDB's own`);
|
||||
}
|
||||
return { access: access as Access, buckets };
|
||||
}
|
||||
|
||||
/** The permissions a grant resolves to, given each named bucket's id. */
|
||||
export function permissionsFor(orgID: string, access: Access, bucketIDs: string[]): InfluxPermission[] {
|
||||
const actions: ("read" | "write")[] = access === "read-write" ? ["read", "write"] : [access];
|
||||
const out: InfluxPermission[] = [];
|
||||
for (const action of actions) {
|
||||
if (bucketIDs.length === 0) {
|
||||
out.push({ action, resource: { type: "buckets", orgID } });
|
||||
continue;
|
||||
}
|
||||
for (const id of bucketIDs) out.push({ action, resource: { type: "buckets", orgID, id } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A permission as a comparable string: what InfluxDB answers carries names and links besides. */
|
||||
function key(p: InfluxPermission): string {
|
||||
return `${p.action}:${p.resource.type}:${p.resource.orgID ?? ""}:${p.resource.id ?? "*"}`;
|
||||
}
|
||||
|
||||
function samePermissions(a: readonly InfluxPermission[], b: readonly InfluxPermission[]): boolean {
|
||||
const x = a.map(key).sort();
|
||||
const y = b.map(key).sort();
|
||||
return x.length === y.length && x.every((v, i) => v === y[i]);
|
||||
}
|
||||
|
||||
export function marked(a: Pick<LegacyAuthorization, "token" | "description">): boolean {
|
||||
return a.token.startsWith(IDENTITY_PREFIX) && (a.description ?? "").startsWith(MARK);
|
||||
}
|
||||
|
||||
function describe(g: ApiGrant): string {
|
||||
return `${MARK} made by the mesh for ${g.consumer ? `a module on ${g.consumer}` : "a consumer"} — do not edit; it is reset`;
|
||||
}
|
||||
|
||||
export class ApiGrants {
|
||||
constructor(private readonly influx: InfluxDBClient, readonly org: string) {}
|
||||
|
||||
private async orgID(): Promise<string> {
|
||||
const id = await this.influx.orgID(this.org);
|
||||
if (!id) throw new Error(`InfluxDB has no org ${JSON.stringify(this.org)} — the org this module serves must exist`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The ids of the named buckets, creating any that are missing when `create` says so. Undefined
|
||||
* when one is missing and may not be created (a read-only question). */
|
||||
private async bucketIDs(orgID: string, names: string[], create: ApiGrant | undefined): Promise<string[] | undefined> {
|
||||
const ids: string[] = [];
|
||||
for (const name of names) {
|
||||
let b = await this.influx.findBucket(orgID, name);
|
||||
if (!b) {
|
||||
if (!create) return undefined;
|
||||
b = await this.influx.createBucket(orgID, name, `${MARK} made by the mesh for ${create.as}; the mesh never deletes it`);
|
||||
}
|
||||
ids.push(b.id);
|
||||
}
|
||||
return ids.sort();
|
||||
}
|
||||
|
||||
/** Create the consumer's authorization, or bring the mesh's existing one back to what the grant
|
||||
* says. Idempotent: a second apply of the same grant changes nothing beyond re-asserting the
|
||||
* password, which InfluxDB can be told but never asked. */
|
||||
async ensure(g: ApiGrant): Promise<"created" | "updated" | "unchanged"> {
|
||||
if (!g.as.startsWith(IDENTITY_PREFIX)) {
|
||||
throw new Error(`${g.as} is not a mesh identity — the mesh names every consumer ${IDENTITY_PREFIX}<node>_<module>`);
|
||||
}
|
||||
const { access, buckets } = askedFor(g.values);
|
||||
const orgID = await this.orgID();
|
||||
const found = await this.influx.findLegacy(g.as);
|
||||
if (found && !marked(found)) {
|
||||
throw new Error(
|
||||
`InfluxDB already has a v1 authorization ${g.as} the mesh did not make — left alone; ` +
|
||||
`delete it if the mesh should own that name`);
|
||||
}
|
||||
const want = permissionsFor(orgID, access, (await this.bucketIDs(orgID, buckets, g))!);
|
||||
|
||||
if (found && found.orgID === orgID && samePermissions(found.permissions, want)) {
|
||||
// Only what differs is written. The password cannot be read back, so it is tried instead.
|
||||
let changed = false;
|
||||
if (found.status === "inactive") {
|
||||
await this.influx.updateLegacy(found.id, { status: "active" });
|
||||
changed = true;
|
||||
}
|
||||
if (!(await this.influx.legacySignsIn(g.as, g.password))) {
|
||||
await this.influx.setLegacyPassword(found.id, g.password);
|
||||
changed = true;
|
||||
}
|
||||
return changed ? "updated" : "unchanged";
|
||||
}
|
||||
// InfluxDB cannot change an authorization's permissions in place, so the mesh's own is made
|
||||
// again. Only ever one the mesh made: a foreign one was refused above.
|
||||
if (found) await this.influx.deleteLegacy(found.id);
|
||||
const made = await this.influx.createLegacy({
|
||||
token: g.as, orgID, status: "active", description: describe(g), permissions: want,
|
||||
});
|
||||
await this.influx.setLegacyPassword(made.id, g.password);
|
||||
return found ? "updated" : "created";
|
||||
}
|
||||
|
||||
/** Whether InfluxDB still holds this consumer's authorization exactly as the grant says: present,
|
||||
* the mesh's, active, allowed what was asked and nothing more, and signing in with the mesh's
|
||||
* password. Reads only — a missing bucket is "not held", never created here. */
|
||||
async holds(g: ApiGrant): Promise<boolean> {
|
||||
const { access, buckets } = askedFor(g.values);
|
||||
const orgID = await this.influx.orgID(this.org);
|
||||
if (!orgID) return false;
|
||||
const found = await this.influx.findLegacy(g.as);
|
||||
if (!found || !marked(found) || found.status === "inactive" || found.orgID !== orgID) return false;
|
||||
const ids = await this.bucketIDs(orgID, buckets, undefined);
|
||||
if (!ids || !samePermissions(found.permissions, permissionsFor(orgID, access, ids))) return false;
|
||||
return this.influx.legacySignsIn(g.as, g.password);
|
||||
}
|
||||
|
||||
/** Withdraw a consumer's authorization — only one the mesh made. Its buckets and their data stay. */
|
||||
async remove(as: string): Promise<"removed" | "absent" | "not ours"> {
|
||||
const found = await this.influx.findLegacy(as);
|
||||
if (!found) return "absent";
|
||||
if (!marked(found)) return "not ours";
|
||||
await this.influx.deleteLegacy(found.id);
|
||||
return "removed";
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
{
|
||||
"module": "influxdb",
|
||||
"version": "1",
|
||||
"provides": [
|
||||
{
|
||||
"name": "influxdb-api",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/influxdb/broker",
|
||||
"admin": "${dir:state}/admin.secret",
|
||||
"admin-token": "${dir:state}/admin-token.secret"
|
||||
"broker": "/var/lib/mesh/influxdb/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
@@ -21,23 +13,9 @@
|
||||
"port": 8086,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant"
|
||||
"why": "queries and writes, over http"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"influxdb-api": {
|
||||
"scheme": "http",
|
||||
"port": 8086,
|
||||
"org": "mesh",
|
||||
"bucket": "default"
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"influxdb-api": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"influxdb-api": "${dir:grants}"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
@@ -48,50 +26,46 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
"path": "/var/lib/influxdb-module",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/influxdb-module/server.env",
|
||||
"mode": "0600",
|
||||
"content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD=${secret:admin}\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${secret:admin-token}\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/influxdb/data",
|
||||
"mode": "0700",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "directory",
|
||||
"path": "/services/influxdb/config",
|
||||
"mode": "0700",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "influxdb",
|
||||
"image": "influxdb@sha256:f75e48af0598e8aec7986e991a848d19a119101a7d563a2e5db1dfaac9c45daa",
|
||||
"env-file": [
|
||||
"${dir:state}/server.env"
|
||||
"/var/lib/influxdb-module/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"8086"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:data}:/var/lib/influxdb2",
|
||||
"${dir:config}:/etc/influxdb2",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro",
|
||||
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro"
|
||||
]
|
||||
"/services/influxdb/data:/var/lib/influxdb2",
|
||||
"/services/influxdb/config:/etc/influxdb2"
|
||||
],
|
||||
"secrets-in-environment": "the image honours DOCKER_INFLUXDB_INIT_PASSWORD_FILE and _ADMIN_TOKEN_FILE; convertible, awaiting a bed that proves it"
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
@@ -109,15 +83,13 @@
|
||||
"volumes": [
|
||||
"/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro",
|
||||
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro",
|
||||
"${dir:grants}:${dir:grants}:ro"
|
||||
"/services/influxdb/config:/var/lib/influxdb/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
|
||||
"MESH_INFLUXDB_URL": "http://127.0.0.1:8086",
|
||||
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
"MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
@@ -125,15 +97,6 @@
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "influxdb",
|
||||
"endpoint": "api"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
@@ -154,5 +117,14 @@
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requires": [
|
||||
"secret"
|
||||
],
|
||||
"secrets": {
|
||||
"secret": {
|
||||
"admin": "/var/lib/influxdb-module/admin.secret",
|
||||
"admin-token": "/var/lib/influxdb-module/admin-token.secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
{
|
||||
"name": "@novox/module-influxdb",
|
||||
"version": "0.1.0",
|
||||
"description": "influxdb — time-series database; provides the mesh influxdb-api interface. Its API client, provisioner and tools live here (novox/hq ADR 0039).",
|
||||
"description": "influxdb — time-series database. Its API client and tools live here (novox/hq ADR 0039).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc client.ts grants.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// influxdb's provisioner — the adapter that makes influxdb a provider of the mesh `influxdb-api`
|
||||
// interface. The reconcile loop, the contributions file and reading the mesh's minted secret are the
|
||||
// sdk harness's; this writes only the per-service half: how InfluxDB creates, checks and removes a
|
||||
// consumer's credential (novox/hq ADR 0039/0040/0048). What that credential is, and why it is a v1
|
||||
// authorization, is in ../grants.ts.
|
||||
//
|
||||
// The `influxdb-api` interface: a consumer reaches `${bound:influxdb-api:scheme}://…:at:…:port`,
|
||||
// signs in as `${bound:influxdb-api:as}` with the password the mesh minted for the pair, and reads
|
||||
// or writes the org's buckets as databases of the same name — `${bound:influxdb-api:bucket}` being
|
||||
// the one this instance serves by default. The org and the default bucket are the assignment's
|
||||
// settings, which reach both what is served and this module's config.json, so the org a consumer is
|
||||
// told and the org its credential is made in cannot disagree.
|
||||
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { InfluxDBClient } from "../client.js";
|
||||
import { ApiGrants } from "../grants.js";
|
||||
|
||||
let grants: ApiGrants | undefined;
|
||||
try {
|
||||
const influx = InfluxDBClient.fromEnv();
|
||||
grants = new ApiGrants(influx, influx.org);
|
||||
} catch (err) {
|
||||
// No admin token: nothing can be provisioned, and the tools loaded beside this must still serve.
|
||||
console.error(`[provisioner:influxdb-api] not started: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
if (grants) serve(grants);
|
||||
|
||||
function serve(grants: ApiGrants): void {
|
||||
runProvisioner("influxdb-api", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
const done = await grants.ensure(p);
|
||||
if (done !== "unchanged") {
|
||||
console.log(`[provisioner:influxdb-api] ${done} v1 authorization ${p.as} in org ${grants.org}`);
|
||||
}
|
||||
},
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
const done = await grants.remove(p.as);
|
||||
if (done === "not ours") {
|
||||
console.error(`[provisioner:influxdb-api] ${p.as}: an authorization of that name exists that the mesh did not make — left alone`);
|
||||
} else if (done === "removed") {
|
||||
console.log(`[provisioner:influxdb-api] removed v1 authorization ${p.as}; its buckets and their data stay`);
|
||||
}
|
||||
},
|
||||
|
||||
// Asked every minute by the harness: whether InfluxDB still holds this consumer's authorization
|
||||
// exactly as the mesh gave it, so one deleted, disabled or re-passworded behind the mesh's back is
|
||||
// made whole again (hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return grants.holds(p);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
// What holds influxdb to the `influxdb-api` provision (grants.ts): one v1 authorization per consumer,
|
||||
// under the username and password the mesh gave, allowed only what the consumer contributed; made
|
||||
// once and brought back on every apply; buckets created when missing and never deleted; and an
|
||||
// authorization the mesh did not make — same name or not — never adopted, changed or deleted.
|
||||
//
|
||||
// InfluxDB is a fake: the routes the module touches, answering with the status codes and shapes
|
||||
// InfluxDB 2.9 gives (a filter matching nothing is a 404, a password outside 8–72 characters a 400,
|
||||
// an inactive authorization or a wrong password a 401 on /query). Run against the compiled module
|
||||
// (npm test builds first), the way the runtime loads it.
|
||||
|
||||
import { test, after, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
|
||||
import { InfluxDBClient } from "../dist/client.js";
|
||||
import { ApiGrants, MARK, askedFor, marked } from "../dist/grants.js";
|
||||
|
||||
type Rec = Record<string, any>;
|
||||
|
||||
const ADMIN = "operator-token";
|
||||
const orgs = new Map<string, string>([["zurag", "org1"]]);
|
||||
let buckets: Rec[] = [];
|
||||
let auths: Rec[] = [];
|
||||
let calls: string[] = [];
|
||||
let seq = 0;
|
||||
|
||||
function body(req: IncomingMessage): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => resolve(raw ? JSON.parse(raw) : undefined));
|
||||
});
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, value?: unknown): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(value === undefined ? "" : JSON.stringify(value));
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, "http://fake");
|
||||
const p = url.pathname;
|
||||
calls.push(`${req.method} ${p}`);
|
||||
if (p === "/query") {
|
||||
const basic = (req.headers.authorization ?? "").replace(/^Basic /, "");
|
||||
const [u, pw] = Buffer.from(basic, "base64").toString().split(":");
|
||||
const a = auths.find((x) => x.token === u);
|
||||
if (!a || a.status !== "active" || a.password === undefined || a.password !== pw) {
|
||||
return send(res, 401, { code: "unauthorized", message: "Unauthorized" });
|
||||
}
|
||||
return send(res, 200, { results: [{ statement_id: 0 }] });
|
||||
}
|
||||
if (req.headers.authorization !== `Token ${ADMIN}`) return send(res, 401, { code: "unauthorized" });
|
||||
if (p === "/api/v2/orgs") {
|
||||
const id = orgs.get(url.searchParams.get("org") ?? "");
|
||||
if (!id) return send(res, 404, { code: "not found", message: "organization name not found" });
|
||||
return send(res, 200, { orgs: [{ id, name: url.searchParams.get("org") }] });
|
||||
}
|
||||
if (p === "/api/v2/buckets" && req.method === "GET") {
|
||||
const found = buckets.filter((b) => b.orgID === url.searchParams.get("orgID") && b.name === url.searchParams.get("name"));
|
||||
if (found.length === 0) return send(res, 404, { code: "not found", message: "bucket not found" });
|
||||
return send(res, 200, { buckets: found });
|
||||
}
|
||||
if (p === "/api/v2/buckets" && req.method === "POST") {
|
||||
const b = { ...(await body(req)), id: `b${++seq}` };
|
||||
buckets.push(b);
|
||||
return send(res, 201, b);
|
||||
}
|
||||
if (p === "/private/legacy/authorizations" && req.method === "GET") {
|
||||
const found = auths.filter((a) => a.token === url.searchParams.get("token"));
|
||||
if (found.length === 0) return send(res, 404, { code: "not found", message: "authorization not found" });
|
||||
// Never answers with the password: InfluxDB keeps only its hash.
|
||||
return send(res, 200, { authorizations: found.map(({ password, ...a }) => ({ ...a, links: {} })) });
|
||||
}
|
||||
if (p === "/private/legacy/authorizations" && req.method === "POST") {
|
||||
const a = await body(req);
|
||||
if (auths.some((x) => x.token === a.token)) return send(res, 409, { code: "conflict", message: "token already exists" });
|
||||
const made = { ...a, id: `a${++seq}`, status: a.status ?? "active" };
|
||||
auths.push(made);
|
||||
return send(res, 201, made);
|
||||
}
|
||||
const m = /^\/private\/legacy\/authorizations\/([^/]+)(\/password)?$/.exec(p);
|
||||
const a = m && auths.find((x) => x.id === m[1]);
|
||||
if (!a) return send(res, 404, { code: "not found" });
|
||||
if (m![2] && req.method === "POST") {
|
||||
const { password } = await body(req);
|
||||
if (typeof password !== "string" || password.length < 8 || password.length > 72) {
|
||||
return send(res, 400, { code: "invalid", message: "passwords must be between 8 and 72 characters long" });
|
||||
}
|
||||
a.password = password;
|
||||
return send(res, 204);
|
||||
}
|
||||
if (req.method === "PATCH") {
|
||||
Object.assign(a, await body(req));
|
||||
return send(res, 200, a);
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
auths = auths.filter((x) => x !== a);
|
||||
return send(res, 204);
|
||||
}
|
||||
send(res, 405);
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||
after(() => server.close());
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const grants = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMIN, "zurag"), "zurag");
|
||||
|
||||
const PW = "mesh-minted-password-of-forty-characters";
|
||||
|
||||
/** Grafana on ace, as the mesh hands it to the provisioner. */
|
||||
function grafana(password = PW, values: Record<string, unknown> = { access: "read" }) {
|
||||
return { as: "mesh_ace_grafana", password, consumer: "ace", values };
|
||||
}
|
||||
/** Node-RED on ace: writes one bucket. */
|
||||
function nodered(password = PW, values: Record<string, unknown> = { access: "write", buckets: ["zurag"] }) {
|
||||
return { as: "mesh_ace_nodered", password, consumer: "ace", values };
|
||||
}
|
||||
|
||||
function only(token: string): Rec {
|
||||
const found = auths.filter((a) => a.token === token);
|
||||
assert.equal(found.length, 1, `exactly one authorization ${token}, found ${found.length}`);
|
||||
return found[0];
|
||||
}
|
||||
|
||||
function perms(a: Rec): string[] {
|
||||
return a.permissions.map((p: Rec) => `${p.action}:${p.resource.type}:${p.resource.id ?? "*"}`).sort();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
buckets = [{ id: "zb", orgID: "org1", name: "zurag" }];
|
||||
auths = [];
|
||||
calls = [];
|
||||
});
|
||||
|
||||
test("what a contribution may ask for, and what is refused", () => {
|
||||
assert.deepEqual(askedFor({}), { access: "read", buckets: [] });
|
||||
assert.deepEqual(askedFor({ access: "read-write", buckets: ["b", "a", "a"] }), { access: "read-write", buckets: ["a", "b"] });
|
||||
assert.throws(() => askedFor({ access: "admin" }), /access/);
|
||||
assert.throws(() => askedFor({ access: "write" }), /names no bucket/);
|
||||
assert.throws(() => askedFor({ buckets: "zurag" }), /list of bucket names/);
|
||||
assert.throws(() => askedFor({ access: "write", buckets: ["_monitoring"] }), /system bucket/);
|
||||
});
|
||||
|
||||
test("a reader is given one authorization, reading every bucket of the org, under the mesh's password", async () => {
|
||||
assert.equal(await grants.ensure(grafana()), "created");
|
||||
const a = only("mesh_ace_grafana");
|
||||
assert.equal(a.orgID, "org1");
|
||||
assert.equal(a.status, "active");
|
||||
assert.ok(a.description.startsWith(MARK));
|
||||
assert.deepEqual(perms(a), ["read:buckets:*"]);
|
||||
assert.equal(a.password, PW);
|
||||
assert.equal(await grants.holds(grafana()), true);
|
||||
});
|
||||
|
||||
test("a writer is allowed its own buckets only, and a missing one is made — never deleted", async () => {
|
||||
assert.equal(await grants.ensure(nodered(PW, { access: "write", buckets: ["zurag", "printer"] })), "created");
|
||||
const made = buckets.find((b) => b.name === "printer");
|
||||
assert.ok(made, "the missing bucket was created");
|
||||
assert.deepEqual(made!.retentionRules, [], "kept for ever: retention is the operator's choice");
|
||||
assert.deepEqual(perms(only("mesh_ace_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
|
||||
assert.equal(await grants.remove("mesh_ace_nodered"), "removed");
|
||||
assert.equal(buckets.length, 2, "withdrawing the consumer leaves every bucket and its data");
|
||||
});
|
||||
|
||||
test("applying the same grant again writes nothing", async () => {
|
||||
await grants.ensure(grafana());
|
||||
calls = [];
|
||||
assert.equal(await grants.ensure(grafana()), "unchanged");
|
||||
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
|
||||
only("mesh_ace_grafana");
|
||||
});
|
||||
|
||||
test("a rotated password is set in place; a changed access remakes only the mesh's own", async () => {
|
||||
await grants.ensure(nodered());
|
||||
const id = only("mesh_ace_nodered").id;
|
||||
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), false);
|
||||
assert.equal(await grants.ensure(nodered("rotated-password-0123456789")), "updated");
|
||||
assert.equal(only("mesh_ace_nodered").id, id, "updated, not replaced");
|
||||
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), true);
|
||||
|
||||
await grants.ensure(nodered(PW, { access: "read-write", buckets: ["zurag"] }));
|
||||
assert.deepEqual(perms(only("mesh_ace_nodered")), ["read:buckets:zb", "write:buckets:zb"]);
|
||||
assert.equal(await grants.holds(nodered(PW, { access: "read-write", buckets: ["zurag"] })), true);
|
||||
});
|
||||
|
||||
test("an authorization disabled, re-passworded or deleted behind the mesh's back is not held, and is made whole", async () => {
|
||||
await grants.ensure(grafana());
|
||||
only("mesh_ace_grafana").status = "inactive";
|
||||
assert.equal(await grants.holds(grafana()), false);
|
||||
assert.equal(await grants.ensure(grafana()), "updated");
|
||||
assert.equal(await grants.holds(grafana()), true);
|
||||
|
||||
only("mesh_ace_grafana").password = "somebody-else-set-this";
|
||||
assert.equal(await grants.holds(grafana()), false);
|
||||
await grants.ensure(grafana());
|
||||
assert.equal(await grants.holds(grafana()), true);
|
||||
|
||||
auths = [];
|
||||
assert.equal(await grants.holds(grafana()), false);
|
||||
assert.equal(await grants.ensure(grafana()), "created");
|
||||
});
|
||||
|
||||
test("holds only reads, and a bucket gone missing is not held rather than made", async () => {
|
||||
await grants.ensure(nodered());
|
||||
buckets = [];
|
||||
calls = [];
|
||||
assert.equal(await grants.holds(nodered()), false);
|
||||
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
|
||||
assert.equal(buckets.length, 0);
|
||||
});
|
||||
|
||||
test("an authorization of the same name the mesh did not make is refused, and left exactly as it was", async () => {
|
||||
auths = [{ id: "theirs", token: "mesh_ace_grafana", orgID: "org1", status: "active", description: "hand-made",
|
||||
permissions: [{ action: "write", resource: { type: "buckets", orgID: "org1" } }], password: "their-password" }];
|
||||
const before = JSON.stringify(auths);
|
||||
await assert.rejects(grants.ensure(grafana()), /did not make/);
|
||||
assert.equal(JSON.stringify(auths), before);
|
||||
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
|
||||
assert.equal(await grants.holds(grafana()), false);
|
||||
assert.equal(await grants.remove("mesh_ace_grafana"), "not ours");
|
||||
assert.equal(auths.length, 1, "never deleted");
|
||||
});
|
||||
|
||||
test("the predecessor's own v1 users and tokens are never touched", async () => {
|
||||
auths = [{ id: "hal", token: "grafana", orgID: "org1", status: "active", description: "",
|
||||
permissions: [{ action: "read", resource: { type: "buckets", orgID: "org1" } }], password: "old-password" }];
|
||||
await grants.ensure(grafana());
|
||||
assert.equal(auths.find((a) => a.id === "hal")!.password, "old-password");
|
||||
assert.equal(await grants.remove("grafana"), "not ours");
|
||||
assert.equal(marked({ token: "grafana", description: `${MARK} x` }), false, "the mark needs the mesh's name too");
|
||||
});
|
||||
|
||||
test("an org the instance does not have, or a non-mesh name, makes nothing", async () => {
|
||||
const elsewhere = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMIN, "nope"), "nope");
|
||||
await assert.rejects(elsewhere.ensure(grafana()), /no org "nope"/);
|
||||
await assert.rejects(grants.ensure({ ...grafana(), as: "grafana" }), /not a mesh identity/);
|
||||
assert.equal(auths.length, 0);
|
||||
});
|
||||
|
||||
test("a withdrawn consumer's authorization is removed, and an absent one is not an error", async () => {
|
||||
await grants.ensure(grafana());
|
||||
assert.equal(await grants.remove("mesh_ace_grafana"), "removed");
|
||||
assert.equal(auths.length, 0);
|
||||
assert.equal(await grants.remove("mesh_ace_grafana"), "absent");
|
||||
});
|
||||
@@ -8,10 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"client.ts",
|
||||
"grants.ts",
|
||||
"provisioner/index.ts",
|
||||
"tools/index.ts"
|
||||
]
|
||||
"include": ["client.ts", "tools/index.ts"]
|
||||
}
|
||||
|
||||
+16
-53
@@ -2,8 +2,7 @@
|
||||
// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client
|
||||
// talks its /api/v2.0 REST API, and only jackett's tools import it.
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface JackettIndexer {
|
||||
id: string;
|
||||
@@ -44,36 +43,18 @@ export class JackettClient {
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and
|
||||
* the key must be present. The key is read from the settings-merged config or MESH_JACKETT_API_KEY,
|
||||
* or, failing those, discovered from Jackett's own ServerConfig.json under MESH_JACKETT_CONFIG_DIR
|
||||
* — the file Jackett writes it to, as sonarr/radarr read theirs from config.xml — so a running
|
||||
* server needs no key configured by hand and no secret has to be put in an assignment. Without a
|
||||
* URL or key there is nothing to talk to, so this throws and the module contributes no tools
|
||||
* rather than failing half-configured.
|
||||
* the key must be present — without them there is nothing to talk to, so this throws and the
|
||||
* module contributes no tools rather than failing half-configured.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
|
||||
const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_JACKETT_URL;
|
||||
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY
|
||||
?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config");
|
||||
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY;
|
||||
if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL");
|
||||
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY or make the config dir readable");
|
||||
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY");
|
||||
return new JackettClient(url, apiKey);
|
||||
}
|
||||
|
||||
/** Discover the API key from Jackett's ServerConfig.json (the linuxserver image keeps it at
|
||||
* <config>/Jackett/ServerConfig.json), falling back to null. */
|
||||
static detectApiKey(configDir: string): string | null {
|
||||
for (const file of [join(configDir, "Jackett", "ServerConfig.json"), join(configDir, "ServerConfig.json")]) {
|
||||
if (!existsSync(file)) continue;
|
||||
try {
|
||||
const key = (JSON.parse(readFileSync(file, "utf8")) as { APIKey?: unknown }).APIKey;
|
||||
if (typeof key === "string" && key) return key;
|
||||
} catch { /* unreadable or mid-write: try the next, then give up */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
url.searchParams.set("apikey", this.apiKey);
|
||||
@@ -83,36 +64,18 @@ export class JackettClient {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured indexers Jackett proxies. `configured=false` also lists the ones not set up.
|
||||
* Read from the Torznab `t=indexers` feed, not /api/v2.0/indexers: that one is the web UI's and
|
||||
* wants a login cookie (it answers an API-key request with a redirect), while the Torznab feed is
|
||||
* what the key is for. The feed carries no last error, so `lastError` stays unset.
|
||||
*/
|
||||
/** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */
|
||||
async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> {
|
||||
const url = new URL(`${this.baseUrl}/api/v2.0/indexers/all/results/torznab/api`);
|
||||
url.searchParams.set("apikey", this.apiKey);
|
||||
url.searchParams.set("t", "indexers");
|
||||
url.searchParams.set("configured", configuredOnly ? "true" : "false");
|
||||
const res = await fetch(url.toString(), { headers: { Accept: "application/xml" } });
|
||||
if (!res.ok) throw new Error(`Jackett API torznab t=indexers: ${res.status} ${await res.text()}`);
|
||||
const xml = await res.text();
|
||||
// Torznab reports failures (a wrong key among them) as 200 with an <error> body.
|
||||
const err = xml.match(/<error code="(\d+)" description="([^"]*)"/);
|
||||
if (err) throw new Error(`Jackett API torznab t=indexers: error ${err[1]} ${err[2]}`);
|
||||
const text = (block: string, tag: string) =>
|
||||
block.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
|
||||
const out: JackettIndexer[] = [];
|
||||
for (const m of xml.matchAll(/<indexer id="([^"]+)" configured="([^"]+)">([\s\S]*?)<\/indexer>/g)) {
|
||||
out.push({
|
||||
id: m[1],
|
||||
name: text(m[3], "title") ?? m[1],
|
||||
type: text(m[3], "type") ?? "unknown",
|
||||
configured: m[2] === "true",
|
||||
siteLink: text(m[3], "link"),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" });
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
return list.map((i: any) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
type: i.type,
|
||||
configured: i.configured ?? false,
|
||||
siteLink: i.site_link,
|
||||
lastError: i.last_error || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
{
|
||||
"module": "jackett",
|
||||
"version": "1",
|
||||
"provides": [
|
||||
{
|
||||
"name": "jackett-api",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"jackett-api": {
|
||||
"scheme": "http",
|
||||
"port": 9117,
|
||||
"url-base": ""
|
||||
}
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
@@ -23,7 +10,7 @@
|
||||
"port": 9117,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through, which other modules reach as jackett-api"
|
||||
"why": "the indexer proxy"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -33,15 +20,10 @@
|
||||
"path": "/var/lib/mesh/jackett",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "directory",
|
||||
"path": "/services/jackett/config",
|
||||
"mode": "0700",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
@@ -49,7 +31,7 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "jackett",
|
||||
"image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583",
|
||||
"image": "lscr.io/linuxserver/jackett@sha256:fd72d42b731ebf750b5de9711127251cf3b3f609419c32083ea8b3b3ee840b77",
|
||||
"env": {
|
||||
"PUID": "1000",
|
||||
"PGID": "1000",
|
||||
@@ -59,13 +41,13 @@
|
||||
"9117"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:config}:/config"
|
||||
"/services/jackett/config:/config"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/config.json",
|
||||
"path": "/var/lib/mesh/jackett/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
@@ -77,12 +59,12 @@
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
|
||||
"${dir:state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:config}:/var/lib/jackett/config:ro"
|
||||
"/var/lib/mesh/jackett/config.json:/run/config/config.json:ro",
|
||||
"/services/jackett/config:/var/lib/jackett/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
|
||||
"MESH_JACKETT_URL": "http://127.0.0.1:9117",
|
||||
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
|
||||
},
|
||||
@@ -105,7 +87,7 @@
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "${dir:state}/route.json"
|
||||
"route": "/var/lib/mesh/jackett/route.json"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
@@ -9,7 +9,7 @@ export function getJackettTools(jackett: JackettClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "jackett_indexers",
|
||||
description: "List the indexers Jackett proxies, with their type and site.",
|
||||
description: "List the indexers Jackett proxies, with their type and any last error.",
|
||||
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
|
||||
run: async (args) => {
|
||||
const indexers = await jackett.getIndexers(!args.all);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// The Letta API client — letta's own code, living in the module (novox/hq ADR 0039). Its tools
|
||||
// import it; nothing outside letta does.
|
||||
//
|
||||
// Letta authenticates with a single server password. That password is a mesh own-secret handed to
|
||||
// both the server (LETTA_SERVER_PASSWORD) and this client, through the runtime config file the mesh
|
||||
// mounts (its `password` key) — so the module's tools are live without anything configured by hand.
|
||||
// Where a server already has clients, the password is accepted rather than minted.
|
||||
// Letta authenticates with a single server password, presented as a Bearer token. That password is
|
||||
// a mesh own-secret, minted once and handed to both the server (LETTA_SERVER_PASSWORD) and this
|
||||
// client (MESH_LETTA_PASSWORD) — so the module's tools are live without anything configured by hand.
|
||||
// The runtime config file may still override the URL or password.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -58,10 +58,6 @@ export class LettaClient {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// The server's --secure mode checks X-BARE-PASSWORD ("password <it>") and answers a Bearer
|
||||
// token alone with 401 (letta/server/rest_api/app.py, 0.6.x). Both are sent: Bearer is what
|
||||
// later servers read.
|
||||
"X-BARE-PASSWORD": `password ${this.password}`,
|
||||
Authorization: `Bearer ${this.password}`,
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
},
|
||||
|
||||
+25
-21
@@ -5,28 +5,21 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"requires": [
|
||||
"postgres-database",
|
||||
"route"
|
||||
"postgres-database"
|
||||
],
|
||||
"contributes": {
|
||||
"postgres-database": {
|
||||
"name": "letta"
|
||||
},
|
||||
"route": {
|
||||
"label": "letta",
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "${dir:state}/database.json",
|
||||
"route": "${dir:state}/route.json"
|
||||
"postgres-database": "/var/lib/letta/database.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "${dir:state}/database.secret"
|
||||
"postgres-database": "/var/lib/letta/database.secret"
|
||||
},
|
||||
"own-secrets": {
|
||||
"server-password": "${dir:state}/server-password.secret",
|
||||
"openai-api-key": "${dir:state}/openai-api-key.secret",
|
||||
"server-password": "/var/lib/letta/server-password.secret",
|
||||
"broker": "/var/lib/mesh/letta/broker"
|
||||
},
|
||||
"listens": [
|
||||
@@ -35,7 +28,7 @@
|
||||
"port": 8283,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the Letta agent server REST API and web UI, password-protected (--secure); a public name is the route's"
|
||||
"why": "the Letta agent server REST API and web UI; a public name is a route grant later"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -48,15 +41,15 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
"path": "/var/lib/letta",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/server.env",
|
||||
"path": "/var/lib/letta/server.env",
|
||||
"mode": "0600",
|
||||
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nOPENAI_API_KEY=${secret:openai-api-key}\nSECURE=true\nTZ=Europe/Brussels\n"
|
||||
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nSECURE=true\nTZ=Europe/Brussels\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
@@ -67,24 +60,31 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "letta",
|
||||
"image": "letta/letta@sha256:bfd1e49ce45b9a208c941e832c1d1d194017ff210a3784b0ca6c323aed767a29",
|
||||
"image": "letta/letta@sha256:1d2e0692514287c5ed1a483e14e16ed945f8632d315539f5e66373bb7d7c471b",
|
||||
"network": "letta",
|
||||
"env-file": [
|
||||
"${dir:state}/server.env"
|
||||
"/var/lib/letta/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"8283"
|
||||
],
|
||||
"secrets-in-environment": "letta 0.6.x reads its settings from the environment only (pydantic settings, no secrets_dir or _FILE twin), and its startup.sh starts an embedded PostgreSQL unless LETTA_PG_URI is set - so the database password travels inside that URI (startup.sh also echoes it to the log); LETTA_SERVER_PASSWORD and OPENAI_API_KEY have no file source either"
|
||||
"secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted"
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/letta/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/letta/runtime.env",
|
||||
"mode": "0600",
|
||||
"content": "MESH_LETTA_PASSWORD=${secret:server-password}\n"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
@@ -99,10 +99,14 @@
|
||||
"MESH_LETTA_URL": "http://letta:8283",
|
||||
"MESH_LETTA_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"env-file": [
|
||||
"/var/lib/letta/runtime.env"
|
||||
],
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
"artifact": "runtime",
|
||||
"secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
"port": 7080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here — everything else 301s to https and would loop a proxy"
|
||||
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy"
|
||||
},
|
||||
{
|
||||
"name": "web-tls",
|
||||
@@ -315,7 +315,10 @@
|
||||
"${dir:data-data}:/data",
|
||||
"${dir:data-dkim}:/dkim"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "imap",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"module": "matrix",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "client",
|
||||
"port": 6167,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "Conduit's client-server and federation APIs over plain HTTP. Both arrive through the route on 443: Conduit answers /.well-known/matrix/server with <its name>:443, so other homeservers federate through the proxy and nothing needs the traditional 8448"
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"port": 80,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "Element Web, the static browser client, served by the image's nginx; reached through its route"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"homeserver": {
|
||||
"label": "matrix",
|
||||
"endpoint": "client"
|
||||
},
|
||||
"element": {
|
||||
"label": "element",
|
||||
"endpoint": "web"
|
||||
}
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "db",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "conduit-conf",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/conduit.toml",
|
||||
"mode": "0644",
|
||||
"content": "# Written by the mesh (modules/matrix). Conduit reads this file (CONDUIT_CONFIG); nothing comes\n# from the environment. server_name is the homeserver's permanent identity: every user id, room id\n# and signature in the database carries it, so it is the name this module is served under\n# (${bound:route:name-homeserver}) and never changes once a database exists.\n[global]\nserver_name = \"${bound:route:name-homeserver}\"\ndatabase_backend = \"rocksdb\"\ndatabase_path = \"/var/lib/matrix-conduit/\"\naddress = \"0.0.0.0\"\nport = 6167\nmax_request_size = 20000000\nallow_registration = false\nallow_federation = true\nallow_check_for_updates = true\ntrusted_servers = [\"matrix.org\"]\n"
|
||||
},
|
||||
{
|
||||
"id": "element-conf",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/element.json",
|
||||
"mode": "0644",
|
||||
"merge": "json",
|
||||
"content": "{\n \"default_server_name\": \"${bound:route:name-homeserver}\",\n \"default_server_config\": {\n \"m.homeserver\": {\n \"base_url\": \"https://${bound:route:name-homeserver}\"\n },\n \"m.identity_server\": {\n \"base_url\": \"https://vector.im\"\n }\n },\n \"brand\": \"Element\",\n \"integrations_ui_url\": \"https://scalar.vector.im/\",\n \"integrations_rest_url\": \"https://scalar.vector.im/api\",\n \"integrations_widgets_urls\": [\n \"https://scalar.vector.im/_matrix/integrations/v1\",\n \"https://scalar.vector.im/api\",\n \"https://scalar-staging.vector.im/_matrix/integrations/v1\",\n \"https://scalar-staging.vector.im/api\",\n \"https://scalar-staging.riot.im/scalar/api\"\n ],\n \"bug_report_endpoint_url\": \"https://element.io/bugreports/submit\",\n \"uisi_autorageshake_app\": \"element-auto-uisi\",\n \"show_labs_settings\": true,\n \"room_directory\": {\n \"servers\": [\n \"${bound:route:name-homeserver}\",\n \"matrix.org\",\n \"gitter.im\",\n \"libera.chat\"\n ]\n },\n \"enable_presence_by_hs_url\": {\n \"https://matrix.org\": false,\n \"https://matrix-client.matrix.org\": false\n },\n \"terms_and_conditions_links\": [\n {\n \"url\": \"https://element.io/privacy\",\n \"text\": \"Privacy Policy\"\n },\n {\n \"url\": \"https://element.io/cookie-policy\",\n \"text\": \"Cookie Policy\"\n }\n ],\n \"features\": {\n \"feature_video_rooms\": true,\n \"feature_rust_crypto\": true\n },\n \"element_call\": {\n \"url\": \"https://call.element.dev\"\n }\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
"name": "matrix"
|
||||
},
|
||||
{
|
||||
"id": "homeserver",
|
||||
"type": "container",
|
||||
"name": "matrix",
|
||||
"image": "matrixconduit/matrix-conduit@sha256:b0d24248e94f944ca49f90f10c429e3d65f4472bdde25661ecea9840134fb133",
|
||||
"network": "matrix",
|
||||
"env": {
|
||||
"CONDUIT_CONFIG": "/etc/conduit/conduit.toml"
|
||||
},
|
||||
"ports": [
|
||||
"6167"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:db}:/var/lib/matrix-conduit",
|
||||
"${dir:state}/conduit.toml:/etc/conduit/conduit.toml:ro"
|
||||
],
|
||||
"restart-on": [
|
||||
"conduit-conf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "element",
|
||||
"type": "container",
|
||||
"name": "element-web",
|
||||
"image": "vectorim/element-web@sha256:a8f415462ab8d2600a592ba1b92bea51efe5a4d10eb738aab9bed769f7099613",
|
||||
"network": "matrix",
|
||||
"ports": [
|
||||
"80"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:state}/element.json:/app/config.json:ro"
|
||||
],
|
||||
"restart-on": [
|
||||
"element-conf"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# The console (novox/hq ADR 0152, design 34): the mesh's tools for whoever is on a machine, served
|
||||
# over MCP on that machine's loopback.
|
||||
#
|
||||
# **Nothing is compiled here.** The console is the tool runtime's own client — `mesh serve` — which
|
||||
# the runtime image already carries beside the runtime it runs modules with. This recipe changes the
|
||||
# program the image starts and nothing else, so the console is exactly the client a person can run by
|
||||
# hand, started by the mesh instead, on the credential the mesh sealed to the machine.
|
||||
#
|
||||
# One base, named rather than pinned: the mesh answers with the copy it holds (novox/hq issue 044).
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
ENTRYPOINT ["node", "dist/mesh.js"]
|
||||
@@ -1,38 +0,0 @@
|
||||
# mesh-console
|
||||
|
||||
The mesh's tools, on the machine a person sits at, served by a module the mesh assigned there
|
||||
(novox/hq [ADR 0152](https://git.novox.be/novox/hq), design 34).
|
||||
|
||||
Assign it to a machine and an agent on that machine has the mesh's tools at
|
||||
`http://127.0.0.1:<port>/mcp` — MCP over HTTP, `initialize`, `tools/list`, `tools/call`. A person at
|
||||
a terminal reaches the same endpoint with `mesh tools --console http://127.0.0.1:<port>` and
|
||||
`mesh call <module>.<tool> --console …`, with no credential of their own: the console holds it.
|
||||
|
||||
## What it is
|
||||
|
||||
The tool runtime's own client, `mesh serve`, started by the mesh on the credential it sealed to the
|
||||
machine for `<node>.mesh-console`. The manifest says three things nothing else in the catalogue says
|
||||
together:
|
||||
|
||||
- `invokes: ["*"]` — it calls every tool on the mesh, and the bus grants exactly that publish side;
|
||||
- a listener `from: machine` — loopback only, and the filter opens nothing for it;
|
||||
- no `emits`, no `consumes`, no `tools` — nothing on the bus can address it.
|
||||
|
||||
**Loopback is the authority boundary.** Whoever can connect is on the machine, and whoever is on the
|
||||
machine is the account that owns the mesh there (ADR 0034, ADR 0144). There is no token and no login,
|
||||
and `mesh serve` refuses to bind anything but a loopback address.
|
||||
|
||||
## What it lists
|
||||
|
||||
What the running modules answer: every tool runtime serves a `tools` verb for its module, and the
|
||||
console asks the catalogue which modules the mesh holds and each module what it serves. A module that
|
||||
did not answer — not assigned, not up, or built before the runtime answered `tools` — is named in the
|
||||
list's `_meta.notAnswering` and can still be called by `<module>.<tool>`.
|
||||
|
||||
The mesh's own verbs (`status`, `push`, `assign`) are the `mesh-controller` seat's tools under
|
||||
ADR 0132 and are not served on the bus yet; they appear here when they are.
|
||||
|
||||
## Port
|
||||
|
||||
The manifest declares port 4270 and the mesh assigns the machine port as it does for any listener;
|
||||
the console binds `127.0.0.1:${port:4270}`. `node show <machine>` says which port a machine was given.
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"module": "mesh-console",
|
||||
"version": "1",
|
||||
"slug": "console",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"invokes": [
|
||||
"*"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/mesh-console/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "mcp",
|
||||
"port": 4270,
|
||||
"protocol": "tcp",
|
||||
"from": "machine",
|
||||
"why": "the mesh's tools for whoever is on this machine, over MCP on loopback; the machine's login is the authority (novox/hq ADR 0152)"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/mesh-console",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "mesh-console",
|
||||
"network": "host",
|
||||
"args": [
|
||||
"serve"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_CONSOLE_LISTEN": "127.0.0.1:${port:4270}"
|
||||
},
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mesh-console/broker:/run/secrets/broker:ro"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,12 +130,12 @@
|
||||
"network": "mosquitto",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro",
|
||||
"${dir:grants}:${dir: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",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||
"MESH_RECEIVES": "/var/lib/mosquitto-module/grants/mesh.json",
|
||||
"MESH_PROVISION_MQTT": "mosquitto:1883",
|
||||
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
|
||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
+12
-11
@@ -21,23 +21,23 @@
|
||||
"listens": [
|
||||
{
|
||||
"name": "database",
|
||||
"port": 1433,
|
||||
"port": 4848,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"mssql-database": {}
|
||||
},
|
||||
"receives": {
|
||||
"mssql-database": "${dir:grants}/mesh.json"
|
||||
"mssql-database": "/var/lib/mssql/grants/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"mssql-database": "${dir:grants}"
|
||||
"mssql-database": "/var/lib/mssql/grants"
|
||||
},
|
||||
"own-secrets": {
|
||||
"sa": "${dir:state}/sa.secret",
|
||||
"sa": "/var/lib/mssql/sa.secret",
|
||||
"broker": "/var/lib/mesh/mssql/broker"
|
||||
},
|
||||
"resources": [
|
||||
@@ -50,18 +50,19 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
"path": "/var/lib/mssql",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mssql/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "sa-env",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/sa.env",
|
||||
"path": "/var/lib/mssql/sa.env",
|
||||
"mode": "0600",
|
||||
"content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n"
|
||||
},
|
||||
@@ -83,7 +84,7 @@
|
||||
"image": "mcr.microsoft.com/mssql/server@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090",
|
||||
"network": "mssql",
|
||||
"env-file": [
|
||||
"${dir:state}/sa.env"
|
||||
"/var/lib/mssql/sa.env"
|
||||
],
|
||||
"ports": [
|
||||
"1433"
|
||||
@@ -100,8 +101,8 @@
|
||||
"network": "mssql",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mssql/broker:/run/secrets/broker:ro",
|
||||
"${dir:grants}:/var/lib/mssql/grants:ro",
|
||||
"${dir:state}/sa.secret:/run/secrets/sa:ro"
|
||||
"/var/lib/mssql/grants:/var/lib/mssql/grants:ro",
|
||||
"/var/lib/mssql/sa.secret:/run/secrets/sa:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
|
||||
|
||||
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/nodered
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts mqtt/probe.ts mqtt/connection.ts mqtt/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
@@ -22,6 +22,3 @@ COPY --from=build /app/modules/nodered/dist /app/modules/nodered/dist
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/nodered/dist/tools/index.js
|
||||
# NOT dist/mqtt/index.js: that is a step the host runs to completion, named by the `mqtt`
|
||||
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
|
||||
# serving sidecar too, and exit it.
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
//
|
||||
// Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow
|
||||
// configuration, GET /nodes for installed node modules. A default install has no auth; when
|
||||
// adminAuth is on, a bearer token is required — the module's settings accept the mesh-minted
|
||||
// api-token, which the runtime config file carries as `token`.
|
||||
// adminAuth is on, a bearer token (minted at /auth/token) is required.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -82,35 +81,6 @@ export class NodeRedClient {
|
||||
return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] }));
|
||||
}
|
||||
|
||||
/** The whole flow configuration with its revision (API v2), for a deploy that must not clobber
|
||||
* a change made meanwhile. */
|
||||
async flowsWithRev(): Promise<{ rev: string; flows: any[] }> {
|
||||
const body = await this.req("/flows", { headers: this.headers({ "Node-RED-API-Version": "v2" }) });
|
||||
return { rev: String(body?.rev ?? ""), flows: Array.isArray(body?.flows) ? body.flows : [] };
|
||||
}
|
||||
|
||||
/** A node's stored credentials as Node-RED shows them: plain fields, and `has_<field>` for secret ones. */
|
||||
async credentials(type: string, id: string): Promise<{ user?: string; has_password?: boolean }> {
|
||||
return (await this.req(`/credentials/${encodeURIComponent(type)}/${encodeURIComponent(id)}`, { headers: this.headers() })) ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy the flow configuration read at `rev`. Node-RED answers 409 when the flows changed since,
|
||||
* rather than overwriting what someone deployed in between. A node carrying `credentials` has them
|
||||
* stored (encrypted) and counts as changed, so a "nodes" deploy restarts it and nothing else.
|
||||
*/
|
||||
async deployFlowsAt(rev: string, config: any[], type = "nodes"): Promise<void> {
|
||||
await this.req("/flows", {
|
||||
method: "POST",
|
||||
headers: this.headers({
|
||||
"Content-Type": "application/json",
|
||||
"Node-RED-API-Version": "v2",
|
||||
"Node-RED-Deployment-Type": type,
|
||||
}),
|
||||
body: JSON.stringify({ rev, flows: config }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to
|
||||
* Node-RED's deployment types — "full" (default), "nodes", or "flows".
|
||||
@@ -118,13 +88,8 @@ export class NodeRedClient {
|
||||
async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> {
|
||||
const body = await this.req("/flows", {
|
||||
method: "POST",
|
||||
// v2 answers { rev }; v1 answers 204 with no body, which req() cannot parse.
|
||||
headers: this.headers({
|
||||
"Content-Type": "application/json",
|
||||
"Node-RED-API-Version": "v2",
|
||||
"Node-RED-Deployment-Type": type,
|
||||
}),
|
||||
body: JSON.stringify({ flows: config }),
|
||||
headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }),
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return { rev: body?.rev, nodeCount: config.length };
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
"flows.deployed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/mesh/nodered/admin",
|
||||
"api-token": "/var/lib/mesh/nodered/api-token",
|
||||
"broker": "/var/lib/mesh/nodered/broker"
|
||||
},
|
||||
"capabilities": [
|
||||
@@ -28,63 +26,26 @@
|
||||
"path": "/var/lib/mesh/nodered",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/nodered/data",
|
||||
"mode": "0700",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
{
|
||||
"id": "written",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "settings-code",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.js",
|
||||
"mode": "0600",
|
||||
"owner": "1000:1000",
|
||||
"content": "// Node-RED's settings, written by the mesh from the nodered module. What an assignment may change\n// is settings.json beside this file (merged key by key); the credentials are the mesh's secrets and\n// reach Node-RED only through this file. The flows' own credentials stay encrypted in the user\n// directory under the key Node-RED keeps there (.config.runtime.json), which is data, not this.\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst crypto = require(\"crypto\");\n\nconst ADMIN_PASSWORD = \"${secret:admin}\";\nconst API_TOKEN = \"${secret:api-token}\";\nconst ADMIN = { username: \"admin\", permissions: \"*\" };\n\nconst settings = JSON.parse(fs.readFileSync(path.join(__dirname, \"settings.json\"), \"utf8\"));\n// The mesh's keys, not Node-RED's: endpoints lands in every merged file; timeZone is the\n// assignment's way to set the zone flows schedule and format in; mqtt names the broker nodes the\n// module's MQTT step keeps pointed at the mesh's broker; topics is what nodered asks the broker for.\nif (settings.timeZone) process.env.TZ = settings.timeZone;\ndelete settings.timeZone;\ndelete settings.endpoints;\ndelete settings.mqtt;\ndelete settings.topics;\n\nfunction same(a, b) {\n const x = crypto.createHash(\"sha256\").update(String(a)).digest();\n const y = crypto.createHash(\"sha256\").update(String(b)).digest();\n return crypto.timingSafeEqual(x, y);\n}\n\n// The admin secret is a password, or, accepted from an existing install, the bcrypt hash its\n// settings held, so the password people already use keeps working.\nfunction passwordMatches(given) {\n if (/^\\$2[aby]\\$\\d\\d\\$/.test(ADMIN_PASSWORD)) return require(\"bcryptjs\").compare(String(given), ADMIN_PASSWORD);\n return Promise.resolve(same(given, ADMIN_PASSWORD));\n}\n\nmodule.exports = Object.assign(settings, {\n uiPort: 1880,\n adminAuth: {\n type: \"credentials\",\n users: (username) => Promise.resolve(username === ADMIN.username ? ADMIN : null),\n authenticate: (username, password) =>\n username === ADMIN.username\n ? passwordMatches(password).then((ok) => (ok ? ADMIN : null))\n : Promise.resolve(null),\n // The module's own tools call the admin API with this bearer token.\n tokens: (token) => Promise.resolve(same(token, API_TOKEN) ? { username: \"mesh\", permissions: \"*\" } : null),\n },\n});\n"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "1000:1000",
|
||||
"merge": "json",
|
||||
"content": "{\n \"flowFile\": \"flows.json\",\n \"flowFilePretty\": true,\n \"diagnostics\": { \"enabled\": true, \"ui\": true },\n \"runtimeState\": { \"enabled\": false, \"ui\": false },\n \"logging\": { \"console\": { \"level\": \"info\", \"metrics\": false, \"audit\": false } },\n \"exportGlobalContextKeys\": false,\n \"externalModules\": {},\n \"editorTheme\": { \"projects\": { \"enabled\": false } },\n \"functionExternalModules\": true,\n \"debugMaxLength\": 1000,\n \"mqttReconnectTime\": 15000,\n \"serialReconnectTime\": 15000\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "nodered",
|
||||
"image": "nodered/node-red@sha256:a649dd711d55490151a2c39a8e48ad0c44325488fbc0e66315f2d2e19e5e1ace",
|
||||
"image": "nodered/node-red@sha256:02a2b92a41b73d2bc388238b86e4fcaab7fb5466373adb24e1df6aa5845265ff",
|
||||
"env": {
|
||||
"TZ": "Etc/UTC"
|
||||
},
|
||||
"ports": [
|
||||
"1880"
|
||||
],
|
||||
"args": [
|
||||
"--settings",
|
||||
"/config/settings.js"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:data}:/data",
|
||||
"${dir:state}/settings.js:/config/settings.js:ro",
|
||||
"${dir:state}/settings.json:/config/settings.json:ro"
|
||||
],
|
||||
"restart-on": [
|
||||
"settings-code",
|
||||
"settings"
|
||||
"/services/nodered/data:/data"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -92,7 +53,8 @@
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/nodered/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{\n \"token\": \"${secret:api-token}\"\n}\n"
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
@@ -105,66 +67,26 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
|
||||
"MESH_NODERED_URL": "http://127.0.0.1:1880",
|
||||
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
},
|
||||
{
|
||||
"id": "mqtt",
|
||||
"type": "container",
|
||||
"name": "mesh-nodered-mqtt",
|
||||
"network": "host",
|
||||
"run-once": true,
|
||||
"volumes": [
|
||||
"/var/lib/mesh/nodered/config.json:/run/config/config.json:ro",
|
||||
"${dir:written}:/var/lib/nodered-provisions",
|
||||
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
|
||||
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
|
||||
"${dir:state}/settings.json:/run/provisions/settings.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
|
||||
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_PROVISIONS_DIR": "/run/provisions",
|
||||
"MESH_WRITTEN_DIR": "/var/lib/nodered-provisions"
|
||||
},
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/nodered/dist/mqtt/index.js"
|
||||
],
|
||||
"restart-on": [
|
||||
"bound-mqtt-topic",
|
||||
"secret-mqtt-topic",
|
||||
"settings"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"mqtt-topic",
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"mqtt-topic": {
|
||||
"topics": [
|
||||
"#"
|
||||
]
|
||||
},
|
||||
"route": {
|
||||
"label": "nodered",
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "${dir:state}/route.json",
|
||||
"mqtt-topic": "${dir:state}/mqtt-topic.json"
|
||||
},
|
||||
"secrets": {
|
||||
"mqtt-topic": "${dir:state}/mqtt-topic.secret"
|
||||
"route": "/var/lib/mesh/nodered/route.json"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
// Node-RED's MQTT broker config node, pointed at the broker the mesh bound — `mqtt-topic`.
|
||||
//
|
||||
// **Why a step.** Node-RED keeps a broker as a config node in its flows (`flows.json`) and the login
|
||||
// and password in its encrypted credentials file, both of them Node-RED's to write. So this reads the
|
||||
// binding and the pair credential and makes the broker node say the same thing through Node-RED's
|
||||
// admin API — `GET /flows`, then `POST /flows` with the changed node and its `credentials`, deployed
|
||||
// as "nodes" so only what changed restarts — with the module's own `api-token`.
|
||||
//
|
||||
// **Which broker nodes are the mesh's.** Never guessed: a flow may talk to a broker that has nothing
|
||||
// to do with this mesh. The step owns the node it creates itself (id `mesh-mqtt-topic`, "mesh:
|
||||
// mqtt-topic") and the ones an assignment names in settings (`mqtt.brokers`: node ids — how ace's
|
||||
// existing broker node, which every one of its MQTT flows uses, is handed over). With none named and
|
||||
// none made yet, it makes one, so a fresh Node-RED has a broker the flows can pick.
|
||||
//
|
||||
// **Only the connection, and only when it differs.** Host, port, TLS off (the broker serves plain
|
||||
// MQTT), login, password. Every other field of the node — client id, keepalive, birth/close/will
|
||||
// messages — is left as it is. Node-RED never hands a stored password back, so the step keeps a
|
||||
// digest of what it last wrote: equal host/port/login and an equal digest is "already as the mesh
|
||||
// says".
|
||||
//
|
||||
// **Nothing loses its connection without someone seeing it.** The broker is asked first whether it
|
||||
// takes the delivered login; if not, nothing is written and the step fails saying why.
|
||||
//
|
||||
// Pure logic over two seams (Node-RED, the broker), tested against fakes (test/mqtt.test.ts).
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { Probe } from "./probe.js";
|
||||
|
||||
export const PROVISION = "mqtt-topic";
|
||||
/** The id and name of the broker node the step makes when none is named. */
|
||||
export const MESH_BROKER_ID = "mesh-mqtt-topic";
|
||||
export const MESH_BROKER_NAME = "mesh: mqtt-topic";
|
||||
|
||||
/** What the mesh wrote at `binds.mqtt-topic`. */
|
||||
export interface Binding {
|
||||
provision?: string;
|
||||
from?: string;
|
||||
at?: string;
|
||||
as?: string;
|
||||
serves?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type Outcome =
|
||||
| { what: string; result: "unchanged"; note?: string }
|
||||
| { what: string; result: "written"; fields: string[]; note?: string }
|
||||
| { what: string; result: "refused"; problem: string };
|
||||
|
||||
/** A flow node; a broker config node carries `broker`, `port`, `usetls`. */
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Node-RED's admin API, as the step uses it. */
|
||||
export interface NodeRed {
|
||||
/** The whole flow configuration and its revision (API v2). */
|
||||
flows(): Promise<{ rev: string; flows: FlowNode[] }>;
|
||||
/** A node's stored credentials as Node-RED shows them: the user, and only whether a password is set. */
|
||||
credentials(type: string, id: string): Promise<{ user?: string; has_password?: boolean }>;
|
||||
/** Deploy the configuration against the revision it was read at; "nodes" restarts only what changed. */
|
||||
deploy(rev: string, flows: FlowNode[]): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Marks {
|
||||
get(name: string): Promise<string | undefined>;
|
||||
set(name: string, digest: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Deps {
|
||||
nodered: NodeRed;
|
||||
probe: Probe;
|
||||
marks: Marks;
|
||||
}
|
||||
|
||||
export interface Wanted {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function digest(...parts: (string | number)[]): string {
|
||||
return createHash("sha256").update(parts.map(String).join("\u0000")).digest("hex");
|
||||
}
|
||||
|
||||
function isLoopback(host: string): boolean {
|
||||
const h = host.toLowerCase();
|
||||
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
|
||||
}
|
||||
|
||||
/**
|
||||
* The broker and login the mesh says Node-RED uses. A loopback `at` — what the mesh hands a machine
|
||||
* that is not on the private network — is refused: from Node-RED's own container it is Node-RED.
|
||||
*/
|
||||
export function wanted(binding: Binding | undefined, credential: string | undefined): { ok: true; want: Wanted } | { ok: false; problem: string } {
|
||||
if (!binding) return { ok: false, problem: `no binding for ${PROVISION} was delivered — the mesh writes it before this step runs` };
|
||||
const host = typeof binding.at === "string" ? binding.at.trim() : "";
|
||||
if (!host) return { ok: false, problem: `the ${PROVISION} binding names no host (at)` };
|
||||
if (isLoopback(host)) {
|
||||
return {
|
||||
ok: false,
|
||||
problem:
|
||||
`the ${PROVISION} binding says the broker is at ${host}, which from Node-RED's own container is Node-RED ` +
|
||||
`itself; put the machine on the private network so the broker has an address Node-RED can dial`,
|
||||
};
|
||||
}
|
||||
const port = Number(binding.serves?.port);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
return { ok: false, problem: `the ${PROVISION} binding serves no usable port (${String(binding.serves?.port)})` };
|
||||
}
|
||||
const scheme = binding.serves?.scheme;
|
||||
if (scheme !== undefined && scheme !== "mqtt") return { ok: false, problem: `the ${PROVISION} binding serves scheme ${String(scheme)}; this step writes plain MQTT` };
|
||||
const user = typeof binding.as === "string" ? binding.as.trim() : "";
|
||||
if (!user) return { ok: false, problem: `the ${PROVISION} binding names no login (as)` };
|
||||
const password = (credential ?? "").replace(/\n$/, "");
|
||||
if (!password) return { ok: false, problem: `the ${PROVISION} credential is empty or was not delivered` };
|
||||
return { ok: true, want: { host, port, user, password } };
|
||||
}
|
||||
|
||||
/** The broker node ids an assignment named in settings (`mqtt.brokers`), or none. */
|
||||
export function namedBrokers(settings: unknown): string[] {
|
||||
const brokers = (settings as { mqtt?: { brokers?: unknown } } | undefined)?.mqtt?.brokers;
|
||||
return Array.isArray(brokers) ? brokers.filter((b): b is string => typeof b === "string" && b.length > 0) : [];
|
||||
}
|
||||
|
||||
/** A new broker node, Node-RED 5's defaults, pointed at the broker. */
|
||||
export function newBrokerNode(want: Wanted): FlowNode {
|
||||
return {
|
||||
id: MESH_BROKER_ID, type: "mqtt-broker", name: MESH_BROKER_NAME,
|
||||
broker: want.host, port: String(want.port), clientid: "", autoConnect: true, usetls: false,
|
||||
protocolVersion: "4", keepalive: "60", cleansession: true, autoUnsubscribe: true,
|
||||
birthTopic: "", birthQos: "0", birthRetain: "false", birthPayload: "", birthMsg: {},
|
||||
closeTopic: "", closeQos: "0", closeRetain: "false", closePayload: "", closeMsg: {},
|
||||
willTopic: "", willQos: "0", willRetain: "false", willPayload: "", willMsg: {},
|
||||
userProps: "", sessionExpiry: "",
|
||||
};
|
||||
}
|
||||
|
||||
const markFor = (id: string, w: Wanted): string => digest("nodered-mqtt", id, w.host, w.port, w.user, w.password);
|
||||
|
||||
function scrub(err: unknown, secret: string): string {
|
||||
let text = err instanceof Error ? err.message : String(err);
|
||||
for (const form of new Set([secret, encodeURIComponent(secret)])) text = text.split(form).join("***");
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the mesh's broker nodes in line with the binding: one outcome per node. Never throws. A node
|
||||
* the settings name that is not in the flows is refused (the others are still put right).
|
||||
*/
|
||||
export async function reconcileBrokers(deps: Deps, binding: Binding | undefined, credential: string | undefined, named: readonly string[]): Promise<Outcome[]> {
|
||||
const w = wanted(binding, credential);
|
||||
if ("problem" in w) return [{ what: "mqtt", result: "refused", problem: w.problem }];
|
||||
const want = w.want;
|
||||
|
||||
let note: string | undefined;
|
||||
try {
|
||||
const probe = await deps.probe(want.host, want.port, want.user, want.password, "#");
|
||||
if (probe.connack === 4 || probe.connack === 5) {
|
||||
return [{
|
||||
what: "mqtt",
|
||||
result: "refused",
|
||||
problem:
|
||||
`the broker at ${want.host}:${want.port} does not (yet) take the login ${want.user} with the delivered password ` +
|
||||
`(CONNACK ${probe.connack}); mosquitto's provisioner creates it from the grant — nothing was written`,
|
||||
}];
|
||||
}
|
||||
if (probe.connack !== 0) return [{ what: "mqtt", result: "refused", problem: `the broker at ${want.host}:${want.port} answered CONNACK ${probe.connack}; nothing was written` }];
|
||||
if (probe.suback === 0x80) note = `warning: ${want.user} may not subscribe to every topic; flows subscribing outside its grant will get nothing`;
|
||||
} catch (err) {
|
||||
return [{ what: "mqtt", result: "refused", problem: `the broker at ${want.host}:${want.port} could not be asked: ${scrub(err, want.password)}; nothing was written` }];
|
||||
}
|
||||
|
||||
const outcomes: Outcome[] = [];
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
outcomes.length = 0;
|
||||
try {
|
||||
const { rev, flows } = await deps.nodered.flows();
|
||||
const targets = named.length > 0 ? [...named] : [MESH_BROKER_ID];
|
||||
const changed: { id: string; fields: string[] }[] = [];
|
||||
for (const id of targets) {
|
||||
let node = flows.find((n) => n.id === id);
|
||||
if (node && node.type !== "mqtt-broker") {
|
||||
outcomes.push({ what: `broker ${id}`, result: "refused", problem: `node ${id} is a ${node.type}, not an mqtt-broker` });
|
||||
continue;
|
||||
}
|
||||
if (!node) {
|
||||
if (id !== MESH_BROKER_ID) {
|
||||
outcomes.push({ what: `broker ${id}`, result: "refused", problem: `the settings name broker node ${id}, and Node-RED's flows have no such node` });
|
||||
continue;
|
||||
}
|
||||
node = newBrokerNode(want);
|
||||
flows.push(node);
|
||||
node.credentials = { user: want.user, password: want.password };
|
||||
changed.push({ id, fields: ["node"] });
|
||||
continue;
|
||||
}
|
||||
const fields: string[] = [];
|
||||
if (String(node.broker ?? "") !== want.host) fields.push("broker");
|
||||
if (Number(node.port ?? 0) !== want.port) fields.push("port");
|
||||
if (node.usetls === true) fields.push("usetls");
|
||||
const creds = await deps.nodered.credentials("mqtt-broker", id);
|
||||
if ((creds.user ?? "") !== want.user) fields.push("user");
|
||||
if (!creds.has_password || (await deps.marks.get(`broker-${id}`)) !== markFor(id, want)) fields.push("password");
|
||||
if (fields.length === 0) {
|
||||
outcomes.push(note ? { what: `broker ${id}`, result: "unchanged", note } : { what: `broker ${id}`, result: "unchanged" });
|
||||
continue;
|
||||
}
|
||||
node.broker = want.host;
|
||||
node.port = String(want.port);
|
||||
node.usetls = false;
|
||||
node.credentials = { user: want.user, password: want.password };
|
||||
changed.push({ id, fields });
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
await deps.nodered.deploy(rev, flows);
|
||||
for (const c of changed) {
|
||||
await deps.marks.set(`broker-${c.id}`, markFor(c.id, want));
|
||||
outcomes.push({ what: `broker ${c.id}`, result: "written", fields: c.fields, ...(note ? { note } : {}) });
|
||||
}
|
||||
}
|
||||
return outcomes;
|
||||
} catch (err) {
|
||||
// A deploy against a revision someone else changed meanwhile (409) is read again once.
|
||||
if (attempt === 0 && /\b409\b/.test(String(err))) continue;
|
||||
return [...outcomes, { what: "mqtt", result: "refused", problem: scrub(err, want.password) }];
|
||||
}
|
||||
}
|
||||
return outcomes;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// nodered's MQTT step — run once by the host after Node-RED starts, and again whenever the
|
||||
// `mqtt-topic` binding, its pair credential or the settings change (the container's `restart-on`,
|
||||
// novox/hq ADR 0099). It points the mesh's broker config nodes at the broker the mesh bound, through
|
||||
// Node-RED's admin API (connection.ts). It connects to no mesh broker.
|
||||
//
|
||||
// Exits non-zero when anything could not be put right, so the node reports the step failed and the
|
||||
// host runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
|
||||
// else of nodered's (novox/hq ADR 0136). Never prints a password.
|
||||
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { NodeRedClient } from "../client.js";
|
||||
import { namedBrokers, reconcileBrokers, type Binding, type Marks } from "./connection.js";
|
||||
import { probeBroker } from "./probe.js";
|
||||
|
||||
const dir = process.env.MESH_PROVISIONS_DIR ?? "/run/provisions";
|
||||
const writtenDir = process.env.MESH_WRITTEN_DIR ?? "/var/lib/nodered-provisions";
|
||||
const waitSeconds = Number(process.env.MESH_NODERED_WAIT_SECONDS ?? "180");
|
||||
|
||||
const readIfThere = (path: string): Promise<string | undefined> => readFile(path, "utf8").catch(() => undefined);
|
||||
const parse = <T>(raw: string | undefined): T | undefined => {
|
||||
if (raw === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const marks: Marks = {
|
||||
async get(name) {
|
||||
return (await readIfThere(join(writtenDir, `${name}.digest`)))?.trim() || undefined;
|
||||
},
|
||||
async set(name, value) {
|
||||
await mkdir(writtenDir, { recursive: true, mode: 0o700 });
|
||||
const path = join(writtenDir, `${name}.digest`);
|
||||
await writeFile(`${path}.tmp`, `${value}\n`, { mode: 0o600 });
|
||||
await rename(`${path}.tmp`, path);
|
||||
},
|
||||
};
|
||||
|
||||
let client: NodeRedClient;
|
||||
try {
|
||||
client = NodeRedClient.fromEnv();
|
||||
} catch (err) {
|
||||
console.error(`[nodered-mqtt] ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Node-RED answers the admin API once its flows are loaded and the token is good. */
|
||||
async function ready(): Promise<boolean> {
|
||||
const until = Date.now() + waitSeconds * 1000;
|
||||
for (;;) {
|
||||
try {
|
||||
await client.flowsWithRev();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (/\b(401|403)\b/.test(String(err))) {
|
||||
console.error("[nodered-mqtt] Node-RED refuses the api-token — settings.js and this step disagree");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (Date.now() >= until) return false;
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await ready())) {
|
||||
console.error(`[nodered-mqtt] Node-RED's admin API did not answer at ${client.baseUrl} within ${waitSeconds}s`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const binding = parse<Binding>(await readIfThere(join(dir, "mqtt-topic.json")));
|
||||
const secret = await readIfThere(join(dir, "mqtt-topic.secret"));
|
||||
const settings = parse<unknown>(await readIfThere(join(dir, "settings.json")));
|
||||
|
||||
const outcomes = await reconcileBrokers(
|
||||
{
|
||||
nodered: {
|
||||
flows: () => client.flowsWithRev(),
|
||||
credentials: (type, id) => client.credentials(type, id),
|
||||
deploy: (rev, flows) => client.deployFlowsAt(rev, flows, "nodes"),
|
||||
},
|
||||
probe: probeBroker,
|
||||
marks,
|
||||
},
|
||||
binding,
|
||||
secret,
|
||||
namedBrokers(settings),
|
||||
);
|
||||
|
||||
let failed = 0;
|
||||
for (const o of outcomes) {
|
||||
if (o.result === "unchanged") console.log(`[nodered-mqtt] ${o.what}: already as the mesh says${o.note ? ` — ${o.note}` : ""}`);
|
||||
else if (o.result === "written") console.log(`[nodered-mqtt] ${o.what}: wrote ${o.fields.join(", ")}${o.note ? ` — ${o.note}` : ""}`);
|
||||
else {
|
||||
failed++;
|
||||
console.error(`[nodered-mqtt] ${o.what}: ${o.problem}`);
|
||||
}
|
||||
}
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
@@ -1,117 +0,0 @@
|
||||
// Ask the broker, before Node-RED is told anything, whether it takes the login and password the
|
||||
// mesh delivered — and whether that login may subscribe to every topic, as flows expect.
|
||||
//
|
||||
// One MQTT 3.1.1 session: CONNECT (clean, a throwaway client id, so no flow's session is taken
|
||||
// over), read the CONNACK, optionally SUBSCRIBE once and read the SUBACK, DISCONNECT. No dependency:
|
||||
// the handful of bytes MQTT needs for this are written here.
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { connect } from "node:net";
|
||||
|
||||
export interface ProbeResult {
|
||||
/** 0 accepted; 4 bad username or password; 5 not authorised. */
|
||||
connack: number;
|
||||
/** The SUBACK return code for the filter asked about: 0–2 granted, 0x80 refused. */
|
||||
suback?: number;
|
||||
}
|
||||
|
||||
export type Probe = (host: string, port: number, username: string, password: string, subscribe?: string) => Promise<ProbeResult>;
|
||||
|
||||
function str(v: string): Buffer {
|
||||
const b = Buffer.from(v, "utf8");
|
||||
const len = Buffer.alloc(2);
|
||||
len.writeUInt16BE(b.length);
|
||||
return Buffer.concat([len, b]);
|
||||
}
|
||||
|
||||
function packet(type: number, body: Buffer): Buffer {
|
||||
let remaining = body.length;
|
||||
const lenBytes: number[] = [];
|
||||
do {
|
||||
let byte = remaining % 128;
|
||||
remaining = Math.floor(remaining / 128);
|
||||
if (remaining > 0) byte |= 0x80;
|
||||
lenBytes.push(byte);
|
||||
} while (remaining > 0);
|
||||
return Buffer.concat([Buffer.from([type, ...lenBytes]), body]);
|
||||
}
|
||||
|
||||
/** The first complete packet in `buf`: its type byte, its body, and how many bytes it took. */
|
||||
export function firstPacket(buf: Buffer): { type: number; body: Buffer; used: number } | undefined {
|
||||
if (buf.length < 2) return undefined;
|
||||
let length = 0;
|
||||
let multiplier = 1;
|
||||
let i = 1;
|
||||
for (;;) {
|
||||
if (i >= buf.length) return undefined;
|
||||
const byte = buf[i++];
|
||||
length += (byte & 0x7f) * multiplier;
|
||||
if ((byte & 0x80) === 0) break;
|
||||
multiplier *= 128;
|
||||
if (i > 4) throw new Error("malformed MQTT remaining length");
|
||||
}
|
||||
if (buf.length < i + length) return undefined;
|
||||
return { type: buf[0], body: buf.subarray(i, i + length), used: i + length };
|
||||
}
|
||||
|
||||
export const probeBroker: Probe = (host, port, username, password, subscribe) => {
|
||||
const connectBody = Buffer.concat([
|
||||
str("MQTT"),
|
||||
Buffer.from([4, 0xc2, 0, 10]), // level 4 (3.1.1); username + password + clean session; keepalive 10s
|
||||
str(`mesh-probe-${randomBytes(6).toString("hex")}`),
|
||||
str(username),
|
||||
str(password),
|
||||
]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect({ host, port });
|
||||
let buf = Buffer.alloc(0);
|
||||
const result: ProbeResult = { connack: -1 };
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`no answer from the broker at ${host}:${port} within 10s`));
|
||||
}, 10_000);
|
||||
const finish = (): void => {
|
||||
clearTimeout(timer);
|
||||
if (result.connack === 0) socket.end(Buffer.from([0xe0, 0]));
|
||||
else socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
socket.on("connect", () => socket.write(packet(0x10, connectBody)));
|
||||
socket.on("data", (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
for (;;) {
|
||||
let p;
|
||||
try {
|
||||
p = firstPacket(buf);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (!p) return;
|
||||
buf = buf.subarray(p.used);
|
||||
const kind = p.type >> 4;
|
||||
if (kind === 2) {
|
||||
result.connack = p.body[1] ?? -1;
|
||||
if (result.connack !== 0 || !subscribe) return finish();
|
||||
// SUBSCRIBE, packet id 1, one filter at QoS 0.
|
||||
socket.write(packet(0x82, Buffer.concat([Buffer.from([0, 1]), str(subscribe), Buffer.from([0])])));
|
||||
} else if (kind === 9) {
|
||||
result.suback = p.body[2];
|
||||
return finish();
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
socket.on("close", () => {
|
||||
if (result.connack === -1) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`the broker at ${host}:${port} closed the connection without answering`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,14 +1,9 @@
|
||||
{
|
||||
"name": "@novox/module-nodered",
|
||||
"version": "0.1.0",
|
||||
"description": "nodered \u2014 flow-based automation. Its client and tools live here (novox/hq ADR 0039).",
|
||||
"description": "nodered — flow-based automation. Its client and tools live here (novox/hq ADR 0039).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc client.ts tools/index.ts mqtt/probe.ts mqtt/connection.ts mqtt/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.0"
|
||||
},
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
// What holds nodered's MQTT step (mqtt/connection.ts): the broker nodes the mesh owns — the one it
|
||||
// makes, or the ones settings name — are made to use the broker and login the mesh bound, only after
|
||||
// the broker takes that login; every other field of a node is kept; nothing is deployed when nothing
|
||||
// differs; a node that is not named is never touched; a loopback broker address is refused.
|
||||
//
|
||||
// Node-RED and the broker are fakes answering as the real ones do (admin API v2 of nodered/node-red
|
||||
// 5.0.7, the build ace runs).
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { MESH_BROKER_ID, namedBrokers, reconcileBrokers, type Binding, type FlowNode, type Marks, type NodeRed } from "../mqtt/connection.ts";
|
||||
import type { Probe } from "../mqtt/probe.ts";
|
||||
|
||||
const MINTED = "mesh-minted-password";
|
||||
const binding = (at = "ace.internal"): Binding => ({ provision: "mqtt-topic", from: "ace", at, as: "mesh_ace_nodered", serves: { scheme: "mqtt", port: 1883 } });
|
||||
|
||||
/** ace's flows, reduced: its one broker node (dead, zurag.be:1884) and a node that uses it. */
|
||||
function aceFlows(): FlowNode[] {
|
||||
return [
|
||||
{ id: "2b0aece9c5f3b307", type: "mqtt-broker", name: "MQTT Broker", broker: "zurag.be", port: "1884", clientid: "", usetls: false, protocolVersion: "4", keepalive: "60" },
|
||||
{ id: "fe0cae96f1e3ae4d", type: "mqtt in", topic: "stat/sonoff_office_light_switch/RESULT", broker: "2b0aece9c5f3b307", z: "t" },
|
||||
{ id: "other-broker", type: "mqtt-broker", name: "someone else's", broker: "test.mosquitto.org", port: "1883" },
|
||||
];
|
||||
}
|
||||
|
||||
function fakeNodeRed(flows: FlowNode[], creds: Record<string, { user?: string; password?: string }> = {}) {
|
||||
let rev = "r1";
|
||||
const deploys: FlowNode[][] = [];
|
||||
const nodered: NodeRed = {
|
||||
async flows() {
|
||||
return { rev, flows: structuredClone(flows) };
|
||||
},
|
||||
async credentials(_type, id) {
|
||||
const c = creds[id] ?? {};
|
||||
return { user: c.user, has_password: Boolean(c.password) };
|
||||
},
|
||||
async deploy(at, next) {
|
||||
if (at !== rev) throw new Error("Node-RED /flows: 409 version_mismatch");
|
||||
for (const n of next) {
|
||||
if (n.credentials) creds[n.id] = { ...(creds[n.id] ?? {}), ...(n.credentials as object) };
|
||||
}
|
||||
flows.splice(0, flows.length, ...next.map(({ credentials: _c, ...n }) => n as FlowNode));
|
||||
deploys.push(next);
|
||||
rev = `r${deploys.length + 1}`;
|
||||
},
|
||||
};
|
||||
return { nodered, deploys, flows, creds };
|
||||
}
|
||||
|
||||
const marks = (): Marks & { store: Map<string, string> } => {
|
||||
const store = new Map<string, string>();
|
||||
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
|
||||
};
|
||||
const takes: Probe = async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_nodered" && pass === MINTED ? 0 : 5, suback: 0 });
|
||||
|
||||
test("ace: the named broker node is moved to the bound broker and login; the other broker is not touched", async () => {
|
||||
const f = fakeNodeRed(aceFlows(), { "2b0aece9c5f3b307": { user: "luffy", password: "old" }, "other-broker": { user: "x", password: "y" } });
|
||||
const m = marks();
|
||||
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: m }, binding(), `${MINTED}\n`, ["2b0aece9c5f3b307"]);
|
||||
assert.deepEqual(out, [{ what: "broker 2b0aece9c5f3b307", result: "written", fields: ["broker", "port", "user", "password"] }]);
|
||||
const node = f.flows.find((n) => n.id === "2b0aece9c5f3b307");
|
||||
assert.deepEqual(node, { ...aceFlows()[0], broker: "ace.internal", port: "1883", usetls: false });
|
||||
assert.deepEqual(f.creds["2b0aece9c5f3b307"], { user: "mesh_ace_nodered", password: MINTED });
|
||||
assert.deepEqual(f.flows.find((n) => n.id === "other-broker"), aceFlows()[2]);
|
||||
assert.deepEqual(f.creds["other-broker"], { user: "x", password: "y" });
|
||||
// Only the changed node carried credentials in the deploy.
|
||||
assert.deepEqual(f.deploys[0].filter((n) => n.credentials).map((n) => n.id), ["2b0aece9c5f3b307"]);
|
||||
|
||||
// Again: nothing differs, nothing is deployed.
|
||||
const again = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: m }, binding(), MINTED, ["2b0aece9c5f3b307"]);
|
||||
assert.deepEqual(again, [{ what: "broker 2b0aece9c5f3b307", result: "unchanged" }]);
|
||||
assert.equal(f.deploys.length, 1);
|
||||
});
|
||||
|
||||
test("fresh: with nothing named, the step makes its own broker node", async () => {
|
||||
const f = fakeNodeRed([]);
|
||||
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding(), MINTED, []);
|
||||
assert.deepEqual(out, [{ what: `broker ${MESH_BROKER_ID}`, result: "written", fields: ["node"] }]);
|
||||
assert.equal(f.flows[0].type, "mqtt-broker");
|
||||
assert.equal(f.flows[0].broker, "ace.internal");
|
||||
assert.deepEqual(f.creds[MESH_BROKER_ID], { user: "mesh_ace_nodered", password: MINTED });
|
||||
});
|
||||
|
||||
test("a login the broker does not take is never written", async () => {
|
||||
const f = fakeNodeRed(aceFlows());
|
||||
const out = await reconcileBrokers({ nodered: f.nodered, probe: async () => ({ connack: 5 }), marks: marks() }, binding(), MINTED, ["2b0aece9c5f3b307"]);
|
||||
assert.equal(out[0].result, "refused");
|
||||
assert.equal(f.deploys.length, 0);
|
||||
});
|
||||
|
||||
test("a named node that is not there, or a loopback broker, is refused", async () => {
|
||||
const f = fakeNodeRed(aceFlows());
|
||||
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding(), MINTED, ["gone"]);
|
||||
assert.match((out[0] as { problem: string }).problem, /no such node/);
|
||||
const lo = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding("127.0.0.1"), MINTED, []);
|
||||
assert.match((lo[0] as { problem: string }).problem, /Node-RED itself/);
|
||||
assert.equal(f.deploys.length, 0);
|
||||
});
|
||||
|
||||
test("a deploy that raced another is read again once", async () => {
|
||||
const f = fakeNodeRed(aceFlows());
|
||||
let first = true;
|
||||
const racing: NodeRed = {
|
||||
...f.nodered,
|
||||
async flows() {
|
||||
const got = await f.nodered.flows();
|
||||
if (first) {
|
||||
first = false;
|
||||
return { ...got, rev: "stale" };
|
||||
}
|
||||
return got;
|
||||
},
|
||||
};
|
||||
const out = await reconcileBrokers({ nodered: racing, probe: takes, marks: marks() }, binding(), MINTED, ["2b0aece9c5f3b307"]);
|
||||
assert.equal(out[0].result, "written");
|
||||
assert.equal(f.deploys.length, 1);
|
||||
});
|
||||
|
||||
test("settings name broker nodes under mqtt.brokers", () => {
|
||||
assert.deepEqual(namedBrokers({ mqtt: { brokers: ["a", "", 3, "b"] } }), ["a", "b"]);
|
||||
assert.deepEqual(namedBrokers({ endpoints: {} }), []);
|
||||
assert.deepEqual(namedBrokers(undefined), []);
|
||||
});
|
||||
@@ -8,11 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"client.ts",
|
||||
"tools/index.ts",
|
||||
"mqtt/probe.ts",
|
||||
"mqtt/connection.ts",
|
||||
"mqtt/index.ts"
|
||||
]
|
||||
"include": ["client.ts", "tools/index.ts"]
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable",
|
||||
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable",
|
||||
"MESH_PROVISION_POSTGRES_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
|
||||
+16
-14
@@ -27,13 +27,13 @@
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"redis-cache": "${dir:grants}/mesh.json"
|
||||
"redis-cache": "/var/lib/redis-module/grants/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"redis-cache": "${dir:grants}"
|
||||
"redis-cache": "/var/lib/redis-module/grants"
|
||||
},
|
||||
"secrets": {
|
||||
"secret": "${dir:state}/default.secret"
|
||||
"secret": "/var/lib/redis-module/default.secret"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/redis/broker"
|
||||
@@ -57,27 +57,29 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
"path": "/var/lib/redis-module",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"id": "grants-dir",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/redis-module/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/redis/data",
|
||||
"mode": "0700",
|
||||
"owner": "999:1000"
|
||||
"owner": "999:999"
|
||||
},
|
||||
{
|
||||
"id": "server-conf",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/redis.conf",
|
||||
"path": "/var/lib/redis-module/redis.conf",
|
||||
"mode": "0600",
|
||||
"content": "requirepass ${secret:secret}\nappendonly yes\ndir /data\n",
|
||||
"owner": "999:1000"
|
||||
"owner": "999:999"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
@@ -88,14 +90,14 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "redis",
|
||||
"image": "redis@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7",
|
||||
"image": "redis@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf",
|
||||
"network": "redis",
|
||||
"ports": [
|
||||
"6379"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:data}:/data",
|
||||
"${dir:state}/redis.conf:/etc/redis/redis.conf:ro"
|
||||
"/services/redis/data:/data",
|
||||
"/var/lib/redis-module/redis.conf:/etc/redis/redis.conf:ro"
|
||||
],
|
||||
"args": [
|
||||
"/etc/redis/redis.conf"
|
||||
@@ -111,8 +113,8 @@
|
||||
"network": "redis",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/redis/broker:/run/secrets/broker:ro",
|
||||
"${dir:grants}:/var/lib/redis-module/grants:ro",
|
||||
"${dir:state}/default.secret:/run/secrets/default:ro"
|
||||
"/var/lib/redis-module/grants:/var/lib/redis-module/grants:ro",
|
||||
"/var/lib/redis-module/default.secret:/run/secrets/default:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
|
||||
+21
-41
@@ -10,35 +10,35 @@
|
||||
"port": 8443,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the controller web UI and API, over its own self-signed tls; named through the proxy as an https route"
|
||||
"why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later"
|
||||
},
|
||||
{
|
||||
"name": "inform",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "device inform, how APs and switches check in and are adopted; the controller tells devices this number, so the machine must publish it on the same one"
|
||||
"why": "device inform \u2014 how APs and switches check in and are adopted"
|
||||
},
|
||||
{
|
||||
"name": "stun",
|
||||
"port": 3478,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "STUN for managed devices; the controller tells devices this number, so the machine must publish it on the same one"
|
||||
"why": "STUN, so managed devices can find the controller through NAT"
|
||||
},
|
||||
{
|
||||
"name": "discovery",
|
||||
"port": 10001,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "device discovery broadcasts from unadopted devices and the UniFi apps"
|
||||
"why": "device discovery \u2014 the controller finds unadopted devices on the network"
|
||||
},
|
||||
{
|
||||
"name": "discovery-l2",
|
||||
"port": 1900,
|
||||
"port": 1902,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "make-controller-discoverable-on-L2 (SSDP); the software listens on 1900, which machines commonly have taken by another SSDP speaker"
|
||||
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900"
|
||||
},
|
||||
{
|
||||
"name": "portal-tls",
|
||||
@@ -76,15 +76,10 @@
|
||||
"path": "/var/lib/mesh/unifi",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/unifi/data",
|
||||
"mode": "0700",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
@@ -92,17 +87,17 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "unifi-controller",
|
||||
"image": "lscr.io/linuxserver/unifi-controller@sha256:0ae315a3a45635e443899e30e86bd507c2c48922cb27f4bc7241777885f4650e",
|
||||
"image": "lscr.io/linuxserver/unifi-controller@sha256:fcd5d8b13a77a588c79c1b49e5fc9ad08115aa3bb1a3576c589c64908a68845f",
|
||||
"ports": [
|
||||
"8443",
|
||||
"8080",
|
||||
"3478/udp",
|
||||
"10001/udp",
|
||||
"1900/udp",
|
||||
"8843",
|
||||
"8880",
|
||||
"6789",
|
||||
"5514/udp"
|
||||
"8443:8443",
|
||||
"8080:8080",
|
||||
"3478:3478/udp",
|
||||
"10001:10001/udp",
|
||||
"1902:1900/udp",
|
||||
"8843:8843",
|
||||
"8880:8880",
|
||||
"6789:6789",
|
||||
"5514:5514/udp"
|
||||
],
|
||||
"env": {
|
||||
"PUID": "1000",
|
||||
@@ -112,7 +107,7 @@
|
||||
"MEM_STARTUP": "1024"
|
||||
},
|
||||
"volumes": [
|
||||
"${dir:data}:/config"
|
||||
"/services/unifi/data:/config"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -120,7 +115,7 @@
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/unifi/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{\n \"site\": \"default\",\n \"password\": \"${secret:controller}\"\n}\n",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
@@ -134,7 +129,7 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_UNIFI_URL": "https://127.0.0.1:${port:8443}",
|
||||
"MESH_UNIFI_URL": "https://127.0.0.1:8443",
|
||||
"MESH_UNIFI_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
@@ -143,23 +138,8 @@
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "unifi",
|
||||
"endpoint": "web",
|
||||
"scheme": "https",
|
||||
"insecure": true
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/unifi/broker",
|
||||
"controller": "/var/lib/mesh/unifi/controller"
|
||||
"broker": "/var/lib/mesh/unifi/broker"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
Reference in New Issue
Block a user