951 lines
25 KiB
Go
951 lines
25 KiB
Go
package agent
|
|
|
|
// probe.go ports monitor-probe's agent collector (collect.rs, MIT) to Go:
|
|
// host Facts (once per process) and per-heartbeat Metrics read directly from
|
|
// /proc and /sys with the standard library only. The port keeps the source's
|
|
// accounting discipline:
|
|
// - memory "used" is free(1)'s column: MemTotal - MemAvailable;
|
|
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree);
|
|
// - network totals sum only "counted" interfaces — loopback, container,
|
|
// tunnel, bridge/bond/VLAN stacked devices are excluded by a three-layer
|
|
// filter so no byte is counted twice;
|
|
// - boot epoch = kernel boot_id + FNV-1a fingerprint of the counted set,
|
|
// so a set change re-baselines exactly like a reboot;
|
|
// - every source degrades independently: an unreadable file yields a zero
|
|
// field, never a failed collection round.
|
|
|
|
import (
|
|
"hash/fnv"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
)
|
|
|
|
// ProbeFacts mirrors monitor's Facts frame: static host identity, sampled
|
|
// once and piggybacked on the register request and (cheaply) heartbeats.
|
|
type ProbeFacts struct {
|
|
Hostname string `json:"hostname"`
|
|
OS string `json:"os"`
|
|
Kernel string `json:"kernel"`
|
|
Arch string `json:"arch"`
|
|
Virt string `json:"virt"`
|
|
CPUName string `json:"cpuName"`
|
|
CPUCores uint32 `json:"cpuCores"`
|
|
MemTotal uint64 `json:"memTotal"`
|
|
SwapTotal uint64 `json:"swapTotal"`
|
|
DiskTotal uint64 `json:"diskTotal"`
|
|
AgentVersion string `json:"agentVersion"`
|
|
IPv4 string `json:"ipv4"`
|
|
IPv6 string `json:"ipv6"`
|
|
}
|
|
|
|
// ProbeMetrics mirrors monitor's Metrics frame: one sample per heartbeat.
|
|
type ProbeMetrics struct {
|
|
BootID string `json:"bootId"`
|
|
Iface string `json:"iface"`
|
|
Uptime uint64 `json:"uptime"`
|
|
CPU float32 `json:"cpu"`
|
|
Load [3]float32 `json:"load"`
|
|
MemTotal uint64 `json:"memTotal"`
|
|
MemUsed uint64 `json:"memUsed"`
|
|
SwapTotal uint64 `json:"swapTotal"`
|
|
SwapUsed uint64 `json:"swapUsed"`
|
|
DiskTotal uint64 `json:"diskTotal"`
|
|
DiskUsed uint64 `json:"diskUsed"`
|
|
NetRxTotal uint64 `json:"netRxTotal"`
|
|
NetTxTotal uint64 `json:"netTxTotal"`
|
|
NetRx uint64 `json:"netRx"`
|
|
NetTx uint64 `json:"netTx"`
|
|
TCP uint32 `json:"tcp"`
|
|
UDP uint32 `json:"udp"`
|
|
Procs uint32 `json:"procs"`
|
|
}
|
|
|
|
// probeCollector holds the cross-sample state the rates need (previous CPU
|
|
// jiffies and per-interface counters). Kept in memory only, like the source.
|
|
type probeCollector struct {
|
|
mu sync.Mutex
|
|
prevCPU *cpuSample
|
|
prevNet map[string][2]uint64
|
|
prevNetT time.Time
|
|
}
|
|
|
|
type cpuSample struct{ total, idle uint64 }
|
|
|
|
var (
|
|
probeColl = &probeCollector{}
|
|
factsOnce sync.Once
|
|
factsCache *ProbeFacts
|
|
)
|
|
|
|
// collectFacts returns the process-cached Facts, sampling on first use.
|
|
func collectFacts() *ProbeFacts {
|
|
factsOnce.Do(func() { factsCache = sampleFacts() })
|
|
return factsCache
|
|
}
|
|
|
|
func sampleFacts() *ProbeFacts {
|
|
mem := parseMeminfo(readProcFile("meminfo"))
|
|
diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts")))
|
|
cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo"))
|
|
v4, v6 := probeAddresses()
|
|
return &ProbeFacts{
|
|
Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"),
|
|
OS: osPrettyName(),
|
|
Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"),
|
|
Arch: runtime.GOARCH,
|
|
Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")),
|
|
CPUName: cpuName,
|
|
CPUCores: cpuCores,
|
|
MemTotal: mem["MemTotal"],
|
|
SwapTotal: mem["SwapTotal"],
|
|
DiskTotal: diskTotal,
|
|
AgentVersion: config.GetAgentDisplayVersion(),
|
|
IPv4: v4,
|
|
IPv6: v6,
|
|
}
|
|
}
|
|
|
|
// collectMetrics takes one full sample. Individual sources fail soft.
|
|
func collectMetrics() *ProbeMetrics {
|
|
probeColl.mu.Lock()
|
|
defer probeColl.mu.Unlock()
|
|
mem := parseMeminfo(readProcFile("meminfo"))
|
|
memTotal, memUsed := memUsedFree(mem)
|
|
swapTotal, swapUsed := swapUsedFree(mem)
|
|
diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts")))
|
|
counted := countedInterfaces(parseNetDev(readProcFile("net/dev")), sysNetRoot)
|
|
var rxTotal, txTotal uint64
|
|
names := make([]string, 0, len(counted))
|
|
for _, c := range counted {
|
|
rxTotal += c.rx
|
|
txTotal += c.tx
|
|
names = append(names, c.name)
|
|
}
|
|
bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names)
|
|
rxRate, txRate := probeColl.netRate(counted, time.Now())
|
|
tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6"))
|
|
return &ProbeMetrics{
|
|
BootID: bootID,
|
|
Uptime: parseUptime(readProcFile("uptime")),
|
|
CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))),
|
|
Load: parseLoadavg(readProcFile("loadavg")),
|
|
MemTotal: memTotal,
|
|
MemUsed: memUsed,
|
|
SwapTotal: swapTotal,
|
|
SwapUsed: swapUsed,
|
|
DiskTotal: diskTotal,
|
|
DiskUsed: diskUsed,
|
|
NetRxTotal: rxTotal,
|
|
NetTxTotal: txTotal,
|
|
NetRx: rxRate,
|
|
NetTx: txRate,
|
|
TCP: tcp,
|
|
UDP: udp,
|
|
Procs: procCount(),
|
|
}
|
|
}
|
|
|
|
// --- file helpers -----------------------------------------------------------
|
|
|
|
const sysNetRoot = "/sys/class/net"
|
|
|
|
func readProcFile(name string) string {
|
|
b, err := os.ReadFile(filepath.Join("/proc", name))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func readTrimFile(path, fallback string) string {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
if s := strings.TrimSpace(string(b)); s != "" {
|
|
return s
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// --- memory (free口径) -------------------------------------------------------
|
|
|
|
func parseMeminfo(text string) map[string]uint64 {
|
|
out := make(map[string]uint64)
|
|
for _, line := range strings.Split(text, "\n") {
|
|
key, rest, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
f := strings.Fields(rest)
|
|
if len(f) == 0 {
|
|
continue
|
|
}
|
|
kb, err := strconv.ParseUint(f[0], 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out[key] = kb * 1024
|
|
}
|
|
return out
|
|
}
|
|
|
|
// memUsedFree is free(1)'s used column: total - MemAvailable, with the
|
|
// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an
|
|
// available above the total, as LXCFS can report, -> total - MemFree).
|
|
func memUsedFree(m map[string]uint64) (total, used uint64) {
|
|
total = m["MemTotal"]
|
|
if total == 0 {
|
|
return 0, 0
|
|
}
|
|
avail, ok := m["MemAvailable"]
|
|
if !ok {
|
|
avail = m["MemFree"] + m["Buffers"] + m["Cached"]
|
|
}
|
|
if avail > total {
|
|
avail = m["MemFree"]
|
|
}
|
|
if avail > total {
|
|
avail = total
|
|
}
|
|
return total, total - avail
|
|
}
|
|
|
|
func swapUsedFree(m map[string]uint64) (total, used uint64) {
|
|
total = m["SwapTotal"]
|
|
free := m["SwapFree"]
|
|
if free > total {
|
|
free = total
|
|
}
|
|
return total, total - free
|
|
}
|
|
|
|
// --- cpu --------------------------------------------------------------------
|
|
|
|
func parseCPUJiffies(stat string) *cpuSample {
|
|
for _, line := range strings.Split(stat, "\n") {
|
|
if !strings.HasPrefix(line, "cpu ") {
|
|
continue
|
|
}
|
|
f := strings.Fields(line[4:])
|
|
if len(f) < 5 {
|
|
return nil
|
|
}
|
|
var v [8]uint64
|
|
var total uint64
|
|
for i := 0; i < len(f) && i < 8; i++ {
|
|
n, err := strconv.ParseUint(f[i], 10, 64)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
v[i] = n
|
|
total += n
|
|
}
|
|
return &cpuSample{total: total, idle: v[3] + v[4]}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
|
|
if now == nil {
|
|
return 0
|
|
}
|
|
prev := c.prevCPU
|
|
c.prevCPU = now
|
|
if prev == nil || now.total <= prev.total {
|
|
return 0
|
|
}
|
|
dt := float32(now.total - prev.total)
|
|
di := float32(0)
|
|
if now.idle > prev.idle {
|
|
di = float32(now.idle - prev.idle)
|
|
}
|
|
pct := (dt - di) / dt * 100
|
|
if pct < 0 {
|
|
pct = 0
|
|
}
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
return pct
|
|
}
|
|
|
|
func parseLoadavg(text string) [3]float32 {
|
|
var out [3]float32
|
|
f := strings.Fields(text)
|
|
for i := 0; i < 3 && i < len(f); i++ {
|
|
if v, err := strconv.ParseFloat(f[i], 32); err == nil {
|
|
out[i] = float32(v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseUptime(text string) uint64 {
|
|
f := strings.Fields(text)
|
|
if len(f) == 0 {
|
|
return 0
|
|
}
|
|
v, err := strconv.ParseFloat(f[0], 64)
|
|
if err != nil || v < 0 {
|
|
return 0
|
|
}
|
|
return uint64(v)
|
|
}
|
|
|
|
// cpuInfo returns the display name and logical core count from /proc/cpuinfo.
|
|
func cpuInfo(text string) (string, uint32) {
|
|
name := ""
|
|
cores := uint32(0)
|
|
for _, line := range strings.Split(text, "\n") {
|
|
key, val, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(key) {
|
|
case "processor":
|
|
cores++
|
|
case "model name", "Model", "cpu model":
|
|
if name == "" {
|
|
name = strings.TrimSpace(val)
|
|
}
|
|
}
|
|
}
|
|
if cores == 0 {
|
|
cores = uint32(runtime.NumCPU())
|
|
if cores == 0 {
|
|
cores = 1
|
|
}
|
|
}
|
|
if name == "" {
|
|
name = armCPUName(text)
|
|
}
|
|
if name == "" {
|
|
name = "unknown"
|
|
}
|
|
return name, cores
|
|
}
|
|
|
|
// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does,
|
|
// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76).
|
|
func armCPUName(text string) string {
|
|
var vendor *armVendor
|
|
var names []string
|
|
seen := map[string]bool{}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
key, val, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(key) {
|
|
case "CPU implementer":
|
|
vendor = nil
|
|
if id, ok := parseHexID(val); ok {
|
|
for i := range armVendors {
|
|
if armVendors[i].id == id {
|
|
vendor = &armVendors[i]
|
|
}
|
|
}
|
|
}
|
|
case "CPU part":
|
|
if vendor == nil {
|
|
continue
|
|
}
|
|
name := vendor.name
|
|
if id, ok := parseHexID(val); ok {
|
|
for _, p := range vendor.parts {
|
|
if p.id == id {
|
|
name = p.name
|
|
}
|
|
}
|
|
}
|
|
if !seen[name] {
|
|
seen[name] = true
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(names, "/")
|
|
}
|
|
|
|
func parseHexID(s string) (uint32, bool) {
|
|
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
|
|
v, err := strconv.ParseUint(s, 16, 32)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return uint32(v), true
|
|
}
|
|
|
|
type armPart struct {
|
|
id uint32
|
|
name string
|
|
}
|
|
type armVendor struct {
|
|
id uint32
|
|
name string
|
|
parts []armPart
|
|
}
|
|
|
|
// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts
|
|
// plausibly met on VPS hardware. An unlisted part falls back to the vendor.
|
|
var armVendors = []armVendor{
|
|
{0x41, "ARM", []armPart{
|
|
{0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"},
|
|
{0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"},
|
|
{0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"},
|
|
{0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"},
|
|
{0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"},
|
|
{0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"},
|
|
{0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"},
|
|
{0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"},
|
|
{0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"},
|
|
}},
|
|
{0x48, "HiSilicon", []armPart{
|
|
{0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"},
|
|
{0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"},
|
|
}},
|
|
{0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}},
|
|
{0x50, "APM", []armPart{{0x000, "X-Gene"}}},
|
|
{0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}},
|
|
{0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}},
|
|
}
|
|
|
|
// --- os / virt ---------------------------------------------------------------
|
|
|
|
func osPrettyName() string {
|
|
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
|
|
return strings.Trim(v, "\"'")
|
|
}
|
|
}
|
|
}
|
|
return "Linux"
|
|
}
|
|
|
|
// detectVirt ports the source's chain: containers first, then hypervisor.
|
|
// CPUID is unreachable from Go's stdlib, so the x86 signature check is
|
|
// replaced by the DMI strings (the source's own non-x86 fallback) plus the
|
|
// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both
|
|
// reads as "none", same caveat the source documents for cleared CPUID bits.
|
|
func detectVirt(mounts, cpuinfo string) string {
|
|
if _, err := os.Stat("/proc/vz"); err == nil {
|
|
if _, err := os.Stat("/proc/bc"); err != nil {
|
|
return "openvz"
|
|
}
|
|
}
|
|
if s := readTrimFile("/run/systemd/container", ""); s != "" {
|
|
return s
|
|
}
|
|
if _, err := os.Stat("/.dockerenv"); err == nil {
|
|
return "docker"
|
|
}
|
|
if _, err := os.Stat("/run/.containerenv"); err == nil {
|
|
return "podman"
|
|
}
|
|
for _, line := range strings.Split(mounts, "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") {
|
|
return "lxc"
|
|
}
|
|
}
|
|
if _, err := os.Stat("/proc/xen"); err == nil {
|
|
return "xen"
|
|
}
|
|
if _, err := os.Stat("/sys/hypervisor/type"); err == nil {
|
|
return "xen"
|
|
}
|
|
dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " +
|
|
readTrimFile("/sys/class/dmi/id/sys_vendor", ""))
|
|
for _, m := range []struct{ word, name string }{
|
|
{"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"},
|
|
{"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"},
|
|
{"bhyve", "bhyve"}, {"xen", "xen"},
|
|
} {
|
|
if strings.Contains(dmi, m.word) {
|
|
return m.name
|
|
}
|
|
}
|
|
if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil {
|
|
return "kvm"
|
|
}
|
|
if strings.Contains(cpuinfo, "hypervisor") {
|
|
return "vm"
|
|
}
|
|
return "none"
|
|
}
|
|
|
|
// --- network: counted set, epoch, rates -------------------------------------
|
|
|
|
type netCounters struct {
|
|
name string
|
|
rx, tx uint64
|
|
}
|
|
|
|
func parseNetDev(text string) []netCounters {
|
|
var out []netCounters
|
|
lines := strings.Split(text, "\n")
|
|
for _, line := range lines {
|
|
name, rest, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
f := strings.Fields(rest)
|
|
if len(f) < 16 {
|
|
continue
|
|
}
|
|
rx, err1 := strconv.ParseUint(f[0], 10, 64)
|
|
tx, err2 := strconv.ParseUint(f[8], 10, 64)
|
|
if err1 != nil || err2 != nil {
|
|
continue
|
|
}
|
|
out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES).
|
|
var skipIfacePrefixes = []string{
|
|
"lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale",
|
|
"cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap",
|
|
"erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt",
|
|
}
|
|
|
|
func skipIfaceName(name string) bool {
|
|
for _, p := range skipIfacePrefixes {
|
|
if strings.HasPrefix(name, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays —
|
|
// the kernel books their bytes again on the device beneath.
|
|
func isStackedName(name string) bool {
|
|
if strings.Contains(name, ".") {
|
|
return true
|
|
}
|
|
for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} {
|
|
if strings.HasPrefix(name, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var tunnelLinkTypes = map[string]bool{
|
|
"65534": true, "768": true, "769": true, "776": true, "778": true, "823": true,
|
|
}
|
|
|
|
// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes
|
|
// are already counted on another device. Hardware (a `device` link) exempts
|
|
// a device from the tunnel/port rules — a raw-IP LTE modem shares its link
|
|
// type with WireGuard but carries real traffic.
|
|
func countedElsewhere(sysRoot, name string) bool {
|
|
dev := filepath.Join(sysRoot, name)
|
|
uevent := readTrimFile(filepath.Join(dev, "uevent"), "")
|
|
devtypeIs := func(set ...string) bool {
|
|
for _, line := range strings.Split(uevent, "\n") {
|
|
for _, want := range set {
|
|
if line == want {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") {
|
|
return true
|
|
}
|
|
if entries, err := os.ReadDir(dev); err == nil {
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "lower_") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dev, "device")); err == nil {
|
|
return false
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil {
|
|
return true
|
|
}
|
|
if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] {
|
|
return true
|
|
}
|
|
return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve")
|
|
}
|
|
|
|
// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs.
|
|
func ifaceCounts(sysRoot, name string) bool {
|
|
if skipIfaceName(name) || isStackedName(name) {
|
|
return false
|
|
}
|
|
return !countedElsewhere(sysRoot, name)
|
|
}
|
|
|
|
func countedInterfaces(all []netCounters, sysRoot string) []netCounters {
|
|
out := make([]netCounters, 0, len(all))
|
|
for _, c := range all {
|
|
if ifaceCounts(sysRoot, c.name) {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bootEpoch = "{kernel boot_id}/{FNV-1a-64 of the sorted counted names}".
|
|
// The master only tests it for equality: any change re-baselines.
|
|
func bootEpoch(kernelBootID string, names []string) string {
|
|
sorted := append([]string(nil), names...)
|
|
sort.Strings(sorted)
|
|
h := fnv.New64a()
|
|
_, _ = h.Write([]byte(strings.Join(sorted, "\n")))
|
|
return kernelBootID + "/" + hex16(h.Sum64())
|
|
}
|
|
|
|
func hex16(v uint64) string {
|
|
const digits = "0123456789abcdef"
|
|
var buf [16]byte
|
|
for i := 15; i >= 0; i-- {
|
|
buf[i] = digits[v&0xf]
|
|
v >>= 4
|
|
}
|
|
return string(buf[:])
|
|
}
|
|
|
|
// netRate diffs per interface over those present in both samples
|
|
// (saturating), divided by the elapsed wall time.
|
|
func (c *probeCollector) netRate(counted []netCounters, now time.Time) (uint64, uint64) {
|
|
prev, prevT := c.prevNet, c.prevNetT
|
|
next := make(map[string][2]uint64, len(counted))
|
|
for _, ct := range counted {
|
|
next[ct.name] = [2]uint64{ct.rx, ct.tx}
|
|
}
|
|
c.prevNet, c.prevNetT = next, now
|
|
if prev == nil {
|
|
return 0, 0
|
|
}
|
|
secs := now.Sub(prevT).Seconds()
|
|
if secs <= 0 {
|
|
return 0, 0
|
|
}
|
|
var rx, tx uint64
|
|
for _, ct := range counted {
|
|
p, ok := prev[ct.name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if ct.rx > p[0] {
|
|
rx += ct.rx - p[0]
|
|
}
|
|
if ct.tx > p[1] {
|
|
tx += ct.tx - p[1]
|
|
}
|
|
}
|
|
return uint64(float64(rx) / secs), uint64(float64(tx) / secs)
|
|
}
|
|
|
|
// --- addresses (scope §1.5) ---------------------------------------------------
|
|
|
|
// probeAddresses picks one address per family: candidates come from
|
|
// non-skip interfaces that are up, are not link-local, prefer public over
|
|
// private, and for v6 avoid temporary/deprecated/tentative addresses.
|
|
func probeAddresses() (string, string) {
|
|
transient := transientV6(readProcFile("net/if_inet6"))
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
type cand struct {
|
|
ip net.IP
|
|
transient bool
|
|
}
|
|
var held []cand
|
|
for _, ifc := range ifaces {
|
|
if ifc.Flags&net.FlagUp == 0 {
|
|
continue
|
|
}
|
|
if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" {
|
|
continue
|
|
}
|
|
addrs, err := ifc.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, addr := range addrs {
|
|
var ip net.IP
|
|
switch v := addr.(type) {
|
|
case *net.IPNet:
|
|
ip = v.IP
|
|
case *net.IPAddr:
|
|
ip = v.IP
|
|
}
|
|
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
continue
|
|
}
|
|
if !lendsAddress(ifc.Name, ip) {
|
|
continue
|
|
}
|
|
c := cand{ip: ip}
|
|
if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil {
|
|
for _, t := range transient {
|
|
if t.Equal(ip6) {
|
|
c.transient = true
|
|
}
|
|
}
|
|
}
|
|
held = append(held, c)
|
|
}
|
|
}
|
|
pick := func(v6 bool) string {
|
|
var best *cand
|
|
for i := range held {
|
|
c := &held[i]
|
|
if (c.ip.To4() == nil) != v6 {
|
|
continue
|
|
}
|
|
if best == nil {
|
|
best = c
|
|
continue
|
|
}
|
|
bp, cp := isPublicIP(best.ip), isPublicIP(c.ip)
|
|
if cp != bp {
|
|
if cp {
|
|
best = c
|
|
}
|
|
continue
|
|
}
|
|
if v6 && best.transient && !c.transient {
|
|
best = c
|
|
}
|
|
}
|
|
if best == nil {
|
|
return ""
|
|
}
|
|
return best.ip.String()
|
|
}
|
|
return pick(false), pick(true)
|
|
}
|
|
|
|
// lendsAddress mirrors the source: a skipped interface lends no address,
|
|
// except a WireGuard interface lending a public v6 that is not in
|
|
// Cloudflare WARP's shared 2606:4700::/32.
|
|
func lendsAddress(name string, ip net.IP) bool {
|
|
if !skipIfaceName(name) {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) {
|
|
ip16 := ip.To16()
|
|
return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// transientV6 parses /proc/net/if_inet6 for addresses carrying
|
|
// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags.
|
|
func transientV6(text string) []net.IP {
|
|
const transientFlags = 0x01 | 0x08 | 0x20 | 0x40
|
|
var out []net.IP
|
|
for _, line := range strings.Split(text, "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) < 6 {
|
|
continue
|
|
}
|
|
flags, err := strconv.ParseUint(f[4], 16, 32)
|
|
if err != nil || flags&transientFlags == 0 {
|
|
continue
|
|
}
|
|
if len(f[0]) != 32 {
|
|
continue
|
|
}
|
|
ip := make(net.IP, 16)
|
|
ok := true
|
|
for i := 0; i < 16; i++ {
|
|
b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8)
|
|
if err != nil {
|
|
ok = false
|
|
break
|
|
}
|
|
ip[i] = byte(b)
|
|
}
|
|
if ok {
|
|
out = append(out, ip)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isPublicIP mirrors monitor's is_public: v4 excludes RFC1918, CGNAT
|
|
// 100.64/10, loopback, link-local, 0/8, >=224, 192.0.0/24, 198.18/15;
|
|
// v6 is only 2000::/3.
|
|
func isPublicIP(ip net.IP) bool {
|
|
if ip4 := ip.To4(); ip4 != nil {
|
|
a, b, c := ip4[0], ip4[1], ip4[2]
|
|
switch {
|
|
case ip4.IsPrivate(), ip4.IsLoopback(), ip4.IsLinkLocalUnicast():
|
|
return false
|
|
case a == 0 || a >= 224:
|
|
return false
|
|
case a == 100 && b&0xc0 == 64:
|
|
return false
|
|
case a == 192 && b == 0 && c == 0:
|
|
return false
|
|
case a == 198 && b&0xfe == 18:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
ip16 := ip.To16()
|
|
if ip16 == nil {
|
|
return false
|
|
}
|
|
return ip16[0]&0xe0 == 0x20
|
|
}
|
|
|
|
// --- sockets / processes ------------------------------------------------------
|
|
|
|
func connCounts(v4, v6 string) (tcp, udp uint32) {
|
|
stat := func(text, prefix, key string) uint32 {
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if !strings.HasPrefix(line, prefix) {
|
|
continue
|
|
}
|
|
f := strings.Fields(line[len(prefix):])
|
|
for i := 0; i+1 < len(f); i++ {
|
|
if f[i] == key {
|
|
if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil {
|
|
return uint32(n)
|
|
}
|
|
return 0
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse")
|
|
udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse")
|
|
return tcp, udp
|
|
}
|
|
|
|
func procCount() uint32 {
|
|
entries, err := os.ReadDir("/proc")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
var n uint32
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
digits := name != ""
|
|
for i := 0; i < len(name); i++ {
|
|
if name[i] < '0' || name[i] > '9' {
|
|
digits = false
|
|
break
|
|
}
|
|
}
|
|
if digits {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// --- disk (df口径) ---------------------------------------------------------------
|
|
|
|
var skipFSTypes = map[string]bool{
|
|
"tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true,
|
|
"cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true,
|
|
"hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true,
|
|
"pstore": true, "bpf": true, "configfs": true, "fusectl": true,
|
|
"binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true,
|
|
"efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true,
|
|
"fuse": true, "rpc_pipefs": true,
|
|
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true,
|
|
"glusterfs": true, "9p": true,
|
|
}
|
|
|
|
func skipFSType(fstype string) bool {
|
|
if skipFSTypes[fstype] {
|
|
return true
|
|
}
|
|
// Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source.
|
|
if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// realMountPoints ports parse_mounts: only the topmost mount per path,
|
|
// deduped by source device (zfs by pool), real storage only.
|
|
func realMountPoints(text string) []string {
|
|
type row struct{ dev, mount, fstype string }
|
|
var rows []row
|
|
top := map[string]int{}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) < 3 {
|
|
continue
|
|
}
|
|
rows = append(rows, row{f[0], f[1], f[2]})
|
|
top[f[1]] = len(rows) - 1
|
|
}
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for i, r := range rows {
|
|
if top[r.mount] != i || skipFSType(r.fstype) {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" {
|
|
continue
|
|
}
|
|
key := r.dev
|
|
if r.fstype == "zfs" {
|
|
if j := strings.IndexByte(key, '/'); j >= 0 {
|
|
key = key[:j]
|
|
}
|
|
}
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, strings.ReplaceAll(r.mount, `\040`, " "))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// diskUsage sums statfs over the mounts: total = f_blocks, used =
|
|
// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve).
|
|
func diskUsage(mounts []string) (total, used uint64) {
|
|
for _, m := range mounts {
|
|
var st syscall.Statfs_t
|
|
if err := syscall.Statfs(m, &st); err != nil {
|
|
continue
|
|
}
|
|
bs := uint64(st.Frsize)
|
|
if bs == 0 {
|
|
bs = uint64(st.Bsize)
|
|
}
|
|
total += st.Blocks * bs
|
|
if st.Blocks > st.Bfree {
|
|
used += (st.Blocks - st.Bfree) * bs
|
|
}
|
|
}
|
|
return total, used
|
|
}
|