From c97d3150c90bc0188f22e7113b636dafcb1188e0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 12 Sep 2026 18:30:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E5=B1=80=E5=9F=9F=E7=BD=91?= =?UTF-8?q?=E5=A4=A7=E5=B1=8F=E5=8D=A1=E7=89=87=E6=8C=89=E3=80=8C=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E7=B1=BB=E5=88=AB=E3=80=8D=E5=88=A4=E5=AE=9A=20+=20?= =?UTF-8?q?=E4=B8=89=E5=B1=82=E5=85=9C=E5=BA=95=20+=20=E6=89=8B=E5=A1=AB?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88dev=5F0.5.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 判断失误的修正(主人现场实测出来的) 主人现场是 **169.254 自动配置网段**(没有 DHCP,设备靠链路本地地址互访), 平板实测能访问 `http://169.254.79.190:19540/display`;而卡片一开始把链路本地地址 **排除**了,于是显示「未检测到局域网地址」。 根因:`usableLanIPv4` 排除了 `IsLinkLocalUnicast()`,理由写的是「169.254 说明没拿到 IP」 ——那是**对网络健不健康的猜测**,不是**地址能不能用的事实**。 现改为按**地址类别**判定(`ipv4Scope`,全部用标准库谓词,**代码里没有任何网段字面量**): 只排除「作为对端访问目标没有意义」的四类(回环 / 未指定 / 组播 / 广播), 链路本地与私网、全局一样进候选,只**排在最后**并带说明 「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」。 新增**反硬编码护栏用例**:函数体内不得出现 `x.x.x.x` 字面量,且必须用满 6 个谓词。 顺带修掉新用例抓出的真 bug:`ipv4Scope(nil)` 曾落进 default 被当成「可用的 global」, 而 IPv6 的 `To4()` 正是 nil → 「只有 IPv6 的网卡」会被误判成有可用地址。 ## 三层兜底(离线现场必须拿得到地址) 1. **严格轮**:接口 up 且非回环; 2. **宽松轮**:严格轮为空时不再要求 up 位(某些驱动/虚拟网卡读不到),命中标 `confidence: low`; 3. **默认路由探测**:两轮皆空时用 `net.Dial("udp4", …)` 问出本机源地址 (UDP connect 只查路由表、不握手不发包,离线安全;TCP 会等 SYN 超时,不能用),标 `confidence: route`。 接口同时回 `scope`(private/global/link-local)与 `skipped`(被跳过的网卡 + 原因, code 收敛为 no-address / only-ipv6 / not-up / loopback——link-local 不再是「被跳过」的理由)。 ## 手填入口(最后一道保险) 自动检测不到、或不是现场要用的网段时,操作员把 `ipconfig` 里的地址填进卡片, 立刻生成链接 + 二维码;排在第一条、带「手动」徽标、可删除、localStorage 持久化、 四条就地校验文案(空/含空格/不像 IP/重复),IPv6 也支持。 ## 自检工具 `debug/诊断/局域网地址自检.exe` + 说明:只读、不写文件、不需管理员, 打印系统给 Go 看的网卡列表(含类别)、默认路由探测结果与可用大屏地址。 (正是它把现场定性成 169.254 网段的。) ## 真浏览器验证(四场景,非源码断言) 链接本地地址出链接+二维码+说明句 / 空态列出被跳过网卡并折叠(5 条 + 等共 N 块)/ low 与 route 两种来源提示各占一行互不顶掉 / 手填地址排第一并刷新不丢 —— 全部通过,0 页面错误。 ## 文档 使用手册 §9、部署指南 7.5/7.9、上线检查清单(**防火墙放行入站 19540** 与 「另一台设备能打开局域网大屏」两条新增)、常见问题 Q101、升级说明 dev_0.5.0; devdocs 05/07/13 同步(含这次误判的来龙去脉与「Get-NetAdapter 显示 Up ≠ 有 IP」)。 **没有数据迁移**,旧赛事包照常导入。 自检:服务端 13 包 ×2;前端 vue-tsc + 680 例 ×2 + build;Console 20 文件 + 68 例。 --- VERSION | 2 +- console/package.json | 2 +- docs/上线检查清单.md | 4 + docs/使用手册.md | 10 +- docs/升级说明.md | 54 +- docs/常见问题.md | 10 + docs/部署指南.md | 26 + server/internal/httpapi/network.go | 528 +++++++++++- .../internal/httpapi/network_internal_test.go | 764 +++++++++++++++++- server/internal/httpapi/network_test.go | 45 +- server/main.go | 4 + web/src/admin/pages/DisplayPage.vue | 141 +++- web/src/admin/pages/lanDisplay.ts | 266 +++++- web/src/admin/tests/lan-display-card.test.ts | 83 +- web/src/admin/tests/lan-display.test.ts | 391 ++++++++- web/src/shared/api.ts | 32 + web/src/shared/tests/network-api.test.ts | 84 +- web/src/shared/types.ts | 58 +- 18 files changed, 2425 insertions(+), 79 deletions(-) diff --git a/VERSION b/VERSION index eeebe8a..83c008d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.4.0 +dev_0.5.0 diff --git a/console/package.json b/console/package.json index 161eb5b..0828522 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.4.0", + "version": "0.5.0", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index 6e3c88b..2b9a6dd 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -17,6 +17,9 @@ - [ ] Windows 10/11 x64,接市电(建议接 UPS) - [ ] 端口 **19540** 未被占用:`netstat -ano | findstr 19540` +- [ ] **防火墙已放行入站 19540**(局域网设备才能连上):在**管理员** cmd 里执行 + `netsh advfirewall firewall add rule name="Scoreboard 19540" dir=in action=allow protocol=TCP localport=19540` + —— 安装包是**按用户安装、不申请管理员提权**,改不了防火墙,这一步必须手动做一次;临时「关闭防火墙」只用于**验证**是不是它挡的,验证完要恢复;正式开赛前请用上面这条命令放行,不要一直关着防火墙(改过端口就把 `19540` 换成实际端口) - [ ] 数据/配置目录可写:默认 `%LOCALAPPDATA%\ScoreboardConsole\`(数据在 `data\`,配置与规则是 `console\config.json`、`config.yaml`、`rules\`;都在安装目录之外,升级/卸载都不会删),需要放行杀毒/防火墙 - [ ] 大屏通过 HDMI 接在**同一台机器**上(系统显示模式设为「扩展」) - [ ] 显示器数量确认:只接一块屏时大屏只能窗口化(按 F11 可切全屏);要每屏一个 kiosk 全屏窗口就得接第二块屏(见部署指南 7.3、7.5) @@ -57,6 +60,7 @@ - [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」;窗口化窗口(单屏)里显示这一行属正常,按 F11 全屏后它与自绘标题栏一起消失(版本号角标已移除,任何画面都不该出现) - [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常) - [ ] **在线大屏与逐屏切页**:同页右侧「在线大屏」列表能看到本机每块屏的在线实例(局域网设备也已在这一列表里,标记为「局域网浏览器」),每行显示当前所在页;用某一行自己的下拉 +「切到此页」能把那一块屏 / 那台设备单独切走,其余屏不受影响(离线实例的控件为灰色,属正常) +- [ ] **局域网大屏能在别的设备打开**:用另一台设备(平板 / 手机)浏览器打开 `http://<主机IP>:19540/display` 能出画面,或直接扫「大屏控制 → 局域网大屏」卡片第一个地址的**二维码**;打不开时先看卡片列出的地址与提示,再按常见问题「大屏控制页显示未检测到局域网地址怎么办?」逐条排查(若卡片地址是 `169.254.x.x`,说明本网段没有 DHCP,同网段的设备照样能连) - [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」 - [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏) - [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布 → 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布) diff --git a/docs/使用手册.md b/docs/使用手册.md index 8c48ac6..61e97af 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -430,7 +430,15 @@ - 卡片右上角徽标显示「N 个可用地址」;每个地址一行,**点链接**直接在新标签页打开,点旁边的**复制**按钮把完整地址(形如 `http://192.168.1.5:19540/display`)复制到剪贴板,按钮会短暂变成「已复制」; - **第一个地址带一个二维码**(下方注明「手机扫码直接打开」),手机相机一扫即可打开,省去手敲长地址; -- **空态**:显示「未检测到局域网地址(可能是没插网线 / 只有回环)。」——意思是主机当前没有别的设备能用上的地址(网线没插、网卡没起来,或只剩本机回环地址),先在 Windows 网络设置里确认主机已连上局域网;如果显示的是「服务端没有下发大屏端口,暂时拼不出能在别的设备打开的地址。」,那是服务端异常,重启 Console / 服务端后再看; +- **地址分三类**,卡片会照实把可用地址都列出来(排除的只有回环、未指定、组播、广播这四类对本机之外没有意义的地址): + - **内网地址**(`192.168.x.x`、`10.x.x.x`、`172.16–31.x.x` 这类):路由器 / 交换机分配或手工设好的地址,也是最常见的现场用法,**排在最前**; + - **全局地址**:不在上面这些网段里的地址(运营商专线等),能用就照常列出来,只是**排在私网地址之后**; + - **自动配置地址**(`169.254` 开头):说明**本网段没有 DHCP**、地址是设备自己配的——在**独立小网络**(没有路由器、设备直连或只接一台交换机)里这是**正常用法**,同网段设备可以互相访问。这类地址**排在最后**,旁边有一句「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」; +- **两种「不那么确定」的来源会各带一句提示,照常能用**(链接与二维码照给,只是说清这条是怎么来的,打不开时换下一条): + - 网卡状态读不到(驱动或虚拟网卡)时:「网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条」; + - 按系统默认路由推断出来时:「这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条」; +- **手动添加地址**:自动检测不到、或者列出来的不是你要的那个网段时,用卡片下方的「手动添加地址」(说明「自动检测不到时,把 ipconfig 里的 IPv4 地址填进来」)把 `ipconfig` 里那个地址填进去、点「添加」——**立刻出链接和二维码**。手填的**排在第一条**、带「手动」徽标,右边多一个「删除」可以撤掉;它记在浏览器本地(localStorage),**刷新不丢**,最多记 10 个。填错不会没反应,而是就地红字说明:没填「请先填一个 IP 地址」、带空格「地址里不能有空格,请重新复制一次 ipconfig 里的地址」、不像地址「这不像一个 IP 地址,请填 ipconfig 里的 IPv4 地址」、重复「这个地址已经在列表里了」; +- **空态**:会**列出检查过哪些网卡、各自为什么被跳过**,例如「没找到可用的局域网地址。已检查:以太网(接口未连接)、WLAN(只有 IPv6 地址)。」——原因有「**接口未连接**」「**只有 IPv6 地址**」「**接口没有地址**」「**回环接口**」四种,最多列 5 条,网卡更多时其余折叠成「**等共 N 块网卡**」。老版本服务端不返回这份清单时,显示的仍是「未检测到局域网地址(可能是没插网线 / 只有回环)。」;如果显示的是「服务端没有下发大屏端口,暂时拼不出能在别的设备打开的地址。」,那是服务端异常,重启 Console / 服务端后再看; - 卡片底部注明「地址由服务端下发的端口与路径拼成;平板上打开后不必登录,大屏本身是只读的」——**平板 / 电视那边不用登录**,只有看这张卡片(也就是本管理面板)才需要身份; - **权限**:这张卡片需要**录入员(recorder)及以上**登录才看得到,只读访客(guest)看到的是「只有登录后的录入员 / 管理员能查看局域网地址,当前为只读访客。」;账号权限不足时提示「当前账号没有查看权限,请先登录(该接口需要录入员及以上)。」; - 复制失败(浏览器不允许自动复制)时会提示「复制失败 / 浏览器不允许自动复制,请手动选中地址复制」,手动选中地址复制即可。 diff --git a/docs/升级说明.md b/docs/升级说明.md index a22f4a3..74df62a 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,7 +6,59 @@ --- -## dev_0.4.0(当前版本) +## dev_0.5.0(当前版本) + +### 一句话 + +**没有数据迁移,旧赛事项目包照常导入**:本版改的是「大屏控制 → 局域网大屏」卡片的取证逻辑——地址改为**按地址类别**判定(`169.254` 自动配置地址不再被排除),并新增**宽松兜底 / 默认路由推断 / 手动添加地址**三层,空态会说明被跳过的网卡与原因。以前卡片会把「自动配置地址」当成没有可用地址,现已修正。 + +### ① 局域网大屏卡片改为按地址类别判定 + +- **以前**:只取「接口 Up 且非回环」的网卡,并把**链路本地地址(`169.254.x.x`)也排除**,理由写的是「DHCP 没拿到地址才自配的,说明网络不健康」——那是对网络健不健康的**猜测**,不是地址能不能用的**事实**。 +- **现场形态**:有机器就在 **`169.254` 自动配置网段**(本网段没有 DHCP,设备靠链路本地地址互访),平板实测能访问 `http://169.254.79.190:19540/display`,网络与防火墙全通;而卡片却因为那条猜测显示「未检测到局域网地址」——**那是误判**。 +- **现在**:只排除四类「作为对端访问目标没有意义」的地址——**回环、未指定(`0.0.0.0`)、组播、广播**;**链路本地(自动配置)地址与私网、全局地址一样进候选**,只是**排在最后**并带一句说明「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」。判定全部走标准库谓词,**不抄一份网段字面量到代码里**(抄错或漏一段就会变成「明明有地址却说没有」,正是这次要修的毛病)。 + +### ② 新增三层兜底(宽松兜底 / 默认路由推断 / 手动添加地址) + +| 层次 | 触发时机 | 卡片表现 | +| --- | --- | --- | +| 严格轮 | 正常机器(网卡 up 位读得到) | 地址照常列出,不加提示 | +| **宽松兜底** | 严格轮一无所获时(Windows 上部分驱动 / 虚拟网卡**读不到 up 位**) | 不再要求 up 位,把地址捡回来并提示「网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条」 | +| **默认路由推断** | 前两轮都为空时 | 问操作系统「去局域网走哪块网卡」(UDP Dial:只查路由表、**不发一个包**),地址提示「这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条」 | +| **手动添加地址** | 以上都没给出你要的网段时 | 卡片下方「手动添加地址」填入 `ipconfig` 里的 IPv4 地址,点「添加」立刻出链接与二维码;手填的**排第一条**、带「手动」徽标、可「删除」,记在浏览器本地**刷新不丢**(最多 10 个) | + +严格轮有结果就绝不跑宽松轮,前两轮任意一轮有结果就绝不跑路由探测——只有真的一个地址都拿不到时才会多花那几次本地 UDP connect,正常机器上一次都不会发生。 + +### ③ 空态会说明被跳过的网卡与原因 + +- 空态从一句「未检测到局域网地址(可能是没插网线 / 只有回环)。」改成逐条列出**检查过、但没贡献地址**的网卡与原因,例如「没找到可用的局域网地址。已检查:以太网(接口未连接)、WLAN(只有 IPv6 地址)。」;四种原因是「接口未连接」「只有 IPv6 地址」「接口没有地址」「回环接口」,**最多列 5 条**,网卡更多时其余折叠成「**等共 N 块网卡**」; +- 老服务端不返回这份清单时,显示的仍是原来那句,行为与升级前完全一致。 + +### ④ 没有数据迁移 + +- 本版**没有任何数据库迁移**,也没有设置项迁移;旧赛事项目包照常导入、切换、导出,已有榜单数字与升级前一致。 + +### ⑤ 升级后 3 分钟核对 + +1. 启动 Console,打开 `logs\console.log`:没有意外报错;正常应有一行「局域网地址:…(网卡名)」,一个地址都没有时会是一条带原因的 WARN,方便直接看出该修哪张网卡; +2. 用录入员及以上账号进「大屏控制」页,确认「局域网大屏」卡片列出地址;只有 `169.254.x.x` 的机器上应能看到它、并带「自动配置地址」那句说明; +3. 用平板 / 手机扫第一个地址的二维码,确认能打开大屏; +4. 自动检测为空时试一次「手动添加地址」(填 `ipconfig` 里的地址),确认立刻出链接与二维码; +5. 局域网设备连不上的机器,按《部署指南》7.5 的命令放行入站 **19540**; +6. 用《上线检查清单》过一遍。 + +### ⑥ 详细说明在哪 + +| 想了解 | 看 | +| --- | --- | +| 三类地址的含义、手动添加地址 | 《使用手册》第 9 节 | +| 防火墙放行与排障常识 | 《部署指南》7.5、7.9 | +| 空态原因与自检工具 | 《常见问题》Q101 | +| 接口字段与判定顺序 | `devdocs/05` 大屏控制与运维 | + +--- + +## dev_0.4.0 ### 一句话 diff --git a/docs/常见问题.md b/docs/常见问题.md index bf17b89..d732994 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -679,3 +679,13 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 新录的这条会成为该小项该级别的**当前纪录**,原来那条自动变成非当前(仍留在库里)。纪录库的 **「来源」列**因此有了两种值:**历史** = 赛前手工登记的旧资料,**本届** = 本场赛事批准破纪录时自动写入的(以前没有历史录入入口,实际只会看到「本届」)。 **删除**:每行右端有「删除」(需**录入员及以上**)。确认框会说明「若它是当前纪录,会恢复上一条」,并注明「删除只影响纪录库,不会改动任何已发布的成绩与积分」;删除不会触发重算。 + +**Q101:大屏控制页显示「未检测到局域网地址」怎么办?** + +先说结论:这多半不是故障,而是主机此刻没有「别的设备能连上」的地址被系统枚举出来。按下面顺序排查: + +1. **先看卡片列出的「已检查」原因**:新版空态会逐条列出检查过的网卡与各自被跳过的原因,例如「没找到可用的局域网地址。已检查:以太网(接口未连接)、WLAN(只有 IPv6 地址)。」——原因有「**接口未连接**」「**只有 IPv6 地址**」「**接口没有地址**」「**回环接口**」四种;最多列 5 条,网卡更多时其余折叠成「**等共 N 块网卡**」。照着原因去修那一张网卡(插好网线 / 给它一个 IPv4 地址 / 看看 DHCP)。如果看到的仍是旧句「未检测到局域网地址(可能是没插网线 / 只有回环)。」,说明这台机器上的前后端还没更新到最新版,按下面几步照样能排查; +2. **`ipconfig` 确认有没有 IPv4 地址**:在 cmd / PowerShell 里跑 `ipconfig`。注意 **Windows 里「网线插着、网卡显示 Up」不等于拿到了 IP**;如果只看到 `169.254.x.x`,说明**本网段没有 DHCP**,地址是设备自配的——这在**独立小网络**(没有路由器、设备直连或只接一台交换机)里是**正常用法,不是故障**,同网段的平板 / 手机照样能访问; +3. **手动添加地址**:自动检测不到、或者列出来的不是你要的那个网段时,把 `ipconfig` 里那条 IPv4 地址填进卡片下方的「手动添加地址」再点「添加」——立刻出链接和二维码(排第一条、带「手动」徽标、刷新不丢,最多 10 个); +4. **放行防火墙入站 19540**:地址对了、别的设备还是打不开,多半是主机防火墙挡了。在**管理员** cmd 里执行 `netsh advfirewall firewall add rule name="Scoreboard 19540" dir=in action=allow protocol=TCP localport=19540`(改过端口就换成实际端口);临时关闭防火墙只用于**验证**是不是它挡的,验证完要恢复; +5. **还不行就跑自检**:运行 `debug\诊断\局域网地址自检.exe`,把它的输出发回给我们。 diff --git a/docs/部署指南.md b/docs/部署指南.md index 2519f6d..bd02dee 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -388,6 +388,31 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da 副屏的全屏窗口被误关时,用这里的「创建全屏窗口」就能恢复,不必重启 Console。 +**「局域网大屏」卡片:怎么拿到地址(两条路)** + +1. **卡片自动检测**:「大屏控制 → 局域网大屏」卡片会列出主机上可用的地址,直接点开或扫码;第一个地址(也就是二维码那一条)就是现场最可能要用的; +2. **`ipconfig` 手填**:自动检测不到、或者列出来的不是你那张网卡时,在管理员 cmd / PowerShell 里跑 `ipconfig`,把对应网卡的 **IPv4 地址**抄进卡片下方的「手动添加地址」再点「添加」——手填的排在第一条、带「手动」徽标,记在浏览器本地,刷新不丢(最多 10 个,可「删除」)。 + +**三类地址的含义**(卡片按类别判定,只排除回环 / 未指定 / 组播 / 广播这四类对本机之外没有意义的地址): + +| 类别 | 形如 | 现场含义 | 排序 | +| --- | --- | --- | --- | +| 内网地址 | `192.168.x.x`、`10.x.x.x`、`172.16–31.x.x` | 路由器 / 交换机分配或手工设的地址,最常用的现场形态 | 最前 | +| 全局地址 | 其它网段(运营商专线等) | 能用就照常列出,但多半要经中间设备才通,不一定平板能直连 | 居中 | +| **自动配置地址** | **`169.254.x.x`** | **本网段没有 DHCP**,地址是设备自己配的;在独立小网络(没路由器、设备直连或只接交换机)里是**正常用法**,同网段设备可互相访问 | 最后 | + +另外两种来源会各带一句提示、**照常能用**:网卡状态读不到时提示「网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条」;按系统默认路由推断出来时提示「这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条」。 + +**防火墙必须放行入站 19540**(默认端口,改过端口就换成实际端口)。在**管理员** cmd 里跑: + +```bat +netsh advfirewall firewall add rule name="Scoreboard 19540" dir=in action=allow protocol=TCP localport=19540 +``` + +为什么安装包不能替你加这条:安装包是**免管理员的按用户安装**(默认装到当前用户目录),**不申请管理员提权**,所以改不了系统防火墙规则;这一步必须由现场用管理员身份手动做一次。临时关掉防火墙只能用来**验证**「是不是防火墙挡的」,验证完要恢复;正式开赛前一定用上面这条命令放行,而不是一直关着防火墙。 + +**排障常识**:Windows 里「网线插着、网卡显示 Up」**不等于拿到了 IP**;`ipconfig` 里看到 `169.254.x.x` 说明**本网段没有 DHCP**(地址是设备自配的)——这在**独立小网络**里是正常用法,**不是故障**,同网段的平板 / 手机照样能访问。 + ### 7.6 公告 管理端「大屏控制 → 公告」推送后: @@ -421,6 +446,7 @@ http://<主机IP>:19540/display - 这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接**与**二维码**(第一个地址),别的设备扫码或在浏览器里粘贴即可打开(该卡片需录入员及以上登录才看得到); - 需要主机设置固定 IP,且设备与主机在同一局域网; +- **主机防火墙要放行入站 19540**(默认端口):安装包是按用户安装、不申请管理员提权,改不了防火墙规则,需要在**管理员** cmd 里手动执行一次,命令与说明见 7.5; - 安卓电视一般常亮,无需额外防熄屏; - 浏览器方案**不受** Console 的 kiosk、防熄屏、选屏控制,字体与动画表现以实际设备为准; - 现场以 Console + HDMI 为主方案,浏览器访问作为备用。 diff --git a/server/internal/httpapi/network.go b/server/internal/httpapi/network.go index f9e69e2..031cd76 100644 --- a/server/internal/httpapi/network.go +++ b/server/internal/httpapi/network.go @@ -2,9 +2,11 @@ package httpapi import ( "bytes" + "log/slog" "net" "net/http" "sort" + "strings" ) // 大屏控制页要把「当前设备的局域网地址」显示成可点链接 + 二维码:面板自己往往是从 @@ -26,14 +28,70 @@ type lanAddress struct { IP string `json:"ip"` Interface string `json:"interface"` Family string `json:"family"` + // Confidence 说明这条地址是怎么来的、有多可信: + // - high:严格轮从网卡枚举里挑出来的(接口 up 位读得到); + // - low :宽松轮兜底捡回来的(网卡 up 位读不到,见 lanAddresses); + // - route:网卡枚举一分钱都没给,靠系统默认路由推断出来的源地址(第三层来源)。 + // 老版本前端会直接忽略这个字段,加上不会破坏契约。 + Confidence string `json:"confidence"` + // Scope 说明这条地址属于哪一类(private / global / link-local,见 ipv4Scope)。 + // 与 Confidence 正交:scope 说的是「这是什么地址」,confidence 说的是「怎么找到的」。 + // 前端据此给链路本地地址补一句说明,而不是自己去猜 IP 前缀。 + Scope string `json:"scope"` } +// 地址置信度取值。low 只在宽松轮出现,route 只在第三层(默认路由探测)出现。 +const ( + confidenceHigh = "high" + confidenceLow = "low" + confidenceRoute = "route" +) + +// 地址类别取值(lanAddress.Scope)。 +const ( + scopePrivate = "private" + scopeGlobal = "global" + scopeLinkLocal = "link-local" +) + +// routeInterfaceLabel 是路由探测那条地址的 Interface 取值。 +// +// 它不是任何一张网卡的名字:这条地址压根不是从网卡枚举来的,写一个假的网卡名会让排查的人 +// 去系统里找一张不存在的网卡。写清来源,接口本身就能自解释(前端不展示这个字段)。 +const routeInterfaceLabel = "默认路由" + +// skippedInterface 解释「一张网卡为什么没贡献地址」。 +// +// 它只用于把空态换成有信息量的话(前端按 code 映射中文),**不参与地址列表的生成**: +// 淘汰谁、留下谁完全由 lanAddresses 的两轮筛选决定,这里是从同一份快照上得出的旁白。 +// 两者若互相影响(例如「被这里标了 not-up 的地址就不列」),空态原因和地址列表就会打架。 +type skippedInterface struct { + Interface string `json:"interface"` + Code string `json:"code"` + Addresses []string `json:"addresses"` +} + +// 跳过原因。取值固定,前端与启动日志各有一份中文映射。 +// +// 一张网卡可能同时命中多个原因(例如既没置 up、又只有 IPv6 地址),此时只给最能解释 +// 问题的那一个。优先级见 skipCode:no-address > only-ipv6 > not-up > loopback。 +// +// 注意这里**没有** link-local:链路本地地址现在会被收下(见 ipv4Scope), +// 它不再是「被跳过」的理由。一张 down 掉、只配了链路本地地址的网卡,真实原因是 not-up。 +const ( + skipNoAddress = "no-address" + skipOnlyIPv6 = "only-ipv6" + skipNotUp = "not-up" + skipLoopback = "loopback" +) + // networkInfo 是 GET /api/system/network 的响应体。 type networkInfo struct { - Port int `json:"port"` - DisplayPath string `json:"displayPath"` - AdminPath string `json:"adminPath"` - Addresses []lanAddress `json:"addresses"` + Port int `json:"port"` + DisplayPath string `json:"displayPath"` + AdminPath string `json:"adminPath"` + Addresses []lanAddress `json:"addresses"` + Skipped []skippedInterface `json:"skipped"` } // ifaceSnapshot 是 net.Interface 里本接口用得上的那部分。 @@ -47,13 +105,12 @@ type ifaceSnapshot struct { Addrs []net.Addr } -// snapshotInterfaces 读一次本机网卡。读不到(极罕见)时返回空切片: -// 前端据此显示「未检测到局域网地址」,比报错更接近事实——服务本身是好的。 -func (s *server) snapshotInterfaces() []ifaceSnapshot { +// readInterfaces 读一次本机网卡。读不到(极罕见)时回错误,由调用方决定怎么记: +// HTTP 路径要降级成空列表(前端显示空态),启动日志那边要换成一条 WARN。 +func readInterfaces() ([]ifaceSnapshot, error) { list, err := net.Interfaces() if err != nil { - s.logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err) - return nil + return nil, err } out := make([]ifaceSnapshot, 0, len(list)) for _, iface := range list { @@ -64,12 +121,173 @@ func (s *server) snapshotInterfaces() []ifaceSnapshot { } out = append(out, ifaceSnapshot{Name: iface.Name, Flags: iface.Flags, Addrs: addrs}) } + return out, nil +} + +// snapshotInterfaces 读一次本机网卡,失败时只记日志并回空切片: +// 前端据此显示「未检测到局域网地址」,比报错更接近事实——服务本身是好的。 +func (s *server) snapshotInterfaces() []ifaceSnapshot { + list, err := readInterfaces() + if err != nil { + s.logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err) + return nil + } + return list +} + +// dialProbe 与 net.Dial 同形(返回值收窄到本文件真正用到的两个方法),测试注入假实现用。 +type dialProbe func(network, address string) (localAddrConn, error) + +// localAddrConn 是探测只需要的那一点能力:问出本地地址,然后关掉。 +type localAddrConn interface { + LocalAddr() net.Addr + Close() error +} + +// routeProbe 是第三层来源的可注入形式:回一组「系统默认路由用的源地址」。 +// nil 表示这一层不跑(启动日志那条路径就是这样:它跟服务启动在同一个 goroutine 上, +// 不该为了记一行日志去碰网络栈)。 +type routeProbe func() []net.IP + +// routeProbeTargets 是默认路由探测的目标,顺序即尝试顺序。 +// +// 内网网关放在前面:现场要的正是「去局域网走哪块网卡」。末尾两个公网地址是兜底—— +// 机器没配默认网关(只有一条去公网的路由,或走的是手机热点 / 4G 网卡)时,问公网地址一样能 +// 让操作系统给出源地址,而那台机器的局域网地址通常就在同一张网卡上。 +// 端口随便写(9 / 53):UDP 下这两个数只用于构造目标地址,一个包都不会发出去。 +var routeProbeTargets = []string{ + "192.168.0.1:9", + "192.168.1.1:9", + "10.0.0.1:9", + "172.16.0.1:9", + "8.8.8.8:53", + "223.5.5.5:53", +} + +// defaultDialProbe 是生产环境的 dialProbe,就是 net.Dial 本身。 +func defaultDialProbe(network, address string) (localAddrConn, error) { + return net.Dial(network, address) +} + +// probeDefaultRoute 是生产环境的 routeProbe:拿 UDP Dial 去问操作系统。 +func probeDefaultRoute() []net.IP { + return probeRouteSourceIPs(defaultDialProbe, routeProbeTargets) +} + +// probeRouteSourceIPs 让操作系统自己回答「去这些目标会走哪块网卡」,取 Dial 的本地地址。 +// +// 为什么 UDP 的 Dial 在完全离线的现场也安全(这是这一层敢放在请求路径里的唯一理由): +// UDP 是「无连接」的,connect() 只做两件本地事——查一次路由表、把本地地址绑好,**不握手、 +// 不发任何数据包**,所以对端在不在、网通不通都不影响它立刻返回;失败也是本地立刻失败 +// (没有去那个网段的路由时直接回 ENETUNREACH)。换成 TCP 就得等 SYN 超时,没网的现场会把 +// 这次请求拖住好几秒。也正因为它不发包,端口号在这里只是占位,写什么都一样。 +// +// 单个目标失败就跳过:没有默认路由的机器会把 6 个目标各试一次,各自立刻失败,代价可忽略。 +func probeRouteSourceIPs(dial dialProbe, targets []string) []net.IP { + if dial == nil { + return nil + } + out := make([]net.IP, 0, len(targets)) + seen := make(map[string]struct{}, len(targets)) + for _, target := range targets { + conn, err := dial("udp4", target) + if err != nil { + continue + } + if addr := conn.LocalAddr(); addr != nil { + if host, _, err := net.SplitHostPort(addr.String()); err == nil { + // 多个目标通常落在同一张网卡上:按地址去重,前端别看到重复的一条。 + if ip4 := net.ParseIP(host).To4(); ip4 != nil { + if _, dup := seen[ip4.String()]; !dup { + seen[ip4.String()] = struct{}{} + out = append(out, ip4) + } + } + } + } + _ = conn.Close() + } return out } -// lanAddresses 从网卡快照里挑出可用的局域网 IPv4 地址,按「现场最可能用的那个」排在最前。 +// routeAddresses 跑第三层:探测 + 过滤,产出可直接下发的地址。 +// 过滤口径与前两轮完全一致(ipv4Scope):探测回回环 / 未指定 / 组播 / 广播照样丢掉 +// ——「系统说会走这块网卡」不等于「这个地址别的设备连得上」,比如只有回环路由的开发机。 +// 链路本地与私网一样照收(scope 如实标出来),主人现场正是靠链路本地地址互访的那种网络。 +func routeAddresses(probe routeProbe) []lanAddress { + // 一开始就分配好:探测没得用、或探测回空时也必须给非 nil 空切片, + // 否则 JSON 会下发 null,前端分不清「没有地址」和「服务端没给这个字段」。 + out := make([]lanAddress, 0, 2) + if probe == nil { + return out + } + seen := make(map[string]struct{}) + for _, ip := range probe() { + ip4 := ip.To4() + if ip4 == nil { + continue + } + scope, usable := ipv4Scope(ip4) + if !usable { + continue + } + key := ip4.String() + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, lanAddress{ + IP: key, + Interface: routeInterfaceLabel, + Family: "ipv4", + Confidence: confidenceRoute, + Scope: scope, + }) + } + sortLanAddresses(out) + return out +} + +// lanAddresses 从网卡快照里挑出可用的局域网 IPv4 地址,按「现场最可能用的那个」排在最前; +// 同时给出被淘汰网卡的原因(只用于解释空态,不参与挑地址)。 +// +// 三层来源,逐层降级,前一层有结果就绝不跑后一层: +// 1. 严格轮:接口置了 up 位的网卡(正常机器都走这里); +// 2. 宽松轮:不要求 up 位(Windows 上 up 位读不到时的兜底); +// 3. 默认路由探测:网卡枚举一份地址都没给时,直接问操作系统「去局域网走哪块网卡」。 +// +// 为什么要有宽松轮: +// 严格轮要求接口置了 up 位——正常情况下这是对的,网线没插的接口确实连不上。但 Windows 上 +// 这个位映射自 IfOperStatusUp,部分驱动 / 虚拟网卡 / 特定连接方式下**读不到 up 位**(接口 +// 实际是通的,标志位却是 0)。只要机器上有一张这样的网卡,整台机器就会被判成「一个可用地址 +// 都没有」,卡片只给一句没有信息量的空话——而局域网明明是好的。 +// 所以严格轮一无所获时再跑一轮不要求 up 位的宽松轮:只是把「可能能用」的地址捡回来并标成 +// low 交给现场判断,比把所有地址都藏起来强得多(真连不上,现场换一条就是了)。 +// 严格轮有结果就绝不跑宽松轮:宁可少给几条,也不要让一张下线的虚拟网卡混进列表里。 +// +// 为什么还要第三层(probe): +// 现场出现过单块 Intel 网卡(Windows 里 Get-NetAdapter 明明白白是 Up)两轮都筛不出东西的机器 +// ——接口枚举这条路在那台机器上就是不通。此时让操作系统自己回答「去局域网会走哪块网卡」是唯一 +// 还能拿到地址的办法,代价只是几次本地的 UDP connect(不发包,见 probeRouteSourceIPs)。 +// **只有前两轮都为空才会走到这里**:正常机器上连一次 Dial 都不会发生,这条路径也就不可能 +// 拖慢正常的请求,更不会在启动时打网络栈(启动日志传的就是 nil)。 +// // 结果永远非 nil:没有任何可用地址时返回空切片,前端才知道这是「没有」而不是「没拿到字段」。 -func lanAddresses(ifaces []ifaceSnapshot) []lanAddress { +func lanAddresses(ifaces []ifaceSnapshot, probe routeProbe) (addresses []lanAddress, skipped []skippedInterface) { + strict := collectLanAddresses(ifaces, usableInterface, confidenceHigh) + if len(strict) > 0 { + return strict, skippedInterfaces(ifaces, strict) + } + lenient := collectLanAddresses(ifaces, fallbackInterface, confidenceLow) + if len(lenient) > 0 { + return lenient, skippedInterfaces(ifaces, lenient) + } + routed := routeAddresses(probe) + return routed, skippedInterfaces(ifaces, routed) +} + +// collectLanAddresses 跑一轮筛选:keep 决定哪些网卡参与本轮,confidence 打在挑出来的地址上。 +func collectLanAddresses(ifaces []ifaceSnapshot, keep func(net.Flags) bool, confidence string) []lanAddress { // 候选先带上 4 字节形式:去重、排序用得到,且避免在比较器里反复做 To4。 type candidate struct { entry lanAddress @@ -78,12 +296,16 @@ func lanAddresses(ifaces []ifaceSnapshot) []lanAddress { seen := make(map[string]struct{}) cands := make([]candidate, 0, len(ifaces)) for _, iface := range ifaces { - if !usableInterface(iface.Flags) { + if !keep(iface.Flags) { continue } for _, raw := range iface.Addrs { ip4 := addrIP(raw).To4() - if ip4 == nil || !usableLanIPv4(ip4) { + if ip4 == nil { + continue + } + scope, usable := ipv4Scope(ip4) + if !usable { continue } key := ip4.String() @@ -94,23 +316,20 @@ func lanAddresses(ifaces []ifaceSnapshot) []lanAddress { } seen[key] = struct{}{} cands = append(cands, candidate{ - entry: lanAddress{IP: key, Interface: iface.Name, Family: "ipv4"}, - ip4: ip4, + entry: lanAddress{ + IP: key, + Interface: iface.Name, + Family: "ipv4", + Confidence: confidence, + Scope: scope, + }, + ip4: ip4, }) } } sort.Slice(cands, func(i, j int) bool { - a, b := cands[i], cands[j] - if ra, rb := lanRank(a.ip4), lanRank(b.ip4); ra != rb { - return ra < rb - } - // 同档按地址数值排序:同一台机器每次刷新都要给出同样的顺序, - // 否则列表第一个地址会跳来跳去,现场刚记住的链接下次就变了。 - if c := bytes.Compare(a.ip4, b.ip4); c != 0 { - return c < 0 - } - return a.entry.Interface < b.entry.Interface + return lanLess(cands[i].entry, cands[i].ip4, cands[j].entry, cands[j].ip4) }) out := make([]lanAddress, 0, len(cands)) @@ -120,7 +339,130 @@ func lanAddresses(ifaces []ifaceSnapshot) []lanAddress { return out } -// usableInterface 判断一张网卡能不能贡献地址: +// lanLess 是地址排序的比较器:档位(越像现场在用的内网段越靠前)→ 地址数值 → 接口名。 +// 网卡枚举与路由探测两条来源共用它,两边的顺序才是一致的、可预期的。 +// +// 同档按地址数值排序:同一台机器每次刷新都要给出同样的顺序, +// 否则列表第一个地址会跳来跳去,现场刚记住的链接下次就变了。 +func lanLess(a lanAddress, aIP net.IP, b lanAddress, bIP net.IP) bool { + if ra, rb := lanRank(aIP), lanRank(bIP); ra != rb { + return ra < rb + } + if c := bytes.Compare(aIP, bIP); c != 0 { + return c < 0 + } + return a.Interface < b.Interface +} + +// sortLanAddresses 按同一套比较器就地排序。 +// 这里重新解析一次 IP 文本:调用点(路由探测)手里只有字符串形式的地址, +// 而它的 IP 都是 net.ParseIP + ipv4Scope 校验过的,To4 一定拿得到。 +func sortLanAddresses(list []lanAddress) { + sort.Slice(list, func(i, j int) bool { + a, b := list[i], list[j] + return lanLess(a, net.ParseIP(a.IP).To4(), b, net.ParseIP(b.IP).To4()) + }) +} + +// skippedInterfaces 逐张说明「它为什么没贡献地址」,供前端把空态换成有信息量的话。 +// addresses 是本次真正下发的那一份(严格轮或宽松轮的结果),同一张网卡只出现一次。 +// +// 有一张地址被别的网卡抢先拿走(同一地址配在桥接 / 双网卡上,去重只留了先出现的那个) +// 的网卡不算被跳过:这不是网卡的问题,报出来只会让现场去查一张没毛病的网卡。 +// 反过来,接口没置 up 却配着可用地址的网卡**要**报出来(code = not-up)——这正是主人这台 +// Windows 机器最可能的现场:地址是好的,只有 up 位读不到。 +func skippedInterfaces(ifaces []ifaceSnapshot, addresses []lanAddress) []skippedInterface { + contributed := make(map[string]struct{}, len(addresses)) + for _, a := range addresses { + contributed[a.Interface] = struct{}{} + } + out := make([]skippedInterface, 0, len(ifaces)) + seen := make(map[string]struct{}, len(ifaces)) + for _, iface := range ifaces { + if _, dup := seen[iface.Name]; dup { + continue + } + seen[iface.Name] = struct{}{} + if _, ok := contributed[iface.Name]; ok { + continue + } + if iface.Flags&net.FlagUp != 0 && hasEligibleIPv4(iface) { + continue + } + out = append(out, skippedInterface{ + Interface: iface.Name, + Code: skipCode(iface), + Addresses: addrStrings(iface.Addrs), + }) + } + return out +} + +// hasEligibleIPv4 判断一张网卡有没有「两轮里都可能被挑走」的地址(宽松轮的口径)。 +func hasEligibleIPv4(iface ifaceSnapshot) bool { + if iface.Flags&net.FlagLoopback != 0 { + return false + } + for _, raw := range iface.Addrs { + if ip4 := addrIP(raw).To4(); ip4 != nil { + if _, usable := ipv4Scope(ip4); usable { + return true + } + } + } + return false +} + +// skipCode 给出「这张网卡为什么没贡献地址」,只取最能解释问题的那一个。 +// +// 优先级(高到低):no-address > only-ipv6 > not-up > loopback。 +// 之所以把地址层面的原因排在接口 up 位之前:现场要看到的是「怎么才能弄好」—— +// 「接口没有地址」「只有 IPv6 地址」是能直接动手改的(插网线 / 换个网段 / 看看 DHCP), +// 而「接口未连接」在 Windows 上还可能是 up 位读不到(见 lanAddresses 的宽松轮)。 +// +// 这里不再有 link-local:链路本地地址会被收下(见 ipv4Scope),不再是跳过的理由; +// 一张 down 掉、只配了链路本地地址的网卡,真实原因是 not-up。 +func skipCode(iface ifaceSnapshot) string { + var hasAddr, hasIPv4, hasIPv6, hasLoopbackAddr bool + for _, raw := range iface.Addrs { + hasAddr = true + ip := addrIP(raw) + if ip == nil { + continue + } + if ip4 := ip.To4(); ip4 != nil { + hasIPv4 = true + hasLoopbackAddr = hasLoopbackAddr || ip4.IsLoopback() + continue + } + hasIPv6 = hasIPv6 || ip.To16() != nil + } + switch { + case !hasAddr: + return skipNoAddress + case !hasIPv4 && hasIPv6: + return skipOnlyIPv6 + case iface.Flags&net.FlagUp == 0: + return skipNotUp + case iface.Flags&net.FlagLoopback != 0, hasLoopbackAddr: + return skipLoopback + default: + // 只剩未指定(0.0.0.0)或组播这类地址:网卡在,但一个能当链接用的地址都没有。 + return skipNoAddress + } +} + +// addrStrings 把网卡地址原样写成 CIDR 文本(如 `10.0.0.10/24`),给 skipped 用。 +// 返回非 nil 空切片,保证 JSON 里是 [] 而不是 null。 +func addrStrings(addrs []net.Addr) []string { + out := make([]string, 0, len(addrs)) + for _, raw := range addrs { + out = append(out, raw.String()) + } + return out +} + +// usableInterface 判断一张网卡能不能贡献地址(严格轮): // - 必须 UP —— 网线没插、接口被 down 掉时,配置还在但包根本发不出去; // - 排除回环接口 —— lo 上的 127.x 对别的设备毫无意义(面板自己就是从 127.0.0.1 打开的, // 真把它显示出来会误导现场照抄一个只有本机能打开的地址)。 @@ -128,18 +470,46 @@ func usableInterface(flags net.Flags) bool { return flags&net.FlagUp != 0 && flags&net.FlagLoopback == 0 } -// usableLanIPv4 判断一个 IPv4 地址能不能给局域网其他设备用。 -// 逐条排除的都是「拿得到、连不上」的地址: -// - 127.0.0.0/8 回环:只有本机能访问; -// - 169.254.0.0/16 link-local:DHCP 没拿到地址时系统自配的,同网段设备通常也到不了; -// - 0.0.0.0:未指定地址,拼不成 URL; -// - 224.0.0.0/4 组播:不是单播目的地址。 -func usableLanIPv4(ip4 net.IP) bool { +// fallbackInterface 是严格轮一无所获时的兜底条件:只排除回环接口,不再要求 up 位。 +// 放宽的只有「读不到 up 位」这一种情况,回环接口在宽松轮里同样没有意义。 +func fallbackInterface(flags net.Flags) bool { + return flags&net.FlagLoopback == 0 +} + +// ipv4Scope 判断一个 IPv4 地址属于哪一类,第二返回值表示它能不能作为候选地址下发。 +// +// 只排除四类「作为对端访问目标没有意义」的地址: +// - 回环(net.IP.IsLoopback):只有本机访问得到; +// - 未指定(IsUnspecified):0.0.0.0 拼不成 URL; +// - 组播(IsMulticast):组播地址不是单播目的地; +// - 广播(等于 net.IPv4bcast):同理。 +// +// **一句话规则:除了这四类,本机持有的、可能被同网段设备访问的 IPv4 一律进候选。** +// 判定全部走标准库谓词,不写网段字面量:网段划分本身是标准库的知识,抄一份到代码里, +// 抄错或漏掉一段就会变成「明明有地址却说没有」——正是这次要修的那个毛病。 +// +// 这里**曾经**把链路本地地址(IsLinkLocalUnicast)也当成「不能用」排除掉,理由写的是 +// 「DHCP 没拿到地址才自配的,说明网络不健康」。那是对网络健不健康的**猜测**,不是地址能不能用的 +// **事实**:主人现场就是无 DHCP 的独立内网,设备之间靠链路本地地址互访,平板实测能打开 +// http://<链路本地地址>:端口/display,网络与防火墙全通,而卡片却因为这条猜测说「未检测到 +// 局域网地址」。判断错了,所以改成按类别判定:链路本地照收,只是排在最后(见 lanRank)。 +func ipv4Scope(ip4 net.IP) (scope string, usable bool) { + // 传进来的必须是 4 字节形式(各调用点都先做了 To4)。这里显式挡一次 nil: + // 谓词对 nil 全返回 false,落到 default 分支就会被当成「可用的 global 地址」—— + // IPv6 的 To4() 正好是 nil,那会让「只有 IPv6 的网卡」被误判成有候选地址。 + if ip4.To4() == nil { + return "", false + } switch { - case ip4.IsLoopback(), ip4.IsUnspecified(), ip4.IsLinkLocalUnicast(), ip4.IsMulticast(): - return false + case ip4.IsLoopback(), ip4.IsUnspecified(), ip4.IsMulticast(), ip4.Equal(net.IPv4bcast): + return "", false + case ip4.IsLinkLocalUnicast(): + return scopeLinkLocal, true + case ip4.IsPrivate(): + return scopePrivate, true + default: + return scopeGlobal, true } - return true } // lanRank 是排序档位(越小越靠前):现场「网线 + WiFi 双网卡」时,排第一的应该是他们真正在用的那个。 @@ -150,9 +520,17 @@ func usableLanIPv4(ip4 net.IP) bool { // - 2:172.16.0.0/12 —— RFC1918 三段里最少见的,多是虚拟化 / 容器网桥(docker 默认就用它), // 真要拿它给平板用也得排在直连网段之后; // - 3:其余(公网地址、运营商专线地址等)—— 能用就留着,但不给第一顺位: -// 现场多半是靠某个中间设备才通的,未必是平板能直连的那个。 +// 现场多半是靠某个中间设备才通的,未必是平板能直连的那个; +// - 4:链路本地 —— 永远排最后。 +// +// 为什么链路本地排最后:它只在「目标设备也用链路本地地址」时才连得上(无 DHCP 的独立内网 +// 正是这种情况),适用范围天然比配过 DHCP / 静态地址的网段窄。而排序第一位会决定现场 +// 「照抄哪一条」和二维码给谁,所以让适用面更广的私网 / 全局地址先来;真只有链路本地可用时, +// 它照样是列表第一条(前面的档位都是空的)。 func lanRank(ip4 net.IP) int { switch { + case ip4.IsLinkLocalUnicast(): + return 4 case ip4[0] == 192 && ip4[1] == 168: return 0 case ip4[0] == 10: @@ -178,10 +556,84 @@ func addrIP(addr net.Addr) net.IP { // systemNetwork 是 GET /api/system/network:大屏控制页用它拼出平板能直接打开的链接。 // 端口取服务端实际监听的那个(见 server.port),不在这里另写一个数字。 func (s *server) systemNetwork(w http.ResponseWriter, r *http.Request) (any, error) { + // 第三层(默认路由探测)只在这个接口里开:它是按请求跑的,前两轮为空才会真的 Dial。 + addresses, skipped := lanAddresses(s.snapshotInterfaces(), probeDefaultRoute) return networkInfo{ Port: s.port, DisplayPath: displayPagePath, AdminPath: adminPagePath, - Addresses: lanAddresses(s.snapshotInterfaces()), + Addresses: addresses, + Skipped: skipped, }, nil } + +// LogLanAddresses 启动时枚举一次网卡,把大屏控制页会用到的局域网地址写进日志。 +// +// 现场排障用:卡片显示空态时,现场第一件事就是看这行日志——服务端可能跑在另一台机器上, +// 而日志里能直接看出「枚举到了什么、为什么一个都没用」,不必再让主人去装工具查网卡。 +// 一个地址都没有时降级为 WARN,并带上 skipped 的原因摘要(中文,能直接看懂)。 +func LogLanAddresses(logger *slog.Logger) { + ifaces, err := readInterfaces() + if err != nil { + logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err) + return + } + logLanAddresses(logger, ifaces) +} + +// logLanAddresses 是 LogLanAddresses 的纯逻辑部分:快照从外面给,日志分支才好钉住。 +// +// probe 传 nil:启动路径**不做默认路由探测**。它跟服务启动在同一个 goroutine 上,为一行日志 +// 去碰网络栈(哪怕 UDP 不阻塞)不值得;也免得日志里的地址和卡片上真正下发的来源不一致 +// ——卡片是按请求探测的,那时网卡状态可能已经变了。日志只负责如实说「枚举到了什么」。 +func logLanAddresses(logger *slog.Logger, ifaces []ifaceSnapshot) { + addresses, skipped := lanAddresses(ifaces, nil) + if len(addresses) == 0 { + // 一个地址都没有:这就是现场来问「卡片为什么是空的」时要找的那条日志, + // 所以原因摘要必须和 WARN 一起出现,不能只在别处留个 code。 + summary := skipSummary(skipped) + if summary == "" { + summary = "没有枚举到任何网卡" + } + logger.Warn("未找到可用的局域网地址:大屏控制页只能显示空态", "已检查", summary) + return + } + logger.Info("局域网地址:" + addressSummary(addresses)) +} + +// addressSummary 拼「10.0.0.10(以太网)、192.168.0.100(ZeroTier)」这样一行日志消息。 +func addressSummary(addresses []lanAddress) string { + parts := make([]string, 0, len(addresses)) + for _, a := range addresses { + parts = append(parts, a.IP+"("+a.Interface+")") + } + return strings.Join(parts, "、") +} + +// skipSummary 把 skipped 摊成「以太网(接口未连接)、WLAN(只有 IPv6 地址)」。 +// 没有可报的网卡时返回空串,由调用方补一句「没有枚举到任何网卡」。 +func skipSummary(skipped []skippedInterface) string { + parts := make([]string, 0, len(skipped)) + for _, s := range skipped { + parts = append(parts, s.Interface+"("+skipReasonLabel(s.Code)+")") + } + return strings.Join(parts, "、") +} + +// skipReasonLabel 把跳过原因翻成日志里能直接看懂的中文。 +// 前端另有一份自己的映射(服务端只下发 code,卡片文案由前端定):日志面向运维、 +// 卡片面向现场操作员,措辞不必一模一样,但两份都得覆盖全部 code。 +func skipReasonLabel(code string) string { + switch code { + case skipNoAddress: + return "接口没有地址" + case skipOnlyIPv6: + return "只有 IPv6 地址" + case skipNotUp: + return "接口未连接" + case skipLoopback: + return "回环接口" + default: + return code + } +} diff --git a/server/internal/httpapi/network_internal_test.go b/server/internal/httpapi/network_internal_test.go index 1c28a72..7626a1f 100644 --- a/server/internal/httpapi/network_internal_test.go +++ b/server/internal/httpapi/network_internal_test.go @@ -1,9 +1,14 @@ package httpapi import ( + "bytes" "encoding/json" + "errors" + "log/slog" "net" + "os" "reflect" + "regexp" "strings" "testing" ) @@ -29,13 +34,50 @@ func lanIPs(got []lanAddress) []string { return out } -// 只回「平板真的连得上」的 IPv4:回环、link-local、未指定、组播、IPv6、down 掉的接口全要滤掉。 +func lanConfidences(got []lanAddress) []string { + out := make([]string, 0, len(got)) + for _, a := range got { + out = append(out, a.Confidence) + } + return out +} + +// ipTexts 把探测回来的 net.IP 摊成文本,方便和字面量比对。 +func ipTexts(got []net.IP) []string { + out := make([]string, 0, len(got)) + for _, ip := range got { + out = append(out, ip.String()) + } + return out +} + +func lanScopes(got []lanAddress) []string { + out := make([]string, 0, len(got)) + for _, a := range got { + out = append(out, a.Scope) + } + return out +} + +// skipCodes 把 skipped 摊成「接口名:code」,用例里一眼就能看出优先级与去重对不对。 +func skipCodes(skipped []skippedInterface) []string { + out := make([]string, 0, len(skipped)) + for _, s := range skipped { + out = append(out, s.Interface+":"+s.Code) + } + return out +} + +// 只回「平板真的连得上」的 IPv4:回环、未指定、组播、广播、IPv6、down 掉的接口全要滤掉。 // 这条用例钉住的是现场最容易踩的坑——面板从 127.0.0.1 打开,顺手把回环地址显示出去, // 现场照着抄到平板上就是打不开。 +// 链路本地地址(169 那一段)现在**要**收下:它是无 DHCP 内网里唯一能用的地址(见 ipv4Scope), +// 但排在私网地址之后。 +// 这里 down 掉的 eth1 配着可用地址却不出现,是因为严格轮已经有结果、宽松轮根本不会跑。 func TestLanAddressesFiltersUnusable(t *testing.T) { ifaces := []ifaceSnapshot{ lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8", "::1/128"), - // 网线插着但 DHCP 没拿到地址:系统自配 169.254.x,同网段设备通常也到不了。 + // 只有 IPv6 链路地址 + 自动配置的 IPv4:自动配置那条是候选(排在私网之后)。 lanIf("eth0", net.FlagUp, "169.254.10.5/16", "fe80::a00:27ff:fe4e:66a1/64"), // 接口 down 掉:配置还在,包发不出去。 lanIf("eth1", 0, "192.168.5.5/24"), @@ -48,8 +90,8 @@ func TestLanAddressesFiltersUnusable(t *testing.T) { lanIf("wlan0", net.FlagUp, "2400:3200::1/64", "10.1.2.3/8"), } - want := []string{"10.1.2.3", "172.20.0.1"} - got := lanAddresses(ifaces) + want := []string{"10.1.2.3", "172.20.0.1", "169.254.10.5"} + got, _ := lanAddresses(ifaces, nil) if !reflect.DeepEqual(lanIPs(got), want) { t.Fatalf("可用地址应为 %v,得到 %v", want, lanIPs(got)) } @@ -58,36 +100,517 @@ func TestLanAddressesFiltersUnusable(t *testing.T) { if a.Family != "ipv4" { t.Fatalf("family 应为 ipv4,得到 %q(%s)", a.Family, a.IP) } + if a.Scope == "" { + t.Fatalf("scope 必须下发(前端据此给链路本地地址加说明),%s 是空的", a.IP) + } + } + // 严格轮命中时一律 high:这些地址都来自「接口确实 up」的网卡,不需要给现场任何警告。 + if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"high", "high", "high"}) { + t.Fatalf("严格轮命中的地址应全部 high,得到 %v", c) } } // 一个地址都挑不出来时必须回空切片而不是 nil:JSON 里要是 null,前端分不清 -// 「没有局域网地址」和「服务端没给这个字段」。 +// 「没有局域网地址」和「服务端没给这个字段」。skipped 同理。 func TestLanAddressesEmptyResultIsNonNil(t *testing.T) { cases := map[string][]ifaceSnapshot{ - "没有网卡": nil, - "只有回环": {lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8")}, - "只有 IPv6": {lanIf("eth0", net.FlagUp, "fe80::1/64")}, - "接口全 down": {lanIf("eth0", 0, "192.168.1.23/24")}, + "没有网卡": nil, + "只有回环": {lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8")}, + "只有 IPv6": { + lanIf("eth0", net.FlagUp, "fe80::1/64"), + }, + // 未指定 / 组播 / 广播这些「当访问目标没意义」的地址,宽松轮也不放行。 + "只有未指定与组播": {lanIf("eth0", net.FlagUp, "0.0.0.0/32", "224.0.0.1/4")}, + "接口全 down 且只有回环": {lanIf("eth0", 0, "127.0.0.1/8")}, } for name, ifaces := range cases { - got := lanAddresses(ifaces) + got, skipped := lanAddresses(ifaces, nil) if got == nil { t.Fatalf("%s:应回空切片而不是 nil", name) } if len(got) != 0 { t.Fatalf("%s:不该有地址,得到 %v", name, lanIPs(got)) } - raw, err := json.Marshal(networkInfo{Port: 1, Addresses: got}) + if skipped == nil { + t.Fatalf("%s:skipped 应回空切片而不是 nil", name) + } + raw, err := json.Marshal(networkInfo{Port: 1, Addresses: got, Skipped: skipped}) if err != nil { t.Fatalf("%s:序列化失败 %v", name, err) } - if !strings.Contains(string(raw), `"addresses":[]`) { - t.Fatalf("%s:addresses 应序列化为 [],得到 %s", name, raw) + if !strings.Contains(string(raw), `"addresses":[]`) || !strings.Contains(string(raw), `"skipped":[`) { + t.Fatalf("%s:addresses 应是 [],skipped 应是数组而不是 null,得到 %s", name, raw) } } } +// 严格轮有结果时绝不跑宽松轮:那张 down 掉却配着 10.0.0.10 的网卡不能混进地址列表, +// 哪怕它「看起来能用」——机器上还有一张正常的网卡,现场要的是确定能连的那条。 +// 同时钉住契约里那份 skipped 样例的形状:没置 up 的网卡带着可用地址、另一张只有 IPv6。 +func TestLanAddressesStrictRoundWins(t *testing.T) { + ifaces := []ifaceSnapshot{ + lanIf("以太网", net.FlagUp, "192.168.1.23/24"), + lanIf("WLAN", 0, "10.0.0.10/24"), + lanIf("蓝牙网络连接", net.FlagUp, "169.254.11.239/16"), + } + + got, skipped := lanAddresses(ifaces, nil) + // 自动配置地址是候选、但排在私网地址之后:现场照抄第一条时不会先抄到适用范围更窄的那条。 + if want := []string{"192.168.1.23", "169.254.11.239"}; !reflect.DeepEqual(lanIPs(got), want) { + t.Fatalf("严格轮应回 %v,得到 %v", want, lanIPs(got)) + } + if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"high", "high"}) { + t.Fatalf("严格轮命中不应出现 low,得到 %v", c) + } + if s := lanScopes(got); !reflect.DeepEqual(s, []string{"private", "link-local"}) { + t.Fatalf("scope 应为 [private link-local],得到 %v", s) + } + // 只有 WLAN 真的没贡献地址(它 down 掉了,严格轮不会跑宽松轮兜底)。 + if want := []string{"WLAN:not-up"}; !reflect.DeepEqual(skipCodes(skipped), want) { + t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped)) + } + // 没置 up 却配着可用地址的网卡要把地址原样报出来:现场一眼就能看出「地址是好的、只是读不到状态」。 + if want := []string{"10.0.0.10/24"}; !reflect.DeepEqual(skipped[0].Addresses, want) { + t.Fatalf("skipped 应带上网卡上的原始地址 %v,得到 %v", want, skipped[0].Addresses) + } +} + +// 这张用例就是主人现场那个坑:整机只看得到「up 位读不到」的网卡(Windows 的驱动 / 虚拟网卡 +// 会把 IfOperStatusUp 读成 0)。严格轮一无所获时必须跑宽松轮,把地址标成 low 交给现场, +// 而不是回一句「未检测到局域网地址」。 +func TestLanAddressesFallsBackToLowConfidence(t *testing.T) { + ifaces := []ifaceSnapshot{ + lanIf("以太网", 0, "10.0.0.10/24"), + lanIf("ZeroTier One", 0, "192.168.0.100/24"), + lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"), + } + + got, skipped := lanAddresses(ifaces, nil) + // 排序档位照旧:192.168 段排最前。 + if want := []string{"192.168.0.100", "10.0.0.10"}; !reflect.DeepEqual(lanIPs(got), want) { + t.Fatalf("宽松轮应回 %v,得到 %v", want, lanIPs(got)) + } + if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"low", "low"}) { + t.Fatalf("宽松轮挑出的地址必须标 low,得到 %v", c) + } + // 地址本身仍然要可用:family、接口名、scope 一条都不能少。 + for _, a := range got { + if a.Family != "ipv4" || a.Interface == "" || a.Scope == "" { + t.Fatalf("宽松轮地址字段不完整: %+v", a) + } + ip := net.ParseIP(a.IP) + if ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() { + t.Fatalf("宽松轮不该放行不可用地址: %+v", a) + } + } + // 真的贡献了地址的网卡不算「被跳过」,只留下那张回环的。 + if want := []string{"lo:loopback"}; !reflect.DeepEqual(skipCodes(skipped), want) { + t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped)) + } +} + +// 宽松轮放宽的只有「up 位」这一条:回环、未指定、组播这些「当访问目标没意义」的地址 +// 在宽松轮里照样不算候选(链路本地则照收,见 TestLanAddressesCollectsLinkLocal)。 +func TestLanAddressesLenientKeepsAddressFilters(t *testing.T) { + ifaces := []ifaceSnapshot{ + lanIf("以太网", 0, "0.0.0.0/32", "224.0.0.1/4", "255.255.255.255/32", "127.0.0.1/8"), + } + + got, skipped := lanAddresses(ifaces, nil) + if len(got) != 0 { + t.Fatalf("宽松轮也不该放行回环 / 未指定 / 组播 / 广播,得到 %v", lanIPs(got)) + } + // 这张网卡一个候选地址都没有,原因按优先级给:接口没置 up 排在回环之前。 + if want := []string{"以太网:not-up"}; !reflect.DeepEqual(skipCodes(skipped), want) { + t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped)) + } +} + +// 主人现场那条规则:**up 的网卡上只配了自动配置地址(链路本地)也要收下**。 +// 那是无 DHCP 的独立内网里唯一能用的地址,平板实测打得开;曾经因为一句「没拿到 DHCP +// 说明网络不健康」的猜测把它滤掉,卡片就报「未检测到局域网地址」。 +func TestLanAddressesCollectsLinkLocal(t *testing.T) { + ifaces := []ifaceSnapshot{ + // 现场那台机器:以太网 up,只有 IPv6 链路地址 + 自动配置 IPv4。 + lanIf("以太网", net.FlagUp, "fe80::b551:79ff:fe1a:2b3c/64", "169.254.79.190/16"), + } + + got, skipped := lanAddresses(ifaces, nil) + if want := []string{"169.254.79.190"}; !reflect.DeepEqual(lanIPs(got), want) { + t.Fatalf("链路本地地址应进 addresses,得到 %v", lanIPs(got)) + } + if s := lanScopes(got); !reflect.DeepEqual(s, []string{"link-local"}) { + t.Fatalf("scope 应为 link-local,得到 %v", s) + } + if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"high"}) { + t.Fatalf("up 的网卡上的地址应是 high,得到 %v", c) + } + // 它贡献了地址,就不该出现在 skipped 里(link-local 已经不是跳过理由了)。 + if got := skipCodes(skipped); len(got) != 0 { + t.Fatalf("skipped 不该提到这张贡献了地址的网卡,得到 %v", got) + } +} + +// down 的网卡上只有自动配置地址时,原因必须是 not-up、**不是** link-local +// (链路本地已经不算「跳过理由」了,真实原因是接口没起来)。 +// 这里另给一张正常的网卡,让严格轮拿到结果——否则宽松轮会像兜底别的地址一样把它捡回来。 +func TestLanAddressesDownLinkLocalReportsNotUp(t *testing.T) { + ifaces := []ifaceSnapshot{ + lanIf("WLAN", net.FlagUp, "192.168.1.23/24"), + lanIf("以太网", 0, "169.254.79.190/16", "fe80::1/64"), + } + + got, skipped := lanAddresses(ifaces, nil) + if want := []string{"192.168.1.23"}; !reflect.DeepEqual(lanIPs(got), want) { + t.Fatalf("严格轮有结果时只应回 %v,得到 %v", want, lanIPs(got)) + } + if want := []string{"以太网:not-up"}; !reflect.DeepEqual(skipCodes(skipped), want) { + t.Fatalf("skipped 应为 %v(不是 link-local),得到 %v", want, skipCodes(skipped)) + } +} + +// 反面:整机只剩一张「读不到 up 位」的网卡、而它只有自动配置地址时,宽松轮照样兜底把它捡回来 +// (这正是主人现场那台机器在 up 位读不到时的形态)。宽松轮放宽的只有 up 位这一条, +// 与地址类别无关;捡回来的地址标 low,前端会额外说明来源。 +func TestLanAddressesLenientCollectsLinkLocalToo(t *testing.T) { + ifaces := []ifaceSnapshot{ + lanIf("以太网", 0, "169.254.79.190/16", "fe80::1/64"), + lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"), + } + + got, skipped := lanAddresses(ifaces, nil) + if want := []string{"169.254.79.190"}; !reflect.DeepEqual(lanIPs(got), want) { + t.Fatalf("宽松轮应兜底回 %v,得到 %v", want, lanIPs(got)) + } + if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"low"}) { + t.Fatalf("兜底捡回来的地址应标 low,得到 %v", c) + } + if s := lanScopes(got); !reflect.DeepEqual(s, []string{"link-local"}) { + t.Fatalf("scope 应为 link-local,得到 %v", s) + } + if want := []string{"lo:loopback"}; !reflect.DeepEqual(skipCodes(skipped), want) { + t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped)) + } +} + +// 排序:同一台机器上私网地址在前、链路本地在后(链接与二维码给第一条,必须给适用范围更广的)。 +func TestLanAddressesRanksLinkLocalLast(t *testing.T) { + ifaces := []ifaceSnapshot{ + lanIf("以太网", net.FlagUp, "169.254.79.190/16"), + lanIf("WLAN", net.FlagUp, "192.168.1.23/24"), + lanIf("ZeroTier", net.FlagUp, "10.0.0.10/24"), + lanIf("docker0", net.FlagUp, "172.20.0.1/16"), + lanIf("运营商专线", net.FlagUp, "203.0.113.9/24"), + } + + got, _ := lanAddresses(ifaces, nil) + want := []string{"192.168.1.23", "10.0.0.10", "172.20.0.1", "203.0.113.9", "169.254.79.190"} + if !reflect.DeepEqual(lanIPs(got), want) { + t.Fatalf("地址顺序应为 %v(链路本地最后),得到 %v", want, lanIPs(got)) + } + if s := lanScopes(got); !reflect.DeepEqual(s, []string{"private", "private", "private", "global", "link-local"}) { + t.Fatalf("scope 顺序不对: %v", s) + } +} + +// ── 第三层来源:默认路由探测 ────────────────────────────────────────────── +// +// 测试里一次真 Dial 都不做:Dial 通过 scriptedRouteProbe 注入假实现。 + +// fakeLocalAddr 是「认不出主机:端口」的本地地址(真机上 pipe / 抽象套接字会是这样)。 +type fakeLocalAddr struct{ text string } + +func (a fakeLocalAddr) Network() string { return "udp" } +func (a fakeLocalAddr) String() string { return a.text } + +// fakeConn 是探测里唯一用到的两点能力:LocalAddr 与 Close。 +type fakeConn struct { + local net.Addr + closed bool +} + +func (c *fakeConn) LocalAddr() net.Addr { return c.local } +func (c *fakeConn) Close() error { + c.closed = true + return nil +} + +// scriptedRouteProbe 造一个「假 Dial + 调用记录」的生产级 routeProbe: +// 走的是真的 probeRouteSourceIPs 与真的目标表,只有 Dial 被换掉。 +// script 里没有的目标 = 没有到那个网段的路由(Dial 直接失败);值为空串 = 本地地址解析不出来。 +func scriptedRouteProbe(calls *[]string, conns *[]*fakeConn, script map[string]string) routeProbe { + dial := func(network, address string) (localAddrConn, error) { + *calls = append(*calls, network+" "+address) + source, ok := script[address] + if !ok { + return nil, errors.New("network is unreachable") + } + conn := &fakeConn{} + if source == "" { + conn.local = fakeLocalAddr{text: "pipe"} + } else { + conn.local = &net.UDPAddr{IP: net.ParseIP(source), Port: 51820} + } + *conns = append(*conns, conn) + return conn, nil + } + return func() []net.IP { return probeRouteSourceIPs(dial, routeProbeTargets) } +} + +// 探测目标必须都是「主机:端口」且是 UDP 能消化的形状:写错了 Dial 会直接失败, +// 现场那一层保险就静默失灵(而它只在别的路都断了时才被用到,根本没人会发现)。 +func TestRouteProbeTargetsAreUsable(t *testing.T) { + want := []string{"192.168.0.1:9", "192.168.1.1:9", "10.0.0.1:9", "172.16.0.1:9", "8.8.8.8:53", "223.5.5.5:53"} + if !reflect.DeepEqual(routeProbeTargets, want) { + t.Fatalf("探测目标应为 %v,得到 %v", want, routeProbeTargets) + } + for _, target := range routeProbeTargets { + host, port, err := net.SplitHostPort(target) + if err != nil || net.ParseIP(host).To4() == nil || port == "" { + t.Fatalf("目标 %q 不是合法的 udp4 目标: %v", target, err) + } + } +} + +// 探测本身只负责「问操作系统 + 去重 + 关掉连接」,地址可用性过滤在 routeAddresses 里做: +// 这一层如实回报系统说了什么(比如只有回环路由的开发机会老实回 127.0.0.1), +// 拿不拿给现场用是下一层的事。反面的本地地址(解析不出主机)不能让整层崩掉。 +func TestProbeRouteSourceIPsDedupsAndClosesConns(t *testing.T) { + var calls []string + var conns []*fakeConn + // 第 1、2 个目标都落在 192.168.1.23(去重);第 3、4 个给回环 / link-local(这一层照收, + // 由 routeAddresses 滤掉);第 5 个本地地址解析不出来;第 6 个没有路由。 + probe := scriptedRouteProbe(&calls, &conns, map[string]string{ + "192.168.0.1:9": "192.168.1.23", + "192.168.1.1:9": "192.168.1.23", + "10.0.0.1:9": "127.0.0.1", + "172.16.0.1:9": "169.254.10.5", + "223.5.5.5:53": "", + // 8.8.8.8:53 不在脚本里 = 没有路由 + }) + + got := probe() + want := []string{"192.168.1.23", "127.0.0.1", "169.254.10.5"} + if !reflect.DeepEqual(ipTexts(got), want) { + t.Fatalf("探测应回 %v(去重、保留系统原话),得到 %v", want, ipTexts(got)) + } + if len(calls) != len(routeProbeTargets) { + t.Fatalf("每个目标都该试一次(共 %d 次),得到 %v", len(routeProbeTargets), calls) + } + for _, call := range calls { + if !strings.HasPrefix(call, "udp4 ") { + t.Fatalf("探测必须是 udp4(TCP 在离线现场会等超时),得到 %q", call) + } + } + // 拿到的连接必须关掉:现场一次请求最多开 6 个套接字,漏关就是慢性句柄泄漏。 + for i, conn := range conns { + if !conn.closed { + t.Fatalf("第 %d 个探测连接没有关闭", i+1) + } + } +} + +// 没有默认路由(离线 / 只有回环)时探测回空,不能把「问不出来」变成错误或 panic。 +func TestProbeRouteSourceIPsNoRoute(t *testing.T) { + var calls []string + var conns []*fakeConn + probe := scriptedRouteProbe(&calls, &conns, nil) + + if got := probe(); len(got) != 0 { + t.Fatalf("没有路由时应回空,得到 %v", ipTexts(got)) + } + if len(calls) != len(routeProbeTargets) { + t.Fatalf("每个目标都该试一次,得到 %v", calls) + } +} + +// 前三层都为空、但路由探测命中时:返回 confidence = route 的地址(第三层来源)。 +// 这正是那台单块 Intel 网卡的 Windows:枚举一条都给不出,只能问操作系统要。 +// (这里的网卡快照要真的「什么都没有」:只配 IPv6 的 up 网卡 + 回环,才轮得到第三层。) +func TestLanAddressesRouteFallback(t *testing.T) { + ifaces := []ifaceSnapshot{ + lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"), + lanIf("以太网", net.FlagUp, "fe80::1/64"), + } + var calls []string + var conns []*fakeConn + probe := scriptedRouteProbe(&calls, &conns, map[string]string{"192.168.0.1:9": "192.168.5.9"}) + + got, skipped := lanAddresses(ifaces, probe) + if want := []string{"192.168.5.9"}; !reflect.DeepEqual(lanIPs(got), want) { + t.Fatalf("路由探测命中时应回 %v,得到 %v", want, lanIPs(got)) + } + if c := lanConfidences(got); !reflect.DeepEqual(c, []string{"route"}) { + t.Fatalf("探测来的地址应标 route,得到 %v", c) + } + if s := lanScopes(got); !reflect.DeepEqual(s, []string{"private"}) { + t.Fatalf("探测来的地址也要有 scope,得到 %v", s) + } + if got[0].Family != "ipv4" || got[0].Interface != routeInterfaceLabel { + t.Fatalf("探测来的地址字段不对: %+v", got[0]) + } + // 网卡那两条原因照旧给出来:地址是路由给的,不代表这些网卡没被跳过。 + if want := []string{"lo:loopback", "以太网:only-ipv6"}; !reflect.DeepEqual(skipCodes(skipped), want) { + t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped)) + } +} + +// 探测回来的地址也要过同一套类别判定:只有回环路由的开发机上,系统会老实回答 127.0.0.1, +// 那种地址给平板毫无意义,必须滤掉后仍然回空;而未指定 / 组播同理。 +// 反过来,探测回来的链路本地地址**要留下**(它是无 DHCP 内网里唯一能用的那种地址)。 +func TestLanAddressesRouteFallbackKeepsAddressFilters(t *testing.T) { + ifaces := []ifaceSnapshot{ + lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"), + } + var calls []string + var conns []*fakeConn + probe := scriptedRouteProbe(&calls, &conns, map[string]string{ + "192.168.0.1:9": "127.0.0.1", + "10.0.0.1:9": "0.0.0.0", + "8.8.8.8:53": "224.0.0.1", + }) + + got, skipped := lanAddresses(ifaces, probe) + if len(got) != 0 { + t.Fatalf("回环 / 未指定 / 组播的探测结果都该被滤掉,得到 %v", lanIPs(got)) + } + if skipped == nil { + t.Fatal("skipped 应回空切片而不是 nil") + } + + // 链路本地的探测结果留下,并如实标 scope。 + var linkLocalCalls []string + var linkLocalConns []*fakeConn + linkLocalProbe := scriptedRouteProbe(&linkLocalCalls, &linkLocalConns, map[string]string{"192.168.0.1:9": "169.254.7.7"}) + + got, _ = lanAddresses(ifaces, linkLocalProbe) + if want := []string{"169.254.7.7"}; !reflect.DeepEqual(lanIPs(got), want) { + t.Fatalf("探测回来的链路本地地址应留下,得到 %v", lanIPs(got)) + } + if s := lanScopes(got); !reflect.DeepEqual(s, []string{"link-local"}) { + t.Fatalf("scope 应为 link-local,得到 %v", s) + } +} + +// 探测失败(机器没有默认路由)时:仍然回空数组、不报错,skipped 照旧把原因说清楚。 +func TestLanAddressesRouteProbeFailureKeepsEmptyResult(t *testing.T) { + ifaces := []ifaceSnapshot{ + lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"), + lanIf("WLAN", net.FlagUp, "fe80::1/64"), + } + var calls []string + var conns []*fakeConn + probe := scriptedRouteProbe(&calls, &conns, nil) + + got, skipped := lanAddresses(ifaces, probe) + if got == nil || len(got) != 0 { + t.Fatalf("探测失败时应回空切片而不是 nil / 地址,得到 %#v", got) + } + if len(calls) != len(routeProbeTargets) { + t.Fatalf("探测失败也该把目标试完,得到 %v", calls) + } + if want := []string{"lo:loopback", "WLAN:only-ipv6"}; !reflect.DeepEqual(skipCodes(skipped), want) { + t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped)) + } +} + +// 前两轮任一有结果时,第三层一次 Dial 都不能发生:正常机器不该为了「保险」去碰网络栈 +// (现场可能完全离线,而这层保险只有在枚举确实给不出任何东西时才有意义)。 +// 这里数的是真 Dial 的次数,不是「探测函数被调了几次」——脚本故意给一个能命中的源地址, +// 真去探测的话在 calls 里一定看得见。 +func TestLanAddressesDoesNotProbeWhenEnumerated(t *testing.T) { + var calls []string + var conns []*fakeConn + probe := scriptedRouteProbe(&calls, &conns, map[string]string{"192.168.0.1:9": "192.168.5.9"}) + + // 严格轮命中。 + strictGot, _ := lanAddresses([]ifaceSnapshot{lanIf("以太网", net.FlagUp, "192.168.1.23/24")}, probe) + if len(calls) != 0 { + t.Fatalf("严格轮命中时一次 Dial 都不该发生,实际 %d 次: %v", len(calls), calls) + } + if c := lanConfidences(strictGot); !reflect.DeepEqual(c, []string{"high"}) { + t.Fatalf("严格轮的地址应标 high,得到 %v", c) + } + + // 宽松轮命中(接口没置 up,但地址可用)。 + lenientGot, _ := lanAddresses([]ifaceSnapshot{lanIf("以太网", 0, "10.0.0.10/24")}, probe) + if len(calls) != 0 { + t.Fatalf("宽松轮命中时一次 Dial 都不该发生,实际 %d 次: %v", len(calls), calls) + } + if c := lanConfidences(lenientGot); !reflect.DeepEqual(c, []string{"low"}) { + t.Fatalf("宽松轮的地址应标 low,得到 %v", c) + } + + // 前两轮都为空:这时候才轮到第三层,而且会把探测目标试一遍。 + routeGot, _ := lanAddresses([]ifaceSnapshot{lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8")}, probe) + if len(calls) != len(routeProbeTargets) { + t.Fatalf("前两轮为空时该把 %d 个目标试一遍,实际 %v", len(routeProbeTargets), calls) + } + if c := lanConfidences(routeGot); !reflect.DeepEqual(c, []string{"route"}) { + t.Fatalf("探测来的地址应标 route,得到 %v", c) + } +} + +// 启动日志那条路径绝不能探测:它跟服务启动在同一个 goroutine 上,为一行日志去碰网络栈不值得。 +// Go 里没有别的办法钉住「调用点传了什么」,所以直接读源码断言。 +func TestStartupLogNeverProbes(t *testing.T) { + raw, err := os.ReadFile("network.go") + if err != nil { + t.Fatalf("读 network.go 失败: %v", err) + } + source := string(raw) + if !strings.Contains(source, "lanAddresses(ifaces, nil)") { + t.Fatal("启动日志必须传 nil 探测(只做枚举):lanAddresses(ifaces, nil) 不见了") + } + if !strings.Contains(source, "lanAddresses(s.snapshotInterfaces(), probeDefaultRoute)") { + t.Fatal("GET /api/system/network 才该带上默认路由探测:systemNetwork 里的调用点不对") + } +} + +// skipped 的 code 优先级与去重:一张网卡同时命中多个原因时只给最能解释问题的那一个 +// (no-address > only-ipv6 > not-up > loopback),且同一接口只出现一次。 +// 注意链路本地不再是原因:只配了链路本地地址的 up 网卡会贡献地址,down 的网卡报 not-up。 +func TestSkippedInterfacesPriorityAndDedup(t *testing.T) { + ifaces := []ifaceSnapshot{ + // 回环接口:最低优先级,只有它自己命中时才轮得到。 + lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"), + // 只有链路本地地址的 down 网卡:原因是 not-up(链路本地已经不算跳过理由)。 + lanIf("WLAN", 0, "169.254.11.239/16"), + // 只有 IPv6:即使 up 位也读不到,仍给 only-ipv6。 + lanIf("vEthernet (WSL)", 0, "fe80::a00:27ff:fe4e:66a1/64"), + // 一个地址都没有:最高优先级。 + lanIf("蓝牙网络连接", net.FlagUp), + // 非回环网卡上只配了回环地址:按地址本身也得报回环。 + lanIf("eth2", net.FlagUp, "127.0.0.5/8"), + // 同一张网卡出现两次(快照可能来自两个来源):只能报一条。 + lanIf("WLAN", 0, "169.254.11.239/16"), + // 有一张正常的网卡,让严格轮拿到结果——否则宽松轮会把全 down 的现场整个改写。 + lanIf("以太网", net.FlagUp, "192.168.1.23/24"), + } + + got, skipped := lanAddresses(ifaces, nil) + if len(got) != 1 { + t.Fatalf("只该有以太网那条地址,得到 %v", lanIPs(got)) + } + want := []string{ + "lo:loopback", + "WLAN:not-up", + "vEthernet (WSL):only-ipv6", + "蓝牙网络连接:no-address", + "eth2:loopback", + } + if !reflect.DeepEqual(skipCodes(skipped), want) { + t.Fatalf("skipped 应为 %v,得到 %v", want, skipCodes(skipped)) + } + // addresses 要原样带出来(CIDR 文本),前端虽然不用,但排查时能直接看到配了什么。 + if !reflect.DeepEqual(skipped[1].Addresses, []string{"169.254.11.239/16"}) { + t.Fatalf("skipped 应带上网卡上的原始地址,得到 %v", skipped[1].Addresses) + } + if skipped[3].Addresses == nil { + t.Fatal("没有地址的接口也应回空切片而不是 nil") + } +} + // 同一地址配在多张网卡上(桥接、双网卡同网段)时只留一条,且保留先出现的那张网卡名; // 顺序按「像不像常见内网段」分档,同档再按地址数值升序——否则列表每次刷新都在跳, // 现场刚记住的链接下次就不是第一条了。 @@ -100,7 +623,7 @@ func TestLanAddressesDedupAndOrder(t *testing.T) { } want := []string{"192.168.1.9", "192.168.1.23", "192.168.1.100", "10.0.0.4", "172.20.1.5", "8.8.8.8"} - got := lanAddresses(ifaces) + got, skipped := lanAddresses(ifaces, nil) if !reflect.DeepEqual(lanIPs(got), want) { t.Fatalf("地址顺序应为 %v,得到 %v", want, lanIPs(got)) } @@ -108,15 +631,111 @@ func TestLanAddressesDedupAndOrder(t *testing.T) { if got[1].Interface != "eth0" { t.Fatalf("去重后应保留先出现的接口名 eth0,得到 %q", got[1].Interface) } + // br0 的地址只是被去重拿走了,网卡本身没毛病:不能出现在「被跳过的网卡」里。 + if len(skipped) != 0 { + t.Fatalf("三张网卡都贡献或只是地址重复,skipped 应为空,得到 %v", skipCodes(skipped)) + } } -// 排序档位本身:192.168/16 → 10/8 → 172.16-31/12 → 其余,越靠前越是现场真在用的那条。 +// skipped 的每个 code 都要能翻成中文日志:这张表是启动日志 WARN 的全部依据, +// 漏一个就会在现场最需要解释的时候打出一个英文 code。 +// 集合本身收敛成 4 个:链路本地不再是跳过理由(它现在是候选地址)。 +func TestSkipReasonLabelCoversEveryCode(t *testing.T) { + labels := map[string]string{} + for _, code := range []string{skipNoAddress, skipOnlyIPv6, skipNotUp, skipLoopback} { + label := skipReasonLabel(code) + if label == "" || label == code { + t.Fatalf("code %q 没有中文说明", code) + } + labels[code] = label + } + if len(labels) != 4 { + t.Fatalf("应该有 4 个 code,得到 %d 个", len(labels)) + } + // 链路本地已经不算原因,日志里也不该再有这个说法。 + if skipReasonLabel("link-local") == "只有自动配置地址 169.254.x" { + t.Fatal("链路本地不该再作为跳过原因出现在日志里") + } +} + +// 启动日志的两种分支:有地址时 INFO 一行「局域网地址:IP(网卡)」, +// 一个都没有时 WARN 并把 skipped 的原因摘要带上——现场排障就靠这一条。 +func TestLogLanAddressesBranches(t *testing.T) { + t.Run("有地址记 INFO", func(t *testing.T) { + out := captureNetworkLogs(t, []ifaceSnapshot{ + lanIf("以太网", net.FlagUp, "192.168.0.100/24"), + lanIf("ZeroTier One", net.FlagUp, "10.0.0.10/8"), + lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8"), + }) + if !strings.Contains(out, "level=INFO") { + t.Fatalf("有地址时应记 INFO,得到 %s", out) + } + if !strings.Contains(out, "局域网地址:192.168.0.100(以太网)、10.0.0.10(ZeroTier One)") { + t.Fatalf("INFO 里应带上地址与网卡名,得到 %s", out) + } + if strings.Contains(out, "level=WARN") { + t.Fatalf("有地址时不该同时有 WARN,得到 %s", out) + } + }) + + // 主人现场那台机器:唯一能用的就是自动配置地址。日志走的是同一套筛选,必须照常打出来 + // (不在日志里另写一套判断,否则又会出现「卡片有、日志没有」这种两处打架)。 + t.Run("自动配置地址照常打出来", func(t *testing.T) { + out := captureNetworkLogs(t, []ifaceSnapshot{ + lanIf("以太网", net.FlagUp, "fe80::b551:79ff:fe1a:2b3c/64", "169.254.79.190/16"), + }) + if !strings.Contains(out, "level=INFO") { + t.Fatalf("有自动配置地址时应记 INFO 而不是 WARN,得到 %s", out) + } + if !strings.Contains(out, "局域网地址:169.254.79.190(以太网)") { + t.Fatalf("INFO 里应带上自动配置地址,得到 %s", out) + } + }) + + t.Run("没有地址记 WARN 并带上原因", func(t *testing.T) { + out := captureNetworkLogs(t, []ifaceSnapshot{ + lanIf("以太网", net.FlagUp, "fe80::1/64"), + lanIf("WLAN", net.FlagUp, "0.0.0.0/32", "224.0.0.1/4"), + }) + if !strings.Contains(out, "level=WARN") { + t.Fatalf("没有地址时应记 WARN,得到 %s", out) + } + if !strings.Contains(out, "未找到可用的局域网地址") { + t.Fatalf("WARN 应说清是没有可用地址,得到 %s", out) + } + if !strings.Contains(out, "以太网(只有 IPv6 地址)") || + !strings.Contains(out, "WLAN(接口没有地址)") { + t.Fatalf("WARN 应带上 skipped 的中文原因摘要,得到 %s", out) + } + if strings.Contains(out, "level=INFO") { + t.Fatalf("没有地址时不该有 INFO,得到 %s", out) + } + }) + + t.Run("一张网卡都没枚举到", func(t *testing.T) { + out := captureNetworkLogs(t, nil) + if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "没有枚举到任何网卡") { + t.Fatalf("没有网卡时也该给 WARN 与说明,得到 %s", out) + } + }) +} + +// captureNetworkLogs 跑一遍 logLanAddresses 并把结构化日志收成文本。 +func captureNetworkLogs(t *testing.T, ifaces []ifaceSnapshot) string { + t.Helper() + var buf bytes.Buffer + logLanAddresses(slog.New(slog.NewTextHandler(&buf, nil)), ifaces) + return buf.String() +} + +// 排序档位本身:192.168/16 → 10/8 → 172.16-31/12 → 其余 → 链路本地,越靠前越是现场真在用的那条。 func TestLanRankPrefersCommonLanSegments(t *testing.T) { order := []string{ "192.168.0.1", "192.168.255.254", // 路由器出厂段 "10.0.0.1", "10.255.255.254", // 企业内网 / 热点 "172.16.0.1", "172.31.255.254", // 最少见,多是虚拟网桥 "172.15.0.1", "172.32.0.1", "203.0.113.9", // 172.16/12 之外 + "169.254.0.1", "169.254.255.254", // 自动配置段永远最后 } for i := 1; i < len(order); i++ { prev, curr := net.ParseIP(order[i-1]).To4(), net.ParseIP(order[i]).To4() @@ -127,6 +746,121 @@ func TestLanRankPrefersCommonLanSegments(t *testing.T) { if lanRank(net.ParseIP("172.15.0.1").To4()) != 3 || lanRank(net.ParseIP("172.32.0.1").To4()) != 3 { t.Fatal("172.16/12 的两侧边界不该被当成常见内网段") } + // 链路本地必须严格排在所有别的类别之后(排序第一位会决定现场照抄哪条、二维码给谁)。 + for _, ip := range []string{"192.168.0.1", "10.0.0.1", "172.16.0.1", "203.0.113.9"} { + if lanRank(net.ParseIP(ip).To4()) >= lanRank(net.ParseIP("169.254.1.1").To4()) { + t.Fatalf("%s 应排在链路本地之前", ip) + } + } +} + +// ── 地址分类(ipv4Scope)─────────────────────────────────────────────── + +// 分类函数的边界:只排除「作为对端访问目标没有意义」的四类。 +// 判定必须走标准库谓词——网段知识抄一份到代码里,抄错一段就是「明明有地址却说没有」。 +func TestIPv4ScopeClassifies(t *testing.T) { + cases := []struct { + ip string + scope string + usable bool + }{ + {"127.0.0.1", "", false}, // 回环 + {"127.255.255.254", "", false}, // 回环段的另一端 + {"0.0.0.0", "", false}, // 未指定 + {"224.0.0.1", "", false}, // 组播 + {"239.255.255.250", "", false}, // 组播段另一端(SSDP) + {"255.255.255.255", "", false}, // 广播 + {"169.254.1.2", "link-local", true}, // 自动配置段 + {"169.254.255.254", "link-local", true}, + {"192.168.1.1", "private", true}, // RFC1918 三段 + {"10.0.0.1", "private", true}, + {"172.16.0.1", "private", true}, + {"172.31.255.254", "private", true}, + {"8.8.8.8", "global", true}, // 其余都是 global + {"203.0.113.9", "global", true}, + {"172.32.0.1", "global", true}, // 172.16/12 的边界之外 + {"100.64.0.1", "global", true}, // 运营商级 NAT:标准库不算 private,照收 + } + for _, c := range cases { + scope, usable := ipv4Scope(net.ParseIP(c.ip).To4()) + if scope != c.scope || usable != c.usable { + t.Fatalf("%s 应为 (%q,%v),得到 (%q,%v)", c.ip, c.scope, c.usable, scope, usable) + } + } + // 认不出的输入(To4 之后仍为 nil)不能 panic。 + if _, usable := ipv4Scope(nil); usable { + t.Fatal("nil 地址不该被当成可用") + } +} + +// 反硬编码护栏(主人明确要求):分类不能用网段字面量,只能调标准库谓词。 +// 断言方式:把 network.go 每行的注释部分去掉(注释里写解释是允许的,代码里写死才是判断), +// 再找那种「写死一段网段」的痕迹。 +// 探测目标表里的网关地址(routeProbeTargets)是「猜网关」而不是「判地址类别」,不在约束内。 +func TestScopeHasNoHardcodedSubnet(t *testing.T) { + source := codeWithoutComments(t, "network.go") + + // 关键:链路本地段绝不能在代码里以字面量出现(那正是这次被修掉的写法)。 + if strings.Contains(source, "169.254") { + t.Fatal("代码里出现了链路本地的网段字面量:类别判定必须用 net.IP 的谓词(IsLinkLocalUnicast 等)") + } + + body := functionBody(t, source, "func ipv4Scope(") + if body == "" { + t.Fatal("找不到 ipv4Scope 的函数体:分类函数改名了?这条护栏要跟着改") + } + if regexp.MustCompile(`\d+\.\d+\.\d+\.\d+`).MatchString(body) { + t.Fatal("ipv4Scope 里出现了 IP 字面量:分类只能调标准库谓词,不能写死网段") + } + for _, predicate := range []string{"IsLoopback", "IsUnspecified", "IsMulticast", "IPv4bcast", "IsLinkLocalUnicast", "IsPrivate"} { + if !strings.Contains(body, predicate) { + t.Fatalf("分类函数里缺少谓词 %s:边界会漏判", predicate) + } + } +} + +// codeWithoutComments 去掉源码每行的注释部分(含整行注释与行尾注释),只留代码。 +func codeWithoutComments(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读 %s 失败: %v", path, err) + } + var out strings.Builder + for _, line := range strings.Split(string(raw), "\n") { + if idx := strings.Index(line, "//"); idx >= 0 { + line = line[:idx] + } + out.WriteString(line) + out.WriteString("\n") + } + return out.String() +} + +// functionBody 抠出以 signature 开头那个函数的函数体(含花括号),找不到回空串。 +func functionBody(t *testing.T, source, signature string) string { + t.Helper() + start := strings.Index(source, signature) + if start < 0 { + return "" + } + open := strings.Index(source[start:], "{") + if open < 0 { + return "" + } + depth := 0 + for i := start + open; i < len(source); i++ { + switch source[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return source[start+open : i+1] + } + } + } + return "" } // 网卡地址实际都是 *net.IPNet,但兜底分支也要能取到 IP,否则换平台(如某些 BSD/虚拟网卡) diff --git a/server/internal/httpapi/network_test.go b/server/internal/httpapi/network_test.go index 3551197..a5ca8e9 100644 --- a/server/internal/httpapi/network_test.go +++ b/server/internal/httpapi/network_test.go @@ -47,7 +47,7 @@ func TestSystemNetworkShape(t *testing.T) { if err := json.Unmarshal(env2.Data, &body); err != nil { t.Fatalf("解析 network: %v (%s)", err, env2.Data) } - assertKeys(t, "network", body, []string{"port", "displayPath", "adminPath", "addresses"}) + assertKeys(t, "network", body, []string{"port", "displayPath", "adminPath", "addresses", "skipped"}) if got, ok := body["port"].(float64); !ok || int(got) != 18080 { t.Fatalf("port 应回注入的实际监听端口 18080,得到 %#v", body["port"]) @@ -64,7 +64,8 @@ func TestSystemNetworkShape(t *testing.T) { } } - // addresses 必须是数组(没有地址时是 [] 而不是 null),每条都带 ip/interface/family 且是 IPv4。 + // addresses 必须是数组(没有地址时是 [] 而不是 null), + // 每条都带 ip/interface/family/confidence/scope 且是 IPv4。 raw, ok := body["addresses"].([]any) if !ok { t.Fatalf("addresses 应是数组,得到 %#v", body["addresses"]) @@ -74,17 +75,49 @@ func TestSystemNetworkShape(t *testing.T) { if !ok { t.Fatalf("地址项应是对象,得到 %#v", item) } - assertKeys(t, "addresses[]", entry, []string{"ip", "interface", "family"}) + assertKeys(t, "addresses[]", entry, []string{"ip", "interface", "family", "confidence", "scope"}) if entry["family"] != "ipv4" { t.Fatalf("family 应为 ipv4,得到 %#v", entry["family"]) } + if c, _ := entry["confidence"].(string); c != "high" && c != "low" && c != "route" { + t.Fatalf("confidence 应是 high / low / route,得到 %#v", entry["confidence"]) + } + if s, _ := entry["scope"].(string); s != "private" && s != "global" && s != "link-local" { + t.Fatalf("scope 应是 private / global / link-local,得到 %#v", entry["scope"]) + } ip := net.ParseIP(entry["ip"].(string)) if ip == nil || ip.To4() == nil { t.Fatalf("ip 应是 IPv4 文本,得到 %#v", entry["ip"]) } - // 回环与 link-local 是「拿得到、连不上」的地址,绝不能出现在给平板用的列表里。 - if ip.IsLoopback() || ip.IsLinkLocalUnicast() { - t.Fatalf("不该出现回环 / link-local 地址: %#v", entry) + // 回环 / 未指定 / 组播 / 广播是「拿得到、连不上」的地址,绝不能出现在给平板用的列表里。 + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.Equal(net.IPv4bcast) { + t.Fatalf("不该出现回环 / 未指定 / 组播 / 广播地址: %#v", entry) + } + // 链路本地**是**候选(无 DHCP 的独立内网里它是唯一能用的地址),但必须如实标 scope。 + if ip.IsLinkLocalUnicast() && entry["scope"] != "link-local" { + t.Fatalf("链路本地地址的 scope 应为 link-local,得到 %#v", entry["scope"]) + } + } + + // skipped 也必须始终是数组(没有可解释的接口时是 [] 而不是 null),每条的 code 都在约定集合里。 + // 它只是给前端解释空态用的旁白,地址列表不受它影响。 + skipped, ok := body["skipped"].([]any) + if !ok { + t.Fatalf("skipped 应是数组,得到 %#v", body["skipped"]) + } + // 集合里没有 link-local:链路本地现在是候选地址,不再是「被跳过」的理由。 + codes := map[string]bool{"no-address": true, "only-ipv6": true, "not-up": true, "loopback": true} + for _, item := range skipped { + entry, ok := item.(map[string]any) + if !ok { + t.Fatalf("skipped 项应是对象,得到 %#v", item) + } + assertKeys(t, "skipped[]", entry, []string{"interface", "code", "addresses"}) + if code, _ := entry["code"].(string); !codes[code] { + t.Fatalf("skipped 的 code 不在约定集合里: %#v", entry["code"]) + } + if _, ok := entry["addresses"].([]any); !ok { + t.Fatalf("skipped 的 addresses 应是数组,得到 %#v", entry["addresses"]) } } } diff --git a/server/main.go b/server/main.go index 8e777c2..2fc932c 100644 --- a/server/main.go +++ b/server/main.go @@ -88,6 +88,10 @@ func main() { // 只能靠人去数据目录里翻一个没人提过的文件。 warnPendingRestore(logger, cfg.DataDir) + // 启动时把本机局域网地址记一条:大屏控制页在别的设备上打开,卡片显示空态时现场未必 + // 拿得到服务端窗口,这行日志(有地址是 INFO、没有是带原因的 WARN)是排障的第一站。 + httpapi.LogLanAddresses(logger) + projectName, projectSource, err := resolveProjectName(cfg.DataDir, *project) if err != nil { logger.Error("解析赛事项目失败", "err", err) diff --git a/web/src/admin/pages/DisplayPage.vue b/web/src/admin/pages/DisplayPage.vue index 6c6095a..59bbc5d 100644 --- a/web/src/admin/pages/DisplayPage.vue +++ b/web/src/admin/pages/DisplayPage.vue @@ -12,7 +12,15 @@ import { settingsApi } from '../components/system/systemApi' import { useAuthStore } from '../stores/auth' import { useEventStore } from '../stores/event' import { useToastStore } from '../stores/toast' -import { copyText, lanDisplayLinks } from './lanDisplay' +import { + copyText, + lanDisplayLinks, + lanEmptyNote, + manualAddressStorage, + parseManualAddress, + readManualAddresses, + writeManualAddresses, +} from './lanDisplay' /** * 页面选项直接取大屏那份清单(`@/display/types`):规范页名与中文标签只维护一处, @@ -348,11 +356,61 @@ const lanGateReason = computed(() => auth.readOnly && !data.isMock ? '只有登录后的录入员 / 管理员能查看局域网地址,当前为只读访客。' : null, ) -const lanLinks = computed(() => (lanInfo.value === null ? [] : lanDisplayLinks(lanInfo.value))) +/** + * 手填的局域网地址(卡片最后的兜底)。 + * + * 为什么要有:自动检测这条路在个别机器上就是给的(up 位读不到、枚举不出),或者给出的 + * 网段不是现场要用的那张网卡。操作员从 ipconfig 抄一个 IP 进来,是唯一不依赖检测手段的办法。 + * + * 存在 localStorage 里:现场是「填一次、整个赛事都用」,切个页面或刷新就丢会让人反复抄 ipconfig。 + * 存储不可用(隐私模式 / 配额满)时只当没存上,这个功能本身不请求服务端,也就不该因为存储问题挂掉。 + * + * 声明在 lanLinks 之前:那是个 computed,函数体里引用到手写地址列表,先有数据源再算结果。 + */ +const manualIps = ref(readManualAddresses(manualAddressStorage())) +const manualInput = ref('') +/** 就地红字:非法输入时告诉操作员哪里不对,而不是默默什么都不发生。 */ +const manualError = ref(null) + +function addManualAddress(): void { + const result = parseManualAddress(manualInput.value, manualIps.value) + if (!result.ok) { + manualError.value = result.error + return + } + manualIps.value = [...manualIps.value, result.ip] + writeManualAddresses(manualAddressStorage(), manualIps.value) + manualInput.value = '' + manualError.value = null +} + +/** 删除只影响手填列表:自动检测出来的地址不受影响(它们不是操作员加进来的)。 */ +function removeManualAddress(ip: string): void { + manualIps.value = manualIps.value.filter((item) => item !== ip) + writeManualAddresses(manualAddressStorage(), manualIps.value) +} + +const lanLinks = computed(() => + lanInfo.value === null ? [] : lanDisplayLinks(lanInfo.value, manualIps.value), +) + +/** + * 空态文案:逐条列出服务端检查过、但没被采用的网卡与原因(最多 5 条)。 + * + * `lanInfo` 为 null 时不会有东西渲染这句话(模板在前面就分流了),这里给空串只是为了让 + * 类型收敛,不引入第二种「读取中」的写法。 + */ +const lanEmptyText = computed(() => (lanInfo.value === null ? '' : lanEmptyNote(lanInfo.value))) /** 有地址但服务端没给端口:拼不出链接,这句话要和「没插网线」分开说。 */ const lanPortMissing = computed(() => lanInfo.value !== null && lanInfo.value.port <= 0) +/** + * 手填入口只在「能拼出链接」时给:端口或路径都没拿到时,填了也只会得到一条打不开的地址。 + * 只读访客 / 示例数据模式不额外设限——这个功能纯在本地浏览器里跑,不请求服务端,也就没有权限问题。 + */ +const lanManualEnabled = computed(() => lanInfo.value !== null && !lanPortMissing.value) + /** * 第一个地址的小二维码。 * @@ -927,6 +985,15 @@ async function send(
{{ item.url }} + 手动 +
+ +

{{ item.hint }}

+ +

{{ item.scopeHint }}

-

未检测到局域网地址(可能是没插网线 / 只有回环)。

+

{{ lanEmptyText }}

+ + +
+
手动添加地址
+

自动检测不到时,把 ipconfig 里的 IPv4 地址填进来

+
+
+ +
+ +
+

{{ manualError }}

+

地址由服务端下发的端口与路径拼成;平板上打开后不必登录,大屏本身是只读的。 @@ -1138,6 +1241,38 @@ async function send( font-size: 12px; color: var(--mx-t3); } +/* 低置信地址的提示:贴在地址行下面,和普通说明拉开一点距离。 */ +.lan-uncertain { + margin-top: 6px; +} +/* 手填入口:和上面的地址列表隔开一点,像一个独立的小工具。 */ +.lan-manual { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--mx-border); +} +.lan-manual-title { + font-size: 13px; + font-weight: 600; + color: var(--mx-t2); +} +.lan-manual-note { + font-size: 12px; + color: var(--mx-t3); + line-height: 1.6; + margin-top: 4px; +} +.lan-manual-form { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} +/* 输入框占满剩余宽度:IP 不长,但窄栏里也得让人看清自己填了什么。 */ +.lan-manual-form .mx-input-wrap { + flex: 1; + min-width: 0; +} .textarea-wrap { height: auto; align-items: flex-start; diff --git a/web/src/admin/pages/lanDisplay.ts b/web/src/admin/pages/lanDisplay.ts index bc510fc..a57c123 100644 --- a/web/src/admin/pages/lanDisplay.ts +++ b/web/src/admin/pages/lanDisplay.ts @@ -1,4 +1,11 @@ -import type { NetworkAddress, NetworkInfo } from '@/shared' +import type { + NetworkAddress, + NetworkAddressScope, + NetworkConfidence, + NetworkInfo, + NetworkSkipCode, + NetworkSkippedInterface, +} from '@/shared' /** * 「局域网大屏」卡片的纯逻辑。 @@ -13,6 +20,27 @@ export interface LanDisplayLink { ip: string /** 完整地址:`http://:`。 */ url: string + /** 地址来源(服务端的 confidence,手填的按 high 处理)。 */ + confidence: NetworkConfidence + /** 是否是操作员手填的地址(卡片上要带「手动」徽标、可删除)。 */ + manual: boolean + /** + * 这条地址要额外说明的一句话(low / route 才有,high 为 null)。 + * + * 文案在这里算好、模板直接用:提示与来源的对应关系只维护一处,页面里不会出现 + * 「某个分支忘了提示」或「两个分支都提示」这类只有肉眼才能发现的偏差。 + */ + hint: string | null + /** 地址类别(服务端给的;老服务端没这个字段时为 null)。 */ + scope: NetworkAddressScope | null + /** + * 类别说明:只有自动配置地址(link-local)才需要解释一句「本网段没有 DHCP」, + * 私网 / 全局地址什么都不说。 + * + * 它与 hint 是**两条互不影响的提示**:一条说「这是什么地址」,一条说「怎么找到的」, + * 两个都可以有,页面上各占一行。 + */ + scopeHint: string | null } /** 拼一条局域网大屏地址;IPv6 要加方括号,否则端口那一段会被当成地址的一部分。 */ @@ -22,14 +50,242 @@ export function displayUrl(address: NetworkAddress, network: NetworkInfo): strin } /** - * 把服务端返回的网络信息摊平成可点的链接。 + * 把服务端返回的网络信息 + 操作员手填的地址摊平成可点的链接。 * * 端口缺失(服务端异常 / 老版本接口)时给空数组:拼 `http://ip:0/display` 这种 - * 「看着像链接、点开连不上」的东西比空态更糟。 + * 「看着像链接、点开连不上」的东西比空态更糟。手填地址同样靠这个端口拼,所以一起落空。 + * + * 低置信(`confidence: 'low'` / `'route'`)的地址**照常给链接**:它们只是来源不常规 + * (网卡 up 位读不到 / 是靠默认路由推断的),地址本身往往真的能用;藏起来等于把现场 + * 唯一能用的那一条也拿走。 + * + * 手填的排在自动检测结果**前面**:现场手填就是因为他认定「自动出来的那几条不是我要的网段」, + * 他关心的那条理应第一眼就看到、二维码也出在它上面。同一个地址既手填又被自动检测到时只留 + * 手填的那条(同一个链接出现两次,现场会以为是两个不同的东西)。 */ -export function lanDisplayLinks(network: NetworkInfo): LanDisplayLink[] { +export function lanDisplayLinks(network: NetworkInfo, manual: string[] = []): LanDisplayLink[] { if (!Number.isFinite(network.port) || network.port <= 0) return [] - return network.addresses.map((address) => ({ ip: address.ip, url: displayUrl(address, network) })) + const manualLinks = manual.map((ip) => manualLink(ip, network)) + const manualSet = new Set(manual) + const autoLinks = network.addresses + .filter((address) => !manualSet.has(address.ip)) + .map((address) => { + const confidence = address.confidence ?? 'high' + const scope = address.scope ?? null + return { + ip: address.ip, + url: displayUrl(address, network), + confidence, + manual: false, + hint: confidenceHint(confidence), + scope, + scopeHint: scopeHint(scope), + } + }) + return [...manualLinks, ...autoLinks] +} + +/** + * 手填地址也是普通地址:接口名留空(它不是从哪张网卡枚举出来的,写一个假网卡名只会误导)。 + * + * confidence 记 high 只是形状上的统一(这个字段本是服务端给的来源判定,手填的地址压根没经过 + * 服务端),真正区分手填的是 manual;也正因为它是操作员自己认定的地址,不给任何来源提示语。 + * scope 同理留 null:不按 IP 前缀猜类别(猜错会给出与事实不符的说明),也就不给类别提示。 + */ +function manualLink(ip: string, network: NetworkInfo): LanDisplayLink { + return { + ip, + url: displayUrl({ ip, interface: '', family: ip.includes(':') ? 'ipv6' : 'ipv4' }, network), + confidence: 'high', + manual: true, + hint: null, + scope: null, + scopeHint: null, + } +} + +/** + * 来源提示:只有「不常规的来源」才需要解释一句,正常枚举出来的(high)什么都不说。 + * 现场看到提示才知道这条打不开时该换下一条,而不是以为整台机器没网。 + */ +export function confidenceHint(confidence: NetworkConfidence): string | null { + if (confidence === 'low') return LAN_LOW_CONFIDENCE_HINT + if (confidence === 'route') return LAN_ROUTE_CONFIDENCE_HINT + return null +} + +/** + * 类别提示:只有自动配置地址要解释——它看着像「网络没配好」,实际在无 DHCP 的独立内网里 + * 就是正常且唯一可用的地址,不说清现场会以为卡片又出错了。 + */ +export function scopeHint(scope: NetworkAddressScope | null): string | null { + return scope === 'link-local' ? LAN_LINK_LOCAL_SCOPE_HINT : null +} + +/** + * 跳过原因(服务端 `skipped[].code`)的中文说明。 + * + * 服务端只下发机器可读的 code:同一件事在服务端启动日志里另有一份面向运维的措辞, + * 而这里面向现场操作员。表必须覆盖全部 code(类型上就是穷尽的,漏一个编译不过)。 + */ +export const NETWORK_SKIP_REASON_LABELS: Record = { + 'no-address': '接口没有地址', + 'only-ipv6': '只有 IPv6 地址', + 'not-up': '接口未连接', + loopback: '回环接口', +} + +/** 未知 code 的兜底:服务端比前端新时也要说人话,不能让原因变成空白。 */ +export function skipReasonLabel(code: string): string { + return NETWORK_SKIP_REASON_LABELS[code as NetworkSkipCode] ?? `未知原因(${code})` +} + +/** 一条被跳过的网卡:「以太网(接口未连接)」。 */ +function skippedLabel(item: NetworkSkippedInterface): string { + return `${item.interface === '' ? '未知网卡' : item.interface}(${skipReasonLabel(item.code)})` +} + +/** 卡片在没有可用地址时给的那句话(老服务端没有 skipped,只能沿用这句)。 */ +export const LAN_EMPTY_FALLBACK = '未检测到局域网地址(可能是没插网线 / 只有回环)。' + +/** + * 低置信地址旁的提示:链接与二维码照常给,但要说明这条是兜底来的—— + * 现场遇到「扫了打不开」时才知道下一步该换哪一条,而不是以为整台机器没网。 + */ +export const LAN_LOW_CONFIDENCE_HINT = + '网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条' + +/** + * 路由探测来的地址旁的提示:它不是从网卡枚举出来的,而是问操作系统「去局域网走哪块网卡」得到的。 + * 这种地址通常就是对的(系统自己路由出来的源地址),但要先说清来源,现场打不开时才不会怀疑机器没网。 + */ +export const LAN_ROUTE_CONFIDENCE_HINT = + '这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条' + +/** + * 自动配置地址(链路本地)旁的提示。 + * + * 这种地址看着像「网络没配好」(系统自配的、没有 DHCP),但在没有 DHCP 的独立内网里它正是 + * 设备之间互访用的地址——现场平板就是靠它打开的。不解释一句,现场会以为卡片又出错了, + * 或者把它当成「不能用」的地址去手填一个更糟的。 + */ +export const LAN_LINK_LOCAL_SCOPE_HINT = '自动配置地址:本网段没有 DHCP,同网段设备可以互相访问' + +/** 空态里最多列出几张网卡,其余的折叠成「等共 N 块网卡」。 */ +export const LAN_SKIP_LIST_LIMIT = 5 + +/** + * 空态文案:把服务端「检查过、但没被采用」的网卡逐条列出来。 + * + * 原来只有一句「未检测到局域网地址(可能是没插网线 / 只有回环)」,而真正的原因可能 + * 是「只有 IPv6」「只有 169.254」「接口没置 up」——现场拿着那句话无从下手。 + * 改成逐条列原因后,主人一眼就能看出该修哪张网卡。 + * 服务端没给 skipped(老版本)时退回原来那句,行为与升级前完全一致。 + * + * 网卡多的机器(虚拟网卡几十张)上原因会排成长长一串,卡片放不下也没人看:只列前 5 张, + * 其余折叠成「等共 N 块网卡」——数字本身就是线索(比如 30 张说明这台机器上虚拟网卡很多)。 + */ +export function lanEmptyNote(network: NetworkInfo): string { + const skipped = network.skipped ?? [] + if (skipped.length === 0) return LAN_EMPTY_FALLBACK + const shown = skipped.slice(0, LAN_SKIP_LIST_LIMIT).map(skippedLabel) + const folded = skipped.length > shown.length ? `等共 ${skipped.length} 块网卡` : '' + return `没找到可用的局域网地址。已检查:${shown.join('、')}${folded}。` +} + +/** 手填地址在 localStorage 里的键。带前缀,免得和同域下别的页面撞。 */ +export const MANUAL_LAN_STORAGE_KEY = 'scoreboard.display.manualLanAddresses' + +/** 手填地址最多存几个:现场可能存着旧的;没有上限的话这条列表会一直长下去。 */ +export const MAX_MANUAL_ADDRESSES = 10 + +/** localStorage 的最小接口;注入假实现后,node 里也能把存取与脏数据这两条路走一遍。 */ +export interface ManualAddressStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +/** 浏览器的 localStorage;隐私模式 / 禁用存储时**读属性本身就抛**,所以整段包在 try 里。 */ +export function manualAddressStorage(): ManualAddressStorage | null { + try { + return (globalThis as { localStorage?: ManualAddressStorage }).localStorage ?? null + } catch { + return null + } +} + +/** 读出持久化的手填地址:坏数据(不是数组 / 不是字符串 / 不是 IP / 重复)一律不认。 */ +export function readManualAddresses(storage: ManualAddressStorage | null): string[] { + if (storage === null) return [] + let raw: string | null + try { + raw = storage.getItem(MANUAL_LAN_STORAGE_KEY) + } catch { + return [] + } + if (raw === null) return [] + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + const out: string[] = [] + for (const item of parsed) { + if (typeof item !== 'string') continue + // 存下来的可能是旧版本写的、或人手工改过的内容:每条都按同一套规则重新校验, + // 免得一个坏地址变成一条点不开的链接,或者把整个列表带崩。 + const result = parseManualAddress(item, out) + if (result.ok) out.push(result.ip) + } + return out.slice(0, MAX_MANUAL_ADDRESSES) +} + +/** 写回手填地址;写不进去(配额满 / 隐私模式)只当没存上,绝不让它影响页面。 */ +export function writeManualAddresses(storage: ManualAddressStorage | null, ips: string[]): void { + if (storage === null) return + try { + storage.setItem(MANUAL_LAN_STORAGE_KEY, JSON.stringify(ips.slice(0, MAX_MANUAL_ADDRESSES))) + } catch { + // 存不下就存不下:地址这一会儿还在页面上,刷新丢了也只是要再填一次。 + } +} + +/** 校验结果:要么给出规整后的地址,要么给出就地红字要显示的那句话。 */ +export type ManualAddressParse = { ok: true; ip: string } | { ok: false; error: string } + +/** + * 校验操作员手输的地址(从 ipconfig 抄来的那串)。 + * + * 规则:首尾空格先去掉——从 ipconfig 复制粘贴时几乎必然带上;中间还有空白就是抄错了。 + * 只认 IPv4 点分十进制与 IPv6 字面量(IPv6 要允许:现场也可能用双栈地址,拼 URL 加方括号 + * 的逻辑 displayUrl 里已经有了)。不做回环 / link-local 过滤:手填的本来就是「操作员比我清楚」 + * 那一条,他真填了 127.0.0.1 也是他自己的判断,卡片不该替他改。 + */ +export function parseManualAddress(raw: string, existing: string[] = []): ManualAddressParse { + const value = raw.trim() + if (value === '') return { ok: false, error: '请先填一个 IP 地址' } + if (/\s/.test(value)) return { ok: false, error: '地址里不能有空格,请重新复制一次 ipconfig 里的地址' } + const ip = canonicalIp(value) + if (ip === null) return { ok: false, error: '这不像一个 IP 地址,请填 ipconfig 里的 IPv4 地址' } + if (existing.includes(ip)) return { ok: false, error: '这个地址已经在列表里了' } + return { ok: true, ip } +} + +/** 认得出就给出规整形式(IPv6 统一成小写压缩写法,去重才靠得住),认不出回 null。 */ +function canonicalIp(value: string): string | null { + const parts = value.split('.') + if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) return value + if (!value.includes(':') || value.includes('%')) return null + try { + // 借 URL 解析器做 IPv6 校验:手写正则太难覆盖压缩写法(:: 的位置、内嵌 IPv4 等), + // 而浏览器/node 的 URL 只接受合法的 IPv6 字面量,顺带把大写与压缩形式规整掉。 + const host = new URL(`http://[${value}]`).hostname + return host === '' ? null : host.slice(1, -1) + } catch { + return null + } } /** 复制用到的宿主能力;全部可注入,这样在 node 里也能把回退路径走一遍。 */ diff --git a/web/src/admin/tests/lan-display-card.test.ts b/web/src/admin/tests/lan-display-card.test.ts index 6e12430..a6afceb 100644 --- a/web/src/admin/tests/lan-display-card.test.ts +++ b/web/src/admin/tests/lan-display-card.test.ts @@ -30,12 +30,87 @@ const { ApiError, adminApi } = await import('../api/client') const PAGE = readFileSync(new URL('../pages/DisplayPage.vue', import.meta.url), 'utf8') -test('卡片文案齐全:标题、一句话说明、复制反馈、扫码说明、空态', () => { +test('卡片文案齐全:标题、一句话说明、复制反馈、扫码说明、来源提示', () => { assert.match(PAGE, /局域网大屏/) assert.match(PAGE, /在别的设备浏览器打开下面的地址,就是一块大屏/) assert.match(PAGE, /手机扫码直接打开/) assert.match(PAGE, /已复制/) - assert.match(PAGE, /未检测到局域网地址(可能是没插网线 \/ 只有回环)/) + // 提示原文钉在 lanDisplay.ts(只有一处),页面只负责渲染 lanDisplayLinks 给的那句话: + // 页面自己判断 confidence 就会漏掉第三种来源(route)。 + assert.match(PAGE, /\{\{ item\.hint \}\}/) + assert.doesNotMatch(PAGE, /LAN_LOW_CONFIDENCE_HINT|LAN_ROUTE_CONFIDENCE_HINT/) +}) + +test('空态文案由 lanEmptyNote 生成(逐条列原因),不写死在页面里', () => { + assert.match(PAGE, /from '\.\/lanDisplay'/) + assert.match(PAGE, /lanEmptyNote,/) + assert.match(PAGE, /

\{\{ lanEmptyText \}\}<\/p>/) + assert.match(PAGE, /const lanEmptyText = computed\(/) + // 老文案属于 lanDisplay.ts(老服务端分支),页面里不该再抄一份。 + assert.doesNotMatch(PAGE, /class="display-note">未检测到局域网地址/) +}) + +test('来源提示只挂在有提示的那条地址旁(high 一句都不说)', () => { + // 提示挂在 item.hint 上,而 hint 由 lanDisplay 按来源给:high → null,low / route → 各自的句子。 + assert.match(PAGE, /

/) + // 链接与二维码不受来源影响:up 位读不到 / 路由推断出来的地址照常渲染。 + assert.match(PAGE, //) + assert.match(PAGE, /v-if="index === 0 && lanQrSvg !== null"/) +}) + +test('类别说明:只有 scopeHint 不为空的那条地址才出现(私网 / 全局没有)', () => { + assert.match( + PAGE, + /

\{\{ item\.scopeHint \}\}<\/p>/, + ) + // 页面不自己判类别、更不按前缀猜:说明句由 lanDisplay 按服务端给的 scope 算好。 + assert.doesNotMatch(PAGE, /link-local|169\.254/) +}) + +test('来源提示与类别说明是两条独立的行,不能互相顶掉', () => { + // 两条都必须是 v-if:写成 v-if + v-else 的话,一条地址明明两个提示都该给,却只会显示一个。 + const hintIndex = PAGE.indexOf('{{ item.hint }}') + const scopeIndex = PAGE.indexOf('{{ item.scopeHint }}') + assert.ok(hintIndex > 0 && scopeIndex > hintIndex, '两条提示都要渲染,且各占一行') + assert.doesNotMatch(PAGE, /v-else[\s\S]{0,200}?\{\{ item\.scopeHint \}\}/) +}) + +test('手填入口:标题、副说明、输入框、添加按钮、就地红字', () => { + assert.match(PAGE, /手动添加地址/) + assert.match(PAGE, /自动检测不到时,把 ipconfig 里的 IPv4 地址填进来/) + assert.match(PAGE, /v-model="manualInput"/) + assert.match(PAGE, /@keyup\.enter="addManualAddress"/) + assert.match(PAGE, />添加<\/button>/) + // 非法输入必须当场红字说明,而不是点了没反应。 + assert.match(PAGE, /v-if="manualError !== null" class="mx-helper mx-helper-error"/) + // 输入框本身也要标红:现场一眼看出是这一格填错了。 + assert.match(PAGE, /:class="\{ 'mx-input-error': manualError !== null \}"/) + // 只在拼得出链接时给入口(端口没下发时填了也白填)。 + assert.match(PAGE, /v-if="lanManualEnabled" class="lan-manual"/) +}) + +test('手填的地址带「手动」徽标、可删除,并写进 localStorage', () => { + assert.match(PAGE, /v-if="item\.manual" class="mx-badge mx-badge-neutral">手动\(readManualAddresses\(manualAddressStorage\(\)\)\)/) + assert.match(PAGE, /writeManualAddresses\(manualAddressStorage\(\), manualIps\.value\)/) + // 删除只动手填列表;自动检测出来的地址不写回存储、也不受影响。 + assert.match(PAGE, /manualIps\.value\.filter\(\(item\) => item !== ip\)/) +}) + +test('添加这条路真的会校验并持久化(只看「有 writeManualAddresses」会被别处的调用蒙混过去)', () => { + const addFn = /function addManualAddress\(\): void \{[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? '' + assert.notEqual(addFn, '', '找不到 addManualAddress') + assert.match(addFn, /parseManualAddress\(manualInput\.value, manualIps\.value\)/) + assert.match(addFn, /writeManualAddresses\(manualAddressStorage\(\), manualIps\.value\)/) + // 校验不过必须当场返回,不能把非法值塞进列表。 + assert.match(addFn, /if \(!result\.ok\) \{[\s\S]*?return/) +}) + +test('手填入口不依赖权限与示例数据:没有 gate / isMock 判断', () => { + const manualBlock = /

[\s\S]*?<\/div>\s*<\/div>/.exec(PAGE)?.[0] ?? '' + assert.notEqual(manualBlock, '') + assert.doesNotMatch(manualBlock, /lanGateReason|isMock|readOnly/) }) test('链接本身就是可点的新窗口链接', () => { @@ -43,8 +118,8 @@ test('链接本身就是可点的新窗口链接', () => { }) test('页面的地址只来自 lanDisplayLinks(),不写死端口或路径', () => { - assert.match(PAGE, /import \{ copyText, lanDisplayLinks \} from '\.\/lanDisplay'/) - assert.match(PAGE, /lanDisplayLinks\(lanInfo\.value\)/) + assert.match(PAGE, /from '\.\/lanDisplay'/) + assert.match(PAGE, /lanDisplayLinks\(lanInfo\.value, manualIps\.value\)/) // 19540 一旦出现在页面里,就说明有人在拼地址时替服务端做了决定。 assert.doesNotMatch(PAGE, /19540/) }) diff --git a/web/src/admin/tests/lan-display.test.ts b/web/src/admin/tests/lan-display.test.ts index 99b2225..fd89a06 100644 --- a/web/src/admin/tests/lan-display.test.ts +++ b/web/src/admin/tests/lan-display.test.ts @@ -1,8 +1,28 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' -import type { NetworkInfo } from '@/shared' -import { copyText, displayUrl, lanDisplayLinks } from '../pages/lanDisplay' +import type { NetworkConfidence, NetworkInfo, NetworkSkipCode } from '@/shared' +import { + confidenceHint, + copyText, + displayUrl, + LAN_EMPTY_FALLBACK, + LAN_LINK_LOCAL_SCOPE_HINT, + LAN_LOW_CONFIDENCE_HINT, + LAN_ROUTE_CONFIDENCE_HINT, + LAN_SKIP_LIST_LIMIT, + lanDisplayLinks, + lanEmptyNote, + MANUAL_LAN_STORAGE_KEY, + MAX_MANUAL_ADDRESSES, + NETWORK_SKIP_REASON_LABELS, + parseManualAddress, + readManualAddresses, + scopeHint, + skipReasonLabel, + writeManualAddresses, + type ManualAddressStorage, +} from '../pages/lanDisplay' /** * 「局域网大屏」卡片的两条硬要求: @@ -55,6 +75,373 @@ test('端口为 0 时给空数组,不拼 http://ip:0/display 这种点不开 assert.deepEqual(lanDisplayLinks({ ...NETWORK, port: 0 }), []) }) +test('低置信地址照常生成链接(藏起来等于把现场唯一能用的那条也拿走),并带上 confidence', () => { + const info: NetworkInfo = { + ...NETWORK, + addresses: [ + { ip: '192.168.1.23', interface: '以太网', family: 'ipv4', confidence: 'low' }, + { ip: '10.0.8.7', interface: 'wlan0', family: 'ipv4' }, + ], + } + + assert.deepEqual(lanDisplayLinks(info), [ + { + ip: '192.168.1.23', + url: 'http://192.168.1.23:19540/display', + confidence: 'low', + manual: false, + hint: LAN_LOW_CONFIDENCE_HINT, + scope: null, + scopeHint: null, + }, + // 老服务端没有 confidence / scope 字段:按 high,且不加任何提示。 + { + ip: '10.0.8.7', + url: 'http://10.0.8.7:19540/display', + confidence: 'high', + manual: false, + hint: null, + scope: null, + scopeHint: null, + }, + ]) +}) + +test('路由推断出来的地址标 route,并给一句自己的提示', () => { + const info: NetworkInfo = { + ...NETWORK, + addresses: [{ ip: '192.168.5.9', interface: '默认路由', family: 'ipv4', confidence: 'route', scope: 'private' }], + } + + assert.deepEqual(lanDisplayLinks(info), [ + { + ip: '192.168.5.9', + url: 'http://192.168.5.9:19540/display', + confidence: 'route', + manual: false, + hint: LAN_ROUTE_CONFIDENCE_HINT, + scope: 'private', + scopeHint: null, + }, + ]) +}) + +test('来源提示:只有不常规的来源才说话,high 一句都不多说', () => { + assert.equal(confidenceHint('high'), null) + assert.equal(confidenceHint('low'), LAN_LOW_CONFIDENCE_HINT) + assert.equal(confidenceHint('route'), LAN_ROUTE_CONFIDENCE_HINT) + // 三条文案必须互不相同:route 的地址被说成「网卡状态读不到」会把人引到完全错的方向。 + assert.notEqual(LAN_ROUTE_CONFIDENCE_HINT, LAN_LOW_CONFIDENCE_HINT) + assert.equal(LAN_ROUTE_CONFIDENCE_HINT, '这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条') +}) + +// ── 地址类别(scope)──────────────────────────────────────────────────── + +test('自动配置地址给一句说明,私网 / 全局 / 未知都不给', () => { + assert.equal(LAN_LINK_LOCAL_SCOPE_HINT, '自动配置地址:本网段没有 DHCP,同网段设备可以互相访问') + assert.equal(scopeHint('link-local'), LAN_LINK_LOCAL_SCOPE_HINT) + assert.equal(scopeHint('private'), null) + assert.equal(scopeHint('global'), null) + assert.equal(scopeHint(null), null) +}) + +test('主人现场那条地址:自动配置地址照常给链接、二维码位置在第一条,并带上说明', () => { + const info: NetworkInfo = { + ...NETWORK, + addresses: [ + { ip: '192.168.1.23', interface: 'WLAN', family: 'ipv4', confidence: 'high', scope: 'private' }, + { ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'high', scope: 'link-local' }, + ], + } + + const links = lanDisplayLinks(info) + assert.deepEqual( + links.map((link) => [link.ip, link.scope, link.scopeHint]), + [ + ['192.168.1.23', 'private', null], + // 私网在前、自动配置在后;后者照常是可点的链接,只是多一句说明。 + ['169.254.79.190', 'link-local', LAN_LINK_LOCAL_SCOPE_HINT], + ], + ) + assert.equal(links[1]!.url, 'http://169.254.79.190:19540/display') +}) + +test('来源提示与类别提示互不顶掉:同一条地址两条都给', () => { + const info: NetworkInfo = { + ...NETWORK, + addresses: [{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'low', scope: 'link-local' }], + } + + const link = lanDisplayLinks(info)[0]! + assert.equal(link.hint, LAN_LOW_CONFIDENCE_HINT) + assert.equal(link.scopeHint, LAN_LINK_LOCAL_SCOPE_HINT) +}) + +test('scope 认不出(老服务端)时不给类别说明,也不按前缀自己猜', () => { + const info: NetworkInfo = { + ...NETWORK, + // 老服务端不返回 scope:即使地址恰好长得像自动配置地址,也不加说明(分类是服务端的知识)。 + addresses: [{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'high' }], + } + const link = lanDisplayLinks(info)[0]! + assert.equal(link.scope, null) + assert.equal(link.scopeHint, null) +}) + +test('低置信提示的文案只有一处', () => { + assert.equal(LAN_LOW_CONFIDENCE_HINT, '网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条') +}) + +test('空态逐条列出被跳过的网卡与原因', () => { + const info: NetworkInfo = { + ...NETWORK, + addresses: [], + skipped: [ + { interface: '以太网', code: 'not-up', addresses: ['10.0.0.10/24'] }, + { interface: 'WLAN', code: 'only-ipv6', addresses: ['fe80::1/64'] }, + ], + } + + assert.equal( + lanEmptyNote(info), + '没找到可用的局域网地址。已检查:以太网(接口未连接)、WLAN(只有 IPv6 地址)。', + ) +}) + +test('空态:服务端没给 skipped(老版本)时退回原来那句话', () => { + assert.equal(lanEmptyNote({ ...NETWORK, addresses: [] }), LAN_EMPTY_FALLBACK) + assert.equal(lanEmptyNote({ ...NETWORK, addresses: [], skipped: [] }), LAN_EMPTY_FALLBACK) +}) + +test('空态:网卡名缺失时也不能拼出「()」', () => { + const info: NetworkInfo = { + ...NETWORK, + addresses: [], + skipped: [{ interface: '', code: 'no-address', addresses: [] }], + } + assert.equal(lanEmptyNote(info), '没找到可用的局域网地址。已检查:未知网卡(接口没有地址)。') +}) + +test('原因映射覆盖全部 code(一个都不能漏)与未知 code 的兜底', () => { + // 集合里没有 link-local:自动配置地址现在是候选地址,不再是「被跳过」的理由。 + const codes: NetworkSkipCode[] = ['no-address', 'only-ipv6', 'not-up', 'loopback'] + + // 表本身必须与 code 集合完全对应:多一个少一个都在这里红灯。 + assert.deepEqual(Object.keys(NETWORK_SKIP_REASON_LABELS).sort(), [...codes].sort()) + for (const code of codes) { + const label = skipReasonLabel(code) + assert.equal(label, NETWORK_SKIP_REASON_LABELS[code]) + assert.notEqual(label.trim(), '') + // 未知 code 之外不能落到兜底话术上,否则「漏了 code」会被兜底悄悄盖住。 + assert.doesNotMatch(label, /未知原因/) + } + // 服务端比前端新:认不出的 code 也要说人话,不能留空白。 + assert.equal(skipReasonLabel('brand-new-code'), '未知原因(brand-new-code)') + // 链路本地不再是跳过原因:旧服务端万一还发这个 code,也不能显示成「地址不能用」那一类说法。 + assert.notEqual(skipReasonLabel('link-local'), '只有自动配置地址(169.254.x)') +}) + +test('每条地址的置信度只可能是 high / low / route', () => { + const info: NetworkInfo = { + ...NETWORK, + addresses: [{ ip: '10.0.0.4', interface: 'ZeroTier', family: 'ipv4' }], + } + const confidence: NetworkConfidence = lanDisplayLinks(info)[0]!.confidence + assert.equal(confidence, 'high') +}) + +// ── 空态的折叠 ──────────────────────────────────────────────────────────── + +test('空态最多列 5 张网卡,其余的折叠成「等共 N 块网卡」', () => { + const many: NetworkInfo = { + ...NETWORK, + addresses: [], + skipped: Array.from({ length: 30 }, (_, index) => ({ + interface: `vEthernet ${index + 1}`, + code: 'no-address' as const, + addresses: [], + })), + } + + const note = lanEmptyNote(many) + // 只有前 5 张带原因,第 6 张的接口名不能出现(否则等于没折叠)。 + for (let index = 1; index <= LAN_SKIP_LIST_LIMIT; index += 1) { + assert.match(note, new RegExp(`vEthernet ${index}(接口没有地址)`)) + } + assert.doesNotMatch(note, /vEthernet 6(/) + assert.match(note, /等共 30 块网卡。$/) + assert.equal( + note, + '没找到可用的局域网地址。已检查:vEthernet 1(接口没有地址)、vEthernet 2(接口没有地址)、' + + 'vEthernet 3(接口没有地址)、vEthernet 4(接口没有地址)、vEthernet 5(接口没有地址)等共 30 块网卡。', + ) +}) + +test('空态:不超过 5 张时不折叠(不出现「等共」)', () => { + const info: NetworkInfo = { + ...NETWORK, + addresses: [], + skipped: Array.from({ length: LAN_SKIP_LIST_LIMIT }, (_, index) => ({ + interface: `网卡${index + 1}`, + code: 'loopback' as const, + addresses: [], + })), + } + assert.doesNotMatch(lanEmptyNote(info), /等共/) + // 恰好 5 张也要一张不漏地列出来。 + assert.match(lanEmptyNote(info), /网卡5(回环接口)。$/) +}) + +// ── 手填地址 ────────────────────────────────────────────────────────────── + +test('手填地址:合法 IP 排在自动检测结果前面,带「手动」标记与链接', () => { + const links = lanDisplayLinks(NETWORK, ['192.168.9.9']) + + assert.deepEqual(links, [ + { + ip: '192.168.9.9', + url: 'http://192.168.9.9:19540/display', + confidence: 'high', + manual: true, + // 手填的是操作员自己的判断,不需要额外解释来源;类别也不猜(scope 为 null)。 + hint: null, + scope: null, + scopeHint: null, + }, + { + ip: '192.168.1.23', + url: 'http://192.168.1.23:19540/display', + confidence: 'high', + manual: false, + hint: null, + scope: null, + scopeHint: null, + }, + { + ip: '10.0.8.7', + url: 'http://10.0.8.7:19540/display', + confidence: 'high', + manual: false, + hint: null, + scope: null, + scopeHint: null, + }, + ]) +}) + +test('手填地址:多个按添加顺序排在前面,IPv6 拼 URL 带方括号', () => { + const links = lanDisplayLinks(NETWORK, ['10.1.1.1', 'fe80::1']) + assert.deepEqual( + links.map((link) => link.ip), + ['10.1.1.1', 'fe80::1', '192.168.1.23', '10.0.8.7'], + ) + assert.equal(links[1]!.url, 'http://[fe80::1]:19540/display') + assert.equal(links[1]!.manual, true) +}) + +test('手填地址:与自动检测重复时只留手填的那条(同一个链接不出现两次)', () => { + const links = lanDisplayLinks(NETWORK, ['192.168.1.23']) + assert.deepEqual( + links.map((link) => [link.ip, link.manual]), + [ + ['192.168.1.23', true], + ['10.0.8.7', false], + ], + ) +}) + +test('手填地址:端口没下发时连手填的也拼不出链接(不留半条能点的地址)', () => { + assert.deepEqual(lanDisplayLinks({ ...NETWORK, port: 0 }, ['192.168.9.9']), []) +}) + +test('手填校验:合法 IPv4 / IPv6 收下,非法输入给红字且不添加', () => { + assert.deepEqual(parseManualAddress('192.168.1.23'), { ok: true, ip: '192.168.1.23' }) + // 从 ipconfig 复制粘贴常带首尾空格:去掉后就合法。 + assert.deepEqual(parseManualAddress(' 192.168.1.23 '), { ok: true, ip: '192.168.1.23' }) + // IPv6 允许,并统一成小写压缩写法(去重才靠得住)。 + assert.deepEqual(parseManualAddress('FE80::0001'), { ok: true, ip: 'fe80::1' }) + assert.deepEqual(parseManualAddress('::1'), { ok: true, ip: '::1' }) + + const bad: Array<[string, RegExp]> = [ + ['', /请先填一个 IP 地址/], + [' ', /请先填一个 IP 地址/], + ['192.168.1.23 10.0.0.1', /不能有空格/], + ['192.168.1.2 3', /不能有空格/], + ['192.168.1', /不像一个 IP 地址/], + ['999.168.1.1', /不像一个 IP 地址/], + ['192.168.1.256', /不像一个 IP 地址/], + ['abc', /不像一个 IP 地址/], + ['not:an:ip', /不像一个 IP 地址/], + ['fe80::1%eth0', /不像一个 IP 地址/], + ] + for (const [raw, message] of bad) { + const result = parseManualAddress(raw) + assert.equal(result.ok, false, `${raw} 不该通过校验`) + if (!result.ok) assert.match(result.error, message) + } +}) + +test('手填校验:重复的地址不添加,并说清原因', () => { + const result = parseManualAddress('10.0.0.4', ['192.168.1.23', '10.0.0.4']) + assert.equal(result.ok, false) + if (!result.ok) assert.match(result.error, /已经在列表里/) +}) + +test('手填持久化:localStorage 往返,坏数据一律不认', () => { + const store = fakeStorage() + + assert.deepEqual(readManualAddresses(store), []) + writeManualAddresses(store, ['192.168.9.9', '10.1.1.1']) + assert.equal(store.value(MANUAL_LAN_STORAGE_KEY), '["192.168.9.9","10.1.1.1"]') + assert.deepEqual(readManualAddresses(store), ['192.168.9.9', '10.1.1.1']) + + // 脏数据:不是数组 / 元素不是字符串 / 不是 IP / 重复 —— 全部丢掉,不能让页面崩。 + store.setValue(MANUAL_LAN_STORAGE_KEY, '{"not":"an array"}') + assert.deepEqual(readManualAddresses(store), []) + store.setValue(MANUAL_LAN_STORAGE_KEY, '{ 这不是 json') + assert.deepEqual(readManualAddresses(store), []) + store.setValue(MANUAL_LAN_STORAGE_KEY, JSON.stringify(['192.168.1.23', 42, 'abc', ' ', '192.168.1.23'])) + assert.deepEqual(readManualAddresses(store), ['192.168.1.23']) + + // 超量的旧数据要在读的时候就被截断,不然键里会一直堆着几十条。 + store.setValue( + MANUAL_LAN_STORAGE_KEY, + JSON.stringify(Array.from({ length: MAX_MANUAL_ADDRESSES + 5 }, (_, index) => `10.0.0.${index + 1}`)), + ) + assert.equal(readManualAddresses(store).length, MAX_MANUAL_ADDRESSES) +}) + +test('手填持久化:存储不可用(隐私模式 / 配额满)时静默降级,不抛异常', () => { + assert.deepEqual(readManualAddresses(null), []) + writeManualAddresses(null, ['192.168.1.23']) + + const throwing: ManualAddressStorage = { + getItem: () => { + throw new Error('SecurityError') + }, + setItem: () => { + throw new Error('QuotaExceededError') + }, + } + assert.deepEqual(readManualAddresses(throwing), []) + writeManualAddresses(throwing, ['192.168.1.23']) +}) + +/** 内存版 localStorage:node 里没有浏览器存储,存取两条路都要能测。 */ +function fakeStorage(): ManualAddressStorage & { value(key: string): string | null; setValue(key: string, raw: string): void } { + const data = new Map() + return { + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value) + }, + value: (key) => data.get(key) ?? null, + setValue: (key, raw) => { + data.set(key, raw) + }, + } +} + test('复制:navigator.clipboard 不存在时走到 execCommand 回退', async () => { const seen: string[] = [] const ok = await copyText('http://192.168.1.23:19540/display', { diff --git a/web/src/shared/api.ts b/web/src/shared/api.ts index 59788e6..62d7820 100644 --- a/web/src/shared/api.ts +++ b/web/src/shared/api.ts @@ -27,7 +27,9 @@ import type { ImportConflictDecision, LiveEvents, NetworkAddress, + NetworkAddressScope, NetworkInfo, + NetworkSkippedInterface, Page, PageQuery, PointAdjustment, @@ -721,6 +723,30 @@ function readNetworkAddress(value: unknown): NetworkAddress | null { ip, interface: readString(value, 'interface') ?? '', family: readString(value, 'family') ?? 'ipv4', + // 只认服务端契约里的 low / route 两个「需要额外提示」的值:老服务端没有这个字段 + // (它的地址本来就走严格轮),值也不认识时按 high 处理——凭一个没见过的值在卡片上 + // 多印一句提示,是凭空造出来的噪声。 + confidence: value.confidence === 'low' || value.confidence === 'route' ? value.confidence : 'high', + // 类别同样只认契约里的三个值;认不出(老服务端)时留 undefined,卡片就不加类别说明。 + // 绝不按 IP 前缀在这里补一个:分类是服务端的知识,前端猜出来的值会和它打架。 + scope: readScope(value.scope), + } +} + +function readScope(value: unknown): NetworkAddressScope | undefined { + return value === 'private' || value === 'global' || value === 'link-local' ? value : undefined +} + +/** 归一化 `skipped[]` 的一条:没有 code 的行解释不了任何事,直接丢掉。 */ +function readNetworkSkipped(value: unknown): NetworkSkippedInterface | null { + if (!isRecord(value)) return null + const code = readString(value, 'code') + if (code === null || code === '') return null + const rawAddresses = Array.isArray(value['addresses']) ? value['addresses'] : [] + return { + interface: readString(value, 'interface') ?? '', + code, + addresses: rawAddresses.filter((item): item is string => typeof item === 'string'), } } @@ -730,10 +756,13 @@ function readNetworkAddress(value: unknown): NetworkAddress | null { * 端口/路径是拼地址的原料,前端不替服务端猜:缺端口就留 0,由页面判断「读不到可用地址」 * 并给说明——编一个默认端口出来会得到一条看着对、实际打不开的链接。 * addresses 不是数组时按「没检测到地址」处理,交给页面显示空态。 + * skipped 不是数组时归一化成空数组(等价于「没有可解释的网卡」):老服务端压根不返回这个 + * 字段,卡片据此退回原来那句空态文案,行为与升级前一致。 */ function readNetworkInfo(value: unknown): NetworkInfo { const port = firstNumberOrNull(isRecord(value) ? value['port'] : null) const rawAddresses = isRecord(value) && Array.isArray(value['addresses']) ? value['addresses'] : [] + const rawSkipped = isRecord(value) && Array.isArray(value['skipped']) ? value['skipped'] : [] return { port: port !== null && port > 0 ? Math.floor(port) : 0, displayPath: normalizePath(readString(value, 'displayPath'), '/display'), @@ -741,6 +770,9 @@ function readNetworkInfo(value: unknown): NetworkInfo { addresses: rawAddresses .map(readNetworkAddress) .filter((item): item is NetworkAddress => item !== null), + skipped: rawSkipped + .map(readNetworkSkipped) + .filter((item): item is NetworkSkippedInterface => item !== null), } } diff --git a/web/src/shared/tests/network-api.test.ts b/web/src/shared/tests/network-api.test.ts index 57db2db..5a76493 100644 --- a/web/src/shared/tests/network-api.test.ts +++ b/web/src/shared/tests/network-api.test.ts @@ -28,7 +28,8 @@ const NETWORK = { port: 19540, displayPath: '/display', adminPath: '/admin', - addresses: [{ ip: '192.168.1.23', interface: 'eth0', family: 'ipv4' }], + addresses: [{ ip: '192.168.1.23', interface: 'eth0', family: 'ipv4', confidence: 'high', scope: 'private' }], + skipped: [], } function client(reply: () => Response): ApiClient { @@ -53,6 +54,87 @@ test('network 打的是 /api/system/network,端口与路径原样透传', asyn assert.deepEqual(info, NETWORK) }) +test('confidence:只认服务端契约里的 low / route,其余按 high', async () => { + const info = await client(() => + jsonResponse({ + ...NETWORK, + addresses: [ + { ip: '192.168.1.23', interface: 'eth0', family: 'ipv4', confidence: 'low' }, + { ip: '192.168.5.9', interface: '默认路由', family: 'ipv4', confidence: 'route' }, + // 老服务端没有这个字段、新服务端给了没见过的值:都按 high,不能凭它多印一句提示。 + { ip: '10.0.0.4', interface: 'ZeroTier', family: 'ipv4' }, + { ip: '10.0.0.5', interface: 'WSL', family: 'ipv4', confidence: 'medium' }, + ], + }), + ).system.network() + + assert.deepEqual( + info.addresses.map((a) => a.confidence), + ['low', 'route', 'high', 'high'], + ) +}) + +test('skipped:逐条归一化,缺 code 的行丢掉', async () => { + const info = await client(() => + jsonResponse({ + ...NETWORK, + addresses: [], + skipped: [ + { interface: '以太网', code: 'not-up', addresses: ['10.0.0.10/24'] }, + { interface: 'WLAN', code: 'only-ipv6', addresses: ['fe80::1/64'] }, + // 没有 code 的行解释不了任何事;缺 addresses 的行补空数组。 + { interface: '蓝牙网络连接' }, + 'x', + { interface: 'eth9', code: 'no-address' }, + ], + }), + ).system.network() + + assert.deepEqual(info.skipped, [ + { interface: '以太网', code: 'not-up', addresses: ['10.0.0.10/24'] }, + { interface: 'WLAN', code: 'only-ipv6', addresses: ['fe80::1/64'] }, + { interface: 'eth9', code: 'no-address', addresses: [] }, + ]) +}) + +test('scope:只认契约里的三个值,认不出一律留 undefined(绝不按前缀猜)', async () => { + const info = await client(() => + jsonResponse({ + ...NETWORK, + addresses: [ + { ip: '192.168.1.23', interface: '以太网', family: 'ipv4', scope: 'private' }, + { ip: '203.0.113.9', interface: '专线', family: 'ipv4', scope: 'global' }, + // 主人现场那条:自动配置地址,服务端如实标 link-local。 + { ip: '169.254.79.190', interface: '以太网', family: 'ipv4', scope: 'link-local' }, + // 老服务端没这个字段、或给了没见过的值:留 undefined,前端不加任何类别说明 + // (也不看 IP 前缀自己补一个:分类是服务端的知识)。 + { ip: '169.254.1.2', interface: '旧网卡', family: 'ipv4' }, + { ip: '10.0.0.4', interface: 'ZeroTier', family: 'ipv4', scope: 'carrier-grade' }, + ], + }), + ).system.network() + + assert.deepEqual( + info.addresses.map((a) => a.scope), + ['private', 'global', 'link-local', undefined, undefined], + ) +}) + +test('老服务端没有 skipped 时给空数组,不影响 addresses 的解析', async () => { + const info = await client(() => + jsonResponse({ + port: 19540, + displayPath: '/display', + adminPath: '/admin', + addresses: [{ ip: '192.168.1.23', interface: 'eth0', family: 'ipv4' }], + }), + ).system.network() + + assert.deepEqual(info.skipped, []) + assert.equal(info.addresses[0]?.ip, '192.168.1.23') + assert.equal(info.addresses[0]?.confidence, 'high') +}) + test('addresses 缺失或不是数组时按「没有可用地址」处理,不是解析失败', async () => { // 没插网线 / 只有回环时服务端就回空数组;接口本身是成功的。 assert.deepEqual((await client(() => jsonResponse({ ...NETWORK, addresses: [] })).system.network()).addresses, []) diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 6c0d73f..d92f8bd 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -673,14 +673,64 @@ export interface HealthInfo { version: string } +/** + * 服务端对一条局域网地址的置信度(也就是它的来源): + * - `high` = 接口置了 up 位,从网卡枚举里挑出来的; + * - `low` = 网卡 up 位读不到,兜底轮捡回来的; + * - `route` = 网卡枚举一条都没给,按系统默认路由推断出来的源地址。 + */ +export type NetworkConfidence = 'high' | 'low' | 'route' + +/** + * 一条局域网地址属于哪一类(服务端按标准库谓词判定后下发): + * - `private` = 私网地址; + * - `global` = 其余(公网 / 运营商地址); + * - `link-local` = 自动配置地址(无 DHCP 的独立内网里它就是唯一能用的那种)。 + * + * 前端**不按 IP 前缀自己猜**:分类是服务端的知识,两边各猜一套迟早对不上。 + */ +export type NetworkAddressScope = 'private' | 'global' | 'link-local' + +/** 服务端解释「一张网卡为什么没贡献地址」的原因码。 */ +export type NetworkSkipCode = 'no-address' | 'only-ipv6' | 'not-up' | 'loopback' + /** 本机的一个局域网地址(GET /api/system/network)。 */ export interface NetworkAddress { - /** 形如 `192.168.1.23`;服务端只回可用地址,回环不会被列进来。 */ + /** + * 形如 `192.168.1.23`;服务端只排掉回环 / 未指定 / 组播 / 广播这类 + * 「作为对端访问目标没有意义」的地址,其余(含自动配置地址)都会列出来。 + */ ip: string /** 网卡名,如 `eth0` / `wlan0` / Windows 的 `WLAN`。 */ interface: string /** 地址族,当前只有 `ipv4` 会被列出(大屏地址是 IPv4 点分十进制)。 */ family: string + /** + * 置信度(来源);`low` 表示服务端读不到这张网卡的 up 位(驱动 / 虚拟网卡), + * `route` 表示它压根不是枚举来的、而是系统默认路由给的。老服务端不返回这个字段,解析时按 `high` 处理。 + */ + confidence?: NetworkConfidence + /** + * 地址类别;老服务端不返回这个字段(解析后为 undefined),此时前端不加任何类别说明。 + * `link-local` 会额外提示一句「本网段没有 DHCP」。 + */ + scope?: NetworkAddressScope +} + +/** + * 一张被跳过的网卡及原因(GET /api/system/network 的 `skipped[]`)。 + * + * 服务端只给机器可读的 `code`,中文文案由前端映射(见 admin/pages/lanDisplay.ts)—— + * 同一条信息在服务端启动日志里另有一份面向运维的措辞。 + * 这个数组**只用来解释空态**,地址列表本身与它无关。 + */ +export interface NetworkSkippedInterface { + /** 网卡名,如 `以太网` / `WLAN`。 */ + interface: string + /** 原因码;服务端契约固定为 NetworkSkipCode 那几个,未知值由前端兜底显示。 */ + code: string + /** 该网卡上的原始地址(CIDR 文本),排查时用得上。 */ + addresses: string[] } /** @@ -695,6 +745,12 @@ export interface NetworkInfo { displayPath: string adminPath: string addresses: NetworkAddress[] + /** + * 被跳过的网卡与原因,前端据此把空态换成「已检查:以太网(接口未连接)…」。 + * + * 声明为可选:老服务端不返回这个字段、示例数据也不填它,两种情况下卡片都要退回原空态文案。 + */ + skipped?: NetworkSkippedInterface[] } /** 数据目录 `event_projs/` 下的一个赛事项目(GET /api/projects,admin)。 */