diff --git a/README.md b/README.md index 13a35b0..baf30bf 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,14 @@ 快速连接自动加大负载填满 10 秒,慢速连接用小负载同样 10 秒内完成 - ⬆️ **上传测试**: 同样的固定 10 秒自适应策略 - 🏆 **排行榜**: 单表同时展示延迟 / 下载 / 上传,**点击列头动态排序**(再点切换升降序, - 延迟默认升序、速度默认降序);测速完成后**自动高亮"我的成绩"**(含真实名次, - localStorage 持久化,刷新页面仍高亮);公网 IP 打码,内网来源 IP 完整标注方便调试 -- 🌐 **公网 IP 识别**: 服务器直接识别客户端真实 IP(Caddy `X-Real-IP`), - 不依赖任何外部 IP 查询服务;路由器单向伪装/公网直连时即为真实公网 IP + 延迟默认升序、速度/时间默认降序);**用户列显示地区 + 浏览器**(离线 ip2region 解析地区、 + UA 解析浏览器系统),**不展示任何 IP**;测速完成后**自动高亮"我的成绩"**(含真实名次, + localStorage 持久化,刷新页面仍高亮) +- 🌐 **IP 处理**: 客户端 IP 仅后台保存(数据库),用于地区解析与调试,**不在排行榜展示**; + 页面上方徽标显示"你的公网 IP · 地区";服务器直接识别 IP(Caddy `X-Real-IP`), + 无外部 IP 查询依赖 +- 📍 **地区解析**: 内置 ip2region v4 离线库(`data/ip2region_v4.xdb`,11MB),纯本地解析 + 零外部依赖,加载失败时地区优雅降级为"未知地区" - 🔒 **Unix socket 监听**: 由 Caddy 反代对外提供 HTTPS,不暴露 TCP 端口 ## 项目结构 @@ -107,11 +111,11 @@ speedtest.lmve.net { | 方法 | 路径 | 说明 | | --- | --- | --- | | GET | `/` | 测速页面 | -| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...,"client_ip":...}`) | +| GET | `/api/ping` | 延迟探测(返回 `{"pong":true,"ts":...,"client_ip":...,"location":...}`) | | 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, client_ip?, server_ip?}` | -| GET | `/api/rankings?sort=&order=&include_id=` | 排行榜单表:`sort` ∈ download/upload/latency,`order` ∈ asc/desc,`include_id` 附加指定记录(真实名次 + `is_mine`) | +| POST | `/api/result` | 提交结果 `{latency_ms, jitter_ms, download_mbps, upload_mbps}`(IP/UA 由服务器识别) | +| GET | `/api/rankings?sort=&order=&include_id=` | 排行榜单表:`sort` ∈ download/upload/latency/created,`order` ∈ asc/desc,`include_id` 附加指定记录(真实名次 + `is_mine`);条目含 `location`/`browser`,**不含 IP** | 客户端 IP 由服务器直接识别(Caddy 注入的 `X-Real-IP`),前端不调用任何外部 IP 服务; `client_ip` / `server_ip` 字段为 API 兼容保留(可选)。公网 IP 打码保护隐私; diff --git a/data/ip2region_v4.xdb b/data/ip2region_v4.xdb new file mode 100644 index 0000000..0e75dc8 Binary files /dev/null and b/data/ip2region_v4.xdb differ diff --git a/go.mod b/go.mod index 8a30180..25964b1 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/BurntSushi/toml v1.4.0 github.com/gin-gonic/gin v1.12.0 + github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260814054437-800d19424237 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 ) diff --git a/go.sum b/go.sum index 7362b24..e1e20a1 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260814054437-800d19424237 h1:MnELyo/XfIhNDG7YjVUHkEW0CsgONASbV7SWqVL6/gw= +github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260814054437-800d19424237/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= diff --git a/install.sh b/install.sh index c7bd18e..69bc9d8 100644 --- a/install.sh +++ b/install.sh @@ -128,6 +128,10 @@ deploy_files() { # 复制模板文件(目录结构: internal/web/templates/*.html) cp -f "${BUILD_DIR}/internal/web/templates/"*.html "${INSTALL_DIR}/internal/web/templates/" + # 复制 ip2region 地区库(离线 GeoIP 数据) + mkdir -p "${INSTALL_DIR}/data" + cp -f "${BUILD_DIR}/data/ip2region_v4.xdb" "${INSTALL_DIR}/data/ip2region_v4.xdb" + # 配置文件由程序首次启动时自动生成 + 缺失项自动补全,脚本不干预 if [[ -f "${CONFIG_DIR}/speedtest.toml" ]]; then info "保留现有配置文件: ${CONFIG_DIR}/speedtest.toml" diff --git a/internal/db/models.go b/internal/db/models.go index 923fe2b..45f7c00 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -5,8 +5,10 @@ import "time" // SpeedTestResult 一次完整的测速结果记录 type SpeedTestResult struct { ID uint `gorm:"primaryKey" json:"id"` - ClientIP string `gorm:"size:64;index" json:"client_ip"` // 展示 IP(优先前端探测的公网出口 IP) - ServerIP string `gorm:"size:64" json:"server_ip"` // 服务器看到的来源 IP(NAT 环境下为内网 IP,用于区分设备) + ClientIP string `gorm:"size:64;index" json:"client_ip"` // 客户端 IP(仅后台保存,不对外展示) + ServerIP string `gorm:"size:64" json:"server_ip"` // 服务器看到的来源 IP(NAT 调试用,仅后台保存) + Location string `gorm:"size:64" json:"location"` // 地区(ip2region 离线解析) + UserAgent string `gorm:"size:256" json:"user_agent"` // 浏览器 UA(仅后台保存) LatencyMs float64 `json:"latency_ms"` // 延迟(毫秒,取中位数) JitterMs float64 `json:"jitter_ms"` // 抖动(毫秒,平均绝对偏差) DownloadMbps float64 `json:"download_mbps"` // 下载速度(Mbps) diff --git a/internal/geo/geo.go b/internal/geo/geo.go new file mode 100644 index 0000000..6b36292 --- /dev/null +++ b/internal/geo/geo.go @@ -0,0 +1,124 @@ +package geo + +import ( + "log" + "os" + "strings" + + "github.com/lionsoul2014/ip2region/binding/golang/xdb" +) + +// Geo 基于 ip2region v4 离线库的 IP 地区解析器(纯本地,无外部依赖) +type Geo struct { + searcher *xdb.Searcher +} + +// New 按顺序尝试数据文件路径加载;全部失败时返回可用但禁用的实例(地区解析优雅降级) +func New(paths ...string) *Geo { + var dbPath string + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + dbPath = p + break + } + } + if dbPath == "" { + log.Printf("[geo] 未找到 ip2region 数据文件(尝试: %v),地区解析已禁用", paths) + return &Geo{} + } + + handle, err := os.Open(dbPath) + if err != nil { + log.Printf("[geo] 打开 %s 失败: %v", dbPath, err) + return &Geo{} + } + defer handle.Close() + + header, err := xdb.LoadHeader(handle) + if err != nil { + log.Printf("[geo] 读取 %s 头部失败: %v", dbPath, err) + return &Geo{} + } + version, err := xdb.VersionFromHeader(header) + if err != nil { + log.Printf("[geo] 识别 %s 版本失败: %v", dbPath, err) + return &Geo{} + } + + // 整库载入内存,查询性能最好(约 11MB) + buff, err := xdb.LoadContentFromFile(dbPath) + if err != nil { + log.Printf("[geo] 加载 %s 数据失败: %v", dbPath, err) + return &Geo{} + } + searcher, err := xdb.NewWithBuffer(version, buff) + if err != nil { + log.Printf("[geo] 初始化搜索器失败: %v", err) + return &Geo{} + } + + log.Printf("[geo] ip2region 已加载(%.0f MB,%s)", float64(len(buff))/1024/1024, dbPath) + return &Geo{searcher: searcher} +} + +// Search 返回友好地区名(如 "广东省深圳市 电信"、"新加坡");解析失败返回 "" +func (g *Geo) Search(ip string) string { + if g == nil || g.searcher == nil || ip == "" { + return "" + } + region, err := g.searcher.Search(ip) + if err != nil { + return "" + } + return Format(region) +} + +// Format 把 ip2region v4 原始格式 "国家|省|市|ISP|国家码" 转为友好显示 +// 例:中国|江苏省|南京市|0|CN → "江苏省南京市";中国|上海|上海市|电信|CN → "上海市 电信"; +// United States|California|0|Google LLC|US → "United States California · Google LLC" +func Format(region string) string { + parts := strings.Split(region, "|") + if len(parts) < 5 { + return region + } + country, province, city, isp := parts[0], parts[1], parts[2], parts[3] + if country == "" || country == "0" || country == "Reserved" { + return "" // 保留地址/内网/IPv6 不在 v4 库中 + } + if isp == "0" { + isp = "" + } + + if country == "中国" { + loc := "" + switch { + case province != "" && province != "0" && city != "" && city != "0": + if city == province || strings.Contains(city, province) { + loc = city // 直辖市:"上海市" + } else { + loc = province + city // "江苏省南京市" + } + case province != "" && province != "0": + loc = province + default: + loc = "中国" + } + if isp != "" { + loc += " " + isp + } + return loc + } + + // 国外:国家 + 州/市(英文) + loc := country + switch { + case province != "" && province != "0": + loc += " " + province + case city != "" && city != "0": + loc += " " + city + } + if isp != "" { + loc += " · " + isp + } + return loc +} diff --git a/internal/web/handlers/speedtest.go b/internal/web/handlers/speedtest.go index 0f6ebe1..e91cf3a 100644 --- a/internal/web/handlers/speedtest.go +++ b/internal/web/handlers/speedtest.go @@ -13,6 +13,7 @@ import ( "speedtest/config" "speedtest/internal/db" + "speedtest/internal/geo" "speedtest/internal/store" "github.com/gin-gonic/gin" @@ -28,13 +29,18 @@ const ( type Handler struct { stores *store.Stores cfg config.SpeedtestConfig - pool [][]byte // 预生成随机数据池(下载测速负载) + geo *geo.Geo // IP 地区解析(离线) + pool [][]byte // 预生成随机数据池(下载测速负载) poolNext atomic.Int64 } // NewHandler creates a new speedtest Handler. func NewHandler(stores *store.Stores, cfg config.SpeedtestConfig) (*Handler, error) { - h := &Handler{stores: stores, cfg: cfg} + h := &Handler{ + stores: stores, + cfg: cfg, + geo: geo.New("data/ip2region_v4.xdb", "/opt/speedtest/data/ip2region_v4.xdb"), + } if err := h.buildPool(); err != nil { return nil, err } @@ -74,31 +80,16 @@ func clientIP(c *gin.Context) string { return host } -// maskIP 排行榜展示时对 IP 打码(保留前 3 段) -func maskIP(ip string) string { - if strings.Contains(ip, ":") { - // IPv6:保留前 3 段 - parts := strings.Split(ip, ":") - if len(parts) > 3 { - return strings.Join(parts[:3], ":") + ":****" - } - return ip - } - parts := strings.Split(ip, ".") - if len(parts) == 4 { - return strings.Join(parts[:3], ".") + ".*" - } - return ip -} - -// Ping 延迟探测:返回最小响应,供前端计算 RTT;同时回传服务器看到的客户端 IP -// (NAT 环境下可能是内网 IP,前端会用公网探测结果替代) +// Ping 延迟探测:返回最小响应,供前端计算 RTT; +// 同时回传客户端 IP(页面上方展示用)与其地区 func (h *Handler) Ping(c *gin.Context) { + ip := clientIP(c) c.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, gin.H{ "pong": true, "ts": time.Now().UnixMilli(), - "client_ip": clientIP(c), + "client_ip": ip, + "location": h.geo.Search(ip), }) } @@ -217,6 +208,8 @@ func (h *Handler) Result(c *gin.Context) { record := &db.SpeedTestResult{ ClientIP: ip, ServerIP: serverIP, + Location: h.geo.Search(ip), + UserAgent: truncate(c.Request.UserAgent(), 255), LatencyMs: req.LatencyMs, JitterMs: req.JitterMs, DownloadMbps: req.DownloadMbps, @@ -230,15 +223,62 @@ func (h *Handler) Result(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true, "id": record.ID}) } -// RankItem 排行榜展示条目 -// ClientIP 打码展示;ServerIP 为来源 IP(内网 IP 完整返回,供站长调试区分设备) +// truncate 截断字符串到 n 个字节 +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +// parseBrowser 从 User-Agent 解析简短的浏览器 + 系统信息,如 "Chrome · Windows" +func parseBrowser(ua string) string { + lower := strings.ToLower(ua) + + browser := "其他" + switch { + case strings.Contains(lower, "edg/"): + browser = "Edge" + case strings.Contains(lower, "chrome"): + browser = "Chrome" + case strings.Contains(lower, "firefox"): + browser = "Firefox" + case strings.Contains(lower, "micromessenger"): + browser = "微信" + case strings.Contains(lower, "qqbrowser") || strings.Contains(lower, " qq/"): + browser = "QQ浏览器" + case strings.Contains(lower, "ucbrowser"): + browser = "UC浏览器" + case strings.Contains(lower, "opera") || strings.Contains(lower, "opr/"): + browser = "Opera" + case strings.Contains(lower, "safari"): + browser = "Safari" + } + + osName := "其他系统" + switch { + case strings.Contains(lower, "windows"): + osName = "Windows" + case strings.Contains(lower, "android"): + osName = "Android" + case strings.Contains(lower, "iphone") || strings.Contains(lower, "ipad") || strings.Contains(lower, "ios"): + osName = "iOS" + case strings.Contains(lower, "mac os") || strings.Contains(lower, "macintosh"): + osName = "macOS" + case strings.Contains(lower, "linux"): + osName = "Linux" + } + + return browser + " · " + osName +} + +// RankItem 排行榜展示条目(不含任何 IP 信息——IP 仅后台保存,不对外展示) type RankItem struct { ID uint `json:"id"` - Rank int `json:"rank"` // 名次(含附加记录的真实名次) - IsMine bool `json:"is_mine"` // 是否请求方指定的记录(前端高亮"我的成绩") - ClientIP string `json:"client_ip"` - ServerIP string `json:"server_ip"` - IsPrivateIP bool `json:"is_private_ip"` // ClientIP 是否为内网(探测失败回退场景) + Rank int `json:"rank"` // 名次(含附加记录的真实名次) + IsMine bool `json:"is_mine"` // 是否请求方指定的记录(前端高亮"我的成绩") + Location string `json:"location"` // 用户地区(ip2region) + Browser string `json:"browser"` // 浏览器 + 系统(由 UA 解析) LatencyMs float64 `json:"latency_ms"` JitterMs float64 `json:"jitter_ms"` DownloadMbps float64 `json:"download_mbps"` @@ -246,30 +286,13 @@ 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), - ServerIP: r.ServerIP, - IsPrivateIP: isPrivateIP(r.ClientIP), + Location: r.Location, + Browser: parseBrowser(r.UserAgent), LatencyMs: r.LatencyMs, JitterMs: r.JitterMs, DownloadMbps: r.DownloadMbps, diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 619f27e..5d2e227 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -109,12 +109,8 @@ .val-up { font-weight: 700; color: var(--accent2); } .val-la { font-weight: 700; color: var(--green); } .unit-sm { color: var(--muted); font-size: 11px; font-weight: 400; } - .ip { color: var(--muted); font-size: 13px; } - .ip-src { color: #ffb45e; font-size: 12px; margin-left: 6px; } - .ip-tag { - display: inline-block; margin-left: 6px; padding: 1px 7px; font-size: 11px; - color: #ffb45e; border: 1px solid rgba(255, 180, 94, 0.4); border-radius: 999px; vertical-align: 1px; - } + .loc { font-size: 13px; } + .ua { color: var(--muted); font-size: 12px; margin-top: 2px; } .time { color: var(--muted); font-size: 12px; } .empty { text-align: center; color: var(--muted); padding: 26px 0; font-size: 14px; } .rank-hint { text-align: center; color: var(--muted); font-size: 12px; padding: 12px 0 8px; } @@ -178,7 +174,7 @@ # - IP + 用户 延迟 下载 上传 @@ -430,7 +426,7 @@ function isPrivateIP(ip) { return false; } -function showMyIP(ip) { +function showMyIP(ip, location) { const el = document.getElementById('myIP'); if (!ip) { el.hidden = true; return; } el.hidden = false; @@ -438,7 +434,7 @@ function showMyIP(ip) { el.textContent = '你的 IP: ' + ip + '(内网 / NAT 环境)'; el.className = 'myip private'; } else { - el.textContent = '你的公网 IP: ' + ip; + el.textContent = '你的公网 IP: ' + ip + (location ? ' · ' + location : ''); el.className = 'myip'; } } @@ -488,23 +484,12 @@ function renderRanking(rows) { } rankBody.innerHTML = rows.map(r => { const cls = r.rank === 1 ? 'top1' : r.rank === 2 ? 'top2' : r.rank === 3 ? 'top3' : ''; - let ipHtml; - if (r.is_private_ip) { - // 纯内网记录:显示内网 IP 完整值,便于调试 - ipHtml = '' + r.client_ip + '内网'; - if (r.server_ip && r.server_ip !== r.client_ip) { - ipHtml += '← ' + r.server_ip + ''; - } - } else { - ipHtml = '' + r.client_ip + ''; - // 仅在来源为内网时标注(NAT 场景区分同一出口下的不同设备) - if (r.server_ip && isPrivateIP(r.server_ip)) { - ipHtml += '← ' + r.server_ip + ''; - } - } + // 用户信息:地区 + 浏览器(不展示任何 IP,IP 仅后台保存) + const user = '
' + (r.location || '未知地区') + '
' + + '
' + (r.browser || '未知设备') + '
'; return '' + '' + r.rank + '' + - '' + ipHtml + (r.is_mine ? '我的成绩' : '') + '' + + '' + user + (r.is_mine ? '我的成绩' : '') + '' + '' + r.latency_ms.toFixed(1) + ' ms' + '' + r.download_mbps.toFixed(2) + ' Mbps' + '' + r.upload_mbps.toFixed(2) + ' Mbps' + @@ -572,10 +557,11 @@ async function runTest() { updateGaugeSpeed(upload); markDone(document.getElementById('cardUpload')); - // 4. 展示 IP(服务器识别,单向伪装后为真实公网 IP) + // 4. 展示 IP 与地区(页面上方徽标;排行榜不展示 IP) try { const pr = await fetch('/api/ping?t=' + Date.now(), { cache: 'no-store' }); - showMyIP((await pr.json()).client_ip || ''); + const pi = await pr.json(); + showMyIP(pi.client_ip || '', pi.location || ''); } catch (e) { /* ignore */ } // 5. 提交(记录我的成绩 ID,用于排行榜高亮)