From 75eee9d4a0e3658d470715a88241c864ec65302e Mon Sep 17 00:00:00 2001 From: jochens Date: Tue, 29 Sep 2026 23:41:21 +0200 Subject: [PATCH 1/2] jackett: its config dir is placed, and its tools find their own key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest named /services/jackett/config and /var/lib/mesh/jackett/config.json — host paths ADR 0112 takes out of definitions. The config dir is now a pathless directory (${dir:config}) and the runtime's config and route binding live in a placed state dir, as searxng does. The image is pinned to v0.24.2627-ls34, the digest ace runs today; the old pin (v0.24.2517-ls16) was older than the running version. The runtime reached jackett at a fixed 127.0.0.1:9117; it now uses ${port:9117}, the machine port the mesh actually assigned. The tools never loaded: the client needed an API key nobody set. Like sonarr/radarr read config.xml, it now reads APIKey from Jackett's own ServerConfig.json (the config dir is already mounted read-only), so no secret goes into an assignment. jackett_indexers called /api/v2.0/indexers, which is the web UI's endpoint and answers an API key with a redirect; it now reads the Torznab t=indexers feed, and treats Torznab's 200-with- as a failure. Verified: catalogue key tests (MESH_CATALOGUE set, not skipped); a throwaway container of the pinned image on a fresh 0700 1000:1000 config dir serves its UI; the client discovers the key from the generated ServerConfig.json, lists 617 indexers through the Torznab feed, searches via /results, and a wrong key is refused; client.ts typechecks under --strict. --- modules/jackett/client.ts | 69 ++++++++++++++++++++++++++-------- modules/jackett/module.json | 23 +++++++----- modules/jackett/tools/index.ts | 2 +- 3 files changed, 68 insertions(+), 26 deletions(-) diff --git a/modules/jackett/client.ts b/modules/jackett/client.ts index 61f5d92..a4369ad 100644 --- a/modules/jackett/client.ts +++ b/modules/jackett/client.ts @@ -2,7 +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"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; export interface JackettIndexer { id: string; @@ -43,18 +44,36 @@ export class JackettClient { /** * Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and - * the key must be present — without them there is nothing to talk to, so this throws and the - * module contributes no tools rather than failing half-configured. + * the key must be present. The key is read from the settings-merged config or MESH_JACKETT_API_KEY, + * or, failing those, discovered from Jackett's own ServerConfig.json under MESH_JACKETT_CONFIG_DIR + * — the file Jackett writes it to, as sonarr/radarr read theirs from config.xml — so a running + * server needs no key configured by hand and no secret has to be put in an assignment. Without a + * URL or key there is nothing to talk to, so this throws and the module contributes no tools + * rather than failing half-configured. */ 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 ?? 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"); + if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY or make the config dir readable"); return new JackettClient(url, apiKey); } + /** Discover the API key from Jackett's ServerConfig.json (the linuxserver image keeps it at + * /Jackett/ServerConfig.json), falling back to null. */ + static detectApiKey(configDir: string): string | null { + for (const file of [join(configDir, "Jackett", "ServerConfig.json"), join(configDir, "ServerConfig.json")]) { + if (!existsSync(file)) continue; + try { + const key = (JSON.parse(readFileSync(file, "utf8")) as { APIKey?: unknown }).APIKey; + if (typeof key === "string" && key) return key; + } catch { /* unreadable or mid-write: try the next, then give up */ } + } + return null; + } + private async get(path: string, params: Record = {}): Promise { const url = new URL(`${this.baseUrl}${path}`); url.searchParams.set("apikey", this.apiKey); @@ -64,18 +83,36 @@ export class JackettClient { return res.json(); } - /** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */ + /** + * The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. + * Read from the Torznab `t=indexers` feed, not /api/v2.0/indexers: that one is the web UI's and + * wants a login cookie (it answers an API-key request with a redirect), while the Torznab feed is + * what the key is for. The feed carries no last error, so `lastError` stays unset. + */ async getIndexers(configuredOnly = true): Promise { - const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" }); - const list = Array.isArray(raw) ? raw : []; - return list.map((i: any) => ({ - id: i.id, - name: i.name, - type: i.type, - configured: i.configured ?? false, - siteLink: i.site_link, - lastError: i.last_error || undefined, - })); + const url = new URL(`${this.baseUrl}/api/v2.0/indexers/all/results/torznab/api`); + url.searchParams.set("apikey", this.apiKey); + url.searchParams.set("t", "indexers"); + url.searchParams.set("configured", configuredOnly ? "true" : "false"); + const res = await fetch(url.toString(), { headers: { Accept: "application/xml" } }); + if (!res.ok) throw new Error(`Jackett API torznab t=indexers: ${res.status} ${await res.text()}`); + const xml = await res.text(); + // Torznab reports failures (a wrong key among them) as 200 with an body. + const err = xml.match(/ + block.match(new RegExp(`<${tag}>([^<]*)`))?.[1]; + const out: JackettIndexer[] = []; + for (const m of xml.matchAll(/([\s\S]*?)<\/indexer>/g)) { + out.push({ + id: m[1], + name: text(m[3], "title") ?? m[1], + type: text(m[3], "type") ?? "unknown", + configured: m[2] === "true", + siteLink: text(m[3], "link"), + }); + } + return out; } /** diff --git a/modules/jackett/module.json b/modules/jackett/module.json index 0b61232..f969d2c 100644 --- a/modules/jackett/module.json +++ b/modules/jackett/module.json @@ -10,7 +10,7 @@ "port": 9117, "protocol": "tcp", "from": "mesh", - "why": "the indexer proxy" + "why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through" } ], "resources": [ @@ -20,10 +20,15 @@ "path": "/var/lib/mesh/jackett", "mode": "0700" }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, { "id": "config", "type": "directory", - "path": "/services/jackett/config", "mode": "0700", "owner": "1000:1000" }, @@ -31,7 +36,7 @@ "id": "server", "type": "container", "name": "jackett", - "image": "lscr.io/linuxserver/jackett@sha256:fd72d42b731ebf750b5de9711127251cf3b3f609419c32083ea8b3b3ee840b77", + "image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583", "env": { "PUID": "1000", "PGID": "1000", @@ -41,13 +46,13 @@ "9117" ], "volumes": [ - "/services/jackett/config:/config" + "${dir:config}:/config" ] }, { "id": "runtime-config", "type": "file", - "path": "/var/lib/mesh/jackett/config.json", + "path": "${dir:state}/config.json", "mode": "0600", "content": "{}\n", "merge": "json" @@ -59,12 +64,12 @@ "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" + "${dir:state}/config.json:/run/config/config.json:ro", + "${dir:config}:/var/lib/jackett/config:ro" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_JACKETT_URL": "http://127.0.0.1:9117", + "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" }, @@ -87,7 +92,7 @@ } }, "binds": { - "route": "/var/lib/mesh/jackett/route.json" + "route": "${dir:state}/route.json" }, "build": { "on": [ diff --git a/modules/jackett/tools/index.ts b/modules/jackett/tools/index.ts index ccb360a..8c62150 100644 --- a/modules/jackett/tools/index.ts +++ b/modules/jackett/tools/index.ts @@ -9,7 +9,7 @@ export function getJackettTools(jackett: JackettClient): ToolDefinition[] { return [ { name: "jackett_indexers", - description: "List the indexers Jackett proxies, with their type and any last error.", + description: "List the indexers Jackett proxies, with their type and site.", input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } }, run: async (args) => { const indexers = await jackett.getIndexers(!args.all); From 9d716ed87521baaeb3cac8309c266afd2ded023a Mon Sep 17 00:00:00 2001 From: jochens Date: Wed, 30 Sep 2026 13:05:36 +0200 Subject: [PATCH 2/2] jackett: provide its Torznab API as jackett-api sonarr, radarr, lidarr and bookshelf reached jackett as http://jackett:9117 (a HAL container name) or https://indexers.zurag.be (its public route), typed into each app by hand. The mesh has neither: an app now requires jackett-api and its downloads step writes the bound address into the app. Serves scheme, port and url-base; `at` and the machine port come from the binding. Mesh scope, like sonarr-api: an indexer proxy shares no files with its consumers. The pair credential is jackett's one API key. The mesh cannot mint it, so the operator accepts it per consumer pair (ADR 0092), as #156 does for sonarr-api; a consumer's step refuses a minted value and names the accept. --- modules/jackett/module.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/modules/jackett/module.json b/modules/jackett/module.json index f969d2c..04b2f11 100644 --- a/modules/jackett/module.json +++ b/modules/jackett/module.json @@ -1,6 +1,19 @@ { "module": "jackett", "version": "1", + "provides": [ + { + "name": "jackett-api", + "scope": "mesh" + } + ], + "serves": { + "jackett-api": { + "scheme": "http", + "port": 9117, + "url-base": "" + } + }, "capabilities": [ "container-runtime" ], @@ -10,7 +23,7 @@ "port": 9117, "protocol": "tcp", "from": "mesh", - "why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through" + "why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through, which other modules reach as jackett-api" } ], "resources": [