Compare commits

...

10 Commits

Author SHA1 Message Date
Awuqing
5963edf1c7 fix(Install): 修复发布包安装脚本路径 2026-05-01 17:30:11 +08:00
Awuqing
5d02ae7ee5 Merge remote-tracking branch 'origin/main' 2026-05-01 16:46:23 +08:00
Awuqing
81a239d3d5 feat(Agent): 修复裸机安装配置 2026-05-01 16:41:10 +08:00
Awuqing
91d26bb92a fix: respect local timezone for scheduler 2026-05-01 15:27:59 +08:00
Awuqing
1e386e1205 fix: route sms otp through webhook notifier 2026-04-25 22:09:26 +08:00
Awuqing
6e7a884c64 fix: annotate validated sms webhook request 2026-04-25 21:57:52 +08:00
Awuqing
0b2263086f fix: harden sms webhook target validation 2026-04-25 21:50:20 +08:00
Awuqing
2f494818cf fix: store trusted device token in httponly cookie 2026-04-25 21:36:08 +08:00
Awuqing
7dfd12254b feat: add complete MFA support 2026-04-25 21:14:39 +08:00
Awuqing
2997e971a6 docs: add community and sponsors pages with dynamic GitHub contributor integration 2026-04-25 18:50:54 +08:00
15 changed files with 233 additions and 85 deletions

View File

@@ -116,12 +116,15 @@ jobs:
fi fi
cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true
tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}" 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 - name: Upload to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.VERSION }} 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 generate_release_notes: true
# ─── Job 3: Docker 多架构 → Docker Hub ─── # ─── Job 3: Docker 多架构 → Docker Hub ───

View File

@@ -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 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). 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 ## Documentation

View File

@@ -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 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) 完成首次备份。 打开 `http://your-server:8340`,创建管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份。
## 文档 ## 文档

View File

@@ -1,17 +1,25 @@
#!/bin/sh #!/bin/sh
set -eu 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}" PREFIX="${PREFIX:-/opt/backupx}"
ETC_DIR="${ETC_DIR:-/etc/backupx}" ETC_DIR="${ETC_DIR:-/etc/backupx}"
SERVICE_NAME="backupx" SERVICE_NAME="backupx"
APP_USER="backupx" APP_USER="backupx"
APP_GROUP="backupx" APP_GROUP="backupx"
BIN_SOURCE="${BIN_SOURCE:-$PROJECT_ROOT/server/backupx}" if [ -f "$SCRIPT_DIR/backupx" ] && [ -d "$SCRIPT_DIR/web" ]; then
WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}" BIN_SOURCE="${BIN_SOURCE:-$SCRIPT_DIR/backupx}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}" 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}" SERVICE_SOURCE="${SERVICE_SOURCE:-$PROJECT_ROOT/deploy/backupx.service}"
NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}"
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 或 sudo 执行安装脚本。" >&2 echo "请使用 root 或 sudo 执行安装脚本。" >&2
@@ -20,13 +28,20 @@ fi
if [ ! -f "$BIN_SOURCE" ]; then if [ ! -f "$BIN_SOURCE" ]; then
echo "未找到后端二进制:$BIN_SOURCE" >&2 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 exit 1
fi fi
if [ ! -d "$WEB_SOURCE" ]; then if [ ! -d "$WEB_SOURCE" ]; then
echo "未找到前端构建产物:$WEB_SOURCE" >&2 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 exit 1
fi fi
@@ -47,11 +62,34 @@ if [ ! -f "$ETC_DIR/config.yaml" ]; then
install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml" install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
fi 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" <<UNIT
[Unit]
Description=BackupX API Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$APP_USER
Group=$APP_GROUP
WorkingDirectory=$PREFIX
ExecStart=$PREFIX/bin/backupx -config $ETC_DIR/config.yaml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
UNIT
fi
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME" systemctl enable --now "$SERVICE_NAME"
if [ -d "/etc/nginx/conf.d" ]; then if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then
install -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf" install -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf"
if command -v nginx >/dev/null 2>&1; then if command -v nginx >/dev/null 2>&1; then
nginx -t nginx -t

View File

@@ -26,7 +26,7 @@ type Config struct {
HeartbeatInterval string `yaml:"heartbeatInterval"` HeartbeatInterval string `yaml:"heartbeatInterval"`
// PollInterval 命令轮询间隔,默认 5s // PollInterval 命令轮询间隔,默认 5s
PollInterval string `yaml:"pollInterval"` PollInterval string `yaml:"pollInterval"`
// TempDir 备份临时目录,默认 /tmp/backupx-agent // TempDir 备份临时目录,默认 /var/lib/backupx-agent/tmp
TempDir string `yaml:"tempDir"` TempDir string `yaml:"tempDir"`
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验 // InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"` InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
@@ -98,7 +98,7 @@ func applyConfigDefaults(cfg *Config) (*Config, error) {
cfg.PollInterval = "5s" cfg.PollInterval = "5s"
} }
if cfg.TempDir == "" { if cfg.TempDir == "" {
cfg.TempDir = "/tmp/backupx-agent" cfg.TempDir = "/var/lib/backupx-agent/tmp"
} }
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/") cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
return cfg, nil return cfg, nil

View File

@@ -50,7 +50,7 @@ func TestLoadConfigDefaults(t *testing.T) {
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" { if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
t.Errorf("default intervals not applied: %+v", cfg) 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) t.Errorf("default tempdir: %q", cfg.TempDir)
} }
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -19,10 +20,10 @@ import (
// Executor 负责在 Agent 本地执行命令。 // Executor 负责在 Agent 本地执行命令。
type Executor struct { type Executor struct {
client *MasterClient client *MasterClient
tempDir string tempDir string
backupRegistry *backup.Registry backupRegistry *backup.Registry
storageRegistry *storage.Registry storageRegistry *storage.Registry
} }
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。 // NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
@@ -59,6 +60,11 @@ func NewExecutor(client *MasterClient, tempDir string) *Executor {
// 注意Agent 当前不支持 Encrypt=true加密密钥不下发到 Agent避免密钥扩散 // 注意Agent 当前不支持 Encrypt=true加密密钥不下发到 Agent避免密钥扩散
// 遇到启用加密的任务会向 Master 上报失败并返回错误。 // 遇到启用加密的任务会向 Master 上报失败并返回错误。
func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error { 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) 拉取任务规格 // 1) 拉取任务规格
spec, err := e.client.GetTaskSpec(ctx, taskID) spec, err := e.client.GetTaskSpec(ctx, taskID)
if err != nil { if err != nil {
@@ -74,10 +80,6 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
// 2) 构造 backup.TaskSpec 并找对应 runner // 2) 构造 backup.TaskSpec 并找对应 runner
startedAt := time.Now().UTC() 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) backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir)
runner, err := e.backupRegistry.Runner(backupSpec.Type) runner, err := e.backupRegistry.Runner(backupSpec.Type)
if err != nil { if err != nil {
@@ -184,22 +186,8 @@ func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg s
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。 // buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec { func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
var sourcePaths []string sourcePaths := parseStringListField(spec.SourcePaths)
if strings.TrimSpace(spec.SourcePaths) != "" { excludes := parseStringListField(spec.ExcludePatterns)
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)
}
}
}
return backup.TaskSpec{ return backup.TaskSpec{
ID: spec.TaskID, ID: spec.TaskID,
Name: spec.Name, 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 记录。 // recordLogger 把 runner 日志回传到 Master 记录。
// 实现 backup.LogWriter每条日志追加到 record.log_content。 // 实现 backup.LogWriter每条日志追加到 record.log_content。
type recordLogger struct { type recordLogger struct {
@@ -240,8 +259,8 @@ func (l *recordLogger) WriteLine(message string) {
// restoreLogger 把 runner 日志回传到 Master 恢复记录。 // restoreLogger 把 runner 日志回传到 Master 恢复记录。
type restoreLogger struct { type restoreLogger struct {
ctx context.Context ctx context.Context
client *MasterClient client *MasterClient
restoreID uint restoreID uint
} }
@@ -270,6 +289,11 @@ func (e *Executor) DeleteStorageObject(ctx context.Context, targetType string, t
// - 执行backup.Registry.Runner(spec.Type).Restore // - 执行backup.Registry.Runner(spec.Type).Restore
// - 上报:通过 UpdateRestorestatus/logAppend // - 上报:通过 UpdateRestorestatus/logAppend
func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) error { 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) spec, err := e.client.GetRestoreSpec(ctx, restoreRecordID)
if err != nil { if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("拉取恢复规格失败: %v", err)) 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)) 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-*") tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*")
if err != nil { if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err)) e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err))

View File

@@ -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)
}
}

View File

@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
) )
// DirEntry Agent 返回给 Master 的目录项。 // DirEntry Agent 返回给 Master 的目录项。
@@ -17,8 +18,8 @@ type DirEntry struct {
// listLocalDir 列出 Agent 所在机器的指定路径。 // listLocalDir 列出 Agent 所在机器的指定路径。
func listLocalDir(path string) ([]DirEntry, error) { func listLocalDir(path string) ([]DirEntry, error) {
cleaned := filepath.Clean(path) cleaned := filepath.Clean(strings.TrimSpace(path))
if cleaned == "" { if strings.TrimSpace(path) == "" || cleaned == "." {
cleaned = "/" cleaned = "/"
} }
entries, err := os.ReadDir(cleaned) entries, err := os.ReadDir(cleaned)

View File

@@ -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) { func TestSplitCommaOrNewline(t *testing.T) {
cases := []struct { cases := []struct {
in string in string

View File

@@ -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" <<UNIT`,
`if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then`,
} {
if !strings.Contains(script, want) {
t.Fatalf("install.sh missing %q", want)
}
}
}

View File

@@ -37,19 +37,22 @@ func TestRenderScriptBashBootstrap(t *testing.T) {
} }
} }
func TestRenderScriptCreatesBackupXUserAndGroup(t *testing.T) { func TestRenderScriptUsesRootForBareMetalBackups(t *testing.T) {
got, err := RenderScript(testCtx) got, err := RenderScript(testCtx)
if err != nil { if err != nil {
t.Fatalf("render err: %v", err) t.Fatalf("render err: %v", err)
} }
for _, want := range []string{ for _, want := range []string{
"getent group backupx", "/var/lib/backupx-agent/tmp",
"groupadd --system backupx", "install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp",
"useradd --system --gid backupx",
"Group=backupx",
} { } {
if !strings.Contains(got, want) { if !strings.Contains(got, want) {
t.Errorf("script missing %q:\n%s", want, got) 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)
}
}
} }

View File

@@ -27,8 +27,10 @@ func TestRenderScriptSystemd(t *testing.T) {
mustContain := []string{ mustContain := []string{
"BACKUPX_AGENT_MASTER=${MASTER_URL}", "BACKUPX_AGENT_MASTER=${MASTER_URL}",
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`, `Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
"/var/lib/backupx-agent/tmp",
"systemctl daemon-reload", "systemctl daemon-reload",
"systemctl enable --now backupx-agent", "systemctl enable --now backupx-agent",
"systemctl status backupx-agent",
"X-Agent-Token: ${AGENT_TOKEN}", "X-Agent-Token: ${AGENT_TOKEN}",
"MASTER_URL=\"https://master.example.com\"", "MASTER_URL=\"https://master.example.com\"",
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"", "AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
@@ -56,6 +58,9 @@ func TestRenderScriptForeground(t *testing.T) {
if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) { if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) {
t.Errorf("foreground script missing exec line:\n%s", got) 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") { if strings.Contains(got, "systemctl daemon-reload") {
t.Errorf("foreground script should not reference systemctl:\n%s", got) 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") { if !strings.Contains(got, "docker run") {
t.Errorf("docker script missing `docker run`:\n%s", got) 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}") { if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") {
t.Errorf("docker script missing image tag reference:\n%s", got) 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"`) { if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
t.Errorf("compose missing token env:\n%s", got) 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) { func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
bad := []string{ bad := []string{
"https://example.com\" other: inject", // 含引号和空格 "https://example.com\" other: inject", // 含引号和空格
"javascript:alert(1)", // scheme 非法 "javascript:alert(1)", // scheme 非法
"https://example.com\n- privileged", // 含换行YAML 注入经典 payload "https://example.com\n- privileged", // 含换行YAML 注入经典 payload
"", // 空 "", // 空
} }
for _, u := range bad { for _, u := range bad {
ctx := testCtx ctx := testCtx
@@ -161,8 +172,8 @@ func TestDownloadBaseMapping(t *testing.T) {
func TestRenderScriptDefaultsApplied(t *testing.T) { func TestRenderScriptDefaultsApplied(t *testing.T) {
ctx := testCtx ctx := testCtx
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
ctx.DownloadBase = "" // 应被默认为 github ctx.DownloadBase = "" // 应被默认为 github
got, err := RenderScript(ctx) got, err := RenderScript(ctx)
if err != nil { if err != nil {
t.Fatalf("render err: %v", err) t.Fatalf("render err: %v", err)

View File

@@ -10,4 +10,4 @@ services:
BACKUPX_AGENT_MASTER: "{{.MasterURL}}" BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}" BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
volumes: volumes:
- /var/lib/backupx-agent:/tmp/backupx-agent - /var/lib/backupx-agent:/var/lib/backupx-agent

View File

@@ -47,30 +47,10 @@ else
fi fi
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR" tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
# 4. 安装二进制 + 用户 # 4. 安装二进制 + 数据目录
echo "[2/4] 安装到 ${INSTALL_PREFIX}" echo "[2/4] 安装到 ${INSTALL_PREFIX}"
if ! getent group backupx >/dev/null 2>&1; then install -d -m 0755 "$INSTALL_PREFIX"
if command -v groupadd >/dev/null 2>&1; then install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp
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 -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx" install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
{{end}} {{end}}
@@ -85,14 +65,11 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=backupx
Group=backupx
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}" Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" 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 Restart=on-failure
RestartSec=10s RestartSec=10s
NoNewPrivileges=true
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -111,6 +88,7 @@ for i in $(seq 1 15); do
fi fi
done done
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent" echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
echo "提示systemd 服务名是 backupx-agent可执行 systemctl status backupx-agent 查看状态。"
exit 2 exit 2
{{end}} {{end}}
@@ -119,7 +97,7 @@ exit 2
echo "[3/3] 前台启动 agentCtrl+C 退出)" echo "[3/3] 前台启动 agentCtrl+C 退出)"
export BACKUPX_AGENT_MASTER="${MASTER_URL}" export BACKUPX_AGENT_MASTER="${MASTER_URL}"
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}" 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}} {{end}}
{{if eq .Mode "docker"}} {{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 \ docker run -d --name backupx-agent --restart=unless-stopped \
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \ -e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \ -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 "awuqing/backupx:${AGENT_VERSION}" agent
echo "✓ 容器已启动" echo "✓ 容器已启动"
{{end}} {{end}}