Files
3x-ui-agent/internal/web/service/probe/traffic.go
T
Rogue 29de9761c8
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00

399 lines
13 KiB
Go

package probe
import (
"database/sql"
"math"
"strings"
"time"
)
// Traffic is one agent's whole-machine ledger row (hub Traffic): the
// running totals booked from the kernel counters of its counted
// interfaces. This is the machine-wide account (close to what providers
// bill), separate from the panel's per-inbound/per-client xray traffic.
type Traffic struct {
TotalRx int64 `json:"totalRx"`
TotalTx int64 `json:"totalTx"`
MonthRx int64 `json:"monthRx"`
MonthTx int64 `json:"monthTx"`
MonthStart string `json:"monthStart"`
DayRx int64 `json:"dayRx"`
DayTx int64 `json:"dayTx"`
DayStart string `json:"-"` // read-side gating only, as in hub
}
// MonthUsed is this period's usage as the node's plan meters it
// (hub month_used): "up" counts tx only, "down" rx only, "max" the
// larger direction, anything else (incl. "sum") both directions.
func (t Traffic) MonthUsed(mode string) int64 {
switch mode {
case "up":
return t.MonthTx
case "down":
return t.MonthRx
case "max":
if t.MonthRx > t.MonthTx {
return t.MonthRx
}
return t.MonthTx
default:
return satAdd(t.MonthRx, t.MonthTx)
}
}
func satAdd(a, b int64) int64 {
if b > 0 && a > math.MaxInt64-b {
return math.MaxInt64
}
if b < 0 && a < math.MinInt64-b {
return math.MinInt64
}
return a + b
}
// PeriodStart is the start of the billing period containing the given
// date for a reset day of month (hub period_start). A reset day past
// the end of a short month lands on that month's last day.
func PeriodStart(today time.Time, resetDay int64) time.Time {
if resetDay < 1 {
resetDay = 1
}
if resetDay > 31 {
resetDay = 31
}
clamped := func(y int, m time.Month) time.Time {
last := time.Date(y, m+1, 1, 0, 0, 0, 0, time.Local).AddDate(0, 0, -1).Day()
d := int(resetDay)
if d > last {
d = last
}
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
}
today = time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.Local)
this := clamped(today.Year(), today.Month())
if !today.Before(this) {
return this
}
if today.Month() == time.January {
return clamped(today.Year()-1, time.December)
}
return clamped(today.Year(), today.Month()-1)
}
const dateLayout = "2006-01-02"
func dateOnly(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
}
// ensureRows lazily creates the agent's meta + traffic rows so booking
// and reading never special-case a first report. A meta row created
// here is also the moment the agent joins the probe system, so the
// auto-join probe tasks are assigned then — and only then, matching
// the hub, where auto_join applies to nodes created after the task
// was flagged, never retroactively to existing ones (B4).
func (s *Store) ensureRows(agentID int64) error {
res, err := s.db.Exec(`INSERT OR IGNORE INTO probe_meta (agent_id) VALUES (?)`, agentID)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err == nil && n > 0 {
if _, err := s.db.Exec(`INSERT OR IGNORE INTO probe_ping_node (task_id, agent_id)
SELECT id, ? FROM probe_ping_task WHERE auto_join`, agentID); err != nil {
return err
}
}
_, err = s.db.Exec(`INSERT OR IGNORE INTO probe_traffic (agent_id) VALUES (?)`, agentID)
return err
}
// ResetDay reads the agent's billing reset day from probe_meta
// (default 1 when unset, hub's node default).
func (s *Store) ResetDay(agentID int64) (int64, error) {
var day int64
err := s.db.QueryRow(`SELECT traffic_reset_day FROM probe_meta WHERE agent_id = ?`, agentID).Scan(&day)
if err == sql.ErrNoRows {
return 1, nil
}
if err != nil {
return 1, err
}
if day < 1 {
return 1, nil
}
if day > 31 {
return 31, nil
}
return day, nil
}
// accumulate books one reading of an agent's raw kernel counters into
// its running totals — a faithful port of hub db.rs accumulate().
//
// Only bytes the master observed a counter climb through are booked.
// Without a baseline under this exact boot there is nothing to subtract
// from: a first reading, a counter that shrank under the same boot, and
// a changed boot_id all book a zero delta and merely re-align the
// baseline (booking the bare reading would charge the machine's whole
// history again). The one exception is a machine that booted after the
// master last heard from it (booted > prevLastSeenSec): its counters
// started from zero while unobserved, so the reading is exactly the
// bytes since boot and is booked whole — a reboot during a master
// outage would otherwise lose the whole outage.
//
// at dates the reading for the day and billing period (hub local
// calendar); month/day boundaries never move a stored stamp backwards,
// so a panel correction stamped with the current period is not undone
// by a held-back reading.
func (s *Store) accumulate(agentID int64, bootID string, rx, tx int64, at time.Time, booted *int64, prevLastSeenSec int64) (Traffic, error) {
if err := s.ensureRows(agentID); err != nil {
return Traffic{}, err
}
resetDay, err := s.ResetDay(agentID)
if err != nil {
return Traffic{}, err
}
var (
prevBoot, monthStart, dayStart string
lastRx, lastTx int64
totalRx, totalTx, monthRx, monthTx, dayRx, dayTx int64
)
err = s.db.QueryRow(`
SELECT boot_id, last_rx, last_tx, total_rx, total_tx, month_rx, month_tx,
month_start, day_rx, day_tx, day_start
FROM probe_traffic WHERE agent_id = ?`, agentID).
Scan(&prevBoot, &lastRx, &lastTx, &totalRx, &totalTx, &monthRx, &monthTx,
&monthStart, &dayRx, &dayTx, &dayStart)
if err != nil {
return Traffic{}, err
}
kernelPart := func(b string) string {
if i := strings.IndexByte(b, '/'); i >= 0 {
return b[:i]
}
return b
}
unseenBoot := prevBoot != "" &&
kernelPart(prevBoot) != kernelPart(bootID) &&
booted != nil && prevLastSeenSec > 0 && *booted > prevLastSeenSec
var dRx, dTx int64
switch {
case unseenBoot:
dRx, dTx = rx, tx
case prevBoot == "" || prevBoot != bootID:
dRx, dTx = 0, 0
default:
if rx > lastRx {
dRx = rx - lastRx
}
if tx > lastTx {
dTx = tx - lastTx
}
}
// Saturating adds: a total near MaxInt64 must not wrap negative.
totalRx = satAdd(totalRx, dRx)
totalTx = satAdd(totalTx, dTx)
monthRx = satAdd(monthRx, dRx)
monthTx = satAdd(monthTx, dTx)
dayRx = satAdd(dayRx, dRx)
dayTx = satAdd(dayTx, dTx)
date := dateOnly(at)
today := dateOnly(time.Now())
dated := func(stored string) time.Time {
if stamp, err := time.ParseInLocation(dateLayout, stored, time.Local); err == nil && !stamp.After(today) {
if stamp.After(date) {
return stamp
}
return date
}
return date
}
period := PeriodStart(dated(monthStart), resetDay)
if monthStart != period.Format(dateLayout) {
// A new billing period restarts the month counter, not the total.
monthRx, monthTx = dRx, dTx
}
day := dated(dayStart)
if dayStart != day.Format(dateLayout) {
dayRx, dayTx = dRx, dTx
}
_, err = s.db.Exec(`
UPDATE probe_traffic SET boot_id = ?, last_rx = ?, last_tx = ?,
total_rx = ?, total_tx = ?, month_rx = ?, month_tx = ?, month_start = ?,
day_rx = ?, day_tx = ?, day_start = ?
WHERE agent_id = ?`,
bootID, rx, tx, totalRx, totalTx, monthRx, monthTx, period.Format(dateLayout),
dayRx, dayTx, day.Format(dateLayout), agentID)
if err != nil {
return Traffic{}, err
}
return Traffic{
TotalRx: totalRx, TotalTx: totalTx,
MonthRx: monthRx, MonthTx: monthTx, MonthStart: period.Format(dateLayout),
DayRx: dayRx, DayTx: dayTx, DayStart: day.Format(dateLayout),
}, nil
}
// SetTraffic corrects a ledger row from the panel side (hub
// set_traffic): month figures are re-stamped with the current period so
// the correction is not read back as belonging to an old period and
// discarded by the next booking. Nil fields keep their stored values.
// False when the agent has no meta row yet.
func (s *Store) SetTraffic(agentID int64, totalRx, totalTx, monthRx, monthTx *int64) (bool, error) {
resetDay, err := s.ResetDay(agentID)
if err != nil {
return false, err
}
var exists int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM probe_meta WHERE agent_id = ?`, agentID).Scan(&exists); err != nil {
return false, err
}
if exists == 0 {
return false, nil
}
if err := s.ensureRows(agentID); err != nil {
return false, err
}
period := PeriodStart(dateOnly(time.Now()), resetDay).Format(dateLayout)
// COALESCE keeps stored values for nil fields; month figures only
// move when the stored stamp already is the current period, and the
// stamp always advances to the current period (hub semantics).
_, err = s.db.Exec(`
UPDATE probe_traffic SET
total_rx = COALESCE(?, total_rx), total_tx = COALESCE(?, total_tx),
month_rx = COALESCE(?, CASE WHEN month_start = ? THEN month_rx ELSE 0 END),
month_tx = COALESCE(?, CASE WHEN month_start = ? THEN month_tx ELSE 0 END),
month_start = ?
WHERE agent_id = ?`,
ptrOrNil(totalRx), ptrOrNil(totalTx), ptrOrNil(monthRx), period,
ptrOrNil(monthTx), period, period, agentID)
if err != nil {
return false, err
}
return true, nil
}
func ptrOrNil(v *int64) any {
if v == nil {
return nil
}
return *v
}
// gate applies the read-side period gating of hub all_traffic: the
// period counters restart lazily in accumulate on the node's next
// report, so a node offline since before a boundary still holds the
// previous period's bytes on disk; readers see them as zero unless the
// stored stamp is the current period.
func gate(t Traffic, dayStart string, resetDay int64, now time.Time) Traffic {
period := PeriodStart(dateOnly(now), resetDay).Format(dateLayout)
if t.MonthStart != period {
t.MonthRx, t.MonthTx = 0, 0
}
if dayStart != dateOnly(now).Format(dateLayout) {
t.DayRx, t.DayTx = 0, 0
}
return t
}
// TrafficFor returns one agent's ledger with read-side period gating —
// the read port B3's /api/nodes injects total/month figures from.
func (s *Store) TrafficFor(agentID int64) (Traffic, bool, error) {
var t Traffic
var dayStart string
err := s.db.QueryRow(`
SELECT total_rx, total_tx, month_rx, month_tx, month_start, day_rx, day_tx, day_start
FROM probe_traffic WHERE agent_id = ?`, agentID).
Scan(&t.TotalRx, &t.TotalTx, &t.MonthRx, &t.MonthTx, &t.MonthStart, &t.DayRx, &t.DayTx, &dayStart)
if err == sql.ErrNoRows {
return Traffic{}, false, nil
}
if err != nil {
return Traffic{}, false, err
}
resetDay, err := s.ResetDay(agentID)
if err != nil {
return Traffic{}, false, err
}
gated := gate(t, dayStart, resetDay, time.Now())
if meta, ok, err := s.Meta(agentID); err != nil {
return Traffic{}, false, err
} else if ok {
gated = applyCalibration(gated, meta.TrafficCalUpGB, meta.TrafficCalDownGB)
}
return gated, true, nil
}
// AllTraffic returns every agent's ledger in one query, gated as in
// TrafficFor — the bulk read port for B3's node list.
// calBytes converts a GB calibration value to bytes on the panel's
// 1 GB = 1e9 B convention (the same one the traffic limit uses).
func calBytes(gb float64) int64 {
return int64(math.Round(gb * 1e9))
}
// applyCalibration shifts a ledger row by the operator's per-node
// corrections (V2.0.1): uplink correction on every tx figure,
// downlink on every rx figure. A correction may exceed the counted
// value (a node re-imaged mid-period); figures floor at zero rather
// than going negative.
func applyCalibration(t Traffic, calUpGB, calDownGB float64) Traffic {
up, down := calBytes(calUpGB), calBytes(calDownGB)
if up == 0 && down == 0 {
return t
}
floor := func(v, delta int64) int64 {
if v += delta; v < 0 {
return 0
}
return v
}
t.TotalTx = floor(t.TotalTx, up)
t.MonthTx = floor(t.MonthTx, up)
t.DayTx = floor(t.DayTx, up)
t.TotalRx = floor(t.TotalRx, down)
t.MonthRx = floor(t.MonthRx, down)
t.DayRx = floor(t.DayRx, down)
return t
}
func (s *Store) AllTraffic() (map[int64]Traffic, error) {
rows, err := s.db.Query(`
SELECT t.agent_id, t.total_rx, t.total_tx, t.month_rx, t.month_tx, t.month_start,
t.day_rx, t.day_tx, t.day_start, COALESCE(m.traffic_reset_day, 1),
COALESCE(m.traffic_cal_up_gb, 0), COALESCE(m.traffic_cal_down_gb, 0)
FROM probe_traffic t LEFT JOIN probe_meta m ON m.agent_id = t.agent_id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[int64]Traffic)
now := time.Now()
for rows.Next() {
var id, resetDay int64
var t Traffic
var dayStart string
var calUpGB, calDownGB float64
if err := rows.Scan(&id, &t.TotalRx, &t.TotalTx, &t.MonthRx, &t.MonthTx, &t.MonthStart,
&t.DayRx, &t.DayTx, &dayStart, &resetDay, &calUpGB, &calDownGB); err != nil {
return nil, err
}
out[id] = applyCalibration(gate(t, dayStart, resetDay, now), calUpGB, calDownGB)
}
return out, rows.Err()
}
// parseDay parses a stored "2006-01-02" stamp (used by tests).
func parseDay(s string) (time.Time, bool) {
t, err := time.ParseInLocation(dateLayout, s, time.Local)
if err != nil {
return time.Time{}, false
}
return t, true
}