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.
464 lines
13 KiB
Go
464 lines
13 KiB
Go
//go:build windows
|
|
|
|
package agent
|
|
|
|
// probe_windows.go is the Windows sampler for the probe collector,
|
|
// feeding the same Facts/Metrics contract and accounting discipline
|
|
// as the Linux sampler (see probe.go) from the Win32 surface:
|
|
//
|
|
// - CPU%: GetSystemTimes differenced through the shared cpuPercent
|
|
// (kernel time includes idle, exactly like /proc/stat jiffies);
|
|
// - memory: GlobalMemoryStatusEx, used = TotalPhys - AvailPhys
|
|
// (the free(1) "available" column's spirit); swap is the pagefile
|
|
// pair (TotalPageFile/AvailPageFile);
|
|
// - disk: fixed drives only (GetDriveType == DRIVE_FIXED), summed
|
|
// like df: used = total - free bytes (GetDiskFreeSpaceEx's
|
|
// TotalFree, NOT the caller-available figure, which discounts
|
|
// quotas the way f_bavail discounts the ext reserve);
|
|
// - network: GetIfTable2 rows filtered to OperStatus Up, ifType
|
|
// Ethernet (6) or IEEE80211 (71), non-loopback. This approximates
|
|
// the Linux three-layer filter: Windows exposes no stacking
|
|
// topology in the MIB, but tunnel/virtual adapters report their
|
|
// own ifTypes (tunnel 131, loopback 24, ...), so an allowlist of
|
|
// physical media lands on the same counted set in practice. The
|
|
// cost of the difference: a physical-medium bond/virtual switch
|
|
// vNIC presenting as Ethernet IS counted here even where Linux
|
|
// layer 3 would have skipped it;
|
|
// - boot identity: the registry MachineGuid stands in for the
|
|
// kernel boot_id; the counted-set FNV fingerprint is shared, so a
|
|
// NIC change re-baselines exactly like on Linux;
|
|
// - uptime: GetTickCount64 (via x/sys' DurationSinceBoot wrapper);
|
|
// - load averages, TCP/UDP connection counts have no exported
|
|
// x/sys/windows (v0.47.0) source — GetSystemTimes-style raw
|
|
// counters exist for CPU/memory, but the package exports no
|
|
// GetTcpTable2/GetUdpTable2 wrapper, so per the independent-
|
|
// degradation discipline Load stays zero and TCP/UDP stay 0
|
|
// until a wrapper exists;
|
|
// - every call fails soft to zeros, like an unreadable /proc file.
|
|
|
|
import (
|
|
"net"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
|
|
"golang.org/x/sys/windows"
|
|
"golang.org/x/sys/windows/registry"
|
|
)
|
|
|
|
// kernel32 procs x/sys/windows v0.47.0 does not wrap.
|
|
var (
|
|
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
|
|
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
|
|
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
|
|
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
|
|
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
|
|
)
|
|
|
|
func sampleFacts() *ProbeFacts {
|
|
memTotal, _, swapTotal, _ := winMemory()
|
|
diskTotal, _ := winDiskUsage()
|
|
v4, v6 := winAddresses()
|
|
return &ProbeFacts{
|
|
Hostname: winHostname(),
|
|
OS: winOSName(),
|
|
Kernel: winKernelBuild(),
|
|
Arch: runtime.GOARCH,
|
|
Virt: winVirt(),
|
|
CPUName: winCPUName(),
|
|
CPUCores: uint32(runtime.NumCPU()),
|
|
MemTotal: memTotal,
|
|
SwapTotal: swapTotal,
|
|
DiskTotal: diskTotal,
|
|
AgentVersion: config.GetAgentDisplayVersion(),
|
|
IPv4: v4,
|
|
IPv6: v6,
|
|
}
|
|
}
|
|
|
|
func collectMetrics() *ProbeMetrics {
|
|
probeColl.mu.Lock()
|
|
defer probeColl.mu.Unlock()
|
|
memTotal, memUsed, swapTotal, swapUsed := winMemory()
|
|
diskTotal, diskUsed := winDiskUsage()
|
|
rows := winIfaceRows()
|
|
all := make([]netCounters, 0, len(rows))
|
|
counted := make([]netCounters, 0, len(rows))
|
|
for _, r := range rows {
|
|
c := netCounters{name: r.name, rx: r.rx, tx: r.tx}
|
|
all = append(all, c)
|
|
if winIfaceCounts(r) {
|
|
counted = append(counted, c)
|
|
}
|
|
}
|
|
if pin := statIfacePinValue(); pin != "" {
|
|
counted = pinnedInterfaces(all, 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)
|
|
}
|
|
rxRate, txRate := probeColl.netRate(counted, time.Now())
|
|
return &ProbeMetrics{
|
|
BootID: bootEpoch(winMachineGUID(), names),
|
|
Uptime: uint64(windows.DurationSinceBoot().Seconds()),
|
|
CPU: probeColl.cpuPercent(winCPUSample()),
|
|
Load: [3]float32{},
|
|
MemTotal: memTotal,
|
|
MemUsed: memUsed,
|
|
SwapTotal: swapTotal,
|
|
SwapUsed: swapUsed,
|
|
DiskTotal: diskTotal,
|
|
DiskUsed: diskUsed,
|
|
NetRxTotal: rxTotal,
|
|
NetTxTotal: txTotal,
|
|
NetRx: rxRate,
|
|
NetTx: txRate,
|
|
TCP: 0, // no exported x/sys wrapper (see header)
|
|
UDP: 0, // no exported x/sys wrapper (see header)
|
|
Procs: winProcCount(),
|
|
}
|
|
}
|
|
|
|
// --- registry helpers --------------------------------------------------------
|
|
|
|
func regGetString(root registry.Key, path, name string) string {
|
|
k, err := registry.OpenKey(root, path, registry.QUERY_VALUE)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer k.Close()
|
|
v, _, err := k.GetStringValue(name)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(v)
|
|
}
|
|
|
|
func winMachineGUID() string {
|
|
return regGetString(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, "MachineGuid")
|
|
}
|
|
|
|
func winOSName() string {
|
|
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
|
|
name := regGetString(registry.LOCAL_MACHINE, key, "ProductName")
|
|
if name == "" {
|
|
return "Windows"
|
|
}
|
|
// DisplayVersion ("23H2") disambiguates: ProductName still says
|
|
// "Windows 10" on most Windows 11 installs.
|
|
if dv := regGetString(registry.LOCAL_MACHINE, key, "DisplayVersion"); dv != "" {
|
|
name += " " + dv
|
|
}
|
|
return name
|
|
}
|
|
|
|
func winKernelBuild() string {
|
|
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
|
|
if b := regGetString(registry.LOCAL_MACHINE, key, "CurrentBuild"); b != "" {
|
|
return b
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func winCPUName() string {
|
|
if n := regGetString(registry.LOCAL_MACHINE,
|
|
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, "ProcessorNameString"); n != "" {
|
|
return n
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// winVirt is best-effort: the BIOS strings Windows itself surfaces.
|
|
// No match reads "unknown" (Windows guests of a hypervisor that hides
|
|
// them are indistinguishable from bare metal this way).
|
|
func winVirt() string {
|
|
const bios = `HARDWARE\DESCRIPTION\System\BIOS`
|
|
hay := strings.ToLower(regGetString(registry.LOCAL_MACHINE, bios, "SystemManufacturer") +
|
|
" " + regGetString(registry.LOCAL_MACHINE, bios, "SystemProductName"))
|
|
for _, m := range []struct{ word, name string }{
|
|
{"vmware", "vmware"}, {"virtualbox", "virtualbox"}, {"qemu", "kvm"},
|
|
{"kvm", "kvm"}, {"xen", "xen"}, {"virtual machine", "hyper-v"},
|
|
{"amazon ec2", "kvm"}, {"google", "kvm"}, {"bochs", "bochs"},
|
|
} {
|
|
if strings.Contains(hay, m.word) {
|
|
return m.name
|
|
}
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func winHostname() string {
|
|
if h, err := os.Hostname(); err == nil && h != "" {
|
|
return h
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// --- cpu / memory --------------------------------------------------------------
|
|
|
|
// filetime100ns flattens a FILETIME (100ns ticks) for the shared differ.
|
|
func filetime100ns(ft windows.Filetime) uint64 {
|
|
return uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)
|
|
}
|
|
|
|
// winCPUSample reads the cumulative system times as one cpuSample:
|
|
// total = kernel + user (kernel already includes idle, like jiffies).
|
|
func winCPUSample() *cpuSample {
|
|
var idle, kernel, user windows.Filetime
|
|
r, _, _ := procGetSystemTimes.Call(
|
|
uintptr(unsafe.Pointer(&idle)),
|
|
uintptr(unsafe.Pointer(&kernel)),
|
|
uintptr(unsafe.Pointer(&user)))
|
|
if r == 0 {
|
|
return nil
|
|
}
|
|
return &cpuSample{
|
|
total: filetime100ns(kernel) + filetime100ns(user),
|
|
idle: filetime100ns(idle),
|
|
}
|
|
}
|
|
|
|
type memoryStatusEx struct {
|
|
Length uint32
|
|
MemoryLoad uint32
|
|
TotalPhys uint64
|
|
AvailPhys uint64
|
|
TotalPageFile uint64
|
|
AvailPageFile uint64
|
|
TotalVirtual uint64
|
|
AvailVirtual uint64
|
|
AvailExtendedVirtual uint64
|
|
}
|
|
|
|
// usedFromTotalAvail is the used-column discipline as a pure
|
|
// function: used = total - avail, saturating at 0.
|
|
func usedFromTotalAvail(total, avail uint64) uint64 {
|
|
if avail > total {
|
|
return 0
|
|
}
|
|
return total - avail
|
|
}
|
|
|
|
func winMemory() (total, used, swapTotal, swapUsed uint64) {
|
|
var ms memoryStatusEx
|
|
ms.Length = uint32(unsafe.Sizeof(ms))
|
|
r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms)))
|
|
if r == 0 {
|
|
return 0, 0, 0, 0
|
|
}
|
|
return ms.TotalPhys, usedFromTotalAvail(ms.TotalPhys, ms.AvailPhys),
|
|
ms.TotalPageFile, usedFromTotalAvail(ms.TotalPageFile, ms.AvailPageFile)
|
|
}
|
|
|
|
// --- disk (df口径) -----------------------------------------------------------
|
|
|
|
// driveSpace is one fixed drive's GetDiskFreeSpaceEx result.
|
|
type driveSpace struct{ total, free uint64 }
|
|
|
|
// sumDriveSpace aggregates fixed drives the df way: used = total -
|
|
// free (the caller-available figure is deliberately not consulted).
|
|
func sumDriveSpace(ds []driveSpace) (total, used uint64) {
|
|
for _, d := range ds {
|
|
total += d.total
|
|
used += usedFromTotalAvail(d.total, d.free)
|
|
}
|
|
return total, used
|
|
}
|
|
|
|
// splitDriveStrings decodes the GetLogicalDriveStrings multi-string
|
|
// buffer (NUL-separated roots, double-NUL terminated). Pure, so the
|
|
// layout is unit-testable off-device.
|
|
func splitDriveStrings(buf []uint16) []string {
|
|
var out []string
|
|
start := 0
|
|
for i, c := range buf {
|
|
if c != 0 {
|
|
continue
|
|
}
|
|
if i == start {
|
|
break // double NUL: end of the list
|
|
}
|
|
out = append(out, windows.UTF16ToString(buf[start:i]))
|
|
start = i + 1
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fixedDriveRoots lists roots ("C:\") of DRIVE_FIXED drives.
|
|
func fixedDriveRoots() []string {
|
|
n, err := windows.GetLogicalDriveStrings(0, nil)
|
|
if err != nil || n == 0 {
|
|
return nil
|
|
}
|
|
buf := make([]uint16, n)
|
|
n, err = windows.GetLogicalDriveStrings(n, &buf[0])
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var roots []string
|
|
for _, s := range splitDriveStrings(buf[:n]) {
|
|
p, err := windows.UTF16PtrFromString(s)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if windows.GetDriveType(p) == windows.DRIVE_FIXED {
|
|
roots = append(roots, s)
|
|
}
|
|
}
|
|
return roots
|
|
}
|
|
|
|
func winDiskUsage() (total, used uint64) {
|
|
var spaces []driveSpace
|
|
for _, root := range fixedDriveRoots() {
|
|
p, err := windows.UTF16PtrFromString(root)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var freeToCaller, totalBytes, totalFree uint64
|
|
if err := windows.GetDiskFreeSpaceEx(p, &freeToCaller, &totalBytes, &totalFree); err != nil {
|
|
continue
|
|
}
|
|
spaces = append(spaces, driveSpace{total: totalBytes, free: totalFree})
|
|
}
|
|
return sumDriveSpace(spaces)
|
|
}
|
|
|
|
// --- network: counted set ------------------------------------------------------
|
|
|
|
// winIfaceRow is the filter-relevant slice of one MIB_IF_ROW2.
|
|
type winIfaceRow struct {
|
|
name string
|
|
ifType uint32
|
|
operUp bool
|
|
rx, tx uint64
|
|
}
|
|
|
|
// winIfaceCounts applies the counted-set rule to one row (see the
|
|
// file header for how it approximates the Linux three-layer filter).
|
|
func winIfaceCounts(r winIfaceRow) bool {
|
|
if !r.operUp {
|
|
return false
|
|
}
|
|
if r.ifType == windows.IF_TYPE_SOFTWARE_LOOPBACK {
|
|
return false
|
|
}
|
|
return r.ifType == windows.IF_TYPE_ETHERNET_CSMACD ||
|
|
r.ifType == windows.IF_TYPE_IEEE80211
|
|
}
|
|
|
|
// winIfaceRows reads the full MIB_IF_TABLE2 once; collectMetrics
|
|
// derives both the all-interfaces list (for the operator pin) and the
|
|
// counted set from it.
|
|
func winIfaceRows() []winIfaceRow {
|
|
var table *windows.MibIfTable2
|
|
if err := windows.GetIfTable2Ex(windows.MibIfTableNormal, &table); err != nil || table == nil {
|
|
return nil
|
|
}
|
|
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
|
|
n := int(table.NumEntries)
|
|
if n == 0 {
|
|
return nil
|
|
}
|
|
rows := unsafe.Slice(&table.Table[0], n)
|
|
out := make([]winIfaceRow, 0, n)
|
|
for i := range rows {
|
|
r := &rows[i]
|
|
out = append(out, winIfaceRow{
|
|
name: windows.UTF16ToString(r.Alias[:]),
|
|
ifType: r.Type,
|
|
operUp: r.OperStatus == windows.IfOperStatusUp,
|
|
rx: r.InOctets,
|
|
tx: r.OutOctets,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- processes -----------------------------------------------------------------
|
|
|
|
func winProcCount() uint32 {
|
|
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer windows.CloseHandle(snap)
|
|
var pe windows.ProcessEntry32
|
|
pe.Size = uint32(unsafe.Sizeof(pe))
|
|
if err := windows.Process32First(snap, &pe); err != nil {
|
|
return 0
|
|
}
|
|
var n uint32 = 1
|
|
for {
|
|
if err := windows.Process32Next(snap, &pe); err != nil {
|
|
break
|
|
}
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
// --- addresses -------------------------------------------------------------------
|
|
|
|
// winAddresses picks one address per family from up, non-loopback
|
|
// interfaces: public preferred over private, like the Linux picker —
|
|
// only without its /proc inputs (no operstate file, no temporary-
|
|
// address flags; net.Interfaces' own flags do the loopback cut).
|
|
func winAddresses() (string, string) {
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
var cands []net.IP
|
|
for _, ifc := range ifaces {
|
|
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
|
|
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() || ip.IsLoopback() {
|
|
continue
|
|
}
|
|
cands = append(cands, ip)
|
|
}
|
|
}
|
|
pick := func(v6 bool) string {
|
|
var best net.IP
|
|
for _, ip := range cands {
|
|
if (ip.To4() == nil) != v6 {
|
|
continue
|
|
}
|
|
if best == nil {
|
|
best = ip
|
|
continue
|
|
}
|
|
if isPublicIP(ip) && !isPublicIP(best) {
|
|
best = ip
|
|
}
|
|
}
|
|
if best == nil {
|
|
return ""
|
|
}
|
|
return best.String()
|
|
}
|
|
return pick(false), pick(true)
|
|
}
|