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,77 @@
|
||||
package upwatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── qBittorrent's default upload limit (operator ruling 2026-10-23 19:22; the size is decide row R-1245) ─────────
|
||||
//
|
||||
// qBittorrent seeds at whatever the line gives (measured on 9202: 131 Mbit/s until the receiver capped it), so a
|
||||
// family's uploads — a video call, a photo sync — wait behind it. The box sets qBittorrent's GLOBAL upload limit to a
|
||||
// share of the measured line, and leaves it alone the moment the household touches it:
|
||||
//
|
||||
// - never set by the box, qBittorrent unlimited (0) → the box sets it
|
||||
// - never set by the box, qBittorrent already has a limit → the household's: never touched
|
||||
// - set by the box, qBittorrent still holds exactly that value → the box's: re-set when the line moves > 20 %
|
||||
// - set by the box, qBittorrent holds anything else (0 included) → the household changed it: never touched again
|
||||
//
|
||||
// The value compared is the one qBittorrent STORED (it keeps KiB/s: 2 500 000 reads back as 2 499 584, measured), so
|
||||
// the box's own rounding is never mistaken for a household's change. The limit survives a qBittorrent restart
|
||||
// (measured: `Session\GlobalUPSpeedLimit` in its config). No line measured yet → nothing is set.
|
||||
//
|
||||
// Edge: an app reinstalled from scratch starts unlimited while the record still says „set by the box"; that reads as
|
||||
// the household removing the limit and is left alone. Recorded in the design (`08` §9.7), not guarded.
|
||||
|
||||
// QBLimitState is what the box remembers (settings.json `upwatch.qb_limit`).
|
||||
type QBLimitState struct {
|
||||
BoxSetBytes int64 `json:"box_set_bytes,omitempty"` // the value qBittorrent stored when the box last set it
|
||||
LineMbps float64 `json:"line_mbps,omitempty"` // the line the box set it from
|
||||
SetAt string `json:"set_at,omitempty"`
|
||||
Household bool `json:"household,omitempty"` // the household owns the limit: never touched again
|
||||
HouseholdAt string `json:"household_at,omitempty"`
|
||||
}
|
||||
|
||||
// QBLimiter is the qBittorrent side the rule needs (the QBittorrent explainer satisfies it).
|
||||
type QBLimiter interface {
|
||||
UploadLimit(ctx context.Context, h map[string]string) (int64, error)
|
||||
SetUploadLimit(ctx context.Context, bytesPerSec int64) (int64, error)
|
||||
}
|
||||
|
||||
// QBLimitTarget is share × line, in bytes/s.
|
||||
func QBLimitTarget(lineMbps, share float64) int64 {
|
||||
return int64(math.Round(lineMbps * share * 1e6 / 8))
|
||||
}
|
||||
|
||||
// ApplyQBLimit runs the rule once. It returns the new state and what happened ("set", "kept", "household", "no-line",
|
||||
// "error"). It never lowers or raises a limit the household owns.
|
||||
func ApplyQBLimit(ctx context.Context, q QBLimiter, st QBLimitState, lineMbps float64, lineKnown bool, share float64, now time.Time) (QBLimitState, string, error) {
|
||||
if st.Household {
|
||||
return st, "household", nil
|
||||
}
|
||||
cur, err := q.UploadLimit(ctx, nil)
|
||||
if err != nil {
|
||||
return st, "error", err
|
||||
}
|
||||
if st.BoxSetBytes == 0 {
|
||||
if cur != 0 {
|
||||
st.Household, st.HouseholdAt = true, now.UTC().Format(time.RFC3339)
|
||||
return st, "household", nil
|
||||
}
|
||||
} else if cur != st.BoxSetBytes {
|
||||
st.Household, st.HouseholdAt = true, now.UTC().Format(time.RFC3339)
|
||||
return st, "household", nil
|
||||
} else if lineKnown && st.LineMbps > 0 && math.Abs(lineMbps-st.LineMbps)/st.LineMbps <= 0.2 {
|
||||
return st, "kept", nil
|
||||
}
|
||||
if !lineKnown {
|
||||
return st, "no-line", nil
|
||||
}
|
||||
stored, err := q.SetUploadLimit(ctx, QBLimitTarget(lineMbps, share))
|
||||
if err != nil {
|
||||
return st, "error", err
|
||||
}
|
||||
st.BoxSetBytes, st.LineMbps, st.SetAt = stored, lineMbps, now.UTC().Format(time.RFC3339)
|
||||
return st, "set", nil
|
||||
}
|
||||
Reference in New Issue
Block a user