package main import ( "bytes" "context" "encoding/json" "fmt" "os" "os/exec" "regexp" "strings" "time" ) // ---- parseUsageStats ------------------------------------------------------- type counterPair struct { Uplink int64 Downlink int64 } // orderedCounters keeps first-insertion key order like a JS Map. type orderedCounters struct { keys []string m map[string]*counterPair } func newOrderedCounters() *orderedCounters { return &orderedCounters{m: map[string]*counterPair{}} } func (o *orderedCounters) set(key, direction string, value int64) { entry, ok := o.m[key] if !ok { entry = &counterPair{} o.m[key] = entry o.keys = append(o.keys, key) } if direction == "uplink" { entry.Uplink = value } else { entry.Downlink = value } } var ( inboundStatRe = regexp.MustCompile(`^inbound>>>(.+)>>>traffic>>>(uplink|downlink)$`) userStatRe = regexp.MustCompile(`^user>>>(.+)>>>traffic>>>(uplink|downlink)$`) ) type parsedStats struct { inbounds *orderedCounters users *orderedCounters } // parseUsageStats parses the stats helper's JSON output — agent.js // parseUsageStats(). Zero traffic makes the helper print {"stat":null}, // which is "no data", not a format error. func parseUsageStats(output string) (*parsedStats, error) { invalid := fmt.Errorf("统计接口返回的 JSON stat 格式无效") var top map[string]json.RawMessage if err := json.Unmarshal([]byte(output), &top); err != nil || top == nil { return nil, invalid } statRaw, present := top["stat"] if !present || strings.TrimSpace(string(statRaw)) == "null" { return &parsedStats{inbounds: newOrderedCounters(), users: newOrderedCounters()}, nil } var entries []struct { Name string `json:"name"` Value json.RawMessage `json:"value"` } if err := json.Unmarshal(statRaw, &entries); err != nil { return nil, invalid } parsed := &parsedStats{inbounds: newOrderedCounters(), users: newOrderedCounters()} for _, stat := range entries { var bucket *orderedCounters var key, direction string if m := inboundStatRe.FindStringSubmatch(stat.Name); m != nil { bucket, key, direction = parsed.inbounds, m[1], m[2] } else if m := userStatRe.FindStringSubmatch(stat.Name); m != nil { bucket, key, direction = parsed.users, m[1], m[2] } else { continue } value, reason, valid := jsStatValue(stat.Value) if !valid { return nil, fmt.Errorf("入口计数异常(%s):%s;请运行 ng-agent doctor 查看原始统计", reason, stat.Name) } bucket.set(key, direction, value) } return parsed, nil } // ---- queryUsage ------------------------------------------------------------ type usageSample struct { Kind string `json:"kind"` ID string `json:"id"` Uplink int64 `json:"uplink"` Downlink int64 `json:"downlink"` Epoch string `json:"epoch"` } type usageReport struct { Samples []usageSample `json:"samples"` Error *string `json:"error"` EngineErrors []string `json:"engineErrors"` } func (r *usageReport) toMap() map[string]interface{} { return map[string]interface{}{ "samples": r.Samples, "error": r.Error, "engineErrors": r.EngineErrors, } } // queryUsage reads sing-box counters through the stats helper binary and // shapes them into per-node / per-user samples — agent.js queryUsage(). // Callers must hold stateMu. func queryUsageLocked() (*usageReport, error) { all, err := readResources() if err != nil { return nil, err } resources := []map[string]interface{}{} for _, res := range all { meta := resMeta(res) if meta != nil && jsTruthy(meta["metricsTag"]) && asString(meta["kind"]) == "node" { resources = append(resources, res) } } samples := []usageSample{} errorsList := []string{} engineErrors := []string{} if len(resources) == 0 { return &usageReport{Samples: samples, Error: nil, EngineErrors: engineErrors}, nil } before := serviceEpoch() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) cmd := exec.CommandContext(ctx, cfg.SingBoxStatsBin, "--server=127.0.0.1:"+cfg.APIPort) var outBuf, errBuf bytes.Buffer cmd.Stdout = &outBuf cmd.Stderr = &errBuf runErr := cmd.Run() cancel() after := serviceEpoch() failed := false parsed := &parsedStats{inbounds: newOrderedCounters(), users: newOrderedCounters()} if before != "" && after != "" && before != after { errorsList = append(errorsList, "sing-box restarted during statistics query") engineErrors = append(engineErrors, "sing-box") failed = true } else if runErr != nil { detail := errBuf.String() if detail == "" && runErr != nil { detail = runErr.Error() } if detail == "" { detail = outBuf.String() } errorsList = append(errorsList, fmt.Sprintf("sing-box statsquery failed: %s", truncateRunes(safeError(detail), 160))) engineErrors = append(engineErrors, "sing-box") failed = true } if !failed { part, err := parseUsageStats(outBuf.String()) if err != nil { errorsList = append(errorsList, "sing-box: "+safeError(err.Error())) engineErrors = append(engineErrors, "sing-box") failed = true } else { parsed = part } } if !failed { epochValue := before if epochValue == "" { epochValue = serviceEpoch() } if epochValue == "" { epochValue = "unknown" } epoch := "sing-box:" + epochValue tagNodes := map[string]string{} for _, res := range resources { meta := resMeta(res) tagNodes[asString(meta["metricsTag"])] = asString(meta["nodeId"]) } for _, tag := range parsed.inbounds.keys { counters := parsed.inbounds.m[tag] nodeID, ok := tagNodes[tag] if !ok || nodeID == "" { continue } samples = append(samples, usageSample{Kind: "inbound", ID: nodeID, Uplink: counters.Uplink, Downlink: counters.Downlink, Epoch: epoch}) } userRe := regexp.MustCompile(`^ng:(.+)$`) for _, name := range parsed.users.keys { counters := parsed.users.m[name] match := userRe.FindStringSubmatch(name) if match == nil { continue } samples = append(samples, usageSample{Kind: "user", ID: match[1], Uplink: counters.Uplink, Downlink: counters.Downlink, Epoch: epoch}) } if len(parsed.inbounds.keys) == 0 && len(parsed.users.keys) == 0 { errorsList = append(errorsList, "sing-box 入口尚无统计记录") } } var errPtr *string if len(errorsList) > 0 { joined := strings.Join(errorsList, "; ") errPtr = &joined } return &usageReport{Samples: samples, Error: errPtr, EngineErrors: engineErrors}, nil } func queryUsage() (*usageReport, error) { stateMu.Lock() defer stateMu.Unlock() return queryUsageLocked() } // ---- flush before config changes ------------------------------------------ // flushUsageBeforeChange persists the running engine's counters before an // apply/delete restarts sing-box and destroys them — agent.js // flushUsageBeforeChange(). Callers must hold stateMu. func flushUsageBeforeChangeLocked() error { resources, err := readResources() if err != nil { return err } live := false for _, res := range resources { meta := resMeta(res) if meta != nil && jsTruthy(meta["metricsTag"]) && asString(meta["kind"]) == "node" { live = true break } } if !live || serviceHealth("nexusgate-sing-box").Status != "ready" { return nil } report, err := queryUsageLocked() if err != nil { return err } for _, e := range report.EngineErrors { if e == "sing-box" { return fmt.Errorf("sing-box 重载前无法读取入口计数;请先运行 ng-agent doctor") } } // Await the controller's durable write before the restart. _, err = requestJSON("/api/agent/usage", report.toMap()) return err } // flushAllUsageBeforeRestart backs the `flush-usage` CLI action used by // agent-update.sh. func flushAllUsageBeforeRestart() error { stateMu.Lock() defer stateMu.Unlock() return flushUsageBeforeChangeLocked() } // ---- AnyTLS IP observations ------------------------------------------------ var ( anytlsCtxRe = regexp.MustCompile(`\[(\d+)\s+\d+ms\]\s+inbound/anytls\[([^\]]+)\]:\s+(.*)$`) anytlsSrcRe = regexp.MustCompile(`inbound connection from\s+(\[[^\]]+\]|[\d.]+):\d+`) anytlsAuthRe = regexp.MustCompile(`\[ng:([^\]]+)\] inbound connection to `) ) type observation struct { CustomerID string `json:"customerId"` IP string `json:"ip"` } type observationsResult struct { observations []observation offset *int64 } // readSingBoxObservations tails the sing-box access log from the saved cursor // and extracts authenticated AnyTLS (customerId, source IP) pairs — agent.js // readSingBoxObservations(). Callers must hold stateMu. func readSingBoxObservationsLocked() observationsResult { empty := observationsResult{observations: []observation{}, offset: nil} info, err := os.Stat(cfg.SingBoxLog) if err != nil { return empty } var cursorOffset int64 if data, err := os.ReadFile(cfg.SingBoxCursor); err == nil { var cursor struct { Offset int64 `json:"offset"` } if err := json.Unmarshal(data, &cursor); err == nil { cursorOffset = cursor.Offset } } size := info.Size() if size < cursorOffset { cursorOffset = 0 } start := cursorOffset if floor := size - 2*1024*1024; floor > start { start = floor } if start >= size { return empty } f, err := os.Open(cfg.SingBoxLog) if err != nil { return empty } defer f.Close() buffer := make([]byte, size-start) if _, err := f.ReadAt(buffer, start); err != nil { return empty } end := bytes.LastIndex(buffer, []byte("\n")) if end < 0 { return empty } resources, err := readResources() if err != nil { return empty } entryTags := map[string]bool{} for _, res := range resources { if meta := resMeta(res); meta != nil && asString(meta["kind"]) == "node" { if tag := asString(meta["metricsTag"]); tag != "" { entryTags[tag] = true } } } sourceByID := map[string]string{} uniqueKeys := []string{} unique := map[string]observation{} for _, line := range strings.Split(string(buffer[:end]), "\n") { ctx := anytlsCtxRe.FindStringSubmatch(line) if ctx == nil || !entryTags[ctx[2]] { continue } key := ctx[2] + ":" + ctx[1] if src := anytlsSrcRe.FindStringSubmatch(ctx[3]); src != nil { ip := src[1] ip = strings.TrimPrefix(ip, "[") ip = strings.TrimSuffix(ip, "]") sourceByID[key] = ip } // Only count authenticated AnyTLS sessions, never unauthenticated probes. if authed := anytlsAuthRe.FindStringSubmatch(ctx[3]); authed != nil { if ip, ok := sourceByID[key]; ok { customerID := authed[1] uniqKey := customerID + "|" + ip if _, exists := unique[uniqKey]; !exists { uniqueKeys = append(uniqueKeys, uniqKey) } unique[uniqKey] = observation{CustomerID: customerID, IP: ip} } } } result := observationsResult{observations: []observation{}} for _, k := range uniqueKeys { result.observations = append(result.observations, unique[k]) } offset := start + int64(end) + 1 result.offset = &offset return result } // ---- periodic loops ---------------------------------------------------------- func usageLoop() { for { sleepMS(60000) func() { report, err := queryUsage() var body map[string]interface{} if err != nil { // JS catch fallback: { samples: [], error } (no engineErrors key). msg := safeError(err.Error()) body = map[string]interface{}{"samples": []usageSample{}, "error": msg} } else { body = report.toMap() } if _, err := requestJSON("/api/agent/usage", body); err != nil { logf("Usage report failed %s", safeError(err.Error())) } }() func() { stateMu.Lock() defer stateMu.Unlock() sing := readSingBoxObservationsLocked() if len(sing.observations) > 0 { if _, err := requestJSON("/api/agent/observations", map[string]interface{}{"observations": sing.observations}); err != nil { logf("IP observation report failed %s", safeError(err.Error())) return } } // A failed upload must not advance the cursor; the same log // lines are retried next cycle (the early return above matches // the JS try/catch scope). if sing.offset != nil { body, _ := marshalJSON(map[string]interface{}{"offset": *sing.offset}) if err := os.WriteFile(cfg.SingBoxCursor, []byte(body+"\n"), 0o600); err != nil { logf("IP observation report failed %s", safeError(err.Error())) } } }() } }