mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-28 19:47:27 +08:00
fix(deploy): harden container and bare-metal installs
This commit is contained in:
@@ -110,6 +110,7 @@ jobs:
|
||||
cp -r web/dist "${ARCHIVE_NAME}/web"
|
||||
cp server/config.example.yaml "${ARCHIVE_NAME}/"
|
||||
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 模板
|
||||
if [ -d deploy/grafana ]; then
|
||||
cp -r deploy/grafana "${ARCHIVE_NAME}/grafana"
|
||||
@@ -117,6 +118,8 @@ jobs:
|
||||
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"
|
||||
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
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -124,7 +127,9 @@ jobs:
|
||||
tag_name: ${{ env.VERSION }}
|
||||
files: |
|
||||
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.sha256
|
||||
generate_release_notes: true
|
||||
|
||||
# ─── Job 3: Docker 多架构 → Docker Hub ───
|
||||
|
||||
+16
-11
@@ -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; \
|
||||
fi
|
||||
|
||||
# Database client binaries are required by MySQL and PostgreSQL backup tasks.
|
||||
RUN apk add --no-cache \
|
||||
nginx \
|
||||
tzdata \
|
||||
ca-certificates \
|
||||
docker-cli docker-cli-compose \
|
||||
# Required by mysql/postgresql backup tasks
|
||||
su-exec \
|
||||
mysql-client \
|
||||
postgresql16-client \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
@@ -70,24 +69,30 @@ COPY --from=server-builder /build/server/backupx /app/bin/backupx
|
||||
# Copy frontend static files
|
||||
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 deploy/docker/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /app/data /tmp/backupx && \
|
||||
chown -R backupx:backupx /app /tmp/backupx
|
||||
|
||||
# Nginx needs to write to these dirs
|
||||
RUN mkdir -p /var/lib/nginx/tmp /var/log/nginx && \
|
||||
chown -R backupx:backupx /var/lib/nginx /var/log/nginx /run/nginx
|
||||
touch /app/data/.backupx-owner-v2 && \
|
||||
chown -R backupx:backupx /app/data /tmp/backupx && \
|
||||
chmod 0750 /app/data /tmp/backupx
|
||||
|
||||
WORKDIR /app
|
||||
EXPOSE 8340
|
||||
|
||||
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"]
|
||||
|
||||
@@ -12,6 +12,7 @@ ExecStart=/opt/backupx/bin/backupx -config /etc/backupx/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
UMask=0027
|
||||
LimitNOFILE=65535
|
||||
|
||||
[Install]
|
||||
|
||||
+14
-23
@@ -1,27 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
set -eu
|
||||
|
||||
if [ "${1:-}" = "agent" ]; then
|
||||
exec /app/bin/backupx "$@"
|
||||
# 旧镜像曾以 root 写入数据卷。Master 启动时做一次所有权迁移,随后
|
||||
# 降权运行;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
|
||||
|
||||
# Backend listens on internal port 8341, Nginx exposes 8340
|
||||
export BACKUPX_SERVER_PORT="${BACKUPX_SERVER_PORT_INTERNAL:-8341}"
|
||||
|
||||
# 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
|
||||
# Web 静态文件由 BackupX 后端直接托管。容器只运行一个前台进程,
|
||||
# 让 Docker 准确传递信号、收集退出码并执行健康检查。
|
||||
exec /app/bin/backupx "$@"
|
||||
|
||||
@@ -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
@@ -13,6 +13,7 @@ if [ -f "$SCRIPT_DIR/backupx" ] && [ -d "$SCRIPT_DIR/web" ]; then
|
||||
WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}"
|
||||
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"
|
||||
NGINX_SOURCE="${NGINX_SOURCE:-$SCRIPT_DIR/nginx.conf}"
|
||||
SERVICE_SOURCE_DEFAULT="$SCRIPT_DIR/backupx.service"
|
||||
else
|
||||
SOURCE_BIN_DEFAULT="$PROJECT_ROOT/server/bin/backupx"
|
||||
# Keep compatibility with contributors who built the historical path by
|
||||
@@ -24,14 +25,38 @@ else
|
||||
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}"
|
||||
SERVICE_SOURCE_DEFAULT="$PROJECT_ROOT/deploy/backupx.service"
|
||||
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
|
||||
echo "请使用 root 或 sudo 执行安装脚本。" >&2
|
||||
exit 1
|
||||
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
|
||||
echo "Backend binary not found / 未找到后端二进制:$BIN_SOURCE" >&2
|
||||
echo "源码树安装请先在仓库根目录执行 make build(产物:server/bin/backupx)。" >&2
|
||||
@@ -52,6 +77,18 @@ if [ ! -f "$CONFIG_TEMPLATE" ]; then
|
||||
exit 1
|
||||
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
|
||||
groupadd --system "$APP_GROUP"
|
||||
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"
|
||||
fi
|
||||
|
||||
install -d -o "$APP_USER" -g "$APP_GROUP" "$PREFIX" "$PREFIX/bin" "$PREFIX/web" "$PREFIX/data" "$ETC_DIR"
|
||||
install -m 0755 "$BIN_SOURCE" "$PREFIX/bin/backupx"
|
||||
install -d -o root -g root -m 0755 "$PREFIX" "$PREFIX/bin" "$PREFIX/web"
|
||||
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/"
|
||||
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
|
||||
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
|
||||
# 确保服务账户能读取配置:历史版本曾以 root:root 0640 安装配置,
|
||||
# 导致以 backupx 身份运行的服务因无权读取配置而启动失败(exit 1)。
|
||||
chown "$APP_USER:$APP_GROUP" "$ETC_DIR/config.yaml"
|
||||
# 服务账户只需读取配置,不应拥有修改 /etc 配置或可执行文件的权限。
|
||||
chown root:"$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"
|
||||
else
|
||||
cat > "/etc/systemd/system/$SERVICE_NAME.service" <<UNIT
|
||||
@@ -90,6 +135,7 @@ ExecStart=$PREFIX/bin/backupx -config $ETC_DIR/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
UMask=0027
|
||||
LimitNOFILE=65535
|
||||
|
||||
[Install]
|
||||
@@ -97,7 +143,12 @@ WantedBy=multi-user.target
|
||||
UNIT
|
||||
fi
|
||||
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
|
||||
# the same unauthenticated endpoint used by the first-administrator screen so a
|
||||
@@ -134,12 +185,15 @@ if [ "$READY" -ne 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then
|
||||
install -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf"
|
||||
if command -v nginx >/dev/null 2>&1; then
|
||||
nginx -t
|
||||
systemctl reload nginx || true
|
||||
if [ "$INSTALL_NGINX" = "1" ]; then
|
||||
if [ ! -d "/etc/nginx/conf.d" ] || [ ! -f "$NGINX_SOURCE" ]; then
|
||||
echo "已请求安装 Nginx 配置,但未找到 /etc/nginx/conf.d 或配置模板。" >&2
|
||||
exit 1
|
||||
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
|
||||
|
||||
cat <<MESSAGE
|
||||
@@ -158,7 +212,8 @@ Web 控制台已由后端直接托管,无需额外的 nginx 反向代理即可
|
||||
2. 页面显示“系统初始化 / System setup”时,创建首个管理员用户名和密码。
|
||||
3. 如果未显示初始化表单,请先检查:$HEALTH_URL
|
||||
|
||||
(如已安装 nginx,脚本会自动写入反向代理配置,可继续用 80 端口访问。)
|
||||
如需安装仓库提供的 Nginx 模板,请审核域名与 TLS 配置后重新执行:
|
||||
sudo INSTALL_NGINX=1 ./install.sh
|
||||
|
||||
排查:若服务未监听端口,请查看日志:
|
||||
journalctl -u "$SERVICE_NAME" -n 50 --no-pager
|
||||
|
||||
+8
-2
@@ -8,11 +8,15 @@ server {
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340/api/;
|
||||
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-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
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 "";
|
||||
client_max_body_size 0;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
@@ -23,10 +27,12 @@ server {
|
||||
location /install/ {
|
||||
proxy_pass http://127.0.0.1:8340/install/;
|
||||
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-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
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。
|
||||
|
||||
+27
-11
@@ -3,30 +3,46 @@
|
||||
# 快速启动:docker compose up -d
|
||||
# 访问地址:http://localhost:8340
|
||||
#
|
||||
# 如需从源码构建镜像(而非拉取线上镜像),取消注释 build 行并注释 image 行。
|
||||
# 生产环境建议在 .env 中固定 BACKUPX_IMAGE 版本,并通过 HTTPS 反向代理暴露服务。
|
||||
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
# build: . # 从源码构建时取消此行注释
|
||||
image: ${BACKUPX_IMAGE:-awuqing/backupx:latest}
|
||||
# build: .
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
stop_grace_period: 30s
|
||||
ports:
|
||||
- "8340:8340"
|
||||
- "${BACKUPX_BIND_ADDRESS:-0.0.0.0}:${BACKUPX_PORT:-8340}:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock # 支持 Web 一键更新
|
||||
# 挂载需要备份的宿主机目录(按需添加,:ro 表示只读):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf: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:
|
||||
- TZ=Asia/Shanghai
|
||||
# 远程 Agent 需要通过公网或可路由地址连接 Master 时,取消注释并改成真实 URL:
|
||||
# - BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
|
||||
# 通过 BACKUPX_ 前缀环境变量覆盖配置:
|
||||
# - BACKUPX_LOG_LEVEL=debug
|
||||
# - BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
# 远程 Agent 连接 Master 时,配置为所有节点可达的稳定 HTTPS URL:
|
||||
# BACKUPX_SERVER_EXTERNAL_URL: https://backup.example.com
|
||||
# BACKUPX_LOG_LEVEL: debug
|
||||
# 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:
|
||||
backupx-data:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user