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

@@ -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,
});