package httpapi import ( "bytes" "net" "net/http" "sort" ) // 大屏控制页要把「当前设备的局域网地址」显示成可点链接 + 二维码:面板自己往往是从 // 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"` } // networkInfo 是 GET /api/system/network 的响应体。 type networkInfo struct { Port int `json:"port"` DisplayPath string `json:"displayPath"` AdminPath string `json:"adminPath"` Addresses []lanAddress `json:"addresses"` } // ifaceSnapshot 是 net.Interface 里本接口用得上的那部分。 // // 单独抽出来是为了让筛选与排序成为纯函数:net.Interface 的 Addrs() 直接问操作系统, // 拿真网卡根本造不出「网线 + WiFi 双网卡」「两张网卡配同一个地址」这类现场, // 而这正是最需要钉住的行为。测试构造快照即可,真网卡一行都不用碰。 type ifaceSnapshot struct { Name string Flags net.Flags Addrs []net.Addr } // snapshotInterfaces 读一次本机网卡。读不到(极罕见)时返回空切片: // 前端据此显示「未检测到局域网地址」,比报错更接近事实——服务本身是好的。 func (s *server) snapshotInterfaces() []ifaceSnapshot { list, err := net.Interfaces() if err != nil { s.logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err) return nil } 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 } // lanAddresses 从网卡快照里挑出可用的局域网 IPv4 地址,按「现场最可能用的那个」排在最前。 // 结果永远非 nil:没有任何可用地址时返回空切片,前端才知道这是「没有」而不是「没拿到字段」。 func lanAddresses(ifaces []ifaceSnapshot) []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 !usableInterface(iface.Flags) { continue } for _, raw := range iface.Addrs { ip4 := addrIP(raw).To4() if ip4 == nil || !usableLanIPv4(ip4) { 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"}, 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 }) out := make([]lanAddress, 0, len(cands)) for _, c := range cands { out = append(out, c.entry) } 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 } // 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 { switch { case ip4.IsLoopback(), ip4.IsUnspecified(), ip4.IsLinkLocalUnicast(), ip4.IsMulticast(): return false } return true } // lanRank 是排序档位(越小越靠前):现场「网线 + WiFi 双网卡」时,排第一的应该是他们真正在用的那个。 // // 分档依据是各私有段在实际现场的常见程度: // - 0:192.168.0.0/16 —— 家用与小型办公路由器的出厂段,现场路由器 / 无线热点几乎都在这里; // - 1:10.0.0.0/8 —— 稍大的内网、企业路由器与手机热点(部分机型)常用的段; // - 2:172.16.0.0/12 —— RFC1918 三段里最少见的,多是虚拟化 / 容器网桥(docker 默认就用它), // 真要拿它给平板用也得排在直连网段之后; // - 3:其余(公网地址、运营商专线地址等)—— 能用就留着,但不给第一顺位: // 现场多半是靠某个中间设备才通的,未必是平板能直连的那个。 func lanRank(ip4 net.IP) int { switch { 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) { return networkInfo{ Port: s.port, DisplayPath: displayPagePath, AdminPath: adminPagePath, Addresses: lanAddresses(s.snapshotInterfaces()), }, nil }