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:
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user