Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051)

Nineteen modules gain a broker-bound runtime container that serves the module's
tools under its own scoped account: bazarr, gitea, grafana, home-assistant,
icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi,
photos, portainer, qbittorrent, searxng, tautulli, verdaccio.

Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv
overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env
fallbacks, so URL and credentials come from `settings set`, with the URL defaulting
to the server on the node. nextcloud and mailu also mount the docker socket for
their exec-based tools.

Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token,
the runtime reads the merged config and serves grafana's tools under the scoped
account, with nothing in the manifest. Two gaps this surfaced are filed as hq
issues 008 (a provider runtime's seal key) and 009 (a settings change does not
restart a container runtime).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-04 23:08:40 +02:00
parent 7b55e834e9
commit 9e156a5b9e
38 changed files with 668 additions and 51 deletions
+12 -2
View File
@@ -3,6 +3,8 @@
// missing subtitles, searches providers for them, and records what it downloaded. This client // missing subtitles, searches providers for them, and records what it downloaded. This client
// talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it. // talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it.
import { readFileSync } from "node:fs";
export interface WantedSubtitle { export interface WantedSubtitle {
kind: "episode" | "movie"; kind: "episode" | "movie";
title: string; // series + episode, or movie title title: string; // series + episode, or movie title
@@ -34,6 +36,13 @@ export interface HistoryEntry {
description?: string; description?: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class BazarrClient { export class BazarrClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -47,8 +56,9 @@ export class BazarrClient {
/** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key /** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key
* there is nothing to talk to, so this throws rather than run half-configured. */ * there is nothing to talk to, so this throws rather than run half-configured. */
static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient {
const url = env.MESH_BAZARR_URL; const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE);
const apiKey = env.MESH_BAZARR_API_KEY; const url = cfg.url ?? env.MESH_BAZARR_URL;
const apiKey = cfg.apiKey ?? env.MESH_BAZARR_API_KEY;
if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL"); if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL");
if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY"); if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY");
return new BazarrClient(url, apiKey); return new BazarrClient(url, apiKey);
+18
View File
@@ -80,6 +80,24 @@
"/services/media/anime:/anime", "/services/media/anime:/anime",
"/services/media/downloads:/downloads" "/services/media/downloads:/downloads"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-bazarr",
"image": "mesh-runtime-bazarr@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro",
"/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro",
"/services/bazarr/config:/var/lib/bazarr/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BAZARR_URL": "http://127.0.0.1:6767",
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
}
} }
] ]
} }
+12 -2
View File
@@ -3,6 +3,8 @@
// gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea // gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea
// does. // does.
import { readFileSync } from "node:fs";
/** A repository, trimmed to what the mesh cares about. */ /** A repository, trimmed to what the mesh cares about. */
export interface GiteaRepo { export interface GiteaRepo {
full_name: string; full_name: string;
@@ -42,6 +44,13 @@ export interface GiteaLabel {
name: string; name: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class GiteaClient { export class GiteaClient {
readonly baseUrl: string; readonly baseUrl: string;
private cachedUsername: string | null = null; private cachedUsername: string | null = null;
@@ -60,8 +69,9 @@ export class GiteaClient {
* call to make, so this throws rather than hand back a client that fails on first use. * call to make, so this throws rather than hand back a client that fails on first use.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient {
const url = env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE);
const token = env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`;
const token = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN;
if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN"); if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN");
return new GiteaClient(url, token); return new GiteaClient(url, token);
} }
+24
View File
@@ -88,6 +88,30 @@
"volumes": [ "volumes": [
"/services/gitea/gitea:/data" "/services/gitea/gitea:/data"
] ]
},
{
"id": "config",
"type": "file",
"path": "/var/lib/mesh/gitea/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-gitea",
"image": "mesh-runtime-gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro",
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GITEA_URL": "http://127.0.0.1:3000",
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json"
}
} }
] ]
} }
+14 -4
View File
@@ -2,6 +2,8 @@
// the shared hal sdk, where a change here rebuilt everything; here it rebuilds only grafana. Both // the shared hal sdk, where a change here rebuilt everything; here it rebuilds only grafana. Both
// this module's tools and its events entrypoint import it, and nothing outside grafana does. // this module's tools and its events entrypoint import it, and nothing outside grafana does.
import { readFileSync } from "node:fs";
export interface GrafanaHealth { export interface GrafanaHealth {
database: string; database: string;
version: string; version: string;
@@ -35,6 +37,13 @@ export interface GrafanaAlert {
activeAt?: string; activeAt?: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class GrafanaClient { export class GrafanaClient {
readonly baseUrl: string; readonly baseUrl: string;
private readonly authHeader: string; private readonly authHeader: string;
@@ -51,12 +60,13 @@ export class GrafanaClient {
* Throws when neither is configured — the module then contributes nothing rather than failing. * Throws when neither is configured — the module then contributes nothing rather than failing.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient {
const url = env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`; const cfg = meshConfig(env.MESH_GRAFANA_CONFIG_FILE);
const token = env.MESH_GRAFANA_TOKEN; const url = cfg.url ?? env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`;
const token = cfg.token ?? env.MESH_GRAFANA_TOKEN;
if (token) return new GrafanaClient(url, `Bearer ${token}`); if (token) return new GrafanaClient(url, `Bearer ${token}`);
const password = env.MESH_GRAFANA_PASSWORD; const password = cfg.password ?? env.MESH_GRAFANA_PASSWORD;
if (password) { if (password) {
const user = env.MESH_GRAFANA_USER ?? "admin"; const user = cfg.user ?? env.MESH_GRAFANA_USER ?? "admin";
return new GrafanaClient(url, `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`); return new GrafanaClient(url, `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`);
} }
throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD"); throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD");
+24
View File
@@ -60,6 +60,30 @@
"volumes": [ "volumes": [
"/services/grafana/data:/var/lib/grafana" "/services/grafana/data:/var/lib/grafana"
] ]
},
{
"id": "config",
"type": "file",
"path": "/var/lib/mesh/grafana/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-grafana",
"image": "mesh-runtime-grafana@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/grafana/broker:/run/secrets/broker:ro",
"/var/lib/mesh/grafana/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GRAFANA_URL": "http://127.0.0.1:3000",
"MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json"
}
} }
] ]
} }
+12 -2
View File
@@ -2,6 +2,8 @@
// ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside // ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside
// home-assistant does. Talks to the HA REST API (/api) with a long-lived access token. // home-assistant does. Talks to the HA REST API (/api) with a long-lived access token.
import { readFileSync } from "node:fs";
export interface HAEntityState { export interface HAEntityState {
entity_id: string; entity_id: string;
state: string; state: string;
@@ -18,6 +20,13 @@ export interface HAConfig {
state?: string; state?: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class HomeAssistantClient { export class HomeAssistantClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -34,8 +43,9 @@ export class HomeAssistantClient {
* every API call is Bearer-authenticated and there is nowhere to discover it from. * every API call is Bearer-authenticated and there is nowhere to discover it from.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient {
const url = env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`; const cfg = meshConfig(env.MESH_HOMEASSISTANT_CONFIG_FILE);
const token = env.MESH_HOMEASSISTANT_TOKEN; const url = cfg.url ?? env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`;
const token = cfg.token ?? env.MESH_HOMEASSISTANT_TOKEN;
if (!token) throw new Error("no Home Assistant token — set MESH_HOMEASSISTANT_TOKEN"); if (!token) throw new Error("no Home Assistant token — set MESH_HOMEASSISTANT_TOKEN");
return new HomeAssistantClient(url, token); return new HomeAssistantClient(url, token);
} }
+18
View File
@@ -44,6 +44,24 @@
"volumes": [ "volumes": [
"/services/home-assistant/config:/config" "/services/home-assistant/config:/config"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-home-assistant",
"image": "mesh-runtime-home-assistant@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/home-assistant/broker:/run/secrets/broker:ro",
"/var/lib/mesh/home-assistant/config.json:/run/config/config.json:ro",
"/services/home-assistant/config:/var/lib/home-assistant/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:8123",
"MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json",
"MESH_HOMEASSISTANT_CONFIG_DIR": "/var/lib/home-assistant/config"
}
} }
] ]
} }
+12 -2
View File
@@ -3,6 +3,8 @@
// endpoint reports the live mountpoints and their listener counts — the one thing worth watching, and // endpoint reports the live mountpoints and their listener counts — the one thing worth watching, and
// the basis for both the status tool and the stream started/stopped events. // the basis for both the status tool and the stream started/stopped events.
import { readFileSync } from "node:fs";
export interface IcecastMount { export interface IcecastMount {
/** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */ /** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */
mount: string; mount: string;
@@ -33,6 +35,13 @@ interface RawSource {
server_type?: string; server_type?: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class IcecastClient { export class IcecastClient {
readonly baseUrl: string; readonly baseUrl: string;
private readonly authHeader?: string; private readonly authHeader?: string;
@@ -46,8 +55,9 @@ export class IcecastClient {
} }
static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient {
const url = env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`; const cfg = meshConfig(env.MESH_ICECAST_CONFIG_FILE);
return new IcecastClient(url, env.MESH_ICECAST_ADMIN_USER, env.MESH_ICECAST_ADMIN_PASSWORD); const url = cfg.url ?? (env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`);
return new IcecastClient(url, cfg.user ?? env.MESH_ICECAST_ADMIN_USER, cfg.password ?? env.MESH_ICECAST_ADMIN_PASSWORD);
} }
async getStatus(): Promise<IcecastStatus> { async getStatus(): Promise<IcecastStatus> {
+24
View File
@@ -53,6 +53,30 @@
"ports": [ "ports": [
"8000" "8000"
] ]
},
{
"id": "config",
"type": "file",
"path": "/var/lib/mesh/icecast/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-icecast",
"image": "mesh-runtime-icecast@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/icecast/broker:/run/secrets/broker:ro",
"/var/lib/mesh/icecast/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_ICECAST_URL": "http://127.0.0.1:8000",
"MESH_ICECAST_CONFIG_FILE": "/run/config/config.json"
}
} }
] ]
} }
+13 -3
View File
@@ -1,6 +1,8 @@
// The InfluxDB API client — influxdb's own code, living in the module (novox/hq ADR 0044). Only // The InfluxDB API client — influxdb's own code, living in the module (novox/hq ADR 0044). Only
// this module's tools import it. Talks to the InfluxDB 2.x HTTP API (/api/v2) with a token. // this module's tools import it. Talks to the InfluxDB 2.x HTTP API (/api/v2) with a token.
import { readFileSync } from "node:fs";
export interface InfluxHealth { export interface InfluxHealth {
name?: string; name?: string;
status?: string; status?: string;
@@ -15,6 +17,13 @@ export interface InfluxBucket {
retentionSeconds?: number; retentionSeconds?: number;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class InfluxDBClient { export class InfluxDBClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -32,10 +41,11 @@ export class InfluxDBClient {
* is token-authenticated. The org scopes bucket listing and queries. * is token-authenticated. The org scopes bucket listing and queries.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient {
const url = env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`; const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE);
const token = env.MESH_INFLUXDB_TOKEN; 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"); if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN");
const org = env.MESH_INFLUXDB_ORG ?? "mesh"; const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh";
return new InfluxDBClient(url, token, org); return new InfluxDBClient(url, token, org);
} }
+26 -1
View File
@@ -6,7 +6,8 @@
], ],
"own-secrets": { "own-secrets": {
"admin": "/var/lib/influxdb-module/admin.secret", "admin": "/var/lib/influxdb-module/admin.secret",
"admin-token": "/var/lib/influxdb-module/admin-token.secret" "admin-token": "/var/lib/influxdb-module/admin-token.secret",
"broker": "/var/lib/mesh/influxdb/broker"
}, },
"listens": [ "listens": [
{ {
@@ -17,6 +18,12 @@
} }
], ],
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/influxdb",
"mode": "0700"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -59,6 +66,24 @@
"/services/influxdb/data:/var/lib/influxdb2", "/services/influxdb/data:/var/lib/influxdb2",
"/services/influxdb/config:/etc/influxdb2" "/services/influxdb/config:/etc/influxdb2"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-influxdb",
"image": "mesh-runtime-influxdb@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"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"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_INFLUXDB_URL": "http://127.0.0.1:8086",
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
"MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config"
}
} }
] ]
} }
+12 -2
View File
@@ -2,6 +2,8 @@
// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client // 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. // talks its /api/v2.0 REST API, and only jackett's tools import it.
import { readFileSync } from "node:fs";
export interface JackettIndexer { export interface JackettIndexer {
id: string; id: string;
name: string; name: string;
@@ -22,6 +24,13 @@ export interface JackettResult {
link?: string; link?: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class JackettClient { export class JackettClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -38,8 +47,9 @@ export class JackettClient {
* module contributes no tools rather than failing half-configured. * module contributes no tools rather than failing half-configured.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
const url = env.MESH_JACKETT_URL; const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE);
const apiKey = env.MESH_JACKETT_API_KEY; const url = cfg.url ?? env.MESH_JACKETT_URL;
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY;
if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL"); 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");
return new JackettClient(url, apiKey); return new JackettClient(url, apiKey);
+28 -1
View File
@@ -13,6 +13,12 @@
} }
], ],
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/jackett",
"mode": "0700"
},
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
@@ -36,6 +42,27 @@
"volumes": [ "volumes": [
"/services/jackett/config:/config" "/services/jackett/config:/config"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-jackett",
"image": "mesh-runtime-jackett@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"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"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_JACKETT_URL": "http://127.0.0.1:9117",
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
}
} }
] ],
"own-secrets": {
"broker": "/var/lib/mesh/jackett/broker"
}
} }
+14 -4
View File
@@ -3,6 +3,15 @@
// it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and // it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and
// nothing outside keycloak does. // nothing outside keycloak does.
import { readFileSync } from "node:fs";
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class KeycloakClient { export class KeycloakClient {
readonly baseUrl: string; readonly baseUrl: string;
readonly defaultRealm: string; readonly defaultRealm: string;
@@ -28,11 +37,12 @@ export class KeycloakClient {
* here lets the tool runtime expose no keycloak tools rather than tools that always error. * here lets the tool runtime expose no keycloak tools rather than tools that always error.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient {
const url = env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`; const cfg = meshConfig(env.MESH_KEYCLOAK_CONFIG_FILE);
const adminUser = env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin"; const url = cfg.url ?? env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`;
const adminPass = env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD; const adminUser = cfg.user ?? env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin";
const adminPass = cfg.password ?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD;
if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD"); if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD");
const realm = env.MESH_KEYCLOAK_REALM ?? "master"; const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master";
return new KeycloakClient(url, adminUser, adminPass, realm); return new KeycloakClient(url, adminUser, adminPass, realm);
} }
+24
View File
@@ -91,6 +91,30 @@
"ports": [ "ports": [
"8080" "8080"
] ]
},
{
"id": "config",
"type": "file",
"path": "/var/lib/mesh/keycloak/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-keycloak",
"image": "mesh-runtime-keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/keycloak/broker:/run/secrets/broker:ro",
"/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:8080",
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
}
} }
] ]
} }
+12 -3
View File
@@ -9,6 +9,7 @@
// falls back to `doveadm` inside the imap container, the operation the HTTP surface cannot serve. // falls back to `doveadm` inside the imap container, the operation the HTTP surface cannot serve.
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { readFileSync } from "node:fs";
import { promisify } from "node:util"; import { promisify } from "node:util";
const run = promisify(execFile); const run = promisify(execFile);
@@ -44,6 +45,13 @@ export interface MailMessage {
// The fields we ask doveadm for, once — kept together so read and search stay identical in shape. // The fields we ask doveadm for, once — kept together so read and search stay identical in shape.
const FETCH_FIELDS = "date.received hdr.subject hdr.from body.snippet"; const FETCH_FIELDS = "date.received hdr.subject hdr.from body.snippet";
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class MailuClient { export class MailuClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -62,12 +70,13 @@ export class MailuClient {
* client with neither would only fail later, one call at a time, so it fails here instead. * client with neither would only fail later, one call at a time, so it fails here instead.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient {
const url = env.MESH_MAILU_URL; const cfg = meshConfig(env.MESH_MAILU_CONFIG_FILE);
const apiKey = env.MESH_MAILU_API_KEY; const url = cfg.url ?? env.MESH_MAILU_URL;
const apiKey = cfg.apiKey ?? env.MESH_MAILU_API_KEY;
if (!url || !apiKey) { if (!url || !apiKey) {
throw new Error("Mailu is not configured — set MESH_MAILU_URL and MESH_MAILU_API_KEY"); throw new Error("Mailu is not configured — set MESH_MAILU_URL and MESH_MAILU_API_KEY");
} }
const imapContainer = env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap"; const imapContainer = cfg.container ?? env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap";
return new MailuClient(url, apiKey, imapContainer); return new MailuClient(url, apiKey, imapContainer);
} }
+25
View File
@@ -282,6 +282,31 @@
"/services/mailu/data/certs:/certs", "/services/mailu/data/certs:/certs",
"/services/mailu/data/overrides/nginx:/overrides:ro" "/services/mailu/data/overrides/nginx:/overrides:ro"
] ]
},
{
"id": "config",
"type": "file",
"path": "/var/lib/mesh/mailu/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mailu",
"image": "mesh-runtime-mailu@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro",
"/var/run/docker.sock:/var/run/docker.sock"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_MAILU_URL": "http://127.0.0.1:80",
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json"
}
} }
] ]
} }
+13 -4
View File
@@ -9,6 +9,7 @@
// because occ has no version-stable "list every share" across the releases we run. // because occ has no version-stable "list every share" across the releases we run.
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
export interface NextcloudUser { export interface NextcloudUser {
uid: string; uid: string;
@@ -24,6 +25,13 @@ export interface NextcloudShare {
owner: string; owner: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class NextcloudClient { export class NextcloudClient {
constructor( constructor(
private readonly container: string, private readonly container: string,
@@ -40,10 +48,11 @@ export class NextcloudClient {
* throws without it, and the module then contributes nothing rather than failing. * throws without it, and the module then contributes nothing rather than failing.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient {
const container = env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; const cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE);
const ocsUrl = env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud";
const adminUser = env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`;
const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD; const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin";
const adminPassword = cfg.password ?? env.MESH_NEXTCLOUD_ADMIN_PASSWORD;
if (!adminPassword) throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD"); if (!adminPassword) throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD");
return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword); return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword);
} }
+25
View File
@@ -81,6 +81,31 @@
"volumes": [ "volumes": [
"/services/nextcloud/html:/var/www/html" "/services/nextcloud/html:/var/www/html"
] ]
},
{
"id": "config",
"type": "file",
"path": "/var/lib/mesh/nextcloud/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-nextcloud",
"image": "mesh-runtime-nextcloud@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro",
"/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro",
"/var/run/docker.sock:/var/run/docker.sock"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NEXTCLOUD_URL": "http://127.0.0.1:80",
"MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json"
}
} }
] ]
} }
+12 -2
View File
@@ -5,6 +5,8 @@
// configuration, GET /nodes for installed node modules. A default install has no auth; when // 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 (minted at /auth/token) is required.
import { readFileSync } from "node:fs";
export interface NodeRedFlow { export interface NodeRedFlow {
/** The tab (flow) node id. */ /** The tab (flow) node id. */
id: string; id: string;
@@ -18,6 +20,13 @@ export interface NodeRedNodeModule {
types: string[]; types: string[];
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class NodeRedClient { export class NodeRedClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -35,9 +44,10 @@ export class NodeRedClient {
* a default install needs none. * a default install needs none.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient {
const url = env.MESH_NODERED_URL; const cfg = meshConfig(env.MESH_NODERED_CONFIG_FILE);
const url = cfg.url ?? env.MESH_NODERED_URL;
if (!url) throw new Error("no Node-RED URL — set MESH_NODERED_URL"); if (!url) throw new Error("no Node-RED URL — set MESH_NODERED_URL");
return new NodeRedClient(url, env.MESH_NODERED_TOKEN); return new NodeRedClient(url, cfg.token ?? env.MESH_NODERED_TOKEN);
} }
private headers(extra: Record<string, string> = {}): Record<string, string> { private headers(extra: Record<string, string> = {}): Record<string, string> {
+24
View File
@@ -46,6 +46,30 @@
"volumes": [ "volumes": [
"/services/nodered/data:/data" "/services/nodered/data:/data"
] ]
},
{
"id": "config",
"type": "file",
"path": "/var/lib/mesh/nodered/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-nodered",
"image": "mesh-runtime-nodered@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/nodered/broker:/run/secrets/broker:ro",
"/var/lib/mesh/nodered/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NODERED_URL": "http://127.0.0.1:1880",
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json"
}
} }
] ]
} }
+13 -3
View File
@@ -3,6 +3,8 @@
// nzbget and nothing else. Both this module's tools and its events entrypoint import it, and // nzbget and nothing else. Both this module's tools and its events entrypoint import it, and
// nothing outside nzbget does. NZBGet speaks JSON-RPC at /jsonrpc, behind HTTP Basic auth. // nothing outside nzbget does. NZBGet speaks JSON-RPC at /jsonrpc, behind HTTP Basic auth.
import { readFileSync } from "node:fs";
export interface NzbgetStatus { export interface NzbgetStatus {
/** Bytes/sec — NZBGet reports it split across two 32-bit halves, rejoined here. */ /** Bytes/sec — NZBGet reports it split across two 32-bit halves, rejoined here. */
speedBytesPerSec: number; speedBytesPerSec: number;
@@ -39,6 +41,13 @@ export interface NzbgetHistoryItem {
success: boolean; success: boolean;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class NzbgetClient { export class NzbgetClient {
readonly rpcUrl: string; readonly rpcUrl: string;
private readonly auth: string; private readonly auth: string;
@@ -55,12 +64,13 @@ export class NzbgetClient {
* as "exposes nothing"). The control username defaults to "nzbget", NZBGet's own default. * as "exposes nothing"). The control username defaults to "nzbget", NZBGet's own default.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient {
const url = env.MESH_NZBGET_URL; const cfg = meshConfig(env.MESH_NZBGET_CONFIG_FILE);
const password = env.MESH_NZBGET_PASSWORD; const url = cfg.url ?? env.MESH_NZBGET_URL;
const password = cfg.password ?? env.MESH_NZBGET_PASSWORD;
if (!url || !password) { if (!url || !password) {
throw new Error("NZBGet not configured — set MESH_NZBGET_URL and MESH_NZBGET_PASSWORD"); throw new Error("NZBGet not configured — set MESH_NZBGET_URL and MESH_NZBGET_PASSWORD");
} }
const user = env.MESH_NZBGET_USER ?? "nzbget"; const user = cfg.user ?? env.MESH_NZBGET_USER ?? "nzbget";
return new NzbgetClient(url, user, password); return new NzbgetClient(url, user, password);
} }
+18
View File
@@ -58,6 +58,24 @@
"/services/nzbget/config:/config", "/services/nzbget/config:/config",
"/services/media/downloads:/downloads" "/services/media/downloads:/downloads"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-nzbget",
"image": "mesh-runtime-nzbget@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/nzbget/broker:/run/secrets/broker:ro",
"/var/lib/mesh/nzbget/config.json:/run/config/config.json:ro",
"/services/nzbget/config:/var/lib/nzbget/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NZBGET_URL": "http://127.0.0.1:6789",
"MESH_NZBGET_CONFIG_FILE": "/run/config/config.json",
"MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config"
}
} }
] ]
} }
+12 -2
View File
@@ -2,6 +2,8 @@
// request front-end: viewers ask for movies and shows, and an operator approves them. This client // request front-end: viewers ask for movies and shows, and an operator approves them. This client
// talks its /api/v1 REST API (keyed by an ApiKey header); ombi's tools and events import it. // talks its /api/v1 REST API (keyed by an ApiKey header); ombi's tools and events import it.
import { readFileSync } from "node:fs";
export interface OmbiRequest { export interface OmbiRequest {
kind: "movie" | "tv"; kind: "movie" | "tv";
id: number; id: number;
@@ -20,6 +22,13 @@ export interface RequestCounts {
available: number; available: number;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class OmbiClient { export class OmbiClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -33,8 +42,9 @@ export class OmbiClient {
/** Build from the module's resolved environment. Ombi's API is keyed; without URL and key there /** Build from the module's resolved environment. Ombi's API is keyed; without URL and key there
* is nothing to talk to, so this throws rather than run half-configured. */ * is nothing to talk to, so this throws rather than run half-configured. */
static fromEnv(env: NodeJS.ProcessEnv = process.env): OmbiClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): OmbiClient {
const url = env.MESH_OMBI_URL; const cfg = meshConfig(env.MESH_OMBI_CONFIG_FILE);
const apiKey = env.MESH_OMBI_API_KEY; const url = cfg.url ?? env.MESH_OMBI_URL;
const apiKey = cfg.apiKey ?? env.MESH_OMBI_API_KEY;
if (!url) throw new Error("no Ombi URL — set MESH_OMBI_URL"); if (!url) throw new Error("no Ombi URL — set MESH_OMBI_URL");
if (!apiKey) throw new Error("no Ombi API key — set MESH_OMBI_API_KEY"); if (!apiKey) throw new Error("no Ombi API key — set MESH_OMBI_API_KEY");
return new OmbiClient(url, apiKey); return new OmbiClient(url, apiKey);
+18
View File
@@ -49,6 +49,24 @@
"volumes": [ "volumes": [
"/services/ombi/config:/config" "/services/ombi/config:/config"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-ombi",
"image": "mesh-runtime-ombi@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/ombi/broker:/run/secrets/broker:ro",
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro",
"/services/ombi/config:/var/lib/ombi/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_OMBI_URL": "http://127.0.0.1:3579",
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json",
"MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config"
}
} }
] ]
} }
+12 -2
View File
@@ -3,6 +3,8 @@
// by an API key sent as the `x-api-key` header). The client speaks only what the tools and the // by an API key sent as the `x-api-key` header). The client speaks only what the tools and the
// item-added event need: server version and statistics, albums, and recent assets. // item-added event need: server version and statistics, albums, and recent assets.
import { readFileSync } from "node:fs";
export interface PhotosServerInfo { export interface PhotosServerInfo {
version: string; version: string;
photos?: number; photos?: number;
@@ -24,6 +26,13 @@ export interface PhotosAsset {
createdAt?: string; createdAt?: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class PhotosClient { export class PhotosClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -38,8 +47,9 @@ export class PhotosClient {
* the API key is required, and without it the module contributes nothing rather than reaching an * the API key is required, and without it the module contributes nothing rather than reaching an
* unauthenticated endpoint. */ * unauthenticated endpoint. */
static fromEnv(env: NodeJS.ProcessEnv = process.env): PhotosClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): PhotosClient {
const url = env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`; const cfg = meshConfig(env.MESH_PHOTOS_CONFIG_FILE);
const key = env.MESH_PHOTOS_API_KEY; const url = cfg.url ?? env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`;
const key = cfg.apiKey ?? env.MESH_PHOTOS_API_KEY;
if (!key) throw new Error("no photos API key — set MESH_PHOTOS_API_KEY"); if (!key) throw new Error("no photos API key — set MESH_PHOTOS_API_KEY");
return new PhotosClient(url, key); return new PhotosClient(url, key);
} }
+19
View File
@@ -47,6 +47,25 @@
"/etc/photos/store.json:/etc/photos/store.json:ro", "/etc/photos/store.json:/etc/photos/store.json:ro",
"/etc/photos/store.secret:/etc/photos/store.secret:ro" "/etc/photos/store.secret:/etc/photos/store.secret:ro"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-photos",
"image": "mesh-runtime-photos@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/photos/broker:/run/secrets/broker:ro",
"/var/lib/mesh/photos/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_PHOTOS_URL": "http://127.0.0.1:2283",
"MESH_PHOTOS_CONFIG_FILE": "/run/config/config.json"
}
} }
],
"capabilities": [
"container-runtime"
] ]
} }
+12 -2
View File
@@ -3,6 +3,8 @@
// which the host owns and emits — so this module reads Portainer's own resources (endpoints, // which the host owns and emits — so this module reads Portainer's own resources (endpoints,
// stacks, containers) and exposes them, and stops there. // stacks, containers) and exposes them, and stops there.
import { readFileSync } from "node:fs";
export interface PortainerEndpoint { export interface PortainerEndpoint {
id: number; id: number;
name: string; name: string;
@@ -27,6 +29,13 @@ export interface PortainerContainer {
status: string; status: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class PortainerClient { export class PortainerClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -44,8 +53,9 @@ export class PortainerClient {
* exposes nothing rather than calling Portainer unauthenticated. * exposes nothing rather than calling Portainer unauthenticated.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient {
const url = env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`; const cfg = meshConfig(env.MESH_PORTAINER_CONFIG_FILE);
const token = env.MESH_PORTAINER_TOKEN; const url = cfg.url ?? env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`;
const token = cfg.token ?? env.MESH_PORTAINER_TOKEN;
if (!token) throw new Error("no Portainer token — set MESH_PORTAINER_TOKEN"); if (!token) throw new Error("no Portainer token — set MESH_PORTAINER_TOKEN");
return new PortainerClient(url, token); return new PortainerClient(url, token);
} }
+34 -1
View File
@@ -13,6 +13,12 @@
} }
], ],
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/portainer",
"mode": "0700"
},
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
@@ -31,6 +37,33 @@
"/services/portainer/data:/data", "/services/portainer/data:/data",
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
] ]
},
{
"id": "config",
"type": "file",
"path": "/var/lib/mesh/portainer/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-portainer",
"image": "mesh-runtime-portainer@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/portainer/broker:/run/secrets/broker:ro",
"/var/lib/mesh/portainer/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_PORTAINER_URL": "https://127.0.0.1:9443",
"MESH_PORTAINER_CONFIG_FILE": "/run/config/config.json"
}
} }
] ],
"own-secrets": {
"broker": "/var/lib/mesh/portainer/broker"
}
} }
+13 -3
View File
@@ -7,6 +7,8 @@
// against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID is // against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID is
// captured on login and carried by hand on every later call, with a single re-login on expiry. // captured on login and carried by hand on every later call, with a single re-login on expiry.
import { readFileSync } from "node:fs";
export interface QbTransferInfo { export interface QbTransferInfo {
dlSpeedBytesPerSec: number; dlSpeedBytesPerSec: number;
upSpeedBytesPerSec: number; upSpeedBytesPerSec: number;
@@ -30,6 +32,13 @@ export interface QbTorrent {
savePath: string; savePath: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class QbittorrentClient { export class QbittorrentClient {
readonly baseUrl: string; readonly baseUrl: string;
private sid: string | null = null; private sid: string | null = null;
@@ -49,12 +58,13 @@ export class QbittorrentClient {
* (the harness treats the throw as "exposes nothing"). The user defaults to "admin". * (the harness treats the throw as "exposes nothing"). The user defaults to "admin".
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient {
const url = env.MESH_QBITTORRENT_URL; const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE);
const password = env.MESH_QBITTORRENT_PASSWORD; const url = cfg.url ?? env.MESH_QBITTORRENT_URL;
const password = cfg.password ?? env.MESH_QBITTORRENT_PASSWORD;
if (!url || !password) { if (!url || !password) {
throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD"); throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD");
} }
const user = env.MESH_QBITTORRENT_USER ?? "admin"; const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? "admin";
return new QbittorrentClient(url, user, password); return new QbittorrentClient(url, user, password);
} }
+18
View File
@@ -58,6 +58,24 @@
"/services/qbittorrent/config:/config", "/services/qbittorrent/config:/config",
"/services/media/downloads:/downloads" "/services/media/downloads:/downloads"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-qbittorrent",
"image": "mesh-runtime-qbittorrent@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/qbittorrent/broker:/run/secrets/broker:ro",
"/var/lib/mesh/qbittorrent/config.json:/run/config/config.json:ro",
"/services/qbittorrent/config:/var/lib/qbittorrent/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_QBITTORRENT_URL": "http://127.0.0.1:8080",
"MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json",
"MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config"
}
} }
] ]
} }
+11 -1
View File
@@ -3,6 +3,8 @@
// merged results. Its JSON API (`/search?q=...&format=json`) is what makes a `searxng_search` tool // merged results. Its JSON API (`/search?q=...&format=json`) is what makes a `searxng_search` tool
// useful; the client speaks only that. No credential — the instance is reached inside the mesh. // useful; the client speaks only that. No credential — the instance is reached inside the mesh.
import { readFileSync } from "node:fs";
export interface SearxResult { export interface SearxResult {
title: string; title: string;
url: string; url: string;
@@ -26,6 +28,13 @@ export interface SearxOptions {
pageno?: number; pageno?: number;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class SearxngClient { export class SearxngClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -36,7 +45,8 @@ export class SearxngClient {
/** Build from the module's environment. No key: SearXNG's search API is open on the mesh, so a URL /** Build from the module's environment. No key: SearXNG's search API is open on the mesh, so a URL
* is all it takes — defaulting to the container's own listen port. */ * is all it takes — defaulting to the container's own listen port. */
static fromEnv(env: NodeJS.ProcessEnv = process.env): SearxngClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): SearxngClient {
const url = env.MESH_SEARXNG_URL ?? `http://127.0.0.1:${env.SEARXNG_PORT ?? "8080"}`; const cfg = meshConfig(env.MESH_SEARXNG_CONFIG_FILE);
const url = cfg.url ?? (env.MESH_SEARXNG_URL ?? `http://127.0.0.1:${env.SEARXNG_PORT ?? "8080"}`);
return new SearxngClient(url); return new SearxngClient(url);
} }
+32 -1
View File
@@ -5,7 +5,8 @@
"container-runtime" "container-runtime"
], ],
"own-secrets": { "own-secrets": {
"secret": "/var/lib/searxng-module/secret.secret" "secret": "/var/lib/searxng-module/secret.secret",
"broker": "/var/lib/mesh/searxng/broker"
}, },
"listens": [ "listens": [
{ {
@@ -16,6 +17,12 @@
} }
], ],
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/searxng",
"mode": "0700"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -64,6 +71,30 @@
"ports": [ "ports": [
"8080" "8080"
] ]
},
{
"id": "config",
"type": "file",
"path": "/var/lib/mesh/searxng/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-searxng",
"image": "mesh-runtime-searxng@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/searxng/broker:/run/secrets/broker:ro",
"/var/lib/mesh/searxng/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SEARXNG_URL": "http://127.0.0.1:8080",
"MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json"
}
} }
] ]
} }
+12 -2
View File
@@ -5,6 +5,8 @@
// { response: { result: "success" | "error", message, data } }. This client unwraps that envelope // { response: { result: "success" | "error", message, data } }. This client unwraps that envelope
// and hands back only the data. // and hands back only the data.
import { readFileSync } from "node:fs";
export interface TautulliSession { export interface TautulliSession {
user: string; user: string;
title: string; title: string;
@@ -32,6 +34,13 @@ export interface TautulliHomeStat {
rows: Array<Record<string, unknown>>; rows: Array<Record<string, unknown>>;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class TautulliClient { export class TautulliClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -48,8 +57,9 @@ export class TautulliClient {
* Throws when no key is configured — the module then contributes nothing rather than failing. * Throws when no key is configured — the module then contributes nothing rather than failing.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient {
const url = env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`; const cfg = meshConfig(env.MESH_TAUTULLI_CONFIG_FILE);
const apiKey = env.MESH_TAUTULLI_APIKEY; const url = cfg.url ?? (env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`);
const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY;
if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY"); if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY");
return new TautulliClient(url, apiKey); return new TautulliClient(url, apiKey);
} }
+18
View File
@@ -48,6 +48,24 @@
"volumes": [ "volumes": [
"/services/tautulli/config:/config" "/services/tautulli/config:/config"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-tautulli",
"image": "mesh-runtime-tautulli@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/tautulli/broker:/run/secrets/broker:ro",
"/var/lib/mesh/tautulli/config.json:/run/config/config.json:ro",
"/services/tautulli/config:/var/lib/tautulli/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_TAUTULLI_URL": "http://127.0.0.1:8181",
"MESH_TAUTULLI_CONFIG_FILE": "/run/config/config.json",
"MESH_TAUTULLI_CONFIG_DIR": "/var/lib/tautulli/config"
}
} }
] ]
} }
+12 -2
View File
@@ -2,6 +2,8 @@
// ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside // ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside
// verdaccio does. // verdaccio does.
import { readFileSync } from "node:fs";
export interface VerdaccioPackage { export interface VerdaccioPackage {
name: string; name: string;
version?: string; version?: string;
@@ -17,6 +19,13 @@ export interface PackageInfo {
modified?: string; modified?: string;
} }
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class VerdaccioClient { export class VerdaccioClient {
readonly baseUrl: string; readonly baseUrl: string;
@@ -34,9 +43,10 @@ export class VerdaccioClient {
* port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured. * port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): VerdaccioClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): VerdaccioClient {
const url = env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`; const cfg = meshConfig(env.MESH_VERDACCIO_CONFIG_FILE);
const url = cfg.url ?? (env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`);
if (!url) throw new Error("no verdaccio URL — set MESH_VERDACCIO_URL"); if (!url) throw new Error("no verdaccio URL — set MESH_VERDACCIO_URL");
return new VerdaccioClient(url, env.MESH_VERDACCIO_TOKEN); return new VerdaccioClient(url, cfg.token ?? env.MESH_VERDACCIO_TOKEN);
} }
private async getJson<T>(path: string): Promise<T> { private async getJson<T>(path: string): Promise<T> {
+16
View File
@@ -58,6 +58,22 @@
"/services/verdaccio/storage:/verdaccio/storage", "/services/verdaccio/storage:/verdaccio/storage",
"/services/verdaccio/conf:/verdaccio/conf" "/services/verdaccio/conf:/verdaccio/conf"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-verdaccio",
"image": "mesh-runtime-verdaccio@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/verdaccio/broker:/run/secrets/broker:ro",
"/var/lib/mesh/verdaccio/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_VERDACCIO_URL": "http://127.0.0.1:4873",
"MESH_VERDACCIO_CONFIG_FILE": "/run/config/config.json"
}
} }
] ]
} }