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:
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+12
-2
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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");
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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<IcecastStatus> {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+12
-3
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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<string, string> = {}): Record<string, string> {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+12
-2
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
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<T>(path: string): Promise<T> {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user