5 Commitit 30e05bca03 ... cbb8d2d818

Tekijä SHA1 Viesti Päivämäärä
  yangkaixiang cbb8d2d818 chore(build): 统一版本号格式并同步至UI标题栏 1 kuukausi sitten
  yangkaixiang a02c943a1c refactor(ui): 移除倒计时文案并显示在按钮上 1 kuukausi sitten
  yangkaixiang bfc6155523 refactor(ui): 优化配置变更提示文案为“将改为” 1 kuukausi sitten
  yangkaixiang 1ce3332108 refactor(ui): 将刷新配置文案统一调整为重新获取 1 kuukausi sitten
  yangkaixiang 6bdb9f5df1 refactor(network): 移除维护地址自动配置,支持动态链路本地发现 1 kuukausi sitten

+ 2 - 0
AGENTS.md

@@ -4,3 +4,5 @@
 
 - Windows 编译时,如果因进程占用导致编译失败,可以直接结束占用进程后重新编译。编译结束后重新打开程序。
 - 每次编译 server 端前,需要先修改版本号。
+- 编译win端时,需要修改主界面标题栏的版本号。
+- 版本号格式统一使用 `yyyy.MM.dd.HHmm`,例如 `2026.05.13.1446`。

+ 1 - 1
docs/08-构建与编译.md

@@ -33,7 +33,7 @@ windows\NetworkTool.Client\bin\Debug\net9.0-windows\
 
 每次修改 `server` 端代码时,必须同步更新 `server/internal/config/config.go` 中的 `ServerVersion`。
 
-版本号格式固定为当前时间:`yyyyMMddHHmmss`,例如 `20260511152102`。
+版本号格式固定为当前时间:`yyyy.MM.dd.HHmm`,例如 `2026.05.13.1446`。
 
 该版本号用于排查客户端与远端 Server 是否一致:
 

+ 0 - 5
server/cmd/networktool-server/main.go

@@ -39,11 +39,6 @@ func main() {
 	taskSvc := tasks.New()
 	systemSvc := systemaction.New()
 
-	if err := interfaceSvc.EnsureMaintenanceAddress(); err != nil {
-		log.Error("failed to ensure maintenance address", "error", err.Error())
-		return
-	}
-
 	httpSrv := httpserver.New(cfg, log, deviceSvc, interfaceSvc, configSvc, validatorSvc, netplanSvc, applySvc, taskSvc, systemSvc)
 	udpSrv := discovery.New(cfg, log, deviceSvc)
 

+ 11 - 8
server/internal/config/config.go

@@ -6,7 +6,7 @@ import (
 	"net"
 )
 
-const ServerVersion = "20260512120500"
+const ServerVersion = "2026.05.13.1446"
 
 type Config struct {
 	HTTPHost         string
@@ -26,24 +26,27 @@ func Load(args []string) Config {
 		HTTPPort:         48888,
 		UDPHost:          "0.0.0.0",
 		UDPPort:          50000,
-		MaintenanceIP:    "169.254.100.2",
-		MaintenanceCIDR:  "169.254.100.2/16",
+		MaintenanceIP:    "",
+		MaintenanceCIDR:  "",
 		AdminPassword:    "Dt123$",
 		ServerVersion:    ServerVersion,
 		DeviceIDFallback: "networktool-device",
 	}
 
 	fs := flag.NewFlagSet("networktool-server", flag.ContinueOnError)
-	fs.StringVar(&cfg.MaintenanceIP, "ip", cfg.MaintenanceIP, "maintenance IPv4 address")
+	fs.StringVar(&cfg.MaintenanceIP, "ip", cfg.MaintenanceIP, "maintenance IPv4 address, must be in 169.254.0.0/16 when set")
 	fs.IntVar(&cfg.HTTPPort, "port", cfg.HTTPPort, "HTTP listen port")
 	fs.StringVar(&cfg.AdminPassword, "password", cfg.AdminPassword, "admin password")
 	_ = fs.Parse(args)
 
-	if parsed := net.ParseIP(cfg.MaintenanceIP); parsed == nil || parsed.To4() == nil {
-		panic(fmt.Sprintf("invalid maintenance ip: %s", cfg.MaintenanceIP))
+	if cfg.MaintenanceIP != "" {
+		parsed := net.ParseIP(cfg.MaintenanceIP)
+		ipv4 := parsed.To4()
+		if parsed == nil || ipv4 == nil || ipv4[0] != 169 || ipv4[1] != 254 {
+			panic(fmt.Sprintf("invalid maintenance ip: %s", cfg.MaintenanceIP))
+		}
+		cfg.MaintenanceCIDR = fmt.Sprintf("%s/16", cfg.MaintenanceIP)
 	}
 
-	cfg.MaintenanceCIDR = fmt.Sprintf("%s/16", cfg.MaintenanceIP)
-
 	return cfg
 }

+ 57 - 10
server/internal/discovery/discovery.go

@@ -55,6 +55,12 @@ func (s *Server) Run(ctx context.Context) error {
 			continue
 		}
 
+		lan2IP, mac := s.maintenanceEndpoint()
+		if lan2IP == "" {
+			s.log.Warn("skip discovery response because no 169.254 maintenance address was found")
+			continue
+		}
+
 		device := s.deviceSvc.Get()
 		resp := model.DiscoverResponse{
 			ProtocolVersion: 1,
@@ -63,8 +69,8 @@ func (s *Server) Run(ctx context.Context) error {
 			DeviceID:        device.DeviceID,
 			Hostname:        device.Hostname,
 			ServerVersion:   device.ServerVersion,
-			MAC:             findMACByIP(s.cfg.MaintenanceIP),
-			LAN2IP:          s.cfg.MaintenanceIP,
+			MAC:             mac,
+			LAN2IP:          lan2IP,
 			HTTPPort:        s.cfg.HTTPPort,
 			AuthRequired:    true,
 		}
@@ -73,6 +79,40 @@ func (s *Server) Run(ctx context.Context) error {
 	}
 }
 
+func (s *Server) maintenanceEndpoint() (string, string) {
+	if s.cfg.MaintenanceIP != "" {
+		mac := findMACByIP(s.cfg.MaintenanceIP)
+		if mac != "" {
+			return s.cfg.MaintenanceIP, mac
+		}
+	}
+	return findFirstLinkLocalEndpoint()
+}
+
+func findFirstLinkLocalEndpoint() (string, string) {
+	ifaces, err := net.Interfaces()
+	if err != nil {
+		return "", ""
+	}
+	for _, iface := range ifaces {
+		if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 || len(iface.HardwareAddr) == 0 {
+			continue
+		}
+		addrs, err := iface.Addrs()
+		if err != nil {
+			continue
+		}
+		for _, addr := range addrs {
+			current := ipv4FromAddr(addr)
+			if current == nil || !strings.HasPrefix(current.String(), "169.254.") {
+				continue
+			}
+			return current.String(), iface.HardwareAddr.String()
+		}
+	}
+	return "", ""
+}
+
 func findMACByIP(ip string) string {
 	ifaces, err := net.Interfaces()
 	if err != nil {
@@ -88,14 +128,7 @@ func findMACByIP(ip string) string {
 			continue
 		}
 		for _, addr := range addrs {
-			var current net.IP
-			switch value := addr.(type) {
-			case *net.IPNet:
-				current = value.IP
-			case *net.IPAddr:
-				current = value.IP
-			}
-			current = current.To4()
+			current := ipv4FromAddr(addr)
 			if current == nil {
 				continue
 			}
@@ -109,3 +142,17 @@ func findMACByIP(ip string) string {
 	}
 	return fallback
 }
+
+func ipv4FromAddr(addr net.Addr) net.IP {
+	var current net.IP
+	switch value := addr.(type) {
+	case *net.IPNet:
+		current = value.IP
+	case *net.IPAddr:
+		current = value.IP
+	}
+	if current == nil {
+		return nil
+	}
+	return current.To4()
+}

+ 0 - 3
server/internal/httpserver/server.go

@@ -418,7 +418,6 @@ func (s *Server) runApplyTask(taskID string, input model.InterfaceConfig, manage
 		s.log.Error("netplan apply failed, restoring netplan file", "task_id", taskID, "file", filePath, "error", err.Error())
 		_ = s.netplanSvc.Restore(filePath, backupPath)
 		_ = s.applySvc.Apply()
-		_ = s.interfaceSvc.EnsureMaintenanceAddress()
 		s.logNetplanFile(taskID, filePath, "netplan restored after apply failure")
 		s.taskSvc.Update(taskID, "rolled_back", "rolling_back", fmt.Sprintf("应用 netplan 失败,已自动回滚:%v", err), true)
 		return
@@ -476,7 +475,6 @@ func (s *Server) runApplyAllTask(taskID string, inputs []model.InterfaceConfig,
 		s.log.Error("netplan apply failed, restoring netplan file", "task_id", taskID, "file", filePath, "error", err.Error())
 		_ = s.netplanSvc.Restore(filePath, backupPath)
 		_ = s.applySvc.Apply()
-		_ = s.interfaceSvc.EnsureMaintenanceAddress()
 		s.logNetplanFile(taskID, filePath, "netplan restored after apply failure")
 		s.taskSvc.Update(taskID, "rolled_back", "rolling_back", fmt.Sprintf("应用 netplan 失败,已自动回滚:%v", err), true)
 		return
@@ -598,7 +596,6 @@ func (s *Server) rollbackAppliedConfig(taskID string, filePath string, backupPat
 	s.log.Warn("apply confirmation failed, restoring netplan file", "task_id", taskID, "file", filePath, "reason", reason)
 	_ = s.netplanSvc.Restore(filePath, backupPath)
 	_ = s.applySvc.Apply()
-	_ = s.interfaceSvc.EnsureMaintenanceAddress()
 	s.logNetplanFile(taskID, filePath, "netplan restored after confirmation failure")
 	s.taskSvc.Update(taskID, "rolled_back", "rolling_back", fmt.Sprintf("%s,已自动回滚。", reason), true)
 }

+ 5 - 41
server/internal/network/interfaces/interfaces.go

@@ -1,10 +1,8 @@
 package interfaces
 
 import (
-	"fmt"
 	"net"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"sort"
 	"strings"
@@ -51,43 +49,6 @@ func (s *Service) List() (model.InterfacesResponse, error) {
 	}, nil
 }
 
-func (s *Service) EnsureMaintenanceAddress() error {
-	items, err := s.listPhysicalInterfaces()
-	if err != nil {
-		return err
-	}
-	if len(items) == 0 {
-		return fmt.Errorf("no physical ethernet interfaces found")
-	}
-
-	management := s.detectManagement(items)
-	if management == "" {
-		return fmt.Errorf("failed to detect management interface")
-	}
-
-	for _, item := range items {
-		if item.SystemName != management {
-			continue
-		}
-		for _, addr := range item.IPv4 {
-			if addr.Address == s.cfg.MaintenanceIP {
-				return nil
-			}
-		}
-		break
-	}
-
-	cmd := exec.Command("ip", "addr", "add", s.cfg.MaintenanceCIDR, "dev", management)
-	output, err := cmd.CombinedOutput()
-	if err != nil {
-		if strings.Contains(string(output), "File exists") {
-			return nil
-		}
-		return fmt.Errorf("ip addr add failed on %s: %s", management, strings.TrimSpace(string(output)))
-	}
-	return nil
-}
-
 func (s *Service) listPhysicalInterfaces() ([]model.NetworkInterface, error) {
 	ifaces, err := net.Interfaces()
 	if err != nil {
@@ -118,7 +79,10 @@ func (s *Service) listPhysicalInterfaces() ([]model.NetworkInterface, error) {
 func (s *Service) detectManagement(items []model.NetworkInterface) string {
 	for _, item := range items {
 		for _, addr := range item.IPv4 {
-			if addr.Address == s.cfg.MaintenanceIP {
+			if s.cfg.MaintenanceIP != "" && addr.Address == s.cfg.MaintenanceIP {
+				return item.SystemName
+			}
+			if s.cfg.MaintenanceIP == "" && strings.HasPrefix(addr.Address, "169.254.") {
 				return item.SystemName
 			}
 		}
@@ -158,7 +122,7 @@ func readIPv4(iface net.Interface) []model.IPv4Address {
 		}
 		prefix, _ := ipNet.Mask.Size()
 		source := "unknown"
-		if ipNet.IP.String() == "169.254.100.2" {
+		if strings.HasPrefix(ipNet.IP.String(), "169.254.") {
 			source = "static"
 		}
 		result = append(result, model.IPv4Address{Address: ipNet.IP.String(), Prefix: prefix, Source: source})

+ 1 - 1
windows/NetworkTool.Client/DeviceDetailsWindow.xaml

@@ -149,7 +149,7 @@
                             Padding="12,0"
                             VerticalAlignment="Center"
                             Click="ReloadInterfaceConfigButton_OnClick"
-                            Content="刷新全部网口配置" />
+                             Content="重新获取" />
 
                     <ItemsControl x:Name="InterfacesItemsControl" Grid.Row="1" Grid.ColumnSpan="2" Margin="0,12,0,0">
                         <ItemsControl.ItemTemplate>

+ 9 - 8
windows/NetworkTool.Client/DeviceDetailsWindow.xaml.cs

@@ -168,12 +168,12 @@ public partial class DeviceDetailsWindow : Window
 
     private async void ReloadInterfaceConfigButton_OnClick(object sender, RoutedEventArgs e)
     {
-        if (!ConfirmDiscardPendingChanges("当前配置已修改但尚未应用,刷新会丢失未应用内容。是否继续刷新?", "确认刷新配置"))
+        if (!ConfirmDiscardPendingChanges("当前配置已修改但尚未应用,重新获取会丢失未应用内容。是否继续重新获取?", "确认重新获取配置"))
         {
             return;
         }
 
-        SetBusyState(true, "正在刷新全部网口配置...");
+        SetBusyState(true, "正在重新获取全部网口配置...");
         try
         {
             foreach (var editor in _interfaces)
@@ -183,8 +183,8 @@ public partial class DeviceDetailsWindow : Window
 
             _configValidated = false;
             _configDirty = false;
-            SetConfigStateMessage("已刷新全部网口配置。", false);
-            ShowStatusMessage("已刷新全部网口配置。", StatusMessageType.Success);
+            SetConfigStateMessage("已重新获取全部网口配置。", false);
+            ShowStatusMessage("已重新获取全部网口配置。", StatusMessageType.Success);
         }
         finally
         {
@@ -407,7 +407,8 @@ public partial class DeviceDetailsWindow : Window
 
         void UpdateMessage()
         {
-            messageTextBlock.Text = $"当前客户端仍可连接到设备。是否确认保留这次网络配置?\n\n剩余 {remaining} 秒;超时或取消时,Linux 端会自动回滚。";
+            messageTextBlock.Text = "当前客户端仍可连接到设备。是否确认保留这次网络配置?\n\n超时或取消时,Linux 端会自动回滚。";
+            confirmButton.Content = $"保留({remaining}秒)";
         }
 
         var timer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
@@ -1261,15 +1262,15 @@ public partial class DeviceDetailsWindow : Window
             var lines = new List<string> { DisplayLabel };
             if (IsAddressModified)
             {
-                lines.Add($"IP:{FormatAddressSummary(_originalDhcp4, _originalAddressKeys)} -> {FormatAddressSummary(Dhcp4, GetAddressKeys())}");
+                lines.Add($"IP:{FormatAddressSummary(_originalDhcp4, _originalAddressKeys)} 将改为 {FormatAddressSummary(Dhcp4, GetAddressKeys())}");
             }
             if (IsGatewayModified)
             {
-                lines.Add($"网关:{FormatGatewaySummary(_originalDhcp4, _originalGatewayKeys)} -> {FormatGatewaySummary(Dhcp4, GetGatewayKeys())}");
+                lines.Add($"网关:{FormatGatewaySummary(_originalDhcp4, _originalGatewayKeys)} 将改为 {FormatGatewaySummary(Dhcp4, GetGatewayKeys())}");
             }
             if (IsDnsModified)
             {
-                lines.Add($"DNS:{FormatKeys(_originalDnsKeys)} -> {FormatKeys(GetDnsKeys())}");
+                lines.Add($"DNS:{FormatKeys(_originalDnsKeys)} 将改为 {FormatKeys(GetDnsKeys())}");
             }
 
             return lines.Count == 1 ? string.Empty : string.Join(Environment.NewLine, lines);

+ 9 - 0
windows/NetworkTool.Client/MainWindow.xaml.cs

@@ -1,5 +1,6 @@
 using System.Collections.Generic;
 using System.Collections.ObjectModel;
+using System.Reflection;
 using System.Windows;
 using System.Windows.Controls;
 using System.Windows.Input;
@@ -28,9 +29,17 @@ public partial class MainWindow : Window
     public MainWindow()
     {
         InitializeComponent();
+        Title = $"NetworkTool {GetClientVersion()}";
         Loaded += MainWindow_OnLoaded;
     }
 
+    private static string GetClientVersion()
+    {
+        return Assembly.GetExecutingAssembly()
+            .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
+            .InformationalVersion.Split('+')[0] ?? "unknown";
+    }
+
     private void MainWindow_OnLoaded(object sender, RoutedEventArgs e)
     {
         LoadInitialState();

+ 1 - 0
windows/NetworkTool.Client/NetworkTool.Client.csproj

@@ -6,6 +6,7 @@
     <Nullable>enable</Nullable>
     <ImplicitUsings>enable</ImplicitUsings>
     <UseWPF>true</UseWPF>
+    <InformationalVersion>2026.05.13.1446</InformationalVersion>
   </PropertyGroup>
 
 </Project>

+ 32 - 4
windows/NetworkTool.Client/Services/DiscoveryService.cs

@@ -92,8 +92,7 @@ public sealed class DiscoveryService
                 var response = JsonSerializer.Deserialize<DiscoveryResponse>(result.Buffer);
                 if (response is null
                     || response.MessageType != "discover_response"
-                    || string.IsNullOrWhiteSpace(response.Lan2Ip)
-                    || !response.Lan2Ip.StartsWith("169.254.", StringComparison.Ordinal))
+                    || !TryGetDiscoveredLinkLocalIp(response.Lan2Ip, result.RemoteEndPoint.Address, out var discoveredIp))
                 {
                     continue;
                 }
@@ -110,12 +109,12 @@ public sealed class DiscoveryService
                     Hostname = response.Hostname ?? string.Empty,
                     ServerVersion = response.ServerVersion ?? string.Empty,
                     Mac = mac ?? string.Empty,
-                    Lan2Ip = response.Lan2Ip,
+                    Lan2Ip = discoveredIp,
                     HttpPort = response.HttpPort,
                     AuthRequired = response.AuthRequired,
                 };
 
-                devicesByIp[response.Lan2Ip] = device;
+                devicesByIp[discoveredIp] = device;
                 onDeviceDiscovered?.Invoke(device);
             }
             catch (OperationCanceledException)
@@ -127,6 +126,35 @@ public sealed class DiscoveryService
         return devicesByIp.Values.OrderBy(device => device.Lan2Ip).ToList();
     }
 
+    private static bool TryGetDiscoveredLinkLocalIp(string? lan2Ip, IPAddress remoteAddress, out string discoveredIp)
+    {
+        if (IsLinkLocalIPv4(lan2Ip))
+        {
+            discoveredIp = lan2Ip!.Trim();
+            return true;
+        }
+
+        if (IsLinkLocalIPv4(remoteAddress))
+        {
+            discoveredIp = remoteAddress.ToString();
+            return true;
+        }
+
+        discoveredIp = string.Empty;
+        return false;
+    }
+
+    private static bool IsLinkLocalIPv4(string? ipAddress)
+    {
+        return IPAddress.TryParse(ipAddress, out var parsed) && IsLinkLocalIPv4(parsed);
+    }
+
+    private static bool IsLinkLocalIPv4(IPAddress ipAddress)
+    {
+        var bytes = ipAddress.GetAddressBytes();
+        return ipAddress.AddressFamily == AddressFamily.InterNetwork && bytes[0] == 169 && bytes[1] == 254;
+    }
+
     private static string ResolveMacAddress(IPAddress ipAddress)
     {
         if (ipAddress.AddressFamily != AddressFamily.InterNetwork)