Sfoglia il codice sorgente

feat(agent): 新增系统重启与关机API及客户端支持

yangkaixiang 1 mese fa
parent
commit
c6fee04fbe

+ 6 - 1
agent/cmd/quickip-agent/main.go

@@ -15,6 +15,7 @@ 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/tasks"
@@ -26,6 +27,9 @@ func main() {
 
 	log := logger.New()
 	cfg := config.Load(os.Args[1:])
+	if os.Geteuid() != 0 {
+		log.Warn("agent is not running as root; netplan write/apply and system actions will fail")
+	}
 	deviceSvc := deviceinfo.New(cfg)
 	interfaceSvc := interfacesvc.New(cfg)
 	configSvc := configreadersvc.New()
@@ -34,13 +38,14 @@ func main() {
 	applySvc := applyexecsvc.New()
 	verifySvc := verifysvc.New()
 	taskSvc := tasks.New()
+	systemSvc := systemaction.New()
 
 	if err := interfaceSvc.EnsureMaintenanceAddress(); err != nil {
 		log.Error("failed to ensure maintenance address", "error", err.Error())
 		return
 	}
 
-	httpSrv := httpserver.New(cfg, log, deviceSvc, interfaceSvc, configSvc, validatorSvc, netplanSvc, applySvc, verifySvc, taskSvc)
+	httpSrv := httpserver.New(cfg, log, deviceSvc, interfaceSvc, configSvc, validatorSvc, netplanSvc, applySvc, verifySvc, taskSvc, systemSvc)
 	udpSrv := discovery.New(cfg, log, deviceSvc)
 
 	errCh := make(chan error, 2)

+ 81 - 2
agent/internal/httpserver/server.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"os"
 	"strings"
 	"time"
 
@@ -18,6 +19,7 @@ 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/tasks"
@@ -34,10 +36,11 @@ type Server struct {
 	applySvc     *applyexecsvc.Service
 	verifySvc    *verifysvc.Service
 	taskSvc      *tasks.Service
+	systemSvc    *systemaction.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 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}
 }
 
 func (s *Server) Run(ctx context.Context) error {
@@ -49,6 +52,8 @@ func (s *Server) Run(ctx context.Context) error {
 	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/system/reboot", auth.Middleware(s.cfg, http.HandlerFunc(s.handleReboot)))
+	mux.Handle("/api/system/shutdown", auth.Middleware(s.cfg, http.HandlerFunc(s.handleShutdown)))
 	mux.Handle("/api/tasks/", auth.Middleware(s.cfg, http.HandlerFunc(s.handleTaskGet)))
 	handler := s.withAccessLog(mux)
 
@@ -168,6 +173,10 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
 		writeJSON(w, http.StatusMethodNotAllowed, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
 		return
 	}
+	if !hasRootPrivileges() {
+		writeJSON(w, http.StatusForbidden, model.APIResponse{Code: 4001, Message: "系统执行失败", Data: map[string][]string{"errors": []string{"Agent 未以 root 身份运行,无法写入 netplan 或执行 netplan apply。"}}})
+		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{"请求体格式不正确。"}}})
@@ -197,6 +206,10 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
 		writeJSON(w, http.StatusMethodNotAllowed, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
 		return
 	}
+	if !hasRootPrivileges() {
+		writeJSON(w, http.StatusForbidden, model.APIResponse{Code: 4001, Message: "系统执行失败", Data: map[string][]string{"errors": []string{"Agent 未以 root 身份运行,无法恢复 netplan 或执行 netplan apply。"}}})
+		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": {"请求体格式不正确。"}}})
@@ -233,6 +246,35 @@ func (s *Server) handleTaskGet(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "成功", Data: item})
 }
 
+func (s *Server) handleReboot(w http.ResponseWriter, r *http.Request) {
+	s.handleSystemAction(w, r, "reboot")
+}
+
+func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
+	s.handleSystemAction(w, r, "shutdown")
+}
+
+func (s *Server) handleSystemAction(w http.ResponseWriter, r *http.Request, action string) {
+	if r.Method != http.MethodPost {
+		writeJSON(w, http.StatusMethodNotAllowed, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
+		return
+	}
+	if !hasRootPrivileges() {
+		writeJSON(w, http.StatusForbidden, model.APIResponse{Code: 4001, Message: "系统执行失败", Data: map[string][]string{"errors": []string{"Agent 未以 root 身份运行,无法执行重启或关机。"}}})
+		return
+	}
+
+	task := s.taskSvc.Create()
+	go s.runSystemTask(task.TaskID, action)
+	message := "系统任务已提交"
+	if action == "reboot" {
+		message = "重启任务已提交"
+	} else if action == "shutdown" {
+		message = "关机任务已提交"
+	}
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: message, Data: map[string]any{"action": action, "task_id": task.TaskID}})
+}
+
 func (s *Server) runApplyTask(taskID string, input model.InterfaceConfig, managementInterface string) {
 	s.taskSvc.Update(taskID, "running", "validating", "正在校验配置。", false)
 	result := s.validatorSvc.Validate(input)
@@ -277,6 +319,39 @@ func (s *Server) runApplyTask(taskID string, input model.InterfaceConfig, manage
 	s.taskSvc.Update(taskID, "success", "completed", "目标接口配置已成功应用。", false)
 }
 
+func (s *Server) runSystemTask(taskID string, action string) {
+	detail := "正在发送系统指令。"
+	if action == "reboot" {
+		detail = "正在发送重启指令。"
+	} else if action == "shutdown" {
+		detail = "正在发送关机指令。"
+	}
+	s.taskSvc.Update(taskID, "running", "executing", detail, false)
+
+	var err error
+	switch action {
+	case "reboot":
+		err = s.systemSvc.Reboot()
+	case "shutdown":
+		err = s.systemSvc.Shutdown()
+	default:
+		err = fmt.Errorf("unsupported system action: %s", action)
+	}
+	if err != nil {
+		s.log.Error("system action failed", "action", action, "error", err.Error())
+		s.taskSvc.Update(taskID, "failed", "executing", err.Error(), false)
+		return
+	}
+
+	completedDetail := "系统指令已发送。"
+	if action == "reboot" {
+		completedDetail = "系统重启指令已发送。"
+	} else if action == "shutdown" {
+		completedDetail = "系统关机指令已发送。"
+	}
+	s.taskSvc.Update(taskID, "success", "completed", completedDetail, false)
+}
+
 func (s *Server) interfaceExists(name string) bool {
 	data, err := s.interfaceSvc.List()
 	if err != nil {
@@ -303,3 +378,7 @@ func writeJSON(w http.ResponseWriter, status int, payload model.APIResponse) {
 	w.WriteHeader(status)
 	_ = json.NewEncoder(w).Encode(payload)
 }
+
+func hasRootPrivileges() bool {
+	return os.Geteuid() == 0
+}

+ 32 - 0
agent/internal/systemaction/systemaction.go

@@ -0,0 +1,32 @@
+package systemaction
+
+import (
+	"fmt"
+	"os/exec"
+	"strings"
+)
+
+type Service struct{}
+
+func New() *Service { return &Service{} }
+
+func (s *Service) Reboot() error {
+	return runCommand("systemctl", "reboot")
+}
+
+func (s *Service) Shutdown() error {
+	return runCommand("systemctl", "poweroff")
+}
+
+func runCommand(name string, args ...string) error {
+	cmd := exec.Command(name, args...)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		trimmed := strings.TrimSpace(string(output))
+		if trimmed == "" {
+			return fmt.Errorf("%s failed", strings.Join(append([]string{name}, args...), " "))
+		}
+		return fmt.Errorf("%s failed: %s", strings.Join(append([]string{name}, args...), " "), trimmed)
+	}
+	return nil
+}

+ 42 - 0
build.ps1

@@ -0,0 +1,42 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
+$windowsDir = Join-Path $repoRoot "windows"
+$agentDir = Join-Path $repoRoot "agent"
+$windowsSolution = Join-Path $windowsDir "QuickIP.Client.sln"
+$agentLinuxOutput = Join-Path $agentDir "quickip-agent-linux-amd64"
+
+if (-not (Test-Path -LiteralPath $windowsSolution)) {
+    throw "Windows solution not found: $windowsSolution"
+}
+
+if (-not (Test-Path -LiteralPath $agentDir)) {
+    throw "Agent directory not found: $agentDir"
+}
+
+Write-Host "[1/3] Building Windows client (Debug)..."
+dotnet build $windowsSolution -c Debug
+
+$previousGoos = $env:GOOS
+$previousGoarch = $env:GOARCH
+$previousCgoEnabled = $env:CGO_ENABLED
+
+try {
+    Write-Host "[2/2] Building agent for Linux amd64..."
+    Set-Location -LiteralPath $agentDir
+    $env:GOOS = "linux"
+    $env:GOARCH = "amd64"
+    $env:CGO_ENABLED = "0"
+    go build -o $agentLinuxOutput ./cmd/quickip-agent
+}
+finally {
+    $env:GOOS = $previousGoos
+    $env:GOARCH = $previousGoarch
+    $env:CGO_ENABLED = $previousCgoEnabled
+    Set-Location -LiteralPath $repoRoot
+}
+
+Write-Host "Build completed."
+Write-Host "Windows client output: windows/QuickIP.Client/bin/Debug/net9.0-windows/"
+Write-Host "Agent Linux output: agent/quickip-agent-linux-amd64"

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

@@ -0,0 +1,202 @@
+# 构建与编译
+
+## 1. 目标
+
+统一记录 QuickIP 当前可直接使用的编译命令、默认输出位置,以及一键编译方式。
+
+## 2. Windows 客户端编译
+
+在仓库根目录执行:
+
+```powershell
+dotnet build ".\windows\QuickIP.Client.sln" -c Debug
+```
+
+默认输出目录:
+
+```text
+windows\QuickIP.Client\bin\Debug\net9.0-windows\
+```
+
+说明:
+
+1. 保持使用默认 `Debug` 输出目录
+2. 如果 `QuickIP.Client.exe` 正在运行,重新编译可能因文件被占用而失败
+
+## 3. Agent 编译
+
+当前只需要编译 Linux amd64 版 Agent,用于上传到 Ubuntu 24 设备。
+
+本节默认前提:当前 PowerShell 已位于 `D:\git\QuickIP\agent`。
+
+### 3.1 编译 Linux amd64 版 Agent
+
+在 `agent\` 目录执行:
+
+```powershell
+$env:GOOS="linux"
+$env:GOARCH="amd64"
+$env:CGO_ENABLED="0"
+go build -o ".\quickip-agent-linux-amd64" ".\cmd\quickip-agent"
+```
+
+输出文件:
+
+```text
+agent\quickip-agent-linux-amd64
+```
+
+说明:
+
+1. 该产物用于上传到 Ubuntu 24 设备
+2. 编译完成后,如有需要可把 `GOOS`、`GOARCH` 恢复到原值
+3. `go.mod` 位于 `agent\` 目录下,因此应先进入 `agent` 目录再执行 `go build`
+4. 在 Windows 上交叉编译 Linux 版时,需要设置 `CGO_ENABLED=0`
+
+## 4. 一键编译
+
+仓库根目录已提供 `build.ps1`。
+
+执行命令:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\build.ps1
+```
+
+该脚本会依次完成:
+
+1. 编译 Windows 客户端到默认 `Debug` 目录
+2. 编译 Linux amd64 版 Agent 到 `agent\quickip-agent-linux-amd64`
+
+## 5. 上传与启动
+
+以下示例基于当前联调环境:
+
+1. 远端主机:`x@192.168.229.136`
+2. 远端目标路径:`/home/x/quickip-agent`
+3. 本节默认前提:当前 PowerShell 已位于 `D:\git\QuickIP\agent`
+
+### 5.1 上传 Linux 版 Agent
+
+如果当前已经在 `agent\` 目录执行:
+
+```powershell
+scp .\quickip-agent-linux-amd64 x@192.168.229.136:/home/x/quickip-agent
+```
+
+说明:
+
+1. 该命令会把本地 Linux 版产物上传到远端并命名为 `quickip-agent`
+2. 如果远端当前已有同名进程在运行,建议先停止旧进程再覆盖上传
+
+### 5.2 登录远端主机
+
+```powershell
+ssh x@192.168.229.136
+```
+
+说明:
+
+1. 当前远端 `sudo` 需要交互输入密码
+2. 因此不建议用无交互 `ssh "sudo ..."` 方式直接停进程或启动服务
+3. 更稳妥的方式是先登录远端,再手工执行 `sudo` 命令
+
+### 5.3 停止旧进程
+
+登录远端后执行:
+
+```bash
+sudo pkill -f quickip-agent
+```
+
+### 5.4 设置执行权限
+
+登录远端后执行:
+
+```bash
+chmod +x /home/x/quickip-agent
+```
+
+说明:
+
+1. 上传完成后,建议显式补一次执行权限
+2. 不要依赖 `scp` 后远端权限一定正确
+
+### 5.5 以 root 身份启动 Agent
+
+登录远端后执行:
+
+```bash
+sudo nohup /home/x/quickip-agent --ip 169.254.100.2 --port 48888 --password 'Dt123$' >/home/x/quickip-agent-run.log 2>&1 < /dev/null &
+```
+
+说明:
+
+1. 当前必须以 `root` 身份运行,才能写入 `netplan`、执行 `netplan apply`、重启和关机
+2. 如果不用 `sudo` 启动,配置应用和系统动作会被 Agent 直接拒绝
+
+### 5.6 检查监听状态
+
+登录远端后执行:
+
+```bash
+ss -ltnp | grep 48888
+```
+
+期望看到类似:
+
+```text
+LISTEN ... 169.254.100.2:48888 ... quickip-agent
+```
+
+### 5.7 查看运行日志
+
+登录远端后执行:
+
+```bash
+tail -n 50 /home/x/quickip-agent-run.log
+```
+
+### 5.8 推荐顺序
+
+本机 PowerShell:
+
+```powershell
+scp .\quickip-agent-linux-amd64 x@192.168.229.136:/home/x/quickip-agent
+ssh x@192.168.229.136
+```
+
+远端登录后:
+
+```bash
+sudo pkill -f quickip-agent
+chmod +x /home/x/quickip-agent
+sudo nohup /home/x/quickip-agent --ip 169.254.100.2 --port 48888 --password 'Dt123$' >/home/x/quickip-agent-run.log 2>&1 < /dev/null &
+ss -ltnp | grep 48888
+```
+
+## 6. 常见问题
+
+### 6.1 `go build ./...` 没报错,但找不到可执行文件
+
+这是正常现象。
+
+`go build ./...` 更适合做整模块编译检查,不保证在你期望的位置生成最终可执行文件。
+
+如果需要明确拿到 agent 产物,应使用带 `-o` 的命令,例如:
+
+```powershell
+$env:GOOS="linux"
+$env:GOARCH="amd64"
+$env:CGO_ENABLED="0"
+go build -o ".\quickip-agent-linux-amd64" ".\cmd\quickip-agent"
+```
+
+### 6.2 Windows 客户端编译失败,提示 `QuickIP.Client.exe` 被占用
+
+通常表示客户端程序仍在运行。
+
+处理方式:
+
+1. 先关闭正在运行的 `QuickIP.Client.exe`
+2. 再重新执行 `dotnet build`

+ 223 - 0
scripts/linux-negative-test.sh

@@ -0,0 +1,223 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+HOST="169.254.100.2"
+PORT="48888"
+PASSWORD="Dt123$"
+INTERFACE=""
+
+usage() {
+  cat <<'EOF'
+Usage:
+  linux-negative-test.sh [options]
+
+Purpose:
+  Safe negative checks for the Linux agent.
+  This script does not call /api/network/apply and does not modify netplan.
+
+Checks:
+  - Wrong password should be rejected
+  - Missing interface should fail validate
+  - Invalid IP should fail validate
+  - Gateway outside subnet should fail validate
+  - Invalid DNS should fail validate
+
+Options:
+  --host <ip>         Agent host, default: 169.254.100.2
+  --port <port>       Agent port, default: 48888
+  --password <value>  Correct admin password, default: Dt123$
+  --interface <name>  Existing target interface, default: suggested_target_interface
+  --help              Show this help
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --host)
+      HOST="$2"
+      shift 2
+      ;;
+    --port)
+      PORT="$2"
+      shift 2
+      ;;
+    --password)
+      PASSWORD="$2"
+      shift 2
+      ;;
+    --interface)
+      INTERFACE="$2"
+      shift 2
+      ;;
+    --help|-h)
+      usage
+      exit 0
+      ;;
+    *)
+      echo "Unknown argument: $1" >&2
+      usage >&2
+      exit 2
+      ;;
+  esac
+done
+
+for cmd in curl python3; do
+  if ! command -v "$cmd" >/dev/null 2>&1; then
+    echo "Missing required command: $cmd" >&2
+    exit 2
+  fi
+done
+
+BASE_URL="http://${HOST}:${PORT}"
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+log() {
+  printf '[quickip-negative] %s\n' "$*"
+}
+
+request() {
+  local method="$1"
+  local path="$2"
+  local password="$3"
+  local payload="${4:-}"
+  local body_file="$TMP_DIR/body.json"
+  local http_code
+
+  if [[ -n "$payload" ]]; then
+    http_code="$(curl -sS -o "$body_file" -w "%{http_code}" -X "$method" \
+      -H "X-Admin-Password: ${password}" \
+      -H "Content-Type: application/json" \
+      --data "$payload" \
+      --connect-timeout 3 \
+      --max-time 15 \
+      "${BASE_URL}${path}")"
+  else
+    http_code="$(curl -sS -o "$body_file" -w "%{http_code}" -X "$method" \
+      -H "X-Admin-Password: ${password}" \
+      --connect-timeout 3 \
+      --max-time 15 \
+      "${BASE_URL}${path}")"
+  fi
+
+  printf '%s\n' "$http_code"
+  cat "$body_file"
+}
+
+read_http_code() {
+  sed -n '1p'
+}
+
+read_body() {
+  sed '1d'
+}
+
+json_get() {
+  local body="$1"
+  local expr="$2"
+  BODY_JSON="$body" python3 - "$expr" <<'PY'
+import json
+import os
+import sys
+
+expr = sys.argv[1]
+data = json.loads(os.environ["BODY_JSON"])
+value = data
+for part in expr.split('.'):
+    if part:
+        value = value[part]
+if isinstance(value, list):
+    import json as _json
+    print(_json.dumps(value, ensure_ascii=False))
+else:
+    print(value)
+PY
+}
+
+expect_failure() {
+  local name="$1"
+  local expected_http="$2"
+  local expected_code="$3"
+  local result="$4"
+  local http_code body actual_code
+
+  http_code="$(printf '%s\n' "$result" | read_http_code)"
+  body="$(printf '%s\n' "$result" | read_body)"
+  actual_code="$(json_get "$body" "code")"
+
+  if [[ "$http_code" != "$expected_http" ]]; then
+    echo "${name} failed: expected http=${expected_http}, actual=${http_code}, body=${body}" >&2
+    exit 1
+  fi
+  if [[ "$actual_code" != "$expected_code" ]]; then
+    echo "${name} failed: expected code=${expected_code}, actual=${actual_code}, body=${body}" >&2
+    exit 1
+  fi
+
+  log "${name} -> expected failure confirmed (http=${http_code}, code=${actual_code})"
+}
+
+build_payload() {
+  python3 - "$1" "$2" "$3" "$4" "$5" <<'PY'
+import json
+import sys
+
+interface, ip, prefix, gateway, dns_csv = sys.argv[1:6]
+dns = [item.strip() for item in dns_csv.split(',') if item.strip()]
+payload = {
+    "interface": interface,
+    "ip": ip,
+    "prefix": int(prefix),
+    "gateway": gateway,
+    "dns": dns,
+}
+print(json.dumps(payload, ensure_ascii=False))
+PY
+}
+
+log "discover interface"
+result="$(request GET "/api/network/interfaces" "$PASSWORD")"
+http_code="$(printf '%s\n' "$result" | read_http_code)"
+body="$(printf '%s\n' "$result" | read_body)"
+
+if [[ "$http_code" != "200" ]]; then
+  echo "Failed to query interfaces: http=${http_code} body=${body}" >&2
+  exit 1
+fi
+
+if [[ -z "$INTERFACE" ]]; then
+  INTERFACE="$(json_get "$body" "data.suggested_target_interface")"
+fi
+
+if [[ -z "$INTERFACE" || "$INTERFACE" == "None" ]]; then
+  echo "Could not determine target interface. Pass --interface explicitly." >&2
+  exit 1
+fi
+
+log "target interface=${INTERFACE}"
+
+log "wrong password should be rejected"
+result="$(request GET "/api/health" "wrong-password")"
+expect_failure "wrong password health" "401" "1002" "$result"
+
+log "missing interface should fail validate"
+payload="$(build_payload "not-a-real-iface" "192.168.10.20" "24" "192.168.10.1" "8.8.8.8")"
+result="$(request POST "/api/network/validate" "$PASSWORD" "$payload")"
+expect_failure "invalid interface validate" "400" "3001" "$result"
+
+log "invalid ip should fail validate"
+payload="$(build_payload "$INTERFACE" "999.999.1.1" "24" "192.168.10.1" "8.8.8.8")"
+result="$(request POST "/api/network/validate" "$PASSWORD" "$payload")"
+expect_failure "invalid ip validate" "400" "3001" "$result"
+
+log "gateway outside subnet should fail validate"
+payload="$(build_payload "$INTERFACE" "192.168.10.20" "24" "10.0.0.1" "8.8.8.8")"
+result="$(request POST "/api/network/validate" "$PASSWORD" "$payload")"
+expect_failure "gateway subnet validate" "400" "3001" "$result"
+
+log "invalid dns should fail validate"
+payload="$(build_payload "$INTERFACE" "192.168.10.20" "24" "192.168.10.1" "8.8.8.8,not-an-ip")"
+result="$(request POST "/api/network/validate" "$PASSWORD" "$payload")"
+expect_failure "invalid dns validate" "400" "3001" "$result"
+
+log "negative checks completed"

+ 331 - 0
scripts/linux-smoke-test.sh

@@ -0,0 +1,331 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+HOST="169.254.100.2"
+PORT="48888"
+PASSWORD="Dt123$"
+INTERFACE=""
+IP=""
+PREFIX=""
+GATEWAY=""
+DNS_CSV=""
+DO_APPLY=0
+
+usage() {
+  cat <<'EOF'
+Usage:
+  linux-smoke-test.sh [options]
+
+Default behavior:
+  Runs read-only smoke checks against the agent:
+  - GET /api/health
+  - GET /api/device/info
+  - GET /api/network/interfaces
+  - GET /api/network/config?interface=...
+
+Validate only:
+  linux-smoke-test.sh --interface ens33 --ip 192.168.10.20 --prefix 24 --gateway 192.168.10.1 --dns 8.8.8.8,1.1.1.1
+
+Apply and poll task:
+  linux-smoke-test.sh --interface ens33 --ip 192.168.10.20 --prefix 24 --gateway 192.168.10.1 --dns 8.8.8.8,1.1.1.1 --apply
+
+Options:
+  --host <ip>           Agent host, default: 169.254.100.2
+  --port <port>         Agent port, default: 48888
+  --password <value>    Admin password, default: Dt123$
+  --interface <name>    Target Linux interface, e.g. ens33
+  --ip <ipv4>           IPv4 address for validate/apply
+  --prefix <cidr>       Prefix length for validate/apply
+  --gateway <ipv4>      Optional gateway
+  --dns <csv>           Optional DNS CSV, e.g. 8.8.8.8,1.1.1.1
+  --apply               Actually call /api/network/apply after validate
+  --help                Show this help
+
+Notes:
+  - Without --apply, the script never changes netplan.
+  - With --apply, the agent will rewrite netplan YAML and run netplan apply.
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --host)
+      HOST="$2"
+      shift 2
+      ;;
+    --port)
+      PORT="$2"
+      shift 2
+      ;;
+    --password)
+      PASSWORD="$2"
+      shift 2
+      ;;
+    --interface)
+      INTERFACE="$2"
+      shift 2
+      ;;
+    --ip)
+      IP="$2"
+      shift 2
+      ;;
+    --prefix)
+      PREFIX="$2"
+      shift 2
+      ;;
+    --gateway)
+      GATEWAY="$2"
+      shift 2
+      ;;
+    --dns)
+      DNS_CSV="$2"
+      shift 2
+      ;;
+    --apply)
+      DO_APPLY=1
+      shift
+      ;;
+    --help|-h)
+      usage
+      exit 0
+      ;;
+    *)
+      echo "Unknown argument: $1" >&2
+      usage >&2
+      exit 2
+      ;;
+  esac
+done
+
+for cmd in curl python3; do
+  if ! command -v "$cmd" >/dev/null 2>&1; then
+    echo "Missing required command: $cmd" >&2
+    exit 2
+  fi
+done
+
+BASE_URL="http://${HOST}:${PORT}"
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+log() {
+  printf '[quickip-smoke] %s\n' "$*"
+}
+
+request() {
+  local method="$1"
+  local path="$2"
+  local payload="${3:-}"
+  local body_file="$TMP_DIR/body.json"
+  local http_code
+  : > "$body_file"
+
+  if [[ -n "$payload" ]]; then
+    http_code="$(curl -sS -o "$body_file" -w "%{http_code}" -X "$method" \
+      -H "X-Admin-Password: ${PASSWORD}" \
+      -H "Content-Type: application/json" \
+      --data "$payload" \
+      --connect-timeout 3 \
+      --max-time 15 \
+      "${BASE_URL}${path}" || true)"
+  else
+    http_code="$(curl -sS -o "$body_file" -w "%{http_code}" -X "$method" \
+      -H "X-Admin-Password: ${PASSWORD}" \
+      --connect-timeout 3 \
+      --max-time 15 \
+      "${BASE_URL}${path}" || true)"
+  fi
+
+  printf '%s\n' "$http_code"
+  cat "$body_file"
+}
+
+read_http_code() {
+  sed -n '1p'
+}
+
+read_body() {
+  sed '1d'
+}
+
+assert_api_ok() {
+  local op="$1"
+  local http_code="$2"
+  local body="$3"
+  BODY_JSON="$body" python3 - "$op" "$http_code" <<'PY'
+import json
+import os
+import sys
+
+op = sys.argv[1]
+http_code = int(sys.argv[2])
+body = os.environ["BODY_JSON"]
+
+if not (200 <= http_code < 300):
+    print(f"{op} failed: http={http_code} body={body}", file=sys.stderr)
+    sys.exit(1)
+
+try:
+    data = json.loads(body)
+except json.JSONDecodeError as exc:
+    print(f"{op} failed: invalid json: {exc}: {body}", file=sys.stderr)
+    sys.exit(1)
+
+if data.get("code") != 0:
+    print(f"{op} failed: code={data.get('code')} message={data.get('message')} body={body}", file=sys.stderr)
+    sys.exit(1)
+PY
+}
+
+json_get() {
+  local body="$1"
+  local expr="$2"
+  BODY_JSON="$body" python3 - "$expr" <<'PY'
+import json
+import os
+import sys
+
+expr = sys.argv[1]
+data = json.loads(os.environ["BODY_JSON"])
+value = data
+for part in expr.split('.'):
+    if part:
+        value = value[part]
+if isinstance(value, list):
+    import json as _json
+    print(_json.dumps(value, ensure_ascii=False))
+else:
+    print(value)
+PY
+}
+
+build_payload() {
+  python3 - "$INTERFACE" "$IP" "$PREFIX" "$GATEWAY" "$DNS_CSV" <<'PY'
+import json
+import sys
+
+interface, ip, prefix, gateway, dns_csv = sys.argv[1:6]
+dns = [item.strip() for item in dns_csv.split(',') if item.strip()]
+payload = {
+    "interface": interface,
+    "ip": ip,
+    "prefix": int(prefix),
+    "gateway": gateway,
+    "dns": dns,
+}
+print(json.dumps(payload, ensure_ascii=False))
+PY
+}
+
+poll_task() {
+  local task_id="$1"
+  local max_attempts=20
+  local attempt=1
+  local connection_failures=0
+
+  while (( attempt <= max_attempts )); do
+    local result http_code body status step detail rollback
+    result="$(request GET "/api/tasks/${task_id}")"
+    http_code="$(printf '%s\n' "$result" | read_http_code)"
+    body="$(printf '%s\n' "$result" | read_body)"
+
+    if [[ "$http_code" == "000" || "$http_code" == "0" || -z "$http_code" ]]; then
+      connection_failures=$((connection_failures + 1))
+      log "task=${task_id} poll connection failed (${connection_failures}), retrying"
+      sleep 1
+      attempt=$((attempt + 1))
+      continue
+    fi
+
+    connection_failures=0
+    assert_api_ok "get task ${task_id}" "$http_code" "$body"
+
+    status="$(json_get "$body" "data.status")"
+    step="$(json_get "$body" "data.step")"
+    detail="$(json_get "$body" "data.detail")"
+    rollback="$(json_get "$body" "data.rollback")"
+    log "task=${task_id} status=${status} step=${step} rollback=${rollback} detail=${detail}"
+
+    case "$status" in
+      success)
+        return 0
+        ;;
+      failed|rolled_back)
+        return 1
+        ;;
+    esac
+
+    sleep 1
+    attempt=$((attempt + 1))
+  done
+
+  echo "Timed out waiting for task: ${task_id}" >&2
+  return 1
+}
+
+log "health"
+result="$(request GET "/api/health")"
+http_code="$(printf '%s\n' "$result" | read_http_code)"
+body="$(printf '%s\n' "$result" | read_body)"
+assert_api_ok "health" "$http_code" "$body"
+log "agent_version=$(json_get "$body" "data.agent_version")"
+
+log "device info"
+result="$(request GET "/api/device/info")"
+http_code="$(printf '%s\n' "$result" | read_http_code)"
+body="$(printf '%s\n' "$result" | read_body)"
+assert_api_ok "device info" "$http_code" "$body"
+log "device_id=$(json_get "$body" "data.device_id") hostname=$(json_get "$body" "data.hostname")"
+
+log "interfaces"
+result="$(request GET "/api/network/interfaces")"
+http_code="$(printf '%s\n' "$result" | read_http_code)"
+body="$(printf '%s\n' "$result" | read_body)"
+assert_api_ok "interfaces" "$http_code" "$body"
+
+if [[ -z "$INTERFACE" ]]; then
+  INTERFACE="$(json_get "$body" "data.suggested_target_interface")"
+fi
+
+if [[ -z "$INTERFACE" || "$INTERFACE" == "None" ]]; then
+  echo "Could not determine target interface. Pass --interface explicitly." >&2
+  exit 1
+fi
+
+log "target interface=${INTERFACE} management=$(json_get "$body" "data.management_interface")"
+
+log "read current config"
+result="$(request GET "/api/network/config?interface=${INTERFACE}")"
+http_code="$(printf '%s\n' "$result" | read_http_code)"
+body="$(printf '%s\n' "$result" | read_body)"
+assert_api_ok "config ${INTERFACE}" "$http_code" "$body"
+log "current ip=$(json_get "$body" "data.ip") prefix=$(json_get "$body" "data.prefix") gateway=$(json_get "$body" "data.gateway")"
+
+if [[ -n "$IP" || -n "$PREFIX" || -n "$GATEWAY" || -n "$DNS_CSV" || "$DO_APPLY" -eq 1 ]]; then
+  if [[ -z "$IP" || -z "$PREFIX" ]]; then
+    echo "--ip and --prefix are required for validate/apply" >&2
+    exit 2
+  fi
+
+  payload="$(build_payload)"
+  log "validate payload=${payload}"
+  result="$(request POST "/api/network/validate" "$payload")"
+  http_code="$(printf '%s\n' "$result" | read_http_code)"
+  body="$(printf '%s\n' "$result" | read_body)"
+  assert_api_ok "validate" "$http_code" "$body"
+  log "validate passed"
+
+  if (( DO_APPLY == 1 )); then
+    log "apply"
+    result="$(request POST "/api/network/apply" "$payload")"
+    http_code="$(printf '%s\n' "$result" | read_http_code)"
+    body="$(printf '%s\n' "$result" | read_body)"
+    assert_api_ok "apply" "$http_code" "$body"
+    task_id="$(json_get "$body" "data.task_id")"
+    log "apply submitted task_id=${task_id}"
+    poll_task "$task_id"
+    log "apply task completed successfully"
+  fi
+else
+  log "read-only smoke checks completed"
+fi

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

@@ -102,6 +102,7 @@
                     <RowDefinition Height="Auto" />
                     <RowDefinition Height="Auto" />
                     <RowDefinition Height="Auto" />
+                    <RowDefinition Height="Auto" />
                 </Grid.RowDefinitions>
 
                 <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="新配置" />
@@ -138,6 +139,35 @@
                 <Border Grid.Row="4" Margin="0,12,0,0" Padding="10" Background="#ECFDF5" CornerRadius="10">
                     <TextBlock x:Name="ApplyTaskStatusTextBlock" FontSize="12" Foreground="#065F46" TextWrapping="Wrap" Text="尚未提交配置任务。" />
                 </Border>
+
+                <Border Grid.Row="5" Margin="0,12,0,0" Padding="12" Background="#FEF2F2" CornerRadius="10">
+                    <Grid>
+                        <Grid.ColumnDefinitions>
+                            <ColumnDefinition Width="*" />
+                            <ColumnDefinition Width="Auto" />
+                        </Grid.ColumnDefinitions>
+
+                        <TextBlock VerticalAlignment="Center"
+                                   FontSize="12"
+                                   Foreground="#991B1B"
+                                   TextWrapping="Wrap"
+                                   Text="系统动作会立即影响远端设备。重启或关机后,当前连接可能马上断开。" />
+
+                        <StackPanel Grid.Column="1" Margin="12,0,0,0" Orientation="Horizontal">
+                            <Button x:Name="RebootButton"
+                                    MinHeight="36"
+                                    Padding="14,0"
+                                    Click="RebootButton_OnClick"
+                                    Content="重启设备" />
+                            <Button x:Name="ShutdownButton"
+                                    Margin="10,0,0,0"
+                                    MinHeight="36"
+                                    Padding="14,0"
+                                    Click="ShutdownButton_OnClick"
+                                    Content="关闭设备" />
+                        </StackPanel>
+                    </Grid>
+                </Border>
             </Grid>
         </Border>
     </Grid>

+ 43 - 1
windows/QuickIP.Client/DeviceDetailsWindow.xaml.cs

@@ -195,16 +195,25 @@ public partial class DeviceDetailsWindow : Window
 
     private async Task PollTaskAsync(string taskId)
     {
-        for (var i = 0; i < 10; i++)
+        var transientFailureCount = 0;
+        for (var i = 0; i < 20; i++)
         {
             await Task.Delay(1000);
             var result = await _agentApiService.GetTaskAsync(_baseAddress, _password, _localIPv4, taskId);
             if (!result.Success || result.Data is null)
             {
+                if (result.StatusCode is null)
+                {
+                    transientFailureCount++;
+                    ApplyTaskStatusTextBlock.Text = $"任务 {taskId} 轮询中,检测到短暂断连,正在重试({transientFailureCount})。";
+                    continue;
+                }
+
                 ApplyTaskStatusTextBlock.Text = $"读取任务状态失败:{result.Message}";
                 return;
             }
 
+            transientFailureCount = 0;
             var task = result.Data;
             ApplyTaskStatusTextBlock.Text = $"任务 {task.TaskId} / {task.Status} / {task.Step} / {task.Detail}";
             if (task.Status is "success" or "failed" or "rolled_back")
@@ -221,6 +230,39 @@ public partial class DeviceDetailsWindow : Window
         ApplyTaskStatusTextBlock.Text = $"任务 {taskId} 轮询超时,请稍后手动刷新。";
     }
 
+    private async void RebootButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        await ExecuteSystemActionAsync(
+            "重启设备",
+            "设备将立即重启,当前窗口和连接可能马上中断。是否继续?",
+            () => _agentApiService.RebootAsync(_baseAddress, _password, _localIPv4));
+    }
+
+    private async void ShutdownButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        await ExecuteSystemActionAsync(
+            "关闭设备",
+            "设备将立即关机,当前窗口和连接可能马上中断。是否继续?",
+            () => _agentApiService.ShutdownAsync(_baseAddress, _password, _localIPv4));
+    }
+
+    private async Task ExecuteSystemActionAsync(string title, string confirmMessage, Func<Task<ApiCallResult<RemoteSystemActionResponse>>> action)
+    {
+        if (MessageBox.Show(this, confirmMessage, title, MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK)
+        {
+            return;
+        }
+
+        var result = await action();
+        if (!result.Success || result.Data is null)
+        {
+            ApplyTaskStatusTextBlock.Text = $"{title}失败:{result.Message}";
+            return;
+        }
+
+        ApplyTaskStatusTextBlock.Text = $"{title}任务已提交:{result.Data.TaskId}。命令已发出,设备可能立即断开。";
+    }
+
     private RemoteInterfaceConfig? BuildConfigRequest(string interfaceName)
     {
         if (string.IsNullOrWhiteSpace(NewIpTextBox.Text))

+ 10 - 1
windows/QuickIP.Client/MainWindow.xaml.cs

@@ -484,16 +484,25 @@ public partial class MainWindow : Window
 
     private async Task PollTaskAsync(string taskId)
     {
-        for (var i = 0; i < 10; i++)
+        var transientFailureCount = 0;
+        for (var i = 0; i < 20; i++)
         {
             await Task.Delay(1000);
             var result = await _agentApiService.GetTaskAsync(_connectedBaseAddress, GetCurrentPassword(), _connectedLocalIPv4, taskId);
             if (!result.Success || result.Data is null)
             {
+                if (result.StatusCode is null)
+                {
+                    transientFailureCount++;
+                    ApplyTaskStatusTextBlock.Text = $"任务 {taskId} 轮询中,检测到短暂断连,正在重试({transientFailureCount})。";
+                    continue;
+                }
+
                 ApplyTaskStatusTextBlock.Text = $"读取任务状态失败:{result.Message}";
                 return;
             }
 
+            transientFailureCount = 0;
             var task = result.Data;
             ApplyTaskStatusTextBlock.Text = $"任务 {task.TaskId} / {task.Status} / {task.Step} / {task.Detail}";
             if (task.Status is "success" or "failed" or "rolled_back")

+ 12 - 0
windows/QuickIP.Client/Models/RemoteSystemActionResponse.cs

@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace QuickIP.Client.Models;
+
+public sealed class RemoteSystemActionResponse
+{
+    [JsonPropertyName("action")]
+    public string Action { get; init; } = string.Empty;
+
+    [JsonPropertyName("task_id")]
+    public string TaskId { get; init; } = string.Empty;
+}

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

@@ -222,6 +222,16 @@ public sealed class AgentApiService
         }
     }
 
+    public Task<ApiCallResult<RemoteSystemActionResponse>> RebootAsync(string baseAddress, string password, string localIPv4, CancellationToken cancellationToken = default)
+    {
+        return PostSystemActionAsync(baseAddress, password, localIPv4, "/api/system/reboot", cancellationToken);
+    }
+
+    public Task<ApiCallResult<RemoteSystemActionResponse>> ShutdownAsync(string baseAddress, string password, string localIPv4, CancellationToken cancellationToken = default)
+    {
+        return PostSystemActionAsync(baseAddress, password, localIPv4, "/api/system/shutdown", cancellationToken);
+    }
+
     private HttpClient CreateClient(string baseAddress, string password, string localIPv4)
     {
         var handler = new SocketsHttpHandler();
@@ -241,6 +251,33 @@ public sealed class AgentApiService
         return client;
     }
 
+    private async Task<ApiCallResult<RemoteSystemActionResponse>> PostSystemActionAsync(string baseAddress, string password, string localIPv4, string path, CancellationToken cancellationToken)
+    {
+        try
+        {
+            using var client = CreateClient(baseAddress, password, localIPv4);
+            using var response = await client.PostAsync(path, content: null, cancellationToken);
+            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+            var wrapper = await JsonSerializer.DeserializeAsync<ApiEnvelope<RemoteSystemActionResponse>>(stream, _jsonOptions, cancellationToken);
+
+            return new ApiCallResult<RemoteSystemActionResponse>
+            {
+                Success = response.IsSuccessStatusCode && wrapper?.Data is not null,
+                StatusCode = (int)response.StatusCode,
+                Message = wrapper?.Message ?? (response.IsSuccessStatusCode ? "系统任务已提交" : $"提交失败,HTTP 状态码 {(int)response.StatusCode}。"),
+                Data = wrapper?.Data,
+            };
+        }
+        catch (Exception ex)
+        {
+            return new ApiCallResult<RemoteSystemActionResponse>
+            {
+                Success = false,
+                Message = ex.Message,
+            };
+        }
+    }
+
     private sealed class ApiEnvelope<T>
     {
         public int Code { get; set; }

+ 6 - 0
方案设计.md

@@ -46,6 +46,12 @@
    - 联调前置条件
    - 开发里程碑
 
+8. `docs/08-构建与编译.md`
+   - Windows 客户端编译命令
+   - Agent Windows/Linux 编译命令
+   - 一键编译脚本用法
+   - 常见编译问题
+
 ## 当前约束摘要
 
 1. Linux 发行版:`Ubuntu 24`