fix(admin): 局域网大屏卡片按「地址类别」判定 + 三层兜底 + 手填入口(dev_0.5.0)
## 判断失误的修正(主人现场实测出来的) 主人现场是 **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 例。
This commit is contained in:
1 parent
9ef68c2589
commit
c97d3150c9
18 files changed
+2425
-79
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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/虚拟网卡)
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user