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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user