This commit is contained in:
imengying committed 2026-09-03 01:34:50 +08:00
1 parent 7c76c8cee2
commit eb49a45ae2
7 files changed
+101 -62

No files matched your search

+63 -21
View File
@@ -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<Mutex<ClockCalibration>>;
/// 双栈公网地址探测:分别请求按协议族锁死的 icanhazip 端点(Cloudflare 运营,
/// 纯文本返回调用方出口 IP),记录最近一次成功的结果。主连接只用一个协议族,
/// 这里补齐另一个;探测失败保留旧值,从未成功则为空。
/// 纯文本返回调用方出口 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: Option<IpAddr>,
v6: Option<IpAddr>,
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<String> {
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<String> {
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<IpAddr> {
.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\
+1 -1
View File
@@ -174,7 +174,7 @@ export default function App() {
<div className="brand"><SiteLogo src={config.logo_url} alt="" width="36" height="36" /><strong>{config.site_name}</strong></div>
<div className="header-actions">
<button className="icon-btn" onClick={toggleTheme} title={dark ? ui(locale, "浅色主题", "Light theme") : ui(locale, "深色主题", "Dark theme")}>{dark ? <Sun size={18} /> : <Moon size={18} />}</button>
<button className="icon-btn" onClick={() => window.location.assign("/admin")} title={ui(locale, "进入后台", "Administration")}><UserCircle size={18} /></button>
<a className="icon-btn" href="/admin" target="_blank" rel="noopener noreferrer" title={ui(locale, "进入后台", "Administration")} aria-label={ui(locale, "进入后台", "Administration")}><UserCircle size={18} /></a>
</div>
</div>
</header>
+1 -1
View File
@@ -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(<StrictMode><AdminApp /></StrictMode>);
+5 -20
View File
@@ -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 (
<div className="server-name-meta">
@@ -153,7 +141,7 @@ function ServerIpMeta({ server, agentVersion, onCopy }: { server: AdminServer; a
<button type="button" className="ip-value" title={`点击复制:${entry.ip}`} onClick={() => onCopy(entry.ip)}>{entry.ip}</button>
</span>
))}
{!entries.length ? <span className="meta-item">{server.last_ip || "尚未上报 IP"}</span> : null}
{!entries.length ? <span className="meta-item">尚未探测到公网 IP</span> : null}
<span className="meta-dot">·</span>
<span className="meta-item">{agentVersion ? `Agent v${agentVersion}` : "Agent 未上报版本"}</span>
</div>
@@ -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 ? <div className="admin-toast error" role="alert" aria-live="assertive"><CircleAlert aria-hidden="true" /><span>{error}</span></div>
: notice ? <div className="admin-toast" role="status" aria-live="polite"><CircleCheck aria-hidden="true" /><span>{notice}</span></div> : null) : null}
{!authenticated ? <div className="admin-login-stage">
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={15} />返回</button>
<button className="admin-login-theme" type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={15} /> : <Moon size={15} />}</button>
<form className="login-form glass-panel" onSubmit={login}>
<SiteLogo src={siteLogoUrl} alt="" width="48" height="48" />
@@ -622,8 +607,8 @@ export function AdminPanel({
</div>
<div className="admin-topbar-actions">
<button type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"} aria-label={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={15} /> : <Moon size={15} />}</button>
<button type="button" onClick={onClose} title="主页" aria-label="主页"><ArrowLeft size={15} />主页</button>
<button type="button" onClick={() => void logout()} title="退出" aria-label="退出"><LogOut size={15} />退出</button>
<a className="admin-home-link" href="/" target="_blank" rel="noopener noreferrer" title="主页" aria-label="主页"><ArrowLeft size={15} /></a>
<button type="button" onClick={() => void logout()} title="退出" aria-label="退出"><LogOut size={15} /></button>
</div>
</header>
+3 -6
View File
@@ -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; }
}
+27 -10
View File
@@ -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::<IpAddr>().is_err()) {
return Some("公网地址字段无效");
}
if report.ip_v4.chars().count() > 15
|| (!report.ip_v4.is_empty() && report.ip_v4.parse::<Ipv4Addr>().is_err())
|| report.ip_v6.chars().count() > 45
|| (!report.ip_v6.is_empty() && report.ip_v6.parse::<Ipv6Addr>().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::<AgentReport>(report).is_err());
}
}
#[test]
+1 -3
View File
@@ -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,