a9c1997fbc
gates / gates (push) Successful in 1m17s
internal/upwatch: exact per-container counters from the Docker Engine API, per-app judging (upload >= max(5 Mbit/s, 25% of the line) for 15 min; CPU >= max(150%, 50% x cores) for 30 min), explainers qBittorrent / Transmission / Plex, app_unexplained_upload / app_unexplained_cpu + _cleared (hub 0.169.0). The off-site push is sampled once a second for the line (a lower bound). qBittorrent limit = 50% of the line, the household's limit always wins. 20 red-proofs. Spike + design: felhom.eu audits/SPIKE-upload-watch-2026-10-23.md, architecture/08 section 9. Not released.
141 lines
4.8 KiB
Go
141 lines
4.8 KiB
Go
package upwatch
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type envBox map[string]string
|
|
|
|
func (b envBox) Env(c, n string) (string, error) {
|
|
if v, ok := b[c+"/"+n]; ok {
|
|
return v, nil
|
|
}
|
|
return "", fmt.Errorf("no %s in %s", n, c)
|
|
}
|
|
func (b envBox) Running(string) bool { return true }
|
|
|
|
// qBittorrent as measured on 9202: a login is 204 + a SID cookie, and only when Referer equals the base; transfer/info
|
|
// carries up_info_data; a stale session is 403.
|
|
func fakeQBServer(t *testing.T, pw string, logins *int, expireNext *bool, limit *int64) *httptest.Server {
|
|
var srv *httptest.Server
|
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v2/auth/login":
|
|
r.ParseForm()
|
|
if r.Header.Get("Referer") != srv.URL || r.Form.Get("username") != "admin" || r.Form.Get("password") != pw {
|
|
w.WriteHeader(401)
|
|
return
|
|
}
|
|
*logins++
|
|
http.SetCookie(w, &http.Cookie{Name: "QBT_SID_8080", Value: fmt.Sprint("s", *logins)})
|
|
w.WriteHeader(204)
|
|
case "/api/v2/transfer/info":
|
|
if *expireNext {
|
|
*expireNext = false
|
|
w.WriteHeader(403)
|
|
return
|
|
}
|
|
if !strings.HasPrefix(r.Header.Get("Cookie"), "QBT_SID_8080=") {
|
|
w.WriteHeader(403)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"up_info_data":123456789,"up_info_speed":16384000,"up_rate_limit":0}`)
|
|
case "/api/v2/transfer/uploadLimit":
|
|
fmt.Fprint(w, *limit)
|
|
case "/api/v2/transfer/setUploadLimit":
|
|
r.ParseForm()
|
|
var v int64
|
|
fmt.Sscan(r.Form.Get("limit"), &v)
|
|
*limit = v / 1024 * 1024
|
|
w.WriteHeader(200)
|
|
default:
|
|
w.WriteHeader(404)
|
|
}
|
|
}))
|
|
return srv
|
|
}
|
|
|
|
// RED-PROOF: send no Referer → 401 → fails; log in every minute instead of keeping the session → logins == 3 → fails.
|
|
func TestQBittorrent_AskKeepsTheSessionAndReLogsInOn403(t *testing.T) {
|
|
logins, expire, limit := 0, false, int64(0)
|
|
srv := fakeQBServer(t, "pw-canary", &logins, &expire, &limit)
|
|
defer srv.Close()
|
|
q := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL}
|
|
for i := 0; i < 3; i++ {
|
|
r, err := q.Ask(context.Background())
|
|
if err != nil || !r.HasUploadCounter || r.UploadCounter != 123456789 || !r.HasLimit {
|
|
t.Fatalf("ask %d: %+v %v", i, r, err)
|
|
}
|
|
}
|
|
if logins != 1 {
|
|
t.Fatalf("logins = %d for three minutes — the session must be kept", logins)
|
|
}
|
|
expire = true
|
|
if _, err := q.Ask(context.Background()); err != nil || logins != 2 {
|
|
t.Fatalf("after a 403: err=%v logins=%d — want one fresh login", err, logins)
|
|
}
|
|
got, err := q.SetUploadLimit(context.Background(), 2500000)
|
|
if err != nil || got != 2499584 {
|
|
t.Fatalf("SetUploadLimit = %d %v, want the stored 2499584", got, err)
|
|
}
|
|
}
|
|
|
|
// Transmission's RPC: a 409 hands out the session id; Basic auth with the catalog's PASS.
|
|
func TestTransmission_AskFollowsTheSessionID(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:tr-pw")) {
|
|
w.WriteHeader(401)
|
|
return
|
|
}
|
|
if r.Header.Get("X-Transmission-Session-Id") != "sid-1" {
|
|
w.Header().Set("X-Transmission-Session-Id", "sid-1")
|
|
w.WriteHeader(409)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"arguments":{"current-stats":{"uploadedBytes":987654321},"uploadSpeed":0},"result":"success"}`)
|
|
}))
|
|
defer srv.Close()
|
|
tr := &Transmission{Box: envBox{"transmission/PASS": "tr-pw"}, URL: srv.URL}
|
|
r, err := tr.Ask(context.Background())
|
|
if err != nil || r.UploadCounter != 987654321 || !r.HasUploadCounter {
|
|
t.Fatalf("Ask = %+v %v", r, err)
|
|
}
|
|
}
|
|
|
|
// Plex: the token goes along only when the server is claimed; both session lists are read.
|
|
// RED-PROOF: always send the header → the unclaimed case sends "X-Plex-Token: " → fails.
|
|
func TestPlex_AskReadsSessionsWithTheTokenOnlyWhenClaimed(t *testing.T) {
|
|
var seen []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, has := r.Header["X-Plex-Token"]
|
|
seen = append(seen, fmt.Sprintf("%s token=%v", r.URL.Path, has))
|
|
switch r.URL.Path {
|
|
case "/status/sessions":
|
|
fmt.Fprint(w, `{"MediaContainer":{"size":2}}`)
|
|
case "/transcode/sessions":
|
|
fmt.Fprint(w, `{"MediaContainer":{"size":1}}`)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
for _, tok := range []string{"", "plex-token-canary"} {
|
|
seen = nil
|
|
p := &Plex{Token: func() string { return tok }, URL: func(int) (string, error) { return srv.URL, nil }}
|
|
r, err := p.Ask(context.Background())
|
|
if err != nil || r.Viewers != 2 || r.Transcodes != 1 || !r.HasViewerCount || !r.HasTranscodeCount {
|
|
t.Fatalf("Ask = %+v %v", r, err)
|
|
}
|
|
want := fmt.Sprintf("token=%v", tok != "")
|
|
for _, s := range seen {
|
|
if !strings.HasSuffix(s, want) {
|
|
t.Fatalf("claimed=%v: request %q", tok != "", s)
|
|
}
|
|
}
|
|
}
|
|
}
|