ソースを参照

初始化仓库

yangkaixiang 1 ヶ月 前
コミット
52eacdd435

+ 32 - 0
.gitignore

@@ -0,0 +1,32 @@
+# OS
+.DS_Store
+Thumbs.db
+Desktop.ini
+
+# Editors
+.idea/
+.vscode/
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# Logs
+*.log
+
+# Go
+agent/quickip-agent
+agent/quickip-agent.exe
+agent/quickip-agent-linux-amd64
+agent/bin/
+agent/dist/
+agent/coverage.out
+
+# .NET / WPF
+windows/**/bin/
+windows/**/obj/
+windows/.vs/
+
+# Temp
+tmp/
+temp/

+ 58 - 0
agent/cmd/quickip-agent/main.go

@@ -0,0 +1,58 @@
+package main
+
+import (
+	"context"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"quickip/internal/config"
+	"quickip/internal/deviceinfo"
+	"quickip/internal/discovery"
+	"quickip/internal/httpserver"
+	"quickip/internal/logger"
+	applyexecsvc "quickip/internal/network/applyexec"
+	configreadersvc "quickip/internal/network/configreader"
+	interfacesvc "quickip/internal/network/interfaces"
+	netplansvc "quickip/internal/network/netplan"
+	validatorsvc "quickip/internal/network/validator"
+	verifysvc "quickip/internal/network/verify"
+	"quickip/internal/tasks"
+)
+
+func main() {
+	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer stop()
+
+	log := logger.New()
+	cfg := config.Load(os.Args[1:])
+	deviceSvc := deviceinfo.New(cfg)
+	interfaceSvc := interfacesvc.New(cfg)
+	configSvc := configreadersvc.New()
+	validatorSvc := validatorsvc.New()
+	netplanSvc := netplansvc.New()
+	applySvc := applyexecsvc.New()
+	verifySvc := verifysvc.New()
+	taskSvc := tasks.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, verifySvc, taskSvc)
+	udpSrv := discovery.New(cfg, log, deviceSvc)
+
+	errCh := make(chan error, 2)
+	go func() { errCh <- httpSrv.Run(ctx) }()
+	go func() { errCh <- udpSrv.Run(ctx) }()
+
+	select {
+	case <-ctx.Done():
+		log.Info("agent shutting down")
+	case err := <-errCh:
+		if err != nil {
+			log.Error("agent stopped with error", "error", err.Error())
+		}
+	}
+}

+ 5 - 0
agent/go.mod

@@ -0,0 +1,5 @@
+module quickip
+
+go 1.22.0
+
+require gopkg.in/yaml.v3 v3.0.1

+ 4 - 0
agent/go.sum

@@ -0,0 +1,4 @@
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 50 - 0
agent/internal/auth/auth.go

@@ -0,0 +1,50 @@
+package auth
+
+import (
+	"encoding/json"
+	"net"
+	"net/http"
+	"strings"
+
+	"quickip/internal/config"
+	"quickip/internal/model"
+)
+
+func Middleware(cfg config.Config, next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if !allowedSource(r.RemoteAddr) {
+			writeJSON(w, http.StatusForbidden, model.APIResponse{Code: 1003, Message: "来源 IP 不允许", Data: nil})
+			return
+		}
+
+		password := r.Header.Get("X-Admin-Password")
+		if password == "" {
+			writeJSON(w, http.StatusUnauthorized, model.APIResponse{Code: 1001, Message: "缺少密码", Data: nil})
+			return
+		}
+		if password != cfg.AdminPassword {
+			writeJSON(w, http.StatusUnauthorized, model.APIResponse{Code: 1002, Message: "密码错误", Data: nil})
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+func allowedSource(remoteAddr string) bool {
+	host, _, err := net.SplitHostPort(remoteAddr)
+	if err != nil {
+		host = remoteAddr
+	}
+	ip := net.ParseIP(strings.TrimSpace(host))
+	if ip == nil {
+		return false
+	}
+	_, subnet, _ := net.ParseCIDR("169.254.0.0/16")
+	return subnet.Contains(ip)
+}
+
+func writeJSON(w http.ResponseWriter, status int, payload model.APIResponse) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(payload)
+}

+ 48 - 0
agent/internal/config/config.go

@@ -0,0 +1,48 @@
+package config
+
+import (
+	"flag"
+	"fmt"
+	"net"
+)
+
+type Config struct {
+	HTTPHost         string
+	HTTPPort         int
+	UDPHost          string
+	UDPPort          int
+	MaintenanceIP    string
+	MaintenanceCIDR  string
+	AdminPassword    string
+	AgentVersion     string
+	DeviceIDFallback string
+}
+
+func Load(args []string) Config {
+	cfg := Config{
+		HTTPHost:         "169.254.100.2",
+		HTTPPort:         48888,
+		UDPHost:          "0.0.0.0",
+		UDPPort:          50000,
+		MaintenanceIP:    "169.254.100.2",
+		MaintenanceCIDR:  "169.254.100.2/16",
+		AdminPassword:    "Dt123$",
+		AgentVersion:     "0.1.0",
+		DeviceIDFallback: "quickip-device",
+	}
+
+	fs := flag.NewFlagSet("quickip-agent", flag.ContinueOnError)
+	fs.StringVar(&cfg.MaintenanceIP, "ip", cfg.MaintenanceIP, "maintenance IPv4 address")
+	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))
+	}
+
+	cfg.HTTPHost = cfg.MaintenanceIP
+	cfg.MaintenanceCIDR = fmt.Sprintf("%s/16", cfg.MaintenanceIP)
+
+	return cfg
+}

+ 62 - 0
agent/internal/deviceinfo/deviceinfo.go

@@ -0,0 +1,62 @@
+package deviceinfo
+
+import (
+	"os"
+	"runtime"
+	"strconv"
+	"strings"
+	"time"
+
+	"quickip/internal/config"
+	"quickip/internal/model"
+)
+
+type Service struct {
+	cfg       config.Config
+	startedAt time.Time
+}
+
+func New(cfg config.Config) *Service {
+	return &Service{cfg: cfg, startedAt: time.Now()}
+}
+
+func (s *Service) Get() model.DeviceInfo {
+	hostname, _ := os.Hostname()
+	if hostname == "" {
+		hostname = s.cfg.DeviceIDFallback
+	}
+
+	return model.DeviceInfo{
+		DeviceID:      readMachineID(s.cfg.DeviceIDFallback),
+		Hostname:      hostname,
+		OSVersion:     readOSVersion(),
+		AgentVersion:  s.cfg.AgentVersion,
+		UptimeSeconds: int64(time.Since(s.startedAt).Seconds()),
+	}
+}
+
+func readMachineID(fallback string) string {
+	for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
+		data, err := os.ReadFile(path)
+		if err == nil {
+			value := strings.TrimSpace(string(data))
+			if value != "" {
+				return value
+			}
+		}
+	}
+	return fallback
+}
+
+func readOSVersion() string {
+	data, err := os.ReadFile("/etc/os-release")
+	if err != nil {
+		return runtime.GOOS
+	}
+	for _, line := range strings.Split(string(data), "\n") {
+		if strings.HasPrefix(line, "PRETTY_NAME=") {
+			return strings.Trim(strconv.Quote(strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), `"`)), `"`)
+		}
+	}
+	return runtime.GOOS
+}

+ 71 - 0
agent/internal/discovery/discovery.go

@@ -0,0 +1,71 @@
+package discovery
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net"
+
+	"quickip/internal/config"
+	"quickip/internal/deviceinfo"
+	"quickip/internal/logger"
+	"quickip/internal/model"
+)
+
+type Server struct {
+	cfg       config.Config
+	log       *logger.Logger
+	deviceSvc *deviceinfo.Service
+}
+
+func New(cfg config.Config, log *logger.Logger, deviceSvc *deviceinfo.Service) *Server {
+	return &Server{cfg: cfg, log: log, deviceSvc: deviceSvc}
+}
+
+func (s *Server) Run(ctx context.Context) error {
+	addr, err := net.ResolveUDPAddr("udp4", fmt.Sprintf("%s:%d", s.cfg.UDPHost, s.cfg.UDPPort))
+	if err != nil {
+		return err
+	}
+	conn, err := net.ListenUDP("udp4", addr)
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+
+	go func() {
+		<-ctx.Done()
+		_ = conn.Close()
+	}()
+
+	s.log.Info("udp discovery listening", "addr", conn.LocalAddr().String())
+	buf := make([]byte, 2048)
+	for {
+		n, remote, err := conn.ReadFromUDP(buf)
+		if err != nil {
+			if ctx.Err() != nil {
+				return nil
+			}
+			return err
+		}
+
+		var req model.DiscoverRequest
+		if err := json.Unmarshal(buf[:n], &req); err != nil || req.MessageType != "discover" {
+			continue
+		}
+
+		device := s.deviceSvc.Get()
+		resp := model.DiscoverResponse{
+			ProtocolVersion: 1,
+			MessageType:     "discover_response",
+			RequestID:       req.RequestID,
+			DeviceID:        device.DeviceID,
+			Hostname:        device.Hostname,
+			AgentVersion:    device.AgentVersion,
+			LAN2IP:          s.cfg.MaintenanceIP,
+			AuthRequired:    true,
+		}
+		payload, _ := json.Marshal(resp)
+		_, _ = conn.WriteToUDP(payload, remote)
+	}
+}

+ 305 - 0
agent/internal/httpserver/server.go

@@ -0,0 +1,305 @@
+package httpserver
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"quickip/internal/auth"
+	"quickip/internal/config"
+	"quickip/internal/deviceinfo"
+	"quickip/internal/logger"
+	"quickip/internal/model"
+	applyexecsvc "quickip/internal/network/applyexec"
+	configreadersvc "quickip/internal/network/configreader"
+	interfacesvc "quickip/internal/network/interfaces"
+	netplansvc "quickip/internal/network/netplan"
+	validatorsvc "quickip/internal/network/validator"
+	verifysvc "quickip/internal/network/verify"
+	"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
+}
+
+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) *Server {
+	return &Server{cfg: cfg, log: log, deviceSvc: deviceSvc, interfaceSvc: interfaceSvc, configSvc: configSvc, validatorSvc: validatorSvc, netplanSvc: netplanSvc, applySvc: applySvc, verifySvc: verifySvc, taskSvc: taskSvc}
+}
+
+func (s *Server) Run(ctx context.Context) error {
+	mux := http.NewServeMux()
+	mux.Handle("/api/health", auth.Middleware(s.cfg, http.HandlerFunc(s.handleHealth)))
+	mux.Handle("/api/device/info", auth.Middleware(s.cfg, http.HandlerFunc(s.handleDeviceInfo)))
+	mux.Handle("/api/network/interfaces", auth.Middleware(s.cfg, http.HandlerFunc(s.handleInterfaces)))
+	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/rollback", auth.Middleware(s.cfg, http.HandlerFunc(s.handleRollback)))
+	mux.Handle("/api/tasks/", auth.Middleware(s.cfg, http.HandlerFunc(s.handleTaskGet)))
+	handler := s.withAccessLog(mux)
+
+	server := &http.Server{
+		Addr:              fmt.Sprintf("%s:%d", s.cfg.HTTPHost, s.cfg.HTTPPort),
+		Handler:           handler,
+		ReadHeaderTimeout: 5 * time.Second,
+	}
+
+	go func() {
+		<-ctx.Done()
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		_ = server.Shutdown(shutdownCtx)
+	}()
+
+	s.log.Info("http server listening", "addr", server.Addr)
+	err := server.ListenAndServe()
+	if err == http.ErrServerClosed {
+		return nil
+	}
+	return err
+}
+
+func (s *Server) withAccessLog(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		started := time.Now()
+		rw := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
+		next.ServeHTTP(rw, r)
+		s.log.Info(
+			"http request completed",
+			"method", r.Method,
+			"path", r.URL.Path,
+			"query", r.URL.RawQuery,
+			"remote", r.RemoteAddr,
+			"status", rw.statusCode,
+			"duration_ms", time.Since(started).Milliseconds(),
+		)
+	})
+}
+
+type statusRecorder struct {
+	http.ResponseWriter
+	statusCode int
+}
+
+func (r *statusRecorder) WriteHeader(statusCode int) {
+	r.statusCode = statusCode
+	r.ResponseWriter.WriteHeader(statusCode)
+}
+
+func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "成功", Data: map[string]any{"status": "运行中", "agent_version": s.cfg.AgentVersion}})
+}
+
+func (s *Server) handleDeviceInfo(w http.ResponseWriter, _ *http.Request) {
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "成功", Data: s.deviceSvc.Get()})
+}
+
+func (s *Server) handleInterfaces(w http.ResponseWriter, _ *http.Request) {
+	data, err := s.interfaceSvc.List()
+	if err != nil {
+		writeJSON(w, http.StatusInternalServerError, model.APIResponse{Code: 4001, Message: "系统执行失败", Data: map[string]string{"error": err.Error()}})
+		return
+	}
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "成功", Data: data})
+}
+
+func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
+	interfaceName := r.URL.Query().Get("interface")
+	if interfaceName == "" {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"缺少 interface 参数。"}}})
+		return
+	}
+	if !s.interfaceExists(interfaceName) {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"目标接口不存在。"}}})
+		return
+	}
+	data, err := s.configSvc.Read(interfaceName)
+	if err != nil {
+		writeJSON(w, http.StatusInternalServerError, model.APIResponse{Code: 4001, Message: "系统执行失败", Data: map[string]string{"error": err.Error()}})
+		return
+	}
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "成功", Data: data})
+}
+
+func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		writeJSON(w, http.StatusMethodNotAllowed, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
+		return
+	}
+	body, err := io.ReadAll(r.Body)
+	if err != nil {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"请求体读取失败。"}}})
+		return
+	}
+	var input model.InterfaceConfig
+	if err := json.Unmarshal(body, &input); err != nil {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"请求体格式不正确。"}}})
+		return
+	}
+	if input.Interface != "" && !s.interfaceExists(input.Interface) {
+		result := model.ValidateResponse{Valid: false, Errors: []string{"目标接口不存在。"}, Warnings: []string{}}
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 3001, Message: "配置校验失败", Data: result})
+		return
+	}
+	result := s.validatorSvc.Validate(input)
+	if !result.Valid {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 3001, Message: "配置校验失败", Data: result})
+		return
+	}
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "校验通过", Data: result})
+}
+
+func (s *Server) handleApply(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.InterfaceConfig
+	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 !s.interfaceExists(input.Interface) {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 3001, Message: "配置校验失败", Data: model.ValidateResponse{Valid: false, Errors: []string{"目标接口不存在。"}}})
+		return
+	}
+	result := s.validatorSvc.Validate(input)
+	if !result.Valid {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 3001, Message: "配置校验失败", Data: result})
+		return
+	}
+	management := s.currentManagementInterface()
+	if management == "" {
+		writeJSON(w, http.StatusInternalServerError, model.APIResponse{Code: 4001, Message: "系统执行失败", Data: map[string][]string{"errors": []string{"未能识别管理接口。"}}})
+		return
+	}
+	task := s.taskSvc.Create()
+	go s.runApplyTask(task.TaskID, input, management)
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "配置任务已提交", Data: map[string]any{"interface": input.Interface, "task_id": task.TaskID}})
+}
+
+func (s *Server) handleRollback(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.RollbackRequest
+	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": {"请求体格式不正确。"}}})
+		return
+	}
+	filePath, err := s.netplanSvc.FindSingleFile()
+	if err != nil {
+		writeJSON(w, http.StatusInternalServerError, model.APIResponse{Code: 4001, Message: "系统执行失败", Data: map[string]string{"error": err.Error()}})
+		return
+	}
+	backupPath := filePath + ".quickip.bak"
+	if err := s.netplanSvc.Restore(filePath, backupPath); err != nil {
+		writeJSON(w, http.StatusInternalServerError, model.APIResponse{Code: 3004, Message: "回滚失败", Data: map[string]string{"error": err.Error()}})
+		return
+	}
+	if err := s.applySvc.Apply(); err != nil {
+		writeJSON(w, http.StatusInternalServerError, model.APIResponse{Code: 3004, Message: "回滚失败", Data: map[string]string{"error": err.Error()}})
+		return
+	}
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "回滚成功", Data: map[string]any{"interface": input.Interface, "rolled_back": true}})
+}
+
+func (s *Server) handleTaskGet(w http.ResponseWriter, r *http.Request) {
+	taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
+	if taskID == "" {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"缺少 task_id。"}}})
+		return
+	}
+	item, ok := s.taskSvc.Get(taskID)
+	if !ok {
+		writeJSON(w, http.StatusNotFound, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
+		return
+	}
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "成功", Data: item})
+}
+
+func (s *Server) runApplyTask(taskID string, input model.InterfaceConfig, managementInterface string) {
+	s.taskSvc.Update(taskID, "running", "validating", "正在校验配置。", false)
+	result := s.validatorSvc.Validate(input)
+	if !result.Valid {
+		s.taskSvc.Update(taskID, "failed", "validating", "配置校验失败。", false)
+		return
+	}
+
+	filePath, err := s.netplanSvc.FindSingleFile()
+	if err != nil {
+		s.taskSvc.Update(taskID, "failed", "writing_netplan", err.Error(), false)
+		return
+	}
+
+	s.taskSvc.Update(taskID, "running", "writing_netplan", "正在写入 netplan 配置。", false)
+	backupPath, err := s.netplanSvc.Backup(filePath)
+	if err != nil {
+		s.taskSvc.Update(taskID, "failed", "writing_netplan", err.Error(), false)
+		return
+	}
+	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
+	}
+
+	s.taskSvc.Update(taskID, "running", "applying", "正在应用 netplan 配置。", false)
+	if err := s.applySvc.Apply(); err != nil {
+		_ = s.netplanSvc.Restore(filePath, backupPath)
+		_ = s.applySvc.Apply()
+		s.taskSvc.Update(taskID, "rolled_back", "rolling_back", "配置失败,已自动回滚。", 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)
+		return
+	}
+
+	s.taskSvc.Update(taskID, "success", "completed", "目标接口配置已成功应用。", false)
+}
+
+func (s *Server) interfaceExists(name string) bool {
+	data, err := s.interfaceSvc.List()
+	if err != nil {
+		return false
+	}
+	for _, item := range data.Interfaces {
+		if item.SystemName == name {
+			return true
+		}
+	}
+	return false
+}
+
+func (s *Server) currentManagementInterface() string {
+	data, err := s.interfaceSvc.List()
+	if err != nil {
+		return ""
+	}
+	return data.ManagementInterface
+}
+
+func writeJSON(w http.ResponseWriter, status int, payload model.APIResponse) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(payload)
+}

+ 14 - 0
agent/internal/logger/logger.go

@@ -0,0 +1,14 @@
+package logger
+
+import (
+	"log/slog"
+	"os"
+)
+
+type Logger struct {
+	*slog.Logger
+}
+
+func New() *Logger {
+	return &Logger{Logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))}
+}

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

@@ -0,0 +1,85 @@
+package model
+
+type APIResponse struct {
+	Code    int         `json:"code"`
+	Message string      `json:"message"`
+	Data    interface{} `json:"data"`
+}
+
+type DeviceInfo struct {
+	DeviceID      string `json:"device_id"`
+	Hostname      string `json:"hostname"`
+	OSVersion     string `json:"os_version"`
+	AgentVersion  string `json:"agent_version"`
+	UptimeSeconds int64  `json:"uptime_seconds"`
+}
+
+type IPv4Address struct {
+	Address string `json:"address"`
+	Prefix  int    `json:"prefix"`
+	Source  string `json:"source"`
+}
+
+type NetworkInterface struct {
+	Name              string        `json:"name"`
+	SystemName        string        `json:"system_name"`
+	Role              string        `json:"role"`
+	LinkUp            bool          `json:"link_up"`
+	IsManagement      bool          `json:"is_management_interface"`
+	IsSuggestedTarget bool          `json:"is_suggested_target"`
+	MAC               string        `json:"mac"`
+	IPv4              []IPv4Address `json:"ipv4"`
+	Gateway           string        `json:"gateway"`
+	DNS               []string      `json:"dns"`
+}
+
+type InterfacesResponse struct {
+	ManagementInterface      string             `json:"management_interface"`
+	SuggestedTargetInterface string             `json:"suggested_target_interface"`
+	RequiresTargetSelection  bool               `json:"requires_target_selection"`
+	Interfaces               []NetworkInterface `json:"interfaces"`
+}
+
+type DiscoverRequest struct {
+	ProtocolVersion int    `json:"protocol_version"`
+	MessageType     string `json:"message_type"`
+	RequestID       string `json:"request_id"`
+	ClientName      string `json:"client_name"`
+}
+
+type DiscoverResponse struct {
+	ProtocolVersion int    `json:"protocol_version"`
+	MessageType     string `json:"message_type"`
+	RequestID       string `json:"request_id"`
+	DeviceID        string `json:"device_id"`
+	Hostname        string `json:"hostname"`
+	AgentVersion    string `json:"agent_version"`
+	LAN2IP          string `json:"lan2_ip"`
+	AuthRequired    bool   `json:"auth_required"`
+}
+
+type InterfaceConfig struct {
+	Interface string   `json:"interface"`
+	IP        string   `json:"ip"`
+	Prefix    int      `json:"prefix"`
+	Gateway   string   `json:"gateway"`
+	DNS       []string `json:"dns"`
+}
+
+type ValidateResponse struct {
+	Valid    bool     `json:"valid"`
+	Warnings []string `json:"warnings,omitempty"`
+	Errors   []string `json:"errors,omitempty"`
+}
+
+type RollbackRequest struct {
+	Interface string `json:"interface"`
+}
+
+type TaskResult struct {
+	TaskID   string `json:"task_id"`
+	Status   string `json:"status"`
+	Step     string `json:"step"`
+	Detail   string `json:"detail"`
+	Rollback bool   `json:"rollback"`
+}

+ 20 - 0
agent/internal/network/applyexec/applyexec.go

@@ -0,0 +1,20 @@
+package applyexec
+
+import (
+	"fmt"
+	"os/exec"
+	"strings"
+)
+
+type Service struct{}
+
+func New() *Service { return &Service{} }
+
+func (s *Service) Apply() error {
+	cmd := exec.Command("netplan", "apply")
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("netplan apply failed: %s", strings.TrimSpace(string(output)))
+	}
+	return nil
+}

+ 91 - 0
agent/internal/network/configreader/configreader.go

@@ -0,0 +1,91 @@
+package configreader
+
+import (
+	"bufio"
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"strings"
+
+	"quickip/internal/model"
+)
+
+type Service struct{}
+
+func New() *Service { return &Service{} }
+
+func (s *Service) Read(interfaceName string) (model.InterfaceConfig, error) {
+	iface, err := net.InterfaceByName(interfaceName)
+	if err != nil {
+		return model.InterfaceConfig{}, err
+	}
+
+	config := model.InterfaceConfig{Interface: interfaceName, DNS: []string{}}
+	addrs, err := iface.Addrs()
+	if err != nil {
+		return model.InterfaceConfig{}, err
+	}
+	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
+	}
+
+	config.Gateway = readGateway(interfaceName)
+	config.DNS = readDNS()
+	return config, 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 MustRead(interfaceName string) model.InterfaceConfig {
+	config, err := New().Read(interfaceName)
+	if err != nil {
+		panic(fmt.Sprintf("read interface config failed: %v", err))
+	}
+	return config
+}

+ 201 - 0
agent/internal/network/interfaces/interfaces.go

@@ -0,0 +1,201 @@
+package interfaces
+
+import (
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"quickip/internal/config"
+	"quickip/internal/model"
+)
+
+type Service struct {
+	cfg config.Config
+}
+
+func New(cfg config.Config) *Service {
+	return &Service{cfg: cfg}
+}
+
+func (s *Service) List() (model.InterfacesResponse, error) {
+	items, err := s.listPhysicalInterfaces()
+	if err != nil {
+		return model.InterfacesResponse{}, err
+	}
+
+	management := s.detectManagement(items)
+	for i := range items {
+		items[i].IsManagement = items[i].SystemName == management
+		if items[i].IsManagement {
+			items[i].Name = "LAN2"
+			items[i].Role = "control"
+		} else {
+			items[i].Name = "候选接口"
+			items[i].Role = "business"
+		}
+	}
+
+	suggested, requiresSelection := suggestTarget(items, management)
+	for i := range items {
+		items[i].IsSuggestedTarget = items[i].SystemName == suggested
+		if items[i].IsSuggestedTarget {
+			items[i].Name = "LAN1"
+		}
+	}
+
+	return model.InterfacesResponse{
+		ManagementInterface:      management,
+		SuggestedTargetInterface: suggested,
+		RequiresTargetSelection:  requiresSelection,
+		Interfaces:               items,
+	}, 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 {
+		return nil, err
+	}
+
+	items := make([]model.NetworkInterface, 0)
+	for _, iface := range ifaces {
+		if !isPhysicalEthernet(iface.Name) {
+			continue
+		}
+
+		item := model.NetworkInterface{
+			SystemName: iface.Name,
+			MAC:        iface.HardwareAddr.String(),
+			LinkUp:     readLinkUp(iface.Name),
+			Gateway:    "",
+			DNS:        []string{},
+		}
+		item.IPv4 = readIPv4(iface)
+		items = append(items, item)
+	}
+
+	sort.Slice(items, func(i, j int) bool { return items[i].SystemName < items[j].SystemName })
+	return items, nil
+}
+
+func (s *Service) detectManagement(items []model.NetworkInterface) string {
+	for _, item := range items {
+		for _, addr := range item.IPv4 {
+			if addr.Address == s.cfg.MaintenanceIP {
+				return item.SystemName
+			}
+		}
+	}
+	if len(items) == 2 {
+		return items[1].SystemName
+	}
+	if len(items) > 0 {
+		return items[len(items)-1].SystemName
+	}
+	return ""
+}
+
+func suggestTarget(items []model.NetworkInterface, management string) (string, bool) {
+	candidates := make([]string, 0)
+	for _, item := range items {
+		if item.SystemName != management {
+			candidates = append(candidates, item.SystemName)
+		}
+	}
+	if len(candidates) == 1 {
+		return candidates[0], false
+	}
+	return "", len(candidates) > 1
+}
+
+func readIPv4(iface net.Interface) []model.IPv4Address {
+	addrs, err := iface.Addrs()
+	if err != nil {
+		return nil
+	}
+	result := make([]model.IPv4Address, 0)
+	for _, addr := range addrs {
+		ipNet, ok := addr.(*net.IPNet)
+		if !ok || ipNet.IP.To4() == nil {
+			continue
+		}
+		prefix, _ := ipNet.Mask.Size()
+		source := "unknown"
+		if ipNet.IP.String() == "169.254.100.2" {
+			source = "static"
+		}
+		result = append(result, model.IPv4Address{Address: ipNet.IP.String(), Prefix: prefix, Source: source})
+	}
+	return result
+}
+
+func isPhysicalEthernet(name string) bool {
+	if name == "lo" {
+		return false
+	}
+	for _, prefix := range []string{"docker", "br-", "veth", "virbr", "tun", "tap", "wg", "zt", "vmnet"} {
+		if strings.HasPrefix(name, prefix) {
+			return false
+		}
+	}
+	devicePath := filepath.Join("/sys/class/net", name, "device")
+	if _, err := os.Stat(devicePath); err != nil {
+		return false
+	}
+	return true
+}
+
+func readLinkUp(name string) bool {
+	carrierPath := filepath.Join("/sys/class/net", name, "carrier")
+	data, err := os.ReadFile(carrierPath)
+	if err == nil {
+		return strings.TrimSpace(string(data)) == "1"
+	}
+	statePath := filepath.Join("/sys/class/net", name, "operstate")
+	data, err = os.ReadFile(statePath)
+	if err != nil {
+		return false
+	}
+	return strings.TrimSpace(string(data)) == "up"
+}

+ 158 - 0
agent/internal/network/netplan/netplan.go

@@ -0,0 +1,158 @@
+package netplan
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"quickip/internal/model"
+
+	"gopkg.in/yaml.v3"
+)
+
+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"`
+}
+
+func New() *Service { return &Service{} }
+
+func (s *Service) FindSingleFile() (string, error) {
+	files, err := filepath.Glob("/etc/netplan/*.yaml")
+	if err != nil {
+		return "", err
+	}
+	if len(files) == 0 {
+		return "", fmt.Errorf("未找到 netplan 配置文件")
+	}
+	if len(files) > 1 {
+		return "", fmt.Errorf("检测到多个 netplan 配置文件,首版暂不支持自动处理")
+	}
+	return files[0], nil
+}
+
+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
+	}
+	if err := os.WriteFile(backupPath, data, 0600); err != nil {
+		return "", err
+	}
+	return backupPath, nil
+}
+
+func (s *Service) Restore(path string, backupPath string) error {
+	data, err := os.ReadFile(backupPath)
+	if err != nil {
+		return err
+	}
+	if err := os.WriteFile(path, data, 0600); err != nil {
+		return err
+	}
+	_ = os.Remove(backupPath)
+	return nil
+}
+
+func (s *Service) Write(path string, targetInterface string, input model.InterfaceConfig, managementInterface string, maintenanceCIDR string) error {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return err
+	}
+
+	var cfg fileConfig
+	if err := yaml.Unmarshal(data, &cfg); err != nil {
+		return err
+	}
+	if cfg.Network.Version == 0 {
+		cfg.Network.Version = 2
+	}
+	if cfg.Network.Ethernets == nil {
+		cfg.Network.Ethernets = make(map[string]*ethernetEntry)
+	}
+
+	target := cfg.Network.Ethernets[targetInterface]
+	if target == nil {
+		target = &ethernetEntry{}
+		cfg.Network.Ethernets[targetInterface] = target
+	}
+	dhcpFalse := false
+	target.DHCP4 = &dhcpFalse
+	target.Addresses = []string{fmt.Sprintf("%s/%d", input.IP, input.Prefix)}
+	if input.Gateway != "" {
+		target.Gateway4 = input.Gateway
+	} else {
+		target.Gateway4 = ""
+	}
+	if len(input.DNS) > 0 {
+		target.Nameservers = &nameserverConfig{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)
+	}
+
+	output, err := yaml.Marshal(&cfg)
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, output, 0600)
+}
+
+func contains(items []string, target string) bool {
+	for _, item := range items {
+		if item == target {
+			return true
+		}
+	}
+	return false
+}
+
+func uniqueNonEmpty(items []string) []string {
+	seen := make(map[string]struct{})
+	result := make([]string, 0, len(items))
+	for _, item := range items {
+		if item == "" {
+			continue
+		}
+		if _, ok := seen[item]; ok {
+			continue
+		}
+		seen[item] = struct{}{}
+		result = append(result, item)
+	}
+	return result
+}

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

@@ -0,0 +1,56 @@
+package validator
+
+import (
+	"fmt"
+	"net"
+
+	"quickip/internal/model"
+)
+
+type Service struct{}
+
+func New() *Service { return &Service{} }
+
+func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
+	resp := model.ValidateResponse{Valid: false, Warnings: []string{}, Errors: []string{}}
+
+	if input.Interface == "" {
+		resp.Errors = append(resp.Errors, "目标接口不能为空。")
+	}
+	ip := net.ParseIP(input.IP)
+	if ip == nil || ip.To4() == nil {
+		resp.Errors = append(resp.Errors, "IP 地址格式不正确。")
+	}
+	if input.Prefix < 0 || input.Prefix > 32 {
+		resp.Errors = append(resp.Errors, "前缀长度不正确。")
+	}
+	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 不在同一子网。")
+			}
+		}
+	}
+	for _, dns := range input.DNS {
+		if dns == "" {
+			continue
+		}
+		parsed := net.ParseIP(dns)
+		if parsed == nil || parsed.To4() == nil {
+			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
+}

+ 34 - 0
agent/internal/network/verify/verify.go

@@ -0,0 +1,34 @@
+package verify
+
+import (
+	"fmt"
+	"net"
+
+	"quickip/internal/model"
+)
+
+type Service struct{}
+
+func New() *Service { return &Service{} }
+
+func (s *Service) Verify(input model.InterfaceConfig) error {
+	iface, err := net.InterfaceByName(input.Interface)
+	if err != nil {
+		return err
+	}
+	addrs, err := iface.Addrs()
+	if err != nil {
+		return err
+	}
+	for _, addr := range addrs {
+		ipNet, ok := addr.(*net.IPNet)
+		if !ok || ipNet.IP.To4() == nil {
+			continue
+		}
+		prefix, _ := ipNet.Mask.Size()
+		if ipNet.IP.String() == input.IP && prefix == input.Prefix {
+			return nil
+		}
+	}
+	return fmt.Errorf("目标接口地址未按预期生效")
+}

+ 50 - 0
agent/internal/tasks/tasks.go

@@ -0,0 +1,50 @@
+package tasks
+
+import (
+	"fmt"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"quickip/internal/model"
+)
+
+type Service struct {
+	counter uint64
+	mu      sync.RWMutex
+	items   map[string]model.TaskResult
+}
+
+func New() *Service {
+	return &Service{items: make(map[string]model.TaskResult)}
+}
+
+func (s *Service) Create() model.TaskResult {
+	id := fmt.Sprintf("task-%d-%04d", time.Now().Unix(), atomic.AddUint64(&s.counter, 1))
+	item := model.TaskResult{TaskID: id, Status: "pending", Step: "validating", Detail: "任务已创建", Rollback: false}
+	s.mu.Lock()
+	s.items[id] = item
+	s.mu.Unlock()
+	return item
+}
+
+func (s *Service) Update(taskID string, status string, step string, detail string, rollback bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	item, ok := s.items[taskID]
+	if !ok {
+		return
+	}
+	item.Status = status
+	item.Step = step
+	item.Detail = detail
+	item.Rollback = rollback
+	s.items[taskID] = item
+}
+
+func (s *Service) Get(taskID string) (model.TaskResult, bool) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	item, ok := s.items[taskID]
+	return item, ok
+}

+ 144 - 0
docs/01-总体方案.md

@@ -0,0 +1,144 @@
+# 总体方案
+
+## 1. 目标
+
+开发一个 Windows 桌面客户端,用于通过 `LAN2` 直连 Ubuntu 24 设备,对 Linux 的网络进行发现、管理和配置。
+
+## 2. 当前约束
+
+1. Linux 发行版:`Ubuntu 24`
+2. 网络管理方式:`netplan`
+3. 通信协议:`UDP + HTTP`
+4. 鉴权方式:固定初始化密码
+5. `LAN1`、`LAN2` 仅作为逻辑标识,便于界面展示和硬件对应
+6. 实际配置与操作对象为 Linux 本机真实接口名,例如 `eno1`、`enp1s0`
+7. `LAN2` 长期保留固定维护地址,同时支持接 4G 路由器联网
+8. 首版以新机器初始化为主,不重点处理复杂历史网络配置兼容
+
+## 3. 总体架构
+
+### 3.1 网口角色
+
+#### LAN1
+
+1. 角色:业务口逻辑标识
+2. 用途:接客户业务网络
+3. 地址模式:仅静态 IP
+4. 网关按用户填写内容正常写入
+
+#### LAN2
+
+1. 角色:控制口 + 外联网口逻辑标识
+2. 用途:
+   - Windows 直连维护
+   - 接 4G 路由器联网
+3. 固定维护地址:`169.254.100.2/16`
+4. 可叠加 DHCP 地址
+5. 可通过 DHCP 获取网关等网络参数
+
+### 3.2 组件划分
+
+#### Windows 客户端
+
+建议技术:`C# + WPF`
+
+负责:
+
+1. 选择本机网卡
+2. 将本机网卡切换到 `169.254/16`
+3. 通过 UDP 发现设备
+4. 调用 HTTP API
+5. 展示设备状态
+6. 配置选中的目标网络接口
+7. 执行关机、重启等操作
+
+#### Linux Agent
+
+建议技术:`Go + systemd service`
+
+负责:
+
+1. 监听当前被识别为 `LAN2` 的真实接口上的固定维护地址
+2. 响应 UDP 发现请求
+3. 提供 HTTP 管理接口
+4. 读取当前网络状态
+5. 写入和应用 `netplan`
+6. 验证配置结果
+7. 失败后自动回滚
+8. 执行关机、重启
+
+首版边界:
+
+1. 以新机器初始化为主
+2. 核心目标是把选中的真实接口配置好
+3. 默认修改系统现有 netplan 文件,并在修改前完整备份
+4. 不重点处理复杂旧 netplan 文件接管和多文件冲突兼容
+
+## 4. 地址与路由策略
+
+### 4.1 LAN2 地址策略
+
+`LAN2` 同时允许存在两个 IPv4 地址:
+
+1. 固定维护地址:`169.254.100.2/16`
+2. DHCP 地址:由 4G 路由器分配,例如 `192.168.8.x/24`
+
+规则:
+
+1. `169.254.100.2` 不配置网关
+2. DHCP 地址可带默认网关
+3. DHCP 地址可带网关等网络参数
+4. Agent 只通过 `169.254.100.2` 提供服务
+
+### 4.2 LAN1 地址策略
+
+`LAN1` 仅支持静态配置。
+
+可配置字段:
+
+1. IP:必填
+2. 前缀长度:必填
+3. 网关:选填,填写后正常写入
+4. 首选 DNS:选填
+5. 备用 DNS:选填
+
+### 4.3 LAN1 特殊规则
+
+1. `LAN1` 允许配置 `169.254.x.x`
+2. 当 `LAN1` 配置为 `169.254.x.x` 时:
+   - 应提示这是链路本地地址
+   - 建议网关留空
+   - 建议 DNS 留空
+   - 一般只适合同链路通信
+3. `LAN1` 的 IP、网关、DNS 按用户填写内容正常写入,具体路由优先级由系统实际路由表决定
+
+## 5. 安全边界
+
+### 5.1 网络访问限制
+
+1. Agent 仅监听 `169.254.100.2`
+2. 仅允许来源 IP 为 `169.254.0.0/16` 的请求访问 HTTP 接口
+3. `LAN2` 接入 4G 路由器后,4G 网络侧不能访问 Agent
+
+### 5.2 鉴权方式
+
+当前采用固定初始化密码,暂时直接写死在 Agent 代码中。
+
+规则:
+
+1. 不使用 Token
+2. 不使用 Session
+3. 不单独设计登录接口
+4. 所有 HTTP 接口都必须校验密码
+5. 密码通过 HTTP Header 传递
+
+建议 Header:
+
+`X-Admin-Password: <固定密码>`
+
+注意事项:
+
+1. 不要通过 URL 传递密码
+2. 不要在日志中记录密码 Header
+3. 当前阶段密码写死在代码中,后续再升级为配置文件或每机唯一密码
+4. 可增加简单的密码错误次数限制

+ 42 - 0
docs/02-网口识别规则.md

@@ -0,0 +1,42 @@
+# 网口识别规则
+
+## 1. 识别目标
+
+系统需要识别两类逻辑角色:
+
+1. `LAN2`:当前管理连接所在接口的逻辑标识
+2. `LAN1`:业务口的逻辑标识
+
+## 2. 基本规则
+
+1. Agent 当前管理请求进入的物理有线接口,识别为逻辑上的 `LAN2`
+2. Agent 仅统计物理有线接口
+3. 需排除以下接口:
+   - `lo`
+   - docker 相关虚拟接口
+   - bridge 接口
+   - `veth`
+   - `tun`/`tap`
+   - 其他虚拟网卡
+
+## 3. 自动推断规则
+
+1. 如果系统中只有 2 个物理有线接口:
+   - 当前管理连接所在接口对应逻辑标识 `LAN2`
+   - 另一个接口对应逻辑标识 `LAN1`
+2. 如果系统中超过 2 个物理有线接口:
+   - 自动识别 `LAN2`
+   - 其余接口作为业务口候选列表返回给客户端
+   - 由客户端提示用户选择本次操作目标接口
+3. `LAN1`、`LAN2` 仅用于显示和辅助识别,不写入 Linux 本地配置
+
+## 4. 客户端展示建议
+
+当需要用户选择业务口时,建议展示以下信息:
+
+1. 产品侧逻辑标识或候选标识
+2. Linux 实际接口名
+3. MAC 地址
+4. 当前链路状态
+5. 当前 IPv4 地址列表
+6. 是否为当前管理接口

+ 467 - 0
docs/03-通信与HTTP_API.md

@@ -0,0 +1,467 @@
+# 通信与 HTTP API
+
+## 1. 发现与控制协议
+
+### 1.1 UDP 发现
+
+用途:
+
+1. Windows 客户端在指定网卡上广播发现设备
+2. Linux Agent 响应自身信息
+
+建议端口:`50000`
+
+#### 发现请求
+
+```json
+{
+  "protocol_version": 1,
+  "message_type": "discover",
+  "request_id": "6f7d2f6a-1111-2222-3333-444455556666",
+  "client_name": "WIN-PC-01"
+}
+```
+
+#### 发现响应
+
+```json
+{
+  "protocol_version": 1,
+  "message_type": "discover_response",
+  "request_id": "6f7d2f6a-1111-2222-3333-444455556666",
+  "device_id": "SN202605070001",
+  "hostname": "ubuntu-server-01",
+  "agent_version": "1.0.0",
+  "lan2_ip": "169.254.100.2",
+  "auth_required": true
+}
+```
+
+### 1.2 HTTP 控制
+
+Base URL:
+
+`http://169.254.100.2:48888`
+
+启动参数:
+
+1. `--ip`:指定维护地址,默认 `169.254.100.2`
+2. `--port`:指定 HTTP 端口,默认 `48888`
+3. `--password`:指定管理密码,默认 `Dt123$`
+
+请求头:
+
+```http
+Content-Type: application/json
+X-Admin-Password: 固定初始化密码
+```
+
+## 2. 通用约定
+
+### 2.1 通用响应格式
+
+```json
+{
+  "code": 0,
+  "message": "成功",
+  "data": {}
+}
+```
+
+### 2.2 通用错误码
+
+1. `0`:成功
+2. `1001`:缺少密码
+3. `1002`:密码错误
+4. `1003`:来源 IP 不允许
+5. `2001`:参数错误
+6. `2002`:资源不存在
+7. `3001`:配置校验失败
+8. `3002`:配置应用失败
+9. `3003`:配置失败,已回滚
+10. `3004`:回滚失败
+11. `4001`:系统执行失败
+
+## 3. HTTP API
+
+### 3.1 健康检查
+
+`GET /api/health`
+
+响应:
+
+```json
+{
+  "code": 0,
+  "message": "成功",
+  "data": {
+    "status": "运行中",
+    "agent_version": "1.0.0"
+  }
+}
+```
+
+### 3.2 获取设备信息
+
+`GET /api/device/info`
+
+响应:
+
+```json
+{
+  "code": 0,
+  "message": "成功",
+  "data": {
+    "device_id": "SN202605070001",
+    "hostname": "ubuntu-server-01",
+    "os_version": "Ubuntu 24.04",
+    "agent_version": "1.0.0",
+    "uptime_seconds": 86400
+  }
+}
+```
+
+### 3.3 获取网络接口列表
+
+`GET /api/network/interfaces`
+
+响应:
+
+```json
+{
+  "code": 0,
+  "message": "成功",
+  "data": {
+    "management_interface": "enp2s0",
+    "suggested_target_interface": "enp1s0",
+    "requires_target_selection": false,
+    "interfaces": [
+      {
+        "name": "LAN1",
+        "system_name": "enp1s0",
+        "role": "business",
+        "link_up": true,
+        "is_management_interface": false,
+        "is_suggested_target": true,
+        "mac": "00:11:22:33:44:55",
+        "ipv4": [
+          {
+            "address": "192.168.10.20",
+            "prefix": 24,
+            "source": "static"
+          }
+        ],
+        "gateway": "",
+        "dns": []
+      },
+      {
+        "name": "LAN2",
+        "system_name": "enp2s0",
+        "role": "control",
+        "link_up": true,
+        "is_management_interface": true,
+        "is_suggested_target": false,
+        "mac": "00:11:22:33:44:66",
+        "ipv4": [
+          {
+            "address": "169.254.100.2",
+            "prefix": 16,
+            "source": "static"
+          },
+          {
+            "address": "192.168.8.23",
+            "prefix": 24,
+            "source": "dhcp"
+          }
+        ],
+        "gateway": "192.168.8.1",
+        "dns": ["192.168.8.1"]
+      }
+    ]
+  }
+}
+```
+
+说明:
+
+1. `management_interface` 为当前管理连接所在真实接口名
+2. `suggested_target_interface` 为当前建议操作的真实接口名
+3. `requires_target_selection` 为 `true` 时,客户端应要求用户选择目标接口
+4. `name` 为逻辑展示标识,实际配置应使用 `system_name`
+
+### 3.4 获取指定接口当前配置
+
+`GET /api/network/config?interface=enp1s0`
+
+响应:
+
+```json
+{
+  "code": 0,
+  "message": "成功",
+  "data": {
+    "interface": "enp1s0",
+    "ip": "192.168.10.20",
+    "prefix": 24,
+    "gateway": "",
+    "dns": []
+  }
+}
+```
+
+### 3.5 校验指定接口配置
+
+`POST /api/network/validate`
+
+请求:
+
+```json
+{
+  "interface": "enp1s0",
+  "ip": "192.168.10.20",
+  "prefix": 24,
+  "gateway": "",
+  "dns": []
+}
+```
+
+正常响应:
+
+```json
+{
+  "code": 0,
+  "message": "校验通过",
+  "data": {
+    "valid": true,
+    "warnings": []
+  }
+}
+```
+
+带警告响应:
+
+```json
+{
+  "code": 0,
+  "message": "校验通过",
+  "data": {
+    "valid": true,
+    "warnings": [
+      "目标接口使用的是链路本地地址,通常仅适合同链路通信。"
+    ]
+  }
+}
+```
+
+失败响应:
+
+```json
+{
+  "code": 3001,
+  "message": "配置校验失败",
+  "data": {
+    "valid": false,
+    "errors": [
+      "网关与目标接口 IP 不在同一子网。"
+    ]
+  }
+}
+```
+
+校验规则:
+
+1. `ip` 必填
+2. `prefix` 必填
+3. `gateway` 选填
+4. `dns` 选填
+5. `interface` 必填,必须是有效的真实接口名
+6. 若填写 `gateway`,必须与 `ip` 在同一子网
+7. 若 `ip` 为 `169.254.x.x`,返回中文警告,不直接报错
+
+### 3.6 应用指定接口配置
+
+`POST /api/network/apply`
+
+请求:
+
+```json
+{
+  "interface": "enp1s0",
+  "ip": "192.168.10.20",
+  "prefix": 24,
+  "gateway": "",
+  "dns": []
+}
+```
+
+响应:
+
+```json
+{
+  "code": 0,
+  "message": "配置任务已提交",
+  "data": {
+    "interface": "enp1s0",
+    "task_id": "task-20260507-0001"
+  }
+}
+```
+
+执行流程:
+
+1. 校验参数
+2. 备份当前稳定配置
+3. 写入新的 `netplan`
+4. 执行 `netplan apply`
+5. 验证配置是否生效
+6. 成功则保存
+7. 失败则回滚
+
+### 3.7 手动回滚指定接口配置
+
+`POST /api/network/rollback`
+
+请求:
+
+```json
+{
+  "interface": "enp1s0"
+}
+```
+
+响应:
+
+```json
+{
+  "code": 0,
+  "message": "回滚成功",
+  "data": {
+    "interface": "enp1s0",
+    "rolled_back": true
+  }
+}
+```
+
+### 3.8 查询任务状态
+
+`GET /api/tasks/{task_id}`
+
+进行中响应:
+
+```json
+{
+  "code": 0,
+  "message": "任务执行中",
+  "data": {
+    "task_id": "task-20260507-0001",
+    "status": "running",
+    "step": "applying",
+    "detail": "正在应用 netplan 配置。",
+    "rollback": false
+  }
+}
+```
+
+成功响应:
+
+```json
+{
+  "code": 0,
+  "message": "配置成功",
+  "data": {
+    "task_id": "task-20260507-0001",
+    "status": "success",
+    "step": "completed",
+    "detail": "目标接口配置已成功应用。",
+    "rollback": false
+  }
+}
+```
+
+失败并回滚响应:
+
+```json
+{
+  "code": 3003,
+  "message": "配置失败,已自动回滚",
+  "data": {
+    "task_id": "task-20260507-0001",
+    "status": "rolled_back",
+    "step": "rolling_back",
+    "detail": "网关不可达,已恢复到上一次稳定配置。",
+    "rollback": true
+  }
+}
+```
+
+状态建议:
+
+1. `pending`
+2. `running`
+3. `success`
+4. `failed`
+5. `rolled_back`
+
+步骤建议:
+
+1. `validating`
+2. `writing_netplan`
+3. `applying`
+4. `verifying`
+5. `rolling_back`
+6. `completed`
+
+### 3.9 重启系统
+
+`POST /api/system/reboot`
+
+请求:
+
+```json
+{}
+```
+
+响应:
+
+```json
+{
+  "code": 0,
+  "message": "重启命令已接受",
+  "data": {
+    "accepted": true
+  }
+}
+```
+
+### 3.10 关机系统
+
+`POST /api/system/shutdown`
+
+请求:
+
+```json
+{}
+```
+
+响应:
+
+```json
+{
+  "code": 0,
+  "message": "关机命令已接受",
+  "data": {
+    "accepted": true
+  }
+}
+```
+
+## 4. Agent 请求处理顺序
+
+每个 HTTP 请求统一按以下顺序处理:
+
+1. 检查请求是否发送到 `169.254.100.2`
+2. 检查客户端源 IP 是否属于 `169.254.0.0/16`
+3. 读取 `X-Admin-Password`
+4. 校验密码是否正确
+5. 通过后进入业务逻辑
+6. 返回统一 JSON 响应

+ 541 - 0
docs/04-客户端流程与MVP.md

@@ -0,0 +1,541 @@
+# 客户端流程与 MVP
+
+## 1. 目标接口配置与回滚策略
+
+### 1.1 配置流程
+
+1. 客户端调用 `validate`
+2. 校验通过后调用 `apply`
+3. Agent 备份当前稳定配置
+4. 写入新的 `netplan`
+5. 执行 `netplan apply`
+6. 验证目标接口地址是否生效
+7. 可选验证网关连通性
+8. 成功则标记为稳定配置
+9. 失败则自动回滚
+
+### 1.2 回滚原则
+
+1. 仅回滚目标接口的业务配置
+2. `LAN2` 控制口固定维护地址不受影响
+3. 即使目标接口配错,仍可通过 `LAN2` 恢复
+
+## 2. Windows 客户端流程
+
+### 2.0 页面结构建议
+
+首版建议采用 4 个主界面:
+
+1. 连接页
+2. 设备页
+3. 配置页
+4. 结果页
+
+### 2.0.1 连接页
+
+页面目标:
+
+1. 选择 Windows 本机有线网卡
+2. 切换到维护网络
+3. 输入或自动带出密码
+4. 发现设备并建立连接
+
+页面元素建议:
+
+1. 本机网卡下拉框
+2. 网卡当前状态:
+   - 网卡名称
+   - 链路状态
+   - 当前 IPv4
+3. 密码输入框
+4. 记住密码复选框
+5. 按钮:`切换到维护网络`
+6. 按钮:`发现并连接`
+7. 发现结果区域
+
+交互规则:
+
+1. 若本地已保存密码,页面打开时自动填充
+2. 用户可修改密码后重新保存
+3. 发现成功后自动进入设备页
+
+#### 连接页详细交互
+
+建议按以下顺序执行:
+
+1. 用户选择本机有线网卡
+2. 客户端立即读取并展示该网卡状态:
+   - 网卡名称
+   - 真实系统名称
+   - 是否已插网线
+   - 当前 IPv4
+3. 客户端自动从注册表读取已保存密码并填充到密码框
+4. 用户可手动修改密码
+5. 用户点击 `切换到维护网络`
+6. 客户端记录该网卡原始配置
+7. 客户端将该网卡设置为 `169.254.100.1/16`
+8. 切换成功后,允许点击 `发现并连接`
+9. 用户点击 `发现并连接`
+10. 客户端通过 UDP 广播发现设备
+11. 发现设备后,客户端使用当前密码调用 HTTP 接口进行连接验证
+12. 若连接成功,进入设备页
+
+#### 连接页状态提示建议
+
+建议至少覆盖以下状态提示:
+
+1. `请选择一块有线网卡。`
+2. `当前网卡未检测到链路,请检查网线连接。`
+3. `正在切换到维护网络,请稍候。`
+4. `已切换到维护网络。`
+5. `正在发现设备,请稍候。`
+6. `已发现设备,正在验证连接。`
+7. `连接成功。`
+8. `未发现设备,请确认 Windows 已连接到设备的管理口。`
+9. `密码错误,请检查后重试。`
+10. `连接失败,请稍后重试。`
+
+#### 连接页按钮状态建议
+
+1. 未选择网卡时:
+   - `切换到维护网络` 禁用
+   - `发现并连接` 禁用
+2. 已选择网卡但未切换维护网络时:
+   - `切换到维护网络` 可用
+   - `发现并连接` 禁用
+3. 已成功切换维护网络后:
+   - `切换到维护网络` 可重复执行
+   - `发现并连接` 可用
+4. 正在执行切换或发现时:
+   - 两个按钮都禁用
+
+#### 注册表存储建议
+
+当前阶段建议使用当前用户范围注册表:
+
+1. 路径:`HKEY_CURRENT_USER\Software\QuickIP`
+2. 值名:`SavedPassword`
+3. 仅在用户勾选 `记住密码` 且连接成功后写入
+4. 用户点击 `清除已保存密码` 时删除该值
+
+### 2.0.2 设备页
+
+页面目标:
+
+1. 展示设备基本信息
+2. 展示当前管理接口
+3. 展示目标接口候选列表
+4. 确定本次要配置的真实接口名
+
+页面内容建议:
+
+1. 设备信息:
+   - 设备 ID
+   - 主机名
+   - Ubuntu 版本
+   - Agent 版本
+2. 管理接口信息:
+   - 逻辑标识 `LAN2`
+   - 真实接口名
+   - 当前地址列表
+3. 目标接口列表:
+   - 逻辑标识
+   - 真实接口名
+   - MAC 地址
+   - 链路状态
+   - 当前 IPv4
+
+交互规则:
+
+1. 若 `requires_target_selection=false`,默认选中建议目标接口
+2. 若 `requires_target_selection=true`,用户必须先选择目标接口才能继续
+
+#### 设备页详细交互
+
+页面建议分为两个区域:
+
+1. 设备信息区
+2. 接口识别区
+
+#### 设备信息区建议字段
+
+1. 设备 ID
+2. 主机名
+3. Ubuntu 版本
+4. Agent 版本
+
+#### 接口识别区建议字段
+
+接口列表建议使用表格展示,列如下:
+
+1. 逻辑标识
+2. 真实接口名
+3. MAC 地址
+4. 链路状态
+5. IPv4 地址
+6. 当前角色说明
+
+其中:
+
+1. 当前管理接口显示为 `LAN2`
+2. 当前建议目标接口显示为 `LAN1`
+3. 如果超过两个物理有线接口,其余接口显示为候选接口
+
+#### 设备页选择规则
+
+1. 若接口总数为 2:
+   - 自动将当前管理接口标记为 `LAN2`
+   - 自动将另一个接口标记为 `LAN1`
+   - 默认选中该接口作为本次配置目标
+2. 若接口总数超过 2:
+   - 自动标记当前管理接口为 `LAN2`
+   - 其余接口都列为候选目标接口
+   - 用户必须显式选择一个真实接口名作为本次配置目标
+
+#### 设备页操作按钮建议
+
+1. `刷新接口状态`
+2. `下一步`
+3. `返回连接页`
+
+#### 设备页按钮状态建议
+
+1. 未确定目标接口时:
+   - `下一步` 禁用
+2. 已确定目标接口时:
+   - `下一步` 可用
+3. 正在刷新接口状态时:
+   - `刷新接口状态` 禁用
+   - `下一步` 禁用
+
+#### 设备页状态提示建议
+
+建议至少覆盖以下提示:
+
+1. `正在读取设备信息,请稍候。`
+2. `设备信息读取成功。`
+3. `正在读取接口状态,请稍候。`
+4. `已自动识别当前管理接口。`
+5. `已自动识别建议目标接口。`
+6. `检测到多个候选接口,请选择本次要配置的目标接口。`
+7. `请选择一个目标接口后继续。`
+
+#### 设备页跳转规则
+
+1. 进入页面时自动调用:
+   - `GET /api/device/info`
+   - `GET /api/network/interfaces`
+2. 若已有建议目标接口,客户端自动读取该接口当前配置
+3. 点击 `下一步` 后进入配置页,并携带当前选中的真实接口名
+
+### 2.0.3 配置页
+
+页面目标:
+
+1. 编辑目标接口网络参数
+2. 先校验,再应用
+3. 明确提示当前操作对象的真实接口名
+
+页面表单建议:
+
+1. 目标接口:只读显示当前选中的真实接口名
+2. IP
+3. 子网掩码
+4. 网关
+5. 首选 DNS
+6. 备用 DNS
+
+页面按钮建议:
+
+1. `读取当前配置`
+2. `校验配置`
+3. `应用配置`
+4. `返回上一步`
+
+交互规则:
+
+1. 配置页中始终显示 `当前操作对象:<真实接口名>`
+2. `校验配置` 通过后才允许执行 `应用配置`
+3. 若目标 IP 为 `169.254.x.x`,即时显示提示,不阻止提交
+
+#### 配置页详细交互
+
+页面建议分为三个区域:
+
+1. 当前目标接口信息区
+2. 网络参数编辑区
+3. 操作按钮区
+
+#### 当前目标接口信息区
+
+建议展示:
+
+1. 逻辑标识
+2. 真实接口名
+3. 当前链路状态
+4. 当前 IPv4 地址
+5. 当前网关
+6. 当前 DNS
+
+建议固定提示:
+
+`当前操作对象:<真实接口名>`
+
+#### 网络参数编辑区
+
+建议字段:
+
+1. IP 地址:文本框
+2. 子网掩码:优先使用下拉框,也允许手动输入
+3. 网关:文本框,选填
+4. 首选 DNS:文本框,选填
+5. 备用 DNS:文本框,选填
+
+#### 输入建议
+
+1. IP、网关、DNS 使用普通文本框
+2. 子网掩码建议提供常用选项:
+   - `255.255.255.0`
+   - `255.255.0.0`
+   - `255.0.0.0`
+   - `255.255.255.128`
+   - `255.255.255.192`
+   - `255.255.255.252`
+3. 子网掩码允许用户手工输入其他合法值
+
+#### 校验策略建议
+
+采用“两段校验”方式:
+
+1. 本地即时校验
+2. 服务端点击校验
+
+本地即时校验只处理基础格式问题:
+
+1. IP 是否为合法 IPv4
+2. 网关格式是否正确
+3. DNS 格式是否正确
+4. 子网掩码是否为合法掩码
+
+服务端点击校验处理业务规则:
+
+1. 接口是否有效
+2. 网关是否与目标 IP 同子网
+3. 是否存在链路本地地址警告
+4. 是否可正常写入目标接口配置
+
+#### 配置页操作按钮建议
+
+1. `读取当前配置`
+2. `校验配置`
+3. `应用配置`
+4. `返回上一步`
+
+#### 配置页按钮状态建议
+
+1. 页面初始加载完成后:
+   - `读取当前配置` 可用
+   - `校验配置` 可用
+   - `应用配置` 禁用
+2. 服务端校验通过后:
+   - `应用配置` 可用
+3. 表单被再次修改后:
+   - `应用配置` 重新禁用
+   - 需要重新执行 `校验配置`
+4. 正在读取、校验或应用过程中:
+   - 所有按钮禁用
+
+#### 配置页提示建议
+
+建议至少覆盖以下提示:
+
+1. `请输入目标接口的 IP 地址。`
+2. `子网掩码格式不正确。`
+3. `网关格式不正确。`
+4. `DNS 格式不正确。`
+5. `正在校验配置,请稍候。`
+6. `校验通过。`
+7. `配置校验失败,请检查输入内容。`
+8. `目标接口使用的是链路本地地址,通常仅适合同链路通信。`
+9. `正在提交配置,请稍候。`
+
+#### 应用前确认建议
+
+点击 `应用配置` 前建议弹出确认框。
+
+确认框内容建议包含:
+
+1. 目标接口真实接口名
+2. IP 地址
+3. 子网掩码
+4. 网关
+5. DNS
+
+确认文案建议:
+
+`请确认是否将以上配置应用到目标接口。`
+
+### 2.0.4 结果页
+
+页面目标:
+
+1. 展示配置结果
+2. 说明是否已回滚
+3. 提供后续操作入口
+
+状态建议:
+
+1. 配置成功
+2. 配置失败,已回滚
+3. 配置失败,回滚失败
+
+页面按钮建议:
+
+1. `返回配置页`
+2. `重新读取接口状态`
+3. `重启设备`
+4. `关机设备`
+
+#### 结果页详细交互
+
+结果页应清晰区分以下三种结果:
+
+1. 配置成功
+2. 配置失败,已自动回滚
+3. 配置失败,回滚失败
+
+#### 结果页建议展示字段
+
+1. 目标接口真实接口名
+2. 任务状态
+3. 当前步骤
+4. 结果说明
+5. 是否已回滚
+
+#### 结果页提示建议
+
+建议至少覆盖以下提示:
+
+1. `目标接口配置已成功应用。`
+2. `配置失败,已自动回滚到上一次稳定配置。`
+3. `配置失败,且回滚失败,请立即通过管理口检查设备状态。`
+4. `正在读取最新接口状态,请稍候。`
+
+#### 结果页后续操作建议
+
+1. 配置成功后:
+   - 自动刷新一次接口状态
+   - 允许用户返回配置页继续调整
+2. 配置失败且已回滚:
+   - 默认引导用户返回配置页重新修改
+3. 配置失败且回滚失败:
+   - 重点提示用户继续通过 `LAN2` 管理口处理
+
+### 2.1 设备发现
+
+1. 用户选择本机有线网卡
+2. 客户端检查链路状态
+3. 客户端将本机网卡切换为 `169.254.100.1/16`
+4. 用户输入密码或使用已记住的密码
+5. 客户端发送 UDP 广播
+6. 发现 `169.254.100.2`
+
+### 2.2 读取状态
+
+1. 调用 `GET /api/health`
+2. 调用 `GET /api/device/info`
+3. 调用 `GET /api/network/interfaces`
+4. 调用 `GET /api/network/config?interface=<真实接口名>`
+
+### 2.3 配置目标接口
+
+1. 用户填写:
+   - 目标接口
+   - IP
+   - 子网掩码
+   - 网关
+   - 首选 DNS
+   - 备用 DNS
+2. 客户端将子网掩码转换为前缀长度
+3. 先调用 `validate`
+4. 再调用 `apply`
+5. 轮询任务状态
+6. 展示成功、失败或已回滚结果
+
+### 2.4 系统操作
+
+1. 用户可执行重启
+2. 用户可执行关机
+3. 所有操作都必须携带密码 Header
+
+## 3. 日志建议
+
+1. 记录接口名、来源 IP、结果、耗时
+2. 不记录密码 Header
+3. 不在日志中明文暴露敏感信息
+4. 网络配置变更建议记录:
+   - 变更时间
+   - 原配置
+   - 新配置
+   - 是否成功
+   - 是否发生回滚
+
+## 4. 记住密码策略
+
+### 4.1 目标
+
+允许客户端记住固定初始化密码,下次打开后可直接连接设备。
+
+### 4.2 建议实现
+
+1. 客户端提供 `记住密码` 复选框
+2. 用户勾选后,将密码安全保存到 Windows 本机
+3. 下次打开客户端时自动读取并填充
+4. 用户可手动清除已保存密码
+
+### 4.3 存储方式
+
+当前阶段按简单实现处理,密码明文写入 Windows 注册表。
+
+建议:
+
+1. 将密码保存到当前用户范围的注册表项
+2. 客户端启动时自动读取该值并填充
+3. 用户取消 `记住密码` 时删除该注册表项
+4. 后续如需提升安全性,再升级为受保护存储方式
+
+### 4.4 交互规则
+
+1. 若已保存密码,连接页自动带出
+2. 用户仍可修改密码
+3. 新密码连接成功后,若勾选 `记住密码`,则覆盖旧密码
+4. 若连续出现密码错误,应提示用户检查或清除本地保存密码
+5. 需在文档和实现中明确:当前阶段密码保存在注册表中,为便捷优先方案
+
+## 5. MVP 范围
+
+第一版建议仅覆盖以下范围:
+
+1. Ubuntu 24
+2. netplan
+3. `LAN1`、`LAN2` 仅作为逻辑展示标识
+4. `LAN2` 固定 `169.254.100.2/16`
+5. `LAN2` 支持 DHCP 获取 4G 地址
+6. `LAN2` 支持 DHCP 获取网关与外联网参数
+7. UDP 发现
+8. HTTP 管理接口
+9. Header 固定密码校验(当前阶段密码写死在代码中)
+10. 基于真实接口名的静态 IP 配置
+11. 自动回滚
+12. 关机、重启
+13. 以新机器初始化场景为主
+
+## 6. 后续可扩展项
+
+1. 每台设备唯一初始化密码
+2. HTTPS 或双向认证
+3. 多语言返回
+4. 批量配置
+5. 配置导入导出
+6. 更细的审计日志
+7. 设备升级和远程维护能力

+ 416 - 0
docs/05-Agent模块设计.md

@@ -0,0 +1,416 @@
+# Agent 模块设计
+
+## 1. 目标
+
+定义 Linux Agent 的内部模块划分、职责边界和建议实现顺序,作为后续编码的实现基线。
+
+## 2. 设计原则
+
+1. 保持模块职责单一
+2. 先保证主链路可用,再补充增强能力
+3. 配置写入与任务执行分离
+4. 接口识别、协议处理、系统配置解耦
+5. 回滚能力必须独立存在,不能散落在各业务处理函数中
+6. 首版以新机器初始化为主,复杂历史配置兼容不是重点
+
+## 3. 模块划分
+
+### 3.1 配置模块
+
+建议名称:`config`
+
+职责:
+
+1. 保存 Agent 固定参数
+2. 提供统一配置读取入口
+3. 输出运行时使用的配置对象
+
+当前阶段建议包含:
+
+1. HTTP 监听端口
+2. UDP 发现端口
+3. 固定维护地址 `169.254.100.2/16`
+4. 固定初始化密码
+5. 日志级别
+6. 默认 HTTP 端口为 `48888`
+7. 支持通过启动参数覆盖维护 IP 和 HTTP 端口
+8. 支持通过 `--password` 启动参数覆盖默认密码
+
+说明:
+
+1. 当前阶段密码虽然写死在代码中,但仍建议通过该模块集中输出
+2. 这样后续改为配置文件时,不需要改动上层业务逻辑
+
+### 3.2 日志模块
+
+建议名称:`logger`
+
+职责:
+
+1. 输出统一格式日志
+2. 区分信息日志、警告日志、错误日志
+3. 对敏感字段做过滤
+
+约束:
+
+1. 不记录 `X-Admin-Password`
+2. 不明文输出完整敏感请求头
+3. 网络配置变更要写审计日志
+
+### 3.3 接口枚举与识别模块
+
+建议名称:`network/interfaces`
+
+职责:
+
+1. 枚举系统物理有线接口
+2. 过滤虚拟接口
+3. 输出真实接口信息
+4. 判断当前管理接口
+5. 生成目标接口候选列表
+6. 在首版启动前为管理接口注入维护地址
+
+建议输出字段:
+
+1. 真实接口名
+2. MAC 地址
+3. 链路状态
+4. 当前 IPv4 列表
+5. 是否为当前管理接口
+6. 是否为建议目标接口
+7. 逻辑展示标识
+
+### 3.4 UDP 发现模块
+
+建议名称:`discovery`
+
+职责:
+
+1. 监听 UDP 广播请求
+2. 解析发现报文
+3. 生成发现响应
+4. 返回设备基础信息
+
+边界:
+
+1. 仅负责发现,不做网络配置
+2. 不负责密码校验
+3. 只依赖设备信息与接口识别模块
+
+### 3.5 HTTP 服务模块
+
+建议名称:`httpserver`
+
+职责:
+
+1. 暴露 HTTP API
+2. 路由请求到业务处理器
+3. 返回统一 JSON 响应
+
+边界:
+
+1. 不直接写系统配置
+2. 不直接处理 netplan 文件
+3. 只做协议层编排
+
+### 3.6 HTTP 认证与访问控制模块
+
+建议名称:`auth`
+
+职责:
+
+1. 校验来源 IP 是否属于 `169.254.0.0/16`
+2. 校验请求目标是否为维护地址
+3. 校验 `X-Admin-Password`
+4. 拒绝未授权请求
+
+边界:
+
+1. 该模块只返回通过/失败结果
+2. 不处理具体业务逻辑
+
+### 3.7 设备信息模块
+
+建议名称:`deviceinfo`
+
+职责:
+
+1. 提供设备 ID
+2. 提供主机名
+3. 提供 Ubuntu 版本
+4. 提供 Agent 版本
+5. 提供运行时长等基础信息
+
+### 3.8 网络配置读取模块
+
+建议名称:`network/configreader`
+
+职责:
+
+1. 读取指定真实接口的当前网络配置
+2. 输出统一配置结构
+3. 提供给接口状态页和配置页回填使用
+
+建议输出字段:
+
+1. 接口名
+2. IP
+3. 前缀长度
+4. 网关
+5. DNS
+
+### 3.9 配置校验模块
+
+建议名称:`network/validator`
+
+职责:
+
+1. 校验目标接口是否存在
+2. 校验 IP 格式
+3. 校验前缀长度
+4. 校验网关格式和同网段关系
+5. 校验 DNS 格式
+6. 返回警告信息和错误信息
+
+说明:
+
+1. 该模块只做校验,不写配置
+2. 允许对 `169.254.x.x` 返回警告而非错误
+
+### 3.10 netplan 渲染模块
+
+建议名称:`network/netplan`
+
+职责:
+
+1. 根据目标接口和参数生成 netplan 内容
+2. 保存 netplan 备份
+3. 写入新的 netplan 文件
+4. 提供回滚需要的备份信息
+
+首版建议:
+
+1. 优先处理新机器初始化场景
+2. 以目标真实接口配置成功为主目标
+3. 不优先支持复杂旧 netplan 合并与跨文件重构
+4. 首版默认修改系统现有 netplan 文件
+5. 修改前先做完整备份,再对目标接口对应内容进行修改
+
+边界:
+
+1. 只负责文件层处理
+2. 不负责执行 `netplan apply`
+
+### 3.11 应用执行模块
+
+建议名称:`network/apply`
+
+职责:
+
+1. 调用 `netplan apply`
+2. 检查命令执行结果
+3. 记录执行日志
+
+边界:
+
+1. 只负责执行和结果返回
+2. 不负责配置渲染与业务校验
+
+### 3.12 验证模块
+
+建议名称:`network/verify`
+
+职责:
+
+1. 验证目标接口地址是否生效
+2. 可选验证网关连通性
+3. 输出验证成功或失败结果
+
+说明:
+
+1. 首版只需做最小验证
+2. 可先只验证接口地址是否已成功生效
+3. 网关连通性可以作为增强项或警告项
+
+### 3.13 回滚模块
+
+建议名称:`network/rollback`
+
+职责:
+
+1. 恢复最近一次稳定 netplan 配置
+2. 重新执行 `netplan apply`
+3. 记录回滚结果
+
+首版建议:
+
+1. 回滚目标以当前被修改的 netplan 文件为主
+2. 不扩展到复杂多文件恢复编排
+
+要求:
+
+1. 必须可被任务执行流程调用
+2. 也必须可被手动回滚接口调用
+
+### 3.14 任务管理模块
+
+建议名称:`tasks`
+
+职责:
+
+1. 创建配置任务
+2. 维护任务状态
+3. 记录执行步骤
+4. 为客户端轮询提供状态数据
+
+建议状态:
+
+1. `pending`
+2. `running`
+3. `success`
+4. `failed`
+5. `rolled_back`
+
+建议步骤:
+
+1. `validating`
+2. `writing_netplan`
+3. `applying`
+4. `verifying`
+5. `rolling_back`
+6. `completed`
+
+### 3.15 系统控制模块
+
+建议名称:`systemctl` 或 `systemops`
+
+职责:
+
+1. 执行重启
+2. 执行关机
+3. 返回执行接受状态
+
+## 4. 请求处理链路
+
+### 4.1 `GET /api/network/interfaces`
+
+建议链路:
+
+1. `auth`
+2. `network/interfaces`
+3. `httpserver` 统一组装响应
+
+### 4.2 `POST /api/network/validate`
+
+建议链路:
+
+1. `auth`
+2. `network/interfaces` 校验接口是否存在
+3. `network/validator`
+4. `httpserver` 返回结果
+
+### 4.3 `POST /api/network/apply`
+
+建议链路:
+
+1. `auth`
+2. `tasks` 创建任务
+3. `network/validator`
+4. `network/netplan`
+5. `network/apply`
+6. `network/verify`
+7. 失败时进入 `network/rollback`
+8. `tasks` 更新状态
+
+## 5. 建议目录结构
+
+建议仅作参考:
+
+```text
+agent/
+  cmd/
+    quickip-agent/
+  internal/
+    config/
+    logger/
+    auth/
+    deviceinfo/
+    discovery/
+    httpserver/
+    tasks/
+    systemops/
+    network/
+      interfaces/
+      configreader/
+      validator/
+      netplan/
+      apply/
+      verify/
+      rollback/
+```
+
+## 6. 建议实现顺序
+
+### 第一阶段
+
+先打通最小链路:
+
+1. `config`
+2. `logger`
+3. `deviceinfo`
+4. `network/interfaces`
+5. `discovery`
+6. `httpserver`
+7. `auth`
+8. `GET /api/health`
+9. `GET /api/device/info`
+10. `GET /api/network/interfaces`
+
+### 第二阶段
+
+补齐读取与校验:
+
+1. `network/configreader`
+2. `network/validator`
+3. `GET /api/network/config`
+4. `POST /api/network/validate`
+
+### 第三阶段
+
+补齐配置与回滚主链路:
+
+1. `tasks`
+2. `network/netplan`
+3. `network/apply`
+4. `network/verify`
+5. `network/rollback`
+6. `POST /api/network/apply`
+7. `POST /api/network/rollback`
+8. `GET /api/tasks/{task_id}`
+
+### 第四阶段
+
+补齐系统操作:
+
+1. `systemops`
+2. `POST /api/system/reboot`
+3. `POST /api/system/shutdown`
+
+## 7. MVP 阶段建议收口
+
+为了尽快落地,Agent 首版建议遵循以下约束:
+
+1. 只支持 Ubuntu 24
+2. 只支持 netplan
+3. 只处理 IPv4
+4. 只处理单目标接口配置
+5. 只提供固定密码鉴权
+6. 只做最小必要校验与回滚
+
+## 8. 当前最重要的实现判断
+
+如果现在进入编码,我建议先做下面 3 件事:
+
+1. 先验证 Ubuntu 24 上 `LAN2` 的固定 `169.254.100.2/16` 与 DHCP 共存是否稳定
+2. 先实现 `network/interfaces`,把真实接口识别做稳
+3. 先完成只读接口,再进入 `netplan` 写入和回滚

+ 187 - 0
docs/06-netplan修改策略.md

@@ -0,0 +1,187 @@
+# netplan 修改策略
+
+## 1. 目标
+
+定义首版 QuickIP 在 Ubuntu 24 上修改 netplan 的具体策略,重点说明:
+
+1. 修改哪个文件
+2. 修改哪些字段
+3. 保留哪些字段
+4. 什么时候回滚
+
+## 2. 首版场景
+
+首版默认场景是新机器初始化。
+
+特点:
+
+1. 一般只有一个 netplan YAML 文件
+2. 不重点处理复杂历史网络配置兼容
+3. 核心目标是把选中的真实接口配置正确写入系统
+
+## 3. 文件定位规则
+
+### 3.1 默认规则
+
+1. 扫描 `/etc/netplan/*.yaml`
+2. 如果只存在一个 YAML 文件:
+   - 直接将该文件作为本次修改目标文件
+3. 如果不存在 YAML 文件:
+   - 返回错误,由后续实现决定是否补充自动创建能力
+4. 如果存在多个 YAML 文件:
+   - 首版直接返回错误
+   - 不自动猜测该改哪个文件
+
+### 3.2 为什么这样定
+
+原因:
+
+1. 符合新机器初始化场景
+2. 可避免误改错误文件
+3. 可避免首版陷入复杂多文件兼容
+
+## 4. 修改方式
+
+### 4.1 总体原则
+
+首版不建议做字符串替换或正则替换。
+
+建议方式:
+
+1. 先按 YAML 结构解析目标文件
+2. 找到目标真实接口对应节点
+3. 按结构化方式更新字段
+4. 再将整个文件重新写回
+
+说明:
+
+1. 这是“修改现有文件”
+2. 但实现方式仍然应该是结构化重写
+3. 不建议做纯文本级局部替换
+
+### 4.2 修改范围
+
+首版只修改:
+
+1. 用户选中的目标真实接口配置
+2. 必要时补充当前管理接口的固定维护地址配置
+
+不主动修改:
+
+1. 其他无关接口
+2. 与本次目标接口无关的系统网络字段
+
+## 5. 目标接口字段处理规则
+
+假设当前目标真实接口为 `enp1s0`。
+
+### 5.1 需要写入或覆盖的字段
+
+对于目标接口,首版建议由 QuickIP 明确写入或覆盖以下字段:
+
+1. `dhcp4`
+   - 设为 `false`
+2. `addresses`
+   - 按用户输入的 IP 和前缀长度生成
+3. `gateway4` 或等效默认网关表达
+   - 若用户填写网关,则写入
+   - 若用户未填写网关,则删除该字段
+4. `nameservers.addresses`
+   - 若用户填写 DNS,则写入
+   - 若用户未填写 DNS,则删除或置空
+
+### 5.2 建议删除的旧字段
+
+若目标接口原本存在以下字段,首版建议按新配置重置,避免旧状态残留:
+
+1. 旧的 `addresses`
+2. 旧的 `gateway4`
+3. 旧的 `nameservers`
+4. 旧的 `dhcp4`
+
+### 5.3 建议保留的字段
+
+若目标接口下已存在且与本次配置无直接冲突,首版可考虑保留:
+
+1. `optional`
+2. `mtu`
+3. `wakeonlan`
+
+但首版如果实现复杂,也可以先不保留这些增强字段,重点保证主配置正确。
+
+## 6. 管理接口字段处理规则
+
+当前管理接口对应逻辑标识为 `LAN2`,但实际操作对象仍是真实接口名。
+
+首版目标:
+
+1. 保证管理接口上有固定维护地址 `169.254.100.2/16`
+2. 允许该接口继续通过 DHCP 获取外联地址
+
+建议处理:
+
+1. 若管理接口节点已存在:
+   - 补充或更新固定维护地址
+   - 保留 DHCP4 能力
+2. 若管理接口节点不存在:
+   - 由 QuickIP 创建该接口节点
+
+## 7. 备份与回滚规则
+
+### 7.1 备份规则
+
+每次应用配置前:
+
+1. 先备份目标 netplan 文件
+2. 备份文件应完整保留原始内容
+3. 备份文件路径由实现决定,但应可用于立即回滚
+
+### 7.2 应用规则
+
+1. 备份完成后写回修改后的文件
+2. 执行 `netplan apply`
+3. 检查执行结果
+4. 验证目标接口地址是否生效
+
+### 7.3 回滚规则
+
+出现以下情况时执行回滚:
+
+1. `netplan apply` 失败
+2. 目标接口地址未按预期生效
+3. 其他明确判定为配置失败的情况
+
+回滚步骤:
+
+1. 恢复备份文件
+2. 再次执行 `netplan apply`
+3. 记录回滚结果
+
+## 8. 首版不处理的情况
+
+首版建议明确不自动处理以下情况:
+
+1. `/etc/netplan` 下存在多个 YAML 文件
+2. 多个文件共同描述同一接口
+3. 复杂路由策略合并
+4. IPv6 配置兼容
+5. 复杂自定义 nameserver/search 域保留
+
+这些情况可直接返回错误,由后续版本再扩展。
+
+## 9. 建议实现顺序
+
+1. 先完成“定位单个 netplan 文件”能力
+2. 再完成“读取并解析 YAML 结构”能力
+3. 再完成“修改目标真实接口字段”能力
+4. 再完成“备份、写回、apply、回滚”闭环
+
+## 10. 当前推荐结论
+
+首版 netplan 修改策略建议固定为:
+
+1. 默认认为系统只有一个 netplan YAML 文件
+2. 修改该现有文件
+3. 修改前先完整备份
+4. 按 YAML 结构更新目标真实接口字段
+5. 失败则用原文件回滚

+ 309 - 0
docs/07-Agent首阶段实现清单.md

@@ -0,0 +1,309 @@
+# Agent 首阶段实现清单
+
+## 1. 目标
+
+定义 Linux Agent 从零开始时,第一阶段应该优先完成的模块、接口、数据结构和验证顺序。
+
+第一阶段目标不是立即改 IP,而是先把“发现 + 连接 + 读取状态”整条只读链路打通。
+
+## 2. 第一阶段范围
+
+第一阶段只实现以下能力:
+
+1. Agent 启动
+2. 读取固定运行配置
+3. 启动前为管理接口挂载维护地址
+4. UDP 广播发现
+5. HTTP 服务启动
+6. 固定密码鉴权
+7. 设备信息读取
+8. 真实接口枚举与识别
+9. 返回接口列表
+
+第一阶段暂不实现:
+
+1. `netplan` 写入
+2. 配置校验
+3. 配置应用
+4. 自动回滚
+5. 重启与关机
+
+## 3. 第一阶段必须完成的 HTTP 接口
+
+### 3.1 `GET /api/health`
+
+用途:
+
+1. 客户端探活
+2. 判断 Agent 是否运行
+
+最低返回:
+
+1. 运行状态
+2. Agent 版本
+
+### 3.2 `GET /api/device/info`
+
+用途:
+
+1. 展示设备基础信息
+2. 确认设备身份
+
+最低返回:
+
+1. 设备 ID
+2. 主机名
+3. Ubuntu 版本
+4. Agent 版本
+5. 运行时长
+
+### 3.3 `GET /api/network/interfaces`
+
+用途:
+
+1. 返回真实接口列表
+2. 返回当前管理接口
+3. 返回建议目标接口
+4. 为设备页提供数据
+
+最低返回:
+
+1. `management_interface`
+2. `suggested_target_interface`
+3. `requires_target_selection`
+4. `interfaces[]`
+
+## 4. 第一阶段必须完成的 UDP 能力
+
+### 4.1 UDP 广播监听
+
+要求:
+
+1. 监听固定发现端口
+2. 能接收 Windows 广播报文
+3. 能正确解析 `discover` 请求
+
+### 4.2 UDP 发现响应
+
+要求:
+
+1. 回传 `request_id`
+2. 返回设备基础信息
+3. 返回维护地址 `169.254.100.2`
+4. 返回 `auth_required=true`
+
+## 5. 第一阶段建议模块顺序
+
+### 5.1 `config`
+
+先完成一个最小配置模块,至少输出:
+
+1. HTTP 端口
+2. UDP 端口
+3. 固定维护地址
+4. 固定密码
+5. Agent 版本
+6. HTTP 默认端口 `48888`
+7. 支持 `--ip` 与 `--port` 启动参数
+8. 支持 `--password` 启动参数,默认密码为 `Dt123$`
+
+建议示例:
+
+```bash
+./quickip-agent --ip 169.254.100.2 --port 48888 --password 'Dt123$'
+```
+
+### 5.2 `logger`
+
+提供统一日志入口。
+
+最低要求:
+
+1. 信息日志
+2. 错误日志
+3. 敏感头过滤
+
+### 5.3 `deviceinfo`
+
+负责提供:
+
+1. 主机名
+2. OS 版本
+3. 设备 ID
+4. 运行时长
+
+### 5.4 `network/interfaces`
+
+这是第一阶段最关键模块之一。
+
+最低要求:
+
+1. 枚举物理有线接口
+2. 过滤虚拟接口
+3. 返回接口名、MAC、链路状态、IPv4
+4. 判断当前管理接口
+5. 判断建议目标接口
+6. 在维护地址不存在时,为管理接口执行运行时注入
+
+### 5.5 `auth`
+
+最低要求:
+
+1. 检查来源 IP 是否在 `169.254.0.0/16`
+2. 校验 `X-Admin-Password`
+3. 返回统一失败结果
+
+### 5.6 `httpserver`
+
+最低要求:
+
+1. 路由注册
+2. 统一 JSON 输出
+3. 接入 `auth`
+
+### 5.7 `discovery`
+
+最低要求:
+
+1. 启动 UDP 监听
+2. 收包后读取 `deviceinfo`
+3. 收包后读取管理地址
+4. 返回发现响应
+
+## 6. 第一阶段建议数据结构
+
+### 6.1 AgentConfig
+
+建议字段:
+
+```go
+type AgentConfig struct {
+    HttpPort          int
+    UdpPort           int
+    MaintenanceIP     string
+    MaintenanceCIDR   string
+    AdminPassword     string
+    Version           string
+}
+```
+
+### 6.2 DeviceInfo
+
+建议字段:
+
+```go
+type DeviceInfo struct {
+    DeviceID      string
+    Hostname      string
+    OSVersion     string
+    AgentVersion  string
+    UptimeSeconds int64
+}
+```
+
+### 6.3 NetworkInterface
+
+建议字段:
+
+```go
+type NetworkInterface struct {
+    Name                 string
+    SystemName           string
+    Role                 string
+    LinkUp               bool
+    IsManagement         bool
+    IsSuggestedTarget    bool
+    MAC                  string
+    IPv4                 []IPv4Address
+    Gateway              string
+    DNS                  []string
+}
+```
+
+### 6.4 IPv4Address
+
+建议字段:
+
+```go
+type IPv4Address struct {
+    Address string
+    Prefix  int
+    Source  string
+}
+```
+
+### 6.5 InterfacesResponse
+
+建议字段:
+
+```go
+type InterfacesResponse struct {
+    ManagementInterface     string
+    SuggestedTargetInterface string
+    RequiresTargetSelection bool
+    Interfaces              []NetworkInterface
+}
+```
+
+## 7. 第一阶段建议目录落地
+
+建议优先创建:
+
+```text
+agent/
+  cmd/
+    quickip-agent/
+  internal/
+    config/
+    logger/
+    auth/
+    deviceinfo/
+    discovery/
+    httpserver/
+    network/
+      interfaces/
+```
+
+## 8. 第一阶段联调前置条件
+
+在 Windows 客户端开始联调前,Agent 至少应满足:
+
+1. 能在 Ubuntu 24 上正常启动
+2. 能监听 HTTP 端口
+3. 能响应 UDP 发现
+4. 能返回设备信息
+5. 能返回真实接口列表
+6. 能识别当前管理接口
+
+## 9. 第一阶段验证顺序
+
+建议按下面顺序验证:
+
+1. 本机直接访问 `GET /api/health`
+2. 本机直接访问 `GET /api/device/info`
+3. 本机直接访问 `GET /api/network/interfaces`
+4. Windows 端发送 UDP 发现
+5. Windows 端通过密码访问上述 3 个接口
+
+## 10. 第一阶段完成标准
+
+满足以下条件即可认为第一阶段完成:
+
+1. Windows 能发现设备
+2. Windows 能通过固定密码连接设备
+3. Windows 能看到设备信息
+4. Windows 能看到真实接口列表
+5. Windows 能自动识别当前管理接口
+6. 当接口数为 2 时,Windows 能拿到建议目标接口
+
+## 11. 第二阶段入口
+
+第一阶段完成后,再进入第二阶段:
+
+1. `GET /api/network/config`
+2. `POST /api/network/validate`
+3. `POST /api/network/apply`
+4. `POST /api/network/rollback`
+5. `GET /api/tasks/{task_id}`
+
+也就是先把“只读链路”做稳,再开始改配置。

+ 12 - 0
windows/QuickIP.Client/README.md

@@ -0,0 +1,12 @@
+# QuickIP.Client
+
+此目录预留给 WPF 客户端项目。
+
+后续建议优先补齐:
+
+1. `QuickIP.Client.csproj`
+2. `App.xaml`
+3. `MainWindow.xaml`
+4. `Views/`
+5. `ViewModels/`
+6. `Services/`

+ 21 - 0
windows/README.md

@@ -0,0 +1,21 @@
+# Windows Client
+
+此目录用于存放 QuickIP 的 Windows 桌面客户端代码。
+
+建议结构:
+
+```text
+windows/
+  QuickIP.Client.sln
+  QuickIP.Client/
+    QuickIP.Client.csproj
+    App.xaml
+    MainWindow.xaml
+    Views/
+    ViewModels/
+    Services/
+    Models/
+    Helpers/
+```
+
+当前阶段尚未创建 WPF 工程文件,后续将在此目录下补齐客户端骨架。

+ 70 - 0
方案设计.md

@@ -0,0 +1,70 @@
+# QuickIP 方案设计
+
+本文档作为总入口,详细内容已拆分到 `docs` 目录。
+
+## 文档目录
+
+1. `docs/01-总体方案.md`
+   - 项目目标
+   - 总体架构
+   - 地址与路由策略
+   - 安全边界
+
+2. `docs/02-网口识别规则.md`
+   - `LAN1`、`LAN2` 逻辑标识定义
+   - 真实接口识别规则
+   - 多网口场景处理方式
+
+3. `docs/03-通信与HTTP_API.md`
+   - UDP 发现协议
+   - HTTP API 设计
+   - 认证规则
+   - 回滚与任务状态
+
+4. `docs/04-客户端流程与MVP.md`
+   - Windows 客户端流程
+   - 配置与回滚流程
+   - 日志建议
+   - MVP 范围
+   - 后续扩展项
+
+5. `docs/05-Agent模块设计.md`
+   - Linux Agent 模块划分
+   - 请求处理链路
+   - netplan 与回滚职责
+   - 建议实现顺序
+
+6. `docs/06-netplan修改策略.md`
+   - 现有 netplan 文件定位规则
+   - 目标接口字段修改规则
+   - 备份与回滚规则
+   - 首版实现边界
+
+7. `docs/07-Agent首阶段实现清单.md`
+   - Agent 第一阶段接口清单
+   - 建议结构体与模块顺序
+   - 联调前置条件
+   - 开发里程碑
+
+## 当前约束摘要
+
+1. Linux 发行版:`Ubuntu 24`
+2. 网络管理方式:`netplan`
+3. 通信协议:`UDP + HTTP`
+4. 鉴权方式:固定初始化密码,当前阶段写死在 Agent 代码中
+5. `LAN1`、`LAN2` 仅作为逻辑标识,实际操作对象为 Linux 真实接口名
+6. `LAN2` 保留固定维护地址 `169.254.100.2/16`,同时支持接 4G 路由器联网
+
+## 仓库结构
+
+```text
+QuickIP/
+  agent/
+    cmd/
+    internal/
+    go.mod
+  windows/
+    QuickIP.Client/
+  docs/
+  方案设计.md
+```