// Package agent implements the lightweight 3x-ui node agent. // In agent mode, the node does not run the web panel or database. // It registers with the master via token, receives xray config, manages the // xray process, and reports traffic back to the master. package agent import ( "bytes" "crypto/tls" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) const ( // Default poll interval for config updates defaultPollInterval = 30 * time.Second // Heartbeat interval defaultHeartbeatInterval = 60 * time.Second // Traffic report interval (own ticker; 10s keeps agent inbounds' // traffic/onlines freshness in the same order of magnitude as the // master's seconds-level polling of traditional panel nodes) defaultTrafficInterval = 10 * time.Second ) // DefaultBinFolder is used when XUI_BIN_FOLDER is unset. It matches the // panel's directory layout: xray binary, geo files and config.json live here. const DefaultBinFolder = "/usr/local/x-ui/bin" // Config holds the agent configuration type Config struct { MasterURL string `json:"master_url"` Token string `json:"token"` NodeName string `json:"node_name"` PollInterval time.Duration `json:"poll_interval"` // ApiPort overrides the xray stats API inbound port from the master's // config. Needed when agent and master share a host (e.g. testing), // otherwise both xrays fight over the default 62789. 0 = use master's. ApiPort int `json:"api_port"` // BaselinePath is where the last master-acknowledged traffic counters // are persisted, so an agent restart keeps diffing against them // instead of swallowing unreported traffic into a fresh baseline. // Empty: main derives it next to the agent config file; the agent // falls back to the bin folder. BaselinePath string `json:"baseline_path,omitempty"` } // Agent represents the node agent type Agent struct { config *Config httpClient *http.Client stopCh chan struct{} lastConfigHash string xrayProc *xray.Process xrayAPI xray.XrayAPI apiPort int baselineLoaded bool // Heartbeat telemetry state (P13): last measured master-request RTT // and the mtime-keyed xray version cache. lastRTTms atomic.Int64 hasRTT atomic.Bool xrayVerMu sync.Mutex xrayVer string xrayVerFor string // binary path the cached version was read from xrayVerMod time.Time // One-click update bookkeeping (item 20, see update.go). updateMu sync.Mutex updateRunning bool updateHandled int64 updateFailedReq int64 updateFailedAt time.Time updateReport *agentUpdateStatus exePathOverride string // sing-box runtime (ITEM6 B3, see singbox.go). sbMu serializes // config activation, the sing-box traffic cycle and shutdown: a // flush-before-change must never race a periodic snapshot. sbMu sync.Mutex sbProc *singboxProcess sbStats *singboxStatsClient sbOnline *singboxOnlineTracker sbStatsAddr string lastSingboxHash string sbBaselineLoaded bool // sing-box version cache (telemetry), mirroring the xray one. sbVerMu sync.Mutex sbVer string sbVerFor string sbVerMod time.Time // Latency probe runner (item 7 B4, see ping.go): task set pushed // on the config poll, samples returned on the heartbeat. probes *probeRunner } // ensureSingboxState lazily creates the sing-box runtime state. // Callers hold sbMu (or are in single-threaded setup). func (a *Agent) ensureSingboxState() { if a.sbProc == nil { a.sbProc = &singboxProcess{} } if a.sbStats == nil { a.sbStats = newSingboxStatsClient(a.sbProc.epoch) } if a.sbOnline == nil { a.sbOnline = newSingboxOnlineTracker(singboxLogPath()) } } // New creates a new agent instance func New(cfg *Config) *Agent { if cfg.PollInterval == 0 { cfg.PollInterval = defaultPollInterval } return &Agent{ config: cfg, httpClient: &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, }, }, stopCh: make(chan struct{}), probes: newProbeRunner(), } } // statusError is a non-200 response from the master. It keeps the status // code addressable so the registration retry loop can tell a wrong-path // 404 (master_url missing the panel base path) apart from other failures. type statusError struct { code int body string } func (e *statusError) Error() string { return fmt.Sprintf("register failed with status %d: %s", e.code, e.body) } // Register registers the agent with the master func (a *Agent) Register() error { url := fmt.Sprintf("%s/panel/api/agent/register", a.config.MasterURL) payload := map[string]any{ "token": a.config.Token, "name": a.config.NodeName, // Probe facts ride along (B1): old masters ignore the extra field. "probeFacts": collectFacts(), } body, _ := json.Marshal(payload) req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+a.config.Token) resp, err := a.do(req) if err != nil { return fmt.Errorf("register failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) return &statusError{code: resp.StatusCode, body: string(data)} } logger.Info("Agent registered successfully with master") return nil } // registrationBackoff returns the delay before the next registration // attempt: 5s, 15s, 30s, then capped at 60s. func registrationBackoff(failures int) time.Duration { switch { case failures <= 1: return 5 * time.Second case failures == 2: return 15 * time.Second case failures == 3: return 30 * time.Second default: return 60 * time.Second } } // registerWait sleeps between registration attempts, returning false when // the agent is stopping. A package var so tests can drive the retry loop // without real delays. var registerWait = func(stopCh chan struct{}, d time.Duration) bool { select { case <-stopCh: return false case <-time.After(d): return true } } // errRegistrationAborted marks a registration loop cut short by Stop. var errRegistrationAborted = errors.New("registration aborted: agent stopping") // registerWithRetry keeps trying to register until the master accepts or // the agent is stopped. A one-shot failure used to kill the agent // outright — a typo'd master_url (classically: the panel base path // missing, so the master answers 404) left the node dead and "unknown" // on the panel with nothing retrying. Registration is idempotent on the // master, so retrying with backoff is safe. func (a *Agent) registerWithRetry() error { failures := 0 for { err := a.Register() if err == nil { return nil } failures++ var st *statusError if errors.As(err, &st) && st.code == http.StatusNotFound { logger.Warningf("主控返回 404:检查 master_url 是否包含面板基础路径(如 https://host/rogue),当前 master_url=%s", a.config.MasterURL) } delay := registrationBackoff(failures) logger.Warningf("Registration attempt %d failed: %v; retrying in %s", failures, err, delay) if !registerWait(a.stopCh, delay) { return errRegistrationAborted } } } // Run starts the agent main loop func (a *Agent) Run() error { logger.Info("Starting 3x-ui agent...") logger.Infof("Master: %s, Node: %s", a.config.MasterURL, a.config.NodeName) // Initial registration, retried with backoff: a node that cannot // register yet (master down, URL just fixed, network still coming up) // must not die on the first failure. if err := a.registerWithRetry(); err != nil { if errors.Is(err, errRegistrationAborted) { logger.Info("Agent stopping...") return nil } return err } // Start heartbeat ticker heartbeatTicker := time.NewTicker(defaultHeartbeatInterval) defer heartbeatTicker.Stop() // Start config poll ticker pollTicker := time.NewTicker(a.config.PollInterval) defer pollTicker.Stop() // Start traffic report ticker trafficTicker := time.NewTicker(defaultTrafficInterval) defer trafficTicker.Stop() // Initial config fetch if err := a.fetchAndApplyConfig(); err != nil { logger.Warningf("Initial config fetch failed: %v", err) } for { select { case <-a.stopCh: logger.Info("Agent stopping...") a.stopXray() a.stopSingbox() return nil case <-heartbeatTicker.C: if err := a.sendHeartbeat(); err != nil { logger.Warningf("Heartbeat failed: %v", err) } case <-pollTicker.C: if err := a.fetchAndApplyConfig(); err != nil { logger.Warningf("Config fetch failed: %v", err) } case <-trafficTicker.C: if err := a.collectAndReportTraffic(); err != nil { logger.Warningf("Traffic report failed: %v", err) } } } } // Stop stops the agent func (a *Agent) Stop() { if a.probes != nil { a.probes.stop() } close(a.stopCh) } // do performs one master request and records its round-trip time as the // node->master latency sample reported by the next heartbeat (P13). func (a *Agent) do(req *http.Request) (*http.Response, error) { start := time.Now() resp, err := a.httpClient.Do(req) if err == nil { a.lastRTTms.Store(time.Since(start).Milliseconds()) a.hasRTT.Store(true) } return resp, err } // sendHeartbeat sends a heartbeat to the master func (a *Agent) sendHeartbeat() error { url := fmt.Sprintf("%s/panel/api/agent/heartbeat", a.config.MasterURL) payload := map[string]interface{}{ "token": a.config.Token, "timestamp": time.Now().Unix(), "telemetry": a.collectTelemetry(), // Probe snapshot (B1): facts are process-cached so re-sending them // costs nothing and heals a master that missed the register-time // copy; metrics are sampled fresh each heartbeat. Old masters // ignore both fields. "probeFacts": collectFacts(), "probeMetrics": collectMetrics(), } if rep := a.currentUpdateReport(); rep != nil { payload["updateStatus"] = rep } // Latency probe samples (B4) ride the heartbeat — deliberately not // the traffic report, which stops when xray is down while probing // must not. Drained here, requeued on any failure below. var probeBatch []ProbeResult if a.probes != nil { probeBatch = a.probes.drainResults() if len(probeBatch) > 0 { payload["probeResults"] = probeBatch } } body, _ := json.Marshal(payload) req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { a.requeueProbeBatch(probeBatch) return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+a.config.Token) resp, err := a.do(req) if err != nil { a.requeueProbeBatch(probeBatch) return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { a.requeueProbeBatch(probeBatch) return fmt.Errorf("heartbeat failed with status %d", resp.StatusCode) } return nil } // requeueProbeBatch returns a heartbeat's probe samples to the // runner buffer after a failed send. func (a *Agent) requeueProbeBatch(batch []ProbeResult) { if a.probes != nil { a.probes.requeueResults(batch) } } // fetchAndApplyConfig fetches the xray config from master and applies it if changed func (a *Agent) fetchAndApplyConfig() error { url := fmt.Sprintf("%s/panel/api/agent/config", a.config.MasterURL) req, err := http.NewRequest("GET", url, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+a.config.Token) resp, err := a.do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) return fmt.Errorf("config fetch failed with status %d: %s", resp.StatusCode, string(data)) } var result struct { ConfigHash string `json:"config_hash"` XrayConfig json.RawMessage `json:"xray_config"` SingboxConfig json.RawMessage `json:"singbox_config"` SingboxConfigHash string `json:"singbox_config_hash"` AgentUpdate *updateIntent `json:"agent_update"` // ProbeTasks is a pointer on purpose (B4): a master too old // to know probes omits the field and the agent must keep its // current set, while a present-but-empty list means "run no // probes" and clears it. ProbeTasks *[]ProbeTask `json:"probe_tasks"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return err } // One-click update intents ride this poll (item 20); handle them even // when the xray config itself is unchanged. a.maybeHandleUpdate(result.AgentUpdate) // sing-box runtime (ITEM6 B3): an independent, parallel path. Its // failures are logged and retried on the next poll; they never fail // the xray apply below or mark the poll failed. if result.SingboxConfigHash != "" && result.SingboxConfigHash != a.lastSingboxHash { if err := a.applySingboxConfig(result.SingboxConfig); err != nil { logger.Warningf("Failed to apply sing-box config: %v", err) } else { a.lastSingboxHash = result.SingboxConfigHash logger.Info("sing-box config applied successfully") } } // Probe tasks change independently of the xray config, so they // are applied before the hash early-return below. if result.ProbeTasks != nil && a.probes != nil { a.probes.applyTasks(*result.ProbeTasks) } // Skip if config hasn't changed if result.ConfigHash == a.lastConfigHash && result.ConfigHash != "" { return nil } logger.Info("New xray config received, applying...") if err := a.applyXrayConfig(result.XrayConfig); err != nil { return fmt.Errorf("failed to apply xray config: %w", err) } a.lastConfigHash = result.ConfigHash logger.Info("Xray config applied successfully") return nil } // ensureXrayBinary makes sure the xray binary exists at the panel-layout path. // The install doc puts it at /usr/local/x-ui/bin/xray; the panel's process // manager looks for the arch-specific name, so link one to the other. func ensureXrayBinary() error { binPath := xray.GetBinaryPath() if _, err := os.Stat(binPath); err == nil { return nil } plain := filepath.Join(config.GetBinFolderPath(), "xray") if _, err := os.Stat(plain); err != nil { return fmt.Errorf("xray binary not found at %s or %s", plain, binPath) } // Best effort: link the arch-specific name the process manager expects. if err := os.Symlink(plain, binPath); err != nil && !os.IsExist(err) { return fmt.Errorf("failed to link xray binary: %w", err) } return nil } // applyXrayConfig writes the xray config and (re)starts xray through the // panel's process manager. func (a *Agent) applyXrayConfig(configJSON json.RawMessage) error { if err := ensureXrayBinary(); err != nil { return err } var cfg xray.Config if err := json.Unmarshal(configJSON, &cfg); err != nil { return fmt.Errorf("invalid xray config: %w", err) } // Same-host override: rewrite the stats API inbound port so the agent's // xray doesn't fight the master's xray over the default port. if a.config.ApiPort > 0 { for i := range cfg.InboundConfigs { if cfg.InboundConfigs[i].Tag == "api" { logger.Infof("Overriding xray API port %d -> %d (agent config)", cfg.InboundConfigs[i].Port, a.config.ApiPort) cfg.InboundConfigs[i].Port = a.config.ApiPort } } // Also move the prometheus metrics listener (default 127.0.0.1:11111) // which would collide with the master's xray on the same host. if len(cfg.Metrics) > 0 { var m map[string]any if err := json.Unmarshal(cfg.Metrics, &m); err == nil { if ls, ok := m["listen"].(string); ok && ls != "" { newListen := fmt.Sprintf("127.0.0.1:%d", a.config.ApiPort+1) logger.Infof("Overriding xray metrics listen %s -> %s (agent config)", ls, newListen) m["listen"] = newListen if raw, err := json.Marshal(m); err == nil { cfg.Metrics = raw } } } } } a.apiPort = apiPortFromConfig(&cfg) a.stopXray() // Use a dedicated config file so the agent's xray doesn't fight the // master's xray over the shared config.json when both run on one host. agentConfigPath := config.GetBinFolderPath() + "/config-agent.json" proc := xray.NewTestProcess(&cfg, agentConfigPath) if err := proc.Start(); err != nil { return fmt.Errorf("failed to start xray: %w", err) } a.xrayProc = proc return nil } func (a *Agent) stopXray() { if a.xrayProc != nil { _ = a.xrayProc.Stop() a.xrayProc = nil } } // apiPortFromConfig extracts the stats API port (the inbound tagged "api"). func apiPortFromConfig(cfg *xray.Config) int { for _, inbound := range cfg.InboundConfigs { if inbound.Tag == "api" { return inbound.Port } } return 0 } type trafficReport struct { Inbounds []trafficEntry `json:"inbounds"` Users []userEntry `json:"users"` Onlines []string `json:"onlines"` } type trafficEntry struct { Tag string `json:"tag"` Up int64 `json:"up"` Down int64 `json:"down"` } type userEntry struct { Email string `json:"email"` Up int64 `json:"up"` Down int64 `json:"down"` } // statsClient is the slice of the xray stats API the traffic reporter // uses. The snapshot/commit pair is the loss-proofing contract: snapshots // never advance the baseline, commits do. type statsClient interface { GetTrafficSnapshot() (*xray.TrafficSnapshot, error) CommitTrafficSnapshot(*xray.TrafficSnapshot) RestoreTrafficBaseline(map[string]int64) GetOnlineUsers() ([]xray.OnlineUser, error) } // trafficBaselineFile is the on-disk form of the last committed baseline. type trafficBaselineFile struct { Version int `json:"version"` Values map[string]int64 `json:"values"` } // baselinePath resolves where the acknowledged traffic baseline lives. func (a *Agent) baselinePath() string { if a.config.BaselinePath != "" { return a.config.BaselinePath } return filepath.Join(config.GetBinFolderPath(), "agent-traffic-baseline.json") } // loadTrafficBaseline reads a persisted baseline. A missing file is a // normal first run; a corrupt one is reported so the caller can log it — // both yield no values and the reporter falls back to a baseline-only // first snapshot rather than crashing the agent. func loadTrafficBaseline(path string) (map[string]int64, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var f trafficBaselineFile if err := json.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("parse traffic baseline: %w", err) } return f.Values, nil } // saveTrafficBaseline persists the baseline atomically (write-then-rename) // so a crash mid-write can never leave a torn file behind. func saveTrafficBaseline(path string, values map[string]int64) error { data, err := json.Marshal(trafficBaselineFile{Version: 1, Values: values}) if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } return os.Rename(tmp, path) } // buildTrafficReport shapes a snapshot plus the freshly polled online // users into the wire report, dropping zero-traffic entries. Onlines are // point-in-time data: they ride along with each report and are never part // of the baseline bookkeeping. func buildTrafficReport(snap *xray.TrafficSnapshot, onlineUsers []xray.OnlineUser) trafficReport { report := trafficReport{} for _, t := range snap.Traffics { if t == nil || !t.IsInbound || t.Up+t.Down <= 0 { continue } report.Inbounds = append(report.Inbounds, trafficEntry{Tag: t.Tag, Up: t.Up, Down: t.Down}) } for _, ct := range snap.ClientTraffics { if ct == nil || ct.Email == "" || ct.Up+ct.Down <= 0 { continue } report.Users = append(report.Users, userEntry{Email: ct.Email, Up: ct.Up, Down: ct.Down}) } for _, u := range onlineUsers { report.Onlines = append(report.Onlines, u.Email) } return report } func (r trafficReport) empty() bool { return len(r.Inbounds) == 0 && len(r.Users) == 0 && len(r.Onlines) == 0 } // postTrafficReport sends one report to the master; only an HTTP 200 // counts as acknowledged. func (a *Agent) postTrafficReport(report trafficReport) error { url := fmt.Sprintf("%s/panel/api/agent/traffic", a.config.MasterURL) body, _ := json.Marshal(report) req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+a.config.Token) resp, err := a.do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("traffic report failed with status %d", resp.StatusCode) } return nil } // reportTrafficWith runs one snapshot → report → commit cycle. // // The committed baseline advances ONLY after the master acknowledges the // report (HTTP 200). Core counters are cumulative, so when a report fails // nothing is lost: the next snapshot diffs against the same baseline and // re-includes everything unreported, exactly once. The acknowledged // baseline is persisted after every commit, and restored before the first // snapshot of a restarted agent, so an agent restart cannot swallow the // unreported tail into a fresh baseline either. // // A poll with nothing to send still commits: it establishes the very // first baseline, and keeps the persisted copy fresh. func (a *Agent) reportTrafficWith(sc statsClient, post func(trafficReport) error) error { if !a.baselineLoaded { a.baselineLoaded = true if values, err := loadTrafficBaseline(a.baselinePath()); err != nil { if !os.IsNotExist(err) { logger.Warningf("Traffic baseline at %s unusable (%v); starting from a fresh baseline", a.baselinePath(), err) } } else if len(values) > 0 { sc.RestoreTrafficBaseline(values) } } snap, err := sc.GetTrafficSnapshot() if err != nil { return err } onlineUsers, _ := sc.GetOnlineUsers() report := buildTrafficReport(snap, onlineUsers) if !report.empty() { if err := post(report); err != nil { // No commit: the baseline stays where the master last // acknowledged it, and these deltas are reported again // (grown) on the next poll. return err } } sc.CommitTrafficSnapshot(snap) if err := saveTrafficBaseline(a.baselinePath(), snap.BaselineValues()); err != nil { // The in-memory commit stands — rolling it back would re-report // traffic the master already counted. The stale file self-heals // on the next successful poll; only a crash before that could // re-report a single interval. logger.Warningf("Failed to persist traffic baseline: %v", err) return err } return nil } // collectAndReportTraffic runs one reporting cycle per running core // (xray and, since ITEM6 B3, sing-box). The two cores keep separate // clients and baselines; a failure in one never blocks the other. func (a *Agent) collectAndReportTraffic() error { var firstErr error if err := a.collectAndReportXrayTraffic(); err != nil { firstErr = err } if err := a.collectAndReportSingboxTraffic(); err != nil && firstErr == nil { firstErr = err } return firstErr } // collectAndReportXrayTraffic snapshots the local xray counters and reports // the deltas since the last master-acknowledged baseline. The XrayAPI // instance is kept across polls so baselines survive; xray restarts are // handled (counters resetting to zero are treated as fresh). func (a *Agent) collectAndReportXrayTraffic() error { if a.xrayProc == nil || !a.xrayProc.IsRunning() { return nil } if a.apiPort <= 0 { return nil } if err := a.xrayAPI.Init(a.apiPort); err != nil { return err } defer a.xrayAPI.Close() return a.reportTrafficWith(&a.xrayAPI, a.postTrafficReport) } // singboxBaselineFile is the on-disk form of the sing-box tracker's // last committed baseline. The epoch travels with the values: after // an agent restart against a still-running (or since-restarted) core, // a baseline from another core instance must not be restored. type singboxBaselineFile struct { Version int `json:"version"` Epoch string `json:"epoch"` Values map[string]int64 `json:"values"` } // singboxBaselinePath derives the sing-box baseline location from the // xray one (agent-traffic-baseline.json -> agent-traffic-baseline-singbox.json), // so main's placement next to agent.json carries over. func (a *Agent) singboxBaselinePath() string { p := a.baselinePath() if strings.HasSuffix(p, ".json") { return strings.TrimSuffix(p, ".json") + "-singbox.json" } return p + "-singbox" } func loadSingboxBaseline(path string) (*singboxBaselineFile, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var f singboxBaselineFile if err := json.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("parse sing-box traffic baseline: %w", err) } return &f, nil } func saveSingboxBaseline(path, epoch string, values map[string]int64) error { data, err := json.Marshal(singboxBaselineFile{Version: 1, Epoch: epoch, Values: values}) if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } return os.Rename(tmp, path) } // collectAndReportSingboxTraffic runs one sing-box reporting cycle. func (a *Agent) collectAndReportSingboxTraffic() error { a.sbMu.Lock() defer a.sbMu.Unlock() if a.sbProc == nil || !a.sbProc.isRunning() { return nil } return a.reportSingboxTrafficLocked() } // reportSingboxTrafficLocked is the sing-box counterpart of // reportTrafficWith, with the same contract — commit only after the // master acknowledges (HTTP 200), persist after every commit — plus // the epoch carried through restore/persist. The online set merges // the access-log tracker with the users whose counters just moved. // Callers hold sbMu. func (a *Agent) reportSingboxTrafficLocked() error { a.ensureSingboxState() if a.sbStatsAddr == "" { // The applied config carries no v2ray_api stats block (the // panel-side stats switches are off): nothing exists to read. return nil } a.sbStats.SetAddr(a.sbStatsAddr) if !a.sbBaselineLoaded { a.sbBaselineLoaded = true if f, err := loadSingboxBaseline(a.singboxBaselinePath()); err != nil { if !os.IsNotExist(err) { logger.Warningf("sing-box traffic baseline at %s unusable (%v); starting from a fresh baseline", a.singboxBaselinePath(), err) } } else { a.sbStats.RestoreTrafficBaseline(f.Values, f.Epoch) } } a.sbOnline.Poll(time.Now()) snap, err := a.sbStats.GetTrafficSnapshot() if err != nil { if errors.Is(err, errSingboxEpochChanged) { // The core restarted around this poll (config change or // crash): this round establishes nothing; the next poll // rebases onto the new instance. Not a fault. return nil } return err } report := buildTrafficReport(snap, mergeOnlineUsers(a.sbOnline.Online(time.Now()), snap)) if !report.empty() { if err := a.postTrafficReport(report); err != nil { // No commit: deltas are recomputed against the same // baseline next poll, exactly like the xray path. return err } } a.sbStats.CommitTrafficSnapshot(snap) if err := saveSingboxBaseline(a.singboxBaselinePath(), a.sbProc.epoch(), snap.BaselineValues()); err != nil { // The in-memory commit stands (see the xray path's reasoning). logger.Warningf("Failed to persist sing-box traffic baseline: %v", err) return err } return nil } // LoadConfig loads agent config from file func LoadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, err } return &cfg, nil }