节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
15.87 KiB
Stardust
package identity

import (
	"context"
	"fmt"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"time"
)

// Info 是稳定的节点硬件标识。取不到的字段保持空字符串,不生成随机值。
type Info struct {
	UUID         string
	MachineGuid  string
	SerialNumber string
	Board        string
	DiskID       string
	Macs         string
	Product      string
	Vendor       string
	Processor    string
	GPU          string
	Dpi          string
	Resolution   string
	CLibVersion  string
}

// Gather 采集稳定硬件标识。所有采集失败都被容忍。
func Gather(ctx context.Context) Info {
	var info Info

	switch runtime.GOOS {
	case "linux":
		info.UUID = cleanID(readTrim("/sys/class/dmi/id/product_uuid"))
		info.MachineGuid = cleanID(readTrim("/etc/machine-id"))
		if info.MachineGuid == "" {
			info.MachineGuid = cleanID(readTrim("/var/lib/dbus/machine-id"))
		}
		info.SerialNumber = cleanID(readTrim("/sys/class/dmi/id/product_serial"))
		info.Board = cleanID(readTrim("/sys/class/dmi/id/board_serial"))
		if info.Board == "" {
			info.Board = cleanID(readTrim("/sys/class/dmi/id/board_name"))
		}
		info.DiskID = cleanID(linuxDiskSerial())
	case "windows":
		for key, value := range windowsHardware(ctx) {
			switch key {
			case "UUID":
				info.UUID = cleanID(value)
			case "SERIAL":
				info.SerialNumber = cleanID(value)
			case "BOARD":
				info.Board = cleanID(value)
			case "DISK":
				info.DiskID = cleanID(value)
			case "PRODUCT":
				info.Product = cleanID(value)
			case "VENDOR":
				info.Vendor = cleanID(value)
			case "PROCESSOR":
				info.Processor = cleanID(value)
			case "GPU":
				info.GPU = cleanID(value)
			case "DPI":
				info.Dpi = cleanID(value)
			case "RESOLUTION":
				info.Resolution = cleanID(value)
			case "CRUNTIME":
				info.CLibVersion = cleanID(value)
			}
		}
		info.MachineGuid = cleanID(windowsMachineGuid(ctx))
	case "darwin":
		values := darwinHardware(ctx)
		info.UUID = cleanID(values["UUID"])
		info.SerialNumber = cleanID(values["SERIAL"])
		info.Board = cleanID(values["BOARD"])
	}

	info.Macs = formatMacs(collectMACs())

	return info
}

func readTrim(path string) string {
	data, err := os.ReadFile(path)
	if err != nil {
		return ""
	}

	return strings.TrimSpace(string(data))
}

var invalidIDs = map[string]bool{
	"":                                     true,
	"none":                                 true,
	"unknown":                              true,
	"not specified":                        true,
	"not settable":                         true,
	"to be filled by o.e.m.":               true,
	"to be filled by o.e.m":                true,
	"default string":                       true,
	"system serial number":                 true,
	"0":                                    true,
	"00000000-0000-0000-0000-000000000000": true,
	"ffffffff-ffff-ffff-ffff-ffffffffffff": true,
}

func cleanID(value string) string {
	value = strings.TrimSpace(strings.Trim(value, `"'`))
	if invalidIDs[strings.ToLower(value)] {
		return ""
	}

	// 服务端按大小写敏感比较 UUID/MachineGuid/Serial 等字段,这里必须保留原始大小写。
	// 只有 MAC 由 formatMacs 统一为大写、短横线分隔。
	return value
}

func collectMACs() []string {
	interfaces, err := net.Interfaces()
	if err != nil {
		return nil
	}

	seen := make(map[string]bool)
	var macs []string
	for _, iface := range interfaces {
		if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 || len(iface.HardwareAddr) == 0 {
			continue
		}
		if isVirtualInterface(iface.Name) {
			continue
		}
		if !hasIPv4(iface) {
			continue
		}
		text := iface.HardwareAddr.String()
		if text == "" || text == "00:00:00:00:00:00" || seen[text] {
			continue
		}
		seen[text] = true
		macs = append(macs, text)
	}
	sort.Strings(macs)

	return macs
}

func hasIPv4(iface net.Interface) bool {
	addrs, err := iface.Addrs()
	if err != nil {
		return false
	}
	for _, addr := range addrs {
		if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.To4() != nil && !ipNet.IP.IsLoopback() && !ipNet.IP.IsLinkLocalUnicast() {
			return true
		}
	}

	return false
}

func isVirtualInterface(name string) bool {
	name = strings.ToLower(name)
	prefixes := []string{"lo", "docker", "veth", "br-", "virbr", "vmnet", "tun", "tap", "utun", "awdl", "llw", "bridge", "vboxnet", "zt", "tailscale"}
	for _, prefix := range prefixes {
		if strings.HasPrefix(name, prefix) {
			return true
		}
	}

	return false
}

func formatMacs(macs []string) string {
	result := make([]string, 0, len(macs))
	for _, mac := range macs {
		text := strings.ToUpper(strings.ReplaceAll(mac, ":", "-"))
		if text != "" {
			result = append(result, text)
		}
	}
	sort.Strings(result)
	seen := make(map[string]bool, len(result))
	unique := result[:0]
	for _, mac := range result {
		if !seen[mac] {
			seen[mac] = true
			unique = append(unique, mac)
		}
	}

	return strings.Join(unique, ",")
}

func linuxDiskSerial() string {
	entries, err := os.ReadDir("/sys/block")
	if err != nil {
		return ""
	}
	for _, entry := range entries {
		name := entry.Name()
		if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "md") {
			continue
		}
		if serial := readTrim(filepath.Join("/sys/block", name, "device", "serial")); serial != "" {
			return serial
		}
		if serial := readTrim(filepath.Join("/sys/block", name, "serial")); serial != "" {
			return serial
		}
	}

	return ""
}

func windowsMachineGuid(ctx context.Context) string {
	output := runCommand(ctx, "reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid")
	for _, line := range strings.Split(output, "\n") {
		fields := strings.Fields(line)
		if len(fields) >= 3 && strings.EqualFold(fields[0], "MachineGuid") {
			return fields[len(fields)-1]
		}
	}

	return ""
}

func windowsHardware(ctx context.Context) map[string]string {
	script := `$ErrorActionPreference='SilentlyContinue'
$cs = Get-CimInstance -ClassName Win32_ComputerSystem
$bios = Get-CimInstance -ClassName Win32_BIOS
"UUID=" + (Get-CimInstance -ClassName Win32_ComputerSystemProduct).UUID
"SERIAL=" + $bios.SerialNumber
"BOARD=" + (Get-CimInstance -ClassName Win32_BaseBoard).SerialNumber
"PRODUCT=" + ((Get-CimInstance -ClassName Win32_BaseBoard).Product)
"VENDOR=" + $cs.Manufacturer
"PROCESSOR=" + ((Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1).Name)
"GPU=" + (((Get-CimInstance -ClassName Win32_VideoController | Where-Object { $_.Name -notmatch 'Microsoft Basic' } | ForEach-Object { $n=$_.Name.Trim(); $bytes=0; Get-ChildItem -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}' -ErrorAction SilentlyContinue | ForEach-Object { $p=Get-ItemProperty -LiteralPath $_.PSPath -ErrorAction SilentlyContinue; if ($p.DriverDesc -eq $n -and $p.'HardwareInformation.qwMemorySize' -gt $bytes) { $bytes=$p.'HardwareInformation.qwMemorySize' } }; if ($bytes -le 0) { $bytes=$_.AdapterRAM }; $m=[math]::Round($bytes/1GB); if ($m -gt 0) { "$n($($m)G)" } else { $n } }) -join ','))
"DISK=" + ((Get-CimInstance -ClassName Win32_DiskDrive | Where-Object { $_.MediaType -notmatch 'Removable' } | ForEach-Object { $_.SerialNumber.Trim() } | Where-Object { $_ } | Sort-Object -Unique) -join ',')
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public static class DpiHelper { [DllImport("user32.dll")] public static extern uint GetDpiForSystem(); } public static class MetricsHelper { [DllImport("user32.dll")] public static extern int GetSystemMetrics(int nIndex); }' -ErrorAction SilentlyContinue
$dpi = [DpiHelper]::GetDpiForSystem(); if ($dpi -le 0) { $dpi = 96 }
"DPI=$dpi*$dpi"
"RESOLUTION=" + [MetricsHelper]::GetSystemMetrics(0) + '*' + [MetricsHelper]::GetSystemMetrics(1)
$versions=@()
foreach($base in @('HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes','HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes')){
  if(Test-Path $base){ Get-ChildItem $base | ForEach-Object { $p=Get-ItemProperty $_.PSPath; if($p.Major){ $y=if([int]$p.Major -ge 14){'VC2015-2022'}elseif([int]$p.Major -eq 12){'VC2013'}elseif([int]$p.Major -eq 11){'VC2012'}elseif([int]$p.Major -eq 10){'VC2010'}elseif([int]$p.Major -eq 9){'VC2008'}else{"VC$($p.Major)"}; $v="$($p.Major)"; if($p.Minor){$v+=".$($p.Minor)"}; $s="$y $v"; if($versions -notcontains $s){$versions+=$s} } } }
}
foreach($path in @('HKLM:\SOFTWARE\Microsoft\VisualStudio\10.0\VC\VCRedist\x64','HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\10.0\VC\VCRedist\x64')){ if(Test-Path $path){ $s='VC2010 10.0'; if($versions -notcontains $s){$versions+=$s} } }
"CRUNTIME=" + (($versions | Sort-Object -Descending) -join ';')`
	output := runCommand(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", script)

	return parseKeyValues(output)
}

func darwinHardware(ctx context.Context) map[string]string {
	values := make(map[string]string)

	ioreg := runCommand(ctx, "ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
	for _, line := range strings.Split(ioreg, "\n") {
		if strings.Contains(line, "IOPlatformUUID") {
			if parts := strings.SplitN(line, "=", 2); len(parts) == 2 {
				values["UUID"] = strings.Trim(strings.TrimSpace(parts[1]), `"`)
			}
		}
	}

	profiler := runCommand(ctx, "system_profiler", "SPHardwareDataType")
	for _, line := range strings.Split(profiler, "\n") {
		parts := strings.SplitN(line, ":", 2)
		if len(parts) != 2 {
			continue
		}
		key := strings.TrimSpace(parts[0])
		value := strings.TrimSpace(parts[1])
		switch {
		case strings.EqualFold(key, "Serial Number (system)"):
			values["SERIAL"] = value
		case strings.EqualFold(key, "Hardware UUID"):
			if values["UUID"] == "" {
				values["UUID"] = value
			}
		case strings.EqualFold(key, "Model Identifier"):
			values["BOARD"] = value
		}
	}

	return values
}

func parseKeyValues(output string) map[string]string {
	values := make(map[string]string)
	for _, line := range strings.Split(output, "\n") {
		parts := strings.SplitN(strings.TrimSpace(line), "=", 2)
		if len(parts) == 2 {
			values[strings.ToUpper(strings.TrimSpace(parts[0]))] = strings.TrimSpace(parts[1])
		}
	}

	return values
}

func runCommand(ctx context.Context, name string, args ...string) string {
	ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
	defer cancel()

	cmd := exec.CommandContext(ctx, name, args...)
	output, err := cmd.Output()
	if err != nil {
		return ""
	}

	return string(output)
}

// LocalIP 返回本机出口 IPv4 地址。
func LocalIP() string {
	if conn, err := net.DialTimeout("udp4", "8.8.8.8:80", 2*time.Second); err == nil {
		defer conn.Close()
		if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok && addr.IP != nil {
			return addr.IP.String()
		}
	}

	for _, ip := range localIPv4s() {
		return ip
	}

	return ""
}

// LocalIPs 返回全部非回环 IPv4 地址,主地址排在第一位。
func LocalIPs() string {
	primary := LocalIP()
	ips := localIPv4s()
	sort.Strings(ips)
	if primary != "" {
		ordered := []string{primary}
		for _, ip := range ips {
			if ip != primary {
				ordered = append(ordered, ip)
			}
		}
		ips = ordered
	}

	return strings.Join(ips, ",")
}

func localIPv4s() []string {
	interfaces, err := net.Interfaces()
	if err != nil {
		return nil
	}

	seen := make(map[string]bool)
	var ips []string
	for _, iface := range interfaces {
		if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
			continue
		}
		addrs, err := iface.Addrs()
		if err != nil {
			continue
		}
		for _, addr := range addrs {
			ipNet, ok := addr.(*net.IPNet)
			if !ok || ipNet.IP.To4() == nil || ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() {
				continue
			}
			ip := ipNet.IP.String()
			if !seen[ip] {
				seen[ip] = true
				ips = append(ips, ip)
			}
		}
	}

	return ips
}

// Gateway 返回默认网关。
func Gateway(ctx context.Context) string {
	switch runtime.GOOS {
	case "linux":
		return linuxGateway()
	case "windows":
		return windowsGateway(ctx)
	case "darwin":
		output := runCommand(ctx, "route", "-n", "get", "default")
		for _, line := range strings.Split(output, "\n") {
			parts := strings.SplitN(strings.TrimSpace(line), ":", 2)
			if len(parts) == 2 && strings.EqualFold(strings.TrimSpace(parts[0]), "gateway") {
				return strings.TrimSpace(parts[1])
			}
		}
	}

	return ""
}

func linuxGateway() string {
	data := readTrim("/proc/net/route")
	for _, line := range strings.Split(data, "\n")[1:] {
		fields := strings.Fields(line)
		if len(fields) < 3 || fields[1] != "00000000" {
			continue
		}
		var b [4]byte
		value, err := parseHex32(fields[2])
		if err != nil {
			continue
		}
		b[0] = byte(value)
		b[1] = byte(value >> 8)
		b[2] = byte(value >> 16)
		b[3] = byte(value >> 24)
		return net.IPv4(b[0], b[1], b[2], b[3]).String()
	}

	return ""
}

func parseHex32(text string) (uint32, error) {
	var value uint32
	_, err := fmt.Sscanf(text, "%x", &value)
	if err != nil {
		return 0, err
	}

	return value, nil
}

func windowsGateway(ctx context.Context) string {
	script := `$ErrorActionPreference='SilentlyContinue'
$route = Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Sort-Object RouteMetric | Select-Object -First 1
if ($route) {
    $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex
    if ($adapter -and $adapter.MacAddress) { "$($route.NextHop)/$($adapter.MacAddress)" } else { $route.NextHop }
}`
	output := strings.TrimSpace(runCommand(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", script))
	if output != "" {
		return output
	}

	output = runCommand(ctx, "route", "print", "0.0.0.0")
	for _, line := range strings.Split(output, "\n") {
		fields := strings.Fields(line)
		if len(fields) >= 3 && fields[0] == "0.0.0.0" && fields[1] == "0.0.0.0" && net.ParseIP(fields[2]) != nil {
			return fields[2]
		}
	}

	return ""
}

// DNS 返回 DNS 服务器列表。StarServer 的 Node.Dns 字段限制 50 字符,超长会导致节点校验失败。
func DNS(ctx context.Context) string {
	switch runtime.GOOS {
	case "windows":
		output := runCommand(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command",
			`(Get-DnsClientServerAddress -AddressFamily IPv4 | ForEach-Object { $_.ServerAddresses } | Sort-Object -Unique) -join ','`)
		return truncateDNS(strings.TrimSpace(output))
	default:
		data := readTrim("/etc/resolv.conf")
		var servers []string
		seen := make(map[string]bool)
		for _, line := range strings.Split(data, "\n") {
			fields := strings.Fields(line)
			if len(fields) >= 2 && fields[0] == "nameserver" && !seen[fields[1]] {
				seen[fields[1]] = true
				servers = append(servers, fields[1])
			}
		}
		return truncateDNS(strings.Join(servers, ","))
	}
}

func truncateDNS(value string) string {
	const maxLength = 50
	if len(value) <= maxLength {
		return value
	}
	return value[:maxLength]
}

// TimeZoneName 返回协议使用的时区名。Windows 时区 ID 含空格时改用 GMT 偏移,避免服务端显示异常。
func TimeZoneName() string {
	name, offset := time.Now().Zone()
	if runtime.GOOS == "windows" || name == "" || name == "Local" || strings.Contains(name, " ") {
		sign := "+"
		if offset < 0 {
			sign = "-"
			offset = -offset
		}
		return fmt.Sprintf("GMT%s%02d:%02d", sign, offset/3600, offset%3600/60)
	}

	return name
}

// Architecture 返回协议使用的处理器架构名。
func Architecture() string {
	switch runtime.GOARCH {
	case "amd64":
		return "X64"
	case "386":
		return "X86"
	case "arm64":
		return "Arm64"
	case "arm":
		return "Arm"
	case "mips64", "mips64le":
		return "Mips64"
	case "riscv64":
		return "RiscV64"
	case "loong64":
		return "LA64"
	default:
		return runtime.GOARCH
	}
}

// HostName 返回主机名。
func HostName() string {
	if runtime.GOOS == "linux" {
		if name := readTrim("/etc/hostname"); name != "" {
			return name
		}
	}
	name, _ := os.Hostname()

	return name
}

// UserName 返回当前用户。
func UserName() string {
	if user := os.Getenv("USER"); user != "" {
		return user
	}
	if user := os.Getenv("USERNAME"); user != "" {
		return user
	}

	return ""
}

// ExecutableDir 返回当前可执行文件所在目录。
func ExecutableDir() string {
	exe, err := os.Executable()
	if err != nil {
		return ""
	}

	return filepath.Dir(exe)
}