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