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,
|
||||
|
||||
+28
-2
@@ -246,7 +246,7 @@ services:
|
||||
# 配了 HTTP(S)_PROXY 时必须把 qbittorrent 加进 NO_PROXY:Deno 按主机名匹配,不会因解析到内网 IP 而直连
|
||||
NO_PROXY: localhost,127.0.0.1,postgres,camofox,proxy-relay,qbittorrent,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
qbittorrent:
|
||||
image: lscr.io/linuxserver/qbittorrent:5.1.2 # 5.2.x 登录返回 204 + QBT_SID_<port> cookie,当前适配器不兼容
|
||||
image: lscr.io/linuxserver/qbittorrent:5.2.4 # v4 / 5.0–5.2 都兼容(QB-1 实测 5.1.2、5.2.4)
|
||||
restart: unless-stopped
|
||||
environment: { PUID: "10001", PGID: "10001", TZ: Asia/Shanghai, WEBUI_PORT: "8080" }
|
||||
volumes:
|
||||
@@ -266,9 +266,35 @@ APP_VERSION=<当前版本> docker compose --env-file .env.deploy --profile tls u
|
||||
```
|
||||
|
||||
- 本机经 8081 访问时 qB 的 Host 头校验要求端口与 `WEBUI_PORT` 一致:curl 加 `-H 'Host: localhost:8080'`,否则 401。
|
||||
要从宿主直接用映射端口访问,就让两边端口相同(`WEBUI_PORT=18082` + `127.0.0.1:18082:18082`)。
|
||||
- 用临时密码登录后 `POST /api/v2/app/setPreferences`:`web_ui_password`(随机强密码,存本机凭据文件)、`save_path=/downloads`、
|
||||
`dht/pex/lsd/upnp=false`、`up_limit`/`dl_limit` 限速、`auto_tmm_enabled=false`、`torrent_content_layout=Original`。
|
||||
- 控制台登记:下载器实例 `http://qbittorrent:8080`、管理标签 `umt`、`settings.savePath=/downloads`;路径映射 downloader `/downloads` ↔ `/downloads`;
|
||||
自动整理要求库根为 `managed`(`register_only` 时 `no_library_root`)。
|
||||
- 模拟完成:按发布名把样片放进下载目录,用真实 piece 哈希做种子(testkit `makeTorrent` 的 pieces 是伪值,qB 校验不到 100%),
|
||||
- 模拟完成:按发布名把样片放进下载目录,用真实 piece 哈希做种子(testkit `makeTorrent({ realPieces: true })` 返回 `contents`,
|
||||
写进保存目录;缺省 pieces 是伪值,qB 校验不到 100%),
|
||||
带标签 `umt` 加入 qB,校验后即 100% 做种,`downloads.sync` 接管。
|
||||
|
||||
## 下载器认证与登录兼容(QB-1)
|
||||
|
||||
控制台 设置 → qBittorrent 实例 的「认证方式」三选一,存 `downloader_instance.settings.authKind`,凭证只写、存 `secret_ref`、不回显:
|
||||
|
||||
| 认证方式 | 凭证 | 请求 | 适用 |
|
||||
| --- | --- | --- | --- |
|
||||
| 用户名和密码(`password`) | 密码 | `POST /api/v2/auth/login` 取会话 cookie,之后带 cookie;会话失效(401/403)自动重登一次 | 所有版本 |
|
||||
| API Key(`api_key`) | WebUI 生成的 `qbt_…` | 每个请求带 `Authorization: Bearer <key>`,不调 `/auth/login` | qB 5.2+ |
|
||||
| 无认证(`none`) | 无(切到它时删除已存凭证) | 不登录、不带凭证 | qB 开了"对本地主机 / 白名单子网跳过认证"的内网 |
|
||||
|
||||
旧实例没有 `authKind`:有用户名按账密,无用户名但有凭证按 API Key,都没有按无认证(与改前行为一致)。
|
||||
API Key 在 qB 5.2+ WebUI 选项 → WebUI → API Key 处生成 / 轮换(等价 `POST /api/v2/app/rotateAPIKey`,返回 `{"apiKey":"qbt_…"}`;
|
||||
轮换后旧 Key 立即失效,记得回控制台更新)。
|
||||
|
||||
登录判定(对照 qbittorrent-api `auth_log_in`):
|
||||
|
||||
- 成功: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 "IP 已被封禁" / 429 → 限流(`rate`,默认连续 5 次失败封 1 小时,
|
||||
等封禁过期或重启 qB);API Key 错误 qB 返回 403 → `auth`;无认证被要求认证 → `auth`;5xx → `protocol`。
|
||||
- 实测矩阵(`packages/adapters/src/qbittorrent/qbittorrent.real.test.ts`,设 `QB_REAL` 才跑):5.1.2 账密 / 无认证 / 失败分类,
|
||||
5.2.4 账密 / API Key / 无认证 / 失败分类,各跑 登录 → 添加(testkit `makeTorrent({ realPieces: true })` 真实 piece 哈希,
|
||||
内容写进保存目录,qB 校验即完成)→ 查询 → 标签 → 暂停恢复 → 删除(不删数据)。
|
||||
@@ -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();
|
||||
|
||||
@@ -50,6 +50,13 @@ const Credential = z.string().min(1).max(4096);
|
||||
export const DownloaderKind = z.enum(["qbittorrent"]);
|
||||
export type DownloaderKind = z.infer<typeof DownloaderKind>;
|
||||
|
||||
/**
|
||||
* 下载器认证方式(存 settings.authKind):password 账密登录(凭证=密码);api_key qB 5.2+ WebUI 生成的 API Key
|
||||
* (凭证=Key,走 Authorization: Bearer,不登录);none 无认证(qB 对内网白名单跳过认证)。
|
||||
*/
|
||||
export const DownloaderAuthKind = z.enum(["password", "api_key", "none"]);
|
||||
export type DownloaderAuthKind = z.infer<typeof DownloaderAuthKind>;
|
||||
|
||||
export const DownloaderInstance = EntityBase.extend({
|
||||
name: z.string().min(1),
|
||||
kind: DownloaderKind,
|
||||
@@ -78,7 +85,11 @@ export const DownloaderInstanceCreate = DownloaderInstance.pick({
|
||||
settings: true,
|
||||
})
|
||||
.partial({ kind: true, username: true, managedTag: true, cacheVolumeKey: true, enabled: true, settings: true })
|
||||
.extend({ credential: Credential.optional() });
|
||||
.extend({
|
||||
credential: Credential.optional(),
|
||||
/** 认证方式,写入 settings.authKind;缺省按"有用户名 → 账密,否则有凭证 → API Key,否则无认证"推断 */
|
||||
authKind: DownloaderAuthKind.optional(),
|
||||
});
|
||||
export type DownloaderInstanceCreate = z.infer<typeof DownloaderInstanceCreate>;
|
||||
export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial();
|
||||
export type DownloaderInstanceUpdate = z.infer<typeof DownloaderInstanceUpdate>;
|
||||
|
||||
@@ -65,8 +65,14 @@ export interface MakeTorrentInput {
|
||||
pieceLength?: number;
|
||||
private?: boolean;
|
||||
announce?: string;
|
||||
/** 改变伪 pieces 内容,用于构造同名同大小但 infohash 不同的种子 */
|
||||
/** 改变伪 pieces 内容,用于构造同名同大小但 infohash 不同的种子(realPieces 时也参与确定性内容生成) */
|
||||
pieceSeed?: number;
|
||||
/**
|
||||
* 生成真实 piece hash(可被真 qB 校验通过):true 时按 pieceSeed + 文件路径生成确定性内容,
|
||||
* 也可传函数自定义每个文件的内容(长度须等于 length)。内容随返回值 contents 给出,调用方写盘后 qB 重新校验即完成。
|
||||
* 默认 false:pieces 为伪值,不可真正下载(旧行为)。
|
||||
*/
|
||||
realPieces?: boolean | ((path: string, length: number) => Uint8Array);
|
||||
}
|
||||
|
||||
export interface MadeTorrent {
|
||||
@@ -75,9 +81,28 @@ export interface MadeTorrent {
|
||||
name: string;
|
||||
totalSize: number;
|
||||
files: { path: string; length: number }[];
|
||||
/** realPieces 时:每个文件的内容(path 同 files[].path,含种子根目录) */
|
||||
contents?: { path: string; bytes: Uint8Array }[];
|
||||
}
|
||||
|
||||
/** 构造一个合法的 v1 .torrent(pieces 为伪值,不可真正下载)。 */
|
||||
/** 确定性伪随机内容(xorshift32),同 seed + path 得到同样字节 */
|
||||
export function deterministicContent(seedKey: string, length: number): Uint8Array {
|
||||
let x = 2166136261;
|
||||
for (let i = 0; i < seedKey.length; i++) x = Math.imul(x ^ seedKey.charCodeAt(i), 16777619) >>> 0;
|
||||
if (x === 0) x = 1;
|
||||
const out = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
x ^= x << 13;
|
||||
x >>>= 0;
|
||||
x ^= x >>> 17;
|
||||
x ^= x << 5;
|
||||
x >>>= 0;
|
||||
out[i] = x & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 构造一个合法的 v1 .torrent;realPieces 时 pieces 为按内容计算的真实 SHA-1,否则为伪值。 */
|
||||
export function makeTorrent(input: MakeTorrentInput): MadeTorrent {
|
||||
const pieceLength = input.pieceLength ?? 4 * 1024 * 1024;
|
||||
const info: { [k: string]: BVal } = { name: input.name, "piece length": pieceLength };
|
||||
@@ -90,13 +115,46 @@ export function makeTorrent(input: MakeTorrentInput): MadeTorrent {
|
||||
files = [{ path: input.name, length: info.length as number }];
|
||||
}
|
||||
const total = files.reduce((a, f) => a + f.length, 0);
|
||||
const pieces = Math.max(1, Math.ceil(total / pieceLength));
|
||||
info.pieces = Buffer.alloc(20 * Math.min(pieces, 4), input.pieceSeed ?? 7);
|
||||
let contents: MadeTorrent["contents"];
|
||||
if (input.realPieces) {
|
||||
const gen =
|
||||
typeof input.realPieces === "function"
|
||||
? input.realPieces
|
||||
: (path: string, length: number) => deterministicContent(`${input.pieceSeed ?? 7}:${path}`, length);
|
||||
contents = files.map((f) => {
|
||||
const bytes = gen(f.path, f.length);
|
||||
if (bytes.length !== f.length) throw new Error(`realPieces: ${f.path} 内容长度 ${bytes.length} ≠ ${f.length}`);
|
||||
return { path: f.path, bytes };
|
||||
});
|
||||
// v1:所有文件按顺序拼成一个字节流,按 piece length 切片逐片 SHA-1
|
||||
const hashes: Buffer[] = [];
|
||||
let h = createHash("sha1");
|
||||
let filled = 0;
|
||||
for (const c of contents) {
|
||||
let off = 0;
|
||||
while (off < c.bytes.length) {
|
||||
const n = Math.min(pieceLength - filled, c.bytes.length - off);
|
||||
h.update(c.bytes.subarray(off, off + n));
|
||||
off += n;
|
||||
filled += n;
|
||||
if (filled === pieceLength) {
|
||||
hashes.push(h.digest());
|
||||
h = createHash("sha1");
|
||||
filled = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filled > 0 || hashes.length === 0) hashes.push(h.digest());
|
||||
info.pieces = Buffer.concat(hashes);
|
||||
} else {
|
||||
const pieces = Math.max(1, Math.ceil(total / pieceLength));
|
||||
info.pieces = Buffer.alloc(20 * Math.min(pieces, 4), input.pieceSeed ?? 7);
|
||||
}
|
||||
if (input.private) info.private = 1;
|
||||
const root: { [k: string]: BVal } = { announce: input.announce ?? "https://tracker.example.invalid/announce", info };
|
||||
const bytes = bencode(root);
|
||||
const hash = createHash("sha1").update(bencode(info)).digest("hex");
|
||||
return { bytes, hash, name: input.name, totalSize: total, files };
|
||||
return { bytes, hash, name: input.name, totalSize: total, files, ...(contents ? { contents } : {}) };
|
||||
}
|
||||
|
||||
export function makeMagnet(hash: string, name?: string): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
deterministicContent,
|
||||
inspectTorrent as inspectTorrentFixture,
|
||||
type MadeTorrent,
|
||||
type MakeTorrentInput,
|
||||
|
||||
@@ -70,7 +70,17 @@ export interface MockQbFault {
|
||||
}
|
||||
|
||||
export interface MockQbOptions {
|
||||
version?: "4.6.7" | "5.1.0";
|
||||
/**
|
||||
* 登录行为随版本:≤5.1 成功 200 "Ok." + SID、失败 200 "Fails.";
|
||||
* 5.2.x 成功 204 空体 + QBT_SID_<port>、失败 401 "Unauthorized"。
|
||||
*/
|
||||
version?: "4.6.7" | "5.1.0" | "5.2.4";
|
||||
/** 模拟 qB"对白名单子网跳过认证":不带会话/Key 的请求也放行 */
|
||||
bypassAuth?: boolean;
|
||||
/** 连续登录失败达到该次数后封禁(403 "Your IP address has been banned…"),默认不封禁 */
|
||||
banAfterFailures?: number;
|
||||
/** 5.2.x 会话 cookie 名里的端口(真实 qB 用容器内 WebUI 端口),默认 8080 */
|
||||
webUiPort?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
apiKey?: string;
|
||||
@@ -136,6 +146,9 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
|
||||
const faults = new Map<string, MockQbFault[]>();
|
||||
const calls: MockQbCall[] = [];
|
||||
let rid = 0;
|
||||
const v52 = version.startsWith("5.2");
|
||||
const cookieName = v52 ? `QBT_SID_${opts.webUiPort ?? 8080}` : "SID";
|
||||
let loginFailures = 0;
|
||||
|
||||
const pausedState = (done: boolean) => (v5 ? (done ? "stoppedUP" : "stoppedDL") : done ? "pausedUP" : "pausedDL");
|
||||
|
||||
@@ -557,17 +570,25 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
|
||||
}
|
||||
|
||||
if (endpoint === "auth/login") {
|
||||
if (opts.banAfterFailures !== undefined && loginFailures >= opts.banAfterFailures)
|
||||
return send(403, "Your IP address has been banned after too many failed authentication attempts.");
|
||||
if (form.username === user && form.password === pass) {
|
||||
loginFailures = 0;
|
||||
const sid = randomBytes(16).toString("hex");
|
||||
sessions.add(sid);
|
||||
return send(200, "Ok.", { "set-cookie": `SID=${sid}; HttpOnly; path=/` });
|
||||
return v52
|
||||
? send(204, "", { "set-cookie": `${cookieName}=${sid}; HttpOnly; SameSite=Lax; path=/` })
|
||||
: send(200, "Ok.", { "set-cookie": `SID=${sid}; HttpOnly; path=/` });
|
||||
}
|
||||
return send(200, "Fails.");
|
||||
loginFailures++;
|
||||
return v52 ? send(401, "Unauthorized") : send(200, "Fails.");
|
||||
}
|
||||
const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
|
||||
const sid = parseCookies(req.headers.cookie).SID;
|
||||
const authed = (opts.apiKey && bearer === opts.apiKey) || (sid !== undefined && sessions.has(sid));
|
||||
if (!authed) return send(403, "Forbidden");
|
||||
const sid = parseCookies(req.headers.cookie)[cookieName];
|
||||
const authed =
|
||||
opts.bypassAuth === true || (opts.apiKey && bearer === opts.apiKey) || (sid !== undefined && sessions.has(sid));
|
||||
// 真 qB 5.2:错误的 Bearer Key → 403;没有会话 → 401
|
||||
if (!authed) return v52 && !bearer ? send(401, "Unauthorized") : send(403, "Forbidden");
|
||||
|
||||
const h = handlers[endpoint];
|
||||
if (!h) return send(404, "Not Found");
|
||||
|
||||
@@ -3,7 +3,8 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createMockQb, type MockQb } from "../src/index.ts";
|
||||
import { createMockQb, type MockQb, makeTorrent } from "../src/index.ts";
|
||||
import { bdecodeLoose } from "../src/qbittorrent/bencode.ts";
|
||||
|
||||
let mock: MockQb | null = null;
|
||||
let dir: string | null = null;
|
||||
@@ -88,3 +89,46 @@ describe("mock qB 落盘(B8)", () => {
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeTorrent realPieces", () => {
|
||||
it("按内容计算真实 piece hash:跨文件切片,片数正确,内容确定", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const t = makeTorrent({
|
||||
name: "Real.Pieces",
|
||||
pieceLength: 16384,
|
||||
files: [
|
||||
{ path: "a.mkv", length: 20000 },
|
||||
{ path: "Subs/a.ass", length: 30000 },
|
||||
],
|
||||
realPieces: true,
|
||||
});
|
||||
expect(t.contents?.map((c) => [c.path, c.bytes.length])).toEqual([
|
||||
["Real.Pieces/a.mkv", 20000],
|
||||
["Real.Pieces/Subs/a.ass", 30000],
|
||||
]);
|
||||
const all = Buffer.concat(t.contents!.map((c) => Buffer.from(c.bytes)));
|
||||
const want = Buffer.concat(
|
||||
[0, 1, 2, 3].map((i) =>
|
||||
createHash("sha1")
|
||||
.update(all.subarray(i * 16384, (i + 1) * 16384))
|
||||
.digest(),
|
||||
),
|
||||
);
|
||||
const info = (bdecodeLoose(t.bytes) as { info: { pieces: Buffer } }).info;
|
||||
expect(info.pieces.equals(want)).toBe(true);
|
||||
const again = makeTorrent({
|
||||
name: "Real.Pieces",
|
||||
pieceLength: 16384,
|
||||
files: [
|
||||
{ path: "a.mkv", length: 20000 },
|
||||
{ path: "Subs/a.ass", length: 30000 },
|
||||
],
|
||||
realPieces: true,
|
||||
});
|
||||
expect(again.hash).toBe(t.hash);
|
||||
expect(
|
||||
makeTorrent({ name: "x", length: 10, realPieces: () => new Uint8Array(10) }).contents?.[0]?.bytes,
|
||||
).toHaveLength(10);
|
||||
expect(() => makeTorrent({ name: "x", length: 10, realPieces: () => new Uint8Array(3) })).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user