From 5cc625832684772aa3fb689e91093c7f6b794f87 Mon Sep 17 00:00:00 2001 From: jochens Date: Tue, 29 Sep 2026 23:50:19 +0200 Subject: [PATCH 1/3] Sidecars dial the port they were given, not the software's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host-network sidecar reaches its service over the machine's loopback, and the mesh publishes that service on a machine port it assigns (ADR 0038) — so dialling the software's port reaches whatever else holds it. On ace, searxng's sidecar dialled 127.0.0.1:8080 and got unifi's inform port. The same shape in bazarr, bookshelf, lidarr, nzbget, qbittorrent, radarr and sonarr; each now asks with ${port:N} (hq 088). Found in review of ace's module preparation. --- modules/bazarr/module.json | 2 +- modules/bookshelf/module.json | 2 +- modules/lidarr/module.json | 2 +- modules/nzbget/module.json | 2 +- modules/qbittorrent/module.json | 2 +- modules/radarr/module.json | 2 +- modules/searxng/module.json | 2 +- modules/sonarr/module.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json index 1171144..abe03c9 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -94,7 +94,7 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_BAZARR_URL": "http://127.0.0.1:6767", + "MESH_BAZARR_URL": "http://127.0.0.1:${port: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/bookshelf/module.json b/modules/bookshelf/module.json index 4447e4b..ed17aff 100644 --- a/modules/bookshelf/module.json +++ b/modules/bookshelf/module.json @@ -76,7 +76,7 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_BOOKSHELF_URL": "http://127.0.0.1:8787", + "MESH_BOOKSHELF_URL": "http://127.0.0.1:${port:8787}", "MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config" }, "artifact": "runtime" diff --git a/modules/lidarr/module.json b/modules/lidarr/module.json index 30c71fa..a1dde33 100644 --- a/modules/lidarr/module.json +++ b/modules/lidarr/module.json @@ -75,7 +75,7 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_LIDARR_URL": "http://127.0.0.1:8686", + "MESH_LIDARR_URL": "http://127.0.0.1:${port:8686}", "MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config" }, "artifact": "runtime" diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json index ce3c33d..a73984d 100644 --- a/modules/nzbget/module.json +++ b/modules/nzbget/module.json @@ -81,7 +81,7 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_NZBGET_URL": "http://127.0.0.1:6789", + "MESH_NZBGET_URL": "http://127.0.0.1:${port: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/qbittorrent/module.json b/modules/qbittorrent/module.json index 1efef1a..cb67403 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -82,7 +82,7 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_QBITTORRENT_URL": "http://127.0.0.1:8080", + "MESH_QBITTORRENT_URL": "http://127.0.0.1:${port:8080}", "MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password", "MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json", "MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config" diff --git a/modules/radarr/module.json b/modules/radarr/module.json index 351fcb3..cf9a6f6 100644 --- a/modules/radarr/module.json +++ b/modules/radarr/module.json @@ -75,7 +75,7 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_RADARR_URL": "http://127.0.0.1:7878", + "MESH_RADARR_URL": "http://127.0.0.1:${port:7878}", "MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config" }, "artifact": "runtime" diff --git a/modules/searxng/module.json b/modules/searxng/module.json index d2b0fa0..2aaa312 100644 --- a/modules/searxng/module.json +++ b/modules/searxng/module.json @@ -100,7 +100,7 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_SEARXNG_URL": "http://127.0.0.1:8080", + "MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}", "MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json" }, "restart-on": [ diff --git a/modules/sonarr/module.json b/modules/sonarr/module.json index d91708b..17711be 100644 --- a/modules/sonarr/module.json +++ b/modules/sonarr/module.json @@ -80,7 +80,7 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_SONARR_URL": "http://127.0.0.1:8989", + "MESH_SONARR_URL": "http://127.0.0.1:${port:8989}", "MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config" }, "artifact": "runtime" -- 2.54.0 From 5012f61b00748aa819796f91bc43f173d67779e3 Mon Sep 17 00:00:00 2001 From: jochens Date: Wed, 30 Sep 2026 12:00:41 +0200 Subject: [PATCH 2/3] qbittorrent: placed config, the build ace runs, a login 5.2 accepts, its ports as announced The manifest named /services/qbittorrent/config and /var/lib/mesh/qbittorrent/ config.json, host paths ADR 0112 takes out of definitions. The config dir is now pathless (${dir:config}); the runtime's config and route binding live in a placed state dir. The image is pinned to 5.2.3_v2.0.14-ls477, the digest ace runs; the old pin (ls474) was older than the running build. The tools could not log in to qBittorrent 5.2: it answers a good login with 204 and no body (not 200 "Ok.") and names its cookie QBT_SID_ (not SID). The client now accepts both shapes and sends the cookie back under the name it was set. It also read the user as "admin" always; it now reads WebUI\Username from qBittorrent.conf on the read-only config mount. qBittorrent refuses a request whose Host header names a port other than the one it listens on. With the mesh publishing 8080 on another machine port, the tools and every consumer dialling that port were refused. The WebUI now listens on 8112 (WEBUI_PORT, ace's and HAL's number, and clear of unifi's 8080) and is published on the same number. The torrent port 6881 tcp+udp was not declared at all; it is now, long-form, because the client announces it to peers. sonarr, radarr and lidarr reached it by container name on HAL's shared network. qbittorrent now provides qbittorrent-api (node scope: a download client must share the consumer's spool) and serves scheme, port, url-base and username; the password is the operator-accepted pair credential, as for #156. The web endpoint is routed (label qbittorrent). The runtime dials ${port:8112}, as #154 does. Verified: catalogue tests with MESH_CATALOGUE set (not skipped); rendered for ace with pins and without (8112:8112, 6881:6881, 6881:6881/udp either way); a throwaway of the pinned image on an ace-shaped conf showed the port-mismatch refusal, then on a same-number port the compiled client logged in, read the user from qBittorrent.conf, listed torrents and was refused a wrong password and the default user; strict typecheck and the Dockerfile build pass. --- modules/qbittorrent/client.ts | 51 ++++++++++++++++++------ modules/qbittorrent/module.json | 70 +++++++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 25 deletions(-) diff --git a/modules/qbittorrent/client.ts b/modules/qbittorrent/client.ts index a5c9e80..16995ee 100644 --- a/modules/qbittorrent/client.ts +++ b/modules/qbittorrent/client.ts @@ -3,8 +3,10 @@ // qbittorrent. Both this module's tools and its events entrypoint import it, and nothing outside // qbittorrent does. // -// The WebUI authenticates with a session cookie (SID) obtained by POSTing credentials, and guards -// against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID is +// The WebUI authenticates with a session cookie obtained by POSTing credentials, and guards +// against CSRF by checking the Referer header. The cookie was `SID` before qBittorrent 5.2 and is +// `QBT_SID_` since, and a successful login answers 200 "Ok." before and 204 with no body +// since — both are accepted. Node's fetch keeps no cookie jar, so the cookie is // captured on login and carried by hand on every later call, with a single re-login on expiry. import { readFileSync } from "node:fs"; @@ -39,6 +41,21 @@ function meshConfig(file?: string): Record { catch { return {}; } } +/** The WebUI username from qBittorrent's own qBittorrent.conf, in the config directory the mesh + * mounts read-only (MESH_QBITTORRENT_CONFIG_DIR, which is the container's /config). The software's + * file is the truth about who may log in, so the tools ask it rather than a setting that could + * disagree. Absent, unreadable or unset yields undefined. */ +function confUsername(dir: string | undefined): string | undefined { + if (!dir) return undefined; + try { + const line = readFileSync(`${dir.replace(/\/$/, "")}/qBittorrent/qBittorrent.conf`, "utf8") + .split(/\r?\n/) + .find((l) => l.startsWith("WebUI\\Username=")); + const value = line?.slice("WebUI\\Username=".length).trim(); + return value ? value : undefined; + } catch { return undefined; } +} + /** 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 { @@ -49,7 +66,8 @@ function readSecret(file?: string): string | undefined { export class QbittorrentClient { readonly baseUrl: string; - private sid: string | null = null; + /** The session cookie as `name=value`, sent back exactly as it was set. */ + private session: string | null = null; constructor( baseUrl: string, @@ -63,7 +81,9 @@ export class QbittorrentClient { * Build from the module's resolved environment. URL and password are read from * MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD; both must be present — an unconfigured * qBittorrent throws rather than pretend to be reachable, so the tools/events simply do not load - * (the harness treats the throw as "exposes nothing"). The user defaults to "admin". + * (the harness treats the throw as "exposes nothing"). The user is read from qBittorrent.conf, + * falling back to "admin", the image's default. The password cannot be read there — qBittorrent + * keeps only a PBKDF2 hash — so it is the own-secret the operator accepts. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient { const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE); @@ -72,7 +92,9 @@ export class QbittorrentClient { if (!url || !password) { throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD"); } - const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? "admin"; + // The WebUI username: a setting or the environment if one says so, else whatever + // qBittorrent.conf holds (an adopted machine keeps its own), else the image's "admin". + const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? confUsername(env.MESH_QBITTORRENT_CONFIG_DIR) ?? "admin"; return new QbittorrentClient(url, user, password); } @@ -82,19 +104,22 @@ export class QbittorrentClient { headers: { "Content-Type": "application/x-www-form-urlencoded", Referer: this.baseUrl }, body: new URLSearchParams({ username: this.user, password: this.password }), }); + if (res.status === 401) throw new Error("qBittorrent login rejected — check credentials"); if (!res.ok) throw new Error(`qBittorrent login: ${res.status} ${await res.text()}`); - if ((await res.text()).trim() !== "Ok.") { + // 4.x/5.0/5.1 answer 200 "Ok." or 200 "Fails."; 5.2 answers 204 with no body, or 401. + const body = (await res.text()).trim(); + if (res.status !== 204 && body !== "Ok.") { throw new Error("qBittorrent login rejected — check credentials"); } - const match = res.headers.get("set-cookie")?.match(/SID=([^;]+)/); - if (!match) throw new Error("qBittorrent login returned no SID cookie"); - this.sid = match[1]; + const match = res.headers.get("set-cookie")?.match(/((?:QBT_)?SID(?:_\d+)?)=([^;]+)/); + if (!match) throw new Error("qBittorrent login returned no session cookie"); + this.session = `${match[1]}=${match[2]}`; } private async call(method: "GET" | "POST", path: string, form?: Record): Promise { - if (!this.sid) await this.login(); + if (!this.session) await this.login(); const doFetch = (): Promise => { - const headers: Record = { Referer: this.baseUrl, Cookie: `SID=${this.sid}` }; + const headers: Record = { Referer: this.baseUrl, Cookie: this.session ?? "" }; const init: RequestInit = { method, headers }; if (form) { headers["Content-Type"] = "application/x-www-form-urlencoded"; @@ -103,8 +128,8 @@ export class QbittorrentClient { return fetch(`${this.baseUrl}/api/v2/${path}`, init); }; let res = await doFetch(); - if (res.status === 403) { - // The SID expired — re-authenticate once and retry, rather than fail a routine call. + if (res.status === 403 || res.status === 401) { + // The session expired — re-authenticate once and retry, rather than fail a routine call. await this.login(); res = await doFetch(); } diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index 1efef1a..80a1b8a 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -17,10 +17,24 @@ "listens": [ { "name": "web", - "port": 8080, + "port": 8112, "protocol": "tcp", "from": "mesh", - "why": "the download client's pages" + "why": "the download client's pages, and the WebUI API its consumers and its own tools call; qBittorrent refuses a request whose Host names a port other than the one it listens on, so a caller dialling the machine port gets in only when the machine publishes the same number" + }, + { + "name": "peers", + "port": 6881, + "protocol": "tcp", + "from": "mesh", + "why": "incoming BitTorrent peer connections; the client announces this number to trackers and peers, so the machine must publish it on the same one" + }, + { + "name": "peers-udp", + "port": 6881, + "protocol": "udp", + "from": "mesh", + "why": "DHT and uTP on the same number as the peer port; announced like it, so published on the same one" } ], "accesses": [ @@ -36,10 +50,15 @@ "path": "/var/lib/mesh/qbittorrent", "mode": "0700" }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, { "id": "config", "type": "directory", - "path": "/services/qbittorrent/config", "mode": "0700", "owner": "1000:1000" }, @@ -47,24 +66,27 @@ "id": "server", "type": "container", "name": "qbittorrent", - "image": "lscr.io/linuxserver/qbittorrent@sha256:a00b6a597a3832a1814cde0ef60abc55c94644f3f80902c3432f6af6de8d4a96", + "image": "lscr.io/linuxserver/qbittorrent@sha256:457e4eec2ee3f5e4ef59f237ad51f6143deba9f7445ab48bb5204a98888ef9aa", "env": { "PUID": "1000", "PGID": "1000", - "TZ": "Etc/UTC" + "TZ": "Etc/UTC", + "WEBUI_PORT": "8112" }, "ports": [ - "8080" + "8112:8112", + "6881:6881", + "6881:6881/udp" ], "volumes": [ - "/services/qbittorrent/config:/config", + "${dir:config}:/config", "/services/media/downloads:/downloads" ] }, { "id": "runtime-config", "type": "file", - "path": "/var/lib/mesh/qbittorrent/config.json", + "path": "${dir:state}/config.json", "mode": "0600", "content": "{}\n", "merge": "json" @@ -77,22 +99,46 @@ "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" + "${dir:state}/config.json:/run/config/config.json:ro", + "${dir:config}:/var/lib/qbittorrent/config:ro" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_QBITTORRENT_URL": "http://127.0.0.1:8080", + "MESH_QBITTORRENT_URL": "http://127.0.0.1:${port:8112}", "MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password", "MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json", "MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config" }, "restart-on": [ - "runtime-config" + "runtime-config", + "needs-password" ], "artifact": "runtime" } ], + "provides": [ + "qbittorrent-api" + ], + "serves": { + "qbittorrent-api": { + "scheme": "http", + "port": 8112, + "url-base": "", + "username": "admin" + } + }, + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "qbittorrent", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, "build": { "on": [ { -- 2.54.0 From 412d7bc3770aa00e552eccce29b10488f3a48836 Mon Sep 17 00:00:00 2001 From: jochens Date: Wed, 30 Sep 2026 12:32:16 +0200 Subject: [PATCH 3/3] qbittorrent: it listens on the port the mesh gave it, rather than fixing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest published 8112:8112 and 6881:6881 because qBittorrent refuses a Host naming another port and announces its torrent port to peers — so the machine port must equal the software's. Fixing the number in the manifest states a machine port in a definition. Instead the server runs on the host network and listens where the mesh put it (WEBUI_PORT=${port:8112}, TORRENTING_PORT=${port:6881}): the two are equal by construction on every machine, and an assignment still pins 8112/6881 where clients know them. Verified: the pinned image on the host network with WEBUI_PORT=18710 and TORRENTING_PORT=18711 served the web UI on 18710 (200, no Host mismatch) and listened for peers on 18711 tcp+udp. --- modules/qbittorrent/module.json | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index 80a1b8a..05c4330 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -20,21 +20,21 @@ "port": 8112, "protocol": "tcp", "from": "mesh", - "why": "the download client's pages, and the WebUI API its consumers and its own tools call; qBittorrent refuses a request whose Host names a port other than the one it listens on, so a caller dialling the machine port gets in only when the machine publishes the same number" + "why": "the download client's pages, and the WebUI API its consumers and its own tools call. qBittorrent refuses a request whose Host names a port other than the one it listens on, so it listens on the machine port itself (host network, WEBUI_PORT=${port:8112}) and the two cannot differ on any machine" }, { "name": "peers", "port": 6881, "protocol": "tcp", "from": "mesh", - "why": "incoming BitTorrent peer connections; the client announces this number to trackers and peers, so the machine must publish it on the same one" + "why": "incoming BitTorrent peer connections; announced to trackers and peers, so qBittorrent listens on the machine port itself (TORRENTING_PORT=${port:6881})" }, { "name": "peers-udp", "port": 6881, "protocol": "udp", "from": "mesh", - "why": "DHT and uTP on the same number as the peer port; announced like it, so published on the same one" + "why": "DHT and uTP on the same number as the peer port" } ], "accesses": [ @@ -71,17 +71,14 @@ "PUID": "1000", "PGID": "1000", "TZ": "Etc/UTC", - "WEBUI_PORT": "8112" + "WEBUI_PORT": "${port:8112}", + "TORRENTING_PORT": "${port:6881}" }, - "ports": [ - "8112:8112", - "6881:6881", - "6881:6881/udp" - ], "volumes": [ "${dir:config}:/config", "/services/media/downloads:/downloads" - ] + ], + "network": "host" }, { "id": "runtime-config", -- 2.54.0