From 75fb16bbfb24048b68e2c02e5df3128250a06d46 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 8 Sep 2026 18:27:34 +0200 Subject: [PATCH 1/3] fail2ban: require the `firewall` capability, not the non-existent `intrusion-prevention` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-mesh dry-run found fail2ban unassignable on every node: it declared `capabilities: ["intrusion-prevention"]`, which mesh-host has no detector for (its detectors are container-runtime, package-manager, service-manager, firewall, overlay, graphical-session, seat, privileged). intrusion-prevention is what fail2ban PROVIDES, not a host capability it needs. It bans via iptables/ufw, so it needs `firewall` — the same capability the firewall module declares. The `the-intrusion-prevention` claim (node-exclusive) is unchanged. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/fail2ban/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/fail2ban/module.json b/modules/fail2ban/module.json index efbfe17..8b94092 100644 --- a/modules/fail2ban/module.json +++ b/modules/fail2ban/module.json @@ -2,7 +2,7 @@ "module": "fail2ban", "version": "1", "capabilities": [ - "intrusion-prevention" + "firewall" ], "claims": [ { From d289e5a9287fb5fd63f94e20ac7398f407ba79cb Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 8 Sep 2026 18:40:23 +0200 Subject: [PATCH 2/3] modules: wire tool-runtime app credentials as own-secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six modules that run a mesh- tool-runtime sidecar read an app credential from an env var the manifest never provided, so the sidecar crash-looped in the whole-mesh dry-run (e.g. "no Plex token — set MESH_PLEX_TOKEN"). These are operator-set app secrets, so deliver them the same way cloudflare-dns delivers its API token: an own-secret file mounted read-only, with a MESH__*_FILE env pointing at the mount, and the runtime code preferring that file (falling back to the existing env so nothing regresses). - plex: own-secret token -> /run/secrets/token, MESH_PLEX_TOKEN_FILE - bazarr: own-secret api-key -> /run/secrets/api-key, MESH_BAZARR_API_KEY_FILE - ombi: own-secret api-key -> /run/secrets/api-key, MESH_OMBI_API_KEY_FILE - home-assistant: own-secret token -> /run/secrets/token, MESH_HOMEASSISTANT_TOKEN_FILE - nzbget: own-secret password -> /run/secrets/password, MESH_NZBGET_PASSWORD_FILE (URL stays plain env) - qbittorrent: own-secret password -> /run/secrets/password, MESH_QBITTORRENT_PASSWORD_FILE (URL stays plain env) The operator now completes each with `secret accept --from `. tsc passes for all six. --- modules/bazarr/client.ts | 10 +++++++++- modules/bazarr/module.json | 5 ++++- modules/home-assistant/client.ts | 10 +++++++++- modules/home-assistant/module.json | 5 ++++- modules/nzbget/client.ts | 10 +++++++++- modules/nzbget/module.json | 5 ++++- modules/ombi/client.ts | 10 +++++++++- modules/ombi/module.json | 5 ++++- modules/plex/client.ts | 15 ++++++++++++++- modules/plex/module.json | 5 ++++- modules/qbittorrent/client.ts | 10 +++++++++- modules/qbittorrent/module.json | 5 ++++- 12 files changed, 83 insertions(+), 12 deletions(-) diff --git a/modules/bazarr/client.ts b/modules/bazarr/client.ts index c312938..cead9ba 100644 --- a/modules/bazarr/client.ts +++ b/modules/bazarr/client.ts @@ -43,6 +43,14 @@ function meshConfig(file?: string): Record { catch { return {}; } } +/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`); + * absent or unreadable yields undefined so callers fall back rather than crash. */ +function readSecret(file?: string): string | undefined { + if (!file) return undefined; + try { return readFileSync(file, "utf8").trim(); } + catch { return undefined; } +} + export class BazarrClient { readonly baseUrl: string; @@ -58,7 +66,7 @@ export class BazarrClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient { 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; + const apiKey = cfg.apiKey ?? readSecret(env.MESH_BAZARR_API_KEY_FILE) ?? 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 9a4f684..6267f9b 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -8,7 +8,8 @@ "module.bazarr.subtitle.downloaded" ], "own-secrets": { - "broker": "/var/lib/mesh/bazarr/broker" + "broker": "/var/lib/mesh/bazarr/broker", + "api-key": "/var/lib/mesh/bazarr/api-key" }, "listens": [ { @@ -87,12 +88,14 @@ "network": "host", "volumes": [ "/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro", + "/var/lib/mesh/bazarr/api-key:/run/secrets/api-key: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_API_KEY_FILE": "/run/secrets/api-key", "MESH_BAZARR_CONFIG_FILE": "/run/config/config.json", "MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config" }, diff --git a/modules/home-assistant/client.ts b/modules/home-assistant/client.ts index 525ec7e..62e8323 100644 --- a/modules/home-assistant/client.ts +++ b/modules/home-assistant/client.ts @@ -27,6 +27,14 @@ function meshConfig(file?: string): Record { catch { return {}; } } +/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`); + * absent or unreadable yields undefined so callers fall back rather than crash. */ +function readSecret(file?: string): string | undefined { + if (!file) return undefined; + try { return readFileSync(file, "utf8").trim(); } + catch { return undefined; } +} + export class HomeAssistantClient { readonly baseUrl: string; @@ -45,7 +53,7 @@ export class HomeAssistantClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient { 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; + const token = cfg.token ?? readSecret(env.MESH_HOMEASSISTANT_TOKEN_FILE) ?? 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 2f01f45..1b8fcbc 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -8,7 +8,8 @@ "module.home-assistant.state.changed" ], "own-secrets": { - "broker": "/var/lib/mesh/home-assistant/broker" + "broker": "/var/lib/mesh/home-assistant/broker", + "token": "/var/lib/mesh/home-assistant/token" }, "listens": [ { @@ -61,12 +62,14 @@ "network": "host", "volumes": [ "/var/lib/mesh/home-assistant/broker:/run/secrets/broker:ro", + "/var/lib/mesh/home-assistant/token:/run/secrets/token: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_TOKEN_FILE": "/run/secrets/token", "MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json", "MESH_HOMEASSISTANT_CONFIG_DIR": "/var/lib/home-assistant/config" }, diff --git a/modules/nzbget/client.ts b/modules/nzbget/client.ts index cd5f0c3..f4d82f4 100644 --- a/modules/nzbget/client.ts +++ b/modules/nzbget/client.ts @@ -48,6 +48,14 @@ function meshConfig(file?: string): Record { catch { return {}; } } +/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`); + * absent or unreadable yields undefined so callers fall back rather than crash. */ +function readSecret(file?: string): string | undefined { + if (!file) return undefined; + try { return readFileSync(file, "utf8").trim(); } + catch { return undefined; } +} + export class NzbgetClient { readonly rpcUrl: string; private readonly auth: string; @@ -66,7 +74,7 @@ export class NzbgetClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient { const cfg = meshConfig(env.MESH_NZBGET_CONFIG_FILE); const url = cfg.url ?? env.MESH_NZBGET_URL; - const password = cfg.password ?? env.MESH_NZBGET_PASSWORD; + const password = cfg.password ?? readSecret(env.MESH_NZBGET_PASSWORD_FILE) ?? env.MESH_NZBGET_PASSWORD; if (!url || !password) { throw new Error("NZBGet not configured — set MESH_NZBGET_URL and MESH_NZBGET_PASSWORD"); } diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json index e3dee8e..42b9cdf 100644 --- a/modules/nzbget/module.json +++ b/modules/nzbget/module.json @@ -10,7 +10,8 @@ ], "consumes": [], "own-secrets": { - "broker": "/var/lib/mesh/nzbget/broker" + "broker": "/var/lib/mesh/nzbget/broker", + "password": "/var/lib/mesh/nzbget/password" }, "listens": [ { @@ -74,12 +75,14 @@ "network": "host", "volumes": [ "/var/lib/mesh/nzbget/broker:/run/secrets/broker:ro", + "/var/lib/mesh/nzbget/password:/run/secrets/password: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_PASSWORD_FILE": "/run/secrets/password", "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 8181140..6bfd82d 100644 --- a/modules/ombi/client.ts +++ b/modules/ombi/client.ts @@ -29,6 +29,14 @@ function meshConfig(file?: string): Record { catch { return {}; } } +/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`); + * absent or unreadable yields undefined so callers fall back rather than crash. */ +function readSecret(file?: string): string | undefined { + if (!file) return undefined; + try { return readFileSync(file, "utf8").trim(); } + catch { return undefined; } +} + export class OmbiClient { readonly baseUrl: string; @@ -44,7 +52,7 @@ export class OmbiClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): OmbiClient { 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; + const apiKey = cfg.apiKey ?? readSecret(env.MESH_OMBI_API_KEY_FILE) ?? 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 f16bea2..727355c 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -9,7 +9,8 @@ "module.ombi.request.approved" ], "own-secrets": { - "broker": "/var/lib/mesh/ombi/broker" + "broker": "/var/lib/mesh/ombi/broker", + "api-key": "/var/lib/mesh/ombi/api-key" }, "listens": [ { @@ -66,12 +67,14 @@ "network": "host", "volumes": [ "/var/lib/mesh/ombi/broker:/run/secrets/broker:ro", + "/var/lib/mesh/ombi/api-key:/run/secrets/api-key: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_API_KEY_FILE": "/run/secrets/api-key", "MESH_OMBI_CONFIG_FILE": "/run/config/config.json", "MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config" }, diff --git a/modules/plex/client.ts b/modules/plex/client.ts index 8ff03bb..5aa7e6f 100644 --- a/modules/plex/client.ts +++ b/modules/plex/client.ts @@ -5,6 +5,17 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +/** Read a secret the mesh mounted at a file path (an own-secret); absent or unreadable yields + * undefined, so callers can fall back rather than crash. */ +function readSecret(path: string | undefined): string | undefined { + if (!path) return undefined; + try { + return readFileSync(path, "utf8").trim(); + } catch { + return undefined; + } +} + export interface PlexLibrary { key: string; title: string; @@ -47,7 +58,9 @@ export class PlexClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): PlexClient { const url = env.MESH_PLEX_URL ?? `http://127.0.0.1:${env.PLEX_PORT ?? "32400"}`; const dataDir = env.MESH_PLEX_DATA_DIR ?? "/var/lib/plex"; - const token = env.MESH_PLEX_TOKEN ?? PlexClient.detectToken(dataDir); + // The operator-provided token is an own-secret the mesh mounts at MESH_PLEX_TOKEN_FILE (delivered + // by `secret accept`); prefer it, fall back to a bare env var, then to discovery from the data dir. + const token = readSecret(env.MESH_PLEX_TOKEN_FILE) ?? env.MESH_PLEX_TOKEN ?? PlexClient.detectToken(dataDir); if (!token) throw new Error("no Plex token — set MESH_PLEX_TOKEN or make the data dir readable"); return new PlexClient(url, token); } diff --git a/modules/plex/module.json b/modules/plex/module.json index 665252f..c184c18 100644 --- a/modules/plex/module.json +++ b/modules/plex/module.json @@ -13,7 +13,8 @@ "module.*.download.completed" ], "own-secrets": { - "broker": "/var/lib/mesh/plex/broker" + "broker": "/var/lib/mesh/plex/broker", + "token": "/var/lib/mesh/plex/token" }, "listens": [ { @@ -95,11 +96,13 @@ "network": "host", "volumes": [ "/var/lib/mesh/plex/broker:/run/secrets/broker:ro", + "/var/lib/mesh/plex/token:/run/secrets/token:ro", "/services/plex/config:/var/lib/plex/config:ro" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_PLEX_URL": "http://127.0.0.1:32400", + "MESH_PLEX_TOKEN_FILE": "/run/secrets/token", "MESH_PLEX_DATA_DIR": "/var/lib/plex" } } diff --git a/modules/qbittorrent/client.ts b/modules/qbittorrent/client.ts index 2bf9964..a5c9e80 100644 --- a/modules/qbittorrent/client.ts +++ b/modules/qbittorrent/client.ts @@ -39,6 +39,14 @@ function meshConfig(file?: string): Record { catch { return {}; } } +/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`); + * absent or unreadable yields undefined so callers fall back rather than crash. */ +function readSecret(file?: string): string | undefined { + if (!file) return undefined; + try { return readFileSync(file, "utf8").trim(); } + catch { return undefined; } +} + export class QbittorrentClient { readonly baseUrl: string; private sid: string | null = null; @@ -60,7 +68,7 @@ export class QbittorrentClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient { const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE); const url = cfg.url ?? env.MESH_QBITTORRENT_URL; - const password = cfg.password ?? env.MESH_QBITTORRENT_PASSWORD; + const password = cfg.password ?? readSecret(env.MESH_QBITTORRENT_PASSWORD_FILE) ?? env.MESH_QBITTORRENT_PASSWORD; if (!url || !password) { throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD"); } diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index 56b0858..b13396c 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -10,7 +10,8 @@ ], "consumes": [], "own-secrets": { - "broker": "/var/lib/mesh/qbittorrent/broker" + "broker": "/var/lib/mesh/qbittorrent/broker", + "password": "/var/lib/mesh/qbittorrent/password" }, "listens": [ { @@ -74,12 +75,14 @@ "network": "host", "volumes": [ "/var/lib/mesh/qbittorrent/broker:/run/secrets/broker:ro", + "/var/lib/mesh/qbittorrent/password:/run/secrets/password: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_PASSWORD_FILE": "/run/secrets/password", "MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json", "MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config" }, From 5973d41966955db87e9c1f4843c32b8a2b9b1adf Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 8 Sep 2026 18:43:48 +0200 Subject: [PATCH 3/3] umami: read the admin password from its mounted secret file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-mesh dry-run found umami's runtime crash-looping "admin password is not set": its `admin` own-secret is mounted at /run/secrets/admin, but the client read the bare env UMAMI_ADMIN_PASSWORD, which nothing sets. Same shape as the six tool-runtime credential fixes — read the mounted file first (MESH_UMAMI_ADMIN_PASSWORD_FILE), falling back to the env. (photos and mailu remain deeper conversion jobs — a stub app image and a full Mailu config env — not credential-wiring, tracked separately.) Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/umami/client.ts | 14 +++++++++++++- modules/umami/module.json | 3 ++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/modules/umami/client.ts b/modules/umami/client.ts index 4ef410c..17fea1e 100644 --- a/modules/umami/client.ts +++ b/modules/umami/client.ts @@ -2,6 +2,18 @@ // changes when umami's API does (novox/hq ADR 0039). Both this module's tools and its provisioner // import it; nothing outside umami does. +import { readFileSync } from "node:fs"; + +/** Read a secret from the file the mesh mounted it at, if the pointing env is set. */ +function readSecret(path: string | undefined): string | undefined { + if (!path) return undefined; + try { + return readFileSync(path, "utf8").trim() || undefined; + } catch { + return undefined; + } +} + export interface Website { id: string; name: string; @@ -23,7 +35,7 @@ export class UmamiClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): UmamiClient { const url = env.MESH_PROVISION_UMAMI_URL ?? env.UMAMI_URL; const username = env.UMAMI_USERNAME ?? "admin"; - const password = env.UMAMI_ADMIN_PASSWORD; + const password = readSecret(env.MESH_UMAMI_ADMIN_PASSWORD_FILE) ?? env.UMAMI_ADMIN_PASSWORD; if (!url || !password) { throw new Error("UMAMI url or admin password is not set — umami's own code cannot reach it"); } diff --git a/modules/umami/module.json b/modules/umami/module.json index e18e025..a9bd185 100644 --- a/modules/umami/module.json +++ b/modules/umami/module.json @@ -110,7 +110,8 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_RECEIVES": "/var/lib/umami/grants/mesh.json" + "MESH_RECEIVES": "/var/lib/umami/grants/mesh.json", + "MESH_UMAMI_ADMIN_PASSWORD_FILE": "/run/secrets/admin" }, "env-file": [ "/var/lib/umami/provisioner.env"