diff --git a/README.md b/README.md index 3047238..9b89aeb 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,13 @@ ## 功能特性 - ⚡ **延迟测试**: 前端连续 10 次 ping,取中位数作为延迟、平均绝对偏差作为抖动 -- ⬇️ **下载测试**: 渐进式下载 1 / 2.5 / 5 / 10 / 25 MB 随机数据,实时仪表盘,取最优值 -- ⬆️ **上传测试**: 渐进式上传 1 / 2.5 / 5 / 10 MB 伪随机数据,实时仪表盘,取最优值 +- ⬇️ **下载测试**: 自适应负载——单轮目标 ~2 秒、阶段最短 5 秒,负载从 1MB 起逐轮翻倍 + (上限 400MB),实时仪表盘,取最优值;慢速连接 1-2 轮即结束,快速连接自动加大负载 +- ⬆️ **上传测试**: 同样的自适应策略(1MB 起翻倍,上限 256MB),实时仪表盘,取最优值 - 🏆 **排行榜**: 下载 / 上传 / 延迟 三个榜单各取 Top 10,IP 自动打码(保留前 3 段) +- 🌐 **公网 IP 识别**: 服务器位于 NAT 后(公网访问经路由器转发,服务器只能看到内网 IP), + 前端通过 ipinfo.io / ipify 探测真实公网出口 IP 用于展示与排行;内网 IP 在排行榜中标注 + "内网 / NAT" - 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口 ## 项目结构 @@ -90,20 +94,24 @@ speedtest.lmve.net { addr = "/opt/speedtest/web.sock" # 以 / 开头为 unix socket,否则为 TCP 端口 [speedtest] - max_download_bytes = 104857600 # 单次下载请求上限(默认 100 MB) - max_upload_bytes = 104857600 # 单次上传请求上限(默认 100 MB) + max_download_bytes = 536870912 # 单次下载请求上限(默认 512 MB) + max_upload_bytes = 536870912 # 单次上传请求上限(默认 512 MB) ``` +> 注:默认上限 512MB 服务于前端自适应测速(快速连接负载会翻倍增长); +> 旧版本配置中的 100MB 上限可手动调大,或删除配置行后重启让程序补全默认值。 + ## API | 方法 | 路径 | 说明 | | --- | --- | --- | | GET | `/` | 测速页面 | -| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...}`) | -| GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 100MB) | +| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...,"client_ip":...}`) | +| GET | `/api/download?size=N` | 下载测速,流式返回 N 字节随机数据(默认 10MB,上限 512MB) | | POST | `/api/upload` | 上传测速,接收请求体并返回 `received/elapsed_s/mbps` | -| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps}` | -| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`) | +| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps, client_ip?}` | +| GET | `/api/rankings` | 排行榜(`download/upload/latency` 三榜 + `total`,含 `is_private_ip` 标注) | -客户端真实 IP 通过 Caddy 注入的 `X-Real-IP` / `X-Forwarded-For` 头识别, -排行榜展示时对 IP 打码保护隐私。 +客户端真实 IP 优先取前端探测的公网出口 IP(`client_ip` 字段), +否则回退到 Caddy 注入的 `X-Real-IP` / `X-Forwarded-For`; +排行榜展示时对 IP 打码保护隐私,NAT/内网 IP 标注"内网 / NAT"。 diff --git a/config/defaults.go b/config/defaults.go index ed9c652..1e802f8 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -23,9 +23,11 @@ const ( const DefaultWebAddr = "/opt/speedtest/web.sock" // Default speedtest limits(字节) +// 前端测速为自适应负载(单轮目标 ~2 秒、阶段最短 5 秒), +// 高速连接下负载会翻倍增长,因此上限放宽到 512 MB const ( - DefaultMaxDownloadBytes = 100 * 1024 * 1024 // 100 MB - DefaultMaxUploadBytes = 100 * 1024 * 1024 // 100 MB + DefaultMaxDownloadBytes = 512 * 1024 * 1024 // 512 MB + DefaultMaxUploadBytes = 512 * 1024 * 1024 // 512 MB ) // ConfigFileName is the name of the configuration file diff --git a/internal/web/handlers/speedtest.go b/internal/web/handlers/speedtest.go index 94d232e..44662f7 100644 --- a/internal/web/handlers/speedtest.go +++ b/internal/web/handlers/speedtest.go @@ -91,12 +91,14 @@ func maskIP(ip string) string { return ip } -// Ping 延迟探测:返回最小响应,供前端计算 RTT +// Ping 延迟探测:返回最小响应,供前端计算 RTT;同时回传服务器看到的客户端 IP +// (NAT 环境下可能是内网 IP,前端会用公网探测结果替代) func (h *Handler) Ping(c *gin.Context) { c.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, gin.H{ - "pong": true, - "ts": time.Now().UnixMilli(), + "pong": true, + "ts": time.Now().UnixMilli(), + "client_ip": clientIP(c), }) } @@ -177,6 +179,7 @@ type resultReq struct { JitterMs float64 `json:"jitter_ms"` DownloadMbps float64 `json:"download_mbps"` UploadMbps float64 `json:"upload_mbps"` + ClientIP string `json:"client_ip"` // 可选:前端探测到的公网出口 IP } // Result 保存一次测速结果 @@ -196,8 +199,15 @@ func (h *Handler) Result(c *gin.Context) { return } + // 客户端 IP:优先采用前端探测的公网出口 IP(NAT 环境下服务器只能看到内网 IP); + // 未提供或格式非法(含回环地址伪造)时回退到 Caddy 传递的 X-Real-IP + ip := strings.TrimSpace(req.ClientIP) + if parsed := net.ParseIP(ip); parsed == nil || parsed.IsLoopback() { + ip = clientIP(c) + } + record := &db.SpeedTestResult{ - ClientIP: clientIP(c), + ClientIP: ip, LatencyMs: req.LatencyMs, JitterMs: req.JitterMs, DownloadMbps: req.DownloadMbps, @@ -215,6 +225,7 @@ func (h *Handler) Result(c *gin.Context) { type RankItem struct { ID uint `json:"id"` ClientIP string `json:"client_ip"` + IsPrivateIP bool `json:"is_private_ip"` // NAT/内网环境(如路由器网关),前端标注展示 LatencyMs float64 `json:"latency_ms"` JitterMs float64 `json:"jitter_ms"` DownloadMbps float64 `json:"download_mbps"` @@ -222,12 +233,29 @@ type RankItem struct { CreatedAt string `json:"created_at"` } +// isPrivateIP 判断 IP 是否为内网/保留地址(NAT 网关、局域网、回环等) +func isPrivateIP(ip string) bool { + parsed := net.ParseIP(ip) + if parsed == nil { + return true + } + if parsed.IsLoopback() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast() { + return true + } + if parsed.IsPrivate() || parsed.IsUnspecified() { + return true + } + // IPv4 兼容段(IsPrivate 已覆盖 10/8、172.16/12、192.168/16) + return false +} + func toRankItems(rows []db.SpeedTestResult) []RankItem { items := make([]RankItem, 0, len(rows)) for _, r := range rows { items = append(items, RankItem{ ID: r.ID, ClientIP: maskIP(r.ClientIP), + IsPrivateIP: isPrivateIP(r.ClientIP), LatencyMs: r.LatencyMs, JitterMs: r.JitterMs, DownloadMbps: r.DownloadMbps, diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index ac83e2e..1b06ed5 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -30,6 +30,12 @@ -webkit-background-clip: text; background-clip: text; color: transparent; } .sub { color: var(--muted); font-size: 13px; margin-top: 6px; } + .myip { + display: inline-block; margin-top: 10px; padding: 5px 14px; font-size: 13px; + color: var(--accent1); background: rgba(0, 212, 255, 0.08); + border: 1px solid rgba(0, 212, 255, 0.25); border-radius: 999px; + } + .myip.private { color: #ffb45e; background: rgba(255, 180, 94, 0.08); border-color: rgba(255, 180, 94, 0.3); } .card { background: var(--card); @@ -119,6 +125,7 @@
speedtest.lmve.net · lmve.net 网速测试服务
+
@@ -131,7 +138,7 @@
Mbps
-
点击下方按钮开始测速(约需 20~40 秒)
+
点击下方按钮开始测速(约需 15~30 秒)
@@ -171,7 +178,7 @@ @@ -284,7 +291,14 @@ async function pingTest(n) { /* ================= 下载测试 ================= */ async function downloadOnce(sizeBytes) { const t0 = performance.now(); - const resp = await fetch('/api/download?size=' + sizeBytes + '&t=' + Date.now(), { cache: 'no-store' }); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 60000); + let resp; + try { + resp = await fetch('/api/download?size=' + sizeBytes + '&t=' + Date.now(), { cache: 'no-store', signal: ctrl.signal }); + } finally { + clearTimeout(timer); + } if (!resp.ok || !resp.body) throw new Error('下载测试请求失败'); const reader = resp.body.getReader(); let received = 0, lastT = t0, lastBytes = 0; @@ -298,18 +312,8 @@ async function downloadOnce(sizeBytes) { lastT = now; lastBytes = received; } } - const dt = (performance.now() - t0) / 1000; - return (received * 8) / dt / 1e6; // Mbps -} - -async function downloadTest() { - const sizes = [1048576, 2621440, 5242880, 10485760, 26214400]; // 1, 2.5, 5, 10, 25 MB - const results = []; - for (const size of sizes) { - setPhase('下载测速中 ' + (size / 1048576).toFixed(size < 1048576 * 2 ? 0 : 1) + ' MB …'); - results.push(await downloadOnce(size)); - } - return Math.max(...results); + const ms = performance.now() - t0; + return { mbps: (received * 8) / (ms / 1000) / 1e6, ms }; } /* ================= 上传测试 ================= */ @@ -343,39 +347,115 @@ function uploadOnce(sizeBytes) { } }; xhr.onload = () => { - const dt = (performance.now() - t0) / 1000; - resolve((sizeBytes * 8) / dt / 1e6); + const ms = performance.now() - t0; + resolve({ mbps: (sizeBytes * 8) / (ms / 1000) / 1e6, ms }); }; xhr.onerror = () => reject(new Error('上传测试请求失败')); xhr.ontimeout = () => reject(new Error('上传测试超时')); - xhr.timeout = 30000; + xhr.timeout = 60000; xhr.open('POST', '/api/upload?t=' + Date.now()); xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.send(data); }); } -async function uploadTest() { - const sizes = [1048576, 2621440, 5242880, 10485760]; // 1, 2.5, 5, 10 MB +/* ================= 自适应测速阶段 ================= + * 目标:每轮负载运行 ~2 秒(保证测量准确),负载逐轮翻倍; + * 阶段最短 5 秒,最多 6 轮。慢速连接 1-2 轮即达到单轮 2 秒, + * 快速连接自动加大负载拉长测试时间。 + */ +const MB = 1048576; +const PHASE_MIN_MS = 5000; +const ROUND_TARGET_MS = 2000; +const MAX_ROUNDS = 6; + +async function runPhase(runOnce, minBytes, maxBytes, label) { const results = []; - for (const size of sizes) { - setPhase('上传测速中 ' + (size / 1048576).toFixed(size < 1048576 * 2 ? 0 : 1) + ' MB …'); - results.push(await uploadOnce(size)); + const phaseStart = performance.now(); + let size = minBytes; + for (let rounds = 0; rounds < MAX_ROUNDS; rounds++) { + setPhase(label + ' ' + (size / MB).toFixed(0) + ' MB …'); + const r = await runOnce(size); + results.push(r.mbps); + const elapsed = performance.now() - phaseStart; + const dataEnough = r.ms >= ROUND_TARGET_MS || size >= maxBytes; + const timeEnough = elapsed >= PHASE_MIN_MS && rounds >= 1; + if (dataEnough || timeEnough) break; + size = Math.min(size * 2, maxBytes); } return Math.max(...results); } +async function downloadTest() { + return runPhase(downloadOnce, 1 * MB, 400 * MB, '下载测速中'); +} + +async function uploadTest() { + return runPhase(uploadOnce, 1 * MB, 256 * MB, '上传测速中'); +} + +/* ================= 公网 IP 探测 ================= + * 服务器在 NAT 后面(公网访问经路由器转发,服务器只能看到内网 IP), + * 因此通过外部服务探测用户真实的公网出口 IP,用于展示与排行榜。 + */ +async function detectPublicIP() { + const sources = [ + { url: 'https://ipinfo.io/ip', type: 'text' }, + { url: 'https://api.ipify.org?format=json', type: 'json' }, + ]; + for (const s of sources) { + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 4000); + const resp = await fetch(s.url, { signal: ctrl.signal }); + clearTimeout(timer); + if (!resp.ok) continue; + let ip; + if (s.type === 'json') ip = (await resp.json()).ip; + else ip = (await resp.text()).trim(); + if (typeof ip === 'string' && ip && /^[\d.a-fA-F:]+$/.test(ip)) return ip; + } catch (e) { /* 尝试下一个源 */ } + } + return ''; +} + +function isPrivateIP(ip) { + if (!ip) return false; + if (ip.startsWith('127.') || ip.startsWith('10.') || ip.startsWith('192.168.')) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true; + if (ip.startsWith('169.254.')) return true; + const lower = ip.toLowerCase(); + if (lower === '::1' || lower.startsWith('fe80') || lower.startsWith('fc') || lower.startsWith('fd')) return true; + return false; +} + +function showMyIP(ip, fromServer) { + const el = document.getElementById('myIP'); + if (!ip) { el.hidden = true; return; } + el.hidden = false; + if (isPrivateIP(ip)) { + el.textContent = '你的 IP: ' + ip + '(内网 / NAT 环境)'; + el.className = 'myip private'; + } else { + el.textContent = '你的公网 IP: ' + ip; + el.className = 'myip'; + } + if (fromServer) el.title = '由服务器识别(经 NAT 转发)'; +} + /* ================= 结果提交 ================= */ -async function submitResult(r) { +async function submitResult(r, clientIP) { + const payload = { + latency_ms: +r.latency.toFixed(2), + jitter_ms: +r.jitter.toFixed(2), + download_mbps: +r.download.toFixed(2), + upload_mbps: +r.upload.toFixed(2), + }; + if (clientIP) payload.client_ip = clientIP; const resp = await fetch('/api/result', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - latency_ms: +r.latency.toFixed(2), - jitter_ms: +r.jitter.toFixed(2), - download_mbps: +r.download.toFixed(2), - upload_mbps: +r.upload.toFixed(2), - }), + body: JSON.stringify(payload), }); if (!resp.ok) throw new Error('结果提交失败'); return resp.json(); @@ -403,6 +483,12 @@ function renderRanking(rows) { } rankBody.innerHTML = rows.map((r, i) => { const cls = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : ''; + let ipHtml; + if (r.is_private_ip) { + ipHtml = '内网 / NAT'; + } else { + ipHtml = '' + r.client_ip + ''; + } let speed; if (rankType === 'latency') { speed = '' + r.latency_ms.toFixed(1) + ' ms'; @@ -413,7 +499,7 @@ function renderRanking(rows) { } return '' + '' + (i + 1) + '' + - '' + r.client_ip + '' + + '' + ipHtml + '' + '' + speed + '' + '' + r.created_at + '' + ''; @@ -443,6 +529,11 @@ async function runTest() { ['statLatency', 'statJitter', 'statDownload', 'statUpload'].forEach(id => document.getElementById(id).textContent = '--'); drawGauge(0); + // 并行探测公网出口 IP(不阻塞测速流程) + let publicIP = ''; + let serverIP = ''; + detectPublicIP().then(ip => { if (ip && !isPrivateIP(ip)) publicIP = ip; }); + try { // 1. 延迟 setPhase('延迟测试中(10 次 ping)…'); @@ -452,23 +543,30 @@ async function runTest() { markDone(document.getElementById('cardLatency')); markDone(document.getElementById('cardJitter')); - // 2. 下载 + // 2. 下载(自适应时长,阶段最短 5 秒) setPhase('下载测速准备中…'); const download = await downloadTest(); statDownload.textContent = download.toFixed(2); updateGaugeSpeed(download); markDone(document.getElementById('cardDownload')); - // 3. 上传 + // 3. 上传(自适应时长,阶段最短 5 秒) setPhase('上传测速准备中…'); const upload = await uploadTest(); statUpload.textContent = upload.toFixed(2); updateGaugeSpeed(upload); markDone(document.getElementById('cardUpload')); - // 4. 提交 + // 4. 展示 IP:优先公网探测结果,失败则用服务器识别结果(可能为内网) + try { + const pr = await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' }); + serverIP = (await pr.json()).client_ip || ''; + } catch (e) { /* ignore */ } + showMyIP(publicIP || serverIP, !publicIP); + + // 5. 提交 setPhase('正在提交结果…'); - await submitResult({ latency, jitter, download, upload }); + await submitResult({ latency, jitter, download, upload }, publicIP); await loadRankings(); setPhase('✅ 测试完成,结果已记录'); } catch (e) {