feat(qbittorrent): login compat for v4/v5.0-5.2 and three auth modes

- Login success = HTTP 200 "Ok." or 204 (5.2+); session cookie taken from
  Set-Cookie by name (SID / QBT_SID_<port> / custom) and sent back as-is.
- Failure classes: "Fails." / 401 -> auth; 403 IP ban / 429 -> rate;
  wrong API Key (403) -> auth with hint; 5xx -> protocol.
- Downloader instance auth kind: password / api_key (qB 5.2+ Bearer, no
  /auth/login) / none (whitelisted subnet); stored in settings.authKind,
  credential write-only in secret_ref, cleared when switching to none.
  One qbAuthConfig() shared by sync, agent capability and connection test.
- Settings form: auth kind select, password / API Key fields (no echo).
- testkit: mock qB 5.2 login behaviour, IP ban, bypassAuth;
  makeTorrent({ realPieces }) builds real piece hashes for real qB checks.
- Real-container matrix (QB_REAL, skipped by default): 5.1.2 and 5.2.4,
  login / add / query / tags / pause-resume / delete keeping data.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 20:42:17 +08:00
1 parent c846e91cc4
commit 74f1566985
14 files changed
+756 -59

No files matched your search

+99 -20
View File
@@ -17,8 +17,13 @@ export interface QbInstanceConfig {
baseUrl: string;
username?: string;
password?: string;
/** qB 5.2+ WebUI API Key;有则走 Bearer,不登录 */
/** qB 5.2+ WebUI API Key(WebUI 生成,形如 qbt_…);有则走 Authorization: Bearer,不调 /auth/login */
apiKey?: string;
/**
* 认证方式(对照 MP / qbittorrent-api):password 走 /auth/login 会话 cookie;api_key 只发 Bearer 头;
* none 不认证(qB 设了"对白名单子网跳过认证"的内网)。缺省按是否给了 apiKey 推断。
*/
authMode?: QbAuthMode;
/** 单次请求超时,默认 15000 */
timeoutMs?: number;
/** 幂等读请求的重试次数(不含首次),默认 2;写请求不自动重试 */
@@ -36,6 +41,30 @@ export interface QbInstanceConfig {
fetch?: typeof fetch;
}
export type QbAuthMode = "password" | "api_key" | "none";
/**
* 实例配置 → 认证参数(服务端各处构造客户端共用)。
* authKind 显式给出时按它;缺省兼容旧数据:有用户名 → 账密,无用户名但有凭证 → API Key,否则无认证。
*/
export function qbAuthConfig(i: {
authKind?: unknown;
username?: string | null;
secret?: string | null;
}): Pick<QbInstanceConfig, "authMode" | "username" | "password" | "apiKey"> {
const kind: QbAuthMode =
i.authKind === "password" || i.authKind === "api_key" || i.authKind === "none"
? i.authKind
: i.username
? "password"
: i.secret
? "api_key"
: "none";
if (kind === "api_key") return { authMode: kind, ...(i.secret ? { apiKey: i.secret } : {}) };
if (kind === "none") return { authMode: kind };
return { authMode: kind, username: i.username ?? "", ...(i.secret ? { password: i.secret } : {}) };
}
export interface QbTorrent {
instanceId: string;
hash: string;
@@ -201,6 +230,23 @@ function str(v: unknown): string | null {
return typeof v === "string" && v !== "" ? v : null;
}
/**
* 从登录响应的 Set-Cookie 中取会话 cookie,返回可直接放进 Cookie 头的 "name=value"。
* 优先 SID / QBT_SID_<port> / 名字含 SID 的;否则取第一个有值的 cookie。
*/
export function pickSessionCookie(setCookies: readonly string[]): string | null {
const pairs = setCookies
.map((c) => /^\s*([^=;\s]+)=([^;]*)/.exec(c))
.filter((m): m is RegExpExecArray => m !== null && m[2] !== "")
.map((m) => ({ name: m[1]!, value: m[2]! }));
const hit =
pairs.find((p) => p.name === "SID") ??
pairs.find((p) => /^QBT_SID(_\d+)?$/i.test(p.name)) ??
pairs.find((p) => /sid/i.test(p.name)) ??
pairs[0];
return hit ? `${hit.name}=${hit.value}` : null;
}
export function normalizeTorrent(instanceId: string, r: Record<string, unknown>): QbTorrent {
const rawState = typeof r.state === "string" ? r.state : "";
const progress = num(r.progress) ?? 0;
@@ -253,8 +299,11 @@ export class QbClient implements DownloaderClient {
/** qB 5.x 把 pause/resume 改名为 stop/start;探测到后缓存 */
private legacyPauseApi: boolean | null = null;
private readonly authMode: QbAuthMode;
constructor(private readonly cfg: QbInstanceConfig) {
this.instanceId = cfg.instanceId;
this.authMode = cfg.authMode ?? (cfg.apiKey ? "api_key" : "password");
this.base = cfg.baseUrl.replace(/\/+$/, "");
this.timeoutMs = cfg.timeoutMs ?? 15_000;
this.retries = cfg.retries ?? 2;
@@ -290,7 +339,7 @@ export class QbClient implements DownloaderClient {
/** 登录;并发调用合并为一次。 */
async login(): Promise<void> {
if (this.cfg.apiKey) return;
if (this.authMode !== "password") return;
if (!this.loginInFlight) {
this.loginInFlight = this.doLogin().finally(() => {
this.loginInFlight = null;
@@ -299,6 +348,13 @@ export class QbClient implements DownloaderClient {
return this.loginInFlight;
}
/**
* 登录判定(对照 qbittorrent-api auth_log_in,覆盖 v4 / v5.0–5.2):
* - 成功:HTTP 200 且体为 "Ok."(≤5.1),或 204 空体(5.2+);
* - 会话 cookie 名不固定:≤5.1 为 SID,5.2+ 为 QBT_SID_<WebUI 端口>(容器内端口,不一定等于映射端口),
* 也可能被自定义 —— 一律取 Set-Cookie 里的会话 cookie 原名原值回传;
* - 失败:200 "Fails." / 401 → 凭据错误(auth);403 被封禁 / 429 → 限流(rate);5xx → protocol。
*/
private async doLogin(): Promise<void> {
const endpoint = "auth/login";
const res = await this.rawFetch(endpoint, {
@@ -306,18 +362,30 @@ export class QbClient implements DownloaderClient {
headers: { "content-type": "application/x-www-form-urlencoded", referer: this.base },
body: new URLSearchParams({ username: this.cfg.username ?? "", password: this.cfg.password ?? "" }),
});
const text = await res.text();
if (res.status === 403) throw this.err("auth", "login forbidden (IP banned after failed attempts?)", endpoint, 403);
if (res.status === 429) throw this.err("rate", "login rate limited", endpoint, 429);
if (!res.ok)
throw this.err(res.status >= 500 ? "protocol" : "auth", `login HTTP ${res.status}`, endpoint, res.status);
if (!text.trim().startsWith("Ok")) throw this.err("auth", "login rejected (bad credentials)", endpoint, res.status);
const cookie = res.headers
.getSetCookie()
.map((c) => /^SID=([^;]*)/.exec(c)?.[1])
.find((v) => v !== undefined);
// 关闭鉴权或白名单 IP 时 qB 可能不下发 cookie,仍视为成功
this.sid = cookie ?? "";
const text = (await res.text()).trim();
const s = res.status;
if (s === 204 || (s === 200 && /^ok\.?$/i.test(text))) {
// 关闭鉴权或白名单 IP 时 qB 可能不下发 cookie,仍视为成功
this.sid = pickSessionCookie(res.headers.getSetCookie()) ?? "";
return;
}
if (s === 200 && /^fails\.?$/i.test(text))
throw this.err("auth", "login rejected: bad username or password", endpoint, s);
if (s === 429) throw this.err("rate", "login rate limited", endpoint, s);
if (s === 403) {
if (/ban/i.test(text))
throw this.err("rate", "login blocked: IP banned after too many failed attempts", endpoint, s);
throw this.err("auth", `login forbidden (HTTP 403${text ? `: ${text.slice(0, 80)}` : ""})`, endpoint, s);
}
if (s === 401)
throw this.err(
"auth",
"login rejected (HTTP 401): bad username or password; for a port-mapped container also check that WEBUI_PORT equals the published port (qB rejects mismatched Host)",
endpoint,
s,
);
if (s >= 500) throw this.err("protocol", `login HTTP ${s}`, endpoint, s);
throw this.err("protocol", `unexpected login response: HTTP ${s} ${text.slice(0, 80)}`, endpoint, s);
}
private async send(endpoint: string, opts: ReqOpts): Promise<Response> {
@@ -338,10 +406,13 @@ export class QbClient implements DownloaderClient {
}
private async sendOnce(endpoint: string, opts: ReqOpts, relogged: boolean): Promise<Response> {
if (!this.cfg.apiKey && this.sid === null) await this.login();
const session = this.authMode === "password";
if (session && this.sid === null) await this.login();
const headers: Record<string, string> = { referer: this.base };
if (this.cfg.apiKey) headers.authorization = `Bearer ${this.cfg.apiKey}`;
else if (this.sid) headers.cookie = `SID=${this.sid}`;
if (this.authMode === "api_key") {
if (!this.cfg.apiKey) throw this.err("auth", "API Key auth selected but no API Key configured", endpoint);
headers.authorization = `Bearer ${this.cfg.apiKey}`;
} else if (session && this.sid) headers.cookie = this.sid;
let body: RequestInit["body"] | undefined;
if (opts.body?.kind === "form") {
headers["content-type"] = "application/x-www-form-urlencoded";
@@ -355,13 +426,21 @@ export class QbClient implements DownloaderClient {
const res = await this.rawFetch(path, { method: body ? "POST" : "GET", headers, ...(body ? { body } : {}) });
const s = res.status;
if (s === 401 || s === 403) {
await res.body?.cancel();
if (!this.cfg.apiKey && !relogged) {
const t = (await res.text().catch(() => "")).trim();
if (s === 403 && /ban/i.test(t))
throw this.err("rate", "IP banned after too many failed authentication attempts", endpoint, s);
if (session && !relogged) {
this.sid = null;
await this.login();
return this.sendOnce(endpoint, opts, true);
}
throw this.err("auth", `unauthorized (HTTP ${s})`, endpoint, s);
const why =
this.authMode === "api_key"
? "API Key rejected (check the key; API Key needs qBittorrent 5.2+)"
: this.authMode === "none"
? "WebUI requires authentication (use username/password or API Key, or whitelist this subnet in qB)"
: "session rejected after re-login";
throw this.err("auth", `unauthorized (HTTP ${s}): ${why}`, endpoint, s);
}
if (s === 404) {
const t = await res.text();
@@ -0,0 +1,227 @@
/**
* 真 qBittorrent 兼容性测试(默认跳过)。设置 QB_REAL 后对每个实例、每种认证方式跑一遍
* 登录 / 添加 / 查询 / 标签 / 暂停恢复 / 删除(不删数据),以及失败分类(错密码 → auth,IP 封禁 → rate)。
*
* QB_REAL='[{"label":"5.2.4","url":"http://127.0.0.1:18082","hostDir":"/tmp/qb52/downloads","qbDir":"/downloads",
* "username":"admin","password":"adminadmin","apiKey":"qbt_…"}]'
* hostDir 是 qB 保存目录在本机的路径(测试先把真实内容写进去,qB 校验通过即完成)。
* 容器做了端口映射时 WEBUI_PORT 必须等于映射端口,否则 qB 按 Host 头拒绝(一律 401)。
*/
import { existsSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { makeTorrent } from "@umt/testkit";
import { describe, expect, it } from "vitest";
import { createQbClient, type DownloaderClient, DownloaderError, qbAuthConfig } from "./index.ts";
interface Target {
label: string;
url: string;
hostDir: string;
qbDir: string;
username: string;
password: string;
apiKey?: string | null;
}
const targets: Target[] = (() => {
try {
return JSON.parse(process.env.QB_REAL ?? "[]") as Target[];
} catch {
return [];
}
})();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const fast = { retries: 1, retryBaseMs: 200, lookup: { attempts: 10, intervalMs: 500 }, timeoutMs: 10_000 };
async function waitFor<T>(fn: () => Promise<T | null | undefined | false>, ms = 30_000): Promise<T> {
const end = Date.now() + ms;
for (;;) {
const v = await fn();
if (v) return v;
if (Date.now() > end) throw new Error("waitFor timeout");
await sleep(300);
}
}
async function roundTrip(qb: DownloaderClient, t: Target, tag: string) {
const v = await qb.version();
expect(v.app).toMatch(new RegExp(`^v?${t.label.replace(/\./g, "\\.")}`));
const dir = `umt-real-${tag}-${Date.now().toString(36)}`;
const made = makeTorrent({
name: `Real.Compat.${tag}.2024.1080p`,
pieceLength: 16384,
files: [
{ path: "Real.Compat.2024.1080p.mkv", length: 150_000 },
{ path: "Real.Compat.2024.1080p.chs.srt", length: 4_000 },
],
realPieces: true,
});
for (const c of made.contents!) {
const p = join(t.hostDir, dir, c.path);
await mkdir(dirname(p), { recursive: true });
await writeFile(p, c.bytes);
}
try {
const out = await qb.submit({
source: { kind: "file", bytes: made.bytes, filename: "real.torrent" },
savePath: `${t.qbDir}/${dir}`,
tags: ["umt-real"],
});
expect(out.kind).toBe("added");
// qB 对已有数据做校验:内容正确 → 完成(必要时手动 recheck)
let rechecked = false;
const done = await waitFor(async () => {
const x = await qb.findByHash(made.hash);
if (x && !x.complete && !rechecked && /^(stopped|paused)/.test(x.rawState)) {
rechecked = true;
await qb.recheck([made.hash]);
}
return x?.complete ? x : null;
});
expect(done.progress).toBe(1);
expect((await qb.files(made.hash)).map((f) => f.size).sort((a, b) => a - b)).toEqual([4_000, 150_000]);
await qb.addTags([made.hash], ["umt-real-extra"]);
expect((await qb.list({ tags: ["umt-real", "umt-real-extra"] })).map((x) => x.hash)).toContain(made.hash);
await qb.removeTags([made.hash], ["umt-real-extra"]);
await qb.deleteTagDefinitions(["umt-real-extra"]);
expect((await qb.findByHash(made.hash))?.tags).toEqual(["umt-real"]);
await qb.pause([made.hash]);
await waitFor(async () => (await qb.findByHash(made.hash))?.state === "completed_paused");
await qb.resume([made.hash]);
await waitFor(async () => (await qb.findByHash(made.hash))?.state !== "completed_paused");
await qb.delete([made.hash], { deleteFiles: false });
await waitFor(async () => (await qb.findByHash(made.hash)) === null);
// 不删数据
for (const c of made.contents!) expect(existsSync(join(t.hostDir, dir, c.path))).toBe(true);
} finally {
await qb.delete([made.hash], { deleteFiles: false }).catch(() => {});
await rm(join(t.hostDir, dir), { recursive: true, force: true }).catch(() => {});
}
}
/** 测试夹具:直接调 WebUI 读写偏好(客户端本身对偏好只读) */
async function sessionCookie(t: Target): Promise<string> {
const login = await fetch(`${t.url}/api/v2/auth/login`, {
method: "POST",
body: new URLSearchParams({ username: t.username, password: t.password }),
});
await login.body?.cancel();
return login.headers.getSetCookie()[0]!.split(";")[0]!;
}
async function getPrefs(t: Target, cookie: string): Promise<Record<string, unknown>> {
return (await (await fetch(`${t.url}/api/v2/app/preferences`, { headers: { cookie } })).json()) as Record<
string,
unknown
>;
}
async function setPrefs(t: Target, cookie: string, json: Record<string, unknown>): Promise<void> {
const r = await fetch(`${t.url}/api/v2/app/setPreferences`, {
method: "POST",
headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ json: JSON.stringify(json) }),
});
await r.body?.cancel();
expect(r.ok).toBe(true);
}
describe.skipIf(targets.length === 0)("真 qBittorrent 兼容性", () => {
for (const t of targets) {
it(`${t.label} 账密:登录 / 添加 / 查询 / 标签 / 暂停恢复 / 删除(不删数据)`, async () => {
const qb = createQbClient({
instanceId: `real-${t.label}`,
baseUrl: t.url,
...qbAuthConfig({ authKind: "password", username: t.username, secret: t.password }),
...fast,
});
await roundTrip(qb, t, "pw");
}, 90_000);
it.skipIf(!t.apiKey)(
`${t.label} API Key:不登录,全流程`,
async () => {
const qb = createQbClient({
instanceId: `real-${t.label}-key`,
baseUrl: t.url,
...qbAuthConfig({ authKind: "api_key", secret: t.apiKey ?? null }),
...fast,
});
await roundTrip(qb, t, "key");
const bad = createQbClient({
instanceId: "bad-key",
baseUrl: t.url,
...qbAuthConfig({ authKind: "api_key", secret: "qbt_definitely_wrong" }),
...fast,
});
const e = await bad.version().catch((x) => x);
expect(e).toBeInstanceOf(DownloaderError);
expect(e.kind).toBe("auth");
},
90_000,
);
it(`${t.label} 无认证(白名单子网跳过认证):不登录也能查询与添加`, async () => {
const qb = createQbClient({
instanceId: `real-${t.label}-pw`,
baseUrl: t.url,
...qbAuthConfig({ authKind: "password", username: t.username, secret: t.password }),
...fast,
});
await qb.version();
const cookie = await sessionCookie(t);
const prefs = await getPrefs(t, cookie);
await setPrefs(t, cookie, {
bypass_auth_subnet_whitelist_enabled: true,
bypass_auth_subnet_whitelist: "0.0.0.0/0",
});
try {
const none = createQbClient({
instanceId: "none-ok",
baseUrl: t.url,
...qbAuthConfig({ authKind: "none" }),
...fast,
});
await roundTrip(none, t, "none");
} finally {
await setPrefs(t, cookie, {
bypass_auth_subnet_whitelist_enabled: prefs.bypass_auth_subnet_whitelist_enabled,
bypass_auth_subnet_whitelist: prefs.bypass_auth_subnet_whitelist,
});
}
}, 90_000);
it(`${t.label} 失败分类:错密码 → auth;连续失败被封禁 → rate;无认证 → auth`, async () => {
// 临时把封禁阈值调小(测试结束恢复),避免把本机 IP 封一小时
const cookie = await sessionCookie(t);
const prefs = await getPrefs(t, cookie);
await setPrefs(t, cookie, { web_ui_max_auth_fail_count: 2, web_ui_ban_duration: 3 });
try {
const none = createQbClient({ instanceId: "none", baseUrl: t.url, authMode: "none", ...fast });
expect((await none.version().catch((x) => x)).kind).toBe("auth");
const bad = createQbClient({
instanceId: "bad",
baseUrl: t.url,
username: t.username,
password: "wrong-password",
...fast,
retries: 0,
});
expect((await bad.version().catch((x) => x)).kind).toBe("auth");
expect((await bad.version().catch((x) => x)).kind).toBe("auth");
const banned = await bad.version().catch((x) => x);
expect(banned.kind).toBe("rate");
expect(banned.detail.status).toBe(403);
} finally {
await sleep(4_000);
await setPrefs(t, cookie, {
web_ui_max_auth_fail_count: prefs.web_ui_max_auth_fail_count,
web_ui_ban_duration: prefs.web_ui_ban_duration,
});
}
}, 60_000);
}
});
@@ -8,6 +8,8 @@ import {
mapQbState,
parseMagnet,
parseTorrent,
pickSessionCookie,
qbAuthConfig,
} from "./index.ts";
import { fromDownloaderPath, toDownloaderPath } from "./paths.ts";
@@ -94,6 +96,104 @@ describe("QbClient 鉴权与错误分类", () => {
expect(e.kind).toBe("auth");
});
it("5.2:登录 204 空体 + QBT_SID_<port> 视为成功,会话 cookie 原名回传;过期 401 重登", async () => {
mock = await createMockQb({ version: "5.2.4", webUiPort: 18082 });
const qb = createQbClient({
instanceId: "qb-52",
baseUrl: mock.url,
username: "admin",
password: "adminadmin",
...fast,
});
expect((await qb.version()).webApi).toBe("2.11.4");
await qb.list();
expect(mock.callsTo("auth/login")).toHaveLength(1);
mock.expireSessions();
await expect(qb.list()).resolves.toEqual([]);
expect(mock.callsTo("auth/login")).toHaveLength(2);
});
it("失败分类:Fails./401 → auth;IP 封禁 403 / 429 → rate", async () => {
for (const version of ["4.6.7", "5.2.4"] as const) {
const m = await createMockQb({ version, banAfterFailures: 2 });
const bad = createQbClient({
instanceId: "qb-b",
baseUrl: m.url,
username: "admin",
password: "x",
...fast,
retries: 0,
});
expect((await bad.version().catch((e) => e)).kind).toBe("auth");
expect((await bad.version().catch((e) => e)).kind).toBe("auth");
const banned = await bad.version().catch((e) => e);
expect(banned.kind).toBe("rate");
expect(banned.detail.status).toBe(403);
await m.close();
}
mock = await createMockQb();
mock.fault("auth/login", { status: 429 });
const qb = createQbClient({
instanceId: "qb-r",
baseUrl: mock.url,
username: "admin",
password: "adminadmin",
...fast,
retries: 0,
});
expect((await qb.version().catch((e) => e)).kind).toBe("rate");
});
it("认证方式三选一:API Key 不登录、错 Key → auth 不重登;none 不登录,需要认证时 → auth", async () => {
mock = await createMockQb({ version: "5.2.4", apiKey: "qbt_good" });
const bad = createQbClient({
instanceId: "k",
baseUrl: mock.url,
...qbAuthConfig({ authKind: "api_key", secret: "qbt_bad" }),
...fast,
});
const e = await bad.version().catch((x) => x);
expect(e.kind).toBe("auth");
expect(e.message).toContain("API Key");
const none = createQbClient({ instanceId: "n", baseUrl: mock.url, ...qbAuthConfig({ authKind: "none" }), ...fast });
expect((await none.version().catch((x) => x)).kind).toBe("auth");
expect(mock.callsTo("auth/login")).toHaveLength(0);
await mock.close();
mock = await createMockQb({ version: "5.2.4", bypassAuth: true });
const open = createQbClient({
instanceId: "n2",
baseUrl: mock.url,
...qbAuthConfig({ authKind: "none" }),
...fast,
});
expect((await open.version()).webApi).toBe("2.11.4");
expect(mock.callsTo("auth/login")).toHaveLength(0);
});
it("qbAuthConfig:显式 authKind 优先;缺省按旧数据推断", () => {
expect(qbAuthConfig({ authKind: "api_key", username: "admin", secret: "k" })).toEqual({
authMode: "api_key",
apiKey: "k",
});
expect(qbAuthConfig({ authKind: "none", username: "admin", secret: "p" })).toEqual({ authMode: "none" });
expect(qbAuthConfig({ username: "admin", secret: "p" })).toEqual({
authMode: "password",
username: "admin",
password: "p",
});
expect(qbAuthConfig({ username: null, secret: "k" })).toEqual({ authMode: "api_key", apiKey: "k" });
expect(qbAuthConfig({})).toEqual({ authMode: "none" });
});
it("pickSessionCookie:SID / QBT_SID_<port> / 自定义名都能取到", () => {
expect(pickSessionCookie(["SID=abc; HttpOnly; path=/"])).toBe("SID=abc");
expect(pickSessionCookie(["foo=1; path=/", "QBT_SID_8080=xyz; HttpOnly"])).toBe("QBT_SID_8080=xyz");
expect(pickSessionCookie(["MY_QB_SID=q; path=/"])).toBe("MY_QB_SID=q");
expect(pickSessionCookie(["session=zz"])).toBe("session=zz");
expect(pickSessionCookie(["SID=; Max-Age=0"])).toBeNull();
expect(pickSessionCookie([])).toBeNull();
});
it("会话过期 403 → 自动重登一次后成功", async () => {
const { mock, qb } = await setup();
await qb.list();