Files
awg-profiler-golang/stats.go
T

253 lines
8.7 KiB
Go
Raw Normal View History

2026-07-18 10:02:43 +03:00
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 <iface> 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 <iface> 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)
}
}