Compare commits
81
Commits
f5969a2f9f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ba4e6170 | ||
|
|
5b70ffd78f | ||
|
|
d3ebb01180 | ||
|
|
0020fa17cd | ||
|
|
22c6032144 | ||
|
|
36b35900a2 | ||
|
|
9c97a8a134 | ||
|
|
1bfedd2a9e | ||
|
|
769f0724ca | ||
|
|
c227592e7c | ||
|
|
50ae89e718 | ||
|
|
3ae63f10c5 | ||
|
|
e7799529e4 | ||
|
|
b9068c67bc | ||
|
|
4bf705fea7 | ||
|
|
00893d4944 | ||
|
|
a2b9a9a411 | ||
|
|
9523105df4 | ||
|
|
4449f44cf1 | ||
|
|
47f6e7d78b | ||
|
|
7ab522ba31 | ||
|
|
204bfbbaf9 | ||
|
|
e0c09f46b6 | ||
|
|
e0faf012be | ||
|
|
1b19c79d63 | ||
|
|
4ec2ae1f7f | ||
|
|
9d716ed875 | ||
|
|
3c7aafdc21 | ||
|
|
1080f45012 | ||
|
|
323ef9ec7e | ||
|
|
8f459c7023 | ||
|
|
1247b8c27e | ||
|
|
0fce3ebf5d | ||
|
|
c1a65e2354 | ||
|
|
fe0ed3b74e | ||
|
|
75eee9d4a0 | ||
|
|
0c91e08bad | ||
|
|
37c212d5b4 | ||
|
|
718fb12ef7 | ||
|
|
a32394ec22 | ||
|
|
8064e5da8f | ||
|
|
63a255c5cb | ||
|
|
7ad1fbd5c6 | ||
|
|
67f5f4cffd | ||
|
|
8797335fbc | ||
|
|
53dc108603 | ||
|
|
ebf5ba2d4c | ||
|
|
bbac08a7d2 | ||
|
|
784a5a6514 | ||
|
|
0c31499fb0 | ||
|
|
f118344246 | ||
|
|
822df220ab | ||
|
|
9eb1265bc8 | ||
|
|
41cfc70b53 | ||
|
|
acedc5d9d9 | ||
|
|
521a8dd1e2 | ||
|
|
e145e2236c | ||
|
|
4d7e37e319 | ||
|
|
026421fd6e | ||
|
|
af89bb11ff | ||
|
|
7c18cdbd39 | ||
|
|
4fb16b2e6b | ||
|
|
c5af8635c8 | ||
|
|
87366c5f36 | ||
|
|
f8ca36aacf | ||
|
|
812355bf31 | ||
|
|
016ddb2b3a | ||
|
|
ea17bf46d2 | ||
|
|
4258f01614 | ||
|
|
4d9b4fdfa6 | ||
|
|
eff11b1d4d | ||
|
|
4ead13d4d4 | ||
|
|
3b77dde666 | ||
|
|
5ea4961980 | ||
|
|
2b8a668d06 | ||
|
|
719fb1e025 | ||
|
|
ac5630bee2 | ||
|
|
1c995fa9fc | ||
|
|
d70cb18ea0 | ||
|
|
1d71787896 | ||
|
|
eb62289f89 |
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "baserow",
|
||||
"port": 80
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 80,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 6767,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -110,7 +111,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "subs",
|
||||
"port": 6767
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8787,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -87,7 +88,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "books",
|
||||
"port": 8787
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"module": "ca-trust",
|
||||
"version": "1",
|
||||
"slug": "catrust",
|
||||
"capabilities": [
|
||||
"service-manager"
|
||||
],
|
||||
"requires": [
|
||||
"internal-acme-ca"
|
||||
],
|
||||
"seats": [
|
||||
{
|
||||
"name": "the-mesh-trust-anchor",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-mesh-trust-anchor",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "anchor",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/anchor",
|
||||
"mode": "0755",
|
||||
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n"
|
||||
},
|
||||
{
|
||||
"id": "unit",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/mesh-ca-trust.service",
|
||||
"mode": "0644",
|
||||
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n"
|
||||
},
|
||||
{
|
||||
"id": "trust",
|
||||
"type": "service",
|
||||
"unit": "mesh-ca-trust.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"anchor",
|
||||
"unit"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "de-spiegel",
|
||||
"port": 35621
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 35621,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "registry",
|
||||
"port": 5000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -33,7 +33,7 @@
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.local",
|
||||
"mode": "0644",
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
},
|
||||
{
|
||||
"id": "jail-sshd",
|
||||
@@ -42,6 +42,14 @@
|
||||
"mode": "0644",
|
||||
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n"
|
||||
},
|
||||
{
|
||||
"id": "log",
|
||||
"type": "file",
|
||||
"path": "/var/log/fail2ban.log",
|
||||
"mode": "0640",
|
||||
"create-once": true,
|
||||
"content": ""
|
||||
},
|
||||
{
|
||||
"id": "jail-recidive",
|
||||
"type": "file",
|
||||
|
||||
+35
-2
@@ -9,6 +9,8 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
||||
/** A repository, trimmed to what the mesh cares about. */
|
||||
export interface GiteaRepo {
|
||||
full_name: string;
|
||||
/** The URL a build clones — what a module records as its source. */
|
||||
clone_url?: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
private: boolean;
|
||||
@@ -34,6 +36,9 @@ export interface GiteaPull {
|
||||
title: string;
|
||||
state: string;
|
||||
merged: boolean;
|
||||
/** The commit the merge produced — what a build of the base branch is made from. */
|
||||
merge_commit_sha?: string;
|
||||
merged_at?: string;
|
||||
user?: string;
|
||||
head?: string;
|
||||
base?: string;
|
||||
@@ -116,9 +121,23 @@ export class GiteaClient {
|
||||
|
||||
// ---- Repositories ----
|
||||
|
||||
/** Every repository this token can see, one page. `/user/repos` is only what the token's own
|
||||
* user owns — for the mesh's administrator that is nothing, which is how the forge watched an
|
||||
* empty list and announced no merge (2026-09-28). The search endpoint is the forge's whole view. */
|
||||
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
|
||||
const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
|
||||
return (repos ?? []).map(GiteaClient.mapRepo);
|
||||
const found = await this.request<{ data?: any[] }>(`/repos/search?page=${page}&limit=${limit}`);
|
||||
return (found?.data ?? []).map(GiteaClient.mapRepo);
|
||||
}
|
||||
|
||||
/** Every repository, all pages. */
|
||||
async listAllRepos(): Promise<GiteaRepo[]> {
|
||||
const all: GiteaRepo[] = [];
|
||||
for (let page = 1; page < 100; page++) {
|
||||
const batch = await this.listRepos(page, 50);
|
||||
all.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async createRepo(data: {
|
||||
@@ -210,6 +229,17 @@ export class GiteaClient {
|
||||
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
|
||||
}
|
||||
|
||||
/** The files a merged pull request changed, as paths from the repository's root.
|
||||
*
|
||||
* `limit` is what is asked for, and a merge that changed more says so rather than being read
|
||||
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
|
||||
* so more pages would buy nothing. */
|
||||
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> {
|
||||
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`);
|
||||
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== "");
|
||||
return { paths, truncated: paths.length >= limit };
|
||||
}
|
||||
|
||||
async createPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
@@ -232,6 +262,7 @@ export class GiteaClient {
|
||||
private static mapRepo(r: any): GiteaRepo {
|
||||
return {
|
||||
full_name: r.full_name,
|
||||
clone_url: r.clone_url ?? undefined,
|
||||
name: r.name,
|
||||
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
|
||||
private: Boolean(r.private),
|
||||
@@ -259,6 +290,8 @@ export class GiteaClient {
|
||||
title: p.title,
|
||||
state: p.state,
|
||||
merged: Boolean(p.merged),
|
||||
merge_commit_sha: p.merge_commit_sha ?? undefined,
|
||||
merged_at: p.merged_at ?? undefined,
|
||||
user: p.user?.login,
|
||||
head: p.head?.ref,
|
||||
base: p.base?.ref,
|
||||
|
||||
+80
-2
@@ -31,7 +31,7 @@ try {
|
||||
const seen = new Set<string>();
|
||||
let primed = false;
|
||||
async function pollRepos(client: GiteaClient): Promise<void> {
|
||||
const repos = await client.listRepos(1, 50);
|
||||
const repos = await client.listAllRepos();
|
||||
for (const repo of repos) {
|
||||
if (!seen.has(repo.full_name)) {
|
||||
if (primed) {
|
||||
@@ -49,6 +49,83 @@ async function pollRepos(client: GiteaClient): Promise<void> {
|
||||
primed = true;
|
||||
}
|
||||
|
||||
// **A merge is announced whoever made it.** The merge tool below emits at the instant it acts; a
|
||||
// merge made in the forge's own pages or over its API would emit nothing, and the mesh would go on
|
||||
// believing every module current with its source (novox/hq 04-ISSUES/131). So merged pull requests
|
||||
// are watched the way repositories are: what the forge holds, asked for on a tick, announced once.
|
||||
// What has been announced is kept beside the module's state, so a restart does not announce the
|
||||
// whole history again — and the first tick on a machine with no record announces nothing, because
|
||||
// everything it sees then predates the watching.
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
|
||||
const announced = new Set<string>();
|
||||
let primedMerges = false;
|
||||
// since is the moment the watching began: a merge made before it is history, whatever page of the
|
||||
// forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view
|
||||
// as newer pull requests were updated — was announced as if it had just happened, and the mesh
|
||||
// rebuilt everything built from that repository, once per old merge (2026-09-28).
|
||||
let since = "";
|
||||
if (mergedRecord && existsSync(mergedRecord)) {
|
||||
try {
|
||||
const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string };
|
||||
const list = Array.isArray(kept) ? kept : kept.announced;
|
||||
for (const sha of list) announced.add(sha);
|
||||
since = Array.isArray(kept) ? new Date().toISOString() : kept.since;
|
||||
primedMerges = true;
|
||||
} catch {
|
||||
// An unreadable record is treated as no record: prime again rather than re-announce history.
|
||||
}
|
||||
}
|
||||
function keepAnnounced(): void {
|
||||
if (!mergedRecord) return;
|
||||
mkdirSync(join(mergedRecord, ".."), { recursive: true });
|
||||
const tmp = mergedRecord + ".tmp";
|
||||
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
|
||||
renameSync(tmp, mergedRecord);
|
||||
}
|
||||
async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
const repos = await client.listAllRepos();
|
||||
let changed = false;
|
||||
for (const repo of repos) {
|
||||
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
|
||||
for (const pull of pulls) {
|
||||
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
|
||||
// Announced only if merged since the watching began; recorded either way, so it is looked
|
||||
// at once.
|
||||
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
|
||||
if (primedMerges && fresh) {
|
||||
// What it changed, asked for only now: a module is rebuilt because a file inside its own
|
||||
// directory moved, and without this every module built from a repository is rebuilt for a
|
||||
// change to any of them (novox/hq 04-ISSUES/131).
|
||||
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
|
||||
await emit("pull.merged", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
merge_commit_sha: pull.merge_commit_sha,
|
||||
merged_at: pull.merged_at,
|
||||
clone_url: repo.clone_url,
|
||||
html_url: pull.html_url,
|
||||
paths: changed.paths,
|
||||
paths_truncated: changed.truncated,
|
||||
});
|
||||
// Said, because a trigger that fires silently is indistinguishable from one that did not
|
||||
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
|
||||
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
|
||||
}
|
||||
announced.add(pull.merge_commit_sha);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!primedMerges) since = new Date().toISOString();
|
||||
if (!primedMerges || changed) keepAnnounced();
|
||||
primedMerges = true;
|
||||
}
|
||||
|
||||
if (gitea) {
|
||||
const client = gitea;
|
||||
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
|
||||
@@ -70,5 +147,6 @@ if (gitea) {
|
||||
run();
|
||||
};
|
||||
tick(() => pollRepos(client), 60_000);
|
||||
console.log("[gitea] watching for new repositories");
|
||||
tick(() => pollMerged(client), 30_000);
|
||||
console.log("[gitea] watching for new repositories and merged pull requests");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"route": {
|
||||
"web": {
|
||||
"label": "git",
|
||||
"port": 3000
|
||||
"endpoint": "web"
|
||||
},
|
||||
"internal-api-refused": {
|
||||
"label": "git",
|
||||
@@ -44,12 +44,14 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 3000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the forge, over http"
|
||||
},
|
||||
{
|
||||
"name": "ssh",
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -231,6 +231,11 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
||||
const pull = await gitea.getPullRequest(owner, repo, number);
|
||||
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
||||
// Read it again: the merge commit only exists now, and it is what a build is made from.
|
||||
const merged = await gitea.getPullRequest(owner, repo, number);
|
||||
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than
|
||||
// every module built from the repository (novox/hq 04-ISSUES/131).
|
||||
const changed = await gitea.listPullFiles(owner, repo, number);
|
||||
await emit("pull.merged", {
|
||||
owner,
|
||||
repo,
|
||||
@@ -238,8 +243,12 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
title: pull.title,
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
merge_commit_sha: merged.merge_commit_sha,
|
||||
merged_at: merged.merged_at,
|
||||
method,
|
||||
html_url: pull.html_url,
|
||||
paths: changed.paths,
|
||||
paths_truncated: changed.truncated,
|
||||
});
|
||||
return { merged: true, number, method, deleted_branch: deleteBranch };
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 3000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -96,7 +97,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "grafana",
|
||||
"port": 3000
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "hello",
|
||||
"port": 8080
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8123,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -85,7 +86,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "home-assistant",
|
||||
"port": 8123
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
+48
-22
@@ -1,6 +1,26 @@
|
||||
{
|
||||
"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"
|
||||
],
|
||||
@@ -13,10 +33,11 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "stream",
|
||||
"port": 8000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "streams in from sources and out to listeners"
|
||||
"why": "streams in from sources (HTTP PUT) and out to listeners, plus the status and admin pages; a public name is its route"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -29,28 +50,43 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/icecast-module",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"id": "logs",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "100:101"
|
||||
},
|
||||
{
|
||||
"id": "server-conf",
|
||||
"type": "file",
|
||||
"path": "/var/lib/icecast-module/server.env",
|
||||
"path": "${dir:state}/icecast.xml",
|
||||
"mode": "0600",
|
||||
"content": "ICECAST_SOURCE_PASSWORD=${secret:source}\nICECAST_ADMIN_PASSWORD=${secret:admin}\nICECAST_RELAY_PASSWORD=${secret:relay}\nICECAST_ADMIN_USERNAME=admin\n"
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "icecast",
|
||||
"image": "infiniteproject/icecast@sha256:cd506cf3dfe31ce05fd37d7e672dbd1213e7255cc93d28ecf5a3b547af4e162c",
|
||||
"env-file": [
|
||||
"/var/lib/icecast-module/server.env"
|
||||
],
|
||||
"network": "icecast",
|
||||
"ports": [
|
||||
"8000"
|
||||
],
|
||||
"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"
|
||||
"volumes": [
|
||||
"${dir:state}/icecast.xml:/etc/icecast.xml:ro",
|
||||
"${dir:logs}:/var/log/icecast"
|
||||
],
|
||||
"restart-on": [
|
||||
"server-conf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
@@ -64,14 +100,14 @@
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-icecast",
|
||||
"network": "host",
|
||||
"network": "icecast",
|
||||
"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://127.0.0.1:8000",
|
||||
"MESH_ICECAST_URL": "http://icecast:8000",
|
||||
"MESH_ICECAST_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
@@ -100,15 +136,5 @@
|
||||
"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 tools/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts grants.ts provisioner/index.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
|
||||
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js,/app/modules/influxdb/dist/provisioner/index.js
|
||||
|
||||
+118
-3
@@ -17,6 +17,25 @@ 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 {};
|
||||
@@ -24,13 +43,20 @@ 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,
|
||||
private readonly org: string,
|
||||
readonly org: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
@@ -43,8 +69,10 @@ 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"}`;
|
||||
const token = cfg.token ?? env.MESH_INFLUXDB_TOKEN;
|
||||
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN");
|
||||
// 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 org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh";
|
||||
return new InfluxDBClient(url, token, org);
|
||||
}
|
||||
@@ -61,6 +89,93 @@ 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;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// 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,20 +1,43 @@
|
||||
{
|
||||
"module": "influxdb",
|
||||
"version": "1",
|
||||
"provides": [
|
||||
{
|
||||
"name": "influxdb-api",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/influxdb/broker"
|
||||
"broker": "/var/lib/mesh/influxdb/broker",
|
||||
"admin": "${dir:state}/admin.secret",
|
||||
"admin-token": "${dir:state}/admin-token.secret"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 8086,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "queries and writes, over http"
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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",
|
||||
@@ -25,46 +48,50 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"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"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"/var/lib/influxdb-module/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"8086"
|
||||
],
|
||||
"volumes": [
|
||||
"/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"
|
||||
"${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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
@@ -82,13 +109,15 @@
|
||||
"volumes": [
|
||||
"/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro",
|
||||
"/services/influxdb/config:/var/lib/influxdb/config:ro"
|
||||
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro",
|
||||
"${dir:grants}:${dir:grants}:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_INFLUXDB_URL": "http://127.0.0.1:8086",
|
||||
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
|
||||
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config"
|
||||
"MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
@@ -96,6 +125,15 @@
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "influxdb",
|
||||
"endpoint": "api"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
@@ -116,14 +154,5 @@
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requires": [
|
||||
"secret"
|
||||
],
|
||||
"secrets": {
|
||||
"secret": {
|
||||
"admin": "/var/lib/influxdb-module/admin.secret",
|
||||
"admin-token": "/var/lib/influxdb-module/admin-token.secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-influxdb",
|
||||
"version": "0.1.0",
|
||||
"description": "influxdb — time-series database. Its API client and tools live here (novox/hq ADR 0039).",
|
||||
"description": "influxdb — time-series database; provides the mesh influxdb-api interface. Its API client, provisioner 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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// 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,5 +8,10 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "tools/index.ts"]
|
||||
"include": [
|
||||
"client.ts",
|
||||
"grants.ts",
|
||||
"provisioner/index.ts",
|
||||
"tools/index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
"route": {
|
||||
"site": {
|
||||
"label": "invoicing",
|
||||
"port": 80
|
||||
"endpoint": "web"
|
||||
},
|
||||
"api": {
|
||||
"label": "invoicing-api",
|
||||
"port": 9000
|
||||
"endpoint": "api"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -36,12 +36,14 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 80,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the invoicing web frontend; a public name is a route grant later"
|
||||
},
|
||||
{
|
||||
"name": "api",
|
||||
"port": 9000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
+53
-16
@@ -2,7 +2,8 @@
|
||||
// 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 { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface JackettIndexer {
|
||||
id: string;
|
||||
@@ -43,18 +44,36 @@ 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 — without them 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. 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.
|
||||
*/
|
||||
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;
|
||||
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY
|
||||
?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config");
|
||||
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");
|
||||
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY or make the config dir readable");
|
||||
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);
|
||||
@@ -64,18 +83,36 @@ export class JackettClient {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> {
|
||||
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,
|
||||
}));
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+29
-10
@@ -1,15 +1,29 @@
|
||||
{
|
||||
"module": "jackett",
|
||||
"version": "1",
|
||||
"provides": [
|
||||
{
|
||||
"name": "jackett-api",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"jackett-api": {
|
||||
"scheme": "http",
|
||||
"port": 9117,
|
||||
"url-base": ""
|
||||
}
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 9117,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the indexer proxy"
|
||||
"why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through, which other modules reach as jackett-api"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -19,10 +33,15 @@
|
||||
"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"
|
||||
},
|
||||
@@ -30,7 +49,7 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "jackett",
|
||||
"image": "lscr.io/linuxserver/jackett@sha256:fd72d42b731ebf750b5de9711127251cf3b3f609419c32083ea8b3b3ee840b77",
|
||||
"image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583",
|
||||
"env": {
|
||||
"PUID": "1000",
|
||||
"PGID": "1000",
|
||||
@@ -40,13 +59,13 @@
|
||||
"9117"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/jackett/config:/config"
|
||||
"${dir:config}:/config"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/jackett/config.json",
|
||||
"path": "${dir:state}/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
@@ -58,12 +77,12 @@
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/jackett/config.json:/run/config/config.json:ro",
|
||||
"/services/jackett/config:/var/lib/jackett/config:ro"
|
||||
"${dir:state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:config}:/var/lib/jackett/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_JACKETT_URL": "http://127.0.0.1:9117",
|
||||
"MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
|
||||
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
|
||||
},
|
||||
@@ -82,11 +101,11 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "indexers",
|
||||
"port": 9117
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/mesh/jackett/route.json"
|
||||
"route": "${dir:state}/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 any last error.",
|
||||
description: "List the indexers Jackett proxies, with their type and site.",
|
||||
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
|
||||
run: async (args) => {
|
||||
const indexers = await jackett.getIndexers(!args.all);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "keycloak",
|
||||
"port": 8080
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -34,6 +34,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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, 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.
|
||||
// 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.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -58,6 +58,10 @@ 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),
|
||||
},
|
||||
|
||||
+22
-25
@@ -5,29 +5,37 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"requires": [
|
||||
"postgres-database"
|
||||
"postgres-database",
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"postgres-database": {
|
||||
"name": "letta"
|
||||
},
|
||||
"route": {
|
||||
"label": "letta",
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "/var/lib/letta/database.json"
|
||||
"postgres-database": "${dir:state}/database.json",
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "/var/lib/letta/database.secret"
|
||||
"postgres-database": "${dir:state}/database.secret"
|
||||
},
|
||||
"own-secrets": {
|
||||
"server-password": "/var/lib/letta/server-password.secret",
|
||||
"server-password": "${dir:state}/server-password.secret",
|
||||
"openai-api-key": "${dir:state}/openai-api-key.secret",
|
||||
"broker": "/var/lib/mesh/letta/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8283,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the Letta agent server REST API and web UI; a public name is a route grant later"
|
||||
"why": "the Letta agent server REST API and web UI, password-protected (--secure); a public name is the route's"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -40,15 +48,15 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/letta",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/letta/server.env",
|
||||
"path": "${dir:state}/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}\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}\nOPENAI_API_KEY=${secret:openai-api-key}\nSECURE=true\nTZ=Europe/Brussels\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
@@ -59,31 +67,24 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "letta",
|
||||
"image": "letta/letta@sha256:1d2e0692514287c5ed1a483e14e16ed945f8632d315539f5e66373bb7d7c471b",
|
||||
"image": "letta/letta@sha256:bfd1e49ce45b9a208c941e832c1d1d194017ff210a3784b0ca6c323aed767a29",
|
||||
"network": "letta",
|
||||
"env-file": [
|
||||
"/var/lib/letta/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"8283"
|
||||
],
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/letta/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"content": "{\n \"password\": \"${secret:server-password}\"\n}\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",
|
||||
@@ -98,14 +99,10 @@
|
||||
"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",
|
||||
"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"
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8686,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -86,7 +87,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "lidarr",
|
||||
"port": 8686
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
+17
-10
@@ -16,27 +16,27 @@
|
||||
"route": {
|
||||
"web": {
|
||||
"label": "mail",
|
||||
"port": 7443,
|
||||
"endpoint": "web-tls",
|
||||
"scheme": "https",
|
||||
"insecure": true
|
||||
},
|
||||
"acme": {
|
||||
"label": "mail",
|
||||
"path": "/.well-known/acme-challenge",
|
||||
"port": 7080,
|
||||
"endpoint": "web",
|
||||
"priority": 100
|
||||
},
|
||||
"autoconfig": {
|
||||
"label": "autoconfig",
|
||||
"port": 4243
|
||||
"endpoint": "autoconfig"
|
||||
},
|
||||
"autodiscover": {
|
||||
"label": "autodiscover",
|
||||
"port": 4243
|
||||
"endpoint": "autoconfig"
|
||||
},
|
||||
"automx": {
|
||||
"label": "automx",
|
||||
"port": 4243
|
||||
"endpoint": "autoconfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,6 +60,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "smtp",
|
||||
"port": 25,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -67,6 +68,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "pop3",
|
||||
"port": 110,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -74,6 +76,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "imap",
|
||||
"port": 143,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -81,6 +84,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "smtps",
|
||||
"port": 465,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -88,6 +92,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "submission",
|
||||
"port": 587,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -95,6 +100,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "imaps",
|
||||
"port": 993,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -102,6 +108,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "pop3s",
|
||||
"port": 995,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -109,18 +116,21 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"port": 7080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"name": "web-tls",
|
||||
"port": 7443,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here"
|
||||
},
|
||||
{
|
||||
"name": "autoconfig",
|
||||
"port": 4243,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -305,10 +315,7 @@
|
||||
"${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",
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
]
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "imap",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 59125,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -22,7 +22,7 @@ COPY . .
|
||||
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
|
||||
# symlinks to a launcher that requires its library relatively — resolved away when the base image
|
||||
# was assembled.
|
||||
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
# **A module may need something the base image does not carry.** The base holds what every module
|
||||
@@ -48,3 +48,8 @@ COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
|
||||
# to listen for what the builder announces. Serve binds the broker first, then imports these, so
|
||||
# `on()` has something to subscribe to.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
|
||||
|
||||
# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named
|
||||
# here, beside the entrypoints above, because the module knows which of its files prepares its state
|
||||
# and nothing else could: the mesh asks one word and this says what answers it.
|
||||
ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js
|
||||
|
||||
@@ -14,10 +14,12 @@ import { Graph, type Made } from "./store.js";
|
||||
|
||||
const graph = Graph.fromEnv();
|
||||
|
||||
// Before subscribing, and idempotent. The runtime is restarted until its store is reachable, which
|
||||
// is the same arrangement model-usage uses: a schema step that had to reach the provider over the
|
||||
// overlay would block the very apply that brings the overlay up.
|
||||
await graph.migrate();
|
||||
// The schema is not brought up here. The mesh prepares this module's state before it starts this
|
||||
// version, and does not start it if that failed (novox/hq ADR 0135) — see prepare/index.ts. Doing it
|
||||
// at start made a schema that could not be reached a crash loop instead of a stop, with the graph
|
||||
// keeping a gap and nothing saying so. The reason it used to be here — that a step blocking the apply
|
||||
// would block the very apply that brings the overlay up — stopped being true when a step's failure
|
||||
// became this module's business and not the machine's (ADR 0136).
|
||||
|
||||
/** What the builder says when it has built something. */
|
||||
interface Built {
|
||||
@@ -47,7 +49,15 @@ interface Built {
|
||||
replay?: boolean;
|
||||
}
|
||||
|
||||
await on("mesh-build-machine.built", async (event) => {
|
||||
/**
|
||||
* What a build means for the graph, wherever it came from.
|
||||
*
|
||||
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
|
||||
* and the control plane says what it already held when this module asks what it missed
|
||||
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
|
||||
* months ago — see `replay` above.
|
||||
*/
|
||||
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||
const body = event.body as Built;
|
||||
if (!body.module || !body.commit) {
|
||||
// Said rather than dropped: a build that announced itself without saying what it built is a
|
||||
@@ -89,7 +99,11 @@ await on("mesh-build-machine.built", async (event) => {
|
||||
because: next.because,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// As it happens, and what the mesh already held when this module asked what it missed.
|
||||
await on("mesh-build-machine.built", placeTheBuild);
|
||||
await on("mesh-controller.built-before", placeTheBuild);
|
||||
|
||||
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
|
||||
//
|
||||
|
||||
@@ -29,13 +29,16 @@
|
||||
"broker": "/var/lib/mesh/mesh-catalog/broker"
|
||||
},
|
||||
"consumes": [
|
||||
"mesh-build-machine.built"
|
||||
"mesh-build-machine.built",
|
||||
"mesh-controller.built-before"
|
||||
],
|
||||
"emits": [
|
||||
"registered",
|
||||
"upgraded",
|
||||
"rebuild-needed"
|
||||
"rebuild-needed",
|
||||
"catching-up"
|
||||
],
|
||||
"prepares": true,
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// The catalogue's state, brought to the shape this version needs (novox/hq ADR 0135).
|
||||
//
|
||||
// **The mesh runs this before the version that needs it, and does not start that version if it
|
||||
// fails** — and the refusal reaches this module and nothing else on the machine
|
||||
// (novox/hq ADR 0136). That is the whole difference from where this used to happen: at start, inside
|
||||
// the runtime, a schema that could not be brought up was a crash loop, the graph kept a gap, and
|
||||
// nothing anywhere said so.
|
||||
//
|
||||
// Nothing here connects to the broker. Preparation runs before the version that would use it, so
|
||||
// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this
|
||||
// process exiting non-zero is how the host knows not to start the runtime.
|
||||
import { Graph } from "../store.js";
|
||||
|
||||
const graph = Graph.fromEnv();
|
||||
await graph.migrate();
|
||||
console.log("[mesh-catalog] the module graph's schema is what this version needs");
|
||||
await graph.close();
|
||||
@@ -12,6 +12,7 @@
|
||||
"pg.d.ts",
|
||||
"store.ts",
|
||||
"index.ts",
|
||||
"tools/index.ts"
|
||||
"tools/index.ts",
|
||||
"prepare/index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@
|
||||
"route": {
|
||||
"api": {
|
||||
"label": "files-api",
|
||||
"port": 9000
|
||||
"endpoint": "s3"
|
||||
},
|
||||
"console": {
|
||||
"label": "files",
|
||||
"port": 9001
|
||||
"endpoint": "console"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -31,12 +31,14 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "s3",
|
||||
"port": 9000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the S3 endpoint"
|
||||
},
|
||||
{
|
||||
"name": "console",
|
||||
"port": 9001,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "database",
|
||||
"port": 27017,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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 client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
|
||||
+38
-24
@@ -20,6 +20,8 @@ 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 {
|
||||
@@ -141,14 +143,19 @@ export class MosquittoClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
async createScopedClient(username: string, password: string, topicPrefix: string): Promise<void> {
|
||||
async createScopedClient(username: string, password: string, filters: readonly 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);
|
||||
@@ -161,17 +168,18 @@ export class MosquittoClient {
|
||||
await this.ctl("createClient", username, "-p", password);
|
||||
}
|
||||
|
||||
// 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.)
|
||||
// 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.
|
||||
await ignoreExisting(this.ctl("createRole", role));
|
||||
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"));
|
||||
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));
|
||||
}
|
||||
// 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).
|
||||
@@ -181,25 +189,31 @@ export class MosquittoClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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).
|
||||
*/
|
||||
async holdsClient(username: string, password: string): Promise<boolean> {
|
||||
async holdsClient(username: string, password: string, filters: readonly 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 out: string;
|
||||
let client: string;
|
||||
let role: string;
|
||||
try {
|
||||
out = await this.ctl("getClient", username);
|
||||
client = await this.ctl("getClient", username);
|
||||
role = await this.ctl("getRole", username);
|
||||
} catch (err) {
|
||||
if (/not\s*found|does not exist|no such/i.test(String(err))) return false;
|
||||
throw err;
|
||||
}
|
||||
return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out);
|
||||
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;
|
||||
}
|
||||
|
||||
/** Remove a client and the per-client role created for it, idempotently. */
|
||||
|
||||
@@ -20,26 +20,31 @@
|
||||
"mosquitto.topic.deprovisioned"
|
||||
],
|
||||
"serves": {
|
||||
"mqtt-topic": {}
|
||||
"mqtt-topic": {
|
||||
"scheme": "mqtt",
|
||||
"port": 1883
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"mqtt-topic": "/var/lib/mosquitto-module/grants/mesh.json"
|
||||
"mqtt-topic": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"mqtt-topic": "/var/lib/mosquitto-module/grants"
|
||||
"mqtt-topic": "${dir:grants}"
|
||||
},
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/mosquitto-module/admin.secret",
|
||||
"admin": "/var/lib/mesh/mosquitto/admin",
|
||||
"broker": "/var/lib/mesh/mosquitto/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "mqtt",
|
||||
"port": 1883,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "modules on any machine that were granted a topic namespace"
|
||||
},
|
||||
{
|
||||
"name": "mqtt-websockets",
|
||||
"port": 8081,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -56,26 +61,24 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mosquitto-module",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants-dir",
|
||||
"id": "grants",
|
||||
"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": "/var/lib/mosquitto-module/mosquitto.conf",
|
||||
"path": "${dir:state}/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"
|
||||
@@ -91,8 +94,8 @@
|
||||
"name": "mosquitto-bootstrap",
|
||||
"run-once": true,
|
||||
"volumes": [
|
||||
"/services/mosquitto/data:/mosquitto/data",
|
||||
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
|
||||
"${dir:data}:/mosquitto/data",
|
||||
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_MQTT": "mosquitto:1883",
|
||||
@@ -110,15 +113,15 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "mosquitto",
|
||||
"image": "eclipse-mosquitto@sha256:6f8d8a947c506f8a2290ec65cd4bd2bc7cb4d43fb5f6271f861cb013e2ef9797",
|
||||
"image": "eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408",
|
||||
"network": "mosquitto",
|
||||
"ports": [
|
||||
"1883",
|
||||
"8081"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mosquitto/data:/mosquitto/data",
|
||||
"/var/lib/mosquitto-module/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
|
||||
"${dir:data}:/mosquitto/data",
|
||||
"${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -128,12 +131,12 @@
|
||||
"network": "mosquitto",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mosquitto-module/grants:/var/lib/mosquitto-module/grants:ro",
|
||||
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_RECEIVES": "/var/lib/mosquitto-module/grants/mesh.json",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||
"MESH_PROVISION_MQTT": "mosquitto:1883",
|
||||
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
|
||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin"
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-mosquitto",
|
||||
"version": "0.1.0",
|
||||
"description": "mosquitto — provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).",
|
||||
"description": "mosquitto \u2014 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,7 +5,14 @@
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// **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.
|
||||
@@ -15,6 +22,7 @@
|
||||
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();
|
||||
|
||||
@@ -29,13 +37,20 @@ async function announce(type: string, body: Record<string, string>): Promise<voi
|
||||
|
||||
runProvisioner("mqtt-topic", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
// 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);
|
||||
// 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);
|
||||
await announce("topic.provisioned", {
|
||||
consumer: p.consumer ?? "",
|
||||
username: p.as,
|
||||
topicPrefix,
|
||||
topicPrefix: granted.own ? p.as : "",
|
||||
topics: granted.filters.join(" "),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -46,6 +61,9 @@ 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> {
|
||||
return mosquitto.holdsClient(p.as, p.password);
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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), []);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
|
||||
"include": ["topics.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
|
||||
}
|
||||
|
||||
+12
-12
@@ -20,23 +20,24 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 4848,
|
||||
"name": "database",
|
||||
"port": 1433,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "modules on any machine that were granted a database. 4848, not 1433: the machine this replaces has served it there since it was installed, and every consumer was handed that number"
|
||||
"why": "modules on any machine that were granted a database, and the people who were given its address; the machine port is the assignment's to pin"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"mssql-database": {}
|
||||
},
|
||||
"receives": {
|
||||
"mssql-database": "/var/lib/mssql/grants/mesh.json"
|
||||
"mssql-database": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"mssql-database": "/var/lib/mssql/grants"
|
||||
"mssql-database": "${dir:grants}"
|
||||
},
|
||||
"own-secrets": {
|
||||
"sa": "/var/lib/mssql/sa.secret",
|
||||
"sa": "${dir:state}/sa.secret",
|
||||
"broker": "/var/lib/mesh/mssql/broker"
|
||||
},
|
||||
"resources": [
|
||||
@@ -49,19 +50,18 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mssql",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mssql/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "sa-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mssql/sa.env",
|
||||
"path": "${dir:state}/sa.env",
|
||||
"mode": "0600",
|
||||
"content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n"
|
||||
},
|
||||
@@ -83,7 +83,7 @@
|
||||
"image": "mcr.microsoft.com/mssql/server@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090",
|
||||
"network": "mssql",
|
||||
"env-file": [
|
||||
"/var/lib/mssql/sa.env"
|
||||
"${dir:state}/sa.env"
|
||||
],
|
||||
"ports": [
|
||||
"1433"
|
||||
@@ -100,8 +100,8 @@
|
||||
"network": "mssql",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mssql/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mssql/grants:/var/lib/mssql/grants:ro",
|
||||
"/var/lib/mssql/sa.secret:/run/secrets/sa:ro"
|
||||
"${dir:grants}:/var/lib/mssql/grants:ro",
|
||||
"${dir:state}/sa.secret:/run/secrets/sa:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "n8n",
|
||||
"port": 5682
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 5682,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"consumes": [],
|
||||
"listens": [
|
||||
{
|
||||
"name": "bus",
|
||||
"port": 4222,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "drive",
|
||||
"port": 80
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -38,6 +38,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 80,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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 \
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts mqtt/probe.ts mqtt/connection.ts mqtt/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
@@ -22,3 +22,6 @@ 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,7 +3,8 @@
|
||||
//
|
||||
// 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 (minted at /auth/token) is required.
|
||||
// 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`.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -81,6 +82,35 @@ 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".
|
||||
@@ -88,8 +118,13 @@ export class NodeRedClient {
|
||||
async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> {
|
||||
const body = await this.req("/flows", {
|
||||
method: "POST",
|
||||
headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }),
|
||||
body: JSON.stringify(config),
|
||||
// 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 }),
|
||||
});
|
||||
return { rev: body?.rev, nodeCount: config.length };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"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": [
|
||||
@@ -12,6 +14,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 1880,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -25,26 +28,63 @@
|
||||
"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:02a2b92a41b73d2bc388238b86e4fcaab7fb5466373adb24e1df6aa5845265ff",
|
||||
"image": "nodered/node-red@sha256:a649dd711d55490151a2c39a8e48ad0c44325488fbc0e66315f2d2e19e5e1ace",
|
||||
"env": {
|
||||
"TZ": "Etc/UTC"
|
||||
},
|
||||
"ports": [
|
||||
"1880"
|
||||
],
|
||||
"args": [
|
||||
"--settings",
|
||||
"/config/settings.js"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/nodered/data:/data"
|
||||
"${dir:data}:/data",
|
||||
"${dir:state}/settings.js:/config/settings.js:ro",
|
||||
"${dir:state}/settings.json:/config/settings.json:ro"
|
||||
],
|
||||
"restart-on": [
|
||||
"settings-code",
|
||||
"settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -52,8 +92,7 @@
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/nodered/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
"content": "{\n \"token\": \"${secret:api-token}\"\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
@@ -66,26 +105,66 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_NODERED_URL": "http://127.0.0.1:1880",
|
||||
"MESH_NODERED_URL": "http://127.0.0.1:${port: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",
|
||||
"port": 1880
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/mesh/nodered/route.json"
|
||||
"route": "${dir:state}/route.json",
|
||||
"mqtt-topic": "${dir:state}/mqtt-topic.json"
|
||||
},
|
||||
"secrets": {
|
||||
"mqtt-topic": "${dir:state}/mqtt-topic.secret"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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,9 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-nodered",
|
||||
"version": "0.1.0",
|
||||
"description": "nodered — flow-based automation. Its client and tools live here (novox/hq ADR 0039).",
|
||||
"description": "nodered \u2014 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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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,5 +8,11 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "tools/index.ts"]
|
||||
"include": [
|
||||
"client.ts",
|
||||
"tools/index.ts",
|
||||
"mqtt/probe.ts",
|
||||
"mqtt/connection.ts",
|
||||
"mqtt/index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "@",
|
||||
"port": 4000
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 4000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 6789,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 11434,
|
||||
"protocol": "tcp",
|
||||
"from": "machine",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 3579,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -89,7 +90,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "ombi",
|
||||
"port": 3579
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "office",
|
||||
"port": 9070
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 9070,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "eef",
|
||||
"port": 4012
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 4012,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "filip",
|
||||
"port": 4013
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 4013,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "photos",
|
||||
"port": 4001
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -32,12 +32,14 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 9000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the photos backend API; the client sites on the module network call it"
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"port": 4001,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "stream",
|
||||
"port": 32400,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 9090,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
|
||||
},
|
||||
{
|
||||
"name": "web-tls",
|
||||
"port": 9443,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -103,7 +105,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "portainer",
|
||||
"port": 9090
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "database",
|
||||
"port": 5432,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -104,7 +105,7 @@
|
||||
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable",
|
||||
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port: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,6 +16,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 7878,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -86,7 +87,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "movies",
|
||||
"port": 7878
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
+15
-16
@@ -27,19 +27,20 @@
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"redis-cache": "/var/lib/redis-module/grants/mesh.json"
|
||||
"redis-cache": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"redis-cache": "/var/lib/redis-module/grants"
|
||||
"redis-cache": "${dir:grants}"
|
||||
},
|
||||
"secrets": {
|
||||
"secret": "/var/lib/redis-module/default.secret"
|
||||
"secret": "${dir:state}/default.secret"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/redis/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "cache",
|
||||
"port": 6379,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -56,29 +57,27 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/redis-module",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants-dir",
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/redis-module/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/redis/data",
|
||||
"mode": "0700",
|
||||
"owner": "999:999"
|
||||
"owner": "999:1000"
|
||||
},
|
||||
{
|
||||
"id": "server-conf",
|
||||
"type": "file",
|
||||
"path": "/var/lib/redis-module/redis.conf",
|
||||
"path": "${dir:state}/redis.conf",
|
||||
"mode": "0600",
|
||||
"content": "requirepass ${secret:secret}\nappendonly yes\ndir /data\n",
|
||||
"owner": "999:999"
|
||||
"owner": "999:1000"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
@@ -89,14 +88,14 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "redis",
|
||||
"image": "redis@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf",
|
||||
"image": "redis@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7",
|
||||
"network": "redis",
|
||||
"ports": [
|
||||
"6379"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/redis/data:/data",
|
||||
"/var/lib/redis-module/redis.conf:/etc/redis/redis.conf:ro"
|
||||
"${dir:data}:/data",
|
||||
"${dir:state}/redis.conf:/etc/redis/redis.conf:ro"
|
||||
],
|
||||
"args": [
|
||||
"/etc/redis/redis.conf"
|
||||
@@ -112,8 +111,8 @@
|
||||
"network": "redis",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/redis/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/redis-module/grants:/var/lib/redis-module/grants:ro",
|
||||
"/var/lib/redis-module/default.secret:/run/secrets/default:ro"
|
||||
"${dir:grants}:/var/lib/redis-module/grants:ro",
|
||||
"${dir:state}/default.secret:/run/secrets/default:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
|
||||
@@ -27,12 +27,14 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "http",
|
||||
"port": 80,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
"why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified"
|
||||
},
|
||||
{
|
||||
"name": "https",
|
||||
"port": 443,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
|
||||
+23
-21
@@ -5,11 +5,12 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"own-secrets": {
|
||||
"secret": "/var/lib/searxng-module/secret.secret",
|
||||
"secret": "/var/lib/mesh/searxng/secret",
|
||||
"broker": "/var/lib/mesh/searxng/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -26,22 +27,14 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/searxng-module",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "valkey-data",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/searxng-module/valkey-data",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/searxng-module/server.env",
|
||||
"mode": "0600",
|
||||
"content": "SEARXNG_SECRET=${secret:secret}\nSEARXNG_VALKEY_URL=valkey://valkey:6379/0\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
@@ -62,30 +55,39 @@
|
||||
"warning"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/searxng-module/valkey-data:/data"
|
||||
"${dir:valkey-data}:/data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.yml",
|
||||
"mode": "0600",
|
||||
"merge": "json",
|
||||
"content": "{\n \"use_default_settings\": true,\n \"server\": {\n \"secret_key\": \"${secret:secret}\",\n \"base_url\": false,\n \"limiter\": false,\n \"image_proxy\": false,\n \"public_instance\": false\n },\n \"search\": {\n \"formats\": [\"html\", \"json\"]\n },\n \"valkey\": {\n \"url\": \"valkey://valkey:6379/0\"\n }\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "searxng",
|
||||
"image": "searxng/searxng@sha256:c7cc75852051bf6254afda6ed1b920dd1677d8efe4ab141bf558f02e582f4371",
|
||||
"image": "searxng/searxng@sha256:cd8812607ab73730a0b1a0dc4990223fe1b9e383f6f35947114d0bef7f8bb441",
|
||||
"network": "searxng",
|
||||
"env-file": [
|
||||
"/var/lib/searxng-module/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"8080"
|
||||
],
|
||||
"secrets-in-environment": "SEARXNG_SECRET is env-only, but settings.yml carries server.secret_key; convertible by mounting a generated settings.yml, not yet done"
|
||||
"volumes": [
|
||||
"${dir:state}/settings.yml:/etc/searxng/settings.yml:ro"
|
||||
],
|
||||
"restart-on": [
|
||||
"settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/searxng/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
"content": "{}\n"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
@@ -113,11 +115,11 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "searxng",
|
||||
"port": 8080
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/searxng-module/route.json"
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8989,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -91,7 +92,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "series",
|
||||
"port": 8989
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "ssh",
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -31,6 +32,7 @@
|
||||
"type": "service",
|
||||
"unit": "sshd.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"config"
|
||||
]
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "acme",
|
||||
"port": 9000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8181,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -85,7 +86,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "tautulli",
|
||||
"port": 8181
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "umami",
|
||||
"port": 3000
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -49,10 +49,11 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 3000,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
"why": "one port serves two surfaces: the dashboard (the proxy gates it to the mesh) and the public collection endpoint that the browsers of every tracked site POST to \u2014 so the port itself must be reachable from anywhere"
|
||||
"from": "mesh",
|
||||
"why": "one port serves two surfaces \u2014 the dashboard and the collection endpoint that the browsers of every tracked site POST to. Both are reached through the proxy, by name, so the port is how the proxy reaches this module and nothing else (novox/hq ADR 0045). It said \"anywhere\" and gave the reason that the collection endpoint must be public, which is true of the name and not of the port: opened, the machine-side port served the dashboard over plain HTTP to the internet, bypassing every rule the proxy applies by path"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
|
||||
+50
-21
@@ -6,54 +6,63 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8443,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later"
|
||||
"why": "the controller web UI and API, over its own self-signed tls; named through the proxy as an https route"
|
||||
},
|
||||
{
|
||||
"name": "inform",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "device inform \u2014 how APs and switches check in and are adopted"
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"name": "stun",
|
||||
"port": 3478,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "STUN, so managed devices can find the controller through NAT"
|
||||
"why": "STUN for managed devices; the controller tells devices this number, so the machine must publish it on the same one"
|
||||
},
|
||||
{
|
||||
"name": "discovery",
|
||||
"port": 10001,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "device discovery \u2014 the controller finds unadopted devices on the network"
|
||||
"why": "device discovery broadcasts from unadopted devices and the UniFi apps"
|
||||
},
|
||||
{
|
||||
"port": 1902,
|
||||
"name": "discovery-l2",
|
||||
"port": 1900,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900"
|
||||
"why": "make-controller-discoverable-on-L2 (SSDP); the software listens on 1900, which machines commonly have taken by another SSDP speaker"
|
||||
},
|
||||
{
|
||||
"name": "portal-tls",
|
||||
"port": 8843,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the guest captive portal over https"
|
||||
},
|
||||
{
|
||||
"name": "portal",
|
||||
"port": 8880,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the guest captive portal over http"
|
||||
},
|
||||
{
|
||||
"name": "speedtest",
|
||||
"port": 6789,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "mobile-app speed-test throughput measurement"
|
||||
},
|
||||
{
|
||||
"name": "syslog",
|
||||
"port": 5514,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
@@ -67,10 +76,15 @@
|
||||
"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"
|
||||
},
|
||||
@@ -78,17 +92,17 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "unifi-controller",
|
||||
"image": "lscr.io/linuxserver/unifi-controller@sha256:fcd5d8b13a77a588c79c1b49e5fc9ad08115aa3bb1a3576c589c64908a68845f",
|
||||
"image": "lscr.io/linuxserver/unifi-controller@sha256:0ae315a3a45635e443899e30e86bd507c2c48922cb27f4bc7241777885f4650e",
|
||||
"ports": [
|
||||
"8443:8443",
|
||||
"8080:8080",
|
||||
"3478:3478/udp",
|
||||
"10001:10001/udp",
|
||||
"1902:1900/udp",
|
||||
"8843:8843",
|
||||
"8880:8880",
|
||||
"6789:6789",
|
||||
"5514:5514/udp"
|
||||
"8443",
|
||||
"8080",
|
||||
"3478/udp",
|
||||
"10001/udp",
|
||||
"1900/udp",
|
||||
"8843",
|
||||
"8880",
|
||||
"6789",
|
||||
"5514/udp"
|
||||
],
|
||||
"env": {
|
||||
"PUID": "1000",
|
||||
@@ -98,7 +112,7 @@
|
||||
"MEM_STARTUP": "1024"
|
||||
},
|
||||
"volumes": [
|
||||
"/services/unifi/data:/config"
|
||||
"${dir:data}:/config"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -106,7 +120,7 @@
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/unifi/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"content": "{\n \"site\": \"default\",\n \"password\": \"${secret:controller}\"\n}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
@@ -120,7 +134,7 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_UNIFI_URL": "https://127.0.0.1:8443",
|
||||
"MESH_UNIFI_URL": "https://127.0.0.1:${port:8443}",
|
||||
"MESH_UNIFI_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
@@ -129,8 +143,23 @@
|
||||
"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"
|
||||
"broker": "/var/lib/mesh/unifi/broker",
|
||||
"controller": "/var/lib/mesh/unifi/controller"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
Reference in New Issue
Block a user