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

@@ -9,6 +9,7 @@ import {
getSiteAdapter,
getSiteDefinition,
type ProbeResult,
qbAuthConfig,
type SecretResolver,
SiteError,
type TorrentSource,
@@ -97,12 +98,10 @@ export function createPorts(
if (hit && hit.stamp === stamp) return hit.client;
const settings = (inst.settings ?? {}) as Record<string, unknown>;
const secret = await resolveSecret(inst.secret_ref);
const useApiKey = settings.authKind === "api_key";
const client = createQbClient({
instanceId: inst.id,
baseUrl: inst.base_url,
...(inst.username ? { username: inst.username } : {}),
...(secret ? (useApiKey ? { apiKey: secret } : { password: secret }) : {}),
...qbAuthConfig({ authKind: settings.authKind, username: inst.username, secret: secret ?? null }),
});
clients.set(inst.id, { stamp, client });
return client;
@@ -1,4 +1,4 @@
import { createQbClient, type DownloaderClient } from "@umt/adapters";
import { createQbClient, type DownloaderClient, qbAuthConfig } from "@umt/adapters";
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
import type { DownloaderInstanceRow } from "@umt/db";
@@ -20,8 +20,7 @@ async function defaultClient(ctx: ServerContext, inst: DownloaderInstanceRow): P
return createQbClient({
instanceId: inst.id,
baseUrl: inst.base_url,
...(inst.username ? { username: inst.username } : {}),
...(secret ? (settings.authKind === "api_key" ? { apiKey: secret } : { password: secret }) : {}),
...qbAuthConfig({ authKind: settings.authKind, username: inst.username, secret: secret ?? null }),
});
}
@@ -1,8 +1,9 @@
import { stat } from "node:fs/promises";
import { checkRoot, DEFAULT_ROOT_SENTINEL, EmbyClient, QbClient, writeRootSentinel } from "@umt/adapters";
import { checkRoot, DEFAULT_ROOT_SENTINEL, EmbyClient, QbClient, qbAuthConfig, writeRootSentinel } from "@umt/adapters";
import {
Api,
type AuditAction,
type DownloaderAuthKind,
DownloaderInstance,
DownloaderInstanceCreate,
DownloaderInstanceUpdate,
@@ -238,17 +239,36 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
// ── downloader_instance ──
list("downloader_instance", "downloader-instances", DL.list);
get("downloader_instance", "downloader-instances", DL.get);
/** 认证方式并入 settings.authKind;账密需要用户名,API Key 需要凭证(新填或已存) */
const dlAuth = (
authKind: DownloaderAuthKind | undefined,
next: { username?: string | null; credential?: string; settings?: Record<string, unknown> },
cur?: Record<string, unknown>,
): Record<string, unknown> | undefined => {
const curSettings = (cur?.settings ?? {}) as Record<string, unknown>;
if (authKind === undefined) return next.settings ? { ...curSettings, ...next.settings } : undefined;
const username = next.username !== undefined ? next.username : ((cur?.username as string | null) ?? null);
if (authKind === "password" && !username) throw new ApiException("validation_failed", "账密认证需要填写用户名");
if (authKind === "api_key" && !next.credential && !(cur?.secret_ref && curSettings.authKind === "api_key"))
throw new ApiException("validation_failed", "API Key 认证需要填写 API Key");
return { ...curSettings, ...(next.settings ?? {}), authKind };
};
endpoint(app, DL.create, async ({ body, actor }) => {
const { credential, ...rest } = body;
const ref = credential ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
const { credential, authKind, ...rest } = body;
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) });
const keep = authKind !== "none" && credential;
const ref = keep ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
try {
const row = await insert("downloader_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref });
const row = await insert("downloader_instance", {
...toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS),
secret_ref: ref,
});
await audit(
ctx,
actor,
"downloader_instance.create",
{ type: "downloader_instance", id: row.id },
{ name: row.name },
{ name: row.name, authKind: authKind ?? null },
);
return toApi(row);
} catch (e) {
@@ -257,13 +277,22 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
}
});
endpoint(app, DL.update, async ({ params, body, actor }) => {
const { credential, ...rest } = body;
const values = toDb(rest, JSON_COLS);
if (credential) {
const cur = await getRow("downloader_instance", params.id);
const { credential, authKind, ...rest } = body;
const cur = await getRow("downloader_instance", params.id);
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) }, cur);
const values = toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS);
let dropRef: string | null = null;
if (authKind === "none") {
// 无认证:不再保留密码 / Key
if (cur.secret_ref) {
values.secret_ref = null;
dropRef = cur.secret_ref;
}
} else if (credential) {
values.secret_ref = await putCredential(cur.secret_ref, "downloader_password", credential);
}
const row = await update("downloader_instance", params.id, values);
if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
await audit(
ctx,
actor,
@@ -285,12 +314,15 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
async ({ params, actor }) => {
const row = await getRow("downloader_instance", params.id);
const secret = row.secret_ref ? await ctx.secrets.resolve(row.secret_ref) : undefined;
const username = (row.username as string | null) ?? undefined;
// 有用户名走 WebUI 登录(密码);没有用户名但有凭证视为 qB 5.2+ WebUI API Key
// 认证方式三选一(settings.authKind):账密 / API Key(qB 5.2+,Bearer) / 无认证;旧数据按用户名有无推断
const client = new QbClient({
instanceId: row.id,
baseUrl: String(row.base_url),
...(username ? { username, ...(secret ? { password: secret } : {}) } : secret ? { apiKey: secret } : {}),
...qbAuthConfig({
authKind: ((row.settings ?? {}) as Record<string, unknown>).authKind,
username: (row.username as string | null) ?? null,
secret: secret ?? null,
}),
timeoutMs: 10_000,
retries: 0,
});
@@ -188,6 +188,66 @@ describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => {
noLeak("qb-secret-pass");
});
it("downloader_instance 认证方式三选一:API Key(不登录)/ 无认证(内网白名单)/ 账密;切换校验与凭证清理", async () => {
const k52 = await createMockQb({ version: "5.2.4", apiKey: "qbt_live_key" });
const open = await createMockQb({ version: "5.2.4", bypassAuth: true });
try {
expect(
(await admin("POST", "/downloader-instances", { name: "k0", baseUrl: k52.url, authKind: "api_key" })).status,
).toBe(422);
expect(
(await admin("POST", "/downloader-instances", { name: "p0", baseUrl: k52.url, authKind: "password" })).status,
).toBe(422);
const k = (
await json(
await admin("POST", "/downloader-instances", {
name: "k52",
baseUrl: k52.url,
authKind: "api_key",
credential: "qbt_live_key",
}),
)
).data;
expect(k.settings.authKind).toBe("api_key");
expect(JSON.stringify(k)).not.toContain("qbt_live_key");
expect((await json(await admin("POST", `/downloader-instances/${k.id}/test`))).data.ok).toBe(true);
expect(k52.callsTo("auth/login")).toHaveLength(0);
await admin("PATCH", `/downloader-instances/${k.id}`, { credential: "qbt_wrong" });
const bad = (await json(await admin("POST", `/downloader-instances/${k.id}/test`))).data;
expect(bad.ok).toBe(false);
expect(bad.message).toContain("API Key");
const n = (
await json(
await admin("POST", "/downloader-instances", { name: "open", baseUrl: open.url, authKind: "none" }),
)
).data;
expect(n.secretRef).toBeNull();
expect((await json(await admin("POST", `/downloader-instances/${n.id}/test`))).data.ok).toBe(true);
expect(open.callsTo("auth/login")).toHaveLength(0);
// 切到无认证:凭证删除;无认证连需要认证的实例 → ok=false
const sw = (await json(await admin("PATCH", `/downloader-instances/${k.id}`, { authKind: "none" }))).data;
expect(sw.secretRef).toBeNull();
await expect(h.ctx.secrets.resolve(k.secretRef)).rejects.toThrow(/不存在/);
expect((await json(await admin("POST", `/downloader-instances/${k.id}/test`))).data.ok).toBe(false);
// 再切回 API Key 必须带 Key
expect((await admin("PATCH", `/downloader-instances/${k.id}`, { authKind: "api_key" })).status).toBe(422);
const back = (
await json(
await admin("PATCH", `/downloader-instances/${k.id}`, { authKind: "api_key", credential: "qbt_live_key" }),
)
).data;
expect(back.secretRef).toBeTruthy();
expect((await json(await admin("POST", `/downloader-instances/${k.id}/test`))).data.ok).toBe(true);
for (const id of [k.id, n.id]) await admin("DELETE", `/downloader-instances/${id}`);
noLeak("qbt_live_key");
} finally {
await k52.close();
await open.close();
}
});
it("media_server_instance:无 key 时 test 报未配置;有 key 时 ping 成功", async () => {
const a = (await json(await admin("POST", "/media-server-instances", { name: "emby-nokey", baseUrl: emby.url })))
.data;
+46 -6
View File
@@ -67,6 +67,19 @@ export function TestButton({
const credentialHint = { editHint: "留空不修改" };
const AUTH_KINDS = [
{ value: "password", label: "用户名和密码" },
{ value: "api_key", label: "API Key" },
{ value: "none", label: "无认证(内网白名单)" },
];
/** 已存认证方式;旧数据没有 settings.authKind 时按用户名 / 凭证推断(与服务端一致) */
function authKindOf(r: DownloaderInstance): string {
const k = (r.settings as { authKind?: unknown }).authKind;
if (k === "password" || k === "api_key" || k === "none") return k;
return r.username ? "password" : r.secretRef ? "api_key" : "none";
}
export function DownloadersSection() {
return (
<SettingsBlock section="downloaders" anchor="instances" title="qBittorrent 实例" plain>
@@ -121,8 +134,29 @@ function DownloadersTable() {
required: true,
placeholder: "http://192.168.1.10:8080",
},
{ name: "username", label: "用户名", kind: "text", hint: "留空时凭证按 WebUI API Key 使用" },
{ name: "credential", label: "密码 / API Key", kind: "password", ...credentialHint },
{
name: "authKind",
label: "认证方式",
kind: "select",
options: AUTH_KINDS,
hint: "API Key 需要 qBittorrent 5.2 及以上,在 WebUI 设置里生成",
},
{ name: "username", label: "用户名", kind: "text", visible: (v) => v.authKind === "password" },
{
name: "password",
label: "密码",
kind: "password",
visible: (v) => v.authKind === "password",
...credentialHint,
},
{
name: "apiKey",
label: "API Key",
kind: "password",
placeholder: "qbt_…",
visible: (v) => v.authKind === "api_key",
...credentialHint,
},
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" },
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
{ name: "enabled", label: "启用", kind: "switch" },
@@ -130,8 +164,10 @@ function DownloadersTable() {
initial: {
name: "",
baseUrl: "",
authKind: "password",
username: "",
credential: "",
password: "",
apiKey: "",
managedTag: "umt",
cacheVolumeKey: "",
enabled: true,
@@ -139,8 +175,10 @@ function DownloadersTable() {
fromRow: (r) => ({
name: r.name,
baseUrl: r.baseUrl,
authKind: authKindOf(r),
username: r.username ?? "",
credential: "",
password: "",
apiKey: "",
managedTag: r.managedTag,
cacheVolumeKey: r.cacheVolumeKey ?? "",
enabled: r.enabled,
@@ -149,8 +187,10 @@ function DownloadersTable() {
name: str(v.name),
kind: mode === "create" ? "qbittorrent" : undefined,
baseUrl: str(v.baseUrl),
username: nullStr(v.username),
credential: optStr(v.credential),
authKind: str(v.authKind),
username: v.authKind === "password" ? nullStr(v.username) : null,
credential:
v.authKind === "password" ? optStr(v.password) : v.authKind === "api_key" ? optStr(v.apiKey) : undefined,
managedTag: optStr(v.managedTag),
cacheVolumeKey: nullStr(v.cacheVolumeKey),
enabled: v.enabled === true,