feat: 优化集群部署与堡垒机接入 (#106)

支持受限网络、正向代理、私有 CA 与 SSH 堡垒机部署 Agent。

加固 Docker、systemd、Nginx、安装器、Release 校验与可信代理边界,并完善命令队列索引、前端安装向导及中英文运维文档。
This commit is contained in:
Wu Qing
2026-08-09 02:45:17 +08:00
committed by GitHub
parent 00151e466c
commit 5827074334
86 changed files with 4668 additions and 3082 deletions

View File

@@ -6,11 +6,17 @@ import (
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestDeployInstallScriptSyntax(t *testing.T) {
scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh")
cmd := exec.Command("sh", "-n", scriptPath)
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("POSIX sh is not available on this platform")
}
cmd := exec.Command(sh, "-n", scriptPath)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("install.sh syntax invalid: %v\n%s", err, output)
@@ -30,9 +36,13 @@ func TestDeployInstallScriptSupportsReleasePackageLayout(t *testing.T) {
`BIN_SOURCE="${BIN_SOURCE:-$SCRIPT_DIR/backupx}"`,
`WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}"`,
`CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"`,
`SERVICE_SOURCE_DEFAULT="$SCRIPT_DIR/backupx.service"`,
`发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。`,
`cat > "/etc/systemd/system/$SERVICE_NAME.service" <<UNIT`,
`if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then`,
`if [ "$INSTALL_NGINX" = "1" ]; then`,
`[ "$PREFIX" = "/opt/backupx" ] && [ "$ETC_DIR" = "/etc/backupx" ]`,
`validate_install_path PREFIX "$PREFIX"`,
`拒绝通过符号链接写入受管目录`,
} {
if !strings.Contains(script, want) {
t.Fatalf("install.sh missing %q", want)
@@ -53,9 +63,72 @@ func TestDeployInstallScriptSupportsSourceBuildAndVerifiesFirstSetup(t *testing.
`HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8340/api/auth/setup/status}"`,
`systemctl is-active --quiet "$SERVICE_NAME"`,
`System setup`,
`chown -R root:root "$PREFIX/bin" "$PREFIX/web"`,
`find "$PREFIX/web" -type f -exec chmod 0644`,
`chown root:"$APP_GROUP" "$ETC_DIR/config.yaml"`,
`systemctl restart "$SERVICE_NAME"`,
} {
if !strings.Contains(script, want) {
t.Fatalf("install.sh missing %q", want)
}
}
}
func TestDockerDeploymentUsesSingleUnprivilegedProcess(t *testing.T) {
root := filepath.Join("..", "..", "..")
dockerfileData, err := os.ReadFile(filepath.Join(root, "Dockerfile"))
if err != nil {
t.Fatal(err)
}
dockerfile := string(dockerfileData)
for _, want := range []string{"su-exec", "BACKUPX_SERVER_WEB_ROOT=/app/web", "HEALTHCHECK"} {
if !strings.Contains(dockerfile, want) {
t.Fatalf("Dockerfile missing %q", want)
}
}
for _, forbidden := range []string{"docker-cli", "COPY deploy/docker/nginx.conf"} {
if strings.Contains(dockerfile, forbidden) {
t.Fatalf("Dockerfile still contains %q", forbidden)
}
}
composeData, err := os.ReadFile(filepath.Join(root, "docker-compose.yml"))
if err != nil {
t.Fatal(err)
}
var compose map[string]any
if err := yaml.Unmarshal(composeData, &compose); err != nil {
t.Fatalf("docker-compose.yml is not valid YAML: %v", err)
}
composeText := string(composeData)
for _, want := range []string{"no-new-privileges:true", "cap_drop:", "cap_add:", "DAC_OVERRIDE", "SETGID", "SETUID", "/ready"} {
if !strings.Contains(composeText, want) {
t.Fatalf("docker-compose.yml missing %q", want)
}
}
if strings.Contains(composeText, "docker.sock") {
t.Fatal("docker-compose.yml must not expose the Docker socket")
}
}
func TestReleaseWorkflowPublishesChecksums(t *testing.T) {
workflowPath := filepath.Join("..", "..", "..", ".github", "workflows", "release.yml")
workflowData, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatal(err)
}
var workflow map[string]any
if err := yaml.Unmarshal(workflowData, &workflow); err != nil {
t.Fatalf("release workflow is not valid YAML: %v", err)
}
workflowText := string(workflowData)
for _, want := range []string{
`cp deploy/backupx.service "${ARCHIVE_NAME}/"`,
`sha256sum "${ARCHIVE_NAME}.tar.gz"`,
`backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256`,
} {
if !strings.Contains(workflowText, want) {
t.Fatalf("release workflow missing %q", want)
}
}
}

View File

@@ -44,7 +44,7 @@ func TestRenderScriptUsesRootForBareMetalBackups(t *testing.T) {
}
for _, want := range []string{
"/var/lib/backupx-agent/tmp",
"install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp",
"install -d -m 0700 \"$CONFIG_DIR\" /var/lib/backupx-agent /var/lib/backupx-agent/tmp",
} {
if !strings.Contains(got, want) {
t.Errorf("script missing %q:\n%s", want, got)

View File

@@ -8,6 +8,7 @@ import (
_ "embed"
"fmt"
"net/url"
"path"
"strings"
"text/template"
@@ -30,6 +31,8 @@ type Context struct {
DownloadBase string
InstallPrefix string
NodeID uint
ProxyURL string
CACertFile string
}
// DownloadBaseFor 将下载源枚举转换为具体 URL 前缀。
@@ -84,6 +87,12 @@ func RenderComposeYaml(ctx Context) (string, error) {
// 这些字段被直接写入 shell 双引号字符串和 YAML 双引号值;不做校验会带来
// 注入风险(如 MasterURL 含 `"\nCOMMAND:` 可逃逸 YAML 结构)。
func validateContext(ctx Context) error {
if ctx.Mode != model.InstallModeSystemd && ctx.Mode != model.InstallModeDocker && ctx.Mode != model.InstallModeForeground {
return fmt.Errorf("unsupported install mode %q", ctx.Mode)
}
if ctx.Arch != model.InstallArchAmd64 && ctx.Arch != model.InstallArchArm64 && ctx.Arch != model.InstallArchAuto {
return fmt.Errorf("unsupported install architecture %q", ctx.Arch)
}
if err := validateMasterURL(ctx.MasterURL); err != nil {
return err
}
@@ -93,6 +102,42 @@ func validateContext(ctx Context) error {
if err := validateAgentVersion(ctx.AgentVersion); err != nil {
return err
}
if err := validateProxyURL(ctx.ProxyURL); err != nil {
return err
}
if err := validateCACertFile(ctx.CACertFile); err != nil {
return err
}
if !path.IsAbs(ctx.InstallPrefix) || path.Clean(ctx.InstallPrefix) != ctx.InstallPrefix {
return fmt.Errorf("install prefix must be a clean absolute path without shell metacharacters")
}
for _, c := range ctx.InstallPrefix {
switch {
case c >= '0' && c <= '9':
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c == '/' || c == '.' || c == '_' || c == '-' || c == '+':
default:
return fmt.Errorf("install prefix contains illegal character %q", c)
}
}
if err := validateDownloadBase(ctx.DownloadBase); err != nil {
return err
}
return nil
}
func validateDownloadBase(raw string) error {
if strings.ContainsAny(raw, " \t\r\n\"'`$\\") {
return fmt.Errorf("download base contains illegal characters")
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("download base must be an absolute http(s) URL")
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("download base must not contain credentials, query or fragment")
}
return nil
}
@@ -114,6 +159,43 @@ func validateMasterURL(raw string) error {
if u.Host == "" {
return fmt.Errorf("master URL missing host")
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("master URL must not contain credentials, query or fragment")
}
return nil
}
func validateProxyURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if strings.ContainsAny(raw, " \t\r\n\"'`$\\") {
return fmt.Errorf("proxy URL contains illegal characters")
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return fmt.Errorf("invalid proxy URL")
}
switch u.Scheme {
case "http", "https", "socks5", "socks5h":
default:
return fmt.Errorf("proxy URL scheme must be http, https, socks5 or socks5h")
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
return fmt.Errorf("proxy URL must not contain credentials, path, query or fragment")
}
return nil
}
func validateCACertFile(file string) error {
file = strings.TrimSpace(file)
if file == "" {
return nil
}
if !path.IsAbs(file) || path.Clean(file) != file || strings.ContainsAny(file, " \t\r\n\"'`$\\") {
return fmt.Errorf("CA certificate path must be a clean absolute path without shell metacharacters")
}
return nil
}
@@ -160,6 +242,9 @@ func validateAgentVersion(v string) error {
}
func withDefaults(ctx Context) Context {
if ctx.Arch == "" {
ctx.Arch = model.InstallArchAuto
}
if ctx.InstallPrefix == "" {
ctx.InstallPrefix = "/opt/backupx-agent"
}

View File

@@ -2,11 +2,13 @@ package installscript
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"backupx/server/internal/model"
"gopkg.in/yaml.v3"
)
// 使用合法 hex token32 字节 = 64 字符)以通过 validateAgentToken 校验
@@ -27,11 +29,13 @@ func TestRenderScriptSystemd(t *testing.T) {
t.Fatalf("render err: %v", err)
}
mustContain := []string{
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
`master: "${MASTER_URL}"`,
`tokenFile: "${TOKEN_FILE}"`,
`ExecStart=${INSTALL_PREFIX}/backupx agent --config ${CONFIG_FILE}`,
"/var/lib/backupx-agent/tmp",
"systemctl daemon-reload",
"systemctl enable --now backupx-agent",
"systemctl enable backupx-agent",
"systemctl restart backupx-agent",
"systemctl status backupx-agent",
"X-Agent-Token: ${AGENT_TOKEN}",
"MASTER_URL=\"https://master.example.com\"",
@@ -48,6 +52,29 @@ func TestRenderScriptSystemd(t *testing.T) {
t.Errorf("systemd script unexpectedly contains %q", s)
}
}
if strings.Contains(got, `Environment="BACKUPX_AGENT_TOKEN=`) {
t.Errorf("systemd unit must not expose the agent token in its environment:\n%s", got)
}
}
func TestRenderedInstallScriptSyntax(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("POSIX sh is not available on this platform")
}
for _, mode := range []string{model.InstallModeSystemd, model.InstallModeDocker, model.InstallModeForeground} {
ctx := testCtx
ctx.Mode = mode
script, renderErr := RenderScript(ctx)
if renderErr != nil {
t.Fatal(renderErr)
}
cmd := exec.Command(sh, "-n")
cmd.Stdin = strings.NewReader(script)
if output, syntaxErr := cmd.CombinedOutput(); syntaxErr != nil {
t.Fatalf("%s installer syntax invalid: %v\n%s", mode, syntaxErr, output)
}
}
}
func TestRenderScriptForeground(t *testing.T) {
@@ -90,8 +117,11 @@ func TestRenderScriptDocker(t *testing.T) {
if !strings.Contains(got, `"awuqing/backupx:${AGENT_VERSION}" agent`) {
t.Errorf("docker script must start image in agent mode:\n%s", got)
}
if !strings.Contains(got, `-e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp"`) {
t.Errorf("docker script missing temp dir env:\n%s", got)
if !strings.Contains(got, `-v /etc/backupx-agent:/etc/backupx-agent:ro`) {
t.Errorf("docker script missing protected config mount:\n%s", got)
}
if !strings.Contains(got, `agent --config /etc/backupx-agent/config.yaml`) {
t.Errorf("docker script must load the protected config file:\n%s", got)
}
if !strings.Contains(got, `docker logs --tail=100 backupx-agent`) {
t.Errorf("docker script missing diagnostic log command:\n%s", got)
@@ -102,6 +132,9 @@ func TestRenderScriptDocker(t *testing.T) {
if strings.Contains(got, "systemctl daemon-reload") {
t.Errorf("docker script should not reference systemctl:\n%s", got)
}
if strings.Contains(got, `-e "BACKUPX_AGENT_TOKEN=`) {
t.Errorf("docker inspect must not expose the agent token:\n%s", got)
}
}
func TestDockerEntrypointForwardsAgentSubcommand(t *testing.T) {
@@ -111,12 +144,18 @@ func TestDockerEntrypointForwardsAgentSubcommand(t *testing.T) {
t.Fatalf("read docker entrypoint: %v", err)
}
script := string(got)
if !strings.Contains(script, `"${1:-}" = "agent"`) {
t.Fatalf("entrypoint must detect the agent subcommand before starting server:\n%s", script)
}
if !strings.Contains(script, `exec /app/bin/backupx "$@"`) {
t.Fatalf("entrypoint must exec backupx with forwarded args:\n%s", script)
}
if !strings.Contains(script, `exec su-exec backupx:backupx /app/bin/backupx "$@"`) {
t.Fatalf("master entrypoint must drop privileges after data migration:\n%s", script)
}
if !strings.Contains(script, `export HOME=/app`) {
t.Fatalf("master entrypoint must set the service user's home directory:\n%s", script)
}
if strings.Contains(script, "nginx") || strings.Contains(script, "wait -n") {
t.Fatalf("entrypoint should run a single foreground process:\n%s", script)
}
}
func TestRenderComposeYaml(t *testing.T) {
@@ -138,9 +177,40 @@ func TestRenderComposeYaml(t *testing.T) {
if !strings.Contains(got, `BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"`) {
t.Errorf("compose missing temp dir env:\n%s", got)
}
if !strings.Contains(got, `user: "0:0"`) || !strings.Contains(got, "no-new-privileges:true") {
t.Errorf("compose missing root execution declaration or security option:\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)
}
var document map[string]any
if err := yaml.Unmarshal([]byte(got), &document); err != nil {
t.Fatalf("compose is not valid YAML: %v\n%s", err, got)
}
}
func TestRenderComposeYamlIncludesRestrictedNetworkSettings(t *testing.T) {
ctx := testCtx
ctx.Mode = model.InstallModeDocker
ctx.ProxyURL = "socks5h://127.0.0.1:1080"
ctx.CACertFile = "/etc/backupx-agent/ca.pem"
got, err := RenderComposeYaml(ctx)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
`BACKUPX_AGENT_PROXY_URL: "socks5h://127.0.0.1:1080"`,
`BACKUPX_AGENT_CA_CERT_FILE: "/etc/backupx-agent/ca.pem"`,
`- /etc/backupx-agent/ca.pem:/etc/backupx-agent/ca.pem:ro`,
} {
if !strings.Contains(got, want) {
t.Errorf("compose missing %q:\n%s", want, got)
}
}
var document map[string]any
if err := yaml.Unmarshal([]byte(got), &document); err != nil {
t.Fatalf("compose is not valid YAML: %v\n%s", err, got)
}
}
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
@@ -159,6 +229,73 @@ func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
}
}
func TestRenderScriptIncludesRestrictedNetworkSettings(t *testing.T) {
ctx := testCtx
ctx.MasterURL = "http://127.0.0.1:18340"
ctx.ProxyURL = "socks5h://127.0.0.1:1080"
ctx.CACertFile = "/etc/pki/internal-ca.pem"
got, err := RenderScript(ctx)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
`PROXY_URL="socks5h://127.0.0.1:1080"`,
`CA_CERT_FILE="/etc/pki/internal-ca.pem"`,
`proxyUrl: "${PROXY_URL}"`,
`caCertFile: "${CA_CERT_FILE}"`,
`curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --proxy "$PROXY_URL"`,
} {
if !strings.Contains(got, want) {
t.Errorf("restricted network script missing %q", want)
}
}
}
func TestRenderScriptRejectsUnsafeRestrictedNetworkSettings(t *testing.T) {
for _, mutate := range []func(*Context){
func(ctx *Context) { ctx.ProxyURL = "ftp://proxy.example.com" },
func(ctx *Context) { ctx.ProxyURL = "http://user:pass@proxy.example.com" },
func(ctx *Context) { ctx.ProxyURL = "http://proxy.example.com/connect" },
func(ctx *Context) { ctx.CACertFile = "relative-ca.pem" },
} {
ctx := testCtx
mutate(&ctx)
if _, err := RenderScript(ctx); err == nil {
t.Fatalf("expected restricted network settings to be rejected: %+v", ctx)
}
}
}
func TestRenderScriptRejectsUnsafeDeploymentSettings(t *testing.T) {
for _, mutate := range []func(*Context){
func(ctx *Context) { ctx.Mode = "unknown" },
func(ctx *Context) { ctx.Arch = "386" },
func(ctx *Context) { ctx.InstallPrefix = "/opt/backupx;touch/tmp/pwned" },
func(ctx *Context) { ctx.DownloadBase = "https://user:pass@example.com/releases" },
} {
ctx := testCtx
mutate(&ctx)
if _, err := RenderScript(ctx); err == nil {
t.Fatalf("expected unsafe deployment settings to be rejected: %+v", ctx)
}
}
}
func TestRenderScriptVerifiesReleaseChecksumWithoutEmoji(t *testing.T) {
got, err := RenderScript(testCtx)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"sha256sum -c", `download_file "${URL}.sha256"`, "[OK] 节点已上线", "[WARN]"} {
if !strings.Contains(got, want) {
t.Errorf("script missing %q", want)
}
}
if strings.ContainsAny(got, "\u2713\u26a0") {
t.Fatal("installer output must not use emoji symbols")
}
}
func TestRenderComposeYamlRejectsInjectedMasterURL(t *testing.T) {
ctx := testCtx
ctx.Mode = model.InstallModeDocker

View File

@@ -1,14 +1,22 @@
# BackupX Agent docker-compose 片段
# BackupX Agent Compose 片段
# 生成于 {{.MasterURL}} · 节点 ID {{.NodeID}}
version: "3.8"
# 文件包含长期节点 Token请保存为 0600 权限并在部署完成后限制访问。
services:
backupx-agent:
image: awuqing/backupx:{{.AgentVersion}}
command: ["agent"]
user: "0:0"
restart: unless-stopped
security_opt:
- no-new-privileges:true
environment:
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"
volumes:
{{if .ProxyURL}} BACKUPX_AGENT_PROXY_URL: "{{.ProxyURL}}"
{{end}}{{if .CACertFile}} BACKUPX_AGENT_CA_CERT_FILE: "{{.CACertFile}}"
{{end}} volumes:
- /var/lib/backupx-agent:/var/lib/backupx-agent
{{if .CACertFile}} - {{.CACertFile}}:{{.CACertFile}}:ro
{{end}} # 备份宿主机文件时,必须按需添加只读源目录挂载:
# - /srv/data:/srv/data:ro

View File

@@ -1,12 +1,11 @@
#!/bin/sh
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理/CDN 改写了响应
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理CDN 改写了响应
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
set -eu
umask 077
# 自举到 bash文件执行模式下生效管道模式 $0 不是文件exec 会静默失败,继续用 sh
# 动机:部分 Debian/Ubuntu 用户通过 `curl | sudo sh` 触发时dash 对本脚本报语法错误;
# 若目标机装有 bash优先切换到 bash 获得更一致的行为。
# 文件执行模式下优先使用 bash管道模式继续使 POSIX sh。
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 && [ -f "$0" ]; then
exec bash "$0" "$@"
fi
@@ -17,14 +16,56 @@ AGENT_VERSION="{{.AgentVersion}}"
DOWNLOAD_BASE="{{.DownloadBase}}"
INSTALL_PREFIX="{{.InstallPrefix}}"
ARCH="{{.Arch}}"
PROXY_URL="{{.ProxyURL}}"
CA_CERT_FILE="{{.CACertFile}}"
CONFIG_DIR="/etc/backupx-agent"
CONFIG_FILE="${CONFIG_DIR}/config.yaml"
TOKEN_FILE="${CONFIG_DIR}/agent.token"
download_file() {
source_url="$1"
destination="$2"
if command -v curl >/dev/null 2>&1; then
if [ -n "$PROXY_URL" ]; then
curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --proxy "$PROXY_URL" "$source_url" -o "$destination"
else
curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 "$source_url" -o "$destination"
fi
else
wget -q -T 30 -O "$destination" "$source_url"
fi
}
agent_is_online() {
if command -v curl >/dev/null 2>&1; then
if [ -n "$PROXY_URL" ] && [ -n "$CA_CERT_FILE" ]; then
response=$(curl -fsS --max-time 5 --proxy "$PROXY_URL" --cacert "$CA_CERT_FILE" -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1
elif [ -n "$PROXY_URL" ]; then
response=$(curl -fsS --max-time 5 --proxy "$PROXY_URL" -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1
elif [ -n "$CA_CERT_FILE" ]; then
response=$(curl -fsS --max-time 5 --cacert "$CA_CERT_FILE" -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1
else
response=$(curl -fsS --max-time 5 -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1
fi
else
response=$(wget -q -T 5 --max-redirect=0 -O - --header="X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1
fi
printf '%s' "$response" | grep -q '"status":"online"'
}
# 1. 前置检查
[ "$(id -u)" -eq 0 ] || { echo "请使用 root 或 sudo 执行" >&2; exit 1; }
command -v curl >/dev/null || command -v wget >/dev/null \
command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \
|| { echo "需要 curl 或 wget" >&2; exit 1; }
{{if eq .Mode "systemd"}}command -v systemctl >/dev/null || { echo "不支持非 systemd 系统" >&2; exit 1; }
{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null || { echo "需要先安装 docker" >&2; exit 1; }
command -v grep >/dev/null 2>&1 || { echo "需要 grep" >&2; exit 1; }
if { [ -n "$PROXY_URL" ] || [ -n "$CA_CERT_FILE" ]; } && ! command -v curl >/dev/null 2>&1; then
echo "显式代理或自定义 CA 场景需要 curl" >&2
exit 1
fi
{{if eq .Mode "systemd"}}command -v systemctl >/dev/null 2>&1 || { echo "当前系统不支持 systemd" >&2; exit 1; }
{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null 2>&1 || { echo "需要先安装 Docker" >&2; exit 1; }
{{end}}
# 2. 架构检测
if [ "$ARCH" = "auto" ]; then
case "$(uname -m)" in
@@ -34,24 +75,54 @@ if [ "$ARCH" = "auto" ]; then
esac
fi
# 3. 安全写入 Agent 配置。systemd unit 与容器元数据中不保存节点 Token。
install -d -m 0700 "$CONFIG_DIR" /var/lib/backupx-agent /var/lib/backupx-agent/tmp
printf '%s\n' "$AGENT_TOKEN" > "$TOKEN_FILE"
chmod 0600 "$TOKEN_FILE"
if [ -n "$CA_CERT_FILE" ]; then
[ -r "$CA_CERT_FILE" ] || { echo "无法读取 CA 证书: $CA_CERT_FILE" >&2; exit 1; }
if [ "$CA_CERT_FILE" != "$CONFIG_DIR/ca.pem" ]; then
install -m 0644 "$CA_CERT_FILE" "$CONFIG_DIR/ca.pem"
fi
CA_CERT_FILE="$CONFIG_DIR/ca.pem"
fi
cat > "$CONFIG_FILE" <<CONFIG
master: "${MASTER_URL}"
tokenFile: "${TOKEN_FILE}"
heartbeatInterval: "15s"
pollInterval: "5s"
tempDir: "/var/lib/backupx-agent/tmp"
proxyUrl: "${PROXY_URL}"
caCertFile: "${CA_CERT_FILE}"
CONFIG
chmod 0600 "$CONFIG_FILE"
{{if ne .Mode "docker"}}
# 3. 下载二进制systemd / foreground 模式)
# 4. 下载并安装二进制systemd / foreground 模式)
ARCHIVE="backupx-${AGENT_VERSION}-linux-${ARCH}.tar.gz"
URL="${DOWNLOAD_BASE}/${AGENT_VERSION}/${ARCHIVE}"
TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT HUP INT TERM
echo "[1/4] 下载 ${URL}"
if command -v curl >/dev/null; then
curl -fsSL "$URL" -o "$TMPDIR/pkg.tar.gz"
download_file "$URL" "$TMPDIR/$ARCHIVE"
if download_file "${URL}.sha256" "$TMPDIR/$ARCHIVE.sha256" 2>/dev/null; then
if command -v sha256sum >/dev/null 2>&1; then
(cd "$TMPDIR" && sha256sum -c "$ARCHIVE.sha256")
elif command -v shasum >/dev/null 2>&1; then
(cd "$TMPDIR" && shasum -a 256 -c "$ARCHIVE.sha256")
else
echo "已下载校验文件,但系统缺少 sha256sum 或 shasum拒绝未校验安装" >&2
exit 1
fi
else
wget -qO "$TMPDIR/pkg.tar.gz" "$URL"
echo "[WARN] 当前版本未提供 SHA-256 校验文件,继续兼容安装" >&2
fi
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR"
# 4. 安装二进制 + 数据目录
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
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"
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx.new"
mv -f "$INSTALL_PREFIX/backupx.new" "$INSTALL_PREFIX/backupx"
{{end}}
{{if eq .Mode "systemd"}}
@@ -62,69 +133,78 @@ cat > /etc/systemd/system/backupx-agent.service <<UNIT
Description=BackupX Agent
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=simple
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp
ExecStart=${INSTALL_PREFIX}/backupx agent --config ${CONFIG_FILE}
Restart=on-failure
RestartSec=10s
# Agent 需以 root 运行以读取任意源数据;与单机服务端保持一致的资源/句柄上限。
TimeoutStopSec=30s
UMask=0077
# Agent 以 root 运行,以便读取备份源及执行恢复;节点 Token 仅保存在 0600 文件中。
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
UNIT
chmod 0644 /etc/systemd/system/backupx-agent.service
systemctl daemon-reload
systemctl enable --now backupx-agent
if ! systemctl enable backupx-agent || ! systemctl restart backupx-agent; then
echo "BackupX Agent 服务启动失败" >&2
systemctl status backupx-agent --no-pager >&2 || true
journalctl -u backupx-agent -n 100 --no-pager >&2 || true
exit 1
fi
# 6. 等待上线
echo "[4/4] 等待节点上线"
for i in $(seq 1 15); do
i=0
while [ "$i" -lt 15 ]; do
sleep 2
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
| grep -q '"status":"online"'; then
echo "✓ 节点已上线"
if agent_is_online; then
echo "[OK] 节点已上线"
exit 0
fi
i=$((i + 1))
done
echo " 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
echo "提示systemd 服务名是 backupx-agent可执行 systemctl status backupx-agent 查看状态。"
echo "[WARN] 30 内未收到上线心跳,请检查网络、代理或 SSH 隧道" >&2
echo "排查命令: systemctl status backupx-agent" >&2
echo "排查命令: journalctl -u backupx-agent -n 100 --no-pager" >&2
exit 2
{{end}}
{{if eq .Mode "foreground"}}
# 5. 前台运行
echo "[3/3] 前台启动 agentCtrl+C 退出)"
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent/tmp
echo "[3/3] 前台启动 AgentCtrl+C 退出)"
exec "${INSTALL_PREFIX}/backupx" agent --config "$CONFIG_FILE"
{{end}}
{{if eq .Mode "docker"}}
# Docker 模式:直接用镜像启动容器
# Docker 模式:配置文件只读挂载,避免 Token 出现在 docker inspect 环境变量中。
echo "[1/2] 拉取镜像 awuqing/backupx:${AGENT_VERSION}"
docker pull "awuqing/backupx:${AGENT_VERSION}"
echo "[2/2] 启动容器 backupx-agent"
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}" \
-e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp" \
docker run -d --name backupx-agent --restart=unless-stopped --user 0:0 \
--security-opt no-new-privileges:true \
-v /etc/backupx-agent:/etc/backupx-agent:ro \
-v /var/lib/backupx-agent:/var/lib/backupx-agent \
"awuqing/backupx:${AGENT_VERSION}" agent
echo "✓ 容器已启动,等待节点上线"
for i in $(seq 1 15); do
"awuqing/backupx:${AGENT_VERSION}" agent --config /etc/backupx-agent/config.yaml
echo "提示: Docker Agent 只能访问显式挂载的目录;备份宿主机路径前请按文档添加只读 -v 挂载。"
echo "容器已启动,等待节点上线"
i=0
while [ "$i" -lt 15 ]; do
sleep 2
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
| grep -q '"status":"online"'; then
echo "✓ 节点已上线"
if agent_is_online; then
echo "[OK] 节点已上线"
exit 0
fi
i=$((i + 1))
done
echo " 30s 内未收到上线心跳,请检查容器状态、网络与 Master URL。"
echo "排查命令docker ps -a --filter name=backupx-agent"
echo "排查命令docker logs --tail=100 backupx-agent"
echo "[WARN] 30 内未收到上线心跳,请检查容器、代理、隧道与 Master 地址" >&2
echo "排查命令: docker ps -a --filter name=backupx-agent" >&2
echo "排查命令: docker logs --tail=100 backupx-agent" >&2
exit 2
{{end}}