Browse Source

feat(network): 优化UDP发现回包策略,优先同网段IP

当入站网卡无同网段地址时广播回包;客户端增加路由不可达提示。
yangkaixiang 1 week ago
parent
commit
f6623c70bc

+ 1 - 1
docs/03-通信与HTTP_API.md

@@ -9,7 +9,7 @@
 1. Windows 客户端在指定网卡上广播发现设备
 2. Linux Server 响应自身信息
 
-当 Linux 的多张网卡都配置了 `169.254.x.x` 地址时,Server 会识别广播的入站网卡,在响应中返回该网卡的 IP 和 MAC,并强制通过同一张网卡发送回包。
+当 Linux 的多张网卡都配置了 `169.254.x.x` 地址时,Server 会识别广播的入站网卡,在响应中返回该网卡的维护 IP 和 MAC,并强制通过同一张网卡发送回包。UDP 回包的源 IP 优先选择该网卡上与客户端同网段的地址;没有同网段地址时,使用维护 IP 通过入站网卡广播回包到客户端的 UDP 源端口。入站网卡没有 `169.254.x.x` 维护地址时仍不响应。
 
 建议端口:`50000`
 

+ 1 - 1
docs/05-Server模块设计.md

@@ -89,7 +89,7 @@
 1. 监听 UDP 广播请求
 2. 解析发现报文
 3. 优先按收到广播的本地网卡生成发现响应
-4. 强制使用入站网卡及其 `169.254.x.x` 地址发送回包
+4. 强制使用入站网卡发送回包,源 IP 优先选择该网卡上与客户端同网段的地址;没有同网段地址时广播回包
 5. 返回设备基础信息
 
 边界:

+ 3 - 2
docs/09-使用说明与故障处理.md

@@ -77,7 +77,8 @@ sudo nohup /home/x/nettool-server >/home/x/nettool-server-run.log 2>&1 < /dev/nu
 5. `--password` 可选,用于指定管理密码,默认 `hvAC2026#%`。
 6. 如果启动时修改了 `--password`,Windows 客户端连接时必须填写相同密码。
 7. 如果启动时指定了 `--ip`,该地址需要已存在于 Linux 网口上,否则客户端无法通过该地址连接 Server。
-8. 多张网卡都有 `169.254.x.x` 地址时,Server 会使用收到广播的网卡及其地址返回发现响应。
+8. 多张网卡都有 `169.254.x.x` 地址时,Server 会通过收到广播的网卡返回发现响应,并在响应内公布该网卡的维护 IP。该网卡没有维护 IP 时不响应。
+9. 客户端与入站网卡没有同网段地址时,Server 会通过该网卡广播返回发现响应;此时可以发现设备,但后续 HTTP 连接仍需要可用的双向路由。
 
 ## 3. 正常操作流程
 
@@ -230,7 +231,7 @@ hvAC2026#%
 | 当前网卡未检测到链路,请检查网线连接 | 网线未连接;选错网卡;设备或交换机端口未连接 | 检查网线、网口灯和所选网卡;更换网线或端口后点击“刷新” |
 | 当前网卡没有可用 IPv4,无法搜索设备 | Windows 网卡没有 IPv4 地址 | 给该网卡配置 `169.254.100.1/16`,或检查 Windows 网络设置 |
 | 未发现 `169.254` 开头的设备 IP | Server 未运行;Windows 选错网卡;设备维护地址不对;UDP 被防火墙拦截 | 确认选择与 Linux 维护网卡直连的 Windows 网卡;检查 Linux Server;检查设备是否有 `169.254.100.2/16`;临时关闭防火墙验证 |
-| 双击设备后连接失败 | HTTP 端口不通;Server 未监听;密码错误;本机路由不正确 | 检查 `48888` 端口;确认密码;确认 Windows 使用当前网卡访问设备维护 IP |
+| 设备已发现,但无法建立 HTTP 连接 | UDP 广播发现成功,但当前 Windows 网卡无法访问设备公布的 `169.254.x.x` 维护 IP;或 TCP `48888` 被拦截 | 按客户端弹窗中显示的 Windows IP 和设备维护 IP 检查地址及双向路由;必要时给 Windows 网卡配置 `169.254.x.x/16`;检查 `48888` 端口和防火墙 |
 | 密码错误 | 客户端输入密码与 Server 启动参数不一致 | 确认 Server 的 `--password` 参数,重新输入密码 |
 | HTTP 健康检查返回状态码 `401` | 缺少密码或密码错误 | 重新输入正确密码 |
 | HTTP 健康检查返回状态码 `403` | 权限不足或 Server 拒绝执行对应操作 | 对配置保存、重启、关机类操作,确认 Server 使用 `sudo` 启动 |

+ 1 - 1
server/internal/config/config.go

@@ -6,7 +6,7 @@ import (
 	"net"
 )
 
-const ServerVersion = "2026.07.27.1555"
+const ServerVersion = "2026.07.27.1656"
 
 type Config struct {
 	HTTPHost         string

+ 52 - 2
server/internal/discovery/discovery.go

@@ -79,12 +79,24 @@ func (s *Server) Run(ctx context.Context) error {
 			AuthRequired:    true,
 		}
 		payload, _ := json.Marshal(resp)
-		if _, err := writeToUDP(conn, payload, remote, packetInfo, net.ParseIP(lan2IP)); err != nil {
-			s.log.Warn("failed to send udp discovery response", "remote", remote.String(), "local_ip", lan2IP, "interface_index", packetInfo.ifIndex, "error", err.Error())
+		responseSourceIP := findResponseSourceIP(packetInfo.ifIndex, remote.IP)
+		responseTarget := remote
+		broadcastResponse := false
+		if responseSourceIP == nil && packetInfo.ifIndex > 0 {
+			responseSourceIP = net.ParseIP(lan2IP)
+			responseTarget = broadcastResponseTarget(remote)
+			broadcastResponse = true
+		}
+		if _, err := writeToUDP(conn, payload, responseTarget, packetInfo, responseSourceIP); err != nil {
+			s.log.Warn("failed to send udp discovery response", "remote", remote.String(), "target", responseTarget.String(), "source_ip", responseSourceIP.String(), "maintenance_ip", lan2IP, "interface_index", packetInfo.ifIndex, "broadcast", broadcastResponse, "error", err.Error())
 		}
 	}
 }
 
+func broadcastResponseTarget(remote *net.UDPAddr) *net.UDPAddr {
+	return &net.UDPAddr{IP: net.IPv4bcast, Port: remote.Port}
+}
+
 type udpPacketInfo struct {
 	localIP net.IP
 	ifIndex int
@@ -96,6 +108,7 @@ func (s *Server) maintenanceEndpoint(packetInfo udpPacketInfo) (string, string)
 		if lan2IP != "" {
 			return lan2IP, mac
 		}
+		return "", ""
 	}
 	if packetInfo.localIP != nil {
 		lan2IP, mac := findLinkLocalEndpointByInterfaceIP(packetInfo.localIP.String())
@@ -112,6 +125,43 @@ func (s *Server) maintenanceEndpoint(packetInfo udpPacketInfo) (string, string)
 	return findFirstLinkLocalEndpoint()
 }
 
+func findResponseSourceIP(interfaceIndex int, remoteIP net.IP) net.IP {
+	if interfaceIndex <= 0 {
+		return nil
+	}
+	iface, err := net.InterfaceByIndex(interfaceIndex)
+	if err != nil {
+		return nil
+	}
+	addresses, err := iface.Addrs()
+	if err != nil {
+		return nil
+	}
+	return bestSourceIPForRemote(addresses, remoteIP)
+}
+
+func bestSourceIPForRemote(addresses []net.Addr, remoteIP net.IP) net.IP {
+	remoteIPv4 := remoteIP.To4()
+	if remoteIPv4 == nil {
+		return nil
+	}
+	var best net.IP
+	bestPrefix := -1
+	for _, address := range addresses {
+		ipNet, ok := address.(*net.IPNet)
+		if !ok || ipNet.IP.To4() == nil || !ipNet.Contains(remoteIPv4) {
+			continue
+		}
+		prefix, bits := ipNet.Mask.Size()
+		if bits != 32 || prefix <= bestPrefix {
+			continue
+		}
+		best = ipNet.IP.To4()
+		bestPrefix = prefix
+	}
+	return best
+}
+
 func findLinkLocalEndpointByInterfaceIndex(index int) (string, string) {
 	iface, err := net.InterfaceByIndex(index)
 	if err != nil {

+ 55 - 0
server/internal/discovery/discovery_test.go

@@ -0,0 +1,55 @@
+package discovery
+
+import (
+	"net"
+	"testing"
+)
+
+func TestBestSourceIPForRemoteUsesMostSpecificMatchingSubnet(t *testing.T) {
+	addresses := []net.Addr{
+		mustIPNet(t, "169.254.101.3/16"),
+		mustIPNet(t, "192.168.229.138/24"),
+	}
+
+	actual := bestSourceIPForRemote(addresses, net.ParseIP("192.168.229.1"))
+	if actual == nil || actual.String() != "192.168.229.138" {
+		t.Fatalf("expected source 192.168.229.138, got %v", actual)
+	}
+}
+
+func TestBestSourceIPForRemoteUsesLinkLocalAddressForLinkLocalClient(t *testing.T) {
+	addresses := []net.Addr{
+		mustIPNet(t, "169.254.101.3/16"),
+		mustIPNet(t, "192.168.229.138/24"),
+	}
+
+	actual := bestSourceIPForRemote(addresses, net.ParseIP("169.254.100.1"))
+	if actual == nil || actual.String() != "169.254.101.3" {
+		t.Fatalf("expected source 169.254.101.3, got %v", actual)
+	}
+}
+
+func TestBestSourceIPForRemoteReturnsNilWithoutMatchingSubnet(t *testing.T) {
+	addresses := []net.Addr{mustIPNet(t, "169.254.101.3/16")}
+	if actual := bestSourceIPForRemote(addresses, net.ParseIP("192.168.229.1")); actual != nil {
+		t.Fatalf("expected no matching source, got %v", actual)
+	}
+}
+
+func TestBroadcastResponseTargetUsesRequesterPort(t *testing.T) {
+	remote := &net.UDPAddr{IP: net.ParseIP("192.168.44.1"), Port: 55869}
+	target := broadcastResponseTarget(remote)
+	if !target.IP.Equal(net.IPv4bcast) || target.Port != remote.Port {
+		t.Fatalf("expected 255.255.255.255:%d, got %s", remote.Port, target)
+	}
+}
+
+func mustIPNet(t *testing.T, cidr string) *net.IPNet {
+	t.Helper()
+	ip, network, err := net.ParseCIDR(cidr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	network.IP = ip
+	return network
+}

+ 7 - 3
server/internal/discovery/packetinfo_linux.go

@@ -17,6 +17,9 @@ func enableLocalAddrControl(conn *net.UDPConn) error {
 	var controlErr error
 	err = rawConn.Control(func(fd uintptr) {
 		controlErr = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, syscall.IP_PKTINFO, 1)
+		if controlErr == nil {
+			controlErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1)
+		}
 	})
 	if err != nil {
 		return err
@@ -35,8 +38,7 @@ func readFromUDP(conn *net.UDPConn, buf []byte) (int, *net.UDPAddr, udpPacketInf
 }
 
 func writeToUDP(conn *net.UDPConn, payload []byte, remote *net.UDPAddr, packetInfo udpPacketInfo, localIP net.IP) (int, error) {
-	ipv4 := localIP.To4()
-	if packetInfo.ifIndex <= 0 || ipv4 == nil {
+	if packetInfo.ifIndex <= 0 {
 		return conn.WriteToUDP(payload, remote)
 	}
 
@@ -48,7 +50,9 @@ func writeToUDP(conn *net.UDPConn, payload []byte, remote *net.UDPAddr, packetIn
 
 	info := (*syscall.Inet4Pktinfo)(unsafe.Pointer(&oob[syscall.CmsgLen(0)]))
 	info.Ifindex = int32(packetInfo.ifIndex)
-	copy(info.Spec_dst[:], ipv4)
+	if ipv4 := localIP.To4(); ipv4 != nil {
+		copy(info.Spec_dst[:], ipv4)
+	}
 
 	n, _, err := conn.WriteMsgUDP(payload, oob, remote)
 	return n, err

+ 14 - 0
windows/NetTool.Client/MainWindow.xaml.cs

@@ -370,6 +370,20 @@ public partial class MainWindow : Window
                     continue;
                 }
 
+				if (result.StatusCode is null)
+				{
+					var localIPv4 = selectedAdapter?.IPv4Address ?? string.Empty;
+					var connectionMessage =
+						$"设备已通过 UDP 发现,但无法通过当前 Windows 网卡建立 HTTP 连接。\n\n" +
+						$"当前 Windows IP:{(string.IsNullOrWhiteSpace(localIPv4) ? "无" : localIPv4)}\n" +
+						$"设备维护 IP:{device.Lan2Ip}\n\n" +
+						"请给当前 Windows 网卡配置可访问设备维护 IP 的 169.254.x.x/16 地址,或者配置可用的双向路由。\n" +
+						$"如果地址和路由已正确,请检查 TCP 48888 端口和防火墙。\n\n系统错误:{result.Message}";
+					SetStatus("设备已发现,但当前网卡无法访问设备维护 IP。", StatusMessageType.Error, true);
+					MessageBox.Show(this, connectionMessage, "无法连接设备", MessageBoxButton.OK, MessageBoxImage.Warning);
+					return;
+				}
+
                 SetStatus($"设备已发现,但 HTTP 验证失败:{result.Message}", StatusMessageType.Error, true);
                 return;
             }

+ 1 - 1
windows/NetTool.Client/NetTool.Client.csproj

@@ -8,7 +8,7 @@
     <UseWPF>true</UseWPF>
     <AssemblyName>NetTool.Client</AssemblyName>
     <RootNamespace>NetTool.Client</RootNamespace>
-    <InformationalVersion>2026.07.27.1532</InformationalVersion>
+    <InformationalVersion>2026.07.27.1700</InformationalVersion>
   </PropertyGroup>
 
 </Project>