diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3e4fcc..373eb75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,12 +116,15 @@ jobs: fi cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}" + cp "${ARCHIVE_NAME}.tar.gz" "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" - name: Upload to GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.VERSION }} - files: backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz + files: | + backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz + backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz generate_release_notes: true # ─── Job 3: Docker 多架构 → Docker Hub ─── diff --git a/README.md b/README.md index c344eee..51d5474 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-lin tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh ``` +For ARM64 hosts, use `backupx-linux-arm64.tar.gz`. The archive contains `backupx`, `web/`, `config.example.yaml`, and `install.sh`; run `install.sh` from the extracted directory. + Open `http://your-server:8340`, create the admin account, then follow the [5-minute Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start). ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index b2c5363..661a9b0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -62,6 +62,8 @@ curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-lin tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh ``` +ARM64 主机请下载 `backupx-linux-arm64.tar.gz`。预编译包内包含 `backupx`、`web/`、`config.example.yaml` 和 `install.sh`,请在解压后的目录内执行 `install.sh`。 + 打开 `http://your-server:8340`,创建管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份。 ## 文档 diff --git a/deploy/install.sh b/deploy/install.sh index 6f0a5f5..99ff81c 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,17 +1,25 @@ #!/bin/sh set -eu -PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) PREFIX="${PREFIX:-/opt/backupx}" ETC_DIR="${ETC_DIR:-/etc/backupx}" SERVICE_NAME="backupx" APP_USER="backupx" APP_GROUP="backupx" -BIN_SOURCE="${BIN_SOURCE:-$PROJECT_ROOT/server/backupx}" -WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}" -CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}" +if [ -f "$SCRIPT_DIR/backupx" ] && [ -d "$SCRIPT_DIR/web" ]; then + BIN_SOURCE="${BIN_SOURCE:-$SCRIPT_DIR/backupx}" + WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}" + CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}" + NGINX_SOURCE="${NGINX_SOURCE:-$SCRIPT_DIR/nginx.conf}" +else + BIN_SOURCE="${BIN_SOURCE:-$PROJECT_ROOT/server/backupx}" + WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}" + CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}" + NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}" +fi SERVICE_SOURCE="${SERVICE_SOURCE:-$PROJECT_ROOT/deploy/backupx.service}" -NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}" if [ "$(id -u)" -ne 0 ]; then echo "请使用 root 或 sudo 执行安装脚本。" >&2 @@ -20,13 +28,20 @@ fi if [ ! -f "$BIN_SOURCE" ]; then echo "未找到后端二进制:$BIN_SOURCE" >&2 - echo "请先执行:cd \"$PROJECT_ROOT/server\" && go build -o backupx ./cmd/backupx" >&2 + echo "源码树安装请先执行:cd \"$PROJECT_ROOT/server\" && go build -o backupx ./cmd/backupx" >&2 + echo "发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。" >&2 exit 1 fi if [ ! -d "$WEB_SOURCE" ]; then echo "未找到前端构建产物:$WEB_SOURCE" >&2 - echo "请先执行:cd \"$PROJECT_ROOT/web\" && npm run build" >&2 + echo "源码树安装请先执行:cd \"$PROJECT_ROOT/web\" && npm run build" >&2 + echo "发布包安装请确认当前目录包含 ./web。" >&2 + exit 1 +fi + +if [ ! -f "$CONFIG_TEMPLATE" ]; then + echo "未找到配置模板:$CONFIG_TEMPLATE" >&2 exit 1 fi @@ -47,11 +62,34 @@ if [ ! -f "$ETC_DIR/config.yaml" ]; then install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml" fi -install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service" +if [ -f "$SERVICE_SOURCE" ]; then + install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service" +else + cat > "/etc/systemd/system/$SERVICE_NAME.service" </dev/null 2>&1; then nginx -t 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/deploy_install_test.go b/server/internal/installscript/deploy_install_test.go new file mode 100644 index 0000000..4c0507a --- /dev/null +++ b/server/internal/installscript/deploy_install_test.go @@ -0,0 +1,41 @@ +package installscript + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestDeployInstallScriptSyntax(t *testing.T) { + scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh") + cmd := exec.Command("sh", "-n", scriptPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install.sh syntax invalid: %v\n%s", err, output) + } +} + +func TestDeployInstallScriptSupportsReleasePackageLayout(t *testing.T) { + scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh") + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + script := string(data) + for _, want := range []string{ + `SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)`, + `if [ -f "$SCRIPT_DIR/backupx" ] && [ -d "$SCRIPT_DIR/web" ]; then`, + `BIN_SOURCE="${BIN_SOURCE:-$SCRIPT_DIR/backupx}"`, + `WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}"`, + `CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"`, + `发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。`, + `cat > "/etc/systemd/system/$SERVICE_NAME.service" </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}}