package agentd import ( "context" "encoding/json" "net/http" "sync" "time" agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" ) // ClashUsageSource reads the node's cumulative traffic from sing-box's clash_api // (/connections downloadTotal/uploadTotal) and reports per-window deltas. // // Limitation: the clash API exposes only NODE-TOTAL bytes (no per-user field, and // v2ray_api is not compiled into this sing-box build). So node-total delta is // split evenly across currently provisioned dp_uuids — exact for a single active // user, approximate for multiple. See todo #7 for accurate accounting (rebuild // sing-box with with_v2ray_api). type ClashUsageSource struct { sb *SingBox addr string secret string client *http.Client mu sync.Mutex inited bool lastUp, lastDown int64 } func newClashUsageSource(sb *SingBox) *ClashUsageSource { return &ClashUsageSource{ sb: sb, addr: clashAPIAddr, secret: clashAPISecret, client: &http.Client{Timeout: 5 * time.Second}, } } type clashConnections struct { DownloadTotal int64 `json:"downloadTotal"` UploadTotal int64 `json:"uploadTotal"` } // Collect returns per-dp_uuid usage accumulated since the previous call. // First call only sets the baseline (returns nil). func (c *ClashUsageSource) Collect() []*agentv1.UsageEntry { up, down, ok := c.fetchTotals() if !ok { return nil } c.mu.Lock() defer c.mu.Unlock() if !c.inited { c.lastUp, c.lastDown, c.inited = up, down, true return nil } dUp, dDown := up-c.lastUp, down-c.lastDown c.lastUp, c.lastDown = up, down if dUp < 0 || dDown < 0 { // Counters reset (sing-box restarted) → skip this window. return nil } if dUp == 0 && dDown == 0 { return nil } uuids := c.sb.DpUUIDs() if len(uuids) == 0 { return nil } n := int64(len(uuids)) perUp, perDown := dUp/n, dDown/n entries := make([]*agentv1.UsageEntry, 0, len(uuids)) for _, u := range uuids { entries = append(entries, &agentv1.UsageEntry{ DpUUID: u, BytesUp: perUp, BytesDown: perDown, SessionMinutes: 1, // 本窗口有流量 → 计 1 分钟在线(近似) }) } return entries } func (c *ClashUsageSource) fetchTotals() (up, down int64, ok bool) { req, err := http.NewRequestWithContext( context.Background(), http.MethodGet, "http://"+c.addr+"/connections", nil) if err != nil { return 0, 0, false } if c.secret != "" { req.Header.Set("Authorization", "Bearer "+c.secret) } resp, err := c.client.Do(req) if err != nil { return 0, 0, false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return 0, 0, false } var cc clashConnections if err := json.NewDecoder(resp.Body).Decode(&cc); err != nil { return 0, 0, false } // clash 的 up/down 是「代理视角」:为客户端下载,代理需把数据 upload 给客户端, // 故 clash.uploadTotal ≈ 用户下载、clash.downloadTotal ≈ 用户上传 → 此处对调, // 返回「用户视角」的 (up, down)。 return cc.DownloadTotal, cc.UploadTotal, true }