Sidecars read the vault's key and restart when it is remade; a stale WebUI attempt bans for a minute

The jackett, sonarr, radarr and lidarr runtimes discovered the key from
the software's config at start, so after a rotation they held the old one.
They now read the vault's file first and restart when it is remade.
qBittorrent's init sets WebUI\BanDuration=60 so a consumer's step still
carrying the previous value no longer locks the others out for an hour.
This commit is contained in:
2026-10-01 13:35:45 +02:00
parent 7c97cb7a02
commit 8143811c84
9 changed files with 59 additions and 17 deletions
+7 -1
View File
@@ -32,6 +32,12 @@ function meshConfig(file?: string): Record<string, string> {
catch { return {}; }
}
/** The vault's copy of the key, where the runtime mounts it (ADR 0158): read fresh, trimmed, or null. */
function keyFile(file?: string): string | null {
if (!file) return null;
try { const v = readFileSync(file, "utf8").trim(); return v.length ? v : null; } catch { return null; }
}
export class JackettClient {
readonly baseUrl: string;
@@ -54,7 +60,7 @@ export class JackettClient {
static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
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
const apiKey = cfg.apiKey ?? keyFile(env.MESH_JACKETT_API_KEY_FILE) ?? env.MESH_JACKETT_API_KEY
?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config");
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 or make the config dir readable");
+6 -3
View File
@@ -108,16 +108,19 @@
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:config}:/var/lib/jackett/config:ro"
"${dir:config}:/var/lib/jackett/config:ro",
"${dir:mesh-state}/api-key:/run/secrets/api-key:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config",
"MESH_JACKETT_API_KEY_FILE": "/run/secrets/api-key"
},
"restart-on": [
"runtime-config"
"runtime-config",
"needs-api-key"
],
"artifact": "runtime"
}
+7 -1
View File
@@ -33,6 +33,12 @@ export interface LidarrContentItem {
monitored: boolean;
}
/** The vault's copy of the key, where the runtime mounts it (ADR 0158): read fresh, trimmed, or null. */
function keyFile(file?: string): string | null {
if (!file) return null;
try { const v = readFileSync(file, "utf8").trim(); return v.length ? v : null; } catch { return null; }
}
export class LidarrClient {
readonly baseUrl: string;
@@ -54,7 +60,7 @@ export class LidarrClient {
static fromEnv(env: NodeJS.ProcessEnv = process.env): LidarrClient {
const url = env.MESH_LIDARR_URL ?? `http://127.0.0.1:${env.MESH_LIDARR_PORT ?? "8686"}`;
const configDir = env.MESH_LIDARR_CONFIG_DIR ?? "/config";
const apiKey = env.MESH_LIDARR_API_KEY ?? LidarrClient.detectApiKey(configDir);
const apiKey = keyFile(env.MESH_LIDARR_API_KEY_FILE) ?? env.MESH_LIDARR_API_KEY ?? LidarrClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Lidarr not configured — set MESH_LIDARR_API_KEY or make the config dir readable");
}
+8 -3
View File
@@ -131,14 +131,19 @@
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:config}:/var/lib/lidarr/config:ro"
"${dir:config}:/var/lib/lidarr/config:ro",
"${dir:mesh-state}/api-key:/run/secrets/api-key:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LIDARR_URL": "http://127.0.0.1:${port:8686}",
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config"
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config",
"MESH_LIDARR_API_KEY_FILE": "/run/secrets/api-key"
},
"artifact": "runtime"
"artifact": "runtime",
"restart-on": [
"needs-api-key"
]
},
{
"id": "downloads-config",
+1 -1
View File
@@ -83,7 +83,7 @@
"type": "file",
"path": "${dir:mesh-init}/10-mesh-webui-login.sh",
"mode": "0755",
"content": "#!/bin/bash\n# Written by the mesh (ADR 0158): this module's one credential is the vault's, shared with every\n# consumer; the file is applied here, before the service starts, on every start.\nset -euo pipefail\nCONF=/config/qBittorrent/qBittorrent.conf\nUSER='${setting:username}'\nPASS=$(cat /run/secrets/password)\nmkdir -p /config/qBittorrent\nHASH=$(python3 - \"$PASS\" <<'PY'\nimport sys, os, base64, hashlib\npw = sys.argv[1].encode(); salt = os.urandom(16)\ndk = hashlib.pbkdf2_hmac(\"sha512\", pw, salt, 100000, dklen=64)\nprint(\"@ByteArray(\" + base64.b64encode(salt).decode() + \":\" + base64.b64encode(dk).decode() + \")\")\nPY\n)\ntouch \"$CONF\"\ngrep -q '^\\[Preferences\\]' \"$CONF\" || printf '\\n[Preferences]\\n' >> \"$CONF\"\nfor kv in \"WebUI\\\\Username=$USER\" \"WebUI\\\\Password_PBKDF2=\\\"$HASH\\\"\"; do\n key=${kv%%=*}\n if grep -q \"^${key//\\\\/\\\\\\\\}=\" \"$CONF\"; then\n awk -v kv=\"$kv\" -v key=\"$key\" 'BEGIN{FS=OFS=\"=\"} $1==key {print kv; next} {print}' \"$CONF\" > \"$CONF.mesh\" && mv \"$CONF.mesh\" \"$CONF\"\n else\n awk -v kv=\"$kv\" '{print} /^\\[Preferences\\]/ && !done {print kv; done=1}' \"$CONF\" > \"$CONF.mesh\" && mv \"$CONF.mesh\" \"$CONF\"\n fi\ndone\necho \"[mesh] qBittorrent WebUI login set for $USER from the vault's credential\"\n"
"content": "#!/bin/bash\n# Written by the mesh (ADR 0158): this module's one credential is the vault's, shared with every\n# consumer; the file is applied here, before the service starts, on every start.\nset -euo pipefail\nCONF=/config/qBittorrent/qBittorrent.conf\nUSER='${setting:username}'\nPASS=$(cat /run/secrets/password)\nmkdir -p /config/qBittorrent\nHASH=$(python3 - \"$PASS\" <<'PY'\nimport sys, os, base64, hashlib\npw = sys.argv[1].encode(); salt = os.urandom(16)\ndk = hashlib.pbkdf2_hmac(\"sha512\", pw, salt, 100000, dklen=64)\nprint(\"@ByteArray(\" + base64.b64encode(salt).decode() + \":\" + base64.b64encode(dk).decode() + \")\")\nPY\n)\ntouch \"$CONF\"\ngrep -q '^\\[Preferences\\]' \"$CONF\" || printf '\\n[Preferences]\\n' >> \"$CONF\"\nfor kv in \"WebUI\\\\Username=$USER\" \"WebUI\\\\Password_PBKDF2=\\\"$HASH\\\"\" \"WebUI\\\\BanDuration=60\"; do\n key=${kv%%=*}\n if grep -q \"^${key//\\\\/\\\\\\\\}=\" \"$CONF\"; then\n awk -v kv=\"$kv\" -v key=\"$key\" 'BEGIN{FS=OFS=\"=\"} $1==key {print kv; next} {print}' \"$CONF\" > \"$CONF.mesh\" && mv \"$CONF.mesh\" \"$CONF\"\n else\n awk -v kv=\"$kv\" '{print} /^\\[Preferences\\]/ && !done {print kv; done=1}' \"$CONF\" > \"$CONF.mesh\" && mv \"$CONF.mesh\" \"$CONF\"\n fi\ndone\necho \"[mesh] qBittorrent WebUI login set for $USER from the vault's credential\"\n"
},
{
"id": "server",
+7 -1
View File
@@ -34,6 +34,12 @@ export interface RadarrContentItem {
monitored: boolean;
}
/** The vault's copy of the key, where the runtime mounts it (ADR 0158): read fresh, trimmed, or null. */
function keyFile(file?: string): string | null {
if (!file) return null;
try { const v = readFileSync(file, "utf8").trim(); return v.length ? v : null; } catch { return null; }
}
export class RadarrClient {
readonly baseUrl: string;
@@ -55,7 +61,7 @@ export class RadarrClient {
static fromEnv(env: NodeJS.ProcessEnv = process.env): RadarrClient {
const url = env.MESH_RADARR_URL ?? `http://127.0.0.1:${env.MESH_RADARR_PORT ?? "7878"}`;
const configDir = env.MESH_RADARR_CONFIG_DIR ?? "/config";
const apiKey = env.MESH_RADARR_API_KEY ?? RadarrClient.detectApiKey(configDir);
const apiKey = keyFile(env.MESH_RADARR_API_KEY_FILE) ?? env.MESH_RADARR_API_KEY ?? RadarrClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Radarr not configured — set MESH_RADARR_API_KEY or make the config dir readable");
}
+8 -3
View File
@@ -125,14 +125,19 @@
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:config}:/var/lib/radarr/config:ro"
"${dir:config}:/var/lib/radarr/config:ro",
"${dir:mesh-state}/api-key:/run/secrets/api-key:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RADARR_URL": "http://127.0.0.1:${port:7878}",
"MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config"
"MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config",
"MESH_RADARR_API_KEY_FILE": "/run/secrets/api-key"
},
"artifact": "runtime"
"artifact": "runtime",
"restart-on": [
"needs-api-key"
]
},
{
"id": "downloads-config",
+7 -1
View File
@@ -34,6 +34,12 @@ export interface SonarrContentItem {
monitored: boolean;
}
/** The vault's copy of the key, where the runtime mounts it (ADR 0158): read fresh, trimmed, or null. */
function keyFile(file?: string): string | null {
if (!file) return null;
try { const v = readFileSync(file, "utf8").trim(); return v.length ? v : null; } catch { return null; }
}
export class SonarrClient {
readonly baseUrl: string;
@@ -55,7 +61,7 @@ export class SonarrClient {
static fromEnv(env: NodeJS.ProcessEnv = process.env): SonarrClient {
const url = env.MESH_SONARR_URL ?? `http://127.0.0.1:${env.MESH_SONARR_PORT ?? "8989"}`;
const configDir = env.MESH_SONARR_CONFIG_DIR ?? "/config";
const apiKey = env.MESH_SONARR_API_KEY ?? SonarrClient.detectApiKey(configDir);
const apiKey = keyFile(env.MESH_SONARR_API_KEY_FILE) ?? env.MESH_SONARR_API_KEY ?? SonarrClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Sonarr not configured — set MESH_SONARR_API_KEY or make the config dir readable");
}
+8 -3
View File
@@ -131,14 +131,19 @@
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:config}:/var/lib/sonarr/config:ro"
"${dir:config}:/var/lib/sonarr/config:ro",
"${dir:mesh-state}/api-key:/run/secrets/api-key:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SONARR_URL": "http://127.0.0.1:${port:8989}",
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config"
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config",
"MESH_SONARR_API_KEY_FILE": "/run/secrets/api-key"
},
"artifact": "runtime"
"artifact": "runtime",
"restart-on": [
"needs-api-key"
]
},
{
"id": "downloads-config",