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.
306 lines
11 KiB
Go
306 lines
11 KiB
Go
package upwatch
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Report is what one app says about its own work right now.
|
||
type Report struct {
|
||
// UploadCounter is the app's own cumulative count of bytes it uploaded (qBittorrent `up_info_data`, Transmission
|
||
// `current-stats.uploadedBytes`). It restarts at zero with the app; the watcher treats a drop as a restart.
|
||
UploadCounter uint64
|
||
HasUploadCounter bool
|
||
// Viewers is the number of playing sessions (Plex `/status/sessions` size). A viewer EXPLAINS upload of any size:
|
||
// the per-session bandwidth was not measured in the spike (no claimed server with a remote player tonight), so
|
||
// the rule is deliberately the conservative half — no viewer and a big upload is the Plex2Shell signature.
|
||
Viewers int
|
||
HasViewerCount bool
|
||
// Transcodes is CPU work the app reports (Plex `/transcode/sessions` size). Any transcode explains the CPU.
|
||
Transcodes int
|
||
HasTranscodeCount bool
|
||
// Limit is the app's current global upload limit in bytes/s (0 = none); HasLimit when the app has one to read.
|
||
Limit int64
|
||
HasLimit bool
|
||
}
|
||
|
||
// Explainer asks one app about its own work.
|
||
type Explainer interface {
|
||
Stack() string
|
||
Ask(ctx context.Context) (Report, error)
|
||
}
|
||
|
||
// Box is what an explainer needs outside HTTP — the mediawire seams (`docker exec printenv`, the Plex token read at
|
||
// use). Production: mediawire.DockerBox.
|
||
type Box interface {
|
||
Env(container, name string) (string, error)
|
||
Running(container string) bool
|
||
}
|
||
|
||
// httpDo is one request to an app's own container. Small and local on purpose: mediawire's helpers are methods of
|
||
// its Wirer, which also writes; this package only reads (and sets one limit, qblimit.go).
|
||
func httpDo(ctx context.Context, c *http.Client, method, u string, hdr map[string]string, body []byte, ctype string) (int, []byte, http.Header, error) {
|
||
req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(body))
|
||
if err != nil {
|
||
return 0, nil, nil, err
|
||
}
|
||
for k, v := range hdr {
|
||
if strings.EqualFold(k, "Host") {
|
||
req.Host = v
|
||
continue
|
||
}
|
||
req.Header.Set(k, v)
|
||
}
|
||
if ctype != "" {
|
||
req.Header.Set("Content-Type", ctype)
|
||
}
|
||
resp, err := c.Do(req)
|
||
if err != nil {
|
||
return 0, nil, nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||
return resp.StatusCode, b, resp.Header, nil
|
||
}
|
||
|
||
// ── qBittorrent ──────────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
// QBittorrent asks qBittorrent's Web API. Measured on 9202 (qBittorrent 5.2.4, WebAPI 2.15.1): while seeding, the
|
||
// container sent 1.03–1.07 × what `up_info_data` counted (protocol overhead); `transfer/uploadLimit` and
|
||
// `app/preferences.up_limit` are the same value, persisted across a restart (`Session\GlobalUPSpeedLimit`, KiB/s).
|
||
type QBittorrent struct {
|
||
Box Box
|
||
URL string // default http://qbittorrent:8080 (the container's name on the docker network, as mediawire calls it)
|
||
HTTP *http.Client
|
||
|
||
sess map[string]string // the session cookie, kept between minutes (a login a minute would fill qBittorrent's log)
|
||
}
|
||
|
||
func (q *QBittorrent) Stack() string { return "qbittorrent" }
|
||
|
||
func (q *QBittorrent) base() string {
|
||
if q.URL != "" {
|
||
return q.URL
|
||
}
|
||
return "http://qbittorrent:8080"
|
||
}
|
||
|
||
func (q *QBittorrent) client() *http.Client {
|
||
if q.HTTP != nil {
|
||
return q.HTTP
|
||
}
|
||
return &http.Client{Timeout: 20 * time.Second}
|
||
}
|
||
|
||
var qbCookieRE = regexp.MustCompile(`((?:QBT_)?SID[A-Za-z0-9_]*)=([^;]+)`)
|
||
|
||
// login returns the headers of an authenticated session. The password is the catalog's QBT_ADMIN_PASSWORD, read at
|
||
// use; Referer must equal the base (qBittorrent's CSRF check — a mismatch is a 401, measured).
|
||
func (q *QBittorrent) login(ctx context.Context) (map[string]string, error) {
|
||
pw, err := q.Box.Env("qbittorrent", "QBT_ADMIN_PASSWORD")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
form := url.Values{"username": {"admin"}, "password": {pw}}.Encode()
|
||
code, _, hd, err := httpDo(ctx, q.client(), "POST", q.base()+"/api/v2/auth/login", map[string]string{"Referer": q.base()}, []byte(form), "application/x-www-form-urlencoded")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
m := qbCookieRE.FindStringSubmatch(strings.Join(hd.Values("Set-Cookie"), "; "))
|
||
if (code != 200 && code != 204) || m == nil { // 5.x answers a good login 204
|
||
return nil, fmt.Errorf("qBittorrent login: HTTP %d", code)
|
||
}
|
||
return map[string]string{"Referer": q.base(), "Cookie": m[1] + "=" + m[2]}, nil
|
||
}
|
||
|
||
// session returns the kept session, logging in when there is none or when fresh is set.
|
||
func (q *QBittorrent) session(ctx context.Context, fresh bool) (map[string]string, error) {
|
||
if q.sess != nil && !fresh {
|
||
return q.sess, nil
|
||
}
|
||
h, err := q.login(ctx)
|
||
if err != nil {
|
||
q.sess = nil
|
||
return nil, err
|
||
}
|
||
q.sess = h
|
||
return h, nil
|
||
}
|
||
|
||
func (q *QBittorrent) Ask(ctx context.Context) (Report, error) {
|
||
h, err := q.session(ctx, false)
|
||
if err != nil {
|
||
return Report{}, err
|
||
}
|
||
code, b, _, err := httpDo(ctx, q.client(), "GET", q.base()+"/api/v2/transfer/info", h, nil, "")
|
||
if err == nil && code == 403 { // the session expired (qBittorrent restarted, or its 1-hour timeout): once more
|
||
if h, err = q.session(ctx, true); err != nil {
|
||
return Report{}, err
|
||
}
|
||
code, b, _, err = httpDo(ctx, q.client(), "GET", q.base()+"/api/v2/transfer/info", h, nil, "")
|
||
}
|
||
if err != nil || code != 200 {
|
||
return Report{}, fmt.Errorf("qBittorrent transfer/info: HTTP %d %v", code, err)
|
||
}
|
||
var ti struct {
|
||
UpInfoData uint64 `json:"up_info_data"`
|
||
}
|
||
if err := json.Unmarshal(b, &ti); err != nil {
|
||
return Report{}, err
|
||
}
|
||
r := Report{UploadCounter: ti.UpInfoData, HasUploadCounter: true}
|
||
if lim, err := q.UploadLimit(ctx, h); err == nil {
|
||
r.Limit, r.HasLimit = lim, true
|
||
}
|
||
return r, nil
|
||
}
|
||
|
||
// UploadLimit reads the global upload limit (bytes/s, 0 = none). h nil → logs in.
|
||
func (q *QBittorrent) UploadLimit(ctx context.Context, h map[string]string) (int64, error) {
|
||
if h == nil {
|
||
var err error
|
||
if h, err = q.session(ctx, true); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
code, b, _, err := httpDo(ctx, q.client(), "GET", q.base()+"/api/v2/transfer/uploadLimit", h, nil, "")
|
||
if err != nil || code != 200 {
|
||
return 0, fmt.Errorf("qBittorrent uploadLimit: HTTP %d %v", code, err)
|
||
}
|
||
var v int64
|
||
if _, err := fmt.Sscan(strings.TrimSpace(string(b)), &v); err != nil {
|
||
return 0, fmt.Errorf("qBittorrent uploadLimit: %q", strings.TrimSpace(string(b)))
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// SetUploadLimit sets the global upload limit (bytes/s) and returns the value qBittorrent stored (it keeps KiB/s, so
|
||
// 2 500 000 reads back as 2 499 584 — measured).
|
||
func (q *QBittorrent) SetUploadLimit(ctx context.Context, bytesPerSec int64) (int64, error) {
|
||
h, err := q.session(ctx, true)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
form := url.Values{"limit": {fmt.Sprint(bytesPerSec)}}.Encode()
|
||
code, _, _, err := httpDo(ctx, q.client(), "POST", q.base()+"/api/v2/transfer/setUploadLimit", h, []byte(form), "application/x-www-form-urlencoded")
|
||
if err != nil || code != 200 {
|
||
return 0, fmt.Errorf("qBittorrent setUploadLimit: HTTP %d %v", code, err)
|
||
}
|
||
return q.UploadLimit(ctx, h)
|
||
}
|
||
|
||
// ── Transmission ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
// Transmission asks Transmission's RPC (4.1.3 on 9202): `session-stats.current-stats.uploadedBytes` matched the
|
||
// container's sent bytes within 0.1–2 % while seeding (measured).
|
||
type Transmission struct {
|
||
Box Box
|
||
URL string // default http://transmission:9091
|
||
HTTP *http.Client
|
||
}
|
||
|
||
func (t *Transmission) Stack() string { return "transmission" }
|
||
|
||
func (t *Transmission) Ask(ctx context.Context) (Report, error) {
|
||
pw, err := t.Box.Env("transmission", "PASS")
|
||
if err != nil {
|
||
return Report{}, err
|
||
}
|
||
base := t.URL
|
||
if base == "" {
|
||
base = "http://transmission:9091"
|
||
}
|
||
c := t.HTTP
|
||
if c == nil {
|
||
c = &http.Client{Timeout: 20 * time.Second}
|
||
}
|
||
h := map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("admin:"+pw)), "Host": "transmission:9091"}
|
||
body := []byte(`{"method":"session-stats"}`)
|
||
for i := 0; i < 2; i++ {
|
||
code, b, hd, err := httpDo(ctx, c, "POST", base+"/transmission/rpc", h, body, "application/json")
|
||
if err != nil {
|
||
return Report{}, err
|
||
}
|
||
if code == 409 {
|
||
h["X-Transmission-Session-Id"] = hd.Get("X-Transmission-Session-Id")
|
||
continue
|
||
}
|
||
if code != 200 {
|
||
return Report{}, fmt.Errorf("transmission session-stats: HTTP %d", code)
|
||
}
|
||
var st struct {
|
||
Arguments struct {
|
||
Current struct {
|
||
UploadedBytes uint64 `json:"uploadedBytes"`
|
||
} `json:"current-stats"`
|
||
} `json:"arguments"`
|
||
}
|
||
if err := json.Unmarshal(b, &st); err != nil {
|
||
return Report{}, err
|
||
}
|
||
return Report{UploadCounter: st.Arguments.Current.UploadedBytes, HasUploadCounter: true}, nil
|
||
}
|
||
return Report{}, fmt.Errorf("transmission session-stats: no session id")
|
||
}
|
||
|
||
// ── Plex ─────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
// Plex asks Plex Media Server for its playing and transcoding sessions. Measured on 9202 (1.43.5, unclaimed): both
|
||
// answer `{"MediaContainer":{"size":0}}` without a token from the docker network; a CLAIMED server needs its own
|
||
// PlexOnlineToken, read from Preferences.xml at use exactly as mediawire does (`09` §3 decision 248).
|
||
type Plex struct {
|
||
Token func() string // mediawire.PlexToken(box); "" for an unclaimed server
|
||
URL func(port int) (string, error) // mediawire.PlexURL (Plex refuses a call by NAME as non-local, R-1060)
|
||
HTTP *http.Client
|
||
}
|
||
|
||
func (p *Plex) Stack() string { return "plex" }
|
||
|
||
func (p *Plex) Ask(ctx context.Context) (Report, error) {
|
||
base, err := p.URL(32400)
|
||
if err != nil {
|
||
return Report{}, err
|
||
}
|
||
c := p.HTTP
|
||
if c == nil {
|
||
c = &http.Client{Timeout: 20 * time.Second}
|
||
}
|
||
h := map[string]string{"Accept": "application/json"}
|
||
if tok := p.Token(); tok != "" {
|
||
h["X-Plex-Token"] = tok
|
||
}
|
||
size := func(path string) (int, error) {
|
||
code, b, _, err := httpDo(ctx, c, "GET", base+path, h, nil, "")
|
||
if err != nil || code != 200 {
|
||
return 0, fmt.Errorf("plex %s: HTTP %d %v", path, code, err)
|
||
}
|
||
var mc struct {
|
||
MediaContainer struct {
|
||
Size int `json:"size"`
|
||
} `json:"MediaContainer"`
|
||
}
|
||
if err := json.Unmarshal(b, &mc); err != nil {
|
||
return 0, err
|
||
}
|
||
return mc.MediaContainer.Size, nil
|
||
}
|
||
v, err := size("/status/sessions")
|
||
if err != nil {
|
||
return Report{}, err
|
||
}
|
||
tr, err := size("/transcode/sessions")
|
||
if err != nil {
|
||
return Report{}, err
|
||
}
|
||
return Report{Viewers: v, HasViewerCount: true, Transcodes: tr, HasTranscodeCount: true}, nil
|
||
}
|