3293 lines
111 KiB
Rust
3293 lines
111 KiB
Rust
mod live_batch;
|
|
mod runtime_stats;
|
|
|
|
use std::collections::{HashMap, HashSet, VecDeque};
|
|
use std::env;
|
|
use std::fs::{self, OpenOptions};
|
|
use std::io::{self, BufRead, BufReader, ErrorKind, Read, Write};
|
|
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
#[cfg(unix)]
|
|
use std::os::unix::process::CommandExt;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
#[cfg(target_os = "windows")]
|
|
use std::process::Stdio;
|
|
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
|
use std::sync::{Arc, Condvar, Mutex, mpsc};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use clap::Parser;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
|
use sysinfo::{Disks, Networks, ProcessRefreshKind, ProcessesToUpdate, System};
|
|
use tungstenite::client::IntoClientRequest;
|
|
use tungstenite::{Error as WebSocketError, Message, connect};
|
|
|
|
#[cfg(not(any(
|
|
target_os = "linux",
|
|
target_os = "windows",
|
|
target_os = "macos",
|
|
target_os = "freebsd"
|
|
)))]
|
|
compile_error!("nodeflare-agent supports Linux, Windows, macOS, and FreeBSD");
|
|
|
|
const VERSION: &str = match option_env!("NODEFLARE_VERSION") {
|
|
Some(version) if !version.is_empty() => version,
|
|
_ => env!("CARGO_PKG_VERSION"),
|
|
};
|
|
const LATEST_RELEASE_API: &str = "https://api.github.com/repos/imengying/NodeFlare/releases/latest";
|
|
const PROBE_ATTEMPTS: usize = 4;
|
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
|
|
const MAX_AGENT_BINARY_BYTES: u64 = 64 * 1024 * 1024;
|
|
const MAX_LATENCY_TASKS: usize = 128;
|
|
const LATENCY_WORKERS: usize = 4;
|
|
const MAX_PENDING_LATENCY_RESULTS: usize = 4096;
|
|
const MAX_REPORT_AGE_SECONDS: i64 = 7_000;
|
|
const ONCE_WSS_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
|
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
|
const UPDATE_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
|
const UPDATE_CHECK_JITTER_MAX_SECONDS: u64 = 30 * 60;
|
|
const LIVE_RECONNECT_DELAY: Duration = Duration::from_secs(3);
|
|
const LIVE_QUEUE_CAPACITY: usize = 720;
|
|
const MAX_PENDING_SPOOL_BYTES: u64 = 64 * 1024 * 1024;
|
|
const MAX_PENDING_SPOOL_LINE_BYTES: usize = 1024 * 1024;
|
|
// Database persistence can take a few hundred milliseconds; allow the ACK to arrive
|
|
// before continuing with the configured live interval.
|
|
const LIVE_ACK_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const LIVE_HINT_READ_TIMEOUT: Duration = Duration::from_millis(10);
|
|
const LIVE_REPORT_DIVISOR: u64 = 15;
|
|
const BASIC_INFO_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
|
const SLOW_METRICS_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
|
|
const PUBLIC_IP_REFRESH_INTERVAL: Duration = Duration::from_secs(10 * 60);
|
|
const PUBLIC_IP_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
|
|
const PUBLIC_IP_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const PUBLIC_IP_WAIT_BUDGET: Duration = Duration::from_secs(8);
|
|
const PUBLIC_IP_V4_URL: &str = "https://ipv4.icanhazip.com/";
|
|
const PUBLIC_IP_V6_URL: &str = "https://ipv6.icanhazip.com/";
|
|
const RUNTIME_STATS_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
|
const CLOCK_CALIBRATION_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
|
|
const CLOCK_CALIBRATION_MIN_CHANGE_MS: i64 = 20_000;
|
|
|
|
type Error = Box<dyn std::error::Error + Send + Sync>;
|
|
type Result<T> = std::result::Result<T, Error>;
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct RuntimeConfig {
|
|
token: String,
|
|
endpoint: String,
|
|
report_interval: u64,
|
|
collect_interval: u64,
|
|
network_interface: String,
|
|
agent_mirror: String,
|
|
auto_update: bool,
|
|
latency_tasks: Vec<LatencyTask>,
|
|
}
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(name = "agent", version = VERSION, about = "NodeFlare monitoring agent")]
|
|
struct CliOptions {
|
|
/// NodeFlare endpoint
|
|
#[arg(short = 'e', value_name = "URL")]
|
|
endpoint: String,
|
|
/// Agent token
|
|
#[arg(short = 't', value_name = "TOKEN")]
|
|
token: String,
|
|
/// Initial report interval in seconds (15-3600)
|
|
#[arg(short = 'i', value_name = "SECONDS", default_value_t = 60)]
|
|
interval: u64,
|
|
/// Submit one report and exit
|
|
#[arg(long, conflicts_with = "collect")]
|
|
once: bool,
|
|
/// Print one metric sample and exit
|
|
#[arg(long, conflicts_with = "once")]
|
|
collect: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct LatencyTask {
|
|
id: String,
|
|
name: String,
|
|
task_type: String,
|
|
target: String,
|
|
port: Option<i64>,
|
|
interval_seconds: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
struct LatencyResult {
|
|
task_id: String,
|
|
timestamp: i64,
|
|
latency_ms: f64,
|
|
packet_loss: f64,
|
|
}
|
|
|
|
struct LatencyExecutor {
|
|
task_tx: mpsc::SyncSender<LatencyTask>,
|
|
result_rx: mpsc::Receiver<LatencyResult>,
|
|
in_flight: HashSet<String>,
|
|
stats: Arc<runtime_stats::RuntimeStats>,
|
|
}
|
|
|
|
impl LatencyExecutor {
|
|
fn new(stats: Arc<runtime_stats::RuntimeStats>) -> Result<Self> {
|
|
let (task_tx, task_rx) = mpsc::sync_channel::<LatencyTask>(MAX_LATENCY_TASKS);
|
|
let (result_tx, result_rx) = mpsc::channel();
|
|
let task_rx = Arc::new(Mutex::new(task_rx));
|
|
|
|
for index in 0..LATENCY_WORKERS {
|
|
let task_rx = Arc::clone(&task_rx);
|
|
let result_tx = result_tx.clone();
|
|
thread::Builder::new()
|
|
.name(format!("nodeflare-latency-{index}"))
|
|
.spawn(move || {
|
|
loop {
|
|
let task = match task_rx.lock() {
|
|
Ok(receiver) => receiver.recv(),
|
|
Err(_) => return,
|
|
};
|
|
match task {
|
|
Ok(task) => {
|
|
if result_tx.send(execute_latency_task(task)).is_err() {
|
|
return;
|
|
}
|
|
}
|
|
Err(mpsc::RecvError) => return,
|
|
};
|
|
}
|
|
})?;
|
|
}
|
|
|
|
Ok(Self {
|
|
task_tx,
|
|
result_rx,
|
|
in_flight: HashSet::new(),
|
|
stats,
|
|
})
|
|
}
|
|
|
|
fn enqueue(&mut self, task: LatencyTask) -> bool {
|
|
if self.in_flight.contains(&task.id) {
|
|
return false;
|
|
}
|
|
let task_id = task.id.clone();
|
|
match self.task_tx.try_send(task) {
|
|
Ok(()) => {
|
|
self.in_flight.insert(task_id);
|
|
true
|
|
}
|
|
Err(mpsc::TrySendError::Full(_)) | Err(mpsc::TrySendError::Disconnected(_)) => {
|
|
self.stats.latency_queue_rejected();
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
fn drain(&mut self) -> Vec<LatencyResult> {
|
|
let results = self.result_rx.try_iter().collect::<Vec<_>>();
|
|
for result in &results {
|
|
self.in_flight.remove(&result.task_id);
|
|
}
|
|
results
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RemoteConfig {
|
|
report_interval: u64,
|
|
collect_interval: u64,
|
|
network_interface: String,
|
|
agent_mirror: String,
|
|
auto_update: bool,
|
|
latency_tasks: Vec<LatencyTask>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct GithubRelease {
|
|
tag_name: String,
|
|
assets: Vec<GithubReleaseAsset>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct GithubReleaseAsset {
|
|
name: String,
|
|
browser_download_url: String,
|
|
digest: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct ClockCalibration {
|
|
offset_ms: i64,
|
|
calibrated_at: Option<Instant>,
|
|
}
|
|
|
|
impl ClockCalibration {
|
|
fn observe(&mut self, offset_ms: i64) -> bool {
|
|
let should_update = self.calibrated_at.is_none()
|
|
|| self
|
|
.calibrated_at
|
|
.is_some_and(|at| at.elapsed() >= CLOCK_CALIBRATION_MAX_AGE)
|
|
|| self.offset_ms.abs_diff(offset_ms) >= CLOCK_CALIBRATION_MIN_CHANGE_MS as u64;
|
|
if should_update {
|
|
self.offset_ms = offset_ms;
|
|
self.calibrated_at = Some(Instant::now());
|
|
}
|
|
should_update
|
|
}
|
|
}
|
|
|
|
type SharedClock = Arc<Mutex<ClockCalibration>>;
|
|
|
|
/// 双栈公网地址探测:分别请求按协议族锁死的 icanhazip 端点(Cloudflare 运营,
|
|
/// 纯文本返回调用方出口 IP)。短暂失败保留最近结果,持续失败则清空过期地址。
|
|
#[derive(Default)]
|
|
struct PublicIpValue {
|
|
address: Option<IpAddr>,
|
|
last_success: Option<Instant>,
|
|
}
|
|
|
|
impl PublicIpValue {
|
|
fn observe(&mut self, address: Option<IpAddr>, now: Instant) {
|
|
if let Some(address) = address {
|
|
self.address = Some(address);
|
|
self.last_success = Some(now);
|
|
} else if self
|
|
.last_success
|
|
.is_none_or(|at| now.saturating_duration_since(at) >= PUBLIC_IP_STALE_AFTER)
|
|
{
|
|
self.address = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct PublicIps {
|
|
v4: PublicIpValue,
|
|
v6: PublicIpValue,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct PublicIpProbe {
|
|
state: Arc<Mutex<PublicIps>>,
|
|
probing: Arc<AtomicBool>,
|
|
last_attempt: Arc<Mutex<Option<Instant>>>,
|
|
}
|
|
|
|
impl PublicIpProbe {
|
|
/// 距上次尝试超过刷新间隔且没有探测在进行时,后台起一轮探测(不阻塞采样)。
|
|
fn refresh(&self) {
|
|
let due = self
|
|
.last_attempt
|
|
.lock()
|
|
.unwrap()
|
|
.is_none_or(|at| at.elapsed() >= PUBLIC_IP_REFRESH_INTERVAL);
|
|
if !due || self.probing.swap(true, Ordering::SeqCst) {
|
|
return;
|
|
}
|
|
*self.last_attempt.lock().unwrap() = Some(Instant::now());
|
|
let state = Arc::clone(&self.state);
|
|
let probing = Arc::clone(&self.probing);
|
|
thread::spawn(move || {
|
|
let v4 = probe_public_ip(PUBLIC_IP_V4_URL, false);
|
|
let v6 = probe_public_ip(PUBLIC_IP_V6_URL, true);
|
|
let observed_at = Instant::now();
|
|
let mut state = state.lock().unwrap();
|
|
state.v4.observe(v4, observed_at);
|
|
state.v6.observe(v6, observed_at);
|
|
probing.store(false, Ordering::SeqCst);
|
|
});
|
|
}
|
|
|
|
fn v4(&self) -> Option<String> {
|
|
self.state
|
|
.lock()
|
|
.unwrap()
|
|
.v4
|
|
.address
|
|
.map(|ip| ip.to_string())
|
|
}
|
|
|
|
fn v6(&self) -> Option<String> {
|
|
self.state
|
|
.lock()
|
|
.unwrap()
|
|
.v6
|
|
.address
|
|
.map(|ip| ip.to_string())
|
|
}
|
|
|
|
/// 一次性模式没有后续采样,等首轮探测落地(或预算耗尽)再出报告。
|
|
fn wait_initial(&self) {
|
|
let deadline = Instant::now() + PUBLIC_IP_WAIT_BUDGET;
|
|
while self.probing.load(Ordering::SeqCst) && Instant::now() < deadline {
|
|
thread::sleep(Duration::from_millis(150));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn probe_public_ip(url: &str, ipv6: bool) -> Option<IpAddr> {
|
|
let probe_agent: ureq::Agent = ureq::Agent::config_builder()
|
|
.timeout_global(Some(PUBLIC_IP_PROBE_TIMEOUT))
|
|
.build()
|
|
.into();
|
|
let response = probe_agent
|
|
.get(url)
|
|
.header("User-Agent", format!("nodeflare-agent/{VERSION}"))
|
|
.call()
|
|
.ok()?;
|
|
let mut body = String::new();
|
|
response
|
|
.into_body()
|
|
.into_reader()
|
|
.take(128)
|
|
.read_to_string(&mut body)
|
|
.ok()?;
|
|
parse_public_ip(&body, ipv6)
|
|
}
|
|
|
|
/// 端点返回的协议族必须与请求的锁族一致,防止代理或劫持回一个错族地址。
|
|
fn parse_public_ip(text: &str, ipv6: bool) -> Option<IpAddr> {
|
|
let ip: IpAddr = text.trim().parse().ok()?;
|
|
match (ipv6, ip) {
|
|
(true, IpAddr::V6(_)) | (false, IpAddr::V4(_)) => Some(ip),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
struct LiveSender {
|
|
pending: Arc<(Mutex<VecDeque<Report>>, Condvar)>,
|
|
send_interval: Arc<Mutex<Duration>>,
|
|
persisted_through: Arc<AtomicI64>,
|
|
remote_config: Arc<Mutex<Option<RemoteConfig>>>,
|
|
stats: Arc<runtime_stats::RuntimeStats>,
|
|
}
|
|
|
|
struct LiveSenderWorker {
|
|
pending: Arc<(Mutex<VecDeque<Report>>, Condvar)>,
|
|
configured_interval: Arc<Mutex<Duration>>,
|
|
persisted_through: Arc<AtomicI64>,
|
|
remote_config: Arc<Mutex<Option<RemoteConfig>>>,
|
|
clock: SharedClock,
|
|
stats: Arc<runtime_stats::RuntimeStats>,
|
|
}
|
|
|
|
impl LiveSender {
|
|
fn start(
|
|
config: &RuntimeConfig,
|
|
clock: SharedClock,
|
|
stats: Arc<runtime_stats::RuntimeStats>,
|
|
) -> Result<Self> {
|
|
let pending = Arc::new((Mutex::new(VecDeque::new()), Condvar::new()));
|
|
let send_interval = Arc::new(Mutex::new(live_batch_interval(
|
|
config.report_interval,
|
|
config.collect_interval,
|
|
)));
|
|
let persisted_through = Arc::new(AtomicI64::new(0));
|
|
let remote_config = Arc::new(Mutex::new(None));
|
|
let endpoint = live_endpoint(&config.endpoint)?;
|
|
let token = config.token.clone();
|
|
let sender_state = Arc::clone(&pending);
|
|
let sender_interval = Arc::clone(&send_interval);
|
|
let sender_persisted_through = Arc::clone(&persisted_through);
|
|
let sender_remote_config = Arc::clone(&remote_config);
|
|
let sender_stats = Arc::clone(&stats);
|
|
thread::Builder::new()
|
|
.name("nodeflare-live".to_string())
|
|
.spawn(move || {
|
|
live_sender_loop(
|
|
&endpoint,
|
|
&token,
|
|
LiveSenderWorker {
|
|
pending: sender_state,
|
|
configured_interval: sender_interval,
|
|
persisted_through: sender_persisted_through,
|
|
remote_config: sender_remote_config,
|
|
clock,
|
|
stats: sender_stats,
|
|
},
|
|
)
|
|
})?;
|
|
Ok(Self {
|
|
pending,
|
|
send_interval,
|
|
persisted_through,
|
|
remote_config,
|
|
stats,
|
|
})
|
|
}
|
|
|
|
fn send(&self, report: &Report) {
|
|
let (pending, ready) = &*self.pending;
|
|
if let Ok(mut pending) = pending.lock() {
|
|
if pending.len() >= LIVE_QUEUE_CAPACITY {
|
|
pending.pop_front();
|
|
self.stats.live_queue_dropped(1);
|
|
}
|
|
pending.push_back(report.clone());
|
|
ready.notify_one();
|
|
}
|
|
}
|
|
|
|
fn set_send_interval(&self, report_interval: u64, collect_interval: u64) {
|
|
if let Ok(mut interval) = self.send_interval.lock() {
|
|
*interval = live_batch_interval(report_interval, collect_interval);
|
|
}
|
|
self.pending.1.notify_one();
|
|
}
|
|
|
|
fn persisted_through(&self) -> i64 {
|
|
self.persisted_through.load(Ordering::Acquire)
|
|
}
|
|
|
|
fn take_remote_config(&self) -> Option<RemoteConfig> {
|
|
self.remote_config
|
|
.lock()
|
|
.ok()
|
|
.and_then(|mut config| config.take())
|
|
}
|
|
}
|
|
|
|
fn live_batch_interval(report_interval: u64, collect_interval: u64) -> Duration {
|
|
let seconds = report_interval
|
|
.clamp(15, 3600)
|
|
.div_ceil(LIVE_REPORT_DIVISOR)
|
|
.clamp(1, 60)
|
|
.max(collect_interval.clamp(1, 60));
|
|
Duration::from_secs(seconds)
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
|
struct DiskMetric {
|
|
name: String,
|
|
mount_point: String,
|
|
used: i64,
|
|
total: i64,
|
|
read_bps: f64,
|
|
write_bps: f64,
|
|
read_iops: f64,
|
|
write_iops: f64,
|
|
await_ms: f64,
|
|
utilization: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
struct GpuMetric {
|
|
model: String,
|
|
usage: Option<f64>,
|
|
memory_used: i64,
|
|
memory_total: i64,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
|
struct Report {
|
|
timestamp: i64,
|
|
cpu: f64,
|
|
load1: f64,
|
|
load5: f64,
|
|
load15: f64,
|
|
mem_used: i64,
|
|
mem_total: i64,
|
|
swap_used: i64,
|
|
swap_total: i64,
|
|
disk_used: i64,
|
|
disk_total: i64,
|
|
net_in: f64,
|
|
net_out: f64,
|
|
net_rx_total: i64,
|
|
net_tx_total: i64,
|
|
uptime: i64,
|
|
processes: i64,
|
|
tcp_connections: i64,
|
|
udp_connections: i64,
|
|
cpu_cores: i64,
|
|
cpu_model: String,
|
|
os: String,
|
|
kernel: String,
|
|
arch: String,
|
|
virtualization: String,
|
|
gpu_usage: f64,
|
|
gpu_model: String,
|
|
agent_version: String,
|
|
ip_v4: String,
|
|
ip_v6: String,
|
|
disk_read_bps: f64,
|
|
disk_write_bps: f64,
|
|
disk_read_iops: f64,
|
|
disk_write_iops: f64,
|
|
disk_await_ms: f64,
|
|
disk_utilization: f64,
|
|
disks: Vec<DiskMetric>,
|
|
gpus: Vec<GpuMetric>,
|
|
latency_results: Vec<LatencyResult>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
struct BasicMetrics {
|
|
cpu_cores: i64,
|
|
cpu_model: String,
|
|
os: String,
|
|
kernel: String,
|
|
arch: String,
|
|
virtualization: String,
|
|
gpu_usage: f64,
|
|
gpu_model: String,
|
|
gpus: Vec<GpuMetric>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
struct SlowMetrics {
|
|
disks: Vec<DiskMetric>,
|
|
processes: i64,
|
|
tcp_connections: i64,
|
|
udp_connections: i64,
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
struct CpuSample {
|
|
total: u64,
|
|
idle: u64,
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
struct IoSample {
|
|
rx: u64,
|
|
tx: u64,
|
|
read_ops: u64,
|
|
read_sectors: u64,
|
|
write_ops: u64,
|
|
write_sectors: u64,
|
|
read_millis: u64,
|
|
write_millis: u64,
|
|
io_millis: u64,
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn text(path: impl AsRef<Path>) -> String {
|
|
fs::read_to_string(path).unwrap_or_default()
|
|
}
|
|
|
|
fn command(name: &str, args: &[&str]) -> String {
|
|
Command::new(name)
|
|
.args(args)
|
|
.output()
|
|
.ok()
|
|
.filter(|output| output.status.success())
|
|
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn cpu_sample() -> CpuSample {
|
|
let line = text("/proc/stat").lines().next().unwrap_or("").to_string();
|
|
let values = line
|
|
.split_whitespace()
|
|
.skip(1)
|
|
.filter_map(|value| value.parse::<u64>().ok())
|
|
.collect::<Vec<_>>();
|
|
CpuSample {
|
|
total: values.iter().sum(),
|
|
idle: values.get(3).copied().unwrap_or(0) + values.get(4).copied().unwrap_or(0),
|
|
}
|
|
}
|
|
|
|
fn selected_interface(name: &str, filter: &str) -> bool {
|
|
if filter.trim().is_empty() {
|
|
name != "lo" && !name.to_ascii_lowercase().contains("loopback")
|
|
} else {
|
|
filter.split(',').any(|value| value.trim() == name)
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn disk_device(name: &str) -> bool {
|
|
((name.starts_with("sd") || name.starts_with("vd") || name.starts_with("xvd"))
|
|
&& name
|
|
.chars()
|
|
.last()
|
|
.is_some_and(|value| value.is_ascii_alphabetic()))
|
|
|| (name.starts_with("nvme") && name.contains('n') && !name.contains('p'))
|
|
|| (name.starts_with("mmcblk") && !name.contains('p'))
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn io_sample(filter: &str) -> IoSample {
|
|
let mut sample = IoSample::default();
|
|
for line in text("/proc/net/dev")
|
|
.lines()
|
|
.filter(|line| line.contains(':'))
|
|
{
|
|
let Some((name, values)) = line.split_once(':') else {
|
|
continue;
|
|
};
|
|
if !selected_interface(name.trim(), filter) {
|
|
continue;
|
|
}
|
|
let fields = values.split_whitespace().collect::<Vec<_>>();
|
|
sample.rx = sample
|
|
.rx
|
|
.saturating_add(fields.first().and_then(|v| v.parse().ok()).unwrap_or(0));
|
|
sample.tx = sample
|
|
.tx
|
|
.saturating_add(fields.get(8).and_then(|v| v.parse().ok()).unwrap_or(0));
|
|
}
|
|
for line in text("/proc/diskstats").lines() {
|
|
let fields = line.split_whitespace().collect::<Vec<_>>();
|
|
if fields.len() < 13 || !disk_device(fields[2]) {
|
|
continue;
|
|
}
|
|
sample.read_ops = sample
|
|
.read_ops
|
|
.saturating_add(fields[3].parse().unwrap_or(0));
|
|
sample.read_sectors = sample
|
|
.read_sectors
|
|
.saturating_add(fields[5].parse().unwrap_or(0));
|
|
sample.write_ops = sample
|
|
.write_ops
|
|
.saturating_add(fields[7].parse().unwrap_or(0));
|
|
sample.write_sectors = sample
|
|
.write_sectors
|
|
.saturating_add(fields[9].parse().unwrap_or(0));
|
|
sample.read_millis = sample
|
|
.read_millis
|
|
.saturating_add(fields[6].parse().unwrap_or(0));
|
|
sample.write_millis = sample
|
|
.write_millis
|
|
.saturating_add(fields[10].parse().unwrap_or(0));
|
|
sample.io_millis = sample
|
|
.io_millis
|
|
.saturating_add(fields[12].parse().unwrap_or(0));
|
|
}
|
|
sample
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn mem_value(contents: &str, key: &str) -> i64 {
|
|
contents
|
|
.lines()
|
|
.find(|line| line.starts_with(key))
|
|
.and_then(|line| line.split_whitespace().nth(1))
|
|
.and_then(|value| value.parse::<i64>().ok())
|
|
.unwrap_or(0)
|
|
.saturating_mul(1024)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn file_line_count(path: &str) -> i64 {
|
|
text(path).lines().skip(1).count() as i64
|
|
}
|
|
|
|
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
|
fn connection_counts_from_netstat(output: &str) -> (i64, i64) {
|
|
output.lines().fold((0_i64, 0_i64), |(tcp, udp), line| {
|
|
let protocol = line
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap_or_default()
|
|
.to_ascii_lowercase();
|
|
if protocol.starts_with("tcp") {
|
|
(tcp.saturating_add(1), udp)
|
|
} else if protocol.starts_with("udp") {
|
|
(tcp, udp.saturating_add(1))
|
|
} else {
|
|
(tcp, udp)
|
|
}
|
|
})
|
|
}
|
|
|
|
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
|
fn connection_counts() -> (i64, i64) {
|
|
Command::new("netstat")
|
|
.args(["-an"])
|
|
.output()
|
|
.ok()
|
|
.filter(|output| output.status.success())
|
|
.map(|output| connection_counts_from_netstat(&String::from_utf8_lossy(&output.stdout)))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn disk_usage() -> Vec<DiskMetric> {
|
|
let output = command(
|
|
"df",
|
|
&[
|
|
"-B1",
|
|
"-l",
|
|
"--output=source,target,size,used",
|
|
"-x",
|
|
"tmpfs",
|
|
"-x",
|
|
"devtmpfs",
|
|
],
|
|
);
|
|
output
|
|
.lines()
|
|
.skip(1)
|
|
.filter_map(|line| {
|
|
let fields = line.split_whitespace().collect::<Vec<_>>();
|
|
if fields.len() < 4 {
|
|
return None;
|
|
}
|
|
let total = fields[2].parse::<i64>().ok()?;
|
|
let used = fields[3].parse::<i64>().ok()?;
|
|
(total > 0).then(|| DiskMetric {
|
|
name: fields[0].to_string(),
|
|
mount_point: fields[1].to_string(),
|
|
used,
|
|
total,
|
|
..DiskMetric::default()
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn os_name() -> String {
|
|
text("/etc/os-release")
|
|
.lines()
|
|
.find_map(|line| line.strip_prefix("PRETTY_NAME="))
|
|
.map(|value| value.trim_matches('"').to_string())
|
|
.unwrap_or_else(|| command("uname", &["-s"]))
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn cpu_model() -> String {
|
|
text("/proc/cpuinfo")
|
|
.lines()
|
|
.find_map(|line| {
|
|
let (key, value) = line.split_once(':')?;
|
|
matches!(key.trim(), "model name" | "Hardware").then(|| value.trim().to_string())
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn valid_probe_host(host: &str) -> bool {
|
|
if host.is_empty() || host.len() > 50 || host.starts_with('.') || host.ends_with('.') {
|
|
return false;
|
|
}
|
|
let labels = host.split('.').collect::<Vec<_>>();
|
|
let ipv4_like = labels.len() == 4
|
|
&& labels
|
|
.iter()
|
|
.all(|label| !label.is_empty() && label.chars().all(|c| c.is_ascii_digit()));
|
|
if ipv4_like {
|
|
return host
|
|
.parse()
|
|
.is_ok_and(|address| is_public_probe_ip(IpAddr::V4(address)));
|
|
}
|
|
let lower = host.to_ascii_lowercase();
|
|
if labels.len() < 2
|
|
|| ["local", "localhost", "internal", "lan", "localdomain"]
|
|
.iter()
|
|
.any(|suffix| lower == *suffix || lower.ends_with(&format!(".{suffix}")))
|
|
|| lower == "home.arpa"
|
|
|| lower.ends_with(".home.arpa")
|
|
{
|
|
return false;
|
|
}
|
|
labels.iter().all(|label| {
|
|
!label.is_empty()
|
|
&& label
|
|
.chars()
|
|
.all(|character| character.is_ascii_alphanumeric() || character == '-')
|
|
&& label
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|character| character.is_ascii_alphanumeric())
|
|
&& label
|
|
.chars()
|
|
.last()
|
|
.is_some_and(|character| character.is_ascii_alphanumeric())
|
|
})
|
|
}
|
|
|
|
fn is_public_probe_ip(address: IpAddr) -> bool {
|
|
match address {
|
|
IpAddr::V4(address) => {
|
|
let [a, b, c, _] = address.octets();
|
|
!(a == 0
|
|
|| a == 10
|
|
|| a == 127
|
|
|| (a == 100 && (64..=127).contains(&b))
|
|
|| (a == 169 && b == 254)
|
|
|| (a == 172 && (16..=31).contains(&b))
|
|
|| (a == 192 && b == 0 && c == 0)
|
|
|| (a == 192 && b == 0 && c == 2)
|
|
|| (a == 192 && b == 168)
|
|
|| (a == 198 && (b == 18 || b == 19))
|
|
|| (a == 198 && b == 51 && c == 100)
|
|
|| (a == 203 && b == 0 && c == 113)
|
|
|| a >= 224)
|
|
}
|
|
IpAddr::V6(address) => {
|
|
if let Some(mapped) = address.to_ipv4_mapped() {
|
|
return is_public_probe_ip(IpAddr::V4(mapped));
|
|
}
|
|
address.segments()[0] & 0xe000 == 0x2000
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_public_probe_address(host: &str, port: u16) -> Option<SocketAddr> {
|
|
let addresses = (host, port)
|
|
.to_socket_addrs()
|
|
.ok()?
|
|
.filter(|address| is_public_probe_ip(address.ip()))
|
|
.collect::<Vec<_>>();
|
|
addresses
|
|
.iter()
|
|
.find(|address| address.is_ipv4())
|
|
.or_else(|| addresses.first())
|
|
.copied()
|
|
}
|
|
|
|
fn parse_probe_target(value: &str, port: Option<u16>) -> Option<(String, u16)> {
|
|
let raw = value.trim();
|
|
if raw.is_empty()
|
|
|| raw.len() > 60
|
|
|| raw.contains("://")
|
|
|| raw
|
|
.chars()
|
|
.any(|character| character.is_whitespace() || "/@?#\\[]".contains(character))
|
|
|| raw.contains(':')
|
|
{
|
|
return None;
|
|
}
|
|
let port = port.unwrap_or(443);
|
|
(port > 0 && valid_probe_host(raw)).then(|| (raw.to_ascii_lowercase(), port))
|
|
}
|
|
|
|
fn median(values: &mut [f64]) -> f64 {
|
|
values.sort_by(f64::total_cmp);
|
|
let middle = values.len() / 2;
|
|
if values.len().is_multiple_of(2) {
|
|
(values[middle - 1] + values[middle]) / 2.0
|
|
} else {
|
|
values[middle]
|
|
}
|
|
}
|
|
|
|
fn tcp_latency_probe(target: &str, port: Option<i64>) -> (f64, f64) {
|
|
if target.trim().is_empty() {
|
|
return (-1.0, -1.0);
|
|
}
|
|
let Some(port) = port.and_then(|value| u16::try_from(value).ok()) else {
|
|
return (-1.0, 100.0);
|
|
};
|
|
let Some((host, port)) = parse_probe_target(target, Some(port)) else {
|
|
return (-1.0, 100.0);
|
|
};
|
|
let Some(address) = resolve_public_probe_address(&host, port) else {
|
|
return (-1.0, 100.0);
|
|
};
|
|
tcp_latency_probe_address(&address)
|
|
}
|
|
|
|
fn tcp_latency_probe_address(address: &SocketAddr) -> (f64, f64) {
|
|
let mut latencies = Vec::with_capacity(PROBE_ATTEMPTS);
|
|
for _ in 0..PROBE_ATTEMPTS {
|
|
let started = Instant::now();
|
|
if TcpStream::connect_timeout(address, PROBE_TIMEOUT).is_ok() {
|
|
latencies.push(started.elapsed().as_secs_f64() * 1000.0);
|
|
}
|
|
}
|
|
let loss = (PROBE_ATTEMPTS - latencies.len()) as f64 * 100.0 / PROBE_ATTEMPTS as f64;
|
|
if latencies.is_empty() {
|
|
(-1.0, loss)
|
|
} else {
|
|
(median(&mut latencies), loss)
|
|
}
|
|
}
|
|
|
|
fn ping_latency(output: &str) -> Option<f64> {
|
|
let marker = output
|
|
.find("time=")
|
|
.map(|index| (index + 5, false))
|
|
.or_else(|| output.find("time<").map(|index| (index + 5, true)))?;
|
|
if marker.1 {
|
|
return Some(0.5);
|
|
}
|
|
let value = output[marker.0..]
|
|
.chars()
|
|
.take_while(|character| character.is_ascii_digit() || *character == '.')
|
|
.collect::<String>();
|
|
value.parse().ok()
|
|
}
|
|
|
|
fn ping_latencies(output: &str) -> Vec<f64> {
|
|
output.lines().filter_map(ping_latency).collect()
|
|
}
|
|
|
|
fn icmp_latency_probe(target: &str) -> (f64, f64) {
|
|
let host = target.trim();
|
|
let Some((host, _)) = parse_probe_target(host, None) else {
|
|
return (-1.0, 100.0);
|
|
};
|
|
let Some(address) = resolve_public_probe_address(&host, 443) else {
|
|
return (-1.0, 100.0);
|
|
};
|
|
let destination = address.ip().to_string();
|
|
let mut ping = Command::new("ping");
|
|
#[cfg(target_os = "linux")]
|
|
ping.args(["-n", "-c", "4", "-W", "1", destination.as_str()]);
|
|
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
|
ping.args(["-n", "-c", "4", "-W", "1000", destination.as_str()]);
|
|
#[cfg(target_os = "windows")]
|
|
ping.args(["-n", "4", "-w", "1000", destination.as_str()]);
|
|
let mut latencies = ping
|
|
.output()
|
|
.map(|output| ping_latencies(&String::from_utf8_lossy(&output.stdout)))
|
|
.unwrap_or_default();
|
|
latencies.truncate(PROBE_ATTEMPTS);
|
|
let loss = (PROBE_ATTEMPTS - latencies.len()) as f64 * 100.0 / PROBE_ATTEMPTS as f64;
|
|
if latencies.is_empty() {
|
|
(-1.0, loss)
|
|
} else {
|
|
(median(&mut latencies), loss)
|
|
}
|
|
}
|
|
|
|
fn unix_timestamp() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs() as i64
|
|
}
|
|
|
|
fn unix_timestamp_millis() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis()
|
|
.min(i64::MAX as u128) as i64
|
|
}
|
|
|
|
fn clock_offset_from_http_date(value: &str, started_ms: i64, ended_ms: i64) -> Option<i64> {
|
|
let server_ms: i64 = httpdate::parse_http_date(value)
|
|
.ok()?
|
|
.duration_since(UNIX_EPOCH)
|
|
.ok()?
|
|
.as_millis()
|
|
.try_into()
|
|
.ok()?;
|
|
let midpoint_ms = started_ms.saturating_add(ended_ms.saturating_sub(started_ms) / 2);
|
|
Some(server_ms.saturating_sub(midpoint_ms))
|
|
}
|
|
|
|
fn corrected_timestamp(timestamp: i64, offset_ms: i64) -> i64 {
|
|
timestamp
|
|
.saturating_mul(1_000)
|
|
.saturating_add(offset_ms)
|
|
.div_euclid(1_000)
|
|
}
|
|
|
|
fn monotonic_report_timestamp(corrected: i64, last_emitted: i64, persisted_through: i64) -> i64 {
|
|
corrected
|
|
.max(last_emitted.saturating_add(1))
|
|
.max(persisted_through.saturating_add(1))
|
|
}
|
|
|
|
fn shared_clock_offset(clock: &SharedClock) -> i64 {
|
|
clock.lock().map_or(0, |calibration| calibration.offset_ms)
|
|
}
|
|
|
|
fn observe_clock(clock: &SharedClock, offset_ms: Option<i64>) {
|
|
let Some(offset_ms) = offset_ms else {
|
|
return;
|
|
};
|
|
if let Ok(mut calibration) = clock.lock() {
|
|
calibration.observe(offset_ms);
|
|
}
|
|
}
|
|
|
|
fn execute_latency_task(task: LatencyTask) -> LatencyResult {
|
|
let (latency_ms, packet_loss) = match task.task_type.as_str() {
|
|
"tcp" => tcp_latency_probe(&task.target, task.port),
|
|
"icmp" => icmp_latency_probe(&task.target),
|
|
_ => (-1.0, 100.0),
|
|
};
|
|
LatencyResult {
|
|
task_id: task.id,
|
|
timestamp: unix_timestamp(),
|
|
latency_ms,
|
|
packet_loss,
|
|
}
|
|
}
|
|
|
|
fn nvidia_gpu_info() -> Vec<GpuMetric> {
|
|
let gpu = command(
|
|
"nvidia-smi",
|
|
&[
|
|
"--query-gpu=utilization.gpu,name,memory.used,memory.total",
|
|
"--format=csv,noheader,nounits",
|
|
],
|
|
);
|
|
gpu.lines()
|
|
.filter_map(|line| {
|
|
let fields = line.split(',').map(str::trim).collect::<Vec<_>>();
|
|
if fields.len() < 4 {
|
|
return None;
|
|
}
|
|
if fields[1].is_empty() {
|
|
return None;
|
|
}
|
|
Some(GpuMetric {
|
|
usage: fields[0].parse().ok(),
|
|
model: fields[1].to_string(),
|
|
memory_used: fields[2]
|
|
.parse::<i64>()
|
|
.unwrap_or(0)
|
|
.saturating_mul(1024 * 1024),
|
|
memory_total: fields[3]
|
|
.parse::<i64>()
|
|
.unwrap_or(0)
|
|
.saturating_mul(1024 * 1024),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn basic_gpu_metrics(names: impl IntoIterator<Item = String>) -> Vec<GpuMetric> {
|
|
let mut seen = HashSet::new();
|
|
names
|
|
.into_iter()
|
|
.filter_map(|name| {
|
|
let name = name.split(" (rev ").next().unwrap_or(&name).trim();
|
|
let lower = name.to_ascii_lowercase();
|
|
if name.is_empty()
|
|
|| [
|
|
"sensor hub",
|
|
"management engine",
|
|
"ethernet",
|
|
"wireless",
|
|
"audio controller",
|
|
"usb controller",
|
|
"sata controller",
|
|
"virtio",
|
|
"vmware",
|
|
"qxl",
|
|
"hyper-v",
|
|
"cirrus",
|
|
"microsoft basic display",
|
|
]
|
|
.iter()
|
|
.any(|pattern| lower.contains(pattern))
|
|
|| !seen.insert(name.to_string())
|
|
{
|
|
return None;
|
|
}
|
|
Some(GpuMetric {
|
|
model: name.to_string(),
|
|
usage: None,
|
|
memory_used: 0,
|
|
memory_total: 0,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", test))]
|
|
fn parse_lspci_gpu_names(output: &str) -> Vec<String> {
|
|
output
|
|
.lines()
|
|
.filter(|line| {
|
|
let lower = line.to_ascii_lowercase();
|
|
lower.contains("vga compatible controller")
|
|
|| lower.contains("3d controller")
|
|
|| lower.contains("display controller")
|
|
})
|
|
.filter_map(|line| line.split_once(": ").map(|(_, name)| name.to_string()))
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", test))]
|
|
fn gpu_name_from_uevent(output: &str) -> Option<String> {
|
|
let driver = output
|
|
.lines()
|
|
.find_map(|line| line.strip_prefix("DRIVER="))?;
|
|
let pci_class = output
|
|
.lines()
|
|
.find_map(|line| line.strip_prefix("PCI_CLASS="))
|
|
.unwrap_or_default();
|
|
if u32::from_str_radix(pci_class, 16)
|
|
.ok()
|
|
.map(|class| class >> 16)
|
|
!= Some(3)
|
|
{
|
|
return None;
|
|
}
|
|
match driver {
|
|
"i915" | "xe" => Some("Intel Integrated Graphics".to_string()),
|
|
"amdgpu" | "radeon" => Some("AMD Radeon Graphics".to_string()),
|
|
"nvidia" | "nouveau" => Some("NVIDIA GPU".to_string()),
|
|
"msm" | "msm_drm" => Some("Qualcomm Adreno GPU".to_string()),
|
|
"panfrost" | "lima" => Some("ARM Mali GPU".to_string()),
|
|
"vc4" | "v3d" => Some("Broadcom VideoCore Graphics".to_string()),
|
|
"virtio-pci" | "virtio_gpu" | "bochs-drm" | "qxl" | "vmwgfx" | "cirrus" | "vboxvideo"
|
|
| "hyperv_fb" | "simpledrm" | "simplefb" => None,
|
|
other if !other.is_empty() => Some(format!("GPU ({other})")),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn sysfs_gpu_names() -> Vec<String> {
|
|
let Ok(entries) = fs::read_dir("/sys/class/drm") else {
|
|
return Vec::new();
|
|
};
|
|
entries
|
|
.filter_map(|entry| entry.ok())
|
|
.filter(|entry| {
|
|
let name = entry.file_name();
|
|
let name = name.to_string_lossy();
|
|
name.strip_prefix("card").is_some_and(|suffix| {
|
|
!suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
|
|
})
|
|
})
|
|
.filter_map(|entry| gpu_name_from_uevent(&text(entry.path().join("device/uevent"))))
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", test))]
|
|
fn parse_system_profiler_gpu_names(output: &str) -> Vec<String> {
|
|
output
|
|
.lines()
|
|
.filter_map(|line| {
|
|
line.trim()
|
|
.strip_prefix("Chipset Model:")
|
|
.map(|name| name.trim().to_string())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(any(target_os = "freebsd", test))]
|
|
fn parse_pciconf_gpu_names(output: &str) -> Vec<String> {
|
|
fn block_name(lines: &[&str]) -> Option<String> {
|
|
let header = lines.first()?.to_ascii_lowercase();
|
|
let mut display = header.contains("class=0x03");
|
|
let mut vendor = None;
|
|
let mut device = None;
|
|
for line in lines.iter().skip(1) {
|
|
let Some((key, value)) = line.trim().split_once('=') else {
|
|
continue;
|
|
};
|
|
let key = key.trim();
|
|
let value = value.trim().trim_matches(['\'', '"']);
|
|
match key {
|
|
"class" if value.eq_ignore_ascii_case("display") => display = true,
|
|
"vendor" => vendor = Some(value),
|
|
"device" => device = Some(value),
|
|
_ => {}
|
|
}
|
|
}
|
|
if !display {
|
|
return None;
|
|
}
|
|
match (vendor, device) {
|
|
(Some(vendor), Some(device))
|
|
if !device
|
|
.to_ascii_lowercase()
|
|
.contains(&vendor.to_ascii_lowercase()) =>
|
|
{
|
|
Some(format!("{vendor} {device}"))
|
|
}
|
|
(_, Some(device)) => Some(device.to_string()),
|
|
(Some(vendor), None) => Some(vendor.to_string()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
let mut names = Vec::new();
|
|
let mut block = Vec::new();
|
|
for line in output.lines() {
|
|
if !line.is_empty()
|
|
&& !line
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|character| character.is_whitespace())
|
|
&& !block.is_empty()
|
|
{
|
|
if let Some(name) = block_name(&block) {
|
|
names.push(name);
|
|
}
|
|
block.clear();
|
|
}
|
|
if !line.trim().is_empty() {
|
|
block.push(line);
|
|
}
|
|
}
|
|
if let Some(name) = block_name(&block) {
|
|
names.push(name);
|
|
}
|
|
names
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn basic_gpu_info() -> Vec<GpuMetric> {
|
|
let names = parse_lspci_gpu_names(&command("lspci", &[]));
|
|
basic_gpu_metrics(if names.is_empty() {
|
|
sysfs_gpu_names()
|
|
} else {
|
|
names
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn basic_gpu_info() -> Vec<GpuMetric> {
|
|
basic_gpu_metrics(
|
|
command(
|
|
"powershell.exe",
|
|
&[
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
"Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }",
|
|
],
|
|
)
|
|
.lines()
|
|
.map(str::to_string),
|
|
)
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn basic_gpu_info() -> Vec<GpuMetric> {
|
|
basic_gpu_metrics(parse_system_profiler_gpu_names(&command(
|
|
"system_profiler",
|
|
&["SPDisplaysDataType"],
|
|
)))
|
|
}
|
|
|
|
#[cfg(target_os = "freebsd")]
|
|
fn basic_gpu_info() -> Vec<GpuMetric> {
|
|
basic_gpu_metrics(parse_pciconf_gpu_names(&command("pciconf", &["-lv"])))
|
|
}
|
|
|
|
fn gpu_info() -> Vec<GpuMetric> {
|
|
let detailed = nvidia_gpu_info();
|
|
if detailed.is_empty() {
|
|
basic_gpu_info()
|
|
} else {
|
|
detailed
|
|
}
|
|
}
|
|
|
|
fn average_gpu_usage(gpus: &[GpuMetric]) -> f64 {
|
|
let usages = gpus.iter().filter_map(|gpu| gpu.usage).collect::<Vec<_>>();
|
|
if usages.is_empty() {
|
|
0.0
|
|
} else {
|
|
usages.iter().sum::<f64>() / usages.len() as f64
|
|
}
|
|
}
|
|
|
|
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
|
fn u64_to_i64(value: u64) -> i64 {
|
|
value.min(i64::MAX as u64) as i64
|
|
}
|
|
|
|
fn per_second(value: u64, elapsed_seconds: f64) -> f64 {
|
|
value as f64 / elapsed_seconds.max(0.001)
|
|
}
|
|
|
|
fn valid_sample_schedule(report_interval: u64, collect_interval: u64) -> bool {
|
|
(15..=3600).contains(&report_interval)
|
|
&& (1..=60).contains(&collect_interval)
|
|
&& collect_interval <= report_interval
|
|
&& report_interval.div_ceil(collect_interval) <= LIVE_QUEUE_CAPACITY as u64
|
|
}
|
|
|
|
fn advance_deadline(mut deadline: Instant, interval: Duration, now: Instant) -> Instant {
|
|
while deadline <= now {
|
|
deadline += interval;
|
|
}
|
|
deadline
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
struct Collector {
|
|
previous_cpu: CpuSample,
|
|
previous_io: IoSample,
|
|
previous_at: Instant,
|
|
network_interface: String,
|
|
basic: BasicMetrics,
|
|
basic_at: Instant,
|
|
slow: SlowMetrics,
|
|
slow_at: Instant,
|
|
public_ip: PublicIpProbe,
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
impl Collector {
|
|
fn new(config: &RuntimeConfig) -> Self {
|
|
let mut collector = Self {
|
|
previous_cpu: cpu_sample(),
|
|
previous_io: io_sample(&config.network_interface),
|
|
previous_at: Instant::now(),
|
|
network_interface: config.network_interface.clone(),
|
|
basic: BasicMetrics::default(),
|
|
basic_at: Instant::now(),
|
|
slow: SlowMetrics::default(),
|
|
slow_at: Instant::now(),
|
|
public_ip: PublicIpProbe::default(),
|
|
};
|
|
collector.refresh_basic();
|
|
collector.refresh_slow();
|
|
collector
|
|
}
|
|
|
|
fn refresh_basic(&mut self) {
|
|
let gpus = gpu_info();
|
|
self.basic = BasicMetrics {
|
|
cpu_cores: thread::available_parallelism()
|
|
.map(|value| value.get() as i64)
|
|
.unwrap_or(1),
|
|
cpu_model: cpu_model(),
|
|
os: os_name(),
|
|
kernel: command("uname", &["-r"]),
|
|
arch: env::consts::ARCH.to_string(),
|
|
virtualization: command("systemd-detect-virt", &[]),
|
|
gpu_usage: average_gpu_usage(&gpus),
|
|
gpu_model: gpus
|
|
.iter()
|
|
.map(|gpu| gpu.model.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(" · "),
|
|
gpus,
|
|
};
|
|
self.basic_at = Instant::now();
|
|
}
|
|
|
|
fn refresh_slow(&mut self) {
|
|
let processes = fs::read_dir("/proc")
|
|
.map(|items| {
|
|
items
|
|
.filter_map(|item| item.ok())
|
|
.filter(|item| {
|
|
item.file_name()
|
|
.to_string_lossy()
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_digit())
|
|
})
|
|
.count() as i64
|
|
})
|
|
.unwrap_or(0);
|
|
self.slow = SlowMetrics {
|
|
disks: disk_usage(),
|
|
processes,
|
|
tcp_connections: file_line_count("/proc/net/tcp") + file_line_count("/proc/net/tcp6"),
|
|
udp_connections: file_line_count("/proc/net/udp") + file_line_count("/proc/net/udp6"),
|
|
};
|
|
self.slow_at = Instant::now();
|
|
}
|
|
|
|
fn collect(
|
|
&mut self,
|
|
config: &RuntimeConfig,
|
|
latency_results: Vec<LatencyResult>,
|
|
timestamp: i64,
|
|
) -> Report {
|
|
let sampled_at = Instant::now();
|
|
let elapsed = sampled_at
|
|
.saturating_duration_since(self.previous_at)
|
|
.as_secs_f64()
|
|
.max(0.001);
|
|
let cpu_now = cpu_sample();
|
|
let io_now = io_sample(&config.network_interface);
|
|
let total = cpu_now.total.saturating_sub(self.previous_cpu.total);
|
|
let idle = cpu_now.idle.saturating_sub(self.previous_cpu.idle);
|
|
let cpu = if total == 0 {
|
|
0.0
|
|
} else {
|
|
(total.saturating_sub(idle)) as f64 * 100.0 / total as f64
|
|
};
|
|
let interface_changed = self.network_interface != config.network_interface;
|
|
let io_before = if interface_changed {
|
|
io_now
|
|
} else {
|
|
self.previous_io
|
|
};
|
|
self.previous_cpu = cpu_now;
|
|
self.previous_io = io_now;
|
|
self.previous_at = sampled_at;
|
|
self.network_interface.clone_from(&config.network_interface);
|
|
|
|
if self.basic_at.elapsed() >= BASIC_INFO_REFRESH_INTERVAL {
|
|
self.refresh_basic();
|
|
}
|
|
if self.slow_at.elapsed() >= SLOW_METRICS_REFRESH_INTERVAL {
|
|
self.refresh_slow();
|
|
}
|
|
self.public_ip.refresh();
|
|
|
|
let mem = text("/proc/meminfo");
|
|
let mem_total = mem_value(&mem, "MemTotal:");
|
|
let mem_used = mem_total.saturating_sub(mem_value(&mem, "MemAvailable:"));
|
|
let swap_total = mem_value(&mem, "SwapTotal:");
|
|
let swap_used = swap_total.saturating_sub(mem_value(&mem, "SwapFree:"));
|
|
let loads = text("/proc/loadavg")
|
|
.split_whitespace()
|
|
.take(3)
|
|
.filter_map(|value| value.parse::<f64>().ok())
|
|
.collect::<Vec<_>>();
|
|
let uptime = text("/proc/uptime")
|
|
.split_whitespace()
|
|
.next()
|
|
.and_then(|value| value.parse::<f64>().ok())
|
|
.unwrap_or(0.0) as i64;
|
|
let processes = self.slow.processes;
|
|
let disks = self.slow.disks.clone();
|
|
let disk_used = disks.iter().map(|disk| disk.used).sum();
|
|
let disk_total = disks.iter().map(|disk| disk.total).sum();
|
|
let tcp_connections = self.slow.tcp_connections;
|
|
let udp_connections = self.slow.udp_connections;
|
|
let read_ops = io_now.read_ops.saturating_sub(io_before.read_ops);
|
|
let write_ops = io_now.write_ops.saturating_sub(io_before.write_ops);
|
|
let total_ops = read_ops.saturating_add(write_ops);
|
|
let io_wait = io_now
|
|
.read_millis
|
|
.saturating_sub(io_before.read_millis)
|
|
.saturating_add(io_now.write_millis.saturating_sub(io_before.write_millis));
|
|
let elapsed_ms = elapsed * 1000.0;
|
|
|
|
Report {
|
|
timestamp,
|
|
cpu,
|
|
load1: loads.first().copied().unwrap_or(0.0),
|
|
load5: loads.get(1).copied().unwrap_or(0.0),
|
|
load15: loads.get(2).copied().unwrap_or(0.0),
|
|
mem_used,
|
|
mem_total,
|
|
swap_used,
|
|
swap_total,
|
|
disk_used,
|
|
disk_total,
|
|
net_in: per_second(io_now.rx.saturating_sub(io_before.rx), elapsed),
|
|
net_out: per_second(io_now.tx.saturating_sub(io_before.tx), elapsed),
|
|
net_rx_total: io_now.rx.min(i64::MAX as u64) as i64,
|
|
net_tx_total: io_now.tx.min(i64::MAX as u64) as i64,
|
|
uptime,
|
|
processes,
|
|
tcp_connections,
|
|
udp_connections,
|
|
cpu_cores: self.basic.cpu_cores,
|
|
cpu_model: self.basic.cpu_model.clone(),
|
|
os: self.basic.os.clone(),
|
|
kernel: self.basic.kernel.clone(),
|
|
arch: self.basic.arch.clone(),
|
|
virtualization: self.basic.virtualization.clone(),
|
|
gpu_usage: self.basic.gpu_usage,
|
|
gpu_model: self.basic.gpu_model.clone(),
|
|
agent_version: VERSION.to_string(),
|
|
ip_v4: self.public_ip.v4().unwrap_or_default(),
|
|
ip_v6: self.public_ip.v6().unwrap_or_default(),
|
|
disk_read_bps: per_second(
|
|
io_now
|
|
.read_sectors
|
|
.saturating_sub(io_before.read_sectors)
|
|
.saturating_mul(512),
|
|
elapsed,
|
|
),
|
|
disk_write_bps: per_second(
|
|
io_now
|
|
.write_sectors
|
|
.saturating_sub(io_before.write_sectors)
|
|
.saturating_mul(512),
|
|
elapsed,
|
|
),
|
|
disk_read_iops: per_second(read_ops, elapsed),
|
|
disk_write_iops: per_second(write_ops, elapsed),
|
|
disk_await_ms: if total_ops == 0 {
|
|
0.0
|
|
} else {
|
|
io_wait as f64 / total_ops as f64
|
|
},
|
|
disk_utilization: if elapsed_ms <= 0.0 {
|
|
0.0
|
|
} else {
|
|
(io_now.io_millis.saturating_sub(io_before.io_millis) as f64 * 100.0 / elapsed_ms)
|
|
.clamp(0.0, 100.0)
|
|
},
|
|
disks,
|
|
gpus: self.basic.gpus.clone(),
|
|
latency_results,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
|
struct Collector {
|
|
system: System,
|
|
networks: Networks,
|
|
disks: Disks,
|
|
previous_at: Instant,
|
|
basic: BasicMetrics,
|
|
basic_at: Instant,
|
|
slow: SlowMetrics,
|
|
slow_at: Instant,
|
|
public_ip: PublicIpProbe,
|
|
}
|
|
|
|
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
|
impl Collector {
|
|
fn new(_config: &RuntimeConfig) -> Self {
|
|
let mut collector = Self {
|
|
system: System::new_all(),
|
|
networks: Networks::new_with_refreshed_list(),
|
|
disks: Disks::new_with_refreshed_list(),
|
|
previous_at: Instant::now(),
|
|
basic: BasicMetrics::default(),
|
|
basic_at: Instant::now(),
|
|
slow: SlowMetrics::default(),
|
|
slow_at: Instant::now(),
|
|
public_ip: PublicIpProbe::default(),
|
|
};
|
|
collector.refresh_basic();
|
|
collector.refresh_slow();
|
|
collector
|
|
}
|
|
|
|
fn refresh_basic(&mut self) {
|
|
let gpus = gpu_info();
|
|
self.basic = BasicMetrics {
|
|
cpu_cores: self.system.cpus().len().max(1) as i64,
|
|
cpu_model: self
|
|
.system
|
|
.cpus()
|
|
.first()
|
|
.map(|cpu| cpu.brand().to_string())
|
|
.unwrap_or_default(),
|
|
os: System::long_os_version().unwrap_or_else(|| env::consts::OS.to_string()),
|
|
kernel: System::kernel_version().unwrap_or_default(),
|
|
arch: env::consts::ARCH.to_string(),
|
|
virtualization: String::new(),
|
|
gpu_usage: average_gpu_usage(&gpus),
|
|
gpu_model: gpus
|
|
.iter()
|
|
.map(|gpu| gpu.model.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(" · "),
|
|
gpus,
|
|
};
|
|
self.basic_at = Instant::now();
|
|
}
|
|
|
|
fn refresh_slow(&mut self) {
|
|
self.system.refresh_processes_specifics(
|
|
ProcessesToUpdate::All,
|
|
true,
|
|
ProcessRefreshKind::nothing().without_tasks(),
|
|
);
|
|
self.disks.refresh(true);
|
|
#[cfg(target_os = "windows")]
|
|
let root = format!(
|
|
"{}\\",
|
|
env::var("SystemDrive").unwrap_or_else(|_| "C:".to_string())
|
|
);
|
|
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
|
let root = "/".to_string();
|
|
let mut disks = self
|
|
.disks
|
|
.iter()
|
|
.filter(|disk| !disk.is_removable())
|
|
.map(|disk| {
|
|
let total = disk.total_space();
|
|
DiskMetric {
|
|
name: disk.name().to_string_lossy().to_string(),
|
|
mount_point: disk.mount_point().to_string_lossy().to_string(),
|
|
used: u64_to_i64(total.saturating_sub(disk.available_space())),
|
|
total: u64_to_i64(total),
|
|
..DiskMetric::default()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
disks.sort_by_key(|disk| (disk.mount_point != root, disk.mount_point.clone()));
|
|
let (tcp_connections, udp_connections) = connection_counts();
|
|
self.slow = SlowMetrics {
|
|
disks,
|
|
processes: self.system.processes().len() as i64,
|
|
tcp_connections,
|
|
udp_connections,
|
|
};
|
|
self.slow_at = Instant::now();
|
|
}
|
|
|
|
fn collect(
|
|
&mut self,
|
|
config: &RuntimeConfig,
|
|
latency_results: Vec<LatencyResult>,
|
|
timestamp: i64,
|
|
) -> Report {
|
|
let sampled_at = Instant::now();
|
|
let elapsed = sampled_at
|
|
.saturating_duration_since(self.previous_at)
|
|
.as_secs_f64()
|
|
.max(0.001);
|
|
self.system.refresh_cpu_usage();
|
|
self.system.refresh_memory();
|
|
self.networks.refresh(true);
|
|
self.previous_at = sampled_at;
|
|
if self.basic_at.elapsed() >= BASIC_INFO_REFRESH_INTERVAL {
|
|
self.refresh_basic();
|
|
}
|
|
if self.slow_at.elapsed() >= SLOW_METRICS_REFRESH_INTERVAL {
|
|
self.refresh_slow();
|
|
}
|
|
self.public_ip.refresh();
|
|
|
|
let disk_metrics = self.slow.disks.clone();
|
|
let tcp_connections = self.slow.tcp_connections;
|
|
let udp_connections = self.slow.udp_connections;
|
|
|
|
let mut net_in = 0_u64;
|
|
let mut net_out = 0_u64;
|
|
let mut net_rx_total = 0_u64;
|
|
let mut net_tx_total = 0_u64;
|
|
for (name, network) in &self.networks {
|
|
if !selected_interface(name, &config.network_interface) {
|
|
continue;
|
|
}
|
|
net_in = net_in.saturating_add(network.received());
|
|
net_out = net_out.saturating_add(network.transmitted());
|
|
net_rx_total = net_rx_total.saturating_add(network.total_received());
|
|
net_tx_total = net_tx_total.saturating_add(network.total_transmitted());
|
|
}
|
|
let load = System::load_average();
|
|
Report {
|
|
timestamp,
|
|
cpu: self.system.global_cpu_usage() as f64,
|
|
load1: load.one,
|
|
load5: load.five,
|
|
load15: load.fifteen,
|
|
mem_used: u64_to_i64(self.system.used_memory()),
|
|
mem_total: u64_to_i64(self.system.total_memory()),
|
|
swap_used: u64_to_i64(self.system.used_swap()),
|
|
swap_total: u64_to_i64(self.system.total_swap()),
|
|
disk_used: disk_metrics.iter().map(|disk| disk.used).sum(),
|
|
disk_total: disk_metrics.iter().map(|disk| disk.total).sum(),
|
|
net_in: per_second(net_in, elapsed),
|
|
net_out: per_second(net_out, elapsed),
|
|
net_rx_total: u64_to_i64(net_rx_total),
|
|
net_tx_total: u64_to_i64(net_tx_total),
|
|
uptime: u64_to_i64(System::uptime()),
|
|
processes: self.slow.processes,
|
|
tcp_connections,
|
|
udp_connections,
|
|
cpu_cores: self.basic.cpu_cores,
|
|
cpu_model: self.basic.cpu_model.clone(),
|
|
os: self.basic.os.clone(),
|
|
kernel: self.basic.kernel.clone(),
|
|
arch: self.basic.arch.clone(),
|
|
virtualization: self.basic.virtualization.clone(),
|
|
gpu_usage: self.basic.gpu_usage,
|
|
gpu_model: self.basic.gpu_model.clone(),
|
|
agent_version: VERSION.to_string(),
|
|
ip_v4: self.public_ip.v4().unwrap_or_default(),
|
|
ip_v6: self.public_ip.v6().unwrap_or_default(),
|
|
disk_read_bps: 0.0,
|
|
disk_write_bps: 0.0,
|
|
disk_read_iops: 0.0,
|
|
disk_write_iops: 0.0,
|
|
disk_await_ms: 0.0,
|
|
disk_utilization: 0.0,
|
|
disks: disk_metrics,
|
|
gpus: self.basic.gpus.clone(),
|
|
latency_results,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn runtime_config(options: &CliOptions) -> Result<RuntimeConfig> {
|
|
let interval = options.interval;
|
|
if !(15..=3600).contains(&interval) {
|
|
return Err("interval must be between 15 and 3600 seconds".into());
|
|
}
|
|
let token = options.token.clone();
|
|
let endpoint = options.endpoint.clone();
|
|
if token.is_empty() || token.len() > 512 || token.chars().any(char::is_whitespace) {
|
|
return Err("token is invalid".into());
|
|
}
|
|
if !valid_endpoint(&endpoint) {
|
|
return Err(
|
|
"endpoint must use HTTPS; HTTP is only allowed for loopback development".into(),
|
|
);
|
|
}
|
|
Ok(RuntimeConfig {
|
|
token,
|
|
endpoint: endpoint.trim_end_matches('/').to_string(),
|
|
report_interval: interval,
|
|
collect_interval: 1,
|
|
network_interface: String::new(),
|
|
agent_mirror: String::new(),
|
|
auto_update: true,
|
|
latency_tasks: Vec::new(),
|
|
})
|
|
}
|
|
|
|
fn live_endpoint(endpoint: &str) -> Result<String> {
|
|
let mut url = url::Url::parse(endpoint)?;
|
|
url.set_scheme(if url.scheme() == "https" { "wss" } else { "ws" })
|
|
.map_err(|_| "unsupported live endpoint scheme")?;
|
|
let path = format!("{}/api/agent/ws", url.path().trim_end_matches('/'));
|
|
url.set_path(&path);
|
|
Ok(url.to_string())
|
|
}
|
|
|
|
fn report_batch_id(reports: &[Report]) -> String {
|
|
let encoded = serde_json::to_vec(reports).unwrap_or_default();
|
|
let digest = Sha256::digest(encoded);
|
|
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
|
}
|
|
|
|
type LiveSocket = tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<TcpStream>>;
|
|
|
|
fn connect_live(endpoint: &str, token: &str) -> Result<(LiveSocket, Option<i64>)> {
|
|
let mut request = endpoint.into_client_request()?;
|
|
request
|
|
.headers_mut()
|
|
.insert("Authorization", format!("Bearer {token}").parse()?);
|
|
request
|
|
.headers_mut()
|
|
.insert("User-Agent", format!("nodeflare-agent/{VERSION}").parse()?);
|
|
let started_ms = unix_timestamp_millis();
|
|
let (socket, response) = connect(request)?;
|
|
let ended_ms = unix_timestamp_millis();
|
|
let clock_offset_ms = response
|
|
.headers()
|
|
.get("date")
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| clock_offset_from_http_date(value, started_ms, ended_ms));
|
|
Ok((socket, clock_offset_ms))
|
|
}
|
|
|
|
fn set_live_read_timeout(socket: &mut LiveSocket, timeout: Option<Duration>) -> io::Result<()> {
|
|
match socket.get_mut() {
|
|
tungstenite::stream::MaybeTlsStream::Plain(stream) => stream.set_read_timeout(timeout),
|
|
tungstenite::stream::MaybeTlsStream::Rustls(stream) => {
|
|
stream.sock.set_read_timeout(timeout)
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct LiveAck {
|
|
#[serde(rename = "type")]
|
|
message_type: String,
|
|
ts: i64,
|
|
persisted: bool,
|
|
#[serde(rename = "persistenceError")]
|
|
persistence_error: bool,
|
|
#[serde(rename = "persistedThroughTs")]
|
|
persisted_through_ts: i64,
|
|
#[serde(rename = "nextD1WriteAfterMs")]
|
|
next_persist_after_ms: u64,
|
|
#[serde(rename = "nextWssReportAfterMs")]
|
|
next_wss_report_after_ms: u64,
|
|
#[serde(rename = "realtimeHint")]
|
|
realtime_hint: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct LiveConfigMessage {
|
|
#[serde(rename = "type")]
|
|
message_type: String,
|
|
ts: i64,
|
|
config: RemoteConfig,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RemoteTaskMessage {
|
|
#[serde(rename = "type")]
|
|
message_type: String,
|
|
task_id: String,
|
|
command: String,
|
|
script: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct TaskResultMessage {
|
|
#[serde(rename = "type")]
|
|
message_type: String,
|
|
task_id: String,
|
|
status: String,
|
|
result: String,
|
|
exit_code: Option<i64>,
|
|
}
|
|
|
|
fn execute_remote_task(task: &RemoteTaskMessage) -> TaskResultMessage {
|
|
let task_id = task.task_id.clone();
|
|
|
|
// 优先执行 script,否则执行 command
|
|
let (shell, args) = if !task.script.is_empty() {
|
|
#[cfg(unix)]
|
|
{
|
|
("sh", vec!["-c", task.script.as_str()])
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
("cmd.exe", vec!["/C", task.script.as_str()])
|
|
}
|
|
} else if !task.command.is_empty() {
|
|
#[cfg(unix)]
|
|
{
|
|
("sh", vec!["-c", task.command.as_str()])
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
("cmd.exe", vec!["/C", task.command.as_str()])
|
|
}
|
|
} else {
|
|
return TaskResultMessage {
|
|
message_type: "task_result".to_string(),
|
|
task_id,
|
|
status: "failed".to_string(),
|
|
result: "No command or script provided".to_string(),
|
|
exit_code: Some(-1),
|
|
};
|
|
};
|
|
|
|
match Command::new(shell).args(&args).output() {
|
|
Ok(output) => {
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let combined = if stderr.is_empty() {
|
|
stdout.to_string()
|
|
} else {
|
|
format!("{}\n{}", stdout.trim(), stderr.trim())
|
|
};
|
|
|
|
TaskResultMessage {
|
|
message_type: "task_result".to_string(),
|
|
task_id,
|
|
status: if output.status.success() {
|
|
"success"
|
|
} else {
|
|
"failed"
|
|
}
|
|
.to_string(),
|
|
result: combined.trim().to_string(),
|
|
exit_code: output.status.code().map(|c| c as i64),
|
|
}
|
|
}
|
|
Err(error) => TaskResultMessage {
|
|
message_type: "task_result".to_string(),
|
|
task_id,
|
|
status: "failed".to_string(),
|
|
result: format!("Execution error: {}", error),
|
|
exit_code: Some(-1),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn ack_wss_interval(ack: &LiveAck) -> Duration {
|
|
Duration::from_millis(ack.next_wss_report_after_ms)
|
|
.clamp(Duration::from_secs(1), Duration::from_secs(3600))
|
|
}
|
|
|
|
fn ack_d1_interval(ack: &LiveAck) -> Duration {
|
|
Duration::from_millis(ack.next_persist_after_ms)
|
|
.clamp(Duration::from_secs(1), Duration::from_secs(3600))
|
|
}
|
|
|
|
enum LiveRead {
|
|
Ack(LiveAck),
|
|
Config(RemoteConfig),
|
|
RemoteTask(RemoteTaskMessage),
|
|
Closed,
|
|
Pending,
|
|
}
|
|
|
|
fn read_live_ack(socket: &mut LiveSocket) -> Result<LiveRead> {
|
|
match socket.read() {
|
|
Ok(Message::Text(text)) => {
|
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(text.as_ref()) else {
|
|
return Ok(LiveRead::Pending);
|
|
};
|
|
match value.get("type").and_then(serde_json::Value::as_str) {
|
|
Some("ack") => {
|
|
let Ok(ack) = serde_json::from_value::<LiveAck>(value) else {
|
|
return Ok(LiveRead::Pending);
|
|
};
|
|
if ack.message_type != "ack" || ack.ts <= 0 {
|
|
return Ok(LiveRead::Pending);
|
|
}
|
|
Ok(LiveRead::Ack(ack))
|
|
}
|
|
Some("config") => {
|
|
let Ok(message) = serde_json::from_value::<LiveConfigMessage>(value) else {
|
|
return Ok(LiveRead::Pending);
|
|
};
|
|
if message.message_type != "config" || message.ts <= 0 {
|
|
return Ok(LiveRead::Pending);
|
|
}
|
|
Ok(LiveRead::Config(message.config))
|
|
}
|
|
Some("remote_task") => {
|
|
let Ok(task) = serde_json::from_value::<RemoteTaskMessage>(value) else {
|
|
return Ok(LiveRead::Pending);
|
|
};
|
|
if task.message_type != "remote_task" || task.task_id.is_empty() {
|
|
return Ok(LiveRead::Pending);
|
|
}
|
|
Ok(LiveRead::RemoteTask(task))
|
|
}
|
|
_ => Ok(LiveRead::Pending),
|
|
}
|
|
}
|
|
Ok(Message::Ping(payload)) => {
|
|
socket.send(Message::Pong(payload))?;
|
|
Ok(LiveRead::Pending)
|
|
}
|
|
Ok(Message::Close(_)) => Ok(LiveRead::Closed),
|
|
Ok(_) => Ok(LiveRead::Pending),
|
|
Err(WebSocketError::Io(error))
|
|
if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) =>
|
|
{
|
|
Ok(LiveRead::Pending)
|
|
}
|
|
Err(error) => Err(error.into()),
|
|
}
|
|
}
|
|
|
|
fn wait_for_live_ack(
|
|
socket: &mut LiveSocket,
|
|
remote_config: &Arc<Mutex<Option<RemoteConfig>>>,
|
|
) -> Result<LiveRead> {
|
|
let deadline = Instant::now() + LIVE_ACK_READ_TIMEOUT;
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
if remaining.is_zero() {
|
|
return Ok(LiveRead::Pending);
|
|
}
|
|
set_live_read_timeout(socket, Some(remaining))?;
|
|
match read_live_ack(socket)? {
|
|
LiveRead::Ack(ack) if !ack.realtime_hint => {
|
|
return Ok(LiveRead::Ack(ack));
|
|
}
|
|
LiveRead::Config(config) => {
|
|
if let Ok(mut target) = remote_config.lock() {
|
|
*target = Some(config);
|
|
}
|
|
}
|
|
LiveRead::RemoteTask(task) => {
|
|
// 执行任务并发送结果
|
|
let result = execute_remote_task(&task);
|
|
if let Ok(payload) = serde_json::to_string(&result) {
|
|
let _ = socket.send(Message::Text(payload.into()));
|
|
}
|
|
}
|
|
LiveRead::Closed => return Ok(LiveRead::Closed),
|
|
LiveRead::Ack(_) | LiveRead::Pending => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn live_update_payload(reports: &[Report]) -> Result<String> {
|
|
Ok(serde_json::to_string(&serde_json::json!({
|
|
"type": "update",
|
|
"batchId": report_batch_id(reports),
|
|
"samples": reports,
|
|
}))?)
|
|
}
|
|
|
|
fn observe_persisted_through(target: &AtomicI64, ack: &LiveAck) {
|
|
let _ = ack.persisted;
|
|
target.fetch_max(ack.persisted_through_ts.max(0), Ordering::AcqRel);
|
|
}
|
|
|
|
fn prune_live_queue(pending: &Arc<(Mutex<VecDeque<Report>>, Condvar)>, persisted_through: i64) {
|
|
if persisted_through <= 0 {
|
|
return;
|
|
}
|
|
if let Ok(mut queue) = pending.0.lock() {
|
|
queue.retain(|report| report.timestamp > persisted_through);
|
|
}
|
|
}
|
|
|
|
fn live_batch_after(queue: &VecDeque<Report>, timestamp: i64) -> Vec<Report> {
|
|
let candidates = queue
|
|
.iter()
|
|
.filter(|report| report.timestamp > timestamp)
|
|
.cloned()
|
|
.collect::<VecDeque<_>>();
|
|
let count = live_batch::batch_len(&candidates);
|
|
candidates.into_iter().take(count).collect()
|
|
}
|
|
|
|
fn live_persistence_probe(queue: &VecDeque<Report>, persisted_through: i64) -> Vec<Report> {
|
|
queue
|
|
.iter()
|
|
.rev()
|
|
.find(|report| report.timestamp > persisted_through)
|
|
.cloned()
|
|
.into_iter()
|
|
.collect()
|
|
}
|
|
|
|
fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
|
let LiveSenderWorker {
|
|
pending,
|
|
configured_interval,
|
|
persisted_through,
|
|
remote_config,
|
|
clock,
|
|
stats,
|
|
} = worker;
|
|
let mut socket: Option<LiveSocket> = None;
|
|
let mut wss_interval = configured_interval
|
|
.lock()
|
|
.map(|interval| *interval)
|
|
.unwrap_or(Duration::from_secs(1));
|
|
let mut accepted_through = persisted_through.load(Ordering::Acquire);
|
|
let mut next_send_at = Instant::now();
|
|
let mut next_probe_at = Instant::now();
|
|
'sender: loop {
|
|
if socket.is_none() {
|
|
match connect_live(endpoint, token) {
|
|
Ok((mut connected, offset_ms)) => {
|
|
observe_clock(&clock, offset_ms);
|
|
if set_live_read_timeout(&mut connected, Some(LIVE_HINT_READ_TIMEOUT)).is_err()
|
|
{
|
|
thread::sleep(LIVE_RECONNECT_DELAY);
|
|
continue;
|
|
}
|
|
socket = Some(connected);
|
|
accepted_through = persisted_through.load(Ordering::Acquire);
|
|
next_send_at = Instant::now();
|
|
next_probe_at = Instant::now();
|
|
}
|
|
Err(error) => {
|
|
stats.live_connect_failed();
|
|
eprintln!("live connection failed: {error}");
|
|
thread::sleep(LIVE_RECONNECT_DELAY);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
let (queue_lock, ready) = &*pending;
|
|
let mut queue = match queue_lock.lock() {
|
|
Ok(queue) => queue,
|
|
Err(_) => return,
|
|
};
|
|
let batch = loop {
|
|
let now = Instant::now();
|
|
let unsent = live_batch_after(&queue, accepted_through);
|
|
if !unsent.is_empty() && (now >= next_send_at || now >= next_probe_at) {
|
|
break unsent;
|
|
}
|
|
let persisted = persisted_through.load(Ordering::Acquire);
|
|
if unsent.is_empty() && now >= next_probe_at {
|
|
let probe = live_persistence_probe(&queue, persisted);
|
|
if !probe.is_empty() {
|
|
break probe;
|
|
}
|
|
}
|
|
|
|
let wake_at = if !unsent.is_empty() {
|
|
next_send_at.min(next_probe_at)
|
|
} else if queue.iter().any(|report| report.timestamp > persisted) {
|
|
next_probe_at
|
|
} else {
|
|
now + Duration::from_millis(250)
|
|
};
|
|
let wait = wake_at
|
|
.saturating_duration_since(now)
|
|
.min(Duration::from_millis(250));
|
|
queue = match ready.wait_timeout(queue, wait) {
|
|
Ok((queue, _)) => queue,
|
|
Err(_) => return,
|
|
};
|
|
drop(queue);
|
|
|
|
let mut drop_socket = false;
|
|
if let Some(connected) = socket.as_mut() {
|
|
if set_live_read_timeout(connected, Some(LIVE_HINT_READ_TIMEOUT)).is_err() {
|
|
drop_socket = true;
|
|
} else {
|
|
match read_live_ack(connected) {
|
|
Ok(LiveRead::Closed) => drop_socket = true,
|
|
Ok(LiveRead::Ack(ack)) => {
|
|
observe_persisted_through(&persisted_through, &ack);
|
|
if ack.persistence_error {
|
|
stats.live_persistence_failed();
|
|
drop_socket = true;
|
|
} else {
|
|
prune_live_queue(&pending, ack.persisted_through_ts);
|
|
accepted_through = accepted_through.max(ack.persisted_through_ts);
|
|
wss_interval = ack_wss_interval(&ack);
|
|
next_send_at = Instant::now() + wss_interval;
|
|
next_probe_at = Instant::now() + ack_d1_interval(&ack);
|
|
}
|
|
}
|
|
Ok(LiveRead::Config(config)) => {
|
|
if let Ok(mut target) = remote_config.lock() {
|
|
*target = Some(config);
|
|
}
|
|
}
|
|
Ok(LiveRead::RemoteTask(task)) => {
|
|
let result = execute_remote_task(&task);
|
|
if let Ok(payload) = serde_json::to_string(&result) {
|
|
let _ = connected.send(Message::Text(payload.into()));
|
|
}
|
|
}
|
|
Ok(LiveRead::Pending) => {}
|
|
Err(error) => {
|
|
eprintln!("live hint read failed: {error}");
|
|
drop_socket = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if drop_socket {
|
|
socket = None;
|
|
accepted_through = persisted_through.load(Ordering::Acquire);
|
|
next_send_at = Instant::now();
|
|
next_probe_at = Instant::now();
|
|
thread::sleep(LIVE_RECONNECT_DELAY);
|
|
continue 'sender;
|
|
}
|
|
queue = match queue_lock.lock() {
|
|
Ok(queue) => queue,
|
|
Err(_) => return,
|
|
};
|
|
};
|
|
drop(queue);
|
|
|
|
if let Ok(interval) = configured_interval.lock() {
|
|
wss_interval = (*interval).clamp(Duration::from_secs(1), Duration::from_secs(60));
|
|
}
|
|
|
|
let payload = match live_update_payload(&batch) {
|
|
Ok(payload) => payload,
|
|
Err(error) => {
|
|
eprintln!("live payload encode failed: {error}");
|
|
next_send_at = Instant::now() + wss_interval;
|
|
continue;
|
|
}
|
|
};
|
|
let sent = socket
|
|
.as_mut()
|
|
.is_some_and(|socket| socket.send(Message::Text(payload.into())).is_ok());
|
|
if !sent {
|
|
socket = None;
|
|
accepted_through = persisted_through.load(Ordering::Acquire);
|
|
next_send_at = Instant::now();
|
|
next_probe_at = Instant::now();
|
|
thread::sleep(LIVE_RECONNECT_DELAY);
|
|
continue;
|
|
}
|
|
stats.live_batch_sent(batch.len());
|
|
|
|
let mut drop_socket = false;
|
|
if let Some(socket) = socket.as_mut() {
|
|
match wait_for_live_ack(socket, &remote_config) {
|
|
Ok(LiveRead::Closed) => drop_socket = true,
|
|
Ok(LiveRead::Ack(ack)) => {
|
|
observe_persisted_through(&persisted_through, &ack);
|
|
if ack.persistence_error {
|
|
stats.live_persistence_failed();
|
|
drop_socket = true;
|
|
} else {
|
|
prune_live_queue(&pending, ack.persisted_through_ts);
|
|
if let Some(timestamp) = batch.iter().map(|report| report.timestamp).max() {
|
|
accepted_through = accepted_through.max(timestamp);
|
|
}
|
|
accepted_through = accepted_through.max(ack.persisted_through_ts);
|
|
wss_interval = ack_wss_interval(&ack);
|
|
next_send_at = Instant::now() + wss_interval;
|
|
next_probe_at = Instant::now() + ack_d1_interval(&ack);
|
|
}
|
|
}
|
|
Ok(LiveRead::Config(config)) => {
|
|
if let Ok(mut target) = remote_config.lock() {
|
|
*target = Some(config);
|
|
}
|
|
drop_socket = true;
|
|
}
|
|
Ok(LiveRead::RemoteTask(_task)) => {
|
|
// 在 wait_for_live_ack 中已处理,这里不应该出现
|
|
}
|
|
Ok(LiveRead::Pending) => {
|
|
eprintln!("live ACK timed out");
|
|
drop_socket = true;
|
|
}
|
|
Err(error) => {
|
|
eprintln!("live ACK read failed: {error}");
|
|
drop_socket = true;
|
|
}
|
|
}
|
|
}
|
|
if drop_socket {
|
|
socket = None;
|
|
accepted_through = persisted_through.load(Ordering::Acquire);
|
|
next_send_at = Instant::now();
|
|
next_probe_at = Instant::now();
|
|
thread::sleep(LIVE_RECONNECT_DELAY);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_remote(config: &mut RuntimeConfig, remote: &RemoteConfig) -> bool {
|
|
if valid_sample_schedule(remote.report_interval, remote.collect_interval) {
|
|
config.report_interval = remote.report_interval;
|
|
config.collect_interval = remote.collect_interval;
|
|
}
|
|
config
|
|
.network_interface
|
|
.clone_from(&remote.network_interface);
|
|
let mirror = remote.agent_mirror.trim().trim_end_matches('/');
|
|
if mirror.is_empty() || valid_endpoint(mirror) {
|
|
config.agent_mirror = mirror.to_string();
|
|
}
|
|
config.auto_update = remote.auto_update;
|
|
let tasks = sanitize_latency_tasks(&remote.latency_tasks);
|
|
let changed = config.latency_tasks != tasks;
|
|
config.latency_tasks = tasks;
|
|
changed
|
|
}
|
|
|
|
fn sanitize_latency_tasks(tasks: &[LatencyTask]) -> Vec<LatencyTask> {
|
|
let mut seen = HashSet::new();
|
|
tasks
|
|
.iter()
|
|
.filter(|task| {
|
|
!task.id.is_empty()
|
|
&& task.id.len() <= 80
|
|
&& (1..=80).contains(&task.name.trim().chars().count())
|
|
&& (30..=3600).contains(&task.interval_seconds)
|
|
&& matches!(task.task_type.as_str(), "tcp" | "icmp")
|
|
&& ((task.task_type == "tcp"
|
|
&& task.port.is_some_and(|port| (1..=65535).contains(&port)))
|
|
|| (task.task_type == "icmp" && task.port.is_none()))
|
|
&& parse_probe_target(&task.target, None).is_some()
|
|
&& seen.insert(task.id.clone())
|
|
})
|
|
.take(MAX_LATENCY_TASKS)
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn valid_endpoint(value: &str) -> bool {
|
|
let value = value.trim_end_matches('/');
|
|
if value.is_empty() || value.len() > 2048 || value.chars().any(char::is_whitespace) {
|
|
return false;
|
|
}
|
|
let Ok(parsed) = url::Url::parse(value) else {
|
|
return false;
|
|
};
|
|
if parsed.host().is_none()
|
|
|| !parsed.username().is_empty()
|
|
|| parsed.password().is_some()
|
|
|| parsed.query().is_some()
|
|
|| parsed.fragment().is_some()
|
|
{
|
|
return false;
|
|
}
|
|
if parsed.scheme() == "https" {
|
|
return true;
|
|
}
|
|
if parsed.scheme() != "http" {
|
|
return false;
|
|
}
|
|
match parsed.host() {
|
|
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
|
|
Some(url::Host::Ipv4(address)) => address == std::net::Ipv4Addr::LOCALHOST,
|
|
Some(url::Host::Ipv6(address)) => address.is_loopback(),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
fn update_check_jitter(token: &str) -> Duration {
|
|
let digest = Sha256::digest(token.as_bytes());
|
|
let mut bytes = [0_u8; 8];
|
|
bytes.copy_from_slice(&digest[..8]);
|
|
Duration::from_secs(
|
|
u64::from_be_bytes(bytes) % UPDATE_CHECK_JITTER_MAX_SECONDS.saturating_add(1),
|
|
)
|
|
}
|
|
|
|
fn prune_report_samples(samples: &mut Vec<Report>, now: i64) {
|
|
samples.retain(|report| (report.timestamp - now).abs() <= MAX_REPORT_AGE_SECONDS);
|
|
let mut remaining = MAX_PENDING_LATENCY_RESULTS;
|
|
for report in samples.iter_mut().rev() {
|
|
if report.latency_results.len() > remaining {
|
|
let discard = report.latency_results.len() - remaining;
|
|
report.latency_results.drain(..discard);
|
|
remaining = 0;
|
|
} else {
|
|
remaining -= report.latency_results.len();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pending_spool_path() -> Result<PathBuf> {
|
|
if let Some(value) = env::var_os("NODEFLARE_STATE_DIR") {
|
|
let directory = PathBuf::from(value);
|
|
if !directory.is_absolute() {
|
|
return Err("NODEFLARE_STATE_DIR must be an absolute path".into());
|
|
}
|
|
fs::create_dir_all(&directory)?;
|
|
return Ok(directory.join("pending.jsonl"));
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
if let Some(program_data) = env::var_os("ProgramData") {
|
|
let directory = PathBuf::from(program_data).join("NodeFlare");
|
|
fs::create_dir_all(&directory)?;
|
|
return Ok(directory.join("pending.jsonl"));
|
|
}
|
|
let executable = env::current_exe()?;
|
|
let directory = executable
|
|
.parent()
|
|
.ok_or("agent executable path has no parent directory")?;
|
|
Ok(directory.join("pending.jsonl"))
|
|
}
|
|
|
|
fn load_pending_spool(path: &Path) -> Result<Vec<Report>> {
|
|
let file = match fs::File::open(path) {
|
|
Ok(file) => file,
|
|
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
|
|
Err(error) => return Err(error.into()),
|
|
};
|
|
if file.metadata()?.len() > MAX_PENDING_SPOOL_BYTES {
|
|
return Err("pending report spool is too large".into());
|
|
}
|
|
let mut samples = Vec::new();
|
|
for line in BufReader::new(file).lines() {
|
|
let line = line?;
|
|
if line.len() > MAX_PENDING_SPOOL_LINE_BYTES {
|
|
continue;
|
|
}
|
|
if let Ok(report) = serde_json::from_str::<Report>(&line) {
|
|
samples.push(report);
|
|
}
|
|
}
|
|
if samples.len() > LIVE_QUEUE_CAPACITY {
|
|
let overflow = samples.len() - LIVE_QUEUE_CAPACITY;
|
|
samples.drain(..overflow);
|
|
}
|
|
Ok(samples)
|
|
}
|
|
|
|
fn append_pending_spool(path: &Path, report: &Report) -> Result<()> {
|
|
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
|
|
#[cfg(unix)]
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
|
|
serde_json::to_writer(&mut file, report)?;
|
|
file.write_all(b"\n")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn rewrite_pending_spool(path: &Path, samples: &[Report]) -> Result<()> {
|
|
if samples.is_empty() {
|
|
return match fs::remove_file(path) {
|
|
Ok(()) => Ok(()),
|
|
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
|
Err(error) => Err(error.into()),
|
|
};
|
|
}
|
|
let temporary = path.with_file_name(format!(
|
|
".{}.{}.tmp",
|
|
path.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or("pending"),
|
|
std::process::id()
|
|
));
|
|
let mut file = fs::File::create(&temporary)?;
|
|
#[cfg(unix)]
|
|
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
|
|
for report in samples {
|
|
serde_json::to_writer(&mut file, report)?;
|
|
file.write_all(b"\n")?;
|
|
}
|
|
file.sync_data()?;
|
|
#[cfg(target_os = "windows")]
|
|
if path.exists() {
|
|
fs::remove_file(path)?;
|
|
}
|
|
fs::rename(&temporary, path)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn normalized_version(value: &str) -> &str {
|
|
value.trim().strip_prefix('v').unwrap_or(value.trim())
|
|
}
|
|
|
|
fn version_triplet(value: &str) -> Option<(u64, u64, u64)> {
|
|
let mut parts = normalized_version(value).split('.');
|
|
let version = (
|
|
parts.next()?.parse().ok()?,
|
|
parts.next()?.parse().ok()?,
|
|
parts.next()?.parse().ok()?,
|
|
);
|
|
parts.next().is_none().then_some(version)
|
|
}
|
|
|
|
fn agent_artifact_name() -> Option<&'static str> {
|
|
match (env::consts::OS, env::consts::ARCH) {
|
|
("linux", "x86_64") => Some("agent-linux-x64"),
|
|
("linux", "aarch64") => Some("agent-linux-aarch64"),
|
|
("windows", "x86_64") => Some("agent-windows-x64.exe"),
|
|
("macos", "aarch64") => Some("agent-macos-aarch64"),
|
|
("freebsd", "x86_64") => Some("agent-freebsd-x64"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn executable_format_valid(path: &Path) -> bool {
|
|
let mut magic = [0_u8; 4];
|
|
if fs::File::open(path)
|
|
.and_then(|mut file| file.read_exact(&mut magic))
|
|
.is_err()
|
|
{
|
|
return false;
|
|
}
|
|
match env::consts::OS {
|
|
"linux" | "freebsd" => magic == *b"\x7fELF",
|
|
"windows" => magic.starts_with(b"MZ"),
|
|
"macos" => matches!(
|
|
magic,
|
|
[0xcf, 0xfa, 0xed, 0xfe]
|
|
| [0xfe, 0xed, 0xfa, 0xcf]
|
|
| [0xca, 0xfe, 0xba, 0xbe]
|
|
| [0xbe, 0xba, 0xfe, 0xca]
|
|
),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn sha256_file(path: &Path) -> Result<String> {
|
|
let mut file = fs::File::open(path)?;
|
|
let mut digest = Sha256::new();
|
|
let mut buffer = [0_u8; 64 * 1024];
|
|
loop {
|
|
let read = file.read(&mut buffer)?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
digest.update(&buffer[..read]);
|
|
}
|
|
Ok(digest
|
|
.finalize()
|
|
.iter()
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect())
|
|
}
|
|
|
|
fn release_asset_sha256(asset: &GithubReleaseAsset) -> Option<String> {
|
|
let digest = asset.digest.as_deref()?.strip_prefix("sha256:")?;
|
|
(digest.len() == 64
|
|
&& digest
|
|
.chars()
|
|
.all(|character| character.is_ascii_hexdigit()))
|
|
.then(|| digest.to_ascii_lowercase())
|
|
}
|
|
|
|
fn download_agent(
|
|
agent: &ureq::Agent,
|
|
url: &str,
|
|
destination: &Path,
|
|
expected_version: &str,
|
|
expected_sha256: &str,
|
|
) -> Result<bool> {
|
|
let Ok(response) = agent.get(url).call() else {
|
|
return Ok(false);
|
|
};
|
|
let response = response.into_body().into_reader();
|
|
let mut file = fs::File::create(destination)?;
|
|
let copied = io::copy(&mut response.take(MAX_AGENT_BINARY_BYTES + 1), &mut file)?;
|
|
file.flush()?;
|
|
drop(file);
|
|
if copied > MAX_AGENT_BINARY_BYTES
|
|
|| !executable_format_valid(destination)
|
|
|| sha256_file(destination)? != expected_sha256
|
|
{
|
|
let _ = fs::remove_file(destination);
|
|
return Ok(false);
|
|
}
|
|
#[cfg(unix)]
|
|
fs::set_permissions(destination, fs::Permissions::from_mode(0o755))?;
|
|
|
|
let downloaded_version = Command::new(destination)
|
|
.arg("--version")
|
|
.output()
|
|
.ok()
|
|
.filter(|output| output.status.success())
|
|
.and_then(|output| {
|
|
String::from_utf8_lossy(&output.stdout)
|
|
.split_whitespace()
|
|
.next_back()
|
|
.map(str::to_string)
|
|
});
|
|
let valid = downloaded_version.as_deref().map(normalized_version)
|
|
== Some(normalized_version(expected_version));
|
|
if !valid {
|
|
let _ = fs::remove_file(destination);
|
|
}
|
|
Ok(valid)
|
|
}
|
|
|
|
fn mirrored_download_url(mirror: &str, url: &str) -> String {
|
|
let mirror = mirror.trim().trim_end_matches('/');
|
|
if mirror.is_empty() {
|
|
url.to_string()
|
|
} else {
|
|
format!("{mirror}/{url}")
|
|
}
|
|
}
|
|
|
|
fn update(agent: &ureq::Agent, mirror: &str) -> Result<bool> {
|
|
let mut response = agent
|
|
.get(LATEST_RELEASE_API)
|
|
.header("Accept", "application/vnd.github+json")
|
|
.header("User-Agent", format!("nodeflare-agent/{VERSION}"))
|
|
.call()?;
|
|
let release = response.body_mut().read_json::<GithubRelease>()?;
|
|
let Some(remote_version) = version_triplet(&release.tag_name) else {
|
|
return Ok(false);
|
|
};
|
|
let Some(current_version) = version_triplet(VERSION) else {
|
|
return Ok(false);
|
|
};
|
|
if remote_version <= current_version {
|
|
return Ok(false);
|
|
}
|
|
let Some(artifact) = agent_artifact_name() else {
|
|
return Ok(false);
|
|
};
|
|
let current = env::current_exe()?;
|
|
let temporary = current.with_file_name(format!(
|
|
".nodeflare-agent.{}.download{}",
|
|
std::process::id(),
|
|
if cfg!(target_os = "windows") {
|
|
".exe"
|
|
} else {
|
|
""
|
|
}
|
|
));
|
|
let Some(release_asset) = release.assets.iter().find(|asset| asset.name == artifact) else {
|
|
return Err(format!("latest release does not contain {artifact}").into());
|
|
};
|
|
let Some(expected_sha256) = release_asset_sha256(release_asset) else {
|
|
return Err(
|
|
format!("latest release does not contain a SHA-256 digest for {artifact}").into(),
|
|
);
|
|
};
|
|
if !download_agent(
|
|
agent,
|
|
&mirrored_download_url(mirror, &release_asset.browser_download_url),
|
|
&temporary,
|
|
&release.tag_name,
|
|
&expected_sha256,
|
|
)? {
|
|
return Err("downloaded agent version does not match the configured version".into());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
fs::rename(&temporary, ¤t)?;
|
|
let error = Command::new(¤t).args(env::args_os().skip(1)).exec();
|
|
Err(error.into())
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let script = concat!(
|
|
"$ErrorActionPreference='Stop'; ",
|
|
"$targetPid=[int]$env:NODEFLARE_UPDATE_PID; ",
|
|
"Wait-Process -Id $targetPid; ",
|
|
"Move-Item -LiteralPath $env:NODEFLARE_UPDATE_NEW ",
|
|
"-Destination $env:NODEFLARE_UPDATE_CURRENT -Force; ",
|
|
"try { Start-ScheduledTask -TaskName 'nodeflare-agent' -ErrorAction Stop } ",
|
|
"catch { $restartArgs=@($env:NODEFLARE_UPDATE_ARGS | ConvertFrom-Json); ",
|
|
"Start-Process -FilePath $env:NODEFLARE_UPDATE_CURRENT -ArgumentList $restartArgs }"
|
|
);
|
|
Command::new("powershell.exe")
|
|
.args([
|
|
"-NoLogo",
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
script,
|
|
])
|
|
.env("NODEFLARE_UPDATE_PID", std::process::id().to_string())
|
|
.env("NODEFLARE_UPDATE_NEW", &temporary)
|
|
.env("NODEFLARE_UPDATE_CURRENT", ¤t)
|
|
.env(
|
|
"NODEFLARE_UPDATE_ARGS",
|
|
serde_json::to_string(&env::args().skip(1).collect::<Vec<_>>())?,
|
|
)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()?;
|
|
Ok(true)
|
|
}
|
|
}
|
|
|
|
fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> {
|
|
let mut config = runtime_config(options)?;
|
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
|
.timeout_global(Some(Duration::from_secs(20)))
|
|
.build()
|
|
.into();
|
|
let mut collector = Collector::new(&config);
|
|
collector.public_ip.refresh();
|
|
if once || print_only {
|
|
collector.public_ip.wait_initial();
|
|
}
|
|
let mut next_collect = Instant::now() + Duration::from_secs(config.collect_interval);
|
|
let mut next_latency: HashMap<String, Instant> = HashMap::new();
|
|
let stats = Arc::new(runtime_stats::RuntimeStats::default());
|
|
let mut latency_executor = LatencyExecutor::new(Arc::clone(&stats))?;
|
|
let mut pending_results = Vec::new();
|
|
let spool_path = pending_spool_path()?;
|
|
let mut pending_samples = load_pending_spool(&spool_path).unwrap_or_else(|error| {
|
|
eprintln!("pending report spool ignored: {error}");
|
|
Vec::new()
|
|
});
|
|
let loaded_len = pending_samples.len();
|
|
let loaded_latency = pending_samples
|
|
.iter()
|
|
.map(|report| report.latency_results.len())
|
|
.sum::<usize>();
|
|
prune_report_samples(&mut pending_samples, unix_timestamp());
|
|
if (loaded_len != pending_samples.len()
|
|
|| loaded_latency
|
|
!= pending_samples
|
|
.iter()
|
|
.map(|report| report.latency_results.len())
|
|
.sum::<usize>())
|
|
&& let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples)
|
|
{
|
|
eprintln!("pending report spool cleanup failed: {error}");
|
|
}
|
|
let mut last_emitted_timestamp = pending_samples
|
|
.iter()
|
|
.map(|report| report.timestamp)
|
|
.max()
|
|
.unwrap_or(0);
|
|
let mut next_update_check = Instant::now() + update_check_jitter(&config.token);
|
|
let mut next_stats_log = Instant::now() + RUNTIME_STATS_INTERVAL;
|
|
let clock = Arc::new(Mutex::new(ClockCalibration::default()));
|
|
let live = (!print_only)
|
|
.then(|| LiveSender::start(&config, Arc::clone(&clock), Arc::clone(&stats)))
|
|
.transpose()?;
|
|
if let Some(live) = &live {
|
|
for report in &pending_samples {
|
|
live.send(report);
|
|
}
|
|
}
|
|
let once_deadline = (once && !print_only).then(|| Instant::now() + ONCE_WSS_TIMEOUT);
|
|
let mut once_target_timestamp = None;
|
|
loop {
|
|
if let Some(live) = &live {
|
|
let persisted_through = live.persisted_through();
|
|
let before = pending_samples.len();
|
|
pending_samples.retain(|report| report.timestamp > persisted_through);
|
|
stats.persisted_samples_pruned(before.saturating_sub(pending_samples.len()));
|
|
if before != pending_samples.len()
|
|
&& let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples)
|
|
{
|
|
eprintln!("pending report spool compaction failed: {error}");
|
|
}
|
|
if once_target_timestamp.is_some_and(|target| persisted_through >= target) {
|
|
return Ok(());
|
|
}
|
|
if let Some(remote) = live.take_remote_config() {
|
|
let previous_collect_interval = config.collect_interval;
|
|
let previous_report_interval = config.report_interval;
|
|
if apply_remote(&mut config, &remote) {
|
|
next_latency.clear();
|
|
}
|
|
if config.collect_interval != previous_collect_interval {
|
|
next_collect = Instant::now() + Duration::from_secs(config.collect_interval);
|
|
}
|
|
if config.collect_interval != previous_collect_interval
|
|
|| config.report_interval != previous_report_interval
|
|
{
|
|
live.set_send_interval(config.report_interval, config.collect_interval);
|
|
}
|
|
}
|
|
}
|
|
if once_target_timestamp.is_some()
|
|
&& once_deadline.is_some_and(|deadline| Instant::now() >= deadline)
|
|
{
|
|
return Err("WSS report persistence acknowledgement timed out".into());
|
|
}
|
|
pending_results.extend(latency_executor.drain());
|
|
let current = Instant::now();
|
|
let due = config
|
|
.latency_tasks
|
|
.iter()
|
|
.filter(|task| {
|
|
next_latency
|
|
.get(&task.id)
|
|
.is_none_or(|deadline| *deadline <= current)
|
|
})
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
if !due.is_empty() {
|
|
let scheduled_at = Instant::now();
|
|
for task in due {
|
|
let task_id = task.id.clone();
|
|
let interval = task.interval_seconds.clamp(30, 3600);
|
|
if latency_executor.enqueue(task) {
|
|
next_latency.insert(task_id, scheduled_at + Duration::from_secs(interval));
|
|
}
|
|
}
|
|
}
|
|
|
|
if Instant::now() >= next_collect && (!once || once_target_timestamp.is_none()) {
|
|
let offset_ms = shared_clock_offset(&clock);
|
|
let mut latest_results = HashMap::new();
|
|
for mut result in std::mem::take(&mut pending_results) {
|
|
result.timestamp = corrected_timestamp(result.timestamp, offset_ms);
|
|
latest_results.insert(result.task_id.clone(), result);
|
|
}
|
|
let collection_started = Instant::now();
|
|
let mut report = collector.collect(&config, latest_results.into_values().collect(), 0);
|
|
stats.collection_finished(collection_started.elapsed());
|
|
let persisted_through = live.as_ref().map_or(0, LiveSender::persisted_through);
|
|
report.timestamp = monotonic_report_timestamp(
|
|
corrected_timestamp(unix_timestamp(), offset_ms),
|
|
last_emitted_timestamp,
|
|
persisted_through,
|
|
);
|
|
last_emitted_timestamp = report.timestamp;
|
|
if print_only {
|
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
|
return Ok(());
|
|
}
|
|
append_pending_spool(&spool_path, &report)
|
|
.map_err(|error| format!("pending report spool append failed: {error}"))?;
|
|
if let Some(live) = &live {
|
|
live.send(&report);
|
|
}
|
|
if once {
|
|
once_target_timestamp = Some(report.timestamp);
|
|
}
|
|
pending_samples.push(report);
|
|
let before_prune = pending_samples.len();
|
|
let latency_before_prune = pending_samples
|
|
.iter()
|
|
.map(|report| report.latency_results.len())
|
|
.sum::<usize>();
|
|
prune_report_samples(
|
|
&mut pending_samples,
|
|
corrected_timestamp(unix_timestamp(), offset_ms),
|
|
);
|
|
if pending_samples.len() > LIVE_QUEUE_CAPACITY {
|
|
let overflow = pending_samples.len() - LIVE_QUEUE_CAPACITY;
|
|
pending_samples.drain(..overflow);
|
|
stats.live_queue_dropped(overflow);
|
|
}
|
|
if (before_prune != pending_samples.len()
|
|
|| latency_before_prune
|
|
!= pending_samples
|
|
.iter()
|
|
.map(|report| report.latency_results.len())
|
|
.sum::<usize>())
|
|
&& let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples)
|
|
{
|
|
eprintln!("pending report spool compaction failed: {error}");
|
|
}
|
|
next_collect = advance_deadline(
|
|
next_collect,
|
|
Duration::from_secs(config.collect_interval),
|
|
Instant::now(),
|
|
);
|
|
}
|
|
|
|
if !once
|
|
&& config.auto_update
|
|
&& pending_samples.is_empty()
|
|
&& Instant::now() >= next_update_check
|
|
{
|
|
match update(&agent, &config.agent_mirror) {
|
|
Ok(true) => return Ok(()),
|
|
Ok(false) => next_update_check = Instant::now() + UPDATE_CHECK_INTERVAL,
|
|
Err(error) => {
|
|
eprintln!("agent update failed: {error}");
|
|
next_update_check = Instant::now() + UPDATE_RETRY_INTERVAL;
|
|
}
|
|
}
|
|
}
|
|
if Instant::now() >= next_stats_log {
|
|
stats.log_and_reset();
|
|
next_stats_log = Instant::now() + RUNTIME_STATS_INTERVAL;
|
|
}
|
|
|
|
let collect_wake_at = if once_target_timestamp.is_some() {
|
|
once_deadline.unwrap_or(next_collect)
|
|
} else {
|
|
next_collect
|
|
};
|
|
let maintenance_wake_at = if config.auto_update && pending_samples.is_empty() {
|
|
next_stats_log.min(next_update_check)
|
|
} else {
|
|
next_stats_log
|
|
};
|
|
let wake_at = next_latency
|
|
.values()
|
|
.copied()
|
|
.min()
|
|
.map_or(collect_wake_at, |deadline| deadline.min(collect_wake_at))
|
|
.min(maintenance_wake_at);
|
|
let wait = wake_at
|
|
.saturating_duration_since(Instant::now())
|
|
.min(Duration::from_secs(1));
|
|
if !wait.is_zero() {
|
|
thread::sleep(wait);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
let options = CliOptions::parse();
|
|
let result = run(&options, options.once || options.collect, options.collect);
|
|
if let Err(error) = result {
|
|
eprintln!("agent: {error}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use clap::Parser;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use super::{
|
|
CLOCK_CALIBRATION_MAX_AGE, CliOptions, ClockCalibration, GithubReleaseAsset, LatencyResult,
|
|
LatencyTask, LiveAck, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS,
|
|
PUBLIC_IP_STALE_AFTER, PublicIpValue, Report, UPDATE_CHECK_JITTER_MAX_SECONDS,
|
|
ack_d1_interval, ack_wss_interval, advance_deadline, clock_offset_from_http_date,
|
|
corrected_timestamp, gpu_name_from_uevent, is_public_probe_ip, live_endpoint,
|
|
live_update_payload, monotonic_report_timestamp, normalized_version, parse_lspci_gpu_names,
|
|
parse_pciconf_gpu_names, parse_probe_target, parse_public_ip,
|
|
parse_system_profiler_gpu_names, ping_latencies, ping_latency, prune_report_samples,
|
|
release_asset_sha256, sanitize_latency_tasks, update_check_jitter, valid_endpoint,
|
|
version_triplet,
|
|
};
|
|
|
|
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
|
use super::connection_counts_from_netstat;
|
|
#[cfg(target_os = "linux")]
|
|
use super::disk_device;
|
|
|
|
#[test]
|
|
fn parses_family_matched_public_ips() {
|
|
let v4: std::net::IpAddr = "203.0.113.7".parse().unwrap();
|
|
let v6: std::net::IpAddr = "2001:db8::1".parse().unwrap();
|
|
assert_eq!(parse_public_ip("203.0.113.7\n", false), Some(v4));
|
|
assert_eq!(parse_public_ip("2001:db8::1", true), Some(v6));
|
|
// 协议族不匹配或无法解析时一律视为探测失败。
|
|
assert_eq!(parse_public_ip("2001:db8::1", false), None);
|
|
assert_eq!(parse_public_ip("203.0.113.7", true), None);
|
|
assert_eq!(parse_public_ip("not an ip", false), None);
|
|
assert_eq!(parse_public_ip("", false), None);
|
|
}
|
|
|
|
#[test]
|
|
fn expires_public_ip_after_sustained_probe_failures() {
|
|
let started_at = Instant::now();
|
|
let address = "203.0.113.7".parse().unwrap();
|
|
let mut value = PublicIpValue::default();
|
|
|
|
value.observe(Some(address), started_at);
|
|
value.observe(
|
|
None,
|
|
started_at + PUBLIC_IP_STALE_AFTER - Duration::from_secs(1),
|
|
);
|
|
assert_eq!(value.address, Some(address));
|
|
|
|
value.observe(None, started_at + PUBLIC_IP_STALE_AFTER);
|
|
assert_eq!(value.address, None);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_platform_gpu_names() {
|
|
let lspci = "00:02.0 VGA compatible controller: Intel Corporation CometLake-S GT2 [UHD Graphics 630] (rev 05)\n\
|
|
01:00.0 Audio device: NVIDIA Corporation HDMI Audio\n\
|
|
02:00.0 3D controller: NVIDIA Corporation GA102 [GeForce RTX 3090] (rev a1)";
|
|
assert_eq!(
|
|
parse_lspci_gpu_names(lspci),
|
|
[
|
|
"Intel Corporation CometLake-S GT2 [UHD Graphics 630] (rev 05)",
|
|
"NVIDIA Corporation GA102 [GeForce RTX 3090] (rev a1)",
|
|
]
|
|
);
|
|
|
|
let profiler = "Graphics/Displays:\n\n Apple M3 Max:\n\n Chipset Model: Apple M3 Max\n Metal Support: Metal 3";
|
|
assert_eq!(parse_system_profiler_gpu_names(profiler), ["Apple M3 Max"]);
|
|
|
|
let pciconf = "vgapci0@pci0:0:2:0:\tclass=0x030000 rev=0x02 hdr=0x00 vendor=0x8086\n\
|
|
\tvendor = 'Intel Corporation'\n\
|
|
\tdevice = 'UHD Graphics 630'\n\
|
|
\tclass = display\n\
|
|
em0@pci0:0:25:0:\tclass=0x020000 rev=0x05 hdr=0x00 vendor=0x8086\n\
|
|
\tvendor = 'Intel Corporation'\n\
|
|
\tdevice = 'Ethernet Connection'\n\
|
|
\tclass = network";
|
|
assert_eq!(
|
|
parse_pciconf_gpu_names(pciconf),
|
|
["Intel Corporation UHD Graphics 630"]
|
|
);
|
|
|
|
assert_eq!(
|
|
gpu_name_from_uevent("DRIVER=i915\nPCI_CLASS=30000\nPCI_ID=8086:591B"),
|
|
Some("Intel Integrated Graphics".to_string())
|
|
);
|
|
assert_eq!(
|
|
gpu_name_from_uevent("DRIVER=virtio_gpu\nPCI_CLASS=30000"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn selects_whole_disk_devices() {
|
|
assert!(disk_device("sda"));
|
|
assert!(disk_device("nvme0n1"));
|
|
assert!(!disk_device("sda1"));
|
|
assert!(!disk_device("nvme0n1p1"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_release_versions() {
|
|
assert_eq!(normalized_version("v1.2.3"), "1.2.3");
|
|
assert_eq!(version_triplet("v1.2.3"), Some((1, 2, 3)));
|
|
assert_eq!(version_triplet("1.2"), None);
|
|
assert_eq!(version_triplet("1.2.3.4"), None);
|
|
assert_eq!(version_triplet("rust-3"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn validates_release_asset_digests() {
|
|
let hash = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
|
let mut asset = GithubReleaseAsset {
|
|
name: "agent-linux-x64".to_string(),
|
|
browser_download_url: "https://example.com/agent".to_string(),
|
|
digest: Some(format!("sha256:{hash}")),
|
|
};
|
|
let expected = hash.to_ascii_lowercase();
|
|
assert_eq!(
|
|
release_asset_sha256(&asset).as_deref(),
|
|
Some(expected.as_str())
|
|
);
|
|
asset.digest = Some("sha512:0123".to_string());
|
|
assert_eq!(release_asset_sha256(&asset), None);
|
|
asset.digest = None;
|
|
assert_eq!(release_asset_sha256(&asset), None);
|
|
}
|
|
|
|
#[test]
|
|
fn validates_endpoints() {
|
|
assert!(valid_endpoint("https://monitor.example.com/"));
|
|
assert!(valid_endpoint("http://127.0.0.1:8787"));
|
|
assert!(valid_endpoint("http://localhost:8787"));
|
|
assert!(valid_endpoint("http://[::1]:8787"));
|
|
assert!(!valid_endpoint("http://monitor.example.com"));
|
|
assert!(!valid_endpoint("http://127.0.0.2:8787"));
|
|
assert!(!valid_endpoint("monitor.example.com"));
|
|
assert!(!valid_endpoint("https://"));
|
|
assert!(!valid_endpoint("https://user@example.com"));
|
|
assert!(!valid_endpoint("https://monitor.example.com/?token=abc"));
|
|
assert!(!valid_endpoint("https://bad host.example"));
|
|
}
|
|
|
|
#[test]
|
|
fn builds_live_websocket_endpoints() {
|
|
assert_eq!(
|
|
live_endpoint("https://monitor.example.com").unwrap(),
|
|
"wss://monitor.example.com/api/agent/ws"
|
|
);
|
|
assert_eq!(
|
|
live_endpoint("https://monitor.example.com/base/").unwrap(),
|
|
"wss://monitor.example.com/base/api/agent/ws"
|
|
);
|
|
assert_eq!(
|
|
live_endpoint("http://127.0.0.1:8787").unwrap(),
|
|
"ws://127.0.0.1:8787/api/agent/ws"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_server_realtime_ack_interval() {
|
|
let ack: LiveAck = serde_json::from_str(
|
|
r#"{"type":"ack","ts":100,"persisted":false,"persistenceError":false,"persistedThroughTs":90,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":5000,"realtimeHint":false}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(ack_wss_interval(&ack), Duration::from_secs(5));
|
|
assert_eq!(ack_d1_interval(&ack), Duration::from_secs(60));
|
|
let slow: LiveAck = serde_json::from_str(
|
|
r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":1,"realtimeHint":true}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(ack_wss_interval(&slow), Duration::from_secs(1));
|
|
assert!(slow.realtime_hint);
|
|
let idle: LiveAck = serde_json::from_str(
|
|
r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextD1WriteAfterMs":120000,"nextWssReportAfterMs":3600000,"realtimeHint":false}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(ack_d1_interval(&idle), Duration::from_secs(120));
|
|
}
|
|
|
|
#[test]
|
|
fn encodes_realtime_samples_as_a_batch() {
|
|
let payload = live_update_payload(&[
|
|
Report {
|
|
timestamp: 10,
|
|
cpu: 20.0,
|
|
..Report::default()
|
|
},
|
|
Report {
|
|
timestamp: 15,
|
|
cpu: 30.0,
|
|
..Report::default()
|
|
},
|
|
])
|
|
.unwrap();
|
|
let value: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
|
assert_eq!(value["type"], "update");
|
|
assert_eq!(value["samples"].as_array().unwrap().len(), 2);
|
|
assert_eq!(value["samples"][1]["cpu"], 30.0);
|
|
}
|
|
|
|
#[test]
|
|
fn calibrates_timestamps_from_http_date() {
|
|
let server_ms = 784_111_777_000_i64;
|
|
assert_eq!(
|
|
clock_offset_from_http_date(
|
|
"Sun, 06 Nov 1994 08:49:37 GMT",
|
|
server_ms - 1_000,
|
|
server_ms + 1_000,
|
|
),
|
|
Some(0),
|
|
);
|
|
assert_eq!(corrected_timestamp(100, 2_500), 102);
|
|
assert_eq!(corrected_timestamp(100, -2_500), 97);
|
|
assert_eq!(monotonic_report_timestamp(97, 100, 90), 101);
|
|
assert_eq!(monotonic_report_timestamp(110, 100, 120), 121);
|
|
assert_eq!(monotonic_report_timestamp(130, 120, 110), 130);
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_small_clock_jitter_until_the_calibration_expires() {
|
|
let mut calibration = ClockCalibration::default();
|
|
assert!(calibration.observe(1_000));
|
|
assert!(!calibration.observe(2_000));
|
|
assert_eq!(calibration.offset_ms, 1_000);
|
|
assert!(calibration.observe(25_000));
|
|
calibration.calibrated_at = Some(Instant::now() - CLOCK_CALIBRATION_MAX_AGE);
|
|
assert!(calibration.observe(25_001));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_cli_options() {
|
|
let parsed = CliOptions::try_parse_from([
|
|
"agent",
|
|
"-e",
|
|
"https://monitor.example.com",
|
|
"-t",
|
|
"agent-token",
|
|
"-i",
|
|
"60",
|
|
"--once",
|
|
])
|
|
.unwrap();
|
|
assert_eq!(parsed.endpoint, "https://monitor.example.com");
|
|
assert_eq!(parsed.token, "agent-token");
|
|
assert_eq!(parsed.interval, 60);
|
|
assert!(parsed.once);
|
|
assert!(CliOptions::try_parse_from(["agent", "-t"]).is_err());
|
|
assert!(CliOptions::try_parse_from(["agent", "-t", "first", "-t", "second"]).is_err());
|
|
assert!(
|
|
CliOptions::try_parse_from([
|
|
"agent",
|
|
"-e",
|
|
"https://monitor.example.com",
|
|
"-t",
|
|
"agent-token",
|
|
"--token-file",
|
|
"/run/nodeflare/token",
|
|
])
|
|
.is_err()
|
|
);
|
|
assert!(CliOptions::try_parse_from(["agent", "--once", "--collect"]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn bounds_and_filters_remote_latency_tasks() {
|
|
let valid = LatencyTask {
|
|
id: "task-1".to_string(),
|
|
name: "Cloudflare".to_string(),
|
|
task_type: "tcp".to_string(),
|
|
target: "1.1.1.1".to_string(),
|
|
port: Some(443),
|
|
interval_seconds: 60,
|
|
};
|
|
let mut tasks = vec![valid.clone(), valid];
|
|
tasks.push(LatencyTask {
|
|
id: "bad".to_string(),
|
|
name: "Bad".to_string(),
|
|
task_type: "http".to_string(),
|
|
target: "https://example.com".to_string(),
|
|
port: Some(443),
|
|
interval_seconds: 1,
|
|
});
|
|
for index in 2..=MAX_LATENCY_TASKS + 10 {
|
|
tasks.push(LatencyTask {
|
|
id: format!("task-{index}"),
|
|
name: format!("Task {index}"),
|
|
task_type: "icmp".to_string(),
|
|
target: "1.1.1.1".to_string(),
|
|
port: None,
|
|
interval_seconds: 60,
|
|
});
|
|
}
|
|
let sanitized = sanitize_latency_tasks(&tasks);
|
|
assert_eq!(sanitized.len(), MAX_LATENCY_TASKS);
|
|
assert_eq!(sanitized[0].id, "task-1");
|
|
assert!(!sanitized.iter().any(|task| task.id == "bad"));
|
|
}
|
|
|
|
#[test]
|
|
fn advances_collection_deadlines_without_drift() {
|
|
let start = Instant::now();
|
|
let interval = Duration::from_secs(1);
|
|
assert_eq!(
|
|
advance_deadline(start, interval, start + Duration::from_millis(2500)),
|
|
start + Duration::from_secs(3)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn drops_samples_the_server_would_reject_as_expired() {
|
|
let mut samples = vec![
|
|
Report {
|
|
timestamp: 1_000,
|
|
..Report::default()
|
|
},
|
|
Report {
|
|
timestamp: 8_500,
|
|
..Report::default()
|
|
},
|
|
];
|
|
prune_report_samples(&mut samples, 9_000);
|
|
assert_eq!(samples.len(), 1);
|
|
assert_eq!(samples[0].timestamp, 8_500);
|
|
}
|
|
|
|
#[test]
|
|
fn bounds_pending_latency_results() {
|
|
let mut samples = (0..MAX_PENDING_LATENCY_RESULTS + 10)
|
|
.map(|index| Report {
|
|
timestamp: index as i64 + 1,
|
|
latency_results: vec![LatencyResult {
|
|
task_id: format!("task-{index}"),
|
|
timestamp: index as i64 + 1,
|
|
latency_ms: 10.0,
|
|
packet_loss: 0.0,
|
|
}],
|
|
..Report::default()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
prune_report_samples(&mut samples, MAX_PENDING_LATENCY_RESULTS as i64 + 10);
|
|
assert_eq!(
|
|
samples
|
|
.iter()
|
|
.map(|sample| sample.latency_results.len())
|
|
.sum::<usize>(),
|
|
MAX_PENDING_LATENCY_RESULTS
|
|
);
|
|
assert!(samples.first().unwrap().latency_results.is_empty());
|
|
assert_eq!(samples.last().unwrap().latency_results.len(), 1);
|
|
}
|
|
|
|
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
|
#[test]
|
|
fn parses_netstat_connection_counts() {
|
|
let output = "tcp4 0 0 host.443 peer.1 ESTABLISHED\nudp4 0 0 *.5353 *.*\nTCP host peer ESTABLISHED\n";
|
|
assert_eq!(connection_counts_from_netstat(output), (2, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_probe_targets() {
|
|
assert_eq!(
|
|
parse_probe_target("Example.COM", None),
|
|
Some(("example.com".to_string(), 443))
|
|
);
|
|
assert_eq!(
|
|
parse_probe_target("1.1.1.1", Some(8080)),
|
|
Some(("1.1.1.1".to_string(), 8080))
|
|
);
|
|
for target in [
|
|
"",
|
|
"https://example.com",
|
|
"example.com:0",
|
|
"example.com:65536",
|
|
"999.1.1.1",
|
|
"127.0.0.1:8080",
|
|
"169.254.169.254",
|
|
"192.168.1.1",
|
|
"router.local",
|
|
"localhost",
|
|
"[::1]:443",
|
|
"bad host",
|
|
] {
|
|
assert_eq!(parse_probe_target(target, None), None, "target: {target}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_only_public_probe_addresses() {
|
|
for address in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] {
|
|
assert!(is_public_probe_ip(address.parse().expect("IP address")));
|
|
}
|
|
for address in [
|
|
"0.0.0.0",
|
|
"10.0.0.1",
|
|
"100.64.0.1",
|
|
"127.0.0.1",
|
|
"169.254.169.254",
|
|
"172.16.0.1",
|
|
"192.168.1.1",
|
|
"198.18.0.1",
|
|
"224.0.0.1",
|
|
"::1",
|
|
"fe80::1",
|
|
"fd00:ec2::254",
|
|
] {
|
|
assert!(!is_public_probe_ip(address.parse().expect("IP address")));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_ping_latency() {
|
|
assert_eq!(ping_latency("64 bytes time=12.34 ms"), Some(12.34));
|
|
assert_eq!(ping_latency("64 bytes time<1 ms"), Some(0.5));
|
|
assert_eq!(ping_latency("unreachable"), None);
|
|
assert_eq!(
|
|
ping_latencies("reply time=12.34 ms\nrequest timeout\nreply time=8.5 ms"),
|
|
vec![12.34, 8.5]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn update_check_jitter_is_stable_and_bounded() {
|
|
let first = update_check_jitter("agent-token");
|
|
assert_eq!(first, update_check_jitter("agent-token"));
|
|
assert!(first.as_secs() <= UPDATE_CHECK_JITTER_MAX_SECONDS);
|
|
}
|
|
}
|