Scope B (Windows agent): - probe.go split into shared core + probe_linux.go (verbatim Linux sampler, build-tagged) + probe_windows.go (Win32 sampler: GetSystemTimes CPU diff, GlobalMemoryStatusEx, fixed-drive df totals, GetIfTable2 counted set, MachineGuid boot identity, registry facts; load and TCP/UDP counts stay zero - x/sys v0.47.0 exports no wrapper) - update intents are acknowledged and skipped on Windows (the release assets are Linux binaries; Windows nodes update via install-agent.ps1) - cmd/xui-agent-windows: SCM service host (svc.Run + mgr install/ uninstall/start/stop) mirroring main.go's agent lifecycle wiring - install-agent.ps1: downloads the exe, writes probeOnly agent.json, registers and starts the service, rerun-safe Scope C (version display): - config.GetAgentDisplayVersion (and the master getter) compose from the bare number so a V-form injection no longer doubles the letter (AV2.1.0 -> A2.1.0) - node list view repairs stored AV-prefixed reports at assembly so deployed agents read uniformly without an update Scope D (production fix): - share-link reality sni: when the inbound has a sing-box fragment, sni is the fragment's tls.server_name - the only name the sing-box REALITY server serves. The previous rule picked a uniformly random serverNames entry per generation, which xray tolerated but sing-box refuses as an invalid connection. sid keeps its random pick: the converter carries the whole shortIds list into the fragment, so every listed sid is served.
779 lines
20 KiB
Go
779 lines
20 KiB
Go
//go:build linux
|
|
|
|
package agent
|
|
|
|
// probe_linux.go is the Linux sampler for the probe collector: host
|
|
// Facts and per-heartbeat Metrics read directly from /proc and /sys
|
|
// with the standard library only. The accounting discipline (see
|
|
// probe.go) is the source's:
|
|
// - 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, selected by the
|
|
// three-layer filter below so no byte is counted twice;
|
|
// - boot epoch = kernel boot_id + fingerprint of the counted set.
|
|
|
|
import (
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
)
|
|
|
|
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")))
|
|
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 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 -------------------------------------
|
|
|
|
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
|
|
}
|
|
|
|
// --- 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
|
|
}
|
|
|
|
// --- 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
|
|
}
|