From 81a239d3d52e4ecc1d70a7cdad83fbb838a0e4fc Mon Sep 17 00:00:00 2001 From: Awuqing <3184394176@qq.com> Date: Fri, 1 May 2026 16:41:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(Agent):=20=E4=BF=AE=E5=A4=8D=E8=A3=B8?= =?UTF-8?q?=E6=9C=BA=E5=AE=89=E8=A3=85=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/internal/agent/config.go | 4 +- server/internal/agent/config_test.go | 2 +- server/internal/agent/executor.go | 80 ++++++++++++------- server/internal/agent/executor_test.go | 34 ++++++++ server/internal/agent/fs.go | 5 +- server/internal/agent/fs_test.go | 15 ++++ server/internal/installscript/issue46_test.go | 13 +-- .../internal/installscript/renderer_test.go | 21 +++-- .../templates/agent-compose.yml.tmpl | 2 +- .../templates/agent-install.sh.tmpl | 36 ++------- 10 files changed, 137 insertions(+), 75 deletions(-) create mode 100644 server/internal/agent/executor_test.go diff --git a/server/internal/agent/config.go b/server/internal/agent/config.go index 82d4873..03b0a34 100644 --- a/server/internal/agent/config.go +++ b/server/internal/agent/config.go @@ -26,7 +26,7 @@ type Config struct { HeartbeatInterval string `yaml:"heartbeatInterval"` // PollInterval 命令轮询间隔,默认 5s PollInterval string `yaml:"pollInterval"` - // TempDir 备份临时目录,默认 /tmp/backupx-agent + // TempDir 备份临时目录,默认 /var/lib/backupx-agent/tmp TempDir string `yaml:"tempDir"` // InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验 InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"` @@ -98,7 +98,7 @@ func applyConfigDefaults(cfg *Config) (*Config, error) { cfg.PollInterval = "5s" } if cfg.TempDir == "" { - cfg.TempDir = "/tmp/backupx-agent" + cfg.TempDir = "/var/lib/backupx-agent/tmp" } cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/") return cfg, nil diff --git a/server/internal/agent/config_test.go b/server/internal/agent/config_test.go index 838a38d..f1e5292 100644 --- a/server/internal/agent/config_test.go +++ b/server/internal/agent/config_test.go @@ -50,7 +50,7 @@ func TestLoadConfigDefaults(t *testing.T) { if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" { t.Errorf("default intervals not applied: %+v", cfg) } - if cfg.TempDir != "/tmp/backupx-agent" { + if cfg.TempDir != "/var/lib/backupx-agent/tmp" { t.Errorf("default tempdir: %q", cfg.TempDir) } } diff --git a/server/internal/agent/executor.go b/server/internal/agent/executor.go index 4386aa1..ffef445 100644 --- a/server/internal/agent/executor.go +++ b/server/internal/agent/executor.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "io" "os" @@ -19,10 +20,10 @@ import ( // Executor 负责在 Agent 本地执行命令。 type Executor struct { - client *MasterClient - tempDir string - backupRegistry *backup.Registry - storageRegistry *storage.Registry + client *MasterClient + tempDir string + backupRegistry *backup.Registry + storageRegistry *storage.Registry } // NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。 @@ -59,6 +60,11 @@ func NewExecutor(client *MasterClient, tempDir string) *Executor { // 注意:Agent 当前不支持 Encrypt=true(加密密钥不下发到 Agent,避免密钥扩散)。 // 遇到启用加密的任务会向 Master 上报失败并返回错误。 func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error { + if err := e.ensureTempDir(); err != nil { + e.reportRecordFailure(ctx, recordID, err.Error()) + return err + } + // 1) 拉取任务规格 spec, err := e.client.GetTaskSpec(ctx, taskID) if err != nil { @@ -74,10 +80,6 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er // 2) 构造 backup.TaskSpec 并找对应 runner startedAt := time.Now().UTC() - if err := os.MkdirAll(e.tempDir, 0o755); err != nil { - e.reportRecordFailure(ctx, recordID, fmt.Sprintf("创建临时目录失败: %v", err)) - return err - } backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir) runner, err := e.backupRegistry.Runner(backupSpec.Type) if err != nil { @@ -184,22 +186,8 @@ func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg s // buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。 func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec { - var sourcePaths []string - if strings.TrimSpace(spec.SourcePaths) != "" { - for _, p := range strings.Split(spec.SourcePaths, "\n") { - if p = strings.TrimSpace(p); p != "" { - sourcePaths = append(sourcePaths, p) - } - } - } - var excludes []string - if strings.TrimSpace(spec.ExcludePatterns) != "" { - for _, p := range strings.Split(spec.ExcludePatterns, "\n") { - if p = strings.TrimSpace(p); p != "" { - excludes = append(excludes, p) - } - } - } + sourcePaths := parseStringListField(spec.SourcePaths) + excludes := parseStringListField(spec.ExcludePatterns) return backup.TaskSpec{ ID: spec.TaskID, Name: spec.Name, @@ -222,6 +210,37 @@ func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) ba } } +func (e *Executor) ensureTempDir() error { + if err := os.MkdirAll(e.tempDir, 0o755); err != nil { + return fmt.Errorf("create agent temp dir: %w", err) + } + return nil +} + +func parseStringListField(value string) []string { + trimmed := strings.TrimSpace(value) + if trimmed == "" || trimmed == "[]" { + return nil + } + var jsonItems []string + if err := json.Unmarshal([]byte(trimmed), &jsonItems); err == nil { + return compactStringList(jsonItems) + } + return compactStringList(strings.FieldsFunc(trimmed, func(r rune) bool { + return r == '\n' || r == '\r' + })) +} + +func compactStringList(items []string) []string { + result := make([]string, 0, len(items)) + for _, item := range items { + if trimmed := strings.TrimSpace(item); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + // recordLogger 把 runner 日志回传到 Master 记录。 // 实现 backup.LogWriter,每条日志追加到 record.log_content。 type recordLogger struct { @@ -240,8 +259,8 @@ func (l *recordLogger) WriteLine(message string) { // restoreLogger 把 runner 日志回传到 Master 恢复记录。 type restoreLogger struct { - ctx context.Context - client *MasterClient + ctx context.Context + client *MasterClient restoreID uint } @@ -270,6 +289,11 @@ func (e *Executor) DeleteStorageObject(ctx context.Context, targetType string, t // - 执行:backup.Registry.Runner(spec.Type).Restore // - 上报:通过 UpdateRestore(status/logAppend) func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) error { + if err := e.ensureTempDir(); err != nil { + e.reportRestoreFailure(ctx, restoreRecordID, err.Error()) + return err + } + spec, err := e.client.GetRestoreSpec(ctx, restoreRecordID) if err != nil { e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("拉取恢复规格失败: %v", err)) @@ -282,10 +306,6 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err } e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 开始恢复 %s (type=%s)\n", spec.TaskName, spec.Type)) - if err := os.MkdirAll(e.tempDir, 0o755); err != nil { - e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建临时目录失败: %v", err)) - return err - } tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*") if err != nil { e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err)) diff --git a/server/internal/agent/executor_test.go b/server/internal/agent/executor_test.go new file mode 100644 index 0000000..ee40adf --- /dev/null +++ b/server/internal/agent/executor_test.go @@ -0,0 +1,34 @@ +package agent + +import ( + "reflect" + "testing" + "time" +) + +func TestBuildBackupTaskSpecParsesJSONSourcePaths(t *testing.T) { + spec := &TaskSpec{ + TaskID: 7, + Name: "root-files", + Type: "file", + SourcePaths: `["/root","/etc"]`, + ExcludePatterns: `["*.log","tmp"]`, + } + + got := buildBackupTaskSpec(spec, time.Unix(0, 0), "/var/lib/backupx-agent/tmp") + + if !reflect.DeepEqual(got.SourcePaths, []string{"/root", "/etc"}) { + t.Fatalf("source paths = %#v", got.SourcePaths) + } + if !reflect.DeepEqual(got.ExcludePatterns, []string{"*.log", "tmp"}) { + t.Fatalf("exclude patterns = %#v", got.ExcludePatterns) + } +} + +func TestParseStringListFieldKeepsLegacyLineFormat(t *testing.T) { + got := parseStringListField("/root\n /etc \n") + want := []string{"/root", "/etc"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("paths = %#v, want %#v", got, want) + } +} diff --git a/server/internal/agent/fs.go b/server/internal/agent/fs.go index 135dea3..d76d990 100644 --- a/server/internal/agent/fs.go +++ b/server/internal/agent/fs.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sort" + "strings" ) // DirEntry Agent 返回给 Master 的目录项。 @@ -17,8 +18,8 @@ type DirEntry struct { // listLocalDir 列出 Agent 所在机器的指定路径。 func listLocalDir(path string) ([]DirEntry, error) { - cleaned := filepath.Clean(path) - if cleaned == "" { + cleaned := filepath.Clean(strings.TrimSpace(path)) + if strings.TrimSpace(path) == "" || cleaned == "." { cleaned = "/" } entries, err := os.ReadDir(cleaned) diff --git a/server/internal/agent/fs_test.go b/server/internal/agent/fs_test.go index fe3229b..c9093ec 100644 --- a/server/internal/agent/fs_test.go +++ b/server/internal/agent/fs_test.go @@ -36,6 +36,21 @@ func TestListLocalDir(t *testing.T) { } } +func TestListLocalDirEmptyPathUsesRoot(t *testing.T) { + entries, err := listLocalDir("") + if err != nil { + t.Fatalf("list root: %v", err) + } + if len(entries) == 0 { + t.Fatalf("expected root entries") + } + for _, entry := range entries { + if !filepath.IsAbs(entry.Path) { + t.Fatalf("entry path should be absolute: %+v", entry) + } + } +} + func TestSplitCommaOrNewline(t *testing.T) { cases := []struct { in string diff --git a/server/internal/installscript/issue46_test.go b/server/internal/installscript/issue46_test.go index 14256cf..5c0e17a 100644 --- a/server/internal/installscript/issue46_test.go +++ b/server/internal/installscript/issue46_test.go @@ -37,19 +37,22 @@ func TestRenderScriptBashBootstrap(t *testing.T) { } } -func TestRenderScriptCreatesBackupXUserAndGroup(t *testing.T) { +func TestRenderScriptUsesRootForBareMetalBackups(t *testing.T) { got, err := RenderScript(testCtx) if err != nil { t.Fatalf("render err: %v", err) } for _, want := range []string{ - "getent group backupx", - "groupadd --system backupx", - "useradd --system --gid backupx", - "Group=backupx", + "/var/lib/backupx-agent/tmp", + "install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp", } { if !strings.Contains(got, want) { t.Errorf("script missing %q:\n%s", want, got) } } + for _, forbidden := range []string{"User=backupx", "Group=backupx", "NoNewPrivileges=true"} { + if strings.Contains(got, forbidden) { + t.Errorf("script should not contain %q for bare-metal backups:\n%s", forbidden, got) + } + } } diff --git a/server/internal/installscript/renderer_test.go b/server/internal/installscript/renderer_test.go index e1d3ee5..079c648 100644 --- a/server/internal/installscript/renderer_test.go +++ b/server/internal/installscript/renderer_test.go @@ -27,8 +27,10 @@ func TestRenderScriptSystemd(t *testing.T) { mustContain := []string{ "BACKUPX_AGENT_MASTER=${MASTER_URL}", `Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`, + "/var/lib/backupx-agent/tmp", "systemctl daemon-reload", "systemctl enable --now backupx-agent", + "systemctl status backupx-agent", "X-Agent-Token: ${AGENT_TOKEN}", "MASTER_URL=\"https://master.example.com\"", "AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"", @@ -56,6 +58,9 @@ func TestRenderScriptForeground(t *testing.T) { if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) { t.Errorf("foreground script missing exec line:\n%s", got) } + if !strings.Contains(got, "/var/lib/backupx-agent/tmp") { + t.Errorf("foreground script missing dedicated temp dir:\n%s", got) + } if strings.Contains(got, "systemctl daemon-reload") { t.Errorf("foreground script should not reference systemctl:\n%s", got) } @@ -74,6 +79,9 @@ func TestRenderScriptDocker(t *testing.T) { if !strings.Contains(got, "docker run") { t.Errorf("docker script missing `docker run`:\n%s", got) } + if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") { + t.Errorf("docker script missing agent data volume:\n%s", got) + } if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") { t.Errorf("docker script missing image tag reference:\n%s", got) } @@ -95,14 +103,17 @@ func TestRenderComposeYaml(t *testing.T) { if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) { t.Errorf("compose missing token env:\n%s", got) } + if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") { + t.Errorf("compose missing agent data volume:\n%s", got) + } } func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) { bad := []string{ "https://example.com\" other: inject", // 含引号和空格 - "javascript:alert(1)", // scheme 非法 - "https://example.com\n- privileged", // 含换行,YAML 注入经典 payload - "", // 空 + "javascript:alert(1)", // scheme 非法 + "https://example.com\n- privileged", // 含换行,YAML 注入经典 payload + "", // 空 } for _, u := range bad { ctx := testCtx @@ -161,8 +172,8 @@ func TestDownloadBaseMapping(t *testing.T) { func TestRenderScriptDefaultsApplied(t *testing.T) { ctx := testCtx - ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent - ctx.DownloadBase = "" // 应被默认为 github + ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent + ctx.DownloadBase = "" // 应被默认为 github got, err := RenderScript(ctx) if err != nil { t.Fatalf("render err: %v", err) diff --git a/server/internal/installscript/templates/agent-compose.yml.tmpl b/server/internal/installscript/templates/agent-compose.yml.tmpl index 4a27919..2acad59 100644 --- a/server/internal/installscript/templates/agent-compose.yml.tmpl +++ b/server/internal/installscript/templates/agent-compose.yml.tmpl @@ -10,4 +10,4 @@ services: BACKUPX_AGENT_MASTER: "{{.MasterURL}}" BACKUPX_AGENT_TOKEN: "{{.AgentToken}}" volumes: - - /var/lib/backupx-agent:/tmp/backupx-agent + - /var/lib/backupx-agent:/var/lib/backupx-agent diff --git a/server/internal/installscript/templates/agent-install.sh.tmpl b/server/internal/installscript/templates/agent-install.sh.tmpl index dcf032f..84292f0 100644 --- a/server/internal/installscript/templates/agent-install.sh.tmpl +++ b/server/internal/installscript/templates/agent-install.sh.tmpl @@ -47,30 +47,10 @@ else fi tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR" -# 4. 安装二进制 + 用户 +# 4. 安装二进制 + 数据目录 echo "[2/4] 安装到 ${INSTALL_PREFIX}" -if ! getent group backupx >/dev/null 2>&1; then - if command -v groupadd >/dev/null 2>&1; then - groupadd --system backupx - elif command -v addgroup >/dev/null 2>&1; then - addgroup --system backupx - else - echo "需要 groupadd 或 addgroup 来创建 backupx 组" >&2 - exit 1 - fi -fi -if ! id backupx >/dev/null 2>&1; then - if command -v useradd >/dev/null 2>&1; then - useradd --system --gid backupx --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx - elif command -v adduser >/dev/null 2>&1; then - adduser --system --ingroup backupx --home "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx - else - echo "需要 useradd 或 adduser 来创建 backupx 用户" >&2 - exit 1 - fi -fi -id backupx >/dev/null 2>&1 || { echo "backupx 用户创建失败" >&2; exit 1; } -install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent +install -d -m 0755 "$INSTALL_PREFIX" +install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx" {{end}} @@ -85,14 +65,11 @@ Wants=network-online.target [Service] Type=simple -User=backupx -Group=backupx Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}" Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" -ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent +ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp Restart=on-failure RestartSec=10s -NoNewPrivileges=true [Install] WantedBy=multi-user.target @@ -111,6 +88,7 @@ for i in $(seq 1 15); do fi done echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent" +echo "提示:systemd 服务名是 backupx-agent,可执行 systemctl status backupx-agent 查看状态。" exit 2 {{end}} @@ -119,7 +97,7 @@ exit 2 echo "[3/3] 前台启动 agent(Ctrl+C 退出)" export BACKUPX_AGENT_MASTER="${MASTER_URL}" export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}" -exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent +exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent/tmp {{end}} {{if eq .Mode "docker"}} @@ -131,7 +109,7 @@ docker rm -f backupx-agent >/dev/null 2>&1 || true docker run -d --name backupx-agent --restart=unless-stopped \ -e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \ -e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \ - -v /var/lib/backupx-agent:/tmp/backupx-agent \ + -v /var/lib/backupx-agent:/var/lib/backupx-agent \ "awuqing/backupx:${AGENT_VERSION}" agent echo "✓ 容器已启动" {{end}}