- 新增 internal/selfupdate:发行坐标(基址常量+env 覆盖)、Gitea latest tag 查询、sha256 旁车校验下载、原子换名(旧二进制留 .bak、 失败可回滚),含单测 - 主控:UpdatePanels 对 agent 节点改为记录更新意向(最新 tag 的 V 系→A 系目标;已达标报错不记意向);意向经现有 GET config 轮询 捎带下发;心跳收 updateStatus 回报(failed/current 清意向留状态、 过期 requested_at 忽略),遥测版本达标自动确认 success - agent:识别意向→比自身 A 版本(已达标报 current 不下载)→按架构 下载资产+sha256 校验→换名留 .bak→刷一次心跳→stopXray→ syscall.Exec 自重启;exec 失败回滚 .bak 并报 failed;失败 10 分钟 内不重下同一意向;旧版全程保持运行 - NodeView 增 agentUpdateState/Error/Target 供节点页可观测 - 单测:selfupdate 换名/校验、agent 换名重启/坏校验保旧版/已达标 跳过/exec 失败回滚、主控意向记录/已达标跳过、config 捎带意向、 心跳 failed 清意向、心跳新版本确认 success
647 lines
20 KiB
Go
647 lines
20 KiB
Go
// 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"
|
|
"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
|
|
}
|
|
|
|
// 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{}),
|
|
}
|
|
}
|
|
|
|
// 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]string{
|
|
"token": a.config.Token,
|
|
"name": a.config.NodeName,
|
|
}
|
|
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()
|
|
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() {
|
|
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(),
|
|
}
|
|
if rep := a.currentUpdateReport(); rep != nil {
|
|
payload["updateStatus"] = rep
|
|
}
|
|
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 err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("heartbeat failed with status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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"`
|
|
AgentUpdate *updateIntent `json:"agent_update"`
|
|
}
|
|
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)
|
|
|
|
// 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 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) collectAndReportTraffic() 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)
|
|
}
|
|
|
|
// 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
|
|
}
|