diff --git a/README.md b/README.md index a12bbf3..734ad3c 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' # 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.9 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.10 MEDIASTATION_HTTP_PORT=18080 # 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。 @@ -298,7 +298,7 @@ vim docker-compose.yml # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.9 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.10 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -485,7 +485,7 @@ docker compose up -d ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.9 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.10 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -731,26 +731,26 @@ cd MediaStationGo | 平台 | 包名示例 | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.9-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.9-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.9-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.9-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.9-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.10-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.10-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.10-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.10-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.10-darwin-arm64.tar.gz` | 部署步骤: ```bash # Linux 示例 -tar -xzf MediaStationGo-v0.0.9-linux-amd64.tar.gz -cd MediaStationGo-v0.0.9-linux-amd64 +tar -xzf MediaStationGo-v0.0.10-linux-amd64.tar.gz +cd MediaStationGo-v0.0.10-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows: ```powershell -Expand-Archive .\MediaStationGo-v0.0.9-windows-amd64.zip -cd .\MediaStationGo-v0.0.9-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.10-windows-amd64.zip +cd .\MediaStationGo-v0.0.10-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/README_EN.md b/README_EN.md index 43d93bb..588d0bd 100644 --- a/README_EN.md +++ b/README_EN.md @@ -228,7 +228,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.9 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.10 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -322,7 +322,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.9 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.10 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -554,25 +554,25 @@ Each release provides multi-platform archives: | Platform | Package example | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.9-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.9-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.9-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.9-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.9-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.10-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.10-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.10-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.10-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.10-darwin-arm64.tar.gz` | Linux example: ```bash -tar -xzf MediaStationGo-v0.0.9-linux-amd64.tar.gz -cd MediaStationGo-v0.0.9-linux-amd64 +tar -xzf MediaStationGo-v0.0.10-linux-amd64.tar.gz +cd MediaStationGo-v0.0.10-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows example: ```powershell -Expand-Archive .\MediaStationGo-v0.0.9-windows-amd64.zip -cd .\MediaStationGo-v0.0.9-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.10-windows-amd64.zip +cd .\MediaStationGo-v0.0.10-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/docker-compose.yml b/docker-compose.yml index ff27a96..04b1875 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.9 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.10 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 diff --git a/internal/service/download_clients.go b/internal/service/download_clients.go index 7810d2d..e8e3799 100644 --- a/internal/service/download_clients.go +++ b/internal/service/download_clients.go @@ -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) diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go index 80638cc..25e54a4 100644 --- a/internal/service/qbittorrent.go +++ b/internal/service/qbittorrent.go @@ -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. diff --git a/internal/service/qbittorrent_adp.go b/internal/service/qbittorrent_adp.go index 46822ff..49d18e3 100644 --- a/internal/service/qbittorrent_adp.go +++ b/internal/service/qbittorrent_adp.go @@ -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 } diff --git a/internal/service/qbittorrent_login.go b/internal/service/qbittorrent_login.go new file mode 100644 index 0000000..24f6482 --- /dev/null +++ b/internal/service/qbittorrent_login.go @@ -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) + } +} diff --git a/internal/service/qbittorrent_test.go b/internal/service/qbittorrent_test.go index 805b2eb..b4124d0 100644 --- a/internal/service/qbittorrent_test.go +++ b/internal/service/qbittorrent_test.go @@ -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