Files
3x-ui-agent/internal/agent/fast_report_test.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

152 lines
4.3 KiB
Go

package agent
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// V2.0.1 probe batch, agent side: the config poll's cadence + pin
// are adopted; while the fast channel is active the heartbeat stops
// carrying metrics (they ride the fast report instead); a master
// without the endpoint (404) sends the agent back to heartbeat
// metrics permanently.
type fastMaster struct {
mu sync.Mutex
heartbeat []byte
fastReport []byte
fastStatus int
configBody string
reportStatus int
}
func (m *fastMaster) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
m.mu.Lock()
switch {
case strings.HasSuffix(r.URL.Path, "/config"):
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(m.configBody))
case strings.HasSuffix(r.URL.Path, "/probe-report"):
m.fastReport = body
st := m.fastStatus
m.mu.Unlock()
if st != 0 && st != http.StatusOK {
w.WriteHeader(st)
return
}
_, _ = w.Write([]byte(`{"success":true}`))
case strings.HasSuffix(r.URL.Path, "/heartbeat"):
m.heartbeat = body
m.mu.Unlock()
_, _ = w.Write([]byte(`{"success":true}`))
default:
m.mu.Unlock()
http.NotFound(w, r)
}
})
}
func TestFastReportSplitAndFallback(t *testing.T) {
SetStatIfacePin("")
t.Cleanup(func() { SetStatIfacePin("") })
master := &fastMaster{configBody: `{"probe_fast_interval":5,"probe_stat_iface":"eth7"}`}
srv := httptest.NewServer(master.handler())
defer srv.Close()
a := &Agent{
config: &Config{MasterURL: srv.URL, Token: "tok", NodeName: "n"},
httpClient: http.DefaultClient,
probes: newProbeRunner(),
}
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if got := a.fastIntervalSec.Load(); got != 5 {
t.Fatalf("fast interval = %d, want 5 from the poll", got)
}
if got := statIfacePinValue(); got != "eth7" {
t.Fatalf("stat iface pin = %q, want eth7", got)
}
if !a.fastReportActive() {
t.Fatal("fast report should be active after the poll offered a cadence")
}
// The fast report carries the metrics…
a.sendFastReport()
master.mu.Lock()
fast := master.fastReport
master.mu.Unlock()
var fastBody map[string]json.RawMessage
if err := json.Unmarshal(fast, &fastBody); err != nil || fastBody["probeMetrics"] == nil {
t.Fatalf("fast report = %s (err %v), want a probeMetrics block", fast, err)
}
// …and the heartbeat no longer does.
if err := a.sendHeartbeat(); err != nil {
t.Fatalf("sendHeartbeat: %v", err)
}
master.mu.Lock()
hb := master.heartbeat
master.mu.Unlock()
var hbBody map[string]json.RawMessage
if err := json.Unmarshal(hb, &hbBody); err != nil {
t.Fatalf("heartbeat body: %v", err)
}
if hbBody["probeMetrics"] != nil {
t.Error("heartbeat still carries probeMetrics while the fast channel is active")
}
if hbBody["probeFacts"] == nil || hbBody["telemetry"] == nil {
t.Error("heartbeat lost its heavy content (facts/telemetry)")
}
// A 404 from the fast endpoint (old master) disables the fast
// channel and restores heartbeat metrics.
master.mu.Lock()
master.fastStatus = http.StatusNotFound
master.mu.Unlock()
a.sendFastReport()
if a.fastReportActive() {
t.Fatal("fast report still active after a 404")
}
if err := a.sendHeartbeat(); err != nil {
t.Fatalf("sendHeartbeat after fallback: %v", err)
}
master.mu.Lock()
hb = master.heartbeat
master.mu.Unlock()
hbBody = map[string]json.RawMessage{}
if err := json.Unmarshal(hb, &hbBody); err != nil {
t.Fatalf("heartbeat body after fallback: %v", err)
}
if hbBody["probeMetrics"] == nil {
t.Error("heartbeat did not resume carrying probeMetrics after the 404 fallback")
}
}
// The counted-interface pin: strict single-interface selection, and
// a pin naming a vanished interface counts nothing (so the epoch
// changes and the master re-baselines instead of booking garbage).
func TestPinnedInterfaces(t *testing.T) {
all := []netCounters{
{name: "eth0", rx: 100, tx: 200},
{name: "ens3", rx: 300, tx: 400},
}
got := pinnedInterfaces(all, "ens3")
if len(got) != 1 || got[0].name != "ens3" || got[0].rx != 300 {
t.Errorf("pinnedInterfaces = %+v, want only ens3", got)
}
if got := pinnedInterfaces(all, "gone0"); len(got) != 0 {
t.Errorf("pin for a vanished interface = %+v, want empty", got)
}
}