253 lines
8.7 KiB
Go
253 lines
8.7 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|