diff --git a/agent/src/main.rs b/agent/src/main.rs index 371c20a..e20b2e5 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; #[cfg(target_os = "windows")] use std::process::Stdio; -use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::{mpsc, Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -62,6 +62,11 @@ 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_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; @@ -235,6 +240,96 @@ impl ClockCalibration { type SharedClock = Arc>; +/// 双栈公网地址探测:分别请求按协议族锁死的 icanhazip 端点(Cloudflare 运营, +/// 纯文本返回调用方出口 IP),记录最近一次成功的结果。主连接只用一个协议族, +/// 这里补齐另一个;探测失败保留旧值,从未成功则为空。 +#[derive(Default)] +struct PublicIps { + v4: Option, + v6: Option, +} + +#[derive(Clone, Default)] +struct PublicIpProbe { + state: Arc>, + probing: Arc, + last_attempt: Arc>>, +} + +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 mut state = state.lock().unwrap(); + if let Some(ip) = v4 { + state.v4 = Some(ip); + } + if let Some(ip) = v6 { + state.v6 = Some(ip); + } + probing.store(false, Ordering::SeqCst); + }); + } + + fn v4(&self) -> Option { + self.state.lock().unwrap().v4.map(|ip| ip.to_string()) + } + + fn v6(&self) -> Option { + self.state.lock().unwrap().v6.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 { + let probe_agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(PUBLIC_IP_PROBE_TIMEOUT)) + .build() + .into(); + let mut 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 { + let ip: IpAddr = text.trim().parse().ok()?; + match (ipv6, ip) { + (true, IpAddr::V6(_)) | (false, IpAddr::V4(_)) => Some(ip), + _ => None, + } +} + struct LiveSender { pending: Arc<(Mutex>, Condvar)>, send_interval: Arc>, @@ -389,6 +484,10 @@ struct Report { gpu_usage: f64, gpu_model: String, agent_version: String, + #[serde(default)] + ip_v4: String, + #[serde(default)] + ip_v6: String, disk_read_bps: f64, disk_write_bps: f64, disk_read_iops: f64, @@ -1193,6 +1292,7 @@ struct Collector { basic_at: Instant, slow: SlowMetrics, slow_at: Instant, + public_ip: PublicIpProbe, } #[cfg(target_os = "linux")] @@ -1207,6 +1307,7 @@ impl Collector { basic_at: Instant::now(), slow: SlowMetrics::default(), slow_at: Instant::now(), + public_ip: PublicIpProbe::default(), }; collector.refresh_basic(); collector.refresh_slow(); @@ -1295,6 +1396,7 @@ impl Collector { 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:"); @@ -1355,6 +1457,8 @@ impl Collector { 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 @@ -1399,6 +1503,7 @@ struct Collector { basic_at: Instant, slow: SlowMetrics, slow_at: Instant, + public_ip: PublicIpProbe, } #[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] @@ -1413,6 +1518,7 @@ impl Collector { basic_at: Instant::now(), slow: SlowMetrics::default(), slow_at: Instant::now(), + public_ip: PublicIpProbe::default(), }; collector.refresh_basic(); collector.refresh_slow(); @@ -1505,6 +1611,7 @@ impl Collector { 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; @@ -1553,6 +1660,8 @@ impl Collector { 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, @@ -2394,6 +2503,10 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { .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 = HashMap::new(); let stats = Arc::new(runtime_stats::RuntimeStats::default()); @@ -2622,7 +2735,7 @@ mod tests { 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_system_profiler_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, CliOptions, ClockCalibration, GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, Report, @@ -2635,6 +2748,19 @@ mod tests { #[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 parses_platform_gpu_names() { let lspci = "00:02.0 VGA compatible controller: Intel Corporation CometLake-S GT2 [UHD Graphics 630] (rev 05)\n\ diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index f64aafa..42f583c 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -136,18 +136,24 @@ function ipFamily(ip: string): "v4" | "v6" | null { return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) ? "v4" : null; } -function ServerIpMeta({ ip, agentVersion, onCopy }: { ip: string; agentVersion: string | null; onCopy: (ip: string) => void }) { - const family = ipFamily(ip); +function ServerIpMeta({ server, agentVersion, onCopy }: { server: AdminServer; agentVersion: string | null; onCopy: (ip: string) => void }) { + // 双栈探测上报的地址优先;旧版 agent 还没上报时回落到连接出口地址(last_ip)。 + const family = ipFamily(server.last_ip); + const v4 = server.ip_v4 || (family === "v4" ? server.last_ip : ""); + const v6 = server.ip_v6 || (family === "v6" ? server.last_ip : ""); + const entries = [ + { family: "v4" as const, ip: v4 }, + { family: "v6" as const, ip: v6 }, + ].filter((entry) => entry.ip); return (
- {family ? ( - <> - {family === "v6" ? "IPv6" : "IPv4"} - - - ) : ( - {ip || "尚未上报 IP"} - )} + {entries.map((entry) => ( + + {entry.family === "v6" ? "IPv6" : "IPv4"} + + + ))} + {!entries.length ? {server.last_ip || "尚未上报 IP"} : null} · {agentVersion ? `Agent v${agentVersion}` : "Agent 未上报版本"}
@@ -653,7 +659,7 @@ export function AdminPanel({ toggleSelected(server.id)} ariaLabel={`选择 ${server.name}`} /> -
{server.name}
void copyServerIp(value)} />
+
{server.name}
void copyServerIp(value)} />
))} diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 753e4b0..720a8ae 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -310,7 +310,8 @@ .server-name-main { display: flex; min-width: 0; align-items: center; gap: 7px; } .server-name-main .region-flag { flex: 0 0 auto; font-size: 16px; line-height: 1; } .server-name-main strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } -.server-name-meta { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--admin-muted); font-size: 12px; } +.server-name-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 3px 7px; color: var(--admin-muted); font-size: 12px; } +.server-ip-entry { display: inline-flex; min-width: 0; align-items: center; gap: 5px; } .ip-badge { flex: 0 0 auto; padding: 0 6px; border: 1px solid var(--admin-border); border-radius: 999px; font-size: 10px; font-weight: 600; line-height: 1.5; letter-spacing: .03em; } .ip-badge.v6 { color: var(--admin-accent); border-color: color-mix(in srgb, var(--admin-accent) 40%, transparent); } .ip-value { overflow: hidden; min-width: 0; max-width: 30ch; padding: 0; border: 0; background: none; color: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: copy; } diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index c231c0b..dbb1b02 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -25,10 +25,10 @@ --chart-grid: rgb(83 104 116 / 0.15); --shadow: 0 10px 32px rgb(31 48 58 / 0.09), inset 0 1px 0 rgb(255 255 255 / 0.58); --node-offline-bg: rgb(255 255 255 / 0.2); - /* Card geometry lives here so the grid, skeletons and the latency panels - cannot drift apart and make rows jump as data arrives. */ - --node-card-min-height: 336px; - --node-card-carrier-min-height: 416px; + /* Card geometry lives here so the grid and the latency panels cannot drift + apart and make rows jump as data arrives. */ + --node-card-min-height: 320px; + --node-card-carrier-min-height: 400px; --quality-panel-height: 44px; --carrier-panel-height: 124px; color: var(--foreground); diff --git a/frontend/src/styles/dashboard.css b/frontend/src/styles/dashboard.css index 2516866..0df7b2d 100644 --- a/frontend/src/styles/dashboard.css +++ b/frontend/src/styles/dashboard.css @@ -59,11 +59,11 @@ .os-icon, .os-icon-fallback { width: 16px; height: 16px; flex: 0 0 auto; object-fit: contain; } .os-icon-fallback { color: var(--muted-foreground); } .region-flag { display: inline-flex; min-width: 18px; flex: 0 0 auto; align-items: center; justify-content: center; line-height: 1; } -.node-body { position: relative; display: flex; min-height: 0; flex: 1; flex-direction: column; justify-content: space-between; gap: 14px; padding: 0 16px 16px; } +.node-body { position: relative; display: flex; min-height: 0; flex: 1; flex-direction: column; justify-content: space-between; gap: 8px; padding: 0 16px 16px; } .node-chips { display: flex; height: 19px; flex: 0 0 auto; align-items: center; gap: 6px; overflow: hidden; margin-top: -4px; } .node-chips span { min-width: 0; overflow: hidden; border-radius: 999px; padding: 2px 8px; color: var(--muted-foreground); background: color-mix(in srgb, var(--muted) 82%, transparent); font-size: 12px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } .node-chips span + span { margin-left: auto; text-align: right; } -.metric-grid { display: grid; gap: 12px 16px; grid-template-columns: 1fr 1fr; } +.metric-grid { display: grid; gap: 10px 16px; grid-template-columns: 1fr 1fr; } .metric { display: flex; min-width: 0; flex-direction: column; gap: 4px; } .metric.muted { opacity: .55; } .metric > div { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0694793..aee1447 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -206,6 +206,8 @@ export interface Server extends ServerSummary { export interface AdminServer extends ServerSummary { hidden: boolean; last_ip: string; + ip_v4: string | null; + ip_v6: string | null; network_interface: string; report_interval: number; collect_interval: number; diff --git a/src/db.rs b/src/db.rs index 5a4ca90..96cd55d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -115,6 +115,8 @@ SELECT CAST(json_extract(m.state, '$.report.gpu_usage') AS REAL) AS gpu_usage, json_extract(m.state, '$.report.gpu_model') AS gpu_model, json_extract(m.state, '$.report.agent_version') AS agent_version, + json_extract(m.state, '$.report.ip_v4') AS ip_v4, + json_extract(m.state, '$.report.ip_v6') AS ip_v6, CAST(json_extract(m.state, '$.report.disk_read_bps') AS REAL) AS disk_read_bps, CAST(json_extract(m.state, '$.report.disk_write_bps') AS REAL) AS disk_write_bps, CAST(json_extract(m.state, '$.report.disk_read_iops') AS REAL) AS disk_read_iops, diff --git a/src/lib.rs b/src/lib.rs index c1a72e2..3bb2ff4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,7 @@ mod theme; mod turnstile; use std::collections::HashSet; -use std::net::Ipv4Addr; +use std::net::{IpAddr, Ipv4Addr}; use std::time::Duration; use futures_util::TryStreamExt; @@ -812,6 +812,11 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> { if report.agent_version.chars().count() > 80 { return Some("Agent 版本字段过长"); } + for value in [report.ip_v4.as_str(), report.ip_v6.as_str()] { + if value.chars().count() > 45 || (!value.is_empty() && value.parse::().is_err()) { + return Some("公网地址字段无效"); + } + } if report.disks.len() > 64 || report.gpus.len() > 32 || report.disks.iter().any(|disk| { @@ -1310,11 +1315,26 @@ mod tests { rewrite_remote_theme_index, same_origin, submitted_secret, valid_agent_mirror, valid_background_urls, valid_cloudflare_account_id, valid_cloudflare_api_token, valid_password_derived, valid_ping_target, valid_public_asset_url, validate_latency_task, - validate_server, + validate_report, validate_server, }; - use crate::{db::SECRET_MASK, models::LatencyTaskInput, models::ServerInput}; + use crate::{db::SECRET_MASK, models::AgentReport, models::LatencyTaskInput, models::ServerInput}; use worker::{Method, Url}; + #[test] + fn validates_report_public_ips() { + let mut report = AgentReport::default(); + assert_eq!(validate_report(&report), None); + report.ip_v4 = "203.0.113.7".into(); + report.ip_v6 = "2001:db8::1".into(); + assert_eq!(validate_report(&report), None); + // 非空字段必须是合法 IP,空串表示该协议族不可用、放行。 + report.ip_v4 = "not-an-ip".into(); + assert_eq!(validate_report(&report), Some("公网地址字段无效")); + report.ip_v4 = String::new(); + report.ip_v6 = "2001:db8::1 extra".into(); + assert_eq!(validate_report(&report), Some("公网地址字段无效")); + } + #[test] fn assigns_rate_limits_by_route() { assert_eq!( diff --git a/src/live.rs b/src/live.rs index 43e71f7..4ca6077 100644 --- a/src/live.rs +++ b/src/live.rs @@ -41,7 +41,7 @@ const DASHBOARD_HUB_NAME: &str = "dashboard"; /// 代价:agent 升级后这些值要等下一次 bootstrap 轮询才刷新。 /// 只影响展示,不影响任何判定 —— 告警和持久化读的是 `AgentReport` 本身, /// 剥离只发生在发往浏览器的副本上。 -const BROWSER_OMITTED_REPORT_FIELDS: [&str; 7] = [ +const BROWSER_OMITTED_REPORT_FIELDS: [&str; 9] = [ "cpu_model", "os", "kernel", @@ -49,6 +49,8 @@ const BROWSER_OMITTED_REPORT_FIELDS: [&str; 7] = [ "virtualization", "gpu_model", "agent_version", + "ip_v4", + "ip_v6", ]; fn server_hub_name(server_id: &str) -> String { @@ -1747,6 +1749,8 @@ mod tests { gpu_usage: 0.0, gpu_model: String::new(), agent_version: "test".to_string(), + ip_v4: "203.0.113.7".to_string(), + ip_v6: String::new(), disk_read_bps: 0.0, disk_write_bps: 0.0, disk_read_iops: 0.0, diff --git a/src/models.rs b/src/models.rs index 932ddd5..0aab5be 100644 --- a/src/models.rs +++ b/src/models.rs @@ -215,6 +215,11 @@ pub struct AgentReport { pub gpu_usage: f64, pub gpu_model: String, pub agent_version: String, + /// 双栈公网地址,agent 双栈探测的结果;空串表示该协议族不可用或未上报。 + #[serde(default)] + pub ip_v4: String, + #[serde(default)] + pub ip_v6: String, pub disk_read_bps: f64, pub disk_write_bps: f64, pub disk_read_iops: f64, @@ -244,6 +249,8 @@ pub struct ServerView { #[serde(serialize_with = "serialize_sqlite_bool")] pub auto_renewal: i64, pub last_ip: String, + pub ip_v4: Option, + pub ip_v6: Option, pub network_interface: String, pub reset_day: i64, pub report_interval: i64,