diff --git a/agent/src/main.rs b/agent/src/main.rs index e20b2e5..3cd672e 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -63,6 +63,7 @@ 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/"; @@ -241,12 +242,31 @@ impl ClockCalibration { type SharedClock = Arc>; /// 双栈公网地址探测:分别请求按协议族锁死的 icanhazip 端点(Cloudflare 运营, -/// 纯文本返回调用方出口 IP),记录最近一次成功的结果。主连接只用一个协议族, -/// 这里补齐另一个;探测失败保留旧值,从未成功则为空。 +/// 纯文本返回调用方出口 IP)。短暂失败保留最近结果,持续失败则清空过期地址。 +#[derive(Default)] +struct PublicIpValue { + address: Option, + last_success: Option, +} + +impl PublicIpValue { + fn observe(&mut self, address: Option, 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: Option, - v6: Option, + v4: PublicIpValue, + v6: PublicIpValue, } #[derive(Clone, Default)] @@ -273,23 +293,30 @@ impl PublicIpProbe { 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(); - if let Some(ip) = v4 { - state.v4 = Some(ip); - } - if let Some(ip) = v6 { - state.v6 = Some(ip); - } + state.v4.observe(v4, observed_at); + state.v6.observe(v6, observed_at); probing.store(false, Ordering::SeqCst); }); } fn v4(&self) -> Option { - self.state.lock().unwrap().v4.map(|ip| ip.to_string()) + self.state + .lock() + .unwrap() + .v4 + .address + .map(|ip| ip.to_string()) } fn v6(&self) -> Option { - self.state.lock().unwrap().v6.map(|ip| ip.to_string()) + self.state + .lock() + .unwrap() + .v6 + .address + .map(|ip| ip.to_string()) } /// 一次性模式没有后续采样,等首轮探测落地(或预算耗尽)再出报告。 @@ -306,7 +333,7 @@ fn probe_public_ip(url: &str, ipv6: bool) -> Option { .timeout_global(Some(PUBLIC_IP_PROBE_TIMEOUT)) .build() .into(); - let mut response = probe_agent + let response = probe_agent .get(url) .header("User-Agent", format!("nodeflare-agent/{VERSION}")) .call() @@ -484,9 +511,7 @@ 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, @@ -2735,12 +2760,12 @@ 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_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, - CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, - UPDATE_CHECK_JITTER_MAX_SECONDS, + 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, PublicIpValue, Report, CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS, + MAX_PENDING_LATENCY_RESULTS, PUBLIC_IP_STALE_AFTER, UPDATE_CHECK_JITTER_MAX_SECONDS, }; #[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] @@ -2761,6 +2786,23 @@ mod tests { 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\ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 62b539d..0ba82da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -174,7 +174,7 @@ export default function App() {
{config.site_name}
- +
diff --git a/frontend/src/admin-main.tsx b/frontend/src/admin-main.tsx index 5885c7a..4578d4c 100644 --- a/frontend/src/admin-main.tsx +++ b/frontend/src/admin-main.tsx @@ -60,7 +60,7 @@ function AdminApp() { const next = dark ? "light" : "dark"; localStorage.setItem(THEME_KEY, next); setAppearance(next); - }} onClose={() => window.location.assign("/")} onChanged={() => void loadConfig()} />; + }} onChanged={() => void loadConfig()} />; } createRoot(document.getElementById("root")!).render(); diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 42f583c..0436360 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -128,22 +128,10 @@ const BILLING_CYCLES: Array<{ days: number; label: string }> = [ { days: 0, label: "一次性" }, ]; -// last_ip 来自 CF-Connecting-IP,是裸 IP;v6 带冒号,v4 是四段数字。 -function ipFamily(ip: string): "v4" | "v6" | null { - const value = ip.trim(); - if (!value) return null; - if (value.includes(":")) return "v6"; - return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) ? "v4" : null; -} - 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 }, + { family: "v4" as const, ip: server.ip_v4 || "" }, + { family: "v6" as const, ip: server.ip_v6 || "" }, ].filter((entry) => entry.ip); return (
@@ -153,7 +141,7 @@ function ServerIpMeta({ server, agentVersion, onCopy }: { server: AdminServer; a ))} - {!entries.length ? {server.last_ip || "尚未上报 IP"} : null} + {!entries.length ? 尚未探测到公网 IP : null} · {agentVersion ? `Agent v${agentVersion}` : "Agent 未上报版本"}
@@ -180,13 +168,11 @@ export function AdminPanel({ config, dark, onToggleTheme, - onClose, onChanged, }: { config: Config; dark: boolean; onToggleTheme: () => void; - onClose: () => void; onChanged: () => void; }) { const [authenticated, setAuthenticated] = useState(!!getToken()); @@ -603,7 +589,6 @@ export function AdminPanel({ {authenticated ? (error ?
: notice ?
: null) : null} {!authenticated ?
-
@@ -622,8 +607,8 @@ export function AdminPanel({
- - + +
diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 720a8ae..a7be045 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -56,8 +56,6 @@ .admin-loading { display: grid; min-height: 100dvh; place-items: center; color: var(--admin-text); background: var(--admin-bg); font-size: 14px; } .admin-login-stage { position: relative; display: grid; min-height: 100dvh; place-items: center; background: var(--admin-bg); } -.admin-back { position: absolute; top: 22px; left: 26px; display: inline-flex; min-height: 38px; align-items: center; gap: 6px; border: 1px solid var(--admin-border); border-radius: 7px; padding: 7px 12px; color: var(--admin-text); background: var(--admin-surface); font-size: 13px; } -.admin-back:hover { background: var(--admin-hover); } .admin-login-theme { position: absolute; top: 22px; right: 26px; display: inline-flex; width: 38px; height: 38px; align-items: center; justify-content: center; border: 1px solid var(--admin-border); border-radius: 6px; color: var(--admin-text); background: var(--admin-surface); } .admin-login-theme:hover { background: var(--admin-hover); } @@ -81,8 +79,8 @@ .admin-brand img { width: 32px; height: 32px; flex: 0 0 auto; object-fit: contain; } .admin-brand strong { overflow: hidden; max-width: 320px; font-size: 18px; text-overflow: ellipsis; white-space: nowrap; } .admin-topbar-actions { display: flex; align-items: center; gap: 8px; } -.admin-topbar-actions button { display: inline-flex; min-height: 36px; align-items: center; gap: 6px; border: 1px solid var(--admin-border); border-radius: 6px; padding: 0 11px; color: var(--admin-text); background: var(--admin-surface); font-size: 13px; font-weight: 600; } -.admin-topbar-actions button:hover { border-color: var(--admin-border-hover); background: var(--admin-bg); } +.admin-topbar-actions button, .admin-topbar-actions .admin-home-link { display: inline-flex; min-height: 36px; align-items: center; gap: 6px; border: 1px solid var(--admin-border); border-radius: 6px; padding: 0 11px; color: var(--admin-text); background: var(--admin-surface); font-size: 13px; font-weight: 600; text-decoration: none; } +.admin-topbar-actions button:hover, .admin-topbar-actions .admin-home-link:hover { border-color: var(--admin-border-hover); background: var(--admin-bg); } .admin-body { display: grid; width: 100%; max-width: 100vw; height: auto; min-width: 0; min-height: 0; overflow: hidden; grid-template-columns: 220px minmax(0, 1fr); } .admin-sidebar { min-height: 0; overflow-y: auto; border-right: 1px solid var(--admin-border); padding: 20px 14px; background: var(--admin-surface); } .admin-nav-label { display: block; margin: 0 11px 8px; color: var(--admin-muted); font-size: 12px; font-weight: 700; } @@ -262,7 +260,7 @@ .admin-brand img { width: 29px; height: 29px; } .admin-brand strong { max-width: 125px; font-size: 15px; } .admin-topbar-actions { gap: 5px; } - .admin-topbar-actions button { width: 36px; min-height: 36px; justify-content: center; padding: 0; font-size: 0; } + .admin-topbar-actions button, .admin-topbar-actions .admin-home-link { width: 36px; min-height: 36px; justify-content: center; padding: 0; font-size: 0; } .admin-body { display: flex; height: auto; flex-direction: column; } .admin-sidebar { width: 100%; max-width: 100vw; min-width: 0; overflow-x: auto; overflow-y: hidden; flex: 0 0 auto; border-right: 0; border-bottom: 1px solid var(--admin-border); padding: 7px 10px; } .admin-nav-label { display: none; } @@ -280,7 +278,6 @@ .editor-modal, .install-modal, .latency-editor { padding: 20px 15px; } .alert-rule-editor { padding: 20px 15px; } .alert-server-picker { grid-template-columns: 1fr; } - .admin-back { top: 12px; left: 12px; } .admin-login-theme { top: 12px; right: 12px; } } diff --git a/src/lib.rs b/src/lib.rs index 3bb2ff4..745fabe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,7 @@ mod theme; mod turnstile; use std::collections::HashSet; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{Ipv4Addr, Ipv6Addr}; use std::time::Duration; use futures_util::TryStreamExt; @@ -812,10 +812,12 @@ 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.ip_v4.chars().count() > 15 + || (!report.ip_v4.is_empty() && report.ip_v4.parse::().is_err()) + || report.ip_v6.chars().count() > 45 + || (!report.ip_v6.is_empty() && report.ip_v6.parse::().is_err()) + { + return Some("公网地址字段无效"); } if report.disks.len() > 64 || report.gpus.len() > 32 @@ -1317,7 +1319,9 @@ mod tests { valid_password_derived, valid_ping_target, valid_public_asset_url, validate_latency_task, validate_report, validate_server, }; - use crate::{db::SECRET_MASK, models::AgentReport, models::LatencyTaskInput, models::ServerInput}; + use crate::{ + db::SECRET_MASK, models::AgentReport, models::LatencyTaskInput, models::ServerInput, + }; use worker::{Method, Url}; #[test] @@ -1327,12 +1331,25 @@ mod tests { 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 = "2001:db8::1".into(); + assert_eq!(validate_report(&report), Some("公网地址字段无效")); + report.ip_v4 = "203.0.113.7".into(); + report.ip_v6 = "203.0.113.7".into(); + assert_eq!(validate_report(&report), Some("公网地址字段无效")); report.ip_v4 = "not-an-ip".into(); + report.ip_v6.clear(); 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("公网地址字段无效")); + report.ip_v4.clear(); + assert_eq!(validate_report(&report), None); + } + + #[test] + fn requires_public_ip_fields_in_agent_reports() { + for field in ["ip_v4", "ip_v6"] { + let mut report = serde_json::to_value(AgentReport::default()).unwrap(); + report.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(report).is_err()); + } } #[test] diff --git a/src/models.rs b/src/models.rs index 0aab5be..ae571d0 100644 --- a/src/models.rs +++ b/src/models.rs @@ -215,10 +215,8 @@ pub struct AgentReport { pub gpu_usage: f64, pub gpu_model: String, pub agent_version: String, - /// 双栈公网地址,agent 双栈探测的结果;空串表示该协议族不可用或未上报。 - #[serde(default)] + /// 双栈公网地址,agent 双栈探测的结果;空串表示当前不可用或探测尚未完成。 pub ip_v4: String, - #[serde(default)] pub ip_v6: String, pub disk_read_bps: f64, pub disk_write_bps: f64,