fix(deploy): harden container and bare-metal installs

This commit is contained in:
Awuqing
2026-08-09 02:31:22 +08:00
parent ab181b3197
commit ae9623c078
9 changed files with 217 additions and 116 deletions
+5
View File
@@ -110,6 +110,7 @@ jobs:
cp -r web/dist "${ARCHIVE_NAME}/web" cp -r web/dist "${ARCHIVE_NAME}/web"
cp server/config.example.yaml "${ARCHIVE_NAME}/" cp server/config.example.yaml "${ARCHIVE_NAME}/"
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true
cp deploy/backupx.service "${ARCHIVE_NAME}/" 2>/dev/null || true
# v2.2+: 随发布包提供 Grafana dashboard 与 nginx.conf 模板 # v2.2+: 随发布包提供 Grafana dashboard 与 nginx.conf 模板
if [ -d deploy/grafana ]; then if [ -d deploy/grafana ]; then
cp -r deploy/grafana "${ARCHIVE_NAME}/grafana" cp -r deploy/grafana "${ARCHIVE_NAME}/grafana"
@@ -117,6 +118,8 @@ jobs:
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" cp "${ARCHIVE_NAME}.tar.gz" "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz"
sha256sum "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256"
sha256sum "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" > "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256"
- name: Upload to GitHub Release - name: Upload to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
@@ -124,7 +127,9 @@ jobs:
tag_name: ${{ env.VERSION }} tag_name: ${{ env.VERSION }}
files: | files: |
backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256
backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256
generate_release_notes: true generate_release_notes: true
# ─── Job 3: Docker 多架构 → Docker Hub ─── # ─── Job 3: Docker 多架构 → Docker Hub ───
+16 -11
View File
@@ -51,12 +51,11 @@ RUN if [ "$USE_CHINA_MIRROR" = "true" ]; then \
sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories; \ sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories; \
fi fi
# Database client binaries are required by MySQL and PostgreSQL backup tasks.
RUN apk add --no-cache \ RUN apk add --no-cache \
nginx \
tzdata \ tzdata \
ca-certificates \ ca-certificates \
docker-cli docker-cli-compose \ su-exec \
# Required by mysql/postgresql backup tasks
mysql-client \ mysql-client \
postgresql16-client \ postgresql16-client \
&& rm -rf /var/cache/apk/* && rm -rf /var/cache/apk/*
@@ -70,24 +69,30 @@ COPY --from=server-builder /build/server/backupx /app/bin/backupx
# Copy frontend static files # Copy frontend static files
COPY --from=web-builder /build/web/dist /app/web COPY --from=web-builder /build/web/dist /app/web
# Copy nginx config
COPY deploy/docker/nginx.conf /etc/nginx/http.d/default.conf
# Copy entrypoint # Copy entrypoint
COPY deploy/docker/entrypoint.sh /app/entrypoint.sh COPY deploy/docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
# Create data directories # Create data directories
RUN mkdir -p /app/data /tmp/backupx && \ RUN mkdir -p /app/data /tmp/backupx && \
chown -R backupx:backupx /app /tmp/backupx touch /app/data/.backupx-owner-v2 && \
chown -R backupx:backupx /app/data /tmp/backupx && \
# Nginx needs to write to these dirs chmod 0750 /app/data /tmp/backupx
RUN mkdir -p /var/lib/nginx/tmp /var/log/nginx && \
chown -R backupx:backupx /var/lib/nginx /var/log/nginx /run/nginx
WORKDIR /app WORKDIR /app
EXPOSE 8340 EXPOSE 8340
VOLUME ["/app/data"] VOLUME ["/app/data"]
ENV BACKUPX_SERVER_HOST=0.0.0.0 \
BACKUPX_SERVER_PORT=8340 \
BACKUPX_SERVER_WEB_ROOT=/app/web
USER root
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD su-exec backupx:backupx wget -q -T 3 -O /dev/null http://127.0.0.1:8340/ready || exit 1
STOPSIGNAL SIGTERM
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]
+1
View File
@@ -12,6 +12,7 @@ ExecStart=/opt/backupx/bin/backupx -config /etc/backupx/config.yaml
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
NoNewPrivileges=true NoNewPrivileges=true
UMask=0027
LimitNOFILE=65535 LimitNOFILE=65535
[Install] [Install]
+14 -23
View File
@@ -1,27 +1,18 @@
#!/bin/sh #!/bin/sh
set -e set -eu
if [ "${1:-}" = "agent" ]; then # 旧镜像曾以 root 写入数据卷。Master 启动时做一次所有权迁移,随后
exec /app/bin/backupx "$@" # 降权运行;Agent 模式由部署命令显式决定用户,以访问宿主机备份路径。
if [ "$(id -u)" -eq 0 ] && [ "${1:-}" != "agent" ]; then
chown backupx:backupx /app/data /tmp/backupx
if [ ! -f /app/data/.backupx-owner-v2 ]; then
chown -R backupx:backupx /app/data
su-exec backupx:backupx touch /app/data/.backupx-owner-v2
fi
export HOME=/app
exec su-exec backupx:backupx /app/bin/backupx "$@"
fi fi
# Backend listens on internal port 8341, Nginx exposes 8340 # Web 静态文件由 BackupX 后端直接托管。容器只运行一个前台进程,
export BACKUPX_SERVER_PORT="${BACKUPX_SERVER_PORT_INTERNAL:-8341}" # 让 Docker 准确传递信号、收集退出码并执行健康检查。
exec /app/bin/backupx "$@"
# Start Nginx in background
nginx -g "daemon off;" &
NGINX_PID=$!
# Start BackupX backend
/app/bin/backupx &
APP_PID=$!
# Trap signals for graceful shutdown
trap 'kill $APP_PID $NGINX_PID 2>/dev/null; wait $APP_PID $NGINX_PID 2>/dev/null' SIGTERM SIGINT
echo "BackupX started — Nginx :8340 -> Backend :8341"
# Wait for either process to exit
wait -n $APP_PID $NGINX_PID 2>/dev/null || true
kill $APP_PID $NGINX_PID 2>/dev/null || true
wait $APP_PID $NGINX_PID 2>/dev/null || true
-51
View File
@@ -1,51 +0,0 @@
server {
listen 8340;
server_name _;
root /app/web;
index index.html;
# API reverse proxy to backend
location /api/ {
proxy_pass http://127.0.0.1:8341/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
# Agent one-click install endpoints.
# Some external reverse proxies strip the /api prefix before reaching this
# container, so /install/ must be proxied here instead of falling through to
# the SPA index.html.
location /install/ {
proxy_pass http://127.0.0.1:8341/install/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
}
location = /health { proxy_pass http://127.0.0.1:8341/health; }
location = /ready { proxy_pass http://127.0.0.1:8341/ready; }
location = /metrics { proxy_pass http://127.0.0.1:8341/metrics; }
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Static assets cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
+71 -16
View File
@@ -13,6 +13,7 @@ if [ -f "$SCRIPT_DIR/backupx" ] && [ -d "$SCRIPT_DIR/web" ]; then
WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}" WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}" CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"
NGINX_SOURCE="${NGINX_SOURCE:-$SCRIPT_DIR/nginx.conf}" NGINX_SOURCE="${NGINX_SOURCE:-$SCRIPT_DIR/nginx.conf}"
SERVICE_SOURCE_DEFAULT="$SCRIPT_DIR/backupx.service"
else else
SOURCE_BIN_DEFAULT="$PROJECT_ROOT/server/bin/backupx" SOURCE_BIN_DEFAULT="$PROJECT_ROOT/server/bin/backupx"
# Keep compatibility with contributors who built the historical path by # Keep compatibility with contributors who built the historical path by
@@ -24,14 +25,38 @@ else
WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}" WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}" CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}"
NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}" NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}"
SERVICE_SOURCE_DEFAULT="$PROJECT_ROOT/deploy/backupx.service"
fi fi
SERVICE_SOURCE="${SERVICE_SOURCE:-$PROJECT_ROOT/deploy/backupx.service}" SERVICE_SOURCE_EXPLICIT=0
if [ -n "${SERVICE_SOURCE:-}" ]; then
SERVICE_SOURCE_EXPLICIT=1
fi
SERVICE_SOURCE="${SERVICE_SOURCE:-$SERVICE_SOURCE_DEFAULT}"
INSTALL_NGINX="${INSTALL_NGINX:-0}"
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 或 sudo 执行安装脚本。" >&2 echo "请使用 root 或 sudo 执行安装脚本。" >&2
exit 1 exit 1
fi fi
validate_install_path() {
path_name="$1"
path_value="$2"
case "$path_value" in
/*) ;;
*) echo "$path_name 必须是绝对路径: $path_value" >&2; exit 1 ;;
esac
case "$path_value" in
/|*"//"*|*"/./"*|*"/."|*"/../"*|*"/.."|*[!A-Za-z0-9_./+-]*)
echo "$path_name 必须是规范、安全且非根目录的绝对路径: $path_value" >&2
exit 1
;;
esac
}
validate_install_path PREFIX "$PREFIX"
validate_install_path ETC_DIR "$ETC_DIR"
if [ ! -f "$BIN_SOURCE" ]; then if [ ! -f "$BIN_SOURCE" ]; then
echo "Backend binary not found / 未找到后端二进制:$BIN_SOURCE" >&2 echo "Backend binary not found / 未找到后端二进制:$BIN_SOURCE" >&2
echo "源码树安装请先在仓库根目录执行 make build(产物:server/bin/backupx)。" >&2 echo "源码树安装请先在仓库根目录执行 make build(产物:server/bin/backupx)。" >&2
@@ -52,6 +77,18 @@ if [ ! -f "$CONFIG_TEMPLATE" ]; then
exit 1 exit 1
fi fi
if [ "$SERVICE_SOURCE_EXPLICIT" = "1" ] && [ ! -f "$SERVICE_SOURCE" ]; then
echo "指定的 systemd unit 不存在:$SERVICE_SOURCE" >&2
exit 1
fi
for managed_path in "$PREFIX" "$PREFIX/bin" "$PREFIX/web" "$PREFIX/data" "$ETC_DIR"; do
if [ -L "$managed_path" ]; then
echo "拒绝通过符号链接写入受管目录:$managed_path" >&2
exit 1
fi
done
if ! getent group "$APP_GROUP" >/dev/null 2>&1; then if ! getent group "$APP_GROUP" >/dev/null 2>&1; then
groupadd --system "$APP_GROUP" groupadd --system "$APP_GROUP"
fi fi
@@ -60,19 +97,27 @@ if ! id "$APP_USER" >/dev/null 2>&1; then
useradd --system --gid "$APP_GROUP" --home-dir "$PREFIX" --shell /usr/sbin/nologin "$APP_USER" useradd --system --gid "$APP_GROUP" --home-dir "$PREFIX" --shell /usr/sbin/nologin "$APP_USER"
fi fi
install -d -o "$APP_USER" -g "$APP_GROUP" "$PREFIX" "$PREFIX/bin" "$PREFIX/web" "$PREFIX/data" "$ETC_DIR" install -d -o root -g root -m 0755 "$PREFIX" "$PREFIX/bin" "$PREFIX/web"
install -m 0755 "$BIN_SOURCE" "$PREFIX/bin/backupx" install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$PREFIX/data"
install -d -o root -g "$APP_GROUP" -m 0750 "$ETC_DIR"
install -o root -g root -m 0755 "$BIN_SOURCE" "$PREFIX/bin/backupx.new"
mv -f "$PREFIX/bin/backupx.new" "$PREFIX/bin/backupx"
cp -R "$WEB_SOURCE/." "$PREFIX/web/" cp -R "$WEB_SOURCE/." "$PREFIX/web/"
chown -R "$APP_USER:$APP_GROUP" "$PREFIX" chown -R root:root "$PREFIX/bin" "$PREFIX/web"
find "$PREFIX/web" -type d -exec chmod 0755 {} \;
find "$PREFIX/web" -type f -exec chmod 0644 {} \;
chown -R "$APP_USER:$APP_GROUP" "$PREFIX/data"
if [ ! -f "$ETC_DIR/config.yaml" ]; then if [ ! -f "$ETC_DIR/config.yaml" ]; then
install -o "$APP_USER" -g "$APP_GROUP" -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml" install -o root -g "$APP_GROUP" -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
fi fi
# 确保服务账户读取配置:历史版本曾以 root:root 0640 安装配置, # 服务账户只需读取配置,不应拥有修改 /etc 配置或可执行文件的权限。
# 导致以 backupx 身份运行的服务因无权读取配置而启动失败(exit 1)。 chown root:"$APP_GROUP" "$ETC_DIR/config.yaml"
chown "$APP_USER:$APP_GROUP" "$ETC_DIR/config.yaml" chmod 0640 "$ETC_DIR/config.yaml"
if [ -f "$SERVICE_SOURCE" ]; then # 仓库 unit 使用标准路径;自定义 PREFIX/ETC_DIR 时动态生成以保持路径一致。
# 显式传入 SERVICE_SOURCE 表示调用方已经审核其中的路径,始终优先使用。
if [ -f "$SERVICE_SOURCE" ] && { [ "$SERVICE_SOURCE_EXPLICIT" = "1" ] || { [ "$PREFIX" = "/opt/backupx" ] && [ "$ETC_DIR" = "/etc/backupx" ]; }; }; then
install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service" install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service"
else else
cat > "/etc/systemd/system/$SERVICE_NAME.service" <<UNIT cat > "/etc/systemd/system/$SERVICE_NAME.service" <<UNIT
@@ -90,6 +135,7 @@ ExecStart=$PREFIX/bin/backupx -config $ETC_DIR/config.yaml
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
NoNewPrivileges=true NoNewPrivileges=true
UMask=0027
LimitNOFILE=65535 LimitNOFILE=65535
[Install] [Install]
@@ -97,7 +143,12 @@ WantedBy=multi-user.target
UNIT UNIT
fi fi
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME" if ! systemctl enable "$SERVICE_NAME" || ! systemctl restart "$SERVICE_NAME"; then
echo "BackupX systemd 服务启动失败。" >&2
systemctl status "$SERVICE_NAME" --no-pager >&2 || true
journalctl -u "$SERVICE_NAME" -n 50 --no-pager >&2 || true
exit 1
fi
# systemctl may return before the process has opened its HTTP listener. Verify # systemctl may return before the process has opened its HTTP listener. Verify
# the same unauthenticated endpoint used by the first-administrator screen so a # the same unauthenticated endpoint used by the first-administrator screen so a
@@ -134,12 +185,15 @@ if [ "$READY" -ne 1 ]; then
exit 1 exit 1
fi fi
if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then if [ "$INSTALL_NGINX" = "1" ]; then
install -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf" if [ ! -d "/etc/nginx/conf.d" ] || [ ! -f "$NGINX_SOURCE" ]; then
if command -v nginx >/dev/null 2>&1; then echo "已请求安装 Nginx 配置,但未找到 /etc/nginx/conf.d 或配置模板。" >&2
nginx -t exit 1
systemctl reload nginx || true
fi fi
install -o root -g root -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf"
command -v nginx >/dev/null 2>&1 || { echo "未找到 nginx 命令。" >&2; exit 1; }
nginx -t
systemctl reload nginx
fi fi
cat <<MESSAGE cat <<MESSAGE
@@ -158,7 +212,8 @@ Web 控制台已由后端直接托管,无需额外的 nginx 反向代理即可
2. 页面显示“系统初始化 / System setup”时,创建首个管理员用户名和密码。 2. 页面显示“系统初始化 / System setup”时,创建首个管理员用户名和密码。
3. 如果未显示初始化表单,请先检查:$HEALTH_URL 3. 如果未显示初始化表单,请先检查:$HEALTH_URL
(如已安装 nginx,脚本会自动写入反向代理配置,可继续用 80 端口访问。) 如需安装仓库提供的 Nginx 模板,请审核域名与 TLS 配置后重新执行:
sudo INSTALL_NGINX=1 ./install.sh
排查:若服务未监听端口,请查看日志: 排查:若服务未监听端口,请查看日志:
journalctl -u "$SERVICE_NAME" -n 50 --no-pager journalctl -u "$SERVICE_NAME" -n 50 --no-pager
+8 -2
View File
@@ -8,11 +8,15 @@ server {
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:8340/api/; proxy_pass http://127.0.0.1:8340/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Connection ""; proxy_set_header Connection "";
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off; proxy_buffering off;
proxy_cache off; proxy_cache off;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
@@ -23,10 +27,12 @@ server {
location /install/ { location /install/ {
proxy_pass http://127.0.0.1:8340/install/; proxy_pass http://127.0.0.1:8340/install/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
} }
# 健康检查端点同样不走 SPA fallback。 # 健康检查端点同样不走 SPA fallback。
+27 -11
View File
@@ -3,30 +3,46 @@
# 快速启动:docker compose up -d # 快速启动:docker compose up -d
# 访问地址:http://localhost:8340 # 访问地址:http://localhost:8340
# #
# 如需从源码构建镜像(而非拉取线上镜像),取消注释 build 行并注释 image 行 # 生产环境建议在 .env 中固定 BACKUPX_IMAGE 版本,并通过 HTTPS 反向代理暴露服务
services: services:
backupx: backupx:
image: awuqing/backupx:latest image: ${BACKUPX_IMAGE:-awuqing/backupx:latest}
# build: . # 从源码构建时取消此行注释 # build: .
container_name: backupx container_name: backupx
restart: unless-stopped restart: unless-stopped
init: true
stop_grace_period: 30s
ports: ports:
- "8340:8340" - "${BACKUPX_BIND_ADDRESS:-0.0.0.0}:${BACKUPX_PORT:-8340}:8340"
volumes: volumes:
- backupx-data:/app/data - backupx-data:/app/data
- /var/run/docker.sock:/var/run/docker.sock # 支持 Web 一键更新
# 挂载需要备份的宿主机目录(按需添加,:ro 表示只读): # 挂载需要备份的宿主机目录(按需添加,:ro 表示只读):
# - /var/www:/mnt/www:ro # - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro # - /etc/nginx:/mnt/nginx-conf:ro
# - /home/user/data:/mnt/data:ro # - /home/user/data:/mnt/data:ro
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
# 仅用于旧数据卷迁移与降权;应用进程随后以 backupx 运行。
- CHOWN
- DAC_OVERRIDE
- SETGID
- SETUID
environment: environment:
- TZ=Asia/Shanghai TZ: ${TZ:-Asia/Shanghai}
# 远程 Agent 需要通过公网或可路由地址连接 Master 时,取消注释并改成真实 URL # 远程 Agent 连接 Master 时,配置为所有节点可达的稳定 HTTPS URL
# - BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com # BACKUPX_SERVER_EXTERNAL_URL: https://backup.example.com
# 通过 BACKUPX_ 前缀环境变量覆盖配置: # BACKUPX_LOG_LEVEL: debug
# - BACKUPX_LOG_LEVEL=debug # BACKUPX_BACKUP_MAX_CONCURRENT: "4"
# - BACKUPX_BACKUP_MAX_CONCURRENT=4 healthcheck:
test: ["CMD", "su-exec", "backupx:backupx", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:8340/ready"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes: volumes:
backupx-data: backupx-data:
@@ -6,11 +6,17 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"gopkg.in/yaml.v3"
) )
func TestDeployInstallScriptSyntax(t *testing.T) { func TestDeployInstallScriptSyntax(t *testing.T) {
scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh") 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() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
t.Fatalf("install.sh syntax invalid: %v\n%s", err, output) 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}"`, `BIN_SOURCE="${BIN_SOURCE:-$SCRIPT_DIR/backupx}"`,
`WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}"`, `WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}"`,
`CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"`, `CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"`,
`SERVICE_SOURCE_DEFAULT="$SCRIPT_DIR/backupx.service"`,
`发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。`, `发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。`,
`cat > "/etc/systemd/system/$SERVICE_NAME.service" <<UNIT`, `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) { if !strings.Contains(script, want) {
t.Fatalf("install.sh missing %q", 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}"`, `HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8340/api/auth/setup/status}"`,
`systemctl is-active --quiet "$SERVICE_NAME"`, `systemctl is-active --quiet "$SERVICE_NAME"`,
`System setup`, `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) { if !strings.Contains(script, want) {
t.Fatalf("install.sh missing %q", 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)
}
}
}