v0.342.0 (unreleased): the upload and CPU watch — an app that sends or burns far more than its own numbers explain is one operator-only alarm; the line learnt from the off-site push; qBittorrent's default upload limit (never over the household's)
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.
This commit is contained in:
2026-10-23 21:26:09 +02:00
parent 277e99839d
commit a9c1997fbc
35 changed files with 2438 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
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
}