//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 }