package agent import ( "bytes" "os" "regexp" "sync" "time" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) // ITEM6 B3: online users for the sing-box runtime (the §4.4 spike, // concluded here). // // sing-box's v2ray_api has no online-user source. The access log // does: for every authenticated connection sing-box logs // // [ ] inbound/[]: [] inbound connection to // // with the same user name the stats counters use (verified against // the real v1.13.21 binary for vless, shadowsocks and mixed; // unauthenticated attempts only ever produce the preceding // "inbound connection from" line, never this one). The tracker tails // the managed process's log file from a cursor — NexusGate's // observation discipline: a truncated/rotated file resets the cursor, // reads are window-capped, only complete lines are consumed. // // Semantics versus the xray core's live-connection view: a user is // online while an authenticated connection was observed within // singboxOnlineGrace, OR while their counters moved in the latest // snapshot (the traffic cycle merges that in — it covers long-lived // connections that keep transferring). A connected-but-idle user // ages out after the grace window: sing-box logs nothing per // connection after setup, so idle-liveness is not observable without // opening the clash_api connection table, which the §4.4 evidence // base rules out. The wire contract is unchanged (onlines = [email]). const singboxOnlineGrace = 150 * time.Second // maxSingboxLogWindow caps one poll's read, NexusGate-style: when the // cursor falls further behind (long downtime), only the tail is // scanned for recent users. const maxSingboxLogWindow = 2 * 1024 * 1024 var ( // ANSI CSI sequences (sing-box colorizes the level and conn-id // even when redirected to a file) are stripped before matching. ansiCSIRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[ -/]*[@-~]") // The authenticated-connection line. The user name is sing-box's // stats identity: email for most protocols, username for // mixed/socks/http. singboxAuthLineRegex = regexp.MustCompile(`\[([^\]\s]+)\] inbound connection to `) ) // singboxOnlineTracker derives the online-user set from the managed // process's access log. Safe for concurrent use. type singboxOnlineTracker struct { path string mu sync.Mutex offset int64 lastSeen map[string]int64 // user -> unix seconds of latest authenticated connection } func newSingboxOnlineTracker(path string) *singboxOnlineTracker { return &singboxOnlineTracker{path: path, lastSeen: make(map[string]int64)} } // stripANSI removes terminal color/control sequences from one log line. func stripANSI(line string) string { if !bytes.ContainsRune([]byte(line), 0x1b) { return line } return ansiCSIRegex.ReplaceAllString(line, "") } // parseSingboxAuthLine extracts the user from one raw log line, or "" // when the line is not an authenticated-connection record. func parseSingboxAuthLine(line string) string { m := singboxAuthLineRegex.FindStringSubmatch(stripANSI(line)) if m == nil { return "" } return m[1] } // Poll consumes the log written since the previous poll and records // the users it proves active. Poll errors are swallowed by design: // online data is best-effort and must never fail a traffic cycle. func (t *singboxOnlineTracker) Poll(now time.Time) { t.mu.Lock() defer t.mu.Unlock() info, err := os.Stat(t.path) if err != nil { return } size := info.Size() if size < t.offset { t.offset = 0 // rotated/truncated: rescan from the top } start := t.offset if floor := size - maxSingboxLogWindow; floor > start { start = floor } if start >= size { t.offset = size return } f, err := os.Open(t.path) if err != nil { return } defer f.Close() buf := make([]byte, size-start) if _, err := f.ReadAt(buf, start); err != nil { return } end := bytes.LastIndex(buf, []byte("\n")) if end < 0 { return // no complete line yet; retry next poll } ts := now.Unix() for _, line := range bytes.Split(buf[:end], []byte("\n")) { if user := parseSingboxAuthLine(string(line)); user != "" { t.lastSeen[user] = ts } } t.offset = start + int64(end) + 1 // Prune entries far beyond the grace window so the map stays // bounded on long-lived nodes. cutoff := ts - int64(singboxOnlineGrace/time.Second)*4 for user, seen := range t.lastSeen { if seen < cutoff { delete(t.lastSeen, user) } } } // Online returns the users seen within the grace window. func (t *singboxOnlineTracker) Online(now time.Time) []string { t.mu.Lock() defer t.mu.Unlock() cutoff := now.Unix() - int64(singboxOnlineGrace/time.Second) out := make([]string, 0, len(t.lastSeen)) for user, seen := range t.lastSeen { if seen >= cutoff { out = append(out, user) } } return out } // mergeOnlineUsers unions the log tracker's view with the users whose // counters moved in the latest snapshot (activity within the last // poll interval), deduplicated, in xray.OnlineUser wire shape. func mergeOnlineUsers(logUsers []string, snap *xray.TrafficSnapshot) []xray.OnlineUser { seen := make(map[string]struct{}) out := make([]xray.OnlineUser, 0, len(logUsers)) add := func(email string) { if email == "" { return } if _, dup := seen[email]; dup { return } seen[email] = struct{}{} out = append(out, xray.OnlineUser{Email: email}) } for _, u := range logUsers { add(u) } if snap != nil { for _, ct := range snap.ClientTraffics { if ct != nil && ct.Up+ct.Down > 0 { add(ct.Email) } } } return out }