chore: harden build and deployment workflow

This commit is contained in:
237899745
2026-07-27 13:07:14 +08:00
parent 6692736e88
commit f5377e51bb
10 changed files with 408 additions and 71 deletions

View File

@@ -35,6 +35,7 @@ PROJECT_NAME="wanwanyun"
PROJECT_DIR="/var/www/${PROJECT_NAME}"
REPO_URL="https://git.workyai.cn/237899745/vue-driven-cloud-storage.git"
NODE_VERSION="20"
NODE_MIN_VERSION="20.19.0"
ADMIN_USERNAME=""
ADMIN_PASSWORD=""
DOMAIN=""
@@ -529,13 +530,17 @@ install_dependencies() {
echo ""
}
node_version_supported() {
node -e "const [major, minor] = process.versions.node.split('.').map(Number); process.exit((major > 20 && major < 25) || (major === 20 && minor >= 19) ? 0 : 1)" 2>/dev/null
}
install_nodejs_apt() {
if command -v node &> /dev/null; then
NODE_VER=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [[ $NODE_VER -ge $NODE_VERSION ]]; then
if node_version_supported; then
print_success "Node.js 已安装: $(node -v)"
return
fi
print_warning "当前 Node.js $(node -v) 不满足 ${NODE_MIN_VERSION} 至 24.x准备安装 20.x"
fi
print_info "正在安装 Node.js ${NODE_VERSION}.x..."
@@ -546,11 +551,11 @@ install_nodejs_apt() {
install_nodejs_yum() {
if command -v node &> /dev/null; then
NODE_VER=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [[ $NODE_VER -ge $NODE_VERSION ]]; then
if node_version_supported; then
print_success "Node.js 已安装: $(node -v)"
return
fi
print_warning "当前 Node.js $(node -v) 不满足 ${NODE_MIN_VERSION} 至 24.x准备安装 20.x"
fi
print_info "正在安装 Node.js ${NODE_VERSION}.x..."
@@ -585,11 +590,11 @@ install_nginx_yum() {
install_nodejs_dnf() {
if command -v node &> /dev/null; then
NODE_VER=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [[ $NODE_VER -ge $NODE_VERSION ]]; then
if node_version_supported; then
print_success "Node.js 已安装: $(node -v)"
return
fi
print_warning "当前 Node.js $(node -v) 不满足 ${NODE_MIN_VERSION} 至 24.x准备安装 20.x"
fi
print_info "正在安装 Node.js ${NODE_VERSION}.x..."
@@ -612,11 +617,11 @@ install_nginx_dnf() {
install_nodejs_zypper() {
if command -v node &> /dev/null; then
NODE_VER=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [[ $NODE_VER -ge $NODE_VERSION ]]; then
if node_version_supported; then
print_success "Node.js 已安装: $(node -v)"
return
fi
print_warning "当前 Node.js $(node -v) 不满足 ${NODE_MIN_VERSION} 至 24.x准备安装 20.x"
fi
print_info "正在安装 Node.js ${NODE_VERSION}.x..."
@@ -1390,7 +1395,7 @@ deploy_certbot() {
echo ""
print_info "正在申请 Let's Encrypt 证书..."
if certbot certonly --webroot -w "${PROJECT_DIR}/frontend" -d "$DOMAIN" --non-interactive --agree-tos --email "admin@${DOMAIN}"; then
if certbot certonly --webroot -w "${PROJECT_DIR}/frontend/dist" -d "$DOMAIN" --non-interactive --agree-tos --email "admin@${DOMAIN}"; then
# 将证书复制到Nginx SSL目录
mkdir -p /etc/nginx/ssl
ln -sf "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" "/etc/nginx/ssl/${DOMAIN}.crt"
@@ -1541,7 +1546,7 @@ deploy_acme_letsencrypt() {
# 使用webroot模式申请证书更可靠
# 先尝试正常申请,如果证书已存在则使用--force强制更新
if ~/.acme.sh/acme.sh --issue -d "$DOMAIN" --webroot "${PROJECT_DIR}/frontend"; then
if ~/.acme.sh/acme.sh --issue -d "$DOMAIN" --webroot "${PROJECT_DIR}/frontend/dist"; then
print_success "证书申请成功"
else
# 检查是否是因为证书已存在
@@ -1707,7 +1712,7 @@ deploy_acme_zerossl() {
fi
# 使用webroot模式申请证书更可靠
if ~/.acme.sh/acme.sh --server zerossl --issue -d "$DOMAIN" --webroot "${PROJECT_DIR}/frontend"; then
if ~/.acme.sh/acme.sh --server zerossl --issue -d "$DOMAIN" --webroot "${PROJECT_DIR}/frontend/dist"; then
print_success "证书申请成功"
else
# 检查是否是因为证书已存在
@@ -1866,7 +1871,7 @@ deploy_acme_buypass() {
fi
# 使用webroot模式申请证书更可靠
if ~/.acme.sh/acme.sh --server buypass --issue -d "$DOMAIN" --webroot "${PROJECT_DIR}/frontend"; then
if ~/.acme.sh/acme.sh --server buypass --issue -d "$DOMAIN" --webroot "${PROJECT_DIR}/frontend/dist"; then
print_success "证书申请成功"
else
# 检查是否是因为证书已存在
@@ -2113,6 +2118,26 @@ install_backend_dependencies() {
echo ""
}
install_frontend_dependencies() {
print_step "安装前端依赖并构建生产资源..."
cd "${PROJECT_DIR}/frontend"
if [[ ! -f "package-lock.json" ]]; then
print_error "前端 package-lock.json 不存在,无法执行可重复构建"
exit 1
fi
if npm ci --include=dev && npm run build; then
rm -rf node_modules
print_success "前端生产资源构建完成"
else
print_error "前端构建失败"
print_info "可手动重试: cd ${PROJECT_DIR}/frontend && npm ci --include=dev && npm run build"
exit 1
fi
echo ""
}
create_env_file() {
print_step "创建配置文件..."
@@ -2212,6 +2237,15 @@ PUBLIC_PORT=${HTTP_PORT}
# CSRF 保护(生产环境强烈建议开启)
# 使用 Double Submit Cookie 模式防止跨站请求伪造攻击
ENABLE_CSRF=true
# 全局 API 限流与健康检查阈值
API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=600
HEALTH_MIN_DISK_FREE_BYTES=268435456
# 结构化日志
LOG_LEVEL=info
LOG_FORMAT=json
EOF
print_success "配置文件创建完成"
@@ -2369,7 +2403,9 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' blob: https:; connect-src 'self' https: ws: wss:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self';" always;
# 隐藏Nginx版本号
server_tokens off;
@@ -2388,11 +2424,21 @@ server {
# 前端静态文件
location / {
root ${PROJECT_DIR}/frontend;
root ${PROJECT_DIR}/frontend/dist;
index index.html;
try_files \$uri \$uri/ =404;
}
location /assets/ {
alias ${PROJECT_DIR}/frontend/dist/assets/;
expires 1y;
}
location /downloads/ {
alias ${PROJECT_DIR}/frontend/downloads/;
expires 1h;
}
# 后端API
location /api {
proxy_pass http://localhost:${BACKEND_PORT};
@@ -2404,6 +2450,7 @@ server {
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-Request-ID \$request_id;
# Cookie传递配置验证码session需要
proxy_set_header Cookie \$http_cookie;
@@ -2422,15 +2469,15 @@ server {
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-Request-ID \$request_id;
}
# 静态资源
location /libs {
alias ${PROJECT_DIR}/frontend/libs;
location /libs/ {
alias ${PROJECT_DIR}/frontend/dist/libs/;
expires 30d;
}
}
}
EOF
@@ -2656,7 +2703,9 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' blob: https:; connect-src 'self' https: ws: wss:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self';" always;
# 隐藏Nginx版本号
server_tokens off;
@@ -2675,11 +2724,21 @@ server {
# 前端静态文件
location / {
root ${PROJECT_DIR}/frontend;
root ${PROJECT_DIR}/frontend/dist;
index index.html;
try_files \$uri \$uri/ =404;
}
location /assets/ {
alias ${PROJECT_DIR}/frontend/dist/assets/;
expires 1y;
}
location /downloads/ {
alias ${PROJECT_DIR}/frontend/downloads/;
expires 1h;
}
# 后端API
location /api {
proxy_pass http://localhost:${BACKEND_PORT};
@@ -2691,6 +2750,7 @@ server {
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-Request-ID \$request_id;
# Cookie传递配置验证码session需要
proxy_set_header Cookie \$http_cookie;
@@ -2709,15 +2769,15 @@ server {
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-Request-ID \$request_id;
}
# 静态资源
location /libs {
alias ${PROJECT_DIR}/frontend/libs;
location /libs/ {
alias ${PROJECT_DIR}/frontend/dist/libs/;
expires 30d;
}
}
}
EOF
@@ -2792,7 +2852,9 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' blob: https:; connect-src 'self' https: ws: wss:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 隐藏Nginx版本号
@@ -2812,11 +2874,21 @@ server {
# 前端静态文件
location / {
root ${PROJECT_DIR}/frontend;
root ${PROJECT_DIR}/frontend/dist;
index index.html;
try_files \$uri \$uri/ =404;
}
location /assets/ {
alias ${PROJECT_DIR}/frontend/dist/assets/;
expires 1y;
}
location /downloads/ {
alias ${PROJECT_DIR}/frontend/downloads/;
expires 1h;
}
# 后端API
location /api {
proxy_pass http://localhost:${BACKEND_PORT};
@@ -2828,6 +2900,7 @@ server {
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-Request-ID \$request_id;
# Cookie传递配置验证码session需要
proxy_set_header Cookie \$http_cookie;
@@ -2846,15 +2919,15 @@ server {
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-Request-ID \$request_id;
}
# 静态资源
location /libs {
alias ${PROJECT_DIR}/frontend/libs;
location /libs/ {
alias ${PROJECT_DIR}/frontend/dist/libs/;
expires 30d;
}
}
}
EOF
@@ -3398,6 +3471,12 @@ update_backup_important_files() {
print_success "配置文件已备份"
fi
# 桌面安装包可能由管理端动态上传,不可随前端源码更新丢失
if [[ -d "${PROJECT_DIR}/frontend/downloads" ]]; then
cp -a "${PROJECT_DIR}/frontend/downloads" "$TEMP_BACKUP/frontend-downloads"
print_success "桌面安装包已备份"
fi
print_success "备份完成: $TEMP_BACKUP"
echo ""
}
@@ -3424,6 +3503,23 @@ update_pull_latest_code() {
# 克隆最新代码
git clone "$REPO_URL" "${PROJECT_NAME}-update"
# 在替换线上文件前完成前端构建,失败时旧版本仍保持完整。
if [[ -d "$TEMP_BACKUP/frontend-downloads" ]]; then
mkdir -p "/tmp/${PROJECT_NAME}-update/frontend/downloads"
cp -a "$TEMP_BACKUP/frontend-downloads/." "/tmp/${PROJECT_NAME}-update/frontend/downloads/"
fi
print_info "构建前端生产资源..."
cd "/tmp/${PROJECT_NAME}-update/frontend"
if ! npm ci --include=dev || ! npm run build; then
print_error "前端构建失败,线上文件未替换"
if command -v pm2 &> /dev/null; then
pm2 restart ${PROJECT_NAME}-backend || true
fi
exit 1
fi
rm -rf node_modules
# 更新前端文件
print_info "更新前端文件..."
if [[ -d "/tmp/${PROJECT_NAME}-update/frontend" ]]; then
@@ -3470,14 +3566,109 @@ update_pull_latest_code() {
print_success "配置文件已恢复"
fi
# 清理临时文件
# 临时源码可删除,数据备份保留到健康检查通过后再清理
rm -rf "/tmp/${PROJECT_NAME}-update"
rm -rf "$TEMP_BACKUP"
print_success "代码更新完成"
echo ""
}
update_migrate_nginx_config() {
print_step "迁移 Nginx 前端构建目录..."
local candidates=(
"/www/server/panel/vhost/nginx/${PROJECT_NAME}.conf"
"/etc/nginx/sites-available/${PROJECT_NAME}.conf"
"/etc/nginx/conf.d/${PROJECT_NAME}.conf"
)
local migrated=false
local backup_dir="$TEMP_BACKUP/nginx"
mkdir -p "$backup_dir"
for config_file in "${candidates[@]}"; do
[[ -f "$config_file" ]] || continue
migrated=true
local backup_name
backup_name=$(echo "$config_file" | tr '/ ' '__')
cp -a "$config_file" "$backup_dir/$backup_name"
sed -i "s|root ${PROJECT_DIR}/frontend;|root ${PROJECT_DIR}/frontend/dist;|g" "$config_file"
sed -i "s|location /libs {|location /libs/ {|g" "$config_file"
sed -i "s|alias ${PROJECT_DIR}/frontend/libs;|alias ${PROJECT_DIR}/frontend/dist/libs/;|g" "$config_file"
sed -i 's|Referrer-Policy "no-referrer-when-downgrade"|Referrer-Policy "strict-origin-when-cross-origin"|g' "$config_file"
if ! grep -q "add_header Permissions-Policy" "$config_file" || ! grep -q "add_header Content-Security-Policy" "$config_file"; then
local secured_file="${config_file}.security.$$"
local need_permissions=0
local need_csp=0
grep -q "add_header Permissions-Policy" "$config_file" || need_permissions=1
grep -q "add_header Content-Security-Policy" "$config_file" || need_csp=1
awk -v need_permissions="$need_permissions" -v need_csp="$need_csp" '
/add_header Referrer-Policy/ && !inserted_security {
print
if (need_permissions == 1) {
print " add_header Permissions-Policy \"camera=(), geolocation=(), microphone=()\" always;"
}
if (need_csp == 1) {
print " add_header Content-Security-Policy \"default-src \047self\047; script-src \047self\047 \047unsafe-inline\047 \047unsafe-eval\047; style-src \047self\047 \047unsafe-inline\047; img-src \047self\047 data: blob: https:; font-src \047self\047 data:; media-src \047self\047 blob: https:; connect-src \047self\047 https: ws: wss:; worker-src \047self\047 blob:; object-src \047none\047; base-uri \047self\047; frame-ancestors \047self\047; form-action \047self\047;\" always;"
}
inserted_security = 1
next
}
{ print }
' "$config_file" > "$secured_file"
mv "$secured_file" "$config_file"
fi
if ! grep -q "location /downloads/" "$config_file"; then
local migrated_file="${config_file}.migrate.$$"
awk -v project_dir="$PROJECT_DIR" '
/^[[:space:]]*# 静态资源/ && !inserted {
print " # 桌面安装包由管理端动态发布,不属于 Vite dist"
print " location /downloads/ {"
print " alias " project_dir "/frontend/downloads/;"
print " expires 1h;"
print " }"
print ""
inserted = 1
}
{ print }
' "$config_file" > "$migrated_file"
mv "$migrated_file" "$config_file"
fi
if ! grep -q "root ${PROJECT_DIR}/frontend/dist;" "$config_file" || ! grep -q "location /downloads/" "$config_file"; then
print_error "无法安全迁移 Nginx 配置: $config_file"
cp -a "$backup_dir/$backup_name" "$config_file"
return 1
fi
done
if [[ "$migrated" != "true" ]]; then
print_warning "未找到 install.sh 管理的 Nginx 配置,请手动确认 root 指向 ${PROJECT_DIR}/frontend/dist"
return 0
fi
if ! nginx -t; then
print_error "迁移后的 Nginx 配置校验失败,正在恢复"
for config_file in "${candidates[@]}"; do
[[ -f "$config_file" ]] || continue
local backup_name
backup_name=$(echo "$config_file" | tr '/ ' '__')
if [[ -f "$backup_dir/$backup_name" ]]; then
cp -a "$backup_dir/$backup_name" "$config_file"
fi
done
nginx -t || true
return 1
fi
restart_nginx_safe
print_success "Nginx 已切换到 Vite 构建目录"
echo ""
}
update_install_dependencies() {
print_step "更新后端依赖..."
@@ -3511,7 +3702,7 @@ update_install_dependencies() {
check_cpp_compiler
if [[ -d "node_modules" ]]; then
print_info "清理旧依赖..."
rm -rf node_modules package-lock.json
rm -rf node_modules
fi
print_info "正在重新安装依赖(可能需要几分钟)..."
@@ -3521,6 +3712,7 @@ update_install_dependencies() {
else
print_error "依赖更新失败"
print_warning "请检查错误日志: ~/.npm/_logs/"
return 1
fi
echo ""
@@ -3750,6 +3942,23 @@ update_patch_env() {
else
print_info ".env 已包含 ENABLE_CSRF保持不变"
fi
if ! grep -q "^API_RATE_LIMIT_MAX=" "${PROJECT_DIR}/backend/.env"; then
echo "API_RATE_LIMIT_WINDOW_MS=60000" >> "${PROJECT_DIR}/backend/.env"
echo "API_RATE_LIMIT_MAX=600" >> "${PROJECT_DIR}/backend/.env"
print_info "已补充全局 API 限流默认值"
fi
if ! grep -q "^HEALTH_MIN_DISK_FREE_BYTES=" "${PROJECT_DIR}/backend/.env"; then
echo "HEALTH_MIN_DISK_FREE_BYTES=268435456" >> "${PROJECT_DIR}/backend/.env"
print_info "已补充健康检查磁盘阈值"
fi
if ! grep -q "^LOG_FORMAT=" "${PROJECT_DIR}/backend/.env"; then
echo "LOG_LEVEL=info" >> "${PROJECT_DIR}/backend/.env"
echo "LOG_FORMAT=json" >> "${PROJECT_DIR}/backend/.env"
print_info "已启用 JSON 结构化日志"
fi
else
print_warning "未找到 ${PROJECT_DIR}/backend/.env请手动确认配置"
fi
@@ -3815,6 +4024,9 @@ update_main() {
# 拉取最新代码
update_pull_latest_code
# 旧版本 Nginx 指向 frontend 源码,需迁移到 dist
update_migrate_nginx_config
# 更新依赖
update_install_dependencies
@@ -3831,9 +4043,12 @@ update_main() {
if ! health_check; then
print_error "健康检查未通过,请检查日志"
print_info "查看日志: pm2 logs ${PROJECT_NAME}-backend"
print_info "更新备份保留在: $TEMP_BACKUP"
exit 1
fi
rm -rf "$TEMP_BACKUP"
# 检查版本
update_check_version
@@ -3951,6 +4166,9 @@ main() {
# 安装后端依赖
install_backend_dependencies
# 安装前端依赖并生成 dist
install_frontend_dependencies
# 创建配置文件
create_env_file