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": [ {