Переглянути джерело

feat(api): 支持多IP与静态路由并兼容旧字段

重构网络配置模型以支持 addresses 和 routes 数组,同时保留 ip/gateway 字段以兼容旧客户端。更新文档、日志及底层读写逻辑。
yangkaixiang 1 місяць тому
батько
коміт
778d02608c

+ 49 - 14
docs/03-通信与HTTP_API.md

@@ -205,9 +205,14 @@ X-Admin-Password: Dt123$
   "message": "成功",
   "data": {
     "interface": "enp1s0",
-    "ip": "192.168.10.20",
-    "prefix": 24,
-    "gateway": "",
+    "dhcp4": false,
+    "addresses": [
+      {
+        "ip": "192.168.10.20",
+        "prefix": 24
+      }
+    ],
+    "routes": [],
     "dns": []
   }
 }
@@ -222,9 +227,27 @@ X-Admin-Password: Dt123$
 ```json
 {
   "interface": "enp1s0",
-  "ip": "192.168.10.20",
-  "prefix": 24,
-  "gateway": "",
+  "dhcp4": false,
+  "addresses": [
+    {
+      "ip": "192.168.10.20",
+      "prefix": 24
+    },
+    {
+      "ip": "192.168.20.20",
+      "prefix": 24
+    }
+  ],
+  "routes": [
+    {
+      "to": "default",
+      "via": "192.168.10.1"
+    },
+    {
+      "to": "10.10.0.0/16",
+      "via": "192.168.20.1"
+    }
+  ],
   "dns": []
 }
 ```
@@ -274,13 +297,15 @@ X-Admin-Password: Dt123$
 
 校验规则:
 
-1. `ip` 必填
-2. `prefix` 必填
-3. `gateway` 选
+1. `dhcp4=false` 时,`addresses` 至少需要 1 项
+2. `addresses[].ip` 必填
+3. `addresses[].prefix` 必
 4. `dns` 选填
 5. `interface` 必填,必须是有效的真实接口名
-6. 若填写 `gateway`,必须与 `ip` 在同一子网
-7. 若 `ip` 为 `169.254.x.x`,返回中文警告,不直接报错
+6. `routes[].to` 必须为 `default` 或 IPv4 CIDR
+7. `routes[].via` 必须为 IPv4 地址,且必须与任一 `addresses` 在同一子网
+8. 若任一 `addresses[].ip` 为 `169.254.x.x`,返回中文警告,不直接报错
+9. 为兼容旧客户端,Server 仍接受旧字段 `ip`、`prefix`、`gateway`,并转换为 `addresses` 与默认路由
 
 ### 3.6 应用指定接口配置
 
@@ -291,9 +316,19 @@ X-Admin-Password: Dt123$
 ```json
 {
   "interface": "enp1s0",
-  "ip": "192.168.10.20",
-  "prefix": 24,
-  "gateway": "",
+  "dhcp4": false,
+  "addresses": [
+    {
+      "ip": "192.168.10.20",
+      "prefix": 24
+    }
+  ],
+  "routes": [
+    {
+      "to": "default",
+      "via": "192.168.10.1"
+    }
+  ],
   "dns": []
 }
 ```

+ 6 - 4
docs/06-netplan修改策略.md

@@ -82,10 +82,11 @@
 1. `dhcp4`
    - 设为 `false`
 2. `addresses`
-   - 按用户输入的 IP 和前缀长度生成
-3. `gateway4` 或等效默认网关表达
-   - 若用户填写网关,则写入
-   - 若用户未填写网关,则删除该字段
+   - 按用户输入的一个或多个 IP 和前缀长度生成
+3. `routes`
+   - 若用户填写默认网关或静态路由,则写入
+   - 默认网关表达为 `to: default`
+   - 若用户未填写路由,则删除该字段
 4. `nameservers.addresses`
    - 若用户填写 DNS,则写入
    - 若用户未填写 DNS,则删除或置空
@@ -98,6 +99,7 @@
 2. 旧的 `gateway4`
 3. 旧的 `nameservers`
 4. 旧的 `dhcp4`
+5. 旧的 `routes`
 
 ### 5.3 建议保留的字段
 

+ 25 - 1
server/internal/httpserver/server.go

@@ -349,7 +349,7 @@ func (s *Server) runApplyTask(taskID string, input model.InterfaceConfig, manage
 		s.taskSvc.Update(taskID, "failed", "writing_netplan", err.Error(), false)
 		return
 	}
-	s.log.Info("preparing to write netplan", "task_id", taskID, "file", filePath, "target_interface", input.Interface, "ip", input.IP, "prefix", input.Prefix, "gateway", input.Gateway, "dns", strings.Join(input.DNS, ","))
+	s.log.Info("preparing to write netplan", "task_id", taskID, "file", filePath, "target_interface", input.Interface, "addresses", formatAddresses(input), "routes", formatRoutes(input), "dns", strings.Join(input.DNS, ","))
 	if err := s.netplanSvc.Write(filePath, input.Interface, input, managementInterface, s.cfg.MaintenanceCIDR); err != nil {
 		s.taskSvc.Update(taskID, "failed", "writing_netplan", err.Error(), false)
 		return
@@ -511,3 +511,27 @@ func writeJSON(w http.ResponseWriter, status int, payload model.APIResponse) {
 func hasRootPrivileges() bool {
 	return os.Geteuid() == 0
 }
+
+func formatAddresses(input model.InterfaceConfig) string {
+	addresses := input.Addresses
+	if len(addresses) == 0 && strings.TrimSpace(input.IP) != "" {
+		addresses = []model.InterfaceAddressConfig{{IP: input.IP, Prefix: input.Prefix}}
+	}
+	items := make([]string, 0, len(addresses))
+	for _, address := range addresses {
+		items = append(items, fmt.Sprintf("%s/%d", strings.TrimSpace(address.IP), address.Prefix))
+	}
+	return strings.Join(items, ",")
+}
+
+func formatRoutes(input model.InterfaceConfig) string {
+	routes := input.Routes
+	if len(routes) == 0 && strings.TrimSpace(input.Gateway) != "" {
+		routes = []model.InterfaceRouteConfig{{To: "default", Via: input.Gateway}}
+	}
+	items := make([]string, 0, len(routes))
+	for _, route := range routes {
+		items = append(items, fmt.Sprintf("%s via %s", strings.TrimSpace(route.To), strings.TrimSpace(route.Via)))
+	}
+	return strings.Join(items, ",")
+}

+ 18 - 6
server/internal/model/types.go

@@ -59,12 +59,24 @@ type DiscoverResponse struct {
 }
 
 type InterfaceConfig struct {
-	Interface string   `json:"interface"`
-	Dhcp4     bool     `json:"dhcp4"`
-	IP        string   `json:"ip"`
-	Prefix    int      `json:"prefix"`
-	Gateway   string   `json:"gateway"`
-	DNS       []string `json:"dns"`
+	Interface string                   `json:"interface"`
+	Dhcp4     bool                     `json:"dhcp4"`
+	Addresses []InterfaceAddressConfig `json:"addresses"`
+	Routes    []InterfaceRouteConfig   `json:"routes"`
+	DNS       []string                 `json:"dns"`
+	IP        string                   `json:"ip,omitempty"`
+	Prefix    int                      `json:"prefix,omitempty"`
+	Gateway   string                   `json:"gateway,omitempty"`
+}
+
+type InterfaceAddressConfig struct {
+	IP     string `json:"ip"`
+	Prefix int    `json:"prefix"`
+}
+
+type InterfaceRouteConfig struct {
+	To  string `json:"to"`
+	Via string `json:"via"`
 }
 
 type ValidateResponse struct {

+ 54 - 56
server/internal/network/configreader/configreader.go

@@ -1,7 +1,6 @@
 package configreader
 
 import (
-	"bufio"
 	"fmt"
 	"net"
 	"os"
@@ -17,11 +16,10 @@ import (
 type Service struct{}
 
 type configuredInterfaceValues struct {
-	DHCP4   bool
-	IP      string
-	Prefix  int
-	Gateway string
-	DNS     []string
+	DHCP4     bool
+	Addresses []model.InterfaceAddressConfig
+	Routes    []model.InterfaceRouteConfig
+	DNS       []string
 }
 
 func New() *Service { return &Service{} }
@@ -35,80 +33,69 @@ func (s *Service) Read(interfaceName string) (model.InterfaceConfig, error) {
 	config := model.InterfaceConfig{Interface: interfaceName, DNS: []string{}}
 	configured := readConfiguredInterfaceValues(interfaceName)
 	config.Dhcp4 = configured.DHCP4
-	config.IP = configured.IP
-	config.Prefix = configured.Prefix
-	config.Gateway = configured.Gateway
+	config.Addresses = configured.Addresses
+	config.Routes = configured.Routes
 	if configured.DNS != nil {
 		config.DNS = configured.DNS
 	}
-	if config.Dhcp4 || config.IP == "" {
-		ip, prefix := readInterfaceIPv4(iface)
-		if ip != "" {
-			config.IP = ip
-			config.Prefix = prefix
+	if config.Dhcp4 || len(config.Addresses) == 0 {
+		addresses := readInterfaceIPv4(iface)
+		if len(addresses) > 0 {
+			config.Addresses = addresses
 		}
 	}
-	if config.Dhcp4 || config.Gateway == "" {
-		config.Gateway = readGateway(interfaceName)
+	if len(config.Addresses) > 0 {
+		config.IP = config.Addresses[0].IP
+		config.Prefix = config.Addresses[0].Prefix
 	}
-	if config.Dhcp4 || len(config.DNS) == 0 {
-		config.DNS = readDNS()
+	if config.Dhcp4 || len(config.Routes) == 0 {
+		config.Routes = readRoutes(interfaceName)
+	}
+	for _, route := range config.Routes {
+		if route.To == "default" {
+			config.Gateway = route.Via
+			break
+		}
 	}
 	return config, nil
 }
 
-func readInterfaceIPv4(iface *net.Interface) (string, int) {
+func readInterfaceIPv4(iface *net.Interface) []model.InterfaceAddressConfig {
 	addrs, err := iface.Addrs()
 	if err != nil {
-		return "", 0
+		return nil
 	}
+	result := make([]model.InterfaceAddressConfig, 0)
 	for _, addr := range addrs {
 		ipNet, ok := addr.(*net.IPNet)
 		if !ok || ipNet.IP.To4() == nil {
 			continue
 		}
 		prefix, _ := ipNet.Mask.Size()
-		return ipNet.IP.String(), prefix
+		result = append(result, model.InterfaceAddressConfig{IP: ipNet.IP.String(), Prefix: prefix})
 	}
-	return "", 0
+	return result
 }
 
-func readGateway(interfaceName string) string {
+func readRoutes(interfaceName string) []model.InterfaceRouteConfig {
 	cmd := exec.Command("ip", "route", "show", "dev", interfaceName)
 	output, err := cmd.Output()
 	if err != nil {
-		return ""
+		return nil
 	}
+	result := make([]model.InterfaceRouteConfig, 0)
 	for _, line := range strings.Split(string(output), "\n") {
 		line = strings.TrimSpace(line)
-		if !strings.HasPrefix(line, "default via ") {
+		if line == "" {
 			continue
 		}
 		parts := strings.Fields(line)
-		if len(parts) >= 3 {
-			return parts[2]
-		}
-	}
-	return ""
-}
-
-func readDNS() []string {
-	file, err := os.Open("/etc/resolv.conf")
-	if err != nil {
-		return []string{}
-	}
-	defer file.Close()
-
-	result := make([]string, 0, 2)
-	scanner := bufio.NewScanner(file)
-	for scanner.Scan() {
-		line := strings.TrimSpace(scanner.Text())
-		if !strings.HasPrefix(line, "nameserver ") {
+		if len(parts) >= 3 && parts[0] == "default" && parts[1] == "via" {
+			result = append(result, model.InterfaceRouteConfig{To: "default", Via: parts[2]})
 			continue
 		}
-		parts := strings.Fields(line)
-		if len(parts) >= 2 {
-			result = append(result, parts[1])
+		if len(parts) >= 3 && strings.Contains(parts[0], "/") && parts[1] == "via" {
+			result = append(result, model.InterfaceRouteConfig{To: parts[0], Via: parts[2]})
 		}
 	}
 	return result
@@ -165,9 +152,7 @@ func findInterfaceValues(raw map[string]any, interfaceName string) (configuredIn
 				continue
 			}
 			prefix, _ := ipNet.Mask.Size()
-			values.IP = ip.String()
-			values.Prefix = prefix
-			break
+			values.Addresses = append(values.Addresses, model.InterfaceAddressConfig{IP: ip.String(), Prefix: prefix})
 		}
 	}
 	if nameservers, ok := entry["nameservers"].(map[string]any); ok {
@@ -181,18 +166,31 @@ func findInterfaceValues(raw map[string]any, interfaceName string) (configuredIn
 			}
 			to, _ := route["to"].(string)
 			via, _ := route["via"].(string)
-			if strings.TrimSpace(to) == "default" && strings.TrimSpace(via) != "" {
-				values.Gateway = strings.TrimSpace(via)
-				break
+			to = strings.TrimSpace(to)
+			via = strings.TrimSpace(via)
+			if to != "" && via != "" {
+				values.Routes = append(values.Routes, model.InterfaceRouteConfig{To: to, Via: via})
 			}
 		}
 	}
-	if values.Gateway == "" {
+	if !hasDefaultRoute(values.Routes) {
 		if gateway4, ok := entry["gateway4"].(string); ok {
-			values.Gateway = strings.TrimSpace(gateway4)
+			gateway4 = strings.TrimSpace(gateway4)
+			if gateway4 != "" {
+				values.Routes = append(values.Routes, model.InterfaceRouteConfig{To: "default", Via: gateway4})
+			}
+		}
+	}
+	return values, values.DHCP4 || len(values.Addresses) > 0 || len(values.Routes) > 0 || len(values.DNS) > 0
+}
+
+func hasDefaultRoute(routes []model.InterfaceRouteConfig) bool {
+	for _, route := range routes {
+		if route.To == "default" {
+			return true
 		}
 	}
-	return values, values.DHCP4 || values.IP != "" || values.Gateway != "" || len(values.DNS) > 0
+	return false
 }
 
 func anyToStringSlice(value any) []string {

+ 34 - 3
server/internal/network/netplan/netplan.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"networktool/internal/model"
 
@@ -89,11 +90,21 @@ func (s *Service) Write(path string, targetInterface string, input model.Interfa
 		return os.WriteFile(path, output, 0600)
 	}
 
+	addresses := normalizedAddresses(input)
+	routes := normalizedRoutes(input)
 	target["dhcp4"] = false
-	target["addresses"] = []string{fmt.Sprintf("%s/%d", input.IP, input.Prefix)}
+	addressItems := make([]string, 0, len(addresses))
+	for _, address := range addresses {
+		addressItems = append(addressItems, fmt.Sprintf("%s/%d", strings.TrimSpace(address.IP), address.Prefix))
+	}
+	target["addresses"] = addressItems
 	delete(target, "gateway4")
-	if input.Gateway != "" {
-		target["routes"] = []map[string]string{{"to": "default", "via": input.Gateway}}
+	if len(routes) > 0 {
+		routeItems := make([]map[string]string, 0, len(routes))
+		for _, route := range routes {
+			routeItems = append(routeItems, map[string]string{"to": strings.TrimSpace(route.To), "via": strings.TrimSpace(route.Via)})
+		}
+		target["routes"] = routeItems
 	} else {
 		delete(target, "routes")
 	}
@@ -111,6 +122,26 @@ func (s *Service) Write(path string, targetInterface string, input model.Interfa
 	return os.WriteFile(path, output, 0600)
 }
 
+func normalizedAddresses(input model.InterfaceConfig) []model.InterfaceAddressConfig {
+	if len(input.Addresses) > 0 {
+		return input.Addresses
+	}
+	if strings.TrimSpace(input.IP) == "" {
+		return nil
+	}
+	return []model.InterfaceAddressConfig{{IP: strings.TrimSpace(input.IP), Prefix: input.Prefix}}
+}
+
+func normalizedRoutes(input model.InterfaceConfig) []model.InterfaceRouteConfig {
+	if len(input.Routes) > 0 {
+		return input.Routes
+	}
+	if strings.TrimSpace(input.Gateway) == "" {
+		return nil
+	}
+	return []model.InterfaceRouteConfig{{To: "default", Via: strings.TrimSpace(input.Gateway)}}
+}
+
 func marshalYAML(value any) ([]byte, error) {
 	var output bytes.Buffer
 	encoder := yaml.NewEncoder(&output)

+ 88 - 19
server/internal/network/validator/validator.go

@@ -3,6 +3,7 @@ package validator
 import (
 	"fmt"
 	"net"
+	"strings"
 
 	"networktool/internal/model"
 )
@@ -13,6 +14,8 @@ func New() *Service { return &Service{} }
 
 func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 	resp := model.ValidateResponse{Valid: false, Warnings: []string{}, Errors: []string{}}
+	addresses := normalizedAddresses(input)
+	routes := normalizedRoutes(input)
 
 	if input.Interface == "" {
 		resp.Errors = append(resp.Errors, "目标接口不能为空。")
@@ -21,24 +24,66 @@ func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 		resp.Valid = len(resp.Errors) == 0
 		return resp
 	}
-	ip := net.ParseIP(input.IP)
-	if ip == nil || ip.To4() == nil {
-		resp.Errors = append(resp.Errors, "IP 地址格式不正确。")
+	if len(addresses) == 0 {
+		resp.Errors = append(resp.Errors, "至少需要填写一个 IP 地址。")
 	}
-	if input.Prefix < 0 || input.Prefix > 32 {
-		resp.Errors = append(resp.Errors, "前缀长度不正确。")
+	seenAddresses := make(map[string]struct{})
+	validNetworks := make([]*net.IPNet, 0, len(addresses))
+	for _, address := range addresses {
+		ip := net.ParseIP(address.IP)
+		if ip == nil || ip.To4() == nil {
+			resp.Errors = append(resp.Errors, fmt.Sprintf("IP 地址格式不正确:%s", address.IP))
+			continue
+		}
+		if address.Prefix < 0 || address.Prefix > 32 {
+			resp.Errors = append(resp.Errors, fmt.Sprintf("前缀长度不正确:%s/%d", address.IP, address.Prefix))
+			continue
+		}
+		key := fmt.Sprintf("%s/%d", ip.String(), address.Prefix)
+		if _, ok := seenAddresses[key]; ok {
+			resp.Errors = append(resp.Errors, fmt.Sprintf("IP 地址重复:%s", key))
+			continue
+		}
+		seenAddresses[key] = struct{}{}
+		mask := net.CIDRMask(address.Prefix, 32)
+		validNetworks = append(validNetworks, &net.IPNet{IP: ip.Mask(mask), Mask: mask})
+		ipv4 := ip.To4()
+		if ipv4[0] == 169 && ipv4[1] == 254 {
+			resp.Warnings = append(resp.Warnings, "目标接口使用的是链路本地地址,通常仅适合同链路通信。")
+		}
 	}
-	if input.Gateway != "" {
-		gateway := net.ParseIP(input.Gateway)
-		if gateway == nil || gateway.To4() == nil {
-			resp.Errors = append(resp.Errors, "网关格式不正确。")
-		} else if ip != nil && ip.To4() != nil && input.Prefix >= 0 && input.Prefix <= 32 {
-			mask := net.CIDRMask(input.Prefix, 32)
-			ipNet := &net.IPNet{IP: ip.Mask(mask), Mask: mask}
-			if !ipNet.Contains(gateway) {
-				resp.Errors = append(resp.Errors, "网关与目标接口 IP 不在同一子网。")
+	seenRoutes := make(map[string]struct{})
+	for _, route := range routes {
+		to := strings.TrimSpace(route.To)
+		via := strings.TrimSpace(route.Via)
+		if to == "" {
+			resp.Errors = append(resp.Errors, "路由目标不能为空。")
+			continue
+		}
+		if via == "" {
+			resp.Errors = append(resp.Errors, fmt.Sprintf("路由 %s 的下一跳不能为空。", to))
+			continue
+		}
+		if to != "default" {
+			ip, ipNet, err := net.ParseCIDR(to)
+			if err != nil || ip == nil || ip.To4() == nil || ipNet == nil {
+				resp.Errors = append(resp.Errors, fmt.Sprintf("路由目标格式不正确:%s", to))
 			}
 		}
+		gateway := net.ParseIP(via)
+		if gateway == nil || gateway.To4() == nil {
+			resp.Errors = append(resp.Errors, fmt.Sprintf("路由下一跳格式不正确:%s", via))
+			continue
+		}
+		key := to + " via " + gateway.String()
+		if _, ok := seenRoutes[key]; ok {
+			resp.Errors = append(resp.Errors, fmt.Sprintf("路由重复:%s", key))
+			continue
+		}
+		seenRoutes[key] = struct{}{}
+		if len(validNetworks) > 0 && !containsGateway(validNetworks, gateway) {
+			resp.Errors = append(resp.Errors, fmt.Sprintf("路由下一跳与任一目标接口 IP 都不在同一子网:%s", via))
+		}
 	}
 	for _, dns := range input.DNS {
 		if dns == "" {
@@ -49,12 +94,36 @@ func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 			resp.Errors = append(resp.Errors, fmt.Sprintf("DNS 格式不正确:%s", dns))
 		}
 	}
-	if ip != nil && ip.To4() != nil {
-		if ip[0] == 169 && ip[1] == 254 {
-			resp.Warnings = append(resp.Warnings, "目标接口使用的是链路本地地址,通常仅适合同链路通信。")
-		}
-	}
 
 	resp.Valid = len(resp.Errors) == 0
 	return resp
 }
+
+func normalizedAddresses(input model.InterfaceConfig) []model.InterfaceAddressConfig {
+	if len(input.Addresses) > 0 {
+		return input.Addresses
+	}
+	if strings.TrimSpace(input.IP) == "" {
+		return nil
+	}
+	return []model.InterfaceAddressConfig{{IP: strings.TrimSpace(input.IP), Prefix: input.Prefix}}
+}
+
+func normalizedRoutes(input model.InterfaceConfig) []model.InterfaceRouteConfig {
+	if len(input.Routes) > 0 {
+		return input.Routes
+	}
+	if strings.TrimSpace(input.Gateway) == "" {
+		return nil
+	}
+	return []model.InterfaceRouteConfig{{To: "default", Via: strings.TrimSpace(input.Gateway)}}
+}
+
+func containsGateway(networks []*net.IPNet, gateway net.IP) bool {
+	for _, network := range networks {
+		if network.Contains(gateway) {
+			return true
+		}
+	}
+	return false
+}

+ 10 - 12
windows/NetworkTool.Client/DeviceDetailsWindow.xaml

@@ -109,11 +109,11 @@
                                 <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="接口名" />
                                 <TextBlock x:Name="RemoteConfigInterfaceTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" />
                                 <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="当前 IP" />
-                                <TextBlock x:Name="RemoteConfigIpTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="当前网关" />
-                                <TextBlock x:Name="RemoteConfigGatewayTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" />
+                                <TextBlock x:Name="RemoteConfigIpTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" TextWrapping="Wrap" />
+                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="当前路由" />
+                                <TextBlock x:Name="RemoteConfigGatewayTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" TextWrapping="Wrap" />
                                 <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="当前 DNS" />
-                                <TextBlock x:Name="RemoteConfigDnsTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" />
+                                <TextBlock x:Name="RemoteConfigDnsTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" TextWrapping="Wrap" />
                             </StackPanel>
                         </Border>
 
@@ -121,14 +121,12 @@
                             <StackPanel>
                                 <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="新配置" />
                                 <CheckBox x:Name="Dhcp4CheckBox" Margin="0,12,0,0" VerticalContentAlignment="Center" Checked="ConfigModeChanged_OnChanged" Unchecked="ConfigModeChanged_OnChanged" Content="使用 DHCP 自动获取 IPv4 配置" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="IP 地址" />
-                                <TextBox x:Name="NewIpTextBox" Margin="0,8,0,0" MinHeight="32" VerticalContentAlignment="Center" TextChanged="ConfigInputChanged_OnChanged" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="子网掩码" />
-                                <TextBox x:Name="NewMaskTextBox" Margin="0,8,0,0" MinHeight="32" VerticalContentAlignment="Center" TextChanged="ConfigInputChanged_OnChanged" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="网关" />
-                                <TextBox x:Name="NewGatewayTextBox" Margin="0,8,0,0" MinHeight="32" VerticalContentAlignment="Center" TextChanged="ConfigInputChanged_OnChanged" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="首选 DNS" />
-                                <TextBox x:Name="NewDnsTextBox" Margin="0,8,0,0" MinHeight="32" VerticalContentAlignment="Center" TextChanged="ConfigInputChanged_OnChanged" />
+                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="IP 地址与子网掩码,每行一个:192.168.10.20 255.255.255.0 或 192.168.10.20/24" />
+                                <TextBox x:Name="NewAddressesTextBox" Margin="0,8,0,0" MinHeight="74" AcceptsReturn="True" TextWrapping="NoWrap" VerticalScrollBarVisibility="Auto" TextChanged="ConfigInputChanged_OnChanged" />
+                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="路由,每行一个:default via 192.168.10.1 或 10.10.0.0/16 via 192.168.20.1" />
+                                <TextBox x:Name="NewRoutesTextBox" Margin="0,8,0,0" MinHeight="74" AcceptsReturn="True" TextWrapping="NoWrap" VerticalScrollBarVisibility="Auto" TextChanged="ConfigInputChanged_OnChanged" />
+                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="DNS,每行一个" />
+                                <TextBox x:Name="NewDnsTextBox" Margin="0,8,0,0" MinHeight="54" AcceptsReturn="True" TextWrapping="NoWrap" VerticalScrollBarVisibility="Auto" TextChanged="ConfigInputChanged_OnChanged" />
                             </StackPanel>
                         </Border>
                     </Grid>

+ 117 - 24
windows/NetworkTool.Client/DeviceDetailsWindow.xaml.cs

@@ -85,9 +85,8 @@ public partial class DeviceDetailsWindow : Window
         RemoteConfigIpTextBlock.Text = "-";
         RemoteConfigGatewayTextBlock.Text = "-";
         RemoteConfigDnsTextBlock.Text = "-";
-        NewIpTextBox.Text = string.Empty;
-        NewMaskTextBox.Text = string.Empty;
-        NewGatewayTextBox.Text = string.Empty;
+        NewAddressesTextBox.Text = string.Empty;
+        NewRoutesTextBox.Text = string.Empty;
         NewDnsTextBox.Text = string.Empty;
         _configValidated = false;
     }
@@ -134,14 +133,13 @@ public partial class DeviceDetailsWindow : Window
             var config = result.Data;
             RemoteConfigInterfaceTextBlock.Text = config.Interface;
             RemoteConfigIpTextBlock.Text = FormatCurrentIp(config);
-            RemoteConfigGatewayTextBlock.Text = string.IsNullOrWhiteSpace(config.Gateway) ? "无" : config.Gateway;
+            RemoteConfigGatewayTextBlock.Text = FormatRoutes(config.EffectiveRoutes);
             RemoteConfigDnsTextBlock.Text = config.DnsSummary;
             _suppressConfigChangeHandling = true;
             Dhcp4CheckBox.IsChecked = false;
-            NewIpTextBox.Text = config.IP;
-            NewMaskTextBox.Text = PrefixToMask(config.Prefix);
-            NewGatewayTextBox.Text = config.Gateway;
-            NewDnsTextBox.Text = config.Dns?.FirstOrDefault() ?? string.Empty;
+            NewAddressesTextBox.Text = string.Join(Environment.NewLine, config.EffectiveAddresses.Select(item => $"{item.IP}/{item.Prefix}"));
+            NewRoutesTextBox.Text = string.Join(Environment.NewLine, config.EffectiveRoutes.Select(item => $"{item.To} via {item.Via}"));
+            NewDnsTextBox.Text = config.Dns is null ? string.Empty : string.Join(Environment.NewLine, config.Dns);
             _suppressConfigChangeHandling = false;
             _configValidated = false;
             ShowStatusMessage("已读取Linux端IP配置。");
@@ -216,8 +214,8 @@ public partial class DeviceDetailsWindow : Window
 
         var confirmMessage = $"将要把以下配置应用到接口 {selected.SystemName}:\n\n" +
                              $"模式:{(request.Dhcp4 ? "DHCP 自动获取" : "静态 IPv4")}\n" +
-                             $"IP:{(request.Dhcp4 ? "自动获取" : $"{request.IP}/{request.Prefix}")}\n" +
-                             $"网关:{(string.IsNullOrWhiteSpace(request.Gateway) ? "无" : request.Gateway)}\n" +
+                              $"IP:{(request.Dhcp4 ? "自动获取" : FormatAddresses(request.Addresses))}\n" +
+                              $"路由:{(request.Dhcp4 ? "自动获取" : FormatRoutes(request.Routes))}\n" +
                              $"DNS:{(request.Dns.Count == 0 ? "无" : string.Join(", ", request.Dns))}\n\n" +
                              "请确认是否继续。";
         if (MessageBox.Show(this, confirmMessage, "确认应用配置", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK)
@@ -422,42 +420,138 @@ public partial class DeviceDetailsWindow : Window
     private RemoteInterfaceConfig? BuildConfigRequest(string interfaceName)
     {
         var dhcp4 = Dhcp4CheckBox.IsChecked == true;
-        var prefix = 0;
-        if (!dhcp4 && string.IsNullOrWhiteSpace(NewIpTextBox.Text))
+        var addresses = Array.Empty<RemoteInterfaceAddressConfig>();
+        var routes = Array.Empty<RemoteInterfaceRouteConfig>();
+        if (!dhcp4 && string.IsNullOrWhiteSpace(NewAddressesTextBox.Text))
         {
-            ShowStatusMessage("IP 地址不能为空。");
+            ShowStatusMessage("IP 地址不能为空,至少需要填写一行地址。");
             return null;
         }
 
-        if (!dhcp4 && !TryMaskToPrefix(NewMaskTextBox.Text, out prefix))
+        if (!dhcp4 && !TryParseAddresses(NewAddressesTextBox.Text, out addresses, out var addressError))
         {
-            ShowStatusMessage("子网掩码格式不正确。");
+            ShowStatusMessage(addressError);
             return null;
         }
 
-        var dns = string.IsNullOrWhiteSpace(NewDnsTextBox.Text) ? Array.Empty<string>() : new[] { NewDnsTextBox.Text.Trim() };
+        if (!dhcp4 && !TryParseRoutes(NewRoutesTextBox.Text, out routes, out var routeError))
+        {
+            ShowStatusMessage(routeError);
+            return null;
+        }
+
+        var dns = ParseListText(NewDnsTextBox.Text);
         return new RemoteInterfaceConfig
         {
             Interface = interfaceName,
             Dhcp4 = dhcp4,
-            IP = dhcp4 ? string.Empty : NewIpTextBox.Text.Trim(),
-            Prefix = prefix,
-            Gateway = dhcp4 ? string.Empty : NewGatewayTextBox.Text.Trim(),
+            Addresses = dhcp4 ? Array.Empty<RemoteInterfaceAddressConfig>() : addresses,
+            Routes = dhcp4 ? Array.Empty<RemoteInterfaceRouteConfig>() : routes,
             Dns = dhcp4 ? Array.Empty<string>() : dns,
         };
     }
 
     private static string FormatCurrentIp(RemoteInterfaceConfig config)
     {
-        if (string.IsNullOrWhiteSpace(config.IP))
+        if (config.EffectiveAddresses.Count == 0)
         {
             return config.Dhcp4 ? "DHCP 自动获取,暂无 IPv4" : "无";
         }
 
-        var text = $"{config.IP}/{config.Prefix}";
+        var text = FormatAddresses(config.EffectiveAddresses);
         return config.Dhcp4 ? $"{text} (DHCP)" : text;
     }
 
+    private static string FormatAddresses(IReadOnlyList<RemoteInterfaceAddressConfig> addresses)
+    {
+        return addresses.Count == 0 ? "无" : string.Join(Environment.NewLine, addresses.Select(item => $"{item.IP}/{item.Prefix}"));
+    }
+
+    private static string FormatRoutes(IReadOnlyList<RemoteInterfaceRouteConfig> routes)
+    {
+        return routes.Count == 0 ? "无" : string.Join(Environment.NewLine, routes.Select(item => $"{item.To} via {item.Via}"));
+    }
+
+    private static bool TryParseAddresses(string text, out RemoteInterfaceAddressConfig[] addresses, out string error)
+    {
+        var result = new List<RemoteInterfaceAddressConfig>();
+        foreach (var line in ParseListText(text))
+        {
+            var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+            if (parts.Length == 1 && line.Contains('/'))
+            {
+                var cidrParts = line.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+                if (cidrParts.Length != 2 || !int.TryParse(cidrParts[1], out var prefix) || prefix < 0 || prefix > 32)
+                {
+                    addresses = [];
+                    error = $"地址格式不正确:{line}";
+                    return false;
+                }
+                result.Add(new RemoteInterfaceAddressConfig { IP = cidrParts[0], Prefix = prefix });
+                continue;
+            }
+
+            if (parts.Length != 2)
+            {
+                addresses = [];
+                error = $"地址格式不正确:{line}";
+                return false;
+            }
+            if (!TryMaskOrPrefixToPrefix(parts[1], out var parsedPrefix))
+            {
+                addresses = [];
+                error = $"子网掩码或前缀格式不正确:{line}";
+                return false;
+            }
+            result.Add(new RemoteInterfaceAddressConfig { IP = parts[0], Prefix = parsedPrefix });
+        }
+
+        addresses = result.ToArray();
+        error = string.Empty;
+        return addresses.Length > 0;
+    }
+
+    private static bool TryParseRoutes(string text, out RemoteInterfaceRouteConfig[] routes, out string error)
+    {
+        var result = new List<RemoteInterfaceRouteConfig>();
+        foreach (var line in ParseListText(text))
+        {
+            var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+            if (parts.Length == 1)
+            {
+                result.Add(new RemoteInterfaceRouteConfig { To = "default", Via = parts[0] });
+                continue;
+            }
+            if (parts.Length == 3 && parts[1].Equals("via", StringComparison.OrdinalIgnoreCase))
+            {
+                result.Add(new RemoteInterfaceRouteConfig { To = parts[0], Via = parts[2] });
+                continue;
+            }
+
+            routes = [];
+            error = $"路由格式不正确:{line}";
+            return false;
+        }
+
+        routes = result.ToArray();
+        error = string.Empty;
+        return true;
+    }
+
+    private static string[] ParseListText(string text)
+    {
+        return text.Split(['\r', '\n', ',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+    }
+
+    private static bool TryMaskOrPrefixToPrefix(string text, out int prefix)
+    {
+        if (int.TryParse(text, out prefix) && prefix >= 0 && prefix <= 32)
+        {
+            return true;
+        }
+        return TryMaskToPrefix(text, out prefix);
+    }
+
     private static string PrefixToMask(int prefix)
     {
         if (prefix < 0 || prefix > 32)
@@ -645,9 +739,8 @@ public partial class DeviceDetailsWindow : Window
         ValidateConfigButton.IsEnabled = canEdit;
         ApplyConfigButton.IsEnabled = !_isBusy && _configValidated && hasSelectedInterface;
         Dhcp4CheckBox.IsEnabled = canEdit;
-        NewIpTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
-        NewMaskTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
-        NewGatewayTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
+        NewAddressesTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
+        NewRoutesTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
         NewDnsTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
         RebootButton.IsEnabled = !_isBusy;
         ShutdownButton.IsEnabled = !_isBusy;

+ 50 - 2
windows/NetworkTool.Client/Models/RemoteInterfaceConfig.cs

@@ -1,13 +1,61 @@
+using System.Text.Json.Serialization;
+
 namespace NetworkTool.Client.Models;
 
+public sealed class RemoteInterfaceAddressConfig
+{
+    [JsonPropertyName("ip")]
+    public string IP { get; init; } = string.Empty;
+
+    [JsonPropertyName("prefix")]
+    public int Prefix { get; init; }
+}
+
+public sealed class RemoteInterfaceRouteConfig
+{
+    [JsonPropertyName("to")]
+    public string To { get; init; } = string.Empty;
+
+    [JsonPropertyName("via")]
+    public string Via { get; init; } = string.Empty;
+}
+
 public sealed class RemoteInterfaceConfig
 {
+    [JsonPropertyName("interface")]
     public string Interface { get; init; } = string.Empty;
+
+    [JsonPropertyName("dhcp4")]
     public bool Dhcp4 { get; init; }
+
+    [JsonPropertyName("addresses")]
+    public IReadOnlyList<RemoteInterfaceAddressConfig> Addresses { get; init; } = [];
+
+    [JsonPropertyName("routes")]
+    public IReadOnlyList<RemoteInterfaceRouteConfig> Routes { get; init; } = [];
+
+    [JsonPropertyName("dns")]
+    public IReadOnlyList<string> Dns { get; init; } = [];
+
+    [JsonPropertyName("ip")]
     public string IP { get; init; } = string.Empty;
+
+    [JsonPropertyName("prefix")]
     public int Prefix { get; init; }
+
+    [JsonPropertyName("gateway")]
     public string Gateway { get; init; } = string.Empty;
-    public IReadOnlyList<string> Dns { get; init; } = [];
 
-    public string DnsSummary => Dns is null || Dns.Count == 0 ? "无" : string.Join(", ", Dns);
+    [JsonIgnore]
+    public string DnsSummary => Dns is null || Dns.Count == 0 ? "无" : string.Join(Environment.NewLine, Dns);
+
+    [JsonIgnore]
+    public IReadOnlyList<RemoteInterfaceAddressConfig> EffectiveAddresses => Addresses.Count > 0
+        ? Addresses
+        : string.IsNullOrWhiteSpace(IP) ? [] : [new RemoteInterfaceAddressConfig { IP = IP, Prefix = Prefix }];
+
+    [JsonIgnore]
+    public IReadOnlyList<RemoteInterfaceRouteConfig> EffectiveRoutes => Routes.Count > 0
+        ? Routes
+        : string.IsNullOrWhiteSpace(Gateway) ? [] : [new RemoteInterfaceRouteConfig { To = "default", Via = Gateway }];
 }