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 -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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}"
|
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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user