节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
23.57 KiB
Stardust
//! Stable hardware identifiers and system/network information.
//!
//! Values that cannot be read stay empty. Random values are never generated,
//! so a node keeps a stable identity across restarts.

use std::io::Read;
use std::net::UdpSocket;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use get_if_addrs::IfAddr;

/// Stable hardware identifiers.
#[derive(Clone, Debug, Default)]
pub struct Info {
    /// DMI product UUID.
    pub uuid: String,
    /// OS installation identifier.
    pub machine_guid: String,
    /// Device serial number.
    pub serial_number: String,
    /// Mainboard identifier.
    pub board: String,
    /// System disk serial.
    pub disk_id: String,
    /// Uppercase MAC list separated by commas.
    pub macs: String,
    /// Product/model name.
    pub product: String,
    /// Manufacturer.
    pub vendor: String,
    /// CPU model.
    pub processor: String,
    /// GPU list.
    pub gpu: String,
    /// Screen DPI.
    pub dpi: String,
    /// Screen resolution.
    pub resolution: String,
    /// Installed C runtime versions.
    pub c_lib_version: String,
}

/// Operating system information.
#[derive(Clone, Debug, Default)]
pub struct SystemInfo {
    /// OS name.
    pub os_name: String,
    /// OS version.
    pub os_version: String,
    /// Hostname.
    pub hostname: String,
}

/// Collects stable hardware identifiers, tolerating every failure.
pub fn gather() -> Info {
    let mut info = Info::default();

    #[cfg(target_os = "linux")]
    {
        info.uuid = clean_id(&read_trim("/sys/class/dmi/id/product_uuid"));
        info.machine_guid = {
            let value = clean_id(&read_trim("/etc/machine-id"));
            if value.is_empty() {
                clean_id(&read_trim("/var/lib/dbus/machine-id"))
            } else {
                value
            }
        };
        info.serial_number = clean_id(&read_trim("/sys/class/dmi/id/product_serial"));
        let board = clean_id(&read_trim("/sys/class/dmi/id/board_serial"));
        info.board = if board.is_empty() {
            clean_id(&read_trim("/sys/class/dmi/id/board_name"))
        } else {
            board
        };
        info.disk_id = clean_id(&linux_disk_serial());
    }

    #[cfg(target_os = "windows")]
    {
        for (key, value) in windows_hardware() {
            match key.as_str() {
                "UUID" => info.uuid = clean_id(&value),
                "SERIAL" => info.serial_number = clean_id(&value),
                "BOARD" => info.board = clean_id(&value),
                "DISK" => info.disk_id = clean_id(&value),
                "PRODUCT" => info.product = clean_id(&value),
                "VENDOR" => info.vendor = clean_id(&value),
                "PROCESSOR" => info.processor = clean_id(&value),
                "GPU" => info.gpu = clean_id(&value),
                "DPI" => info.dpi = clean_id(&value),
                "RESOLUTION" => info.resolution = clean_id(&value),
                "CRUNTIME" => info.c_lib_version = clean_id(&value),
                _ => {}
            }
        }
        info.machine_guid = clean_id(&windows_machine_guid());
    }

    #[cfg(target_os = "macos")]
    {
        let values = darwin_hardware();
        for (key, value) in values {
            match key.as_str() {
                "UUID" => info.uuid = clean_id(&value),
                "SERIAL" => info.serial_number = clean_id(&value),
                "BOARD" => info.board = clean_id(&value),
                _ => {}
            }
        }
    }

    info.macs = collect_macs();
    info
}

/// Collects operating system information.
pub fn system_info() -> SystemInfo {
    let details = os_info::get();
    #[allow(unused_mut)]
    let mut info = SystemInfo {
        os_name: details.os_type().to_string(),
        os_version: details.version().to_string(),
        hostname: host_name(),
    };

    #[cfg(target_os = "linux")]
    if let Some(name) = linux_os_name() {
        info.os_name = name;
    }

    #[cfg(target_os = "windows")]
    {
        let (name, version) = windows_os_info();
        if !name.is_empty() {
            info.os_name = name;
        }
        if !version.is_empty() {
            info.os_version = version;
        }
    }

    info
}

#[cfg(target_os = "windows")]
fn windows_os_info() -> (String, String) {
    let script = r#"$ErrorActionPreference='SilentlyContinue'
$os = Get-CimInstance -ClassName Win32_OperatingSystem
"OSNAME=" + $os.Caption
"OSVERSION=" + $os.Version"#;
    let output = run_output(
        "powershell",
        &["-NoProfile", "-NonInteractive", "-Command", script],
        Duration::from_secs(8),
    );
    let values = parse_key_values(&output);
    let name = values
        .get("OSNAME")
        .map(|value| value.trim().trim_start_matches("Microsoft ").to_string())
        .unwrap_or_default();
    let version = values
        .get("OSVERSION")
        .map(|value| value.trim().to_string())
        .unwrap_or_default();
    (name, version)
}

/// Returns the timezone in the format expected by StarServer.
pub fn time_zone_name() -> String {
    let offset = chrono::Local::now().offset().to_string();
    if offset.starts_with('+') || offset.starts_with('-') {
        format!("GMT{offset}")
    } else {
        offset
    }
}

#[allow(dead_code)]
fn read_trim(path: impl AsRef<Path>) -> String {
    std::fs::read_to_string(path)
        .map(|value| value.trim().to_string())
        .unwrap_or_default()
}

const INVALID_IDS: &[&str] = &[
    "",
    "none",
    "unknown",
    "not specified",
    "not settable",
    "to be filled by o.e.m.",
    "to be filled by o.e.m",
    "default string",
    "system serial number",
    "0",
    "00000000-0000-0000-0000-000000000000",
    "ffffffff-ffff-ffff-ffff-ffffffffffff",
];

fn clean_id(value: &str) -> String {
    let value = value.trim().trim_matches(['"', '\'']).trim();
    if INVALID_IDS
        .iter()
        .any(|invalid| invalid.eq_ignore_ascii_case(value))
    {
        return String::new();
    }
    // The server compares UUID/MachineGuid/Serial case-sensitively.
    value.to_string()
}

#[cfg(target_os = "linux")]
fn linux_disk_serial() -> String {
    let Ok(entries) = std::fs::read_dir("/sys/block") else {
        return String::new();
    };
    for entry in entries.flatten() {
        let name = entry.file_name().to_string_lossy().into_owned();
        if name.starts_with("loop")
            || name.starts_with("ram")
            || name.starts_with("dm-")
            || name.starts_with("md")
        {
            continue;
        }
        for relative in ["device/serial", "serial"] {
            let value = read_trim(format!("/sys/block/{name}/{relative}"));
            if !value.is_empty() {
                return value;
            }
        }
    }
    String::new()
}

fn run_output(program: &str, args: &[&str], timeout: Duration) -> String {
    let mut child = match Command::new(program)
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
    {
        Ok(child) => child,
        Err(_) => return String::new(),
    };

    let mut stdout = match child.stdout.take() {
        Some(value) => value,
        None => {
            let _ = child.kill();
            let _ = child.wait();
            return String::new();
        }
    };
    let reader = std::thread::spawn(move || {
        let mut text = String::new();
        let _ = stdout.read_to_string(&mut text);
        text
    });

    let started = Instant::now();
    loop {
        match child.try_wait() {
            Ok(Some(_)) => break,
            Ok(None) if started.elapsed() < timeout => {
                std::thread::sleep(Duration::from_millis(50));
            }
            _ => {
                let _ = child.kill();
                let _ = child.wait();
                break;
            }
        }
    }
    let _ = child.wait();

    reader.join().unwrap_or_default()
}

#[cfg(target_os = "windows")]
fn windows_machine_guid() -> String {
    let output = run_output(
        "reg",
        &[
            "query",
            r"HKLM\SOFTWARE\Microsoft\Cryptography",
            "/v",
            "MachineGuid",
        ],
        Duration::from_secs(8),
    );
    for line in output.lines() {
        let fields: Vec<&str> = line.split_whitespace().collect();
        if fields.len() >= 3 && fields[0].eq_ignore_ascii_case("MachineGuid") {
            return fields[fields.len() - 1].to_string();
        }
    }
    String::new()
}

#[cfg(target_os = "windows")]
fn windows_hardware() -> std::collections::HashMap<String, String> {
    let script = r#"$ErrorActionPreference='SilentlyContinue'
$cs = Get-CimInstance -ClassName Win32_ComputerSystem
$bb = Get-CimInstance -ClassName Win32_BaseBoard
"UUID=" + (Get-CimInstance -ClassName Win32_ComputerSystemProduct).UUID
"SERIAL=" + (Get-CimInstance -ClassName Win32_BIOS).SerialNumber
"BOARD=" + $bb.SerialNumber
"PRODUCT=" + $bb.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 ';')"#;
    let output = run_output(
        "powershell",
        &["-NoProfile", "-NonInteractive", "-Command", script],
        Duration::from_secs(15),
    );
    parse_key_values(&output)
}

fn parse_key_values(output: &str) -> std::collections::HashMap<String, String> {
    let mut values = std::collections::HashMap::new();
    for line in output.lines() {
        if let Some((key, value)) = line.split_once('=') {
            values.insert(key.trim().to_ascii_uppercase(), value.trim().to_string());
        }
    }
    values
}

#[cfg(target_os = "macos")]
fn darwin_hardware() -> std::collections::HashMap<String, String> {
    let mut values = std::collections::HashMap::new();
    let ioreg = run_output(
        "ioreg",
        &["-rd1", "-c", "IOPlatformExpertDevice"],
        Duration::from_secs(8),
    );
    for line in ioreg.lines() {
        if line.contains("IOPlatformUUID") {
            if let Some((_, value)) = line.split_once('=') {
                values.insert(
                    "UUID".to_string(),
                    value.trim().trim_matches('"').trim().to_string(),
                );
            }
        }
    }

    let profiler = run_output(
        "system_profiler",
        &["SPHardwareDataType"],
        Duration::from_secs(12),
    );
    for line in profiler.lines() {
        let Some((key, value)) = line.split_once(':') else {
            continue;
        };
        let key = key.trim();
        let value = value.trim().to_string();
        if key.eq_ignore_ascii_case("Serial Number (system)") {
            values.insert("SERIAL".to_string(), value);
        } else if key.eq_ignore_ascii_case("Hardware UUID") {
            values.entry("UUID".to_string()).or_insert(value);
        } else if key.eq_ignore_ascii_case("Model Identifier") {
            values.insert("BOARD".to_string(), value);
        }
    }

    values
}

fn is_tunnel_interface(name: &str) -> bool {
    let name = name.to_ascii_lowercase();
    ["tun", "tap", "utun", "awdl", "llw"]
        .iter()
        .any(|prefix| name.starts_with(prefix))
}

fn is_virtual_interface(name: &str) -> bool {
    let name = name.to_ascii_lowercase();
    [
        "lo",
        "docker",
        "veth",
        "vethernet",
        "wsl",
        "br-",
        "virbr",
        "vmnet",
        "tun",
        "tap",
        "utun",
        "awdl",
        "llw",
        "bridge",
        "vboxnet",
        "zt",
        "tailscale",
    ]
    .iter()
    .any(|prefix| name.starts_with(prefix))
}

fn collect_macs() -> String {
    let active: std::collections::HashSet<String> = get_if_addrs::get_if_addrs()
        .unwrap_or_default()
        .into_iter()
        .filter_map(|interface| match interface.addr {
            IfAddr::V4(address) if !address.ip.is_loopback() && !address.ip.is_link_local() => {
                Some(interface.name)
            }
            _ => None,
        })
        .collect();

    let networks = sysinfo::Networks::new_with_refreshed_list();
    let mut macs = Vec::new();
    for (name, data) in networks.list() {
        if is_virtual_interface(name) || !active.contains(name) {
            continue;
        }
        let mac = data.mac_address().to_string();
        if mac.is_empty() || mac == "00:00:00:00:00:00" {
            continue;
        }
        macs.push(mac);
    }

    macs.sort();
    macs.dedup();
    macs.iter()
        .map(|mac| mac.replace(':', "-").to_ascii_uppercase())
        .collect::<Vec<_>>()
        .join(",")
}

/// Returns all non-loopback IPv4 addresses, primary address first.
pub fn local_ips() -> String {
    let primary = local_ip();
    let mut addresses: Vec<String> = if cfg!(target_os = "windows") {
        // get_if_addrs 不暴露网卡状态,会包含已断开局域网口的静态 IP。
        let script = r#"$ErrorActionPreference='SilentlyContinue'
$up = @(Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | ForEach-Object { $_.ifIndex })
Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $up -contains $_.InterfaceIndex } | ForEach-Object { $_.IPAddress }"#;
        run_output(
            "powershell",
            &["-NoProfile", "-NonInteractive", "-Command", script],
            Duration::from_secs(8),
        )
        .lines()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .collect()
    } else {
        get_if_addrs::get_if_addrs()
            .unwrap_or_default()
            .into_iter()
            .filter(|interface| !is_tunnel_interface(&interface.name))
            .filter_map(|interface| match interface.addr {
                IfAddr::V4(address) if !address.ip.is_loopback() && !address.ip.is_link_local() => {
                    Some(address.ip.to_string())
                }
                _ => None,
            })
            .collect()
    };
    addresses.sort();
    addresses.dedup();
    if !primary.is_empty() {
        addresses.retain(|address| address != &primary);
        addresses.insert(0, primary);
    }

    addresses.join(",")
}

/// Returns the outbound IPv4 address.
pub fn local_ip() -> String {
    if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
        if socket.connect("8.8.8.8:80").is_ok() {
            if let Ok(address) = socket.local_addr() {
                let ip = address.ip();
                if !ip.is_loopback() && ip.is_ipv4() {
                    return ip.to_string();
                }
            }
        }
    }

    for interface in get_if_addrs::get_if_addrs().unwrap_or_default() {
        if let IfAddr::V4(address) = interface.addr {
            if !address.ip.is_loopback() {
                return address.ip.to_string();
            }
        }
    }

    String::new()
}

/// Returns the default gateway.
pub fn gateway() -> String {
    #[cfg(target_os = "linux")]
    {
        let data = read_trim("/proc/net/route");
        for line in data.lines().skip(1) {
            let fields: Vec<&str> = line.split_whitespace().collect();
            if fields.len() < 3 || fields[1] != "00000000" {
                continue;
            }
            let Ok(value) = u32::from_str_radix(fields[2], 16) else {
                continue;
            };
            let bytes = [
                (value & 0xFF) as u8,
                ((value >> 8) & 0xFF) as u8,
                ((value >> 16) & 0xFF) as u8,
                ((value >> 24) & 0xFF) as u8,
            ];
            return std::net::Ipv4Addr::from(bytes).to_string();
        }
        return String::new();
    }

    #[cfg(target_os = "windows")]
    {
        let script = r#"$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 }
}"#;
        let output = run_output(
            "powershell",
            &["-NoProfile", "-NonInteractive", "-Command", script],
            Duration::from_secs(8),
        );
        let gateway = output.trim();
        if !gateway.is_empty() {
            return gateway.to_string();
        }

        let output = run_output("route", &["print", "0.0.0.0"], Duration::from_secs(8));
        for line in output.lines() {
            let fields: Vec<&str> = line.split_whitespace().collect();
            if fields.len() >= 3
                && fields[0] == "0.0.0.0"
                && fields[1] == "0.0.0.0"
                && fields[2].parse::<std::net::Ipv4Addr>().is_ok()
            {
                return fields[2].to_string();
            }
        }
        return String::new();
    }

    #[cfg(target_os = "macos")]
    {
        let output = run_output("route", &["-n", "get", "default"], Duration::from_secs(8));
        for line in output.lines() {
            if let Some((key, value)) = line.split_once(':') {
                if key.trim().eq_ignore_ascii_case("gateway") {
                    return value.trim().to_string();
                }
            }
        }
        return String::new();
    }

    #[allow(unreachable_code)]
    String::new()
}

/// Returns the DNS server list.
/// StarServer limits `Node.Dns` to 50 characters; longer values fail node validation.
pub fn dns() -> String {
    #[cfg(target_os = "windows")]
    {
        let output = run_output(
            "powershell",
            &[
                "-NoProfile",
                "-NonInteractive",
                "-Command",
                "(Get-DnsClientServerAddress -AddressFamily IPv4 | ForEach-Object { $_.ServerAddresses } | Sort-Object -Unique) -join ','",
            ],
            Duration::from_secs(8),
        );
        return truncate_dns(output.trim());
    }

    #[cfg(not(target_os = "windows"))]
    {
        let data = read_trim("/etc/resolv.conf");
        let mut servers = Vec::new();
        for line in data.lines() {
            let fields: Vec<&str> = line.split_whitespace().collect();
            if fields.len() >= 2 && fields[0] == "nameserver" && !servers.contains(&fields[1]) {
                servers.push(fields[1]);
            }
        }
        truncate_dns(&servers.join(","))
    }
}

fn truncate_dns(value: &str) -> String {
    const MAX_LEN: usize = 50;
    value.chars().take(MAX_LEN).collect()
}

/// Returns the protocol architecture name.
pub fn architecture() -> &'static str {
    match std::env::consts::ARCH {
        "x86_64" => "X64",
        "x86" => "X86",
        "aarch64" => "Arm64",
        "arm" => "Arm",
        "mips64" | "mips64el" => "Mips64",
        "riscv64" => "RiscV64",
        "loongarch64" => "LA64",
        other => other,
    }
}

/// Returns the hostname.
pub fn host_name() -> String {
    #[cfg(target_os = "linux")]
    {
        let name = read_trim("/etc/hostname");
        if !name.is_empty() {
            return name;
        }
    }

    hostname::get()
        .map(|value| value.to_string_lossy().into_owned())
        .unwrap_or_default()
}

/// Returns the current user name.
pub fn user_name() -> String {
    std::env::var("USER")
        .or_else(|_| std::env::var("USERNAME"))
        .unwrap_or_default()
}

/// Returns the executable directory.
pub fn executable_dir() -> String {
    std::env::current_exe()
        .ok()
        .and_then(|path| path.parent().map(PathBuf::from))
        .map(|path| path.to_string_lossy().into_owned())
        .unwrap_or_default()
}

#[cfg(target_os = "linux")]
fn linux_os_name() -> Option<String> {
    let data = read_trim("/etc/os-release");
    for line in data.lines() {
        if let Some(value) = line.strip_prefix("PRETTY_NAME=") {
            return Some(value.trim().trim_matches('"').to_string());
        }
    }
    for line in data.lines() {
        if let Some(value) = line.strip_prefix("NAME=") {
            return Some(value.trim().trim_matches('"').to_string());
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cleans_invalid_ids() {
        assert_eq!(clean_id("  1234  "), "1234");
        assert_eq!(clean_id("\"abc\""), "abc");
        assert_eq!(clean_id("None"), "");
        assert_eq!(clean_id("00000000-0000-0000-0000-000000000000"), "");
    }

    #[test]
    fn detects_virtual_interfaces() {
        assert!(is_virtual_interface("docker0"));
        assert!(is_virtual_interface("veth1234"));
        assert!(is_virtual_interface("tailscale0"));
        assert!(!is_virtual_interface("eth0"));
        assert!(!is_virtual_interface("enp3s0"));
    }

    #[test]
    fn architecture_is_known() {
        let arch = architecture();
        assert!(!arch.is_empty());
    }

    #[test]
    fn parses_key_value_output() {
        let values = parse_key_values("UUID=abc\nSERIAL=xyz\n");
        assert_eq!(values.get("UUID").map(String::as_str), Some("abc"));
        assert_eq!(values.get("SERIAL").map(String::as_str), Some("xyz"));
    }

    #[test]
    fn truncates_long_dns_values() {
        assert_eq!(truncate_dns("223.5.5.5"), "223.5.5.5");
        assert_eq!(truncate_dns(&"1".repeat(60)).len(), 50);
    }

    #[test]
    fn time_zone_is_not_empty() {
        let zone = time_zone_name();
        assert!(!zone.is_empty());
        assert_ne!(zone, "Local");
    }

    #[test]
    fn gathers_without_panicking() {
        let info = gather();
        let _ = info.macs;
        assert!(!local_ips().is_empty());
        let system = system_info();
        assert!(!system.os_name.is_empty());
    }
}