package traffic import ( "bufio" "fmt" "os/exec" "regexp" "strconv" "strings" "sync" "time" ) const ( pollInterval = 20 * time.Second OnlineWindow = 5 * time.Minute trackerTableName = "alpine-router-traffic" ) type IPStats struct { TxBytes uint64 RxBytes uint64 LastActive time.Time } var ( mu sync.RWMutex stats = map[string]*IPStats{} prev = map[string][2]uint64{} useNFT bool ) func Available() bool { mu.RLock() v := useNFT mu.RUnlock() return v } func Start() { if _, err := exec.LookPath("nft"); err != nil { return } if err := setupNFTTable(); err != nil { return } mu.Lock() useNFT = true mu.Unlock() go func() { poll() t := time.NewTicker(pollInterval) defer t.Stop() for range t.C { poll() } }() } func Get(ip string) IPStats { mu.RLock() defer mu.RUnlock() if s, ok := stats[ip]; ok { return *s } return IPStats{} } func IsOnline(ip string) bool { s := Get(ip) return !s.LastActive.IsZero() && time.Since(s.LastActive) < OnlineWindow } func EnsureIPTracked(ip string) { if ip == "" { return } mu.Lock() defer mu.Unlock() if _, ok := prev[ip]; ok { return } prev[ip] = [2]uint64{} addNFTRule(ip) } func setupNFTTable() error { exec.Command("nft", "delete", "table", "ip", trackerTableName).Run() script := fmt.Sprintf(`table ip %s { chain tx { type filter hook forward priority filter + 10; policy accept; } chain rx { type filter hook forward priority filter + 20; policy accept; } }`, trackerTableName) cmd := exec.Command("nft", "-f", "-") cmd.Stdin = strings.NewReader(script) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("nft setup: %s: %w", strings.TrimSpace(string(out)), err) } return nil } func addNFTRule(ip string) { exec.Command("nft", "add", "rule", "ip", trackerTableName, "tx", "ip", "saddr", ip, "counter").Run() exec.Command("nft", "add", "rule", "ip", trackerTableName, "rx", "ip", "daddr", ip, "counter").Run() } var txCounterRe = regexp.MustCompile(`ip saddr (\S+) counter packets \d+ bytes (\d+)`) var rxCounterRe = regexp.MustCompile(`ip daddr (\S+) counter packets \d+ bytes (\d+)`) func poll() { mu.RLock() nft := useNFT mu.RUnlock() if !nft { return } current, err := readNFTCounters() if err != nil { return } now := time.Now() mu.Lock() defer mu.Unlock() for ip, cur := range current { p := prev[ip] dTx := delta(p[0], cur[0]) dRx := delta(p[1], cur[1]) s := stats[ip] if s == nil { s = &IPStats{} stats[ip] = s } s.TxBytes += dTx s.RxBytes += dRx if dTx > 0 || dRx > 0 { s.LastActive = now } prev[ip] = cur } for ip := range prev { if _, exists := current[ip]; !exists { prev[ip] = [2]uint64{0, 0} } } } func readNFTCounters() (map[string][2]uint64, error) { cmd := exec.Command("nft", "list", "table", "ip", trackerTableName) out, err := cmd.Output() if err != nil { return nil, err } result := map[string][2]uint64{} scanner := bufio.NewScanner(strings.NewReader(string(out))) for scanner.Scan() { line := scanner.Text() if m := txCounterRe.FindStringSubmatch(line); m != nil { ip := m[1] b, _ := strconv.ParseUint(m[2], 10, 64) cur := result[ip] cur[0] += b result[ip] = cur } else if m := rxCounterRe.FindStringSubmatch(line); m != nil { ip := m[1] b, _ := strconv.ParseUint(m[2], 10, 64) cur := result[ip] cur[1] += b result[ip] = cur } } return result, nil } func delta(old, cur uint64) uint64 { if cur >= old { return cur - old } return cur }