package main import ( "encoding/json" "os" "strconv" "strings" "sync" "time" ) // PeerStat holds the live transfer/handshake counters for a single peer, parsed // from `awg show dump`. Keys are the peer public keys. type PeerStat struct { PublicKey string `json:"public_key"` Endpoint string `json:"endpoint"` LatestHandshake int64 `json:"latest_handshake"` // unix seconds, 0 = never TransferRx int64 `json:"transfer_rx"` // bytes received by server from peer TransferTx int64 `json:"transfer_tx"` // bytes sent by server to peer Keepalive string `json:"keepalive"` } // onlineWindow is how recent a handshake must be for a peer to count as online. // It must stay above AmneziaWG/WireGuard's rekey interval (~120s): a live but // idle peer only refreshes its handshake on rekey, so a shorter window would // falsely flip connected peers to offline. 150s is the tightest safe value — // it makes a genuinely disconnected peer drop within ~30s of its last rekey // instead of the previous 180s. const onlineWindow = 150 * time.Second // Online reports whether the peer handshaked within onlineWindow. func (p PeerStat) Online() bool { if p.LatestHandshake == 0 { return false } return time.Since(time.Unix(p.LatestHandshake, 0)) <= onlineWindow } // peerStats runs `awg show dump` and returns a map keyed by peer public // key. On any error (interface down, awg missing) it returns an empty map so the // caller can still render the registry without live data. // // Dump line layout for a peer (tab-separated): // // public-key preshared-key endpoint allowed-ips latest-handshake rx tx keepalive // // The first line describes the interface itself and is skipped. func peerStats(iface string) map[string]PeerStat { stats := map[string]PeerStat{} if iface == "" { return stats } out, err := output("awg", "show", iface, "dump") if err != nil { return stats } lines := strings.Split(strings.TrimSpace(out), "\n") for i, line := range lines { if i == 0 || strings.TrimSpace(line) == "" { continue // interface header / blank } f := strings.Fields(line) if len(f) < 8 { continue } hs, _ := strconv.ParseInt(f[4], 10, 64) rx, _ := strconv.ParseInt(f[5], 10, 64) tx, _ := strconv.ParseInt(f[6], 10, 64) endpoint := f[2] if endpoint == "(none)" { endpoint = "" } stats[f[0]] = PeerStat{ PublicKey: f[0], Endpoint: endpoint, LatestHandshake: hs, TransferRx: rx, TransferTx: tx, Keepalive: f[7], } } return stats } // ─── persistent, per-peer cumulative traffic ──────────────────────────────────── // // The live PeerStat counters above are volatile: the WG interface resets them on // every restart, and in the container the userspace amneziawg-go data plane // restarts together with the app, so a plain read is zeroed after any restart. // StatRecord keeps a durable running total per peer in awg_stats.json that only // ever grows, by folding successive live readings in as deltas. // StatRecord is the persisted, cumulative traffic tally for one peer. type StatRecord struct { PublicKey string `json:"public_key"` Since int64 `json:"since"` // unix: when accumulation started / was last cleared UpdatedAt int64 `json:"updated_at"` // unix: last sample that moved the totals TotalRx int64 `json:"total_rx"` // accumulated bytes received from the peer TotalTx int64 `json:"total_tx"` // accumulated bytes sent to the peer LastRx int64 `json:"last_rx"` // last raw counter seen — the delta baseline LastTx int64 `json:"last_tx"` } // StatsStore is the on-disk shape of awg_stats.json, keyed by peer public key. type StatsStore struct { Peers map[string]StatRecord `json:"peers"` } // statsLock serialises every read-modify-write of the stats store. It is // deliberately independent of opLock (which guards WG-mutating operations): the // background sampler must never block behind a long install/init operation. var statsLock sync.Mutex // loadStatsLocked reads awg_stats.json. A missing/unreadable file yields an empty // store; a corrupt file is preserved as awg_stats.json.bad and treated as empty, // so a parse error can never silently wipe good data. Callers must hold statsLock. func loadStatsLocked() *StatsStore { data, err := os.ReadFile(statsFile) if err != nil { return &StatsStore{Peers: map[string]StatRecord{}} } var s StatsStore if err := json.Unmarshal(data, &s); err != nil { warn("Stats file %s is invalid — backing it up as %s.bad and starting fresh", statsFile, statsFile) _ = os.Rename(statsFile, statsFile+".bad") return &StatsStore{Peers: map[string]StatRecord{}} } if s.Peers == nil { s.Peers = map[string]StatRecord{} } return &s } // saveStatsLocked writes the store atomically (temp file + rename). Stats are // best-effort telemetry: on any failure it warns and returns rather than dying, // so a full disk can never crash the server or a CLI operation. Holds statsLock. func saveStatsLocked(s *StatsStore) { data, err := json.MarshalIndent(s, "", " ") if err != nil { warn("Failed to encode stats: %v", err) return } if err := os.MkdirAll(dataDir, 0700); err != nil { warn("Failed to create data dir for stats: %v", err) return } tmp, err := os.CreateTemp(dataDir, "stats-*.tmp") if err != nil { warn("Failed to write stats: %v", err) return } tmpName := tmp.Name() if _, err := tmp.Write(data); err != nil { tmp.Close() os.Remove(tmpName) warn("Failed to write stats: %v", err) return } tmp.Close() if err := os.Rename(tmpName, statsFile); err != nil { os.Remove(tmpName) warn("Failed to write stats: %v", err) } } // accumulate folds a raw counter reading into a running total. A reading that is // not below the baseline contributes its increase; a reading that dropped means // the counter was reset (interface/app restart), so the whole current value is // counted as new traffic. Returns the new total and the new baseline. Because // every delta is non-negative, a total can only ever grow — a restart (which // only lowers the live counter) can never reduce the persisted total. func accumulate(total, last, cur int64) (int64, int64) { if cur >= last { return total + (cur - last), cur } return total + cur, cur } // snapshotLocked returns a copy of the store's records for lock-free rendering. func snapshotLocked(s *StatsStore) map[string]StatRecord { out := make(map[string]StatRecord, len(s.Peers)) for k, v := range s.Peers { out[k] = v } return out } // sampleStats folds one live reading into the persisted totals and returns a // snapshot for the caller to render. Passing in an already-fetched live map lets // callers avoid a second `awg show dump`. An empty live map (interface down / // dump failed) is a no-op that still returns the current snapshot — crucially it // never rebaselines or zeroes anything, and peers missing from a non-empty dump // are left untouched too. func sampleStats(live map[string]PeerStat) map[string]StatRecord { statsLock.Lock() defer statsLock.Unlock() s := loadStatsLocked() now := nowUnix() changed := false for pk, ps := range live { rec, ok := s.Peers[pk] if !ok { // First sighting: start counting from now, ignoring whatever the raw // counter already holds (that traffic predates tracking). "since" is // the mark the user sees. s.Peers[pk] = StatRecord{ PublicKey: pk, Since: now, UpdatedAt: now, LastRx: ps.TransferRx, LastTx: ps.TransferTx, } changed = true continue } nrx, brx := accumulate(rec.TotalRx, rec.LastRx, ps.TransferRx) ntx, btx := accumulate(rec.TotalTx, rec.LastTx, ps.TransferTx) if nrx != rec.TotalRx || ntx != rec.TotalTx || brx != rec.LastRx || btx != rec.LastTx { rec.TotalRx, rec.LastRx = nrx, brx rec.TotalTx, rec.LastTx = ntx, btx rec.UpdatedAt = now s.Peers[pk] = rec changed = true } } if changed { saveStatsLocked(s) } return snapshotLocked(s) } // resetStats clears a peer's accumulated totals and rebaselines to the current // live counter, so subsequent samples count only traffic from now on. "since" is // set to now — the fresh mark from which stats accumulate again. func resetStats(pubkey string, curRx, curTx int64) StatRecord { statsLock.Lock() defer statsLock.Unlock() s := loadStatsLocked() now := nowUnix() rec := StatRecord{PublicKey: pubkey, Since: now, UpdatedAt: now, LastRx: curRx, LastTx: curTx} s.Peers[pubkey] = rec saveStatsLocked(s) return rec } // deleteStats drops a peer's record (called when a client is deleted). func deleteStats(pubkey string) { statsLock.Lock() defer statsLock.Unlock() s := loadStatsLocked() if _, ok := s.Peers[pubkey]; ok { delete(s.Peers, pubkey) saveStatsLocked(s) } }