mirror of
https://github.com/wweir/sower.git
synced 2026-10-20 00:15:34 +00:00
568 lines
17 KiB
Go
568 lines
17 KiB
Go
package admin
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
rtmetrics "runtime/metrics"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/sower-proxy/sower/internal/metrics"
|
|
)
|
|
|
|
const (
|
|
// maxTotalDomains bounds the cumulative domain map. Domains are never
|
|
// dropped for staleness (unlike the old window-only table); the map only
|
|
// evicts its least-recently-seen entry once full, so from-start totals
|
|
// survive while memory stays bounded.
|
|
maxTotalDomains = 100000
|
|
// maxTotalClients bounds the cumulative per-client table.
|
|
maxTotalClients = 4096
|
|
// maxClientsPerDomain guards one domain's per-client breakdown against a
|
|
// pathological number of source IPs; extra clients are not attributed.
|
|
maxClientsPerDomain = 64
|
|
// maxBlockedDomains bounds the per-domain block counter map. Blocked
|
|
// domains are typically few (bounded by the rule set), but a wildcard
|
|
// rule can match an unbounded domain space, so the map evicts its
|
|
// least-recently-seen entry once full.
|
|
maxBlockedDomains = 10000
|
|
staleDomainAge = time.Hour
|
|
snapshotTopN = 100
|
|
clientsTopN = 50
|
|
domainEvictionBatch = 1024
|
|
clientEvictionBatch = 64
|
|
|
|
// historyCapacity keeps one hour of samples at the 5s poll cadence.
|
|
historyCapacity = 720
|
|
// historyMinGap is the minimum time between two history samples; rapid
|
|
// duplicate polls do not advance the ring.
|
|
historyMinGap = time.Second
|
|
// historyMaxGap clamps the deltas of a sample taken after a long absence
|
|
// (e.g. the console was closed) to zero, so charts do not show a spike
|
|
// that aggregates an entire gap into one interval.
|
|
historyMaxGap = 30 * time.Second
|
|
// rateWindow is the sliding window used for per-second rates.
|
|
rateWindow = 60 * time.Second
|
|
|
|
// maxErrorEvents bounds the in-memory error event ring exposed to the
|
|
// console; the ring drops its oldest entry once full.
|
|
maxErrorEvents = 64
|
|
// maxErrorDetailLen truncates the per-event detail so a malformed host
|
|
// cannot bloat the ring or the SSE payload.
|
|
maxErrorDetailLen = 120
|
|
)
|
|
|
|
// DomainSort selects the ordering of the domain stats in a snapshot.
|
|
type DomainSort string
|
|
|
|
const (
|
|
DomainSortBytes DomainSort = "bytes" // total traffic, high to low
|
|
DomainSortRecent DomainSort = "recent" // most recently seen first
|
|
DomainSortConns DomainSort = "conns" // most connections first
|
|
)
|
|
|
|
func (s DomainSort) valid() bool {
|
|
switch s {
|
|
case DomainSortBytes, DomainSortRecent, DomainSortConns:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Source identifies the entry point a connection or DNS query came through.
|
|
type Source string
|
|
|
|
const (
|
|
SourceAll Source = "all"
|
|
SourceHTTP Source = "http"
|
|
SourceHTTPS Source = "https"
|
|
SourceSocks5 Source = "socks5"
|
|
SourceDNS Source = "dns"
|
|
)
|
|
|
|
func (s Source) valid() bool {
|
|
switch s {
|
|
case SourceAll, SourceHTTP, SourceHTTPS, SourceSocks5, SourceDNS:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// DomainStat aggregates traffic for a single domain. Conns counts both proxy
|
|
// connections and DNS queries involving the domain. LastClientIP is the most
|
|
// recent client IP for the displayed view: the filtered client itself when a
|
|
// client filter is active, otherwise the latest client across all sources.
|
|
type DomainStat struct {
|
|
Domain string `json:"domain"`
|
|
Conns uint64 `json:"conns"`
|
|
BytesUp uint64 `json:"bytesUp"`
|
|
BytesDown uint64 `json:"bytesDown"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
LastClientIP string `json:"lastClientIP"`
|
|
}
|
|
|
|
// TrafficSnapshot is an immutable point-in-time view of traffic stats.
|
|
type TrafficSnapshot struct {
|
|
// Uptime is seconds since the process started. It is an int64 on purpose:
|
|
// a time.Duration would serialize as nanoseconds and the frontend renders
|
|
// seconds.
|
|
Uptime int64 `json:"uptime"`
|
|
DNSQueries uint64 `json:"dnsQueries"`
|
|
Conns struct {
|
|
HTTP uint64 `json:"http"`
|
|
HTTPS uint64 `json:"https"`
|
|
Socks5 uint64 `json:"socks5"`
|
|
} `json:"conns"`
|
|
Active struct {
|
|
HTTP uint64 `json:"http"`
|
|
HTTPS uint64 `json:"https"`
|
|
Socks5 uint64 `json:"socks5"`
|
|
} `json:"active"`
|
|
Rates struct {
|
|
BytesUpPerSec float64 `json:"bytesUpPerSec"`
|
|
BytesDownPerSec float64 `json:"bytesDownPerSec"`
|
|
DNSPerSec float64 `json:"dnsPerSec"`
|
|
ConnsPerSec float64 `json:"connsPerSec"`
|
|
} `json:"rates"`
|
|
RuleHits struct {
|
|
Block uint64 `json:"block"`
|
|
Direct uint64 `json:"direct"`
|
|
Proxy uint64 `json:"proxy"`
|
|
} `json:"ruleHits"`
|
|
// Errors aggregates from-start proxy-side failures by kind. Blocked
|
|
// connections are deliberate routing decisions and are never counted
|
|
// here; only dial, DNS, and accept failures are.
|
|
Errors struct {
|
|
Dial uint64 `json:"dial"`
|
|
DNS uint64 `json:"dns"`
|
|
Accept uint64 `json:"accept"`
|
|
} `json:"errors"`
|
|
// Events is the recent error event ring, newest first, capped at
|
|
// maxErrorEvents entries. It carries the same kinds as Errors.
|
|
Events []ErrorEvent `json:"events"`
|
|
// Blocked is the from-start per-domain block counter, ordered by count.
|
|
// It is independent of the domain map: blocked connections never carry
|
|
// traffic, so they would otherwise be invisible to the traffic view.
|
|
Blocked []BlockedStat `json:"blocked"`
|
|
System struct {
|
|
Goroutines uint64 `json:"goroutines"`
|
|
HeapAlloc uint64 `json:"heapAlloc"`
|
|
} `json:"system"`
|
|
BytesUp uint64 `json:"bytesUp"`
|
|
BytesDown uint64 `json:"bytesDown"`
|
|
Domains []DomainStat `json:"domains"`
|
|
Clients []ClientStat `json:"clients"`
|
|
}
|
|
|
|
// BlockedStat aggregates block decisions for a single domain.
|
|
type BlockedStat struct {
|
|
Domain string `json:"domain"`
|
|
Count uint64 `json:"count"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
}
|
|
|
|
// ClientStat aggregates traffic for a single client IP. Conns counts both
|
|
// proxy connections and DNS queries originating from the IP.
|
|
type ClientStat struct {
|
|
IP string `json:"ip"`
|
|
Hostname string `json:"hostname"`
|
|
Conns uint64 `json:"conns"`
|
|
BytesUp uint64 `json:"bytesUp"`
|
|
BytesDown uint64 `json:"bytesDown"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
}
|
|
|
|
// HistorySample is one point in the in-process history ring. Byte, query,
|
|
// connection and rule-hit fields are deltas since the previous sample; the
|
|
// Active fields are instantaneous connection counts. History lives in process
|
|
// memory and is lost on restart.
|
|
type HistorySample struct {
|
|
At time.Time `json:"at"`
|
|
BytesUp uint64 `json:"bytesUp"`
|
|
BytesDown uint64 `json:"bytesDown"`
|
|
DNS uint64 `json:"dns"`
|
|
Conns uint64 `json:"conns"`
|
|
Active uint64 `json:"active"`
|
|
ActiveHTTP uint64 `json:"activeHttp"`
|
|
ActiveHTTPS uint64 `json:"activeHttps"`
|
|
ActiveSocks uint64 `json:"activeSocks"`
|
|
Block uint64 `json:"block"`
|
|
Direct uint64 `json:"direct"`
|
|
Proxy uint64 `json:"proxy"`
|
|
}
|
|
|
|
// History is the bounded in-process time series served by /api/history.
|
|
type History struct {
|
|
Samples []HistorySample `json:"samples"`
|
|
}
|
|
|
|
// ErrorEvent is one recorded proxy-side failure, exposed to the console so
|
|
// service-level problems (unreachable upstream, DNS breakdown, accept
|
|
// pressure) are visible without grepping the system log.
|
|
type ErrorEvent struct {
|
|
At time.Time `json:"at"`
|
|
Kind string `json:"kind"`
|
|
Detail string `json:"detail"`
|
|
}
|
|
|
|
// Stats records proxied payload bytes and request/connection counters. It is
|
|
// not packet-level network accounting. Byte direction is relative to the
|
|
// client: reads from the client are uploads, writes to the client downloads.
|
|
// Aggregate counters are mirrored to OpenTelemetry instruments
|
|
// (internal/metrics) for the standardized /metrics export; the domain map
|
|
// stays here because per-domain attribution is high-cardinality and not
|
|
// OTel-appropriate.
|
|
type Stats struct {
|
|
start time.Time
|
|
metrics *metrics.Metrics
|
|
|
|
dnsQueries atomic.Uint64
|
|
connsHTTP atomic.Uint64
|
|
connsHTTPS atomic.Uint64
|
|
connsSocks atomic.Uint64
|
|
activeHTTP atomic.Uint64
|
|
activeHTTPS atomic.Uint64
|
|
activeSocks atomic.Uint64
|
|
bytesUp atomic.Uint64
|
|
bytesDown atomic.Uint64
|
|
ruleBlock atomic.Uint64
|
|
ruleDirect atomic.Uint64
|
|
ruleProxy atomic.Uint64
|
|
dialFailed atomic.Uint64
|
|
dnsFailed atomic.Uint64
|
|
acceptFailed atomic.Uint64
|
|
|
|
errMu sync.Mutex
|
|
errEvents []ErrorEvent
|
|
|
|
mu sync.Mutex
|
|
domains map[string]*domainStat
|
|
// clients holds the from-start cumulative per-client totals. It is
|
|
// independent of the domain map so client totals survive domain eviction.
|
|
clients map[string]*clientStat
|
|
// blocked holds the from-start per-domain block counts, fed by the router
|
|
// observer. It is independent of the domain map: blocked connections
|
|
// never carry traffic, so they would otherwise be invisible.
|
|
blocked map[string]*blockedStat
|
|
|
|
histMu sync.Mutex
|
|
history []HistorySample
|
|
lastSample sampleCounters
|
|
}
|
|
|
|
// sampleCounters is the cumulative counter state at one sampling point.
|
|
type sampleCounters struct {
|
|
at time.Time
|
|
bytesUp uint64
|
|
bytesDown uint64
|
|
dns uint64
|
|
conns uint64
|
|
block uint64
|
|
direct uint64
|
|
proxy uint64
|
|
}
|
|
|
|
type domainStat struct {
|
|
conns uint64
|
|
bytesUp uint64
|
|
bytesDown uint64
|
|
lastSeen time.Time
|
|
bySource map[Source]*sourceStat
|
|
byClient map[string]*clientStat // client IP -> per-domain stats
|
|
}
|
|
|
|
// sourceStat is the per-source breakdown of a domain's traffic. A domain can
|
|
// be reached through several entry points (HTTP/HTTPS/SOCKS5 proxies and DNS
|
|
// queries), so each is tracked separately for the source-filtered views.
|
|
type sourceStat struct {
|
|
conns uint64
|
|
bytesUp uint64
|
|
bytesDown uint64
|
|
lastSeen time.Time
|
|
}
|
|
|
|
// clientStat is the per-client-IP breakdown of a domain's traffic.
|
|
type clientStat struct {
|
|
conns uint64
|
|
bytesUp uint64
|
|
bytesDown uint64
|
|
lastSeen time.Time
|
|
}
|
|
|
|
// blockedStat is the per-domain block counter.
|
|
type blockedStat struct {
|
|
count uint64
|
|
lastSeen time.Time
|
|
}
|
|
|
|
// NewStats creates the stats registry and its OpenTelemetry instruments.
|
|
func NewStats() (*Stats, error) {
|
|
m, err := metrics.New()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("init metrics: %w", err)
|
|
}
|
|
return &Stats{
|
|
start: time.Now(),
|
|
metrics: m,
|
|
domains: make(map[string]*domainStat),
|
|
clients: make(map[string]*clientStat),
|
|
blocked: make(map[string]*blockedStat),
|
|
}, nil
|
|
}
|
|
|
|
// Metrics exposes the OpenTelemetry export surface (Prometheus /metrics).
|
|
func (s *Stats) Metrics() *metrics.Metrics { return s.metrics }
|
|
|
|
// RecordDNS counts a DNS query for the given qname from the given client IP.
|
|
func (s *Stats) RecordDNS(qname string, clientIP string) {
|
|
s.dnsQueries.Add(1)
|
|
s.metrics.RecordDNS()
|
|
domain := normalizeDomain(qname)
|
|
if domain == "" {
|
|
return
|
|
}
|
|
s.record(domain, SourceDNS, clientIP, 1, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {
|
|
d.conns++
|
|
src.conns++
|
|
if dc != nil {
|
|
dc.conns++
|
|
}
|
|
})
|
|
}
|
|
|
|
// recentErrorEvents returns the error event ring, newest first. The caller
|
|
// must not mutate the returned slice.
|
|
func (s *Stats) recentErrorEvents() []ErrorEvent {
|
|
s.errMu.Lock()
|
|
defer s.errMu.Unlock()
|
|
out := make([]ErrorEvent, len(s.errEvents))
|
|
for i, ev := range s.errEvents {
|
|
out[len(s.errEvents)-1-i] = ev
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RecordProxyError counts one proxy-side failure of the given kind (dial,
|
|
// dns, or accept) and appends it to the console event ring. Blocked
|
|
// connections must not call this: a block is a routing decision, not a
|
|
// failure. detail is a short human-readable context (host, DNS server); it
|
|
// is truncated and must never carry query strings or credentials.
|
|
func (s *Stats) RecordProxyError(kind string, detail string) {
|
|
switch kind {
|
|
case "dial":
|
|
s.dialFailed.Add(1)
|
|
case "dns":
|
|
s.dnsFailed.Add(1)
|
|
case "accept":
|
|
s.acceptFailed.Add(1)
|
|
default:
|
|
return
|
|
}
|
|
s.metrics.RecordProxyError(kind)
|
|
|
|
ev := ErrorEvent{At: time.Now(), Kind: kind, Detail: cleanErrorDetail(detail)}
|
|
s.errMu.Lock()
|
|
s.errEvents = append(s.errEvents, ev)
|
|
if len(s.errEvents) > maxErrorEvents {
|
|
s.errEvents = s.errEvents[len(s.errEvents)-maxErrorEvents:]
|
|
}
|
|
s.errMu.Unlock()
|
|
}
|
|
|
|
// cleanErrorDetail bounds an error detail to the byte budget without
|
|
// splitting a UTF-8 rune (a host can be an IDN in Chinese), and collapses
|
|
// line breaks so a multiline error cannot break the console alert layout.
|
|
func cleanErrorDetail(detail string) string {
|
|
detail = strings.ReplaceAll(detail, "\n", " ")
|
|
if len(detail) > maxErrorDetailLen {
|
|
detail = detail[:maxErrorDetailLen]
|
|
for !utf8.ValidString(detail) {
|
|
detail = detail[:len(detail)-1]
|
|
}
|
|
}
|
|
return detail
|
|
}
|
|
|
|
// RecordRoute counts one connection routing decision. It is called exactly
|
|
// once per connection by the router observer. Block decisions additionally
|
|
// attribute the domain to the per-domain block counter; the domain may be
|
|
// empty for non-block routes or when it cannot be determined.
|
|
func (s *Stats) RecordRoute(route, domain string) {
|
|
switch route {
|
|
case "block":
|
|
s.ruleBlock.Add(1)
|
|
if domain != "" {
|
|
s.recordBlock(domain)
|
|
}
|
|
case "direct":
|
|
s.ruleDirect.Add(1)
|
|
case "proxy":
|
|
s.ruleProxy.Add(1)
|
|
}
|
|
s.metrics.RecordRoute(route)
|
|
}
|
|
|
|
// recordBlock attributes one block decision to a domain, evicting a batch
|
|
// of least-recently-seen entries when the map is full.
|
|
func (s *Stats) recordBlock(domain string) {
|
|
domain = normalizeDomain(domain)
|
|
if domain == "" {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
b := s.blocked[domain]
|
|
if b == nil {
|
|
if len(s.blocked) >= maxBlockedDomains {
|
|
s.evictOldestBlockedLocked(blockedEvictionBatch)
|
|
}
|
|
b = &blockedStat{}
|
|
s.blocked[domain] = b
|
|
}
|
|
b.count++
|
|
b.lastSeen = time.Now()
|
|
}
|
|
|
|
// WrapConn wraps the client connection before protocol parsing so header and
|
|
// handshake bytes are counted. The per-kind connection counter is bumped
|
|
// immediately; per-domain attribution happens later via BindConn.
|
|
func (s *Stats) WrapConn(conn net.Conn, kind string) net.Conn {
|
|
switch kind {
|
|
case "http":
|
|
s.connsHTTP.Add(1)
|
|
s.activeHTTP.Add(1)
|
|
case "https":
|
|
s.connsHTTPS.Add(1)
|
|
s.activeHTTPS.Add(1)
|
|
case "socks5":
|
|
s.connsSocks.Add(1)
|
|
s.activeSocks.Add(1)
|
|
}
|
|
s.metrics.ConnOpened(kind)
|
|
return &countingConn{
|
|
Conn: conn,
|
|
stats: s,
|
|
kind: kind,
|
|
clientIP: ClientIPOf(conn.RemoteAddr()),
|
|
created: time.Now(),
|
|
}
|
|
}
|
|
|
|
// BindConn attributes an already-wrapped connection to a domain and bumps the
|
|
// domain's connection counter. It is a no-op when conn was not created by
|
|
// WrapConn.
|
|
func (s *Stats) BindConn(conn net.Conn, domain string) {
|
|
cc, ok := conn.(*countingConn)
|
|
if !ok {
|
|
return
|
|
}
|
|
domain = normalizeDomain(domain)
|
|
if domain == "" {
|
|
return
|
|
}
|
|
cc.bind(domain)
|
|
source := Source(cc.kind)
|
|
s.record(domain, source, cc.clientIP, 1, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {
|
|
d.conns++
|
|
src.conns++
|
|
if dc != nil {
|
|
dc.conns++
|
|
}
|
|
})
|
|
}
|
|
|
|
// record attributes one event to a domain, its source, and optionally its
|
|
// client IP, with the given counter deltas. The client IP may be empty when it
|
|
// cannot be determined; the per-client stat is then nil and callers must
|
|
// guard. The deltas also feed the independent cumulative client table, so
|
|
// client totals are exact regardless of domain eviction.
|
|
func (s *Stats) record(domain string, source Source, clientIP string, conns, up, down uint64, update func(*domainStat, *sourceStat, *clientStat)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
d := s.domains[domain]
|
|
if d == nil {
|
|
if len(s.domains) >= maxTotalDomains {
|
|
s.evictOldestDomainsLocked(domainEvictionBatch)
|
|
}
|
|
d = &domainStat{bySource: make(map[Source]*sourceStat)}
|
|
s.domains[domain] = d
|
|
}
|
|
src := d.bySource[source]
|
|
if src == nil {
|
|
src = &sourceStat{}
|
|
d.bySource[source] = src
|
|
}
|
|
var dc *clientStat
|
|
if clientIP != "" {
|
|
if d.byClient == nil {
|
|
d.byClient = make(map[string]*clientStat)
|
|
}
|
|
dc = d.byClient[clientIP]
|
|
if dc == nil && len(d.byClient) < maxClientsPerDomain {
|
|
dc = &clientStat{}
|
|
d.byClient[clientIP] = dc
|
|
}
|
|
}
|
|
update(d, src, dc)
|
|
now := time.Now()
|
|
d.lastSeen = now
|
|
src.lastSeen = now
|
|
if dc != nil {
|
|
dc.lastSeen = now
|
|
}
|
|
|
|
if clientIP != "" {
|
|
cc := s.clients[clientIP]
|
|
if cc == nil {
|
|
if len(s.clients) >= maxTotalClients {
|
|
s.evictOldestClientsLocked(clientEvictionBatch)
|
|
}
|
|
cc = &clientStat{}
|
|
s.clients[clientIP] = cc
|
|
}
|
|
cc.conns += conns
|
|
cc.bytesUp += up
|
|
cc.bytesDown += down
|
|
cc.lastSeen = now
|
|
}
|
|
}
|
|
|
|
// normalizeDomain lowercases and strips the trailing dot of a domain or qname.
|
|
func normalizeDomain(domain string) string {
|
|
domain = strings.TrimSpace(domain)
|
|
domain = strings.TrimSuffix(domain, ".")
|
|
return strings.ToLower(domain)
|
|
}
|
|
|
|
// clientIPOf extracts the host part of a network address, falling back to the
|
|
// full address when it has no port.
|
|
func ClientIPOf(addr net.Addr) string {
|
|
if addr == nil {
|
|
return ""
|
|
}
|
|
if host, _, err := net.SplitHostPort(addr.String()); err == nil {
|
|
return host
|
|
}
|
|
return addr.String()
|
|
}
|
|
|
|
// goroutineCount reports the current number of goroutines.
|
|
func goroutineCount() uint64 {
|
|
var samples = []rtmetrics.Sample{{Name: "/sched/goroutines:goroutines"}}
|
|
rtmetrics.Read(samples)
|
|
return samples[0].Value.Uint64()
|
|
}
|
|
|
|
// heapAlloc reads the live heap size without stopping the world.
|
|
func heapAlloc() uint64 {
|
|
var samples = []rtmetrics.Sample{{Name: "/memory/classes/heap/objects:bytes"}}
|
|
rtmetrics.Read(samples)
|
|
return samples[0].Value.Uint64()
|
|
}
|