Files
3x-ui-agent/internal/agent/singbox_online.go
T
Muse 1762e54d5f ITEM6 B3: agent sing-box runtime (parallel path)
Agent consumes singbox_config alongside xray: candidate->check->
flush->swap activation with .previous rollback, wire-level v2ray_api
stats client with NexusGate epoch discipline (mid-query flip voids
the round, new epoch counts in full, agent-first snapshot baselines),
access-log online tracker (authenticated-connection lines, ANSI-safe,
merged with snapshot-active users), per-core traffic baselines with
the sing-box one carrying its epoch. install-agent.sh installs the
self-built sing-box v1.13.21 next to xray (xray/geo removal deferred
to the B5 switchover). Env-gated real-core E2E covers vless-reality,
ss2022, mixed, trojan and the forwarding leg end to end; hysteria2 is
probe-gated (sandbox blocks UDP replies) and stays a B5 pilot item.
2026-10-22 22:48:12 +00:00

183 lines
5.5 KiB
Go

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
//
// [<conn-id> <elapsed>] inbound/<type>[<tag>]: [<user>] inbound connection to <dest>
//
// 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
}