Преглед изворни кода

feat(agent): 网络配置应用增加确认机制及超时回滚

移除自动验证,改为应用后等待客户端确认;超时或取消则自动回滚,防止配置丢失。
yangkaixiang пре 1 месец
родитељ
комит
3e0525b247

+ 2 - 4
agent/cmd/quickip-agent/main.go

@@ -15,9 +15,8 @@ import (
 	configreadersvc "quickip/internal/network/configreader"
 	interfacesvc "quickip/internal/network/interfaces"
 	netplansvc "quickip/internal/network/netplan"
-	"quickip/internal/systemaction"
 	validatorsvc "quickip/internal/network/validator"
-	verifysvc "quickip/internal/network/verify"
+	"quickip/internal/systemaction"
 	"quickip/internal/tasks"
 )
 
@@ -36,7 +35,6 @@ func main() {
 	validatorSvc := validatorsvc.New()
 	netplanSvc := netplansvc.New()
 	applySvc := applyexecsvc.New()
-	verifySvc := verifysvc.New()
 	taskSvc := tasks.New()
 	systemSvc := systemaction.New()
 
@@ -45,7 +43,7 @@ func main() {
 		return
 	}
 
-	httpSrv := httpserver.New(cfg, log, deviceSvc, interfaceSvc, configSvc, validatorSvc, netplanSvc, applySvc, verifySvc, taskSvc, systemSvc)
+	httpSrv := httpserver.New(cfg, log, deviceSvc, interfaceSvc, configSvc, validatorSvc, netplanSvc, applySvc, taskSvc, systemSvc)
 	udpSrv := discovery.New(cfg, log, deviceSvc)
 
 	errCh := make(chan error, 2)

+ 1 - 2
agent/internal/config/config.go

@@ -20,7 +20,7 @@ type Config struct {
 
 func Load(args []string) Config {
 	cfg := Config{
-		HTTPHost:         "169.254.100.2",
+		HTTPHost:         "0.0.0.0",
 		HTTPPort:         48888,
 		UDPHost:          "0.0.0.0",
 		UDPPort:          50000,
@@ -41,7 +41,6 @@ func Load(args []string) Config {
 		panic(fmt.Sprintf("invalid maintenance ip: %s", cfg.MaintenanceIP))
 	}
 
-	cfg.HTTPHost = cfg.MaintenanceIP
 	cfg.MaintenanceCIDR = fmt.Sprintf("%s/16", cfg.MaintenanceIP)
 
 	return cfg

+ 151 - 22
agent/internal/httpserver/server.go

@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"os"
 	"strings"
+	"sync"
 	"time"
 
 	"quickip/internal/auth"
@@ -19,28 +20,35 @@ import (
 	configreadersvc "quickip/internal/network/configreader"
 	interfacesvc "quickip/internal/network/interfaces"
 	netplansvc "quickip/internal/network/netplan"
-	"quickip/internal/systemaction"
 	validatorsvc "quickip/internal/network/validator"
-	verifysvc "quickip/internal/network/verify"
+	"quickip/internal/systemaction"
 	"quickip/internal/tasks"
 )
 
 type Server struct {
-	cfg          config.Config
-	log          *logger.Logger
-	deviceSvc    *deviceinfo.Service
-	interfaceSvc *interfacesvc.Service
-	configSvc    *configreadersvc.Service
-	validatorSvc *validatorsvc.Service
-	netplanSvc   *netplansvc.Service
-	applySvc     *applyexecsvc.Service
-	verifySvc    *verifysvc.Service
-	taskSvc      *tasks.Service
-	systemSvc    *systemaction.Service
+	cfg           config.Config
+	log           *logger.Logger
+	deviceSvc     *deviceinfo.Service
+	interfaceSvc  *interfacesvc.Service
+	configSvc     *configreadersvc.Service
+	validatorSvc  *validatorsvc.Service
+	netplanSvc    *netplansvc.Service
+	applySvc      *applyexecsvc.Service
+	taskSvc       *tasks.Service
+	systemSvc     *systemaction.Service
+	confirmMu     sync.Mutex
+	applyControls map[string]applyControl
 }
 
-func New(cfg config.Config, log *logger.Logger, deviceSvc *deviceinfo.Service, interfaceSvc *interfacesvc.Service, configSvc *configreadersvc.Service, validatorSvc *validatorsvc.Service, netplanSvc *netplansvc.Service, applySvc *applyexecsvc.Service, verifySvc *verifysvc.Service, taskSvc *tasks.Service, systemSvc *systemaction.Service) *Server {
-	return &Server{cfg: cfg, log: log, deviceSvc: deviceSvc, interfaceSvc: interfaceSvc, configSvc: configSvc, validatorSvc: validatorSvc, netplanSvc: netplanSvc, applySvc: applySvc, verifySvc: verifySvc, taskSvc: taskSvc, systemSvc: systemSvc}
+type applyControl struct {
+	confirm chan struct{}
+	cancel  chan struct{}
+}
+
+const applyConfirmationTimeout = 20 * time.Second
+
+func New(cfg config.Config, log *logger.Logger, deviceSvc *deviceinfo.Service, interfaceSvc *interfacesvc.Service, configSvc *configreadersvc.Service, validatorSvc *validatorsvc.Service, netplanSvc *netplansvc.Service, applySvc *applyexecsvc.Service, taskSvc *tasks.Service, systemSvc *systemaction.Service) *Server {
+	return &Server{cfg: cfg, log: log, deviceSvc: deviceSvc, interfaceSvc: interfaceSvc, configSvc: configSvc, validatorSvc: validatorSvc, netplanSvc: netplanSvc, applySvc: applySvc, taskSvc: taskSvc, systemSvc: systemSvc, applyControls: make(map[string]applyControl)}
 }
 
 func (s *Server) Run(ctx context.Context) error {
@@ -51,6 +59,8 @@ func (s *Server) Run(ctx context.Context) error {
 	mux.Handle("/api/network/config", auth.Middleware(s.cfg, http.HandlerFunc(s.handleConfig)))
 	mux.Handle("/api/network/validate", auth.Middleware(s.cfg, http.HandlerFunc(s.handleValidate)))
 	mux.Handle("/api/network/apply", auth.Middleware(s.cfg, http.HandlerFunc(s.handleApply)))
+	mux.Handle("/api/network/apply/confirm", auth.Middleware(s.cfg, http.HandlerFunc(s.handleApplyConfirm)))
+	mux.Handle("/api/network/apply/cancel", auth.Middleware(s.cfg, http.HandlerFunc(s.handleApplyCancel)))
 	mux.Handle("/api/network/rollback", auth.Middleware(s.cfg, http.HandlerFunc(s.handleRollback)))
 	mux.Handle("/api/system/reboot", auth.Middleware(s.cfg, http.HandlerFunc(s.handleReboot)))
 	mux.Handle("/api/system/shutdown", auth.Middleware(s.cfg, http.HandlerFunc(s.handleShutdown)))
@@ -78,6 +88,50 @@ func (s *Server) Run(ctx context.Context) error {
 	return err
 }
 
+func (s *Server) handleApplyConfirm(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		writeJSON(w, http.StatusMethodNotAllowed, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
+		return
+	}
+	var input model.ConfirmApplyRequest
+	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"请求体格式不正确。"}}})
+		return
+	}
+	if input.TaskID == "" {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"缺少 task_id。"}}})
+		return
+	}
+
+	if !s.confirmApply(input.TaskID) {
+		writeJSON(w, http.StatusNotFound, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
+		return
+	}
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "已确认保留配置", Data: map[string]any{"task_id": input.TaskID, "confirmed": true}})
+}
+
+func (s *Server) handleApplyCancel(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		writeJSON(w, http.StatusMethodNotAllowed, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
+		return
+	}
+	var input model.ConfirmApplyRequest
+	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"请求体格式不正确。"}}})
+		return
+	}
+	if input.TaskID == "" {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"缺少 task_id。"}}})
+		return
+	}
+
+	if !s.cancelApply(input.TaskID) {
+		writeJSON(w, http.StatusNotFound, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
+		return
+	}
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "已取消保留配置,正在回滚", Data: map[string]any{"task_id": input.TaskID, "cancelled": true}})
+}
+
 func (s *Server) withAccessLog(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		started := time.Now()
@@ -295,28 +349,103 @@ 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, ","))
 	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
 	}
+	if writtenData, err := os.ReadFile(filePath); err != nil {
+		s.log.Error("failed to read netplan after write", "task_id", taskID, "file", filePath, "error", err.Error())
+	} else {
+		s.log.Info("netplan written", "task_id", taskID, "file", filePath, "content", string(writtenData))
+	}
 
+	control := s.registerApplyControl(taskID)
+	defer s.unregisterApplyControl(taskID)
 	s.taskSvc.Update(taskID, "running", "applying", "正在应用 netplan 配置。", false)
 	if err := s.applySvc.Apply(); err != nil {
+		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.taskSvc.Update(taskID, "rolled_back", "rolling_back", "配置失败,已自动回滚。", true)
+		_ = 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
 	}
 
-	s.taskSvc.Update(taskID, "running", "verifying", "正在验证配置结果。", false)
-	if err := s.verifySvc.Verify(input); err != nil {
-		_ = s.netplanSvc.Restore(filePath, backupPath)
-		_ = s.applySvc.Apply()
-		s.taskSvc.Update(taskID, "rolled_back", "rolling_back", "配置失败,已自动回滚。", true)
+	s.taskSvc.Update(taskID, "running", "confirming", fmt.Sprintf("配置已应用,请在 %d 秒内确认保留;未确认将自动回滚。", int(applyConfirmationTimeout.Seconds())), false)
+	select {
+	case <-control.confirm:
+		_ = os.Remove(backupPath)
+		s.taskSvc.Update(taskID, "success", "completed", "配置已应用并由客户端确认保留。", false)
+		return
+	case <-control.cancel:
+		s.rollbackAppliedConfig(taskID, filePath, backupPath, "用户取消保留配置")
 		return
+	case <-time.After(applyConfirmationTimeout):
+		s.rollbackAppliedConfig(taskID, filePath, backupPath, "确认超时")
+		return
+	}
+}
+
+func (s *Server) rollbackAppliedConfig(taskID string, filePath string, backupPath string, reason string) {
+	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)
+}
+
+func (s *Server) registerApplyControl(taskID string) applyControl {
+	control := applyControl{confirm: make(chan struct{}, 1), cancel: make(chan struct{}, 1)}
+	s.confirmMu.Lock()
+	s.applyControls[taskID] = control
+	s.confirmMu.Unlock()
+	return control
+}
+
+func (s *Server) unregisterApplyControl(taskID string) {
+	s.confirmMu.Lock()
+	delete(s.applyControls, taskID)
+	s.confirmMu.Unlock()
+}
+
+func (s *Server) confirmApply(taskID string) bool {
+	s.confirmMu.Lock()
+	control, ok := s.applyControls[taskID]
+	s.confirmMu.Unlock()
+	if !ok {
+		return false
 	}
+	select {
+	case control.confirm <- struct{}{}:
+	default:
+	}
+	return true
+}
 
-	s.taskSvc.Update(taskID, "success", "completed", "目标接口配置已成功应用。", false)
+func (s *Server) cancelApply(taskID string) bool {
+	s.confirmMu.Lock()
+	control, ok := s.applyControls[taskID]
+	s.confirmMu.Unlock()
+	if !ok {
+		return false
+	}
+	select {
+	case control.cancel <- struct{}{}:
+	default:
+	}
+	return true
+}
+
+func (s *Server) logNetplanFile(taskID string, filePath string, message string) {
+	data, err := os.ReadFile(filePath)
+	if err != nil {
+		s.log.Error("failed to read netplan file for logging", "task_id", taskID, "file", filePath, "error", err.Error())
+		return
+	}
+	s.log.Info(message, "task_id", taskID, "file", filePath, "content", string(data))
 }
 
 func (s *Server) runSystemTask(taskID string, action string) {

+ 5 - 0
agent/internal/model/types.go

@@ -60,6 +60,7 @@ 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"`
@@ -76,6 +77,10 @@ type RollbackRequest struct {
 	Interface string `json:"interface"`
 }
 
+type ConfirmApplyRequest struct {
+	TaskID string `json:"task_id"`
+}
+
 type TaskResult struct {
 	TaskID   string `json:"task_id"`
 	Status   string `json:"status"`

+ 139 - 8
agent/internal/network/configreader/configreader.go

@@ -6,13 +6,24 @@ import (
 	"net"
 	"os"
 	"os/exec"
+	"path/filepath"
 	"strings"
 
 	"quickip/internal/model"
+
+	"gopkg.in/yaml.v3"
 )
 
 type Service struct{}
 
+type configuredInterfaceValues struct {
+	DHCP4   bool
+	IP      string
+	Prefix  int
+	Gateway string
+	DNS     []string
+}
+
 func New() *Service { return &Service{} }
 
 func (s *Service) Read(interfaceName string) (model.InterfaceConfig, error) {
@@ -22,23 +33,44 @@ 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
+	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 || config.Gateway == "" {
+		config.Gateway = readGateway(interfaceName)
+	}
+	if config.Dhcp4 || len(config.DNS) == 0 {
+		config.DNS = readDNS()
+	}
+	return config, nil
+}
+
+func readInterfaceIPv4(iface *net.Interface) (string, int) {
 	addrs, err := iface.Addrs()
 	if err != nil {
-		return model.InterfaceConfig{}, err
+		return "", 0
 	}
 	for _, addr := range addrs {
 		ipNet, ok := addr.(*net.IPNet)
 		if !ok || ipNet.IP.To4() == nil {
 			continue
 		}
-		config.IP = ipNet.IP.String()
-		config.Prefix, _ = ipNet.Mask.Size()
-		break
+		prefix, _ := ipNet.Mask.Size()
+		return ipNet.IP.String(), prefix
 	}
-
-	config.Gateway = readGateway(interfaceName)
-	config.DNS = readDNS()
-	return config, nil
+	return "", 0
 }
 
 func readGateway(interfaceName string) string {
@@ -82,6 +114,105 @@ func readDNS() []string {
 	return result
 }
 
+func readConfiguredInterfaceValues(interfaceName string) configuredInterfaceValues {
+	files, err := filepath.Glob("/etc/netplan/*.yaml")
+	if err != nil || len(files) == 0 {
+		return configuredInterfaceValues{}
+	}
+	for _, filePath := range files {
+		data, err := os.ReadFile(filePath)
+		if err != nil {
+			continue
+		}
+		var raw map[string]any
+		if err := yaml.Unmarshal(data, &raw); err != nil {
+			continue
+		}
+		values, ok := findInterfaceValues(raw, interfaceName)
+		if ok {
+			return values
+		}
+	}
+	return configuredInterfaceValues{}
+}
+
+func findInterfaceValues(raw map[string]any, interfaceName string) (configuredInterfaceValues, bool) {
+	network, ok := raw["network"].(map[string]any)
+	if !ok {
+		return configuredInterfaceValues{}, false
+	}
+	ethernets, ok := network["ethernets"].(map[string]any)
+	if !ok {
+		return configuredInterfaceValues{}, false
+	}
+	entry, ok := ethernets[interfaceName].(map[string]any)
+	if !ok {
+		return configuredInterfaceValues{}, false
+	}
+	values := configuredInterfaceValues{}
+	if dhcp4, ok := entry["dhcp4"].(bool); ok {
+		values.DHCP4 = dhcp4
+	}
+	addresses, ok := entry["addresses"].([]any)
+	if ok {
+		for _, item := range addresses {
+			text, ok := item.(string)
+			if !ok || strings.TrimSpace(text) == "" {
+				continue
+			}
+			ip, ipNet, err := net.ParseCIDR(text)
+			if err != nil || ip == nil || ip.To4() == nil {
+				continue
+			}
+			prefix, _ := ipNet.Mask.Size()
+			values.IP = ip.String()
+			values.Prefix = prefix
+			break
+		}
+	}
+	if nameservers, ok := entry["nameservers"].(map[string]any); ok {
+		values.DNS = anyToStringSlice(nameservers["addresses"])
+	}
+	if routes, ok := entry["routes"].([]any); ok {
+		for _, item := range routes {
+			route, ok := item.(map[string]any)
+			if !ok {
+				continue
+			}
+			to, _ := route["to"].(string)
+			via, _ := route["via"].(string)
+			if strings.TrimSpace(to) == "default" && strings.TrimSpace(via) != "" {
+				values.Gateway = strings.TrimSpace(via)
+				break
+			}
+		}
+	}
+	if values.Gateway == "" {
+		if gateway4, ok := entry["gateway4"].(string); ok {
+			values.Gateway = strings.TrimSpace(gateway4)
+		}
+	}
+	return values, values.DHCP4 || values.IP != "" || values.Gateway != "" || len(values.DNS) > 0
+}
+
+func anyToStringSlice(value any) []string {
+	switch typed := value.(type) {
+	case []string:
+		return append([]string(nil), typed...)
+	case []any:
+		result := make([]string, 0, len(typed))
+		for _, item := range typed {
+			text, ok := item.(string)
+			if ok && strings.TrimSpace(text) != "" {
+				result = append(result, text)
+			}
+		}
+		return result
+	default:
+		return []string{}
+	}
+}
+
 func MustRead(interfaceName string) model.InterfaceConfig {
 	config, err := New().Read(interfaceName)
 	if err != nil {

+ 54 - 49
agent/internal/network/netplan/netplan.go

@@ -13,26 +13,7 @@ import (
 type Service struct{}
 
 type fileConfig struct {
-	Network networkConfig `yaml:"network"`
-}
-
-type networkConfig struct {
-	Version   int                       `yaml:"version,omitempty"`
-	Ethernets map[string]*ethernetEntry `yaml:"ethernets,omitempty"`
-}
-
-type ethernetEntry struct {
-	DHCP4       *bool             `yaml:"dhcp4,omitempty"`
-	Addresses   []string          `yaml:"addresses,omitempty"`
-	Gateway4    string            `yaml:"gateway4,omitempty"`
-	Nameservers *nameserverConfig `yaml:"nameservers,omitempty"`
-	Optional    *bool             `yaml:"optional,omitempty"`
-	MTU         int               `yaml:"mtu,omitempty"`
-	WakeOnLAN   *bool             `yaml:"wakeonlan,omitempty"`
-}
-
-type nameserverConfig struct {
-	Addresses []string `yaml:"addresses,omitempty"`
+	Network map[string]any `yaml:"network"`
 }
 
 func New() *Service { return &Service{} }
@@ -53,9 +34,6 @@ func (s *Service) FindSingleFile() (string, error) {
 
 func (s *Service) Backup(path string) (string, error) {
 	backupPath := path + ".quickip.bak"
-	if _, err := os.Stat(backupPath); err == nil {
-		return backupPath, nil
-	}
 	data, err := os.ReadFile(path)
 	if err != nil {
 		return "", err
@@ -88,41 +66,41 @@ func (s *Service) Write(path string, targetInterface string, input model.Interfa
 	if err := yaml.Unmarshal(data, &cfg); err != nil {
 		return err
 	}
-	if cfg.Network.Version == 0 {
-		cfg.Network.Version = 2
+	if cfg.Network == nil {
+		cfg.Network = make(map[string]any)
 	}
-	if cfg.Network.Ethernets == nil {
-		cfg.Network.Ethernets = make(map[string]*ethernetEntry)
+	if _, ok := cfg.Network["version"]; !ok {
+		cfg.Network["version"] = 2
 	}
 
-	target := cfg.Network.Ethernets[targetInterface]
-	if target == nil {
-		target = &ethernetEntry{}
-		cfg.Network.Ethernets[targetInterface] = target
+	ethernets := ensureMap(cfg.Network, "ethernets")
+	target := ensureMap(ethernets, targetInterface)
+	if input.Dhcp4 {
+		target["dhcp4"] = true
+		delete(target, "addresses")
+		delete(target, "gateway4")
+		delete(target, "routes")
+		delete(target, "nameservers")
+		output, err := yaml.Marshal(&cfg)
+		if err != nil {
+			return err
+		}
+		return os.WriteFile(path, output, 0600)
 	}
-	dhcpFalse := false
-	target.DHCP4 = &dhcpFalse
-	target.Addresses = []string{fmt.Sprintf("%s/%d", input.IP, input.Prefix)}
+
+	target["dhcp4"] = false
+	target["addresses"] = []string{fmt.Sprintf("%s/%d", input.IP, input.Prefix)}
+	delete(target, "gateway4")
 	if input.Gateway != "" {
-		target.Gateway4 = input.Gateway
+		target["routes"] = []map[string]string{{"to": "default", "via": input.Gateway}}
 	} else {
-		target.Gateway4 = ""
+		delete(target, "routes")
 	}
 	if len(input.DNS) > 0 {
-		target.Nameservers = &nameserverConfig{Addresses: input.DNS}
+		nameservers := ensureMap(target, "nameservers")
+		nameservers["addresses"] = input.DNS
 	} else {
-		target.Nameservers = nil
-	}
-
-	mgmt := cfg.Network.Ethernets[managementInterface]
-	if mgmt == nil {
-		mgmt = &ethernetEntry{}
-		cfg.Network.Ethernets[managementInterface] = mgmt
-	}
-	dhcpTrue := true
-	mgmt.DHCP4 = &dhcpTrue
-	if !contains(mgmt.Addresses, maintenanceCIDR) {
-		mgmt.Addresses = append(uniqueNonEmpty(mgmt.Addresses), maintenanceCIDR)
+		delete(target, "nameservers")
 	}
 
 	output, err := yaml.Marshal(&cfg)
@@ -132,6 +110,33 @@ func (s *Service) Write(path string, targetInterface string, input model.Interfa
 	return os.WriteFile(path, output, 0600)
 }
 
+func ensureMap(parent map[string]any, key string) map[string]any {
+	if existing, ok := parent[key].(map[string]any); ok {
+		return existing
+	}
+	child := make(map[string]any)
+	parent[key] = child
+	return child
+}
+
+func anyToStringSlice(value any) []string {
+	switch typed := value.(type) {
+	case []string:
+		return append([]string(nil), typed...)
+	case []any:
+		result := make([]string, 0, len(typed))
+		for _, item := range typed {
+			text, ok := item.(string)
+			if ok {
+				result = append(result, text)
+			}
+		}
+		return result
+	default:
+		return nil
+	}
+}
+
 func contains(items []string, target string) bool {
 	for _, item := range items {
 		if item == target {

+ 4 - 0
agent/internal/network/validator/validator.go

@@ -17,6 +17,10 @@ func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 	if input.Interface == "" {
 		resp.Errors = append(resp.Errors, "目标接口不能为空。")
 	}
+	if input.Dhcp4 {
+		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 地址格式不正确。")

+ 107 - 3
agent/internal/network/verify/verify.go

@@ -1,8 +1,12 @@
 package verify
 
 import (
+	"bufio"
 	"fmt"
 	"net"
+	"os"
+	"os/exec"
+	"strings"
 
 	"quickip/internal/model"
 )
@@ -11,7 +15,7 @@ type Service struct{}
 
 func New() *Service { return &Service{} }
 
-func (s *Service) Verify(input model.InterfaceConfig) error {
+func (s *Service) Verify(input model.InterfaceConfig, managementInterface string, maintenanceCIDR string) error {
 	iface, err := net.InterfaceByName(input.Interface)
 	if err != nil {
 		return err
@@ -20,6 +24,7 @@ func (s *Service) Verify(input model.InterfaceConfig) error {
 	if err != nil {
 		return err
 	}
+	matchedAddress := false
 	for _, addr := range addrs {
 		ipNet, ok := addr.(*net.IPNet)
 		if !ok || ipNet.IP.To4() == nil {
@@ -27,8 +32,107 @@ func (s *Service) Verify(input model.InterfaceConfig) error {
 		}
 		prefix, _ := ipNet.Mask.Size()
 		if ipNet.IP.String() == input.IP && prefix == input.Prefix {
-			return nil
+			matchedAddress = true
+			break
 		}
 	}
-	return fmt.Errorf("目标接口地址未按预期生效")
+	if !matchedAddress {
+		return fmt.Errorf("目标接口地址未按预期生效")
+	}
+
+	actualGateway := readGateway(input.Interface)
+	if strings.TrimSpace(input.Gateway) != strings.TrimSpace(actualGateway) {
+		return fmt.Errorf("目标接口网关未按预期生效")
+	}
+
+	actualDNS := readDNS()
+	if !containsAll(actualDNS, input.DNS) {
+		return fmt.Errorf("DNS 未按预期生效")
+	}
+
+	if managementInterface != "" && maintenanceCIDR != "" {
+		ok, err := hasInterfaceCIDR(managementInterface, maintenanceCIDR)
+		if err != nil {
+			return err
+		}
+		if !ok {
+			return fmt.Errorf("管理接口维护地址未按预期保留")
+		}
+	}
+
+	return nil
+}
+
+func readGateway(interfaceName string) string {
+	cmd := exec.Command("ip", "route", "show", "dev", interfaceName)
+	output, err := cmd.Output()
+	if err != nil {
+		return ""
+	}
+	for _, line := range strings.Split(string(output), "\n") {
+		line = strings.TrimSpace(line)
+		if !strings.HasPrefix(line, "default via ") {
+			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 ") {
+			continue
+		}
+		parts := strings.Fields(line)
+		if len(parts) >= 2 {
+			result = append(result, parts[1])
+		}
+	}
+	return result
+}
+
+func hasInterfaceCIDR(interfaceName string, expectedCIDR string) (bool, error) {
+	iface, err := net.InterfaceByName(interfaceName)
+	if err != nil {
+		return false, err
+	}
+	addrs, err := iface.Addrs()
+	if err != nil {
+		return false, err
+	}
+	for _, addr := range addrs {
+		if addr.String() == expectedCIDR {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+func containsAll(actual []string, expected []string) bool {
+	if len(expected) == 0 {
+		return true
+	}
+	set := make(map[string]struct{}, len(actual))
+	for _, item := range actual {
+		set[strings.TrimSpace(item)] = struct{}{}
+	}
+	for _, item := range expected {
+		if _, ok := set[strings.TrimSpace(item)]; !ok {
+			return false
+		}
+	}
+	return true
 }

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

@@ -140,16 +140,17 @@
 
 1. 备份完成后写回修改后的文件
 2. 执行 `netplan apply`
-3. 检查执行结果
-4. 验证目标接口地址是否生效
+3. 客户端仍可连接时,在限定时间内由用户确认保留配置
+4. 未确认、取消或执行失败时恢复本次备份并再次执行 `netplan apply`
 
 ### 7.3 回滚规则
 
 出现以下情况时执行回滚:
 
-1. `netplan apply` 失败
-2. 目标接口地址未按预期生效
-3. 其他明确判定为配置失败的情况
+1. `netplan apply` 执行失败
+2. 用户未在超时时间内确认保留配置
+3. 用户主动取消保留配置
+4. 其他明确判定为配置失败的情况
 
 回滚步骤:
 

+ 1 - 0
windows/QuickIP.Client/DeviceDetailsWindow.xaml

@@ -120,6 +120,7 @@
                         <Border Grid.Column="1" Margin="8,0,0,0" Padding="14" Background="#F9FAFB" CornerRadius="10">
                             <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="子网掩码" />

+ 201 - 22
windows/QuickIP.Client/DeviceDetailsWindow.xaml.cs

@@ -10,6 +10,7 @@ namespace QuickIP.Client;
 
 public partial class DeviceDetailsWindow : Window
 {
+    private const int ApplyConfirmationTimeoutSeconds = 20;
     private readonly AgentApiService _agentApiService = new();
     private readonly string _baseAddress;
     private readonly string _localIPv4;
@@ -30,8 +31,16 @@ public partial class DeviceDetailsWindow : Window
 
     private async void DeviceDetailsWindow_OnLoaded(object sender, RoutedEventArgs e)
     {
-        await LoadRemoteDetailsAsync();
-        UpdateButtonStates();
+        try
+        {
+            await LoadRemoteDetailsAsync();
+            UpdateButtonStates();
+        }
+        catch (Exception ex)
+        {
+            ShowStatusMessage($"读取设备信息失败:{ex.Message}");
+            SetBusyState(false);
+        }
     }
 
     private async Task LoadRemoteDetailsAsync()
@@ -85,13 +94,21 @@ public partial class DeviceDetailsWindow : Window
 
     private async void RemoteTargetInterfaceComboBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
     {
-        if (RemoteTargetInterfaceComboBox.SelectedItem is not RemoteInterfaceInfo selected)
+        try
         {
-            UpdateButtonStates();
-            return;
-        }
+            if (RemoteTargetInterfaceComboBox.SelectedItem is not RemoteInterfaceInfo selected)
+            {
+                UpdateButtonStates();
+                return;
+            }
 
-        await LoadRemoteInterfaceConfigAsync(selected.SystemName, useBusyState: true);
+            await LoadRemoteInterfaceConfigAsync(selected.SystemName, useBusyState: true);
+        }
+        catch (Exception ex)
+        {
+            ShowStatusMessage($"读取目标接口配置失败:{ex.Message}");
+            SetBusyState(false);
+        }
     }
 
     private async Task LoadRemoteInterfaceConfigAsync(string interfaceName, bool useBusyState = false)
@@ -116,14 +133,15 @@ public partial class DeviceDetailsWindow : Window
 
             var config = result.Data;
             RemoteConfigInterfaceTextBlock.Text = config.Interface;
-            RemoteConfigIpTextBlock.Text = string.IsNullOrWhiteSpace(config.IP) ? "无" : $"{config.IP}/{config.Prefix}";
+            RemoteConfigIpTextBlock.Text = FormatCurrentIp(config);
             RemoteConfigGatewayTextBlock.Text = string.IsNullOrWhiteSpace(config.Gateway) ? "无" : config.Gateway;
             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;
+            NewDnsTextBox.Text = config.Dns?.FirstOrDefault() ?? string.Empty;
             _suppressConfigChangeHandling = false;
             _configValidated = false;
             ShowStatusMessage("已读取Linux端IP配置。");
@@ -197,7 +215,8 @@ public partial class DeviceDetailsWindow : Window
         }
 
         var confirmMessage = $"将要把以下配置应用到接口 {selected.SystemName}:\n\n" +
-                             $"IP:{request.IP}/{request.Prefix}\n" +
+                             $"模式:{(request.Dhcp4 ? "DHCP 自动获取" : "静态 IPv4")}\n" +
+                             $"IP:{(request.Dhcp4 ? "自动获取" : $"{request.IP}/{request.Prefix}")}\n" +
                              $"网关:{(string.IsNullOrWhiteSpace(request.Gateway) ? "无" : request.Gateway)}\n" +
                              $"DNS:{(request.Dns.Count == 0 ? "无" : string.Join(", ", request.Dns))}\n\n" +
                              "请确认是否继续。";
@@ -216,7 +235,7 @@ public partial class DeviceDetailsWindow : Window
                 return;
             }
 
-            ShowStatusMessage($"配置任务已提交:{applyResult.Data.TaskId},正在轮询状态。");
+            ShowStatusMessage("配置任务已提交,正在应用并等待连通确认...");
             await PollTaskAsync(applyResult.Data.TaskId);
         }
         finally
@@ -228,6 +247,7 @@ public partial class DeviceDetailsWindow : Window
     private async Task PollTaskAsync(string taskId)
     {
         var transientFailureCount = 0;
+        var confirmationRequested = false;
         for (var i = 0; i < 20; i++)
         {
             await Task.Delay(1000);
@@ -237,7 +257,7 @@ public partial class DeviceDetailsWindow : Window
                 if (result.StatusCode is null)
                 {
                     transientFailureCount++;
-                    ShowStatusMessage($"任务 {taskId} 轮询中,检测到短暂断连,正在重试({transientFailureCount})。");
+                    ShowStatusMessage($"设备连接短暂中断,正在重试({transientFailureCount})。");
                     continue;
                 }
 
@@ -247,9 +267,26 @@ public partial class DeviceDetailsWindow : Window
 
             transientFailureCount = 0;
             var task = result.Data;
-            ShowStatusMessage($"任务 {task.TaskId} / {task.Status} / {task.Step} / {task.Detail}");
+            ShowStatusMessage(FormatTaskStatusMessage(task));
+            if (task.Status == "running" && task.Step == "confirming" && !confirmationRequested)
+            {
+                confirmationRequested = true;
+                var confirm = ShowApplyConfirmationDialog(ApplyConfirmationTimeoutSeconds);
+                if (confirm)
+                {
+                    var confirmResult = await _agentApiService.ConfirmApplyTaskAsync(_baseAddress, _password, _localIPv4, taskId);
+                    ShowStatusMessage(confirmResult.Success ? "已发送保留配置确认。" : $"发送确认失败:{confirmResult.Message}");
+                }
+                else
+                {
+                    var cancelResult = await _agentApiService.CancelApplyTaskAsync(_baseAddress, _password, _localIPv4, taskId);
+                    ShowStatusMessage(cancelResult.Success ? "已取消保留配置,正在回滚。" : $"发送取消失败:{cancelResult.Message}");
+                }
+            }
+
             if (task.Status is "success" or "failed" or "rolled_back")
             {
+                ShowTaskCompletionDialog(task);
                 if (RemoteTargetInterfaceComboBox.SelectedItem is RemoteInterfaceInfo selected)
                 {
                     await LoadRemoteInterfaceConfigAsync(selected.SystemName);
@@ -262,6 +299,93 @@ public partial class DeviceDetailsWindow : Window
         ShowStatusMessage($"任务 {taskId} 轮询超时,请稍后手动刷新。");
     }
 
+    private bool ShowApplyConfirmationDialog(int timeoutSeconds)
+    {
+        var remaining = timeoutSeconds;
+        var result = false;
+        var messageTextBlock = new TextBlock
+        {
+            Width = 420,
+            TextWrapping = TextWrapping.Wrap,
+            FontSize = 13,
+            Foreground = Brushes.Black,
+        };
+        var confirmButton = new Button
+        {
+            MinWidth = 88,
+            MinHeight = 32,
+            Margin = new Thickness(0, 0, 10, 0),
+            Content = "确认保留",
+            IsDefault = true,
+        };
+        var cancelButton = new Button
+        {
+            MinWidth = 88,
+            MinHeight = 32,
+            Content = "取消回滚",
+            IsCancel = true,
+        };
+        var dialog = new Window
+        {
+            Title = "确认保留网络配置",
+            Owner = this,
+            WindowStartupLocation = WindowStartupLocation.CenterOwner,
+            ResizeMode = ResizeMode.NoResize,
+            SizeToContent = SizeToContent.WidthAndHeight,
+            Content = new StackPanel
+            {
+                Margin = new Thickness(18),
+                Children =
+                {
+                    messageTextBlock,
+                    new StackPanel
+                    {
+                        Margin = new Thickness(0, 18, 0, 0),
+                        HorizontalAlignment = HorizontalAlignment.Right,
+                        Orientation = Orientation.Horizontal,
+                        Children = { confirmButton, cancelButton },
+                    },
+                },
+            },
+        };
+
+        void UpdateMessage()
+        {
+            messageTextBlock.Text = $"当前客户端仍可连接到设备。是否确认保留这次网络配置?\n\n剩余 {remaining} 秒;超时或取消时,Linux 端会自动回滚。";
+        }
+
+        var timer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
+        timer.Tick += (_, _) =>
+        {
+            remaining--;
+            if (remaining <= 0)
+            {
+                timer.Stop();
+                dialog.DialogResult = false;
+                dialog.Close();
+                return;
+            }
+            UpdateMessage();
+        };
+        confirmButton.Click += (_, _) =>
+        {
+            result = true;
+            dialog.DialogResult = true;
+            dialog.Close();
+        };
+        cancelButton.Click += (_, _) =>
+        {
+            dialog.DialogResult = false;
+            dialog.Close();
+        };
+        dialog.Closed += (_, _) => timer.Stop();
+
+        UpdateMessage();
+        timer.Start();
+        dialog.ShowDialog();
+        return result;
+    }
+
     private async void RebootButton_OnClick(object sender, RoutedEventArgs e)
     {
         await ExecuteSystemActionAsync(
@@ -297,13 +421,15 @@ public partial class DeviceDetailsWindow : Window
 
     private RemoteInterfaceConfig? BuildConfigRequest(string interfaceName)
     {
-        if (string.IsNullOrWhiteSpace(NewIpTextBox.Text))
+        var dhcp4 = Dhcp4CheckBox.IsChecked == true;
+        var prefix = 0;
+        if (!dhcp4 && string.IsNullOrWhiteSpace(NewIpTextBox.Text))
         {
             ShowStatusMessage("IP 地址不能为空。");
             return null;
         }
 
-        if (!TryMaskToPrefix(NewMaskTextBox.Text, out var prefix))
+        if (!dhcp4 && !TryMaskToPrefix(NewMaskTextBox.Text, out prefix))
         {
             ShowStatusMessage("子网掩码格式不正确。");
             return null;
@@ -313,13 +439,25 @@ public partial class DeviceDetailsWindow : Window
         return new RemoteInterfaceConfig
         {
             Interface = interfaceName,
-            IP = NewIpTextBox.Text.Trim(),
+            Dhcp4 = dhcp4,
+            IP = dhcp4 ? string.Empty : NewIpTextBox.Text.Trim(),
             Prefix = prefix,
-            Gateway = NewGatewayTextBox.Text.Trim(),
-            Dns = dns,
+            Gateway = dhcp4 ? string.Empty : NewGatewayTextBox.Text.Trim(),
+            Dns = dhcp4 ? Array.Empty<string>() : dns,
         };
     }
 
+    private static string FormatCurrentIp(RemoteInterfaceConfig config)
+    {
+        if (string.IsNullOrWhiteSpace(config.IP))
+        {
+            return config.Dhcp4 ? "DHCP 自动获取,暂无 IPv4" : "无";
+        }
+
+        var text = $"{config.IP}/{config.Prefix}";
+        return config.Dhcp4 ? $"{text} (DHCP)" : text;
+    }
+
     private static string PrefixToMask(int prefix)
     {
         if (prefix < 0 || prefix > 32)
@@ -390,6 +528,19 @@ public partial class DeviceDetailsWindow : Window
         UpdateButtonStates();
     }
 
+    private void ConfigModeChanged_OnChanged(object sender, RoutedEventArgs e)
+    {
+        if (_suppressConfigChangeHandling)
+        {
+            UpdateButtonStates();
+            return;
+        }
+
+        _configValidated = false;
+        ShowStatusMessage("配置模式已变更,请重新点击“2. 校验配置”。");
+        UpdateButtonStates();
+    }
+
     private void ShowStatusMessage(string message)
     {
         ApplyStatusMessageStyle(message);
@@ -457,6 +608,33 @@ public partial class DeviceDetailsWindow : Window
         return markers.Any(marker => message.Contains(marker, StringComparison.Ordinal));
     }
 
+    private static string FormatTaskStatusMessage(RemoteTaskResult task)
+    {
+        return task.Status switch
+        {
+            "success" => string.IsNullOrWhiteSpace(task.Detail) ? "配置已成功应用。" : task.Detail,
+            "failed" => string.IsNullOrWhiteSpace(task.Detail) ? "配置应用失败。" : task.Detail,
+            "rolled_back" => string.IsNullOrWhiteSpace(task.Detail) ? "配置应用失败,已自动回滚。" : task.Detail,
+            _ => task.Step switch
+            {
+                "validating" => "正在校验配置...",
+                "writing_netplan" => "正在写入 Linux 网络配置...",
+                "applying" => "正在应用 Linux 网络配置...",
+                "confirming" => string.IsNullOrWhiteSpace(task.Detail) ? "等待确认保留配置..." : task.Detail,
+                "rolling_back" => "配置应用失败,正在自动回滚...",
+                _ => string.IsNullOrWhiteSpace(task.Detail) ? "正在处理,请稍候..." : task.Detail,
+            }
+        };
+    }
+
+    private void ShowTaskCompletionDialog(RemoteTaskResult task)
+    {
+        var message = FormatTaskStatusMessage(task);
+        var title = task.Status == "success" ? "应用配置成功" : "应用配置失败";
+        var image = task.Status == "success" ? MessageBoxImage.Information : MessageBoxImage.Warning;
+        MessageBox.Show(this, message, title, MessageBoxButton.OK, image);
+    }
+
     private void UpdateButtonStates()
     {
         var hasSelectedInterface = RemoteTargetInterfaceComboBox.SelectedItem is RemoteInterfaceInfo;
@@ -466,10 +644,11 @@ public partial class DeviceDetailsWindow : Window
         ReloadInterfaceConfigButton.IsEnabled = canEdit;
         ValidateConfigButton.IsEnabled = canEdit;
         ApplyConfigButton.IsEnabled = !_isBusy && _configValidated && hasSelectedInterface;
-        NewIpTextBox.IsEnabled = canEdit;
-        NewMaskTextBox.IsEnabled = canEdit;
-        NewGatewayTextBox.IsEnabled = canEdit;
-        NewDnsTextBox.IsEnabled = canEdit;
+        Dhcp4CheckBox.IsEnabled = canEdit;
+        NewIpTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
+        NewMaskTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
+        NewGatewayTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
+        NewDnsTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
         RebootButton.IsEnabled = !_isBusy;
         ShutdownButton.IsEnabled = !_isBusy;
     }

+ 2 - 1
windows/QuickIP.Client/Models/RemoteInterfaceConfig.cs

@@ -3,10 +3,11 @@ namespace QuickIP.Client.Models;
 public sealed class RemoteInterfaceConfig
 {
     public string Interface { get; init; } = string.Empty;
+    public bool Dhcp4 { get; init; }
     public string IP { get; init; } = string.Empty;
     public int Prefix { get; init; }
     public string Gateway { get; init; } = string.Empty;
     public IReadOnlyList<string> Dns { get; init; } = [];
 
-    public string DnsSummary => Dns.Count == 0 ? "无" : string.Join(", ", Dns);
+    public string DnsSummary => Dns is null || Dns.Count == 0 ? "无" : string.Join(", ", Dns);
 }

+ 54 - 0
windows/QuickIP.Client/Services/AgentApiService.cs

@@ -222,6 +222,60 @@ public sealed class AgentApiService
         }
     }
 
+    public async Task<ApiCallResult<RemoteApplyTaskResponse>> ConfirmApplyTaskAsync(string baseAddress, string password, string localIPv4, string taskId, CancellationToken cancellationToken = default)
+    {
+        try
+        {
+            using var client = CreateClient(baseAddress, password, localIPv4);
+            using var response = await client.PostAsJsonAsync("/api/network/apply/confirm", new { task_id = taskId }, _jsonOptions, cancellationToken);
+            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+            var wrapper = await JsonSerializer.DeserializeAsync<ApiEnvelope<RemoteApplyTaskResponse>>(stream, _jsonOptions, cancellationToken);
+
+            return new ApiCallResult<RemoteApplyTaskResponse>
+            {
+                Success = response.IsSuccessStatusCode,
+                StatusCode = (int)response.StatusCode,
+                Message = wrapper?.Message ?? (response.IsSuccessStatusCode ? "已确认保留配置" : $"确认失败,HTTP 状态码 {(int)response.StatusCode}。"),
+                Data = wrapper?.Data,
+            };
+        }
+        catch (Exception ex)
+        {
+            return new ApiCallResult<RemoteApplyTaskResponse>
+            {
+                Success = false,
+                Message = ex.Message,
+            };
+        }
+    }
+
+    public async Task<ApiCallResult<RemoteApplyTaskResponse>> CancelApplyTaskAsync(string baseAddress, string password, string localIPv4, string taskId, CancellationToken cancellationToken = default)
+    {
+        try
+        {
+            using var client = CreateClient(baseAddress, password, localIPv4);
+            using var response = await client.PostAsJsonAsync("/api/network/apply/cancel", new { task_id = taskId }, _jsonOptions, cancellationToken);
+            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+            var wrapper = await JsonSerializer.DeserializeAsync<ApiEnvelope<RemoteApplyTaskResponse>>(stream, _jsonOptions, cancellationToken);
+
+            return new ApiCallResult<RemoteApplyTaskResponse>
+            {
+                Success = response.IsSuccessStatusCode,
+                StatusCode = (int)response.StatusCode,
+                Message = wrapper?.Message ?? (response.IsSuccessStatusCode ? "已取消保留配置" : $"取消失败,HTTP 状态码 {(int)response.StatusCode}。"),
+                Data = wrapper?.Data,
+            };
+        }
+        catch (Exception ex)
+        {
+            return new ApiCallResult<RemoteApplyTaskResponse>
+            {
+                Success = false,
+                Message = ex.Message,
+            };
+        }
+    }
+
     public Task<ApiCallResult<RemoteSystemActionResponse>> RebootAsync(string baseAddress, string password, string localIPv4, CancellationToken cancellationToken = default)
     {
         return PostSystemActionAsync(baseAddress, password, localIPv4, "/api/system/reboot", cancellationToken);