From ae9623c0780f4bb5bcec0f3bf36782f28fb9b568 Mon Sep 17 00:00:00 2001 From: Awuqing <3184394176@qq.com> Date: Sun, 9 Aug 2026 02:31:22 +0800 Subject: [PATCH] fix(deploy): harden container and bare-metal installs --- .github/workflows/release.yml | 5 ++ Dockerfile | 27 +++--- deploy/backupx.service | 1 + deploy/docker/entrypoint.sh | 37 +++----- deploy/docker/nginx.conf | 51 ----------- deploy/install.sh | 87 +++++++++++++++---- deploy/nginx.conf | 10 ++- docker-compose.yml | 38 +++++--- .../installscript/deploy_install_test.go | 77 +++++++++++++++- 9 files changed, 217 insertions(+), 116 deletions(-) delete mode 100644 deploy/docker/nginx.conf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 373eb75..342203a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 ─── diff --git a/Dockerfile b/Dockerfile index 02e4695..88299cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/deploy/backupx.service b/deploy/backupx.service index 1d89354..7330e13 100644 --- a/deploy/backupx.service +++ b/deploy/backupx.service @@ -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] diff --git a/deploy/docker/entrypoint.sh b/deploy/docker/entrypoint.sh index 35c7114..f6cc02f 100644 --- a/deploy/docker/entrypoint.sh +++ b/deploy/docker/entrypoint.sh @@ -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 "$@" diff --git a/deploy/docker/nginx.conf b/deploy/docker/nginx.conf deleted file mode 100644 index ff4fb65..0000000 --- a/deploy/docker/nginx.conf +++ /dev/null @@ -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"; - } -} diff --git a/deploy/install.sh b/deploy/install.sh index da068a1..1c5cfbb 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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" <&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 < "/etc/systemd/system/$SERVICE_NAME.service" <