package agentd import ( "context" "strings" "sync" "time" agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" "github.com/wangjia/pangolin/server/internal/agentd/v2rayapi" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) // V2RayUsageSource reads PER-USER traffic from sing-box's v2ray_api StatsService // (loopback gRPC). Each provisioned dp_uuid has its own counters // // user>>>{dp_uuid}>>>traffic>>>uplink (bytes the user sent) // user>>>{dp_uuid}>>>traffic>>>downlink (bytes the user received) // // QueryStats with reset=true drains+zeroes the counters, so each call yields the // exact per-user delta for the elapsed window — accurate for any number of // concurrent users (unlike the old clash node-total split, see ClashUsageSource). // // uplink/downlink are already user-perspective (no swap needed). Only opaque // dp_uuids and byte counts are read — never identities/addresses/DNS. type V2RayUsageSource struct { addr string mu sync.Mutex conn *grpc.ClientConn client *v2rayapi.StatsClient } func newV2RayUsageSource() *V2RayUsageSource { return &V2RayUsageSource{addr: v2rayAPIAddr} } // dial lazily establishes (and caches) the loopback gRPC connection. func (s *V2RayUsageSource) ensureClient() (*v2rayapi.StatsClient, error) { s.mu.Lock() defer s.mu.Unlock() if s.client != nil { return s.client, nil } conn, err := grpc.NewClient(s.addr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return nil, err } s.conn = conn s.client = v2rayapi.NewStatsClient(conn) return s.client, nil } // Collect drains per-user counters since the previous call. func (s *V2RayUsageSource) Collect() []*agentv1.UsageEntry { client, err := s.ensureClient() if err != nil { return nil } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() resp, err := client.QueryStats(ctx, &v2rayapi.QueryStatsRequest{ Pattern: "user>>>", Reset_: true, }) if err != nil || resp == nil { return nil } // Aggregate uplink/downlink per dp_uuid. type acc struct{ up, down int64 } byUUID := make(map[string]*acc) for _, st := range resp.GetStat() { uuid, dir, ok := parseUserStat(st.GetName()) if !ok { continue } a := byUUID[uuid] if a == nil { a = &acc{} byUUID[uuid] = a } switch dir { case "uplink": a.up += st.GetValue() case "downlink": a.down += st.GetValue() } } entries := make([]*agentv1.UsageEntry, 0, len(byUUID)) for uuid, a := range byUUID { if a.up == 0 && a.down == 0 { continue } entries = append(entries, &agentv1.UsageEntry{ DpUUID: uuid, BytesUp: a.up, BytesDown: a.down, SessionMinutes: 1, // 本窗口有流量 → 计 1 分钟在线(近似) }) } return entries } // parseUserStat splits "user>>>{uuid}>>>traffic>>>{uplink|downlink}". func parseUserStat(name string) (uuid, dir string, ok bool) { parts := strings.Split(name, ">>>") if len(parts) != 4 || parts[0] != "user" || parts[2] != "traffic" { return "", "", false } return parts[1], parts[3], true }