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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user