From 9e156a5b9ee0a5db16f99e58637fc3bcf5a7343c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 23:08:40 +0200 Subject: [PATCH] Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__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 --- modules/bazarr/client.ts | 14 ++++++++++-- modules/bazarr/module.json | 18 +++++++++++++++ modules/gitea/client.ts | 14 ++++++++++-- modules/gitea/module.json | 24 ++++++++++++++++++++ modules/grafana/client.ts | 18 +++++++++++---- modules/grafana/module.json | 24 ++++++++++++++++++++ modules/home-assistant/client.ts | 14 ++++++++++-- modules/home-assistant/module.json | 18 +++++++++++++++ modules/icecast/client.ts | 14 ++++++++++-- modules/icecast/module.json | 24 ++++++++++++++++++++ modules/influxdb/client.ts | 16 +++++++++++--- modules/influxdb/module.json | 27 ++++++++++++++++++++++- modules/jackett/client.ts | 14 ++++++++++-- modules/jackett/module.json | 29 ++++++++++++++++++++++++- modules/keycloak/client.ts | 18 +++++++++++---- modules/keycloak/module.json | 24 ++++++++++++++++++++ modules/mailu/client.ts | 15 ++++++++++--- modules/mailu/module.json | 25 +++++++++++++++++++++ modules/nextcloud/client.ts | 17 +++++++++++---- modules/nextcloud/module.json | 25 +++++++++++++++++++++ modules/nodered/client.ts | 14 ++++++++++-- modules/nodered/module.json | 24 ++++++++++++++++++++ modules/nzbget/client.ts | 16 +++++++++++--- modules/nzbget/module.json | 18 +++++++++++++++ modules/ombi/client.ts | 14 ++++++++++-- modules/ombi/module.json | 18 +++++++++++++++ modules/photos/client.ts | 14 ++++++++++-- modules/photos/module.json | 19 ++++++++++++++++ modules/portainer/client.ts | 14 ++++++++++-- modules/portainer/module.json | 35 +++++++++++++++++++++++++++++- modules/qbittorrent/client.ts | 16 +++++++++++--- modules/qbittorrent/module.json | 18 +++++++++++++++ modules/searxng/client.ts | 12 +++++++++- modules/searxng/module.json | 33 +++++++++++++++++++++++++++- modules/tautulli/client.ts | 14 ++++++++++-- modules/tautulli/module.json | 18 +++++++++++++++ modules/verdaccio/client.ts | 14 ++++++++++-- modules/verdaccio/module.json | 16 ++++++++++++++ 38 files changed, 668 insertions(+), 51 deletions(-) diff --git a/modules/bazarr/client.ts b/modules/bazarr/client.ts index b84f6c5..9d6acce 100644 --- a/modules/bazarr/client.ts +++ b/modules/bazarr/client.ts @@ -3,6 +3,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface WantedSubtitle { kind: "episode" | "movie"; title: string; // series + episode, or movie title @@ -34,6 +36,13 @@ export interface HistoryEntry { description?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class BazarrClient { 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 * there is nothing to talk to, so this throws rather than run half-configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient { - const url = env.MESH_BAZARR_URL; - const apiKey = env.MESH_BAZARR_API_KEY; + const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE); + 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 (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY"); return new BazarrClient(url, apiKey); diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json index b90dd88..d5018a2 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -80,6 +80,24 @@ "/services/media/anime:/anime", "/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" + } } ] } diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index c17b84a..5fe7182 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -3,6 +3,8 @@ // gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea // does. +import { readFileSync } from "node:fs"; + /** A repository, trimmed to what the mesh cares about. */ export interface GiteaRepo { full_name: string; @@ -42,6 +44,13 @@ export interface GiteaLabel { name: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class GiteaClient { readonly baseUrl: string; 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. */ 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 token = env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; + const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE); + 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"); return new GiteaClient(url, token); } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index b6af10e..b8ec92d 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -88,6 +88,30 @@ "volumes": [ "/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" + } } ] } diff --git a/modules/grafana/client.ts b/modules/grafana/client.ts index 41395ae..00916c3 100644 --- a/modules/grafana/client.ts +++ b/modules/grafana/client.ts @@ -2,6 +2,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface GrafanaHealth { database: string; version: string; @@ -35,6 +37,13 @@ export interface GrafanaAlert { activeAt?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class GrafanaClient { readonly baseUrl: 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. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient { - const url = env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`; - const token = env.MESH_GRAFANA_TOKEN; + const cfg = meshConfig(env.MESH_GRAFANA_CONFIG_FILE); + 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}`); - const password = env.MESH_GRAFANA_PASSWORD; + const password = cfg.password ?? env.MESH_GRAFANA_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")}`); } throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD"); diff --git a/modules/grafana/module.json b/modules/grafana/module.json index 3835b69..5d9626e 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -60,6 +60,30 @@ "volumes": [ "/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" + } } ] } diff --git a/modules/home-assistant/client.ts b/modules/home-assistant/client.ts index d7aefa9..d3900ba 100644 --- a/modules/home-assistant/client.ts +++ b/modules/home-assistant/client.ts @@ -2,6 +2,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface HAEntityState { entity_id: string; state: string; @@ -18,6 +20,13 @@ export interface HAConfig { state?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class HomeAssistantClient { readonly baseUrl: string; @@ -34,8 +43,9 @@ export class HomeAssistantClient { * every API call is Bearer-authenticated and there is nowhere to discover it from. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient { - const url = env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`; - const token = env.MESH_HOMEASSISTANT_TOKEN; + const cfg = meshConfig(env.MESH_HOMEASSISTANT_CONFIG_FILE); + 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"); return new HomeAssistantClient(url, token); } diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index 3e63d64..d6cee10 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -44,6 +44,24 @@ "volumes": [ "/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" + } } ] } diff --git a/modules/icecast/client.ts b/modules/icecast/client.ts index d074b55..7035c3d 100644 --- a/modules/icecast/client.ts +++ b/modules/icecast/client.ts @@ -3,6 +3,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface IcecastMount { /** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */ mount: string; @@ -33,6 +35,13 @@ interface RawSource { server_type?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class IcecastClient { readonly baseUrl: string; private readonly authHeader?: string; @@ -46,8 +55,9 @@ export class IcecastClient { } static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient { - const url = env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`; - return new IcecastClient(url, env.MESH_ICECAST_ADMIN_USER, env.MESH_ICECAST_ADMIN_PASSWORD); + const cfg = meshConfig(env.MESH_ICECAST_CONFIG_FILE); + 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 { diff --git a/modules/icecast/module.json b/modules/icecast/module.json index b60778d..5a703eb 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -53,6 +53,30 @@ "ports": [ "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" + } } ] } diff --git a/modules/influxdb/client.ts b/modules/influxdb/client.ts index 12a81eb..ab0fc73 100644 --- a/modules/influxdb/client.ts +++ b/modules/influxdb/client.ts @@ -1,6 +1,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface InfluxHealth { name?: string; status?: string; @@ -15,6 +17,13 @@ export interface InfluxBucket { retentionSeconds?: number; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class InfluxDBClient { readonly baseUrl: string; @@ -32,10 +41,11 @@ export class InfluxDBClient { * is token-authenticated. The org scopes bucket listing and queries. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient { - const url = env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`; - const token = env.MESH_INFLUXDB_TOKEN; + const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE); + const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`; + const token = cfg.token ?? env.MESH_INFLUXDB_TOKEN; if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN"); - const org = env.MESH_INFLUXDB_ORG ?? "mesh"; + const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh"; return new InfluxDBClient(url, token, org); } diff --git a/modules/influxdb/module.json b/modules/influxdb/module.json index ad64e0c..bf95774 100644 --- a/modules/influxdb/module.json +++ b/modules/influxdb/module.json @@ -6,7 +6,8 @@ ], "own-secrets": { "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": [ { @@ -17,6 +18,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/influxdb", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -59,6 +66,24 @@ "/services/influxdb/data:/var/lib/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" + } } ] } diff --git a/modules/jackett/client.ts b/modules/jackett/client.ts index bf52da9..1e76be3 100644 --- a/modules/jackett/client.ts +++ b/modules/jackett/client.ts @@ -2,6 +2,8 @@ // an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client // talks its /api/v2.0 REST API, and only jackett's tools import it. +import { readFileSync } from "node:fs"; + export interface JackettIndexer { id: string; name: string; @@ -22,6 +24,13 @@ export interface JackettResult { link?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class JackettClient { readonly baseUrl: string; @@ -38,8 +47,9 @@ export class JackettClient { * module contributes no tools rather than failing half-configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient { - const url = env.MESH_JACKETT_URL; - const apiKey = env.MESH_JACKETT_API_KEY; + const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_JACKETT_URL; + const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY; 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"); return new JackettClient(url, apiKey); diff --git a/modules/jackett/module.json b/modules/jackett/module.json index f1e4a74..6cbb4a0 100644 --- a/modules/jackett/module.json +++ b/modules/jackett/module.json @@ -13,6 +13,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/jackett", + "mode": "0700" + }, { "id": "config", "type": "directory", @@ -36,6 +42,27 @@ "volumes": [ "/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" + } } diff --git a/modules/keycloak/client.ts b/modules/keycloak/client.ts index 431d331..830418c 100644 --- a/modules/keycloak/client.ts +++ b/modules/keycloak/client.ts @@ -3,6 +3,15 @@ // it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and // 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 { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class KeycloakClient { readonly baseUrl: 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. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient { - const url = env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`; - const adminUser = env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin"; - const adminPass = env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD; + const cfg = meshConfig(env.MESH_KEYCLOAK_CONFIG_FILE); + const url = cfg.url ?? env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`; + 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"); - const realm = env.MESH_KEYCLOAK_REALM ?? "master"; + const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master"; return new KeycloakClient(url, adminUser, adminPass, realm); } diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 0bc6c51..7314428 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -91,6 +91,30 @@ "ports": [ "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" + } } ] } diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index fef227f..32053cb 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -9,6 +9,7 @@ // falls back to `doveadm` inside the imap container, the operation the HTTP surface cannot serve. import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; import { promisify } from "node:util"; 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. 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 { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class MailuClient { 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. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient { - const url = env.MESH_MAILU_URL; - const apiKey = env.MESH_MAILU_API_KEY; + const cfg = meshConfig(env.MESH_MAILU_CONFIG_FILE); + const url = cfg.url ?? env.MESH_MAILU_URL; + const apiKey = cfg.apiKey ?? env.MESH_MAILU_API_KEY; if (!url || !apiKey) { 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); } diff --git a/modules/mailu/module.json b/modules/mailu/module.json index bf64c8a..c8e902e 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -282,6 +282,31 @@ "/services/mailu/data/certs:/certs", "/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" + } } ] } diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts index 5418924..b860680 100644 --- a/modules/nextcloud/client.ts +++ b/modules/nextcloud/client.ts @@ -9,6 +9,7 @@ // because occ has no version-stable "list every share" across the releases we run. import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; export interface NextcloudUser { uid: string; @@ -24,6 +25,13 @@ export interface NextcloudShare { owner: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class NextcloudClient { constructor( private readonly container: string, @@ -40,10 +48,11 @@ export class NextcloudClient { * throws without it, and the module then contributes nothing rather than failing. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient { - const container = env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; - const ocsUrl = env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; - const adminUser = env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; - const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD; + const cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE); + const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; + const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; + 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"); return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword); } diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index a84d447..7653607 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -81,6 +81,31 @@ "volumes": [ "/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" + } } ] } diff --git a/modules/nodered/client.ts b/modules/nodered/client.ts index 7b92f59..d760ca6 100644 --- a/modules/nodered/client.ts +++ b/modules/nodered/client.ts @@ -5,6 +5,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface NodeRedFlow { /** The tab (flow) node id. */ id: string; @@ -18,6 +20,13 @@ export interface NodeRedNodeModule { types: string[]; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class NodeRedClient { readonly baseUrl: string; @@ -35,9 +44,10 @@ export class NodeRedClient { * a default install needs none. */ 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"); - return new NodeRedClient(url, env.MESH_NODERED_TOKEN); + return new NodeRedClient(url, cfg.token ?? env.MESH_NODERED_TOKEN); } private headers(extra: Record = {}): Record { diff --git a/modules/nodered/module.json b/modules/nodered/module.json index 9a48039..b9d84b3 100644 --- a/modules/nodered/module.json +++ b/modules/nodered/module.json @@ -46,6 +46,30 @@ "volumes": [ "/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" + } } ] } diff --git a/modules/nzbget/client.ts b/modules/nzbget/client.ts index 668a586..e217387 100644 --- a/modules/nzbget/client.ts +++ b/modules/nzbget/client.ts @@ -3,6 +3,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface NzbgetStatus { /** Bytes/sec — NZBGet reports it split across two 32-bit halves, rejoined here. */ speedBytesPerSec: number; @@ -39,6 +41,13 @@ export interface NzbgetHistoryItem { success: boolean; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class NzbgetClient { readonly rpcUrl: 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. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient { - const url = env.MESH_NZBGET_URL; - const password = env.MESH_NZBGET_PASSWORD; + const cfg = meshConfig(env.MESH_NZBGET_CONFIG_FILE); + const url = cfg.url ?? env.MESH_NZBGET_URL; + const password = cfg.password ?? env.MESH_NZBGET_PASSWORD; if (!url || !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); } diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json index 184a1b3..239f8a8 100644 --- a/modules/nzbget/module.json +++ b/modules/nzbget/module.json @@ -58,6 +58,24 @@ "/services/nzbget/config:/config", "/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" + } } ] } diff --git a/modules/ombi/client.ts b/modules/ombi/client.ts index 870e37e..8016ccb 100644 --- a/modules/ombi/client.ts +++ b/modules/ombi/client.ts @@ -2,6 +2,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface OmbiRequest { kind: "movie" | "tv"; id: number; @@ -20,6 +22,13 @@ export interface RequestCounts { available: number; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class OmbiClient { 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 * is nothing to talk to, so this throws rather than run half-configured. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): OmbiClient { - const url = env.MESH_OMBI_URL; - const apiKey = env.MESH_OMBI_API_KEY; + const cfg = meshConfig(env.MESH_OMBI_CONFIG_FILE); + 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 (!apiKey) throw new Error("no Ombi API key — set MESH_OMBI_API_KEY"); return new OmbiClient(url, apiKey); diff --git a/modules/ombi/module.json b/modules/ombi/module.json index 8a7a5cb..6e29b26 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -49,6 +49,24 @@ "volumes": [ "/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" + } } ] } diff --git a/modules/photos/client.ts b/modules/photos/client.ts index d0a8b66..2305b1f 100644 --- a/modules/photos/client.ts +++ b/modules/photos/client.ts @@ -3,6 +3,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface PhotosServerInfo { version: string; photos?: number; @@ -24,6 +26,13 @@ export interface PhotosAsset { createdAt?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class PhotosClient { 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 * unauthenticated endpoint. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): PhotosClient { - const url = env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`; - const key = env.MESH_PHOTOS_API_KEY; + const cfg = meshConfig(env.MESH_PHOTOS_CONFIG_FILE); + 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"); return new PhotosClient(url, key); } diff --git a/modules/photos/module.json b/modules/photos/module.json index e24f528..555a044 100644 --- a/modules/photos/module.json +++ b/modules/photos/module.json @@ -47,6 +47,25 @@ "/etc/photos/store.json:/etc/photos/store.json: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" ] } diff --git a/modules/portainer/client.ts b/modules/portainer/client.ts index 79eaa29..a7ac3d1 100644 --- a/modules/portainer/client.ts +++ b/modules/portainer/client.ts @@ -3,6 +3,8 @@ // which the host owns and emits — so this module reads Portainer's own resources (endpoints, // stacks, containers) and exposes them, and stops there. +import { readFileSync } from "node:fs"; + export interface PortainerEndpoint { id: number; name: string; @@ -27,6 +29,13 @@ export interface PortainerContainer { status: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class PortainerClient { readonly baseUrl: string; @@ -44,8 +53,9 @@ export class PortainerClient { * exposes nothing rather than calling Portainer unauthenticated. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient { - const url = env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`; - const token = env.MESH_PORTAINER_TOKEN; + const cfg = meshConfig(env.MESH_PORTAINER_CONFIG_FILE); + 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"); return new PortainerClient(url, token); } diff --git a/modules/portainer/module.json b/modules/portainer/module.json index 0f0da7a..db02e54 100644 --- a/modules/portainer/module.json +++ b/modules/portainer/module.json @@ -13,6 +13,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/portainer", + "mode": "0700" + }, { "id": "data", "type": "directory", @@ -31,6 +37,33 @@ "/services/portainer/data:/data", "/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" + } } diff --git a/modules/qbittorrent/client.ts b/modules/qbittorrent/client.ts index 24cc65f..b59c171 100644 --- a/modules/qbittorrent/client.ts +++ b/modules/qbittorrent/client.ts @@ -7,6 +7,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface QbTransferInfo { dlSpeedBytesPerSec: number; upSpeedBytesPerSec: number; @@ -30,6 +32,13 @@ export interface QbTorrent { savePath: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class QbittorrentClient { readonly baseUrl: string; 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". */ static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient { - const url = env.MESH_QBITTORRENT_URL; - const password = env.MESH_QBITTORRENT_PASSWORD; + const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE); + const url = cfg.url ?? env.MESH_QBITTORRENT_URL; + const password = cfg.password ?? env.MESH_QBITTORRENT_PASSWORD; if (!url || !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); } diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index 4cf1739..5cf65a8 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -58,6 +58,24 @@ "/services/qbittorrent/config:/config", "/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" + } } ] } diff --git a/modules/searxng/client.ts b/modules/searxng/client.ts index bfeb7a9..8bddb1a 100644 --- a/modules/searxng/client.ts +++ b/modules/searxng/client.ts @@ -3,6 +3,8 @@ // 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. +import { readFileSync } from "node:fs"; + export interface SearxResult { title: string; url: string; @@ -26,6 +28,13 @@ export interface SearxOptions { pageno?: number; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class SearxngClient { 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 * is all it takes — defaulting to the container's own listen port. */ 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); } diff --git a/modules/searxng/module.json b/modules/searxng/module.json index 8262a4b..a19c064 100644 --- a/modules/searxng/module.json +++ b/modules/searxng/module.json @@ -5,7 +5,8 @@ "container-runtime" ], "own-secrets": { - "secret": "/var/lib/searxng-module/secret.secret" + "secret": "/var/lib/searxng-module/secret.secret", + "broker": "/var/lib/mesh/searxng/broker" }, "listens": [ { @@ -16,6 +17,12 @@ } ], "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/searxng", + "mode": "0700" + }, { "id": "state", "type": "directory", @@ -64,6 +71,30 @@ "ports": [ "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" + } } ] } diff --git a/modules/tautulli/client.ts b/modules/tautulli/client.ts index 6c308c6..2096a07 100644 --- a/modules/tautulli/client.ts +++ b/modules/tautulli/client.ts @@ -5,6 +5,8 @@ // { response: { result: "success" | "error", message, data } }. This client unwraps that envelope // and hands back only the data. +import { readFileSync } from "node:fs"; + export interface TautulliSession { user: string; title: string; @@ -32,6 +34,13 @@ export interface TautulliHomeStat { rows: Array>; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class TautulliClient { readonly baseUrl: string; @@ -48,8 +57,9 @@ export class TautulliClient { * Throws when no key is configured — the module then contributes nothing rather than failing. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient { - const url = env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`; - const apiKey = env.MESH_TAUTULLI_APIKEY; + const cfg = meshConfig(env.MESH_TAUTULLI_CONFIG_FILE); + 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"); return new TautulliClient(url, apiKey); } diff --git a/modules/tautulli/module.json b/modules/tautulli/module.json index 27bfc8f..94ad122 100644 --- a/modules/tautulli/module.json +++ b/modules/tautulli/module.json @@ -48,6 +48,24 @@ "volumes": [ "/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" + } } ] } diff --git a/modules/verdaccio/client.ts b/modules/verdaccio/client.ts index 9948fdf..0a9acb0 100644 --- a/modules/verdaccio/client.ts +++ b/modules/verdaccio/client.ts @@ -2,6 +2,8 @@ // ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside // verdaccio does. +import { readFileSync } from "node:fs"; + export interface VerdaccioPackage { name: string; version?: string; @@ -17,6 +19,13 @@ export interface PackageInfo { modified?: string; } +/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */ +function meshConfig(file?: string): Record { + if (!file) return {}; + try { return JSON.parse(readFileSync(file, "utf8")) as Record; } + catch { return {}; } +} + export class VerdaccioClient { readonly baseUrl: string; @@ -34,9 +43,10 @@ export class VerdaccioClient { * port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured. */ 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"); - return new VerdaccioClient(url, env.MESH_VERDACCIO_TOKEN); + return new VerdaccioClient(url, cfg.token ?? env.MESH_VERDACCIO_TOKEN); } private async getJson(path: string): Promise { diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index f9d93c0..1f35dcf 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -58,6 +58,22 @@ "/services/verdaccio/storage:/verdaccio/storage", "/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" + } } ] }