fix: make qbittorrent login compatible

This commit is contained in:
ShukeBta
2026-05-29 04:15:44 +08:00
parent 2d90d9cba5
commit b7ea68a67c
8 changed files with 170 additions and 115 deletions
+1 -36
View File
@@ -8,9 +8,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
@@ -130,40 +128,7 @@ func (s *DownloadClientService) Test(ctx context.Context, id string) error {
}
switch c.Type {
case "qbittorrent":
host := strings.TrimRight(c.Host, "/")
body := url.Values{}
body.Set("username", c.Username)
body.Set("password", c.Password)
req, _ := http.NewRequestWithContext(
ctx, http.MethodPost,
host+"/api/v2/auth/login",
strings.NewReader(body.Encode()),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// qBittorrent v4.6+ 默认开启 "host header validation" 并要求
// Referer 与 Host 同源,否则即使账户正确也会拒绝登录。
req.Header.Set("Referer", host)
req.Header.Set("Origin", host)
req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
return errors.New("qbittorrent: 403 — 用户名/密码错误,或 WebUI 启用了 IP 封禁")
}
if resp.StatusCode >= 400 {
return fmt.Errorf("qbittorrent returned %d", resp.StatusCode)
}
// 即使返回 200,body 内容仍可能是 "Fails." 表示登录失败。
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
text := strings.TrimSpace(string(raw))
if text == "Fails." {
return errors.New("qbittorrent: 用户名/密码错误")
}
// 正确响应是 "Ok." — 但部分版本会重定向或返回空体,不强校验。
return nil
return qbitLogin(ctx, s.client, c.Host, c.Username, c.Password)
case "aria2", "transmission":
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Host, nil)
resp, err := s.client.Do(req)
+1 -30
View File
@@ -96,36 +96,7 @@ func (q *QBitClient) Login(ctx context.Context) error {
if q.cfg.BaseURL == "" {
return errors.New("qbittorrent base url not configured")
}
form := url.Values{}
form.Set("username", q.cfg.Username)
form.Set("password", q.cfg.Password)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(q.cfg.BaseURL, "/")+"/api/v2/auth/login",
strings.NewReader(form.Encode()),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
baseURL := strings.TrimRight(q.cfg.BaseURL, "/")
req.Header.Set("Referer", baseURL)
req.Header.Set("Origin", baseURL)
req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := q.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
text := strings.TrimSpace(string(body))
if resp.StatusCode == http.StatusForbidden {
return errors.New("qbittorrent login forbidden: check username/password, WebUI IP ban, CSRF/Host header validation, and allowed subnets")
}
if resp.StatusCode >= 400 || text != "Ok." {
return fmt.Errorf("qbittorrent login failed: status=%d body=%s", resp.StatusCode, text)
}
return nil
return qbitLogin(ctx, q.client, q.cfg.BaseURL, q.cfg.Username, q.cfg.Password)
}
// AddTorrent submits a magnet URL or HTTP(S) URL to qBittorrent.
+1 -25
View File
@@ -273,33 +273,9 @@ func (a *QBitAdapter) loginLocked(ctx context.Context) error {
if a.cfg.Host == "" {
return fmt.Errorf("qbittorrent host not configured")
}
form := url.Values{}
form.Set("username", a.cfg.Username)
form.Set("password", a.cfg.Password)
baseURL := strings.TrimRight(a.cfg.Host, "/")
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/api/v2/auth/login", strings.NewReader(form.Encode()))
if err != nil {
if err := qbitLogin(ctx, a.client, a.cfg.Host, a.cfg.Username, a.cfg.Password); err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", baseURL)
req.Header.Set("Origin", baseURL)
req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
text := strings.TrimSpace(string(body))
if resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("qbittorrent login forbidden: check username/password, WebUI IP ban, CSRF/Host header validation, and allowed subnets")
}
if resp.StatusCode >= 400 || text != "Ok." {
return fmt.Errorf("qbittorrent login failed: status=%d body=%s", resp.StatusCode, text)
}
a.LoggedIn = true
return nil
}
+80
View File
@@ -0,0 +1,80 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
type qbitLoginVariant struct {
name string
referer bool
origin bool
}
func qbitLogin(ctx context.Context, client *http.Client, baseURL, username, password string) error {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return errors.New("qbittorrent host not configured")
}
var lastErr error
for _, variant := range []qbitLoginVariant{
{name: "minimal"},
{name: "referer", referer: true},
{name: "referer-origin", referer: true, origin: true},
} {
err := qbitLoginOnce(ctx, client, baseURL, username, password, variant)
if err == nil {
return nil
}
lastErr = err
if errors.Is(err, errQbitBadCredentials) {
return err
}
}
return lastErr
}
var errQbitBadCredentials = errors.New("qbittorrent: 用户名/密码错误")
func qbitLoginOnce(ctx context.Context, client *http.Client, baseURL, username, password string, variant qbitLoginVariant) error {
form := url.Values{}
form.Set("username", username)
form.Set("password", password)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/api/v2/auth/login", strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if variant.referer {
req.Header.Set("Referer", baseURL)
}
if variant.origin {
req.Header.Set("Origin", baseURL)
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
text := strings.TrimSpace(string(raw))
switch {
case resp.StatusCode == http.StatusOK && text == "Ok.":
return nil
case resp.StatusCode == http.StatusOK && text == "Fails.":
return errQbitBadCredentials
case resp.StatusCode == http.StatusForbidden:
return fmt.Errorf("qbittorrent: 403 forbidden during %s login, body=%q; check qBittorrent WebUI bypass/auth settings for the container IP and host %s", variant.name, text, baseURL)
case resp.StatusCode >= 400:
return fmt.Errorf("qbittorrent login failed during %s login: status=%d body=%q", variant.name, resp.StatusCode, text)
default:
return fmt.Errorf("qbittorrent login unexpected response during %s login: status=%d body=%q", variant.name, resp.StatusCode, text)
}
}
+63
View File
@@ -5,6 +5,7 @@ import (
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strings"
"sync/atomic"
@@ -14,6 +15,68 @@ import (
"go.uber.org/zap"
)
func TestQBitLoginUsesMinimalRequestFirst(t *testing.T) {
var loginAttempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
loginAttempts.Add(1)
if r.Header.Get("Origin") != "" || r.Header.Get("Referer") != "" {
http.Error(w, "unexpected csrf headers", http.StatusForbidden)
return
}
_, _ = w.Write([]byte("Ok."))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewQBitClient(zap.NewNop(), QBitConfig{
BaseURL: server.URL,
Username: "admin",
Password: "adminadmin",
})
if err := client.Login(context.Background()); err != nil {
t.Fatalf("expected minimal login to succeed: %v", err)
}
if loginAttempts.Load() != 1 {
t.Fatalf("login attempts = %d, want 1", loginAttempts.Load())
}
}
func TestQBitLoginRetriesWithRefererWhenRequired(t *testing.T) {
var loginAttempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
loginAttempts.Add(1)
if r.Header.Get("Referer") == "" {
http.Error(w, "missing referer", http.StatusForbidden)
return
}
if r.Header.Get("Origin") != "" {
http.Error(w, "origin blocked", http.StatusForbidden)
return
}
_, _ = w.Write([]byte("Ok."))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
jar, _ := cookiejar.New(nil)
httpClient := &http.Client{Jar: jar}
if err := qbitLogin(context.Background(), httpClient, server.URL, "admin", "adminadmin"); err != nil {
t.Fatalf("expected referer retry to succeed: %v", err)
}
if loginAttempts.Load() != 2 {
t.Fatalf("login attempts = %d, want 2", loginAttempts.Load())
}
}
func TestQBitAddTorrentRequiresVisibleNewTask(t *testing.T) {
oldAttempts := qbitAddVerifyAttempts
oldInterval := qbitAddVerifyInterval