Files
Scoreboard/server/internal/httpapi/network.go
T
AskaEth c97d3150c9 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 例。
2026-09-12 18:30:46 +08:00

640 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"bytes"
"log/slog"
"net"
"net/http"
"sort"
"strings"
)
// 大屏控制页要把「当前设备的局域网地址」显示成可点链接 + 二维码:面板自己往往是从
// 127.0.0.1 打开的,浏览器里的 location.host 对平板毫无用处,所以地址只能由服务端枚举网卡得到。
//
// 路径也随响应一起下发:/ → index.html、/display → display.html、/admin → admin.html 的映射
// 写在 server.go 的 staticHandler 里。前端各自硬编码一份,改前端产物名时就会有两处要跟着动。
const (
displayPagePath = "/display"
adminPagePath = "/admin"
)
// lanAddress 是一个「局域网里其他设备能连上这台机器」的候选地址。
//
// family 目前恒为 ipv4:IPv6 要写成 http://[fe80::1%eth0]:port/display,方括号与 %zone
// 粘到平板浏览器里出错率高,而现场本来就是 IPv4 内网。留着这个字段是为了以后真要补 IPv6 时
// 前端不必改结构(见 06/07 文档里的前端约定)。
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"`
Skipped []skippedInterface `json:"skipped"`
}
// ifaceSnapshot 是 net.Interface 里本接口用得上的那部分。
//
// 单独抽出来是为了让筛选与排序成为纯函数:net.Interface 的 Addrs() 直接问操作系统,
// 拿真网卡根本造不出「网线 + WiFi 双网卡」「两张网卡配同一个地址」这类现场,
// 而这正是最需要钉住的行为。测试构造快照即可,真网卡一行都不用碰。
type ifaceSnapshot struct {
Name string
Flags net.Flags
Addrs []net.Addr
}
// readInterfaces 读一次本机网卡。读不到(极罕见)时回错误,由调用方决定怎么记:
// HTTP 路径要降级成空列表(前端显示空态),启动日志那边要换成一条 WARN。
func readInterfaces() ([]ifaceSnapshot, error) {
list, err := net.Interfaces()
if err != nil {
return nil, err
}
out := make([]ifaceSnapshot, 0, len(list))
for _, iface := range list {
addrs, err := iface.Addrs()
if err != nil {
// 单张网卡读不到地址不影响别的网卡:现场常见的是一张 down 掉的虚拟网卡报错。
continue
}
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
}
// 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, 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
ip4 net.IP
}
seen := make(map[string]struct{})
cands := make([]candidate, 0, len(ifaces))
for _, iface := range ifaces {
if !keep(iface.Flags) {
continue
}
for _, raw := range iface.Addrs {
ip4 := addrIP(raw).To4()
if ip4 == nil {
continue
}
scope, usable := ipv4Scope(ip4)
if !usable {
continue
}
key := ip4.String()
// 同一地址配在多个接口上(桥接、双网卡同网段)时只留先出现的一条:
// 列表里两条一模一样的链接,现场会以为是两个不同的东西。
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
cands = append(cands, candidate{
entry: lanAddress{
IP: key,
Interface: iface.Name,
Family: "ipv4",
Confidence: confidence,
Scope: scope,
},
ip4: ip4,
})
}
}
sort.Slice(cands, func(i, j int) bool {
return lanLess(cands[i].entry, cands[i].ip4, cands[j].entry, cands[j].ip4)
})
out := make([]lanAddress, 0, len(cands))
for _, c := range cands {
out = append(out, c.entry)
}
return out
}
// 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 打开的,
// 真把它显示出来会误导现场照抄一个只有本机能打开的地址)。
func usableInterface(flags net.Flags) bool {
return flags&net.FlagUp != 0 && flags&net.FlagLoopback == 0
}
// 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.IsMulticast(), ip4.Equal(net.IPv4bcast):
return "", false
case ip4.IsLinkLocalUnicast():
return scopeLinkLocal, true
case ip4.IsPrivate():
return scopePrivate, true
default:
return scopeGlobal, true
}
}
// lanRank 是排序档位(越小越靠前):现场「网线 + WiFi 双网卡」时,排第一的应该是他们真正在用的那个。
//
// 分档依据是各私有段在实际现场的常见程度:
// - 0:192.168.0.0/16 —— 家用与小型办公路由器的出厂段,现场路由器 / 无线热点几乎都在这里;
// - 1:10.0.0.0/8 —— 稍大的内网、企业路由器与手机热点(部分机型)常用的段;
// - 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:
return 1
case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31:
return 2
default:
return 3
}
}
// addrIP 从 net.Addr 里取出 IP。网卡地址实际都是 *net.IPNet,*net.IPAddr 只是兜底。
func addrIP(addr net.Addr) net.IP {
switch a := addr.(type) {
case *net.IPNet:
return a.IP
case *net.IPAddr:
return a.IP
}
return nil
}
// 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: 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
}
}