Compare commits

..
Author SHA1 Message Date
jschoubben c1a65e2354 nodered: the sidecar dials the port it was given
The sidecar runs on the host network and dialled 127.0.0.1:1880, the
software's port; the mesh publishes nodered on a machine port it assigns,
so the tools reached whatever else holds 1880, or nothing (hq 088).
2026-09-29 23:50:11 +02:00
jschoubben 0c91e08bad nodered: its settings are files the mesh writes, and its editor is locked
The catalogue ran the image's defaults: no adminAuth, so a routed Node-RED
editor (which runs arbitrary code) was open to anyone who reached it, and
the module's own tools had no token to present to an install that was locked.

- settings.js (fixed, 0600, uid 1000) carries adminAuth: user admin checked
  against the admin secret -- a minted password, or the bcrypt hash an
  existing install held (accepted), so current logins keep working -- and a
  static bearer token (api-token) the sidecar presents. It loads settings.json
  beside it, the one mergeable file; endpoints is dropped there, and an
  optional timeZone sets process.env.TZ (assignments cannot set env).
- The sidecar's runtime config is no longer merged; it carries the token.
- Directories are placed (state, data), the route binds into state.
- Image pinned to 5.0.7 (a649dd71), what ace runs; the old pin was 5.0.6.
- deployFlows asks for API v2: v1 answers 204 with no body, which the client
  tried to parse as JSON.

Verified: catalogue tests pass against this tree. A throwaway 5.0.7 container
started with the generated files: anonymous /flows 401, bearer api-token 200,
bad token 401, password grant 200/403 with a minted password and with a
bcrypt-hash-accepted one; endpoints and timeZone do not reach /settings;
timeZone Europe/Brussels overrides TZ=Etc/UTC; v1 deploy 204, v2 deploy
answers {rev}.
2026-09-29 23:41:18 +02:00
mesh-admin 8064e5da8f Merge pull request 'searxng: its settings are a file the mesh writes, not the image's defaults' (#143) from feat/searxng-settings-as-a-file into main 2026-09-29 20:45:13 +00:00
jschoubben 63a255c5cb searxng: bind its route where its state now lives
The route binding still named /var/lib/searxng-module, the directory the
previous commit placed elsewhere — the host would have written it into a
directory nothing declares. Same shape as gitea and nextcloud.
2026-09-29 22:41:56 +02:00
jschoubben 7ad1fbd5c6 searxng: its settings are a file the mesh writes, not the image's defaults
The module ran searxng on the image's built-in settings, which serve html
only — so the module's own search tool (format=json) was refused by the
software it fronts. And there was no way to configure it per machine: the
only file settings reach was the sidecar's.

settings.yml is now the module's one mergeable file (JSON is YAML): generic
defaults in the manifest (json format on, limiter and image proxy off,
valkey wired), and whatever differs per machine — base_url, method,
autocomplete, suspended times — set as the assignment's settings. The
secret key is filled on the machine through ${secret:secret}, so the
secrets-in-environment exception and the env file go. Directories are
placed. Image pinned to 2026.9.20, what ace runs today (the old pin was
older, 2026.9.1).

The sidecar's config.json is no longer mergeable: settings merge into every
mergeable file of a module, and the sidecar would have received searxng's
keys. It only ever read an optional url, which its env already carries.

Verified on ace: the pinned image serves html and json from a read-only,
root-owned 0600 JSON settings.yml.
2026-09-29 22:33:38 +02:00
jschoubben 67f5f4cffd Merge pull request 'ca-trust: a machine trusts the mesh's authority because a module put its root there' (#142) from feat/ca-trust into main 2026-09-29 14:06:36 +00:00
jschoubben 8797335fbc ca-trust: a machine trusts the mesh's authority because a module put its root there
novox/hq ADR 0147, issue 129. Every internal HTTPS name fails verification
on every machine: the certificates are genuine and nothing on a machine has
ever been told what issued them. The proxy's fetch answers for the proxy and
for nothing else — a browser, git over HTTPS and every module calling another
by an internal name read the machine's own trust store.

The module requires internal-acme-ca, fetches the root over the mesh's own
network (no prior trust to have; that is what this establishes), installs it
among the machine's anchors and refreshes the extracted bundles. Being
unassigned stops the unit, and stopping it takes the anchor away and
refreshes them again.

Arch's layout is named out loud: a machine that keeps anchors elsewhere fails
visibly rather than writing a file nothing reads.
2026-09-29 15:07:40 +02:00
mesh-admin 53dc108603 Merge pull request 'Remove the network-checker module: it does not do what was decided' (#141) from chore/remove-the-network-checker-module into main 2026-09-29 12:43:34 +00:00
jschoubben ebf5ba2d4c Remove the network-checker module: it does not do what was decided
What was in the catalogue was the first thing I built, not the thing ADR 0146
describes. It dialled raw ports on machine addresses from one hosting form and
emitted nothing, so findings would have sat in a file on the machine — the exact
thing issue 145 is about. It was never registered, never assigned, and never ran.

0146 says names per hosting form, fetched over TLS with the certificate verified,
and machines discovered over the bus. That shares nothing with this but the word
checker, so it goes rather than being bent into shape. Recorded as work to be
analysed and built deliberately.

Connectivity is checked by hand in the meantime, against the services the mesh
already runs.
2026-09-29 14:43:25 +02:00
mesh-admin bbac08a7d2 Merge pull request 'A network-checker module: dial what the mesh claims, from where the callers are' (#140) from feat/a-network-checker-module into main 2026-09-29 11:44:37 +00:00
11 changed files with 126 additions and 453 deletions
+56
View File
@@ -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"
]
}
]
}
-84
View File
@@ -1,84 +0,0 @@
// Dial everything the mesh claims is reachable, and say what was found (novox/hq ADR 0145).
//
// Runs on a cadence, from this machine, in this module's own container — the same position every other
// module on the machine calls from. That is the whole point: a check run by the host or by the control
// plane reaches these addresses by a path no ordinary caller uses, and would have passed throughout the
// outage that produced this module (novox/hq 04-ISSUES/145).
//
// It reports and does nothing else. A checker that repaired things would be a second control plane.
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
import { dirname, join } from "node:path";
import { dial, tally, targetsFor, type Counts, type Result, type Roster } from "../reach.js";
/** Where the mesh renders this machine's view of the others, and where the counts are kept between runs. */
const rosterFile = process.env.MESH_NETWORK_CHECKER_ROSTER ?? "/run/config/roster.json";
const stateDir = process.env.MESH_NETWORK_CHECKER_STATE ?? "/run/state";
const probePort = Number(process.env.MESH_NETWORK_CHECKER_PORT ?? "9876");
const publicPort = process.env.MESH_NETWORK_CHECKER_PUBLIC_PORT
? Number(process.env.MESH_NETWORK_CHECKER_PUBLIC_PORT)
: undefined;
const timeoutMs = Number(process.env.MESH_NETWORK_CHECKER_TIMEOUT_MS ?? "4000");
const threshold = Number(process.env.MESH_NETWORK_CHECKER_THRESHOLD ?? "2");
/** read is a JSON file or a stated failure — never a silent default, which is how a checker comes to
* report that everything is fine because it read nothing. */
function read<T>(path: string, whenMissing: T | null): T {
try {
return JSON.parse(readFileSync(path, "utf8")) as T;
} catch (err) {
if (whenMissing !== null) return whenMissing;
console.error(`network-checker: cannot read ${path}: ${(err as Error).message}`);
process.exit(1);
}
}
function writeAtomically(path: string, body: string): void {
mkdirSync(dirname(path), { recursive: true });
const temp = `${path}.writing`;
writeFileSync(temp, body);
renameSync(temp, path);
}
async function main(): Promise<void> {
const roster = read<Roster>(rosterFile, null);
if (!roster.machines?.length) {
console.error("network-checker: the roster names no machines; nothing to check");
process.exit(1);
}
const targets = targetsFor(roster, probePort, publicPort);
// In parallel, because a machine that is away should not delay the rest: a run that takes
// machines × timeout would outlast its own cadence on a mesh of any size.
const results: Result[] = await Promise.all(targets.map((t) => dial(t, timeoutMs)));
const countsFile = join(stateDir, "consecutive.json");
const { counts, broken } = tally(results, read<Counts>(countsFile, {}), threshold);
writeAtomically(countsFile, JSON.stringify(counts, null, 1));
// Written whole, every run: a reader asking "what does this machine reach" gets an answer about now
// rather than the last time something changed.
writeAtomically(join(stateDir, "reach.json"), JSON.stringify({
node: roster.node,
at: new Date().toISOString(),
checked: results.length,
broken: broken.length,
results,
}, null, 1));
for (const b of broken) {
console.error(
`network-checker: ${roster.node} cannot reach ${b.machine} (${b.claim}) at ${b.at}:${b.port} — ` +
`${b.failed} failed${b.detail ? `: ${b.detail}` : ""}, ${b.consecutive} run(s) running`);
}
if (broken.length === 0) {
console.log(`network-checker: ${roster.node} reaches all ${results.length} checked path(s)`);
}
// A broken path is not this process failing. It did its job; exiting non-zero would make the mesh
// read the checker as the fault, and a scheduled step that fails is retried rather than believed.
process.exit(0);
}
void main();
-61
View File
@@ -1,61 +0,0 @@
{
"module": "network-checker",
"version": "1",
"slug": "netcheck",
"listens": [
{
"name": "probe",
"port": 9876,
"protocol": "tcp",
"from": "mesh",
"why": "what the other machines' checkers dial. Deliberately this module's own endpoint and nothing else's: it is admitted by exactly the rule that governs every internally-exposed service, so it fails when that rule is wrong. A probe on a port that is never closed — ssh, say — would have passed throughout the outage this module exists to catch (novox/hq ADR 0145)"
}
],
"facts": {
"roster": {
"path": "/var/lib/network-checker/roster.json",
"template": "{\n \"generated\": \"by the mesh — do not edit; replaced whenever a machine joins or leaves\",\n \"node\": \"{{.Node}}\",\n \"machines\": [{{range $i, $m := .Machines}}{{if $i}},{{end}}\n { \"name\": \"{{$m.Name}}\", \"fqdn\": \"{{$m.FQDN}}\", \"address\": \"{{$m.Address}}\" }{{end}}\n ]\n}\n"
}
},
"build": {
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": ["probe/index.js", "check/index.js"]
}
]
},
"resources": [
{
"id": "state",
"type": "directory",
"path": "/var/lib/network-checker",
"mode": "0700"
},
{
"id": "probe",
"type": "container",
"name": "mesh-network-checker-probe",
"args": ["run", "/app/modules/network-checker/dist/probe/index.js"],
"ports": ["9876"],
"state": "running",
"env": { "MESH_NETWORK_CHECKER_PORT": "9876" }
},
{
"id": "check",
"type": "container",
"name": "mesh-network-checker-check",
"network": "host",
"schedule": "*/5 * * * *",
"args": ["run", "/app/modules/network-checker/dist/check/index.js"],
"volumes": ["/var/lib/network-checker:/run/state"],
"env": {
"MESH_NETWORK_CHECKER_ROSTER": "/run/state/roster.json",
"MESH_NETWORK_CHECKER_STATE": "/run/state",
"MESH_NETWORK_CHECKER_PORT": "${port:9876}"
}
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"name": "@novox/module-network-checker",
"version": "0.1.0",
"description": "network-checker — dials what the mesh claims is reachable, from where the callers are, and says what it found.",
"type": "module",
"private": true,
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
}
-31
View File
@@ -1,31 +0,0 @@
// The endpoint the other machines' checkers dial (novox/hq ADR 0145).
//
// **This module's own endpoint is the instrument.** It is declared reachable over the private network
// like any other service, so it is admitted by exactly the rule that governs every internally-exposed
// service and it fails when that rule is wrong. A probe on a port that is never closed — ssh, say —
// would have passed throughout the outage this module exists to catch.
//
// It accepts a connection and closes it. Answering anything would make this a protocol, and then the
// question would be whether the protocol worked rather than whether the path did.
import { createServer } from "node:net";
const port = Number(process.env.MESH_NETWORK_CHECKER_PORT ?? "9876");
const server = createServer((socket) => {
// Written before closing so a person dialling it by hand sees something, and so a half-open
// connection is not mistaken for a working path by a client that only checks the handshake.
socket.end("mesh network-checker\n");
});
server.on("error", (err: Error) => {
// Said and fatal: a probe that cannot listen must not look like a probe that nothing dialled.
console.error(`network-checker: cannot serve the probe on ${port}: ${err.message}`);
process.exit(1);
});
server.listen(port, () => console.log(`network-checker: probe listening on ${port}`));
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.on(signal, () => server.close(() => process.exit(0)));
}
-155
View File
@@ -1,155 +0,0 @@
// What the mesh claims is reachable, and how to find out (novox/hq ADR 0145).
//
// The mesh asserts three things are callable (ADR 0144): what runs on the same machine, another
// machine's service exposed to the private network, and another machine's service exposed publicly.
// This decides what to dial for each and reads the answers. It opens connections and nothing more —
// the module that owns a service is the one that knows whether it is working.
//
// **The target is this module's own endpoint, and that is deliberate.** The obvious thing to dial is a
// service every machine has, and the services every machine has are the ones never closed — ssh above
// all. Dialling one of those would have passed throughout the outage this exists to catch, because what
// broke was a service exposed to the private network and ssh is admitted unconditionally. A probe on a
// port that cannot fail measures nothing.
import { connect } from "node:net";
import { lookup } from "node:dns";
/** One machine as the mesh's roster describes it. */
export interface Machine {
name: string;
fqdn: string;
address: string;
/** The name this machine is reached by from outside, where it has one. Absent for most machines, and
* a machine with no public face has no public claim to check. */
public?: string;
}
/** The roster the mesh renders for this module: who this machine is, and who the others are. */
export interface Roster {
node: string;
machines: Machine[];
}
/** Which of the mesh's three claims a check is about, so a failure says which one broke. */
export type Claim = "this machine" | "the private network" | "the public network";
/** One thing to dial. */
export interface Target {
claim: Claim;
machine: string;
/** What to dial — a name where the point is that names resolve, an address where it is not. */
at: string;
port: number;
/** Whether `at` is a name that must resolve first, so a resolution failure is reported as one. */
byName: boolean;
}
/** What one dial found. */
export interface Result extends Target {
ok: boolean;
/** Which step failed, so a reader is sent to the right place: the resolver, or the filter. */
failed?: "resolution" | "connection";
detail?: string;
ms: number;
}
/**
* targetsFor is everything this machine should be able to reach, from the roster it was given.
*
* Its own machine first, because that is the case that distinguishes a caller on the machine from a
* caller in one of its containers — the one that broke. Then every other machine over the private
* network. The public claim is only checked where a public address is known for a machine, because a
* machine with no public face has nothing to fail.
*/
export function targetsFor(roster: Roster, probePort: number, publicPort?: number): Target[] {
const out: Target[] = [];
for (const m of roster.machines) {
const own = m.name === roster.node;
out.push({
claim: own ? "this machine" : "the private network",
machine: m.name,
at: m.address,
port: probePort,
byName: false,
});
// And by name, because a name that does not resolve and a port that does not answer are different
// faults with different owners.
out.push({
claim: own ? "this machine" : "the private network",
machine: m.name,
at: m.fqdn,
port: probePort,
byName: true,
});
}
if (publicPort !== undefined) {
for (const m of roster.machines) {
if (!m.public) continue;
out.push({
claim: "the public network",
machine: m.name,
at: m.public,
port: publicPort,
byName: true,
});
}
}
return out;
}
/** dial opens a connection and closes it. Whether the port accepts is the whole of what is asked. */
export function dial(target: Target, timeoutMs: number): Promise<Result> {
const began = Date.now();
const done = (ok: boolean, failed?: Result["failed"], detail?: string): Result => ({
...target, ok, failed, detail, ms: Date.now() - began,
});
return new Promise<Result>((resolve) => {
const open = () => {
const socket = connect({ host: target.at, port: target.port });
const finish = (r: Result) => { socket.destroy(); resolve(r); };
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(done(true)));
socket.once("timeout", () => finish(done(false, "connection", "timed out")));
socket.once("error", (err: Error) => finish(done(false, "connection", err.message)));
};
if (!target.byName) { open(); return; }
// Resolved first and reported separately: a checker that says "unreachable" for a name the
// resolver never answered sends a reader to the filter, which is not where the fault is.
lookup(target.at, (err) => {
if (err) { resolve(done(false, "resolution", err.message)); return; }
open();
});
});
}
/** A path's running count of consecutive failures, keyed so it survives between runs. */
export type Counts = Record<string, number>;
/** keyOf names one path, stably, so a count follows it across runs. */
export function keyOf(t: Target): string {
return `${t.claim}|${t.machine}|${t.at}|${t.port}`;
}
/**
* tally folds this run's results into the counts carried from the last one.
*
* **One failure is not a fault.** A machine rebooting is ordinary, and a checker that cries at the
* first missed dial trains a reader to ignore it — which is worse than not checking (ADR 0145). A path
* is broken once it has failed on consecutive runs, and the count travels with the result so a reader
* can tell "briefly away" from "never worked".
*/
export function tally(results: Result[], before: Counts, threshold: number): {
counts: Counts; broken: Array<Result & { consecutive: number }>;
} {
const counts: Counts = {};
const broken: Array<Result & { consecutive: number }> = [];
for (const r of results) {
const key = keyOf(r);
const n = r.ok ? 0 : (before[key] ?? 0) + 1;
if (n > 0) counts[key] = n;
if (n >= threshold) broken.push({ ...r, consecutive: n });
}
return { counts, broken };
}
@@ -1,72 +0,0 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { keyOf, tally, targetsFor, type Result, type Roster } from "../reach.js";
const roster: Roster = {
node: "here",
machines: [
{ name: "here", fqdn: "here.internal", address: "10.0.0.1" },
{ name: "there", fqdn: "there.internal", address: "10.0.0.2", public: "there.example.test" },
],
};
test("its own machine is checked, which is the case that distinguishes a caller on it from one in a container", () => {
const own = targetsFor(roster, 9876).filter((t) => t.claim === "this machine");
assert.equal(own.length, 2, "its own machine by address and by name");
assert.ok(own.some((t) => t.at === "10.0.0.1" && !t.byName));
assert.ok(own.some((t) => t.at === "here.internal" && t.byName));
});
test("every other machine is checked over the private network", () => {
const other = targetsFor(roster, 9876).filter((t) => t.claim === "the private network");
assert.deepEqual(other.map((t) => t.machine), ["there", "there"]);
});
test("the public claim is only checked where a machine has a public name", () => {
const pub = targetsFor(roster, 9876, 443).filter((t) => t.claim === "the public network");
assert.equal(pub.length, 1, "only the machine with a public name");
assert.equal(pub[0]!.at, "there.example.test");
assert.equal(pub[0]!.port, 443);
});
test("no public claim is made when no public port was given", () => {
assert.equal(targetsFor(roster, 9876).filter((t) => t.claim === "the public network").length, 0);
});
const failed = (at: string): Result => ({
claim: "this machine", machine: "here", at, port: 9876, byName: false,
ok: false, failed: "connection", ms: 1,
});
const passed = (at: string): Result => ({
claim: "this machine", machine: "here", at, port: 9876, byName: false, ok: true, ms: 1,
});
test("one failure is not a fault — a machine rebooting is ordinary", () => {
const { counts, broken } = tally([failed("10.0.0.1")], {}, 2);
assert.equal(broken.length, 0, "one missed dial says nothing");
assert.equal(counts[keyOf(failed("10.0.0.1"))], 1, "and is remembered");
});
test("a path that keeps failing is broken, and the count travels with it", () => {
const first = tally([failed("10.0.0.1")], {}, 2);
const second = tally([failed("10.0.0.1")], first.counts, 2);
assert.equal(second.broken.length, 1);
assert.equal(second.broken[0]!.consecutive, 2, "so a reader can tell briefly away from never worked");
});
test("a path that recovers stops being counted", () => {
const first = tally([failed("10.0.0.1")], {}, 2);
const second = tally([passed("10.0.0.1")], first.counts, 2);
assert.equal(second.broken.length, 0);
assert.deepEqual(second.counts, {}, "nothing carried forward for a path that works");
});
test("a count follows one path and not another", () => {
const a = failed("10.0.0.1");
const b = failed("10.0.0.2");
const first = tally([a, b], {}, 2);
const second = tally([a], first.counts, 2);
assert.equal(second.broken.length, 1, "only the path dialled this run is judged");
assert.equal(second.broken[0]!.at, "10.0.0.1");
});
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["reach.ts", "probe/index.ts", "check/index.ts", "test/*.ts"]
}
+9 -3
View File
@@ -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";
@@ -88,8 +89,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 };
}
+40 -7
View File
@@ -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": [
@@ -26,26 +28,58 @@
"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": "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.\nif (settings.timeZone) process.env.TZ = settings.timeZone;\ndelete settings.timeZone;\ndelete settings.endpoints;\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"
]
},
{
@@ -53,8 +87,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",
@@ -67,7 +100,7 @@
],
"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": [
@@ -86,7 +119,7 @@
}
},
"binds": {
"route": "/var/lib/mesh/nodered/route.json"
"route": "${dir:state}/route.json"
},
"build": {
"on": [
+21 -20
View File
@@ -5,7 +5,7 @@
"container-runtime"
],
"own-secrets": {
"secret": "/var/lib/searxng-module/secret.secret",
"secret": "/var/lib/mesh/searxng/secret",
"broker": "/var/lib/mesh/searxng/broker"
},
"listens": [
@@ -27,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",
@@ -63,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",
@@ -118,7 +119,7 @@
}
},
"binds": {
"route": "/var/lib/searxng-module/route.json"
"route": "${dir:state}/route.json"
},
"build": {
"on": [