Files
3x-ui-agent/internal/agent/probe.go
T
Rogue 29de9761c8
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00

986 lines
26 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"
"sync/atomic"
"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,
}
}
// statIfacePin (V2.0.1) names the single interface to count when
// the operator pinned one on the master (its probe_meta.stat_iface,
// delivered on the config poll). "" keeps the automatic counted-set
// selection below. The pin bypasses the three-layer filter on
// purpose: the operator named this exact device, including when it
// is one the filter would skip (a tunnel the node actually bills
// on). A pinned name absent from /proc/net/dev counts nothing —
// the boot epoch changes with the counted set, so the master
// re-baselines instead of booking a phantom delta.
var statIfacePin atomic.Value // string
// SetStatIfacePin records the master's counted-interface pin.
func SetStatIfacePin(name string) { statIfacePin.Store(name) }
func statIfacePinValue() string {
if v := statIfacePin.Load(); v != nil {
return v.(string)
}
return ""
}
func pinnedInterfaces(all []netCounters, pin string) []netCounters {
for _, c := range all {
if c.name == pin {
return []netCounters{c}
}
}
return nil
}
// 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")))
allIfaces := parseNetDev(readProcFile("net/dev"))
counted := countedInterfaces(allIfaces, sysNetRoot)
if pin := statIfacePinValue(); pin != "" {
counted = pinnedInterfaces(allIfaces, pin)
}
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
}