Files
felhom-controller/controller/internal/upwatch/explain.go
T
admin a9c1997fbc
gates / gates (push) Successful in 1m17s
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)
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.
2026-10-23 21:26:09 +02:00

306 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}