Files
PrivyDrop/deploy.sh
T
david_bai 7809373f88 feat(nginx,sni): enable SNI-based 443 multiplexing (turns:443) and wiring
- docker/scripts/generate-config.sh
	- Add --enable-sni443/--no-sni443 flags; default enable in full+domain.
	- Generate Nginx stream{} with ssl_preread SNI routing: turn.<domain> -> coturn:5349; others -> web:8443.
	- When SNI is enabled, serve HTTPS on 8443 (http layer); otherwise keep 443.
- deploy.sh:
	- Add --with-sni443 and propagate to config generation and LE provisioning.
	- No compose changes required; 8443 remains internal.
- Notes:
	- Backward compatible. SNI is auto-enabled for full+domain, can be toggled with flags.
	- Leverages existing LE automation and TURN cert reuse.
2025-10-07 21:51:28 +08:00

668 lines
22 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
set -e # 遇到错误立即退出
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 脚本目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOCKER_SCRIPTS_DIR="$SCRIPT_DIR/docker/scripts"
# 日志函数
log_info() {
echo -e "${BLUE}$1${NC}"
}
log_success() {
echo -e "${GREEN}$1${NC}"
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
log_error() {
echo -e "${RED}$1${NC}"
}
# 显示帮助信息
show_help() {
cat << EOF
PrivyDrop Docker 一键部署脚本
用法: $0 [选项]
选项:
--domain DOMAIN 指定域名 (用于HTTPS部署)
--mode MODE 部署模式: basic|public|full|private
basic/private: 内网HTTP部署 (默认,private 将跳过网络检测)
public: 公网HTTP部署 + TURN服务器
full: 完整HTTPS部署 + TURN服务器
--with-nginx 启用Nginx反向代理
--with-turn 启用TURN服务器
--with-sni443 启用 443 SNI 分流 (full 模式默认启用)
--le-email EMAIL 使用 Let's Encrypt 时的证书邮箱(full 模式推荐传入)
--clean 清理现有容器和数据
--help 显示帮助信息
示例:
$0 # 基础部署
$0 --mode public --with-turn # 公网部署 + TURN服务器
$0 --domain example.com --mode full # 完整HTTPS部署
$0 --clean # 清理部署
要求:
- Docker Engine 和 Docker Compose V2(命令为 `docker compose`
EOF
}
# 解析命令行参数
parse_arguments() {
DOMAIN_NAME=""
DEPLOYMENT_MODE=""
WITH_NGINX=false
WITH_TURN=false
CLEAN_MODE=false
LE_EMAIL=""
WITH_SNI443=false
while [[ $# -gt 0 ]]; do
case $1 in
--domain)
DOMAIN_NAME="$2"
shift 2
;;
--mode)
DEPLOYMENT_MODE="$2"
shift 2
;;
--with-nginx)
WITH_NGINX=true
shift
;;
--with-turn)
WITH_TURN=true
shift
;;
--with-sni443)
WITH_SNI443=true
shift
;;
--le-email)
LE_EMAIL="$2"
shift 2
;;
--clean)
CLEAN_MODE=true
shift
;;
--help)
show_help
exit 0
;;
*)
log_error "未知参数: $1"
show_help
exit 1
;;
esac
done
# 导出变量供其他脚本使用
export DOMAIN_NAME
export DEPLOYMENT_MODE
export WITH_NGINX
export WITH_TURN
}
# 检查依赖
check_dependencies() {
log_info "检查依赖..."
local missing_deps=()
if ! command -v docker &> /dev/null; then
missing_deps+=("docker")
fi
if ! docker compose version &> /dev/null; then
missing_deps+=("docker compose (V2)")
fi
if ! command -v curl &> /dev/null; then
missing_deps+=("curl")
fi
if ! command -v openssl &> /dev/null; then
missing_deps+=("openssl")
fi
if [[ ${#missing_deps[@]} -gt 0 ]]; then
log_error "缺少依赖: ${missing_deps[*]}"
echo ""
echo "请安装缺少的依赖:"
for dep in "${missing_deps[@]}"; do
case $dep in
docker)
echo " Docker: https://docs.docker.com/get-docker/"
;;
"docker compose (V2)")
echo " Docker Compose V2 插件: https://docs.docker.com/compose/install/"
;;
curl)
echo " curl: sudo apt-get install curl (Ubuntu/Debian)"
;;
openssl)
echo " openssl: sudo apt-get install openssl (Ubuntu/Debian)"
;;
esac
done
exit 1
fi
log_success "依赖检查通过"
}
# 安装并准备 Let's Encryptcertbot
ensure_certbot() {
if command -v certbot >/dev/null 2>&1; then
return 0
fi
log_info "安装 certbot (需要 sudo 权限)..."
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y && sudo apt-get install -y certbot
else
log_error "未检测到 apt-get,请手动安装 certbot 或在支持的系统上运行"
exit 1
fi
}
# 写入 certbot 部署钩子:续期后复制证书并热重载服务
install_certbot_deploy_hook() {
local repo_dir="$SCRIPT_DIR"
local hook_dir="/etc/letsencrypt/renewal-hooks/deploy"
local hook_file="$hook_dir/privydrop-reload.sh"
local compose_file="$repo_dir/docker-compose.yml"
sudo mkdir -p "$hook_dir"
sudo bash -c "cat > '$hook_file'" << EOF
#!/bin/bash
set -e
REPO_DIR="$repo_dir"
COMPOSE_FILE="$compose_file"
# RENEWED_LINEAGE 由 certbot 传入,指向 live/<domain>
if [[ -z "\$RENEWED_LINEAGE" ]]; then
exit 0
fi
cp "\$RENEWED_LINEAGE/fullchain.pem" "\$REPO_DIR/docker/ssl/server-cert.pem"
cp "\$RENEWED_LINEAGE/privkey.pem" "\$REPO_DIR/docker/ssl/server-key.pem"
chmod 600 "\$REPO_DIR/docker/ssl/server-key.pem" || true
# 热重载 nginx,如失败则重启
docker compose -f "\$COMPOSE_FILE" exec -T nginx nginx -s reload 2>/dev/null || \
docker compose -f "\$COMPOSE_FILE" restart nginx || true
# 优先向 coturn 发送 HUP,不行则重启(若未启用则忽略)
docker compose -f "\$COMPOSE_FILE" exec -T coturn sh -c 'kill -HUP 1' 2>/dev/null || \
docker compose -f "\$COMPOSE_FILE" restart coturn || true
EOF
sudo chmod +x "$hook_file"
# 尝试启用 systemd 定时器
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl enable --now certbot.timer 2>/dev/null || true
fi
}
# 使用 webroot 首次签发并启用 443 配置
provision_letsencrypt_cert() {
# 仅在 full 模式且启用 nginx 且存在域名时执行
if [[ "$DEPLOYMENT_MODE" != "full" || "$WITH_NGINX" != "true" ]]; then
return 0
fi
if [[ -z "$DOMAIN_NAME" ]]; then
log_warning "full 模式未指定 --domain,跳过 Lets Encrypt"
return 0
fi
if [[ -z "$LE_EMAIL" ]]; then
log_warning "未指定 --le-email,将使用 --register-unsafely-without-email"
fi
ensure_certbot
install_certbot_deploy_hook
mkdir -p docker/letsencrypt-www docker/ssl
# 如果证书已存在(含 -0001 谱系),跳过签发
if [[ -f "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" ]] || ls -1d /etc/letsencrypt/live/${DOMAIN_NAME}* >/dev/null 2>&1; then
log_info "检测到已存在的证书/谱系,跳过首次签发"
else
log_info "通过 webroot 模式签发 Let's Encrypt 证书..."
local email_args="--email $LE_EMAIL"
if [[ -z "$LE_EMAIL" ]]; then
email_args="--register-unsafely-without-email"
fi
# 需要 80 端口可达且 nginx 已启动
sudo certbot certonly --webroot -w "$(pwd)/docker/letsencrypt-www" \
-d "$DOMAIN_NAME" -d "turn.$DOMAIN_NAME" \
$email_args --agree-tos --non-interactive || {
log_error "证书签发失败,请查看 certbot 输出"
return 1
}
fi
# 解析谱系目录(兼容 -0001/-0002 后缀)并复制到 docker/ssl
local lineage_dir
lineage_dir=$(readlink -f "/etc/letsencrypt/live/$DOMAIN_NAME" 2>/dev/null || true)
if [[ -z "$lineage_dir" || ! -d "$lineage_dir" ]]; then
lineage_dir=$(ls -1d /etc/letsencrypt/live/${DOMAIN_NAME}* 2>/dev/null | sort | tail -1)
fi
if [[ -z "$lineage_dir" || ! -f "$lineage_dir/fullchain.pem" ]]; then
log_error "未找到有效证书谱系目录,请检查 /etc/letsencrypt/live/${DOMAIN_NAME}*"
return 1
fi
sudo cp "$lineage_dir/fullchain.pem" docker/ssl/server-cert.pem
sudo cp "$lineage_dir/privkey.pem" docker/ssl/server-key.pem
sudo chmod 600 docker/ssl/server-key.pem || true
# 启用 443 配置(证书已就绪):不清理,仅追加;传递 SNI 开关(默认 full 启用)
local gen_args=(--mode full --domain "$DOMAIN_NAME" --no-clean --ssl-mode letsencrypt)
[[ "$WITH_SNI443" == "true" ]] && gen_args+=(--enable-sni443)
bash "$DOCKER_SCRIPTS_DIR/generate-config.sh" "${gen_args[@]}" || true
# 热重载 nginx 以启用 443
docker compose exec -T nginx nginx -s reload || docker compose restart nginx
}
# 清理现有部署
clean_deployment() {
if [[ "$CLEAN_MODE" == "true" ]]; then
log_warning "清理现有部署..."
# 停止并删除容器
if [[ -f "docker-compose.yml" ]]; then
docker compose down -v --remove-orphans 2>/dev/null || true
fi
# 优雅停止后兜底强制清理命名容器
docker stop -t 10 privydrop-nginx privydrop-coturn 2>/dev/null || true
docker rm -f privydrop-nginx privydrop-coturn 2>/dev/null || true
# 兜底清理项目网络(若存在)
docker network rm privydrop_privydrop-network 2>/dev/null || true
# 删除镜像
docker images | grep privydrop | awk '{print $3}' | xargs -r docker rmi -f 2>/dev/null || true
# 清理配置文件
rm -rf docker/nginx/conf.d/*.conf docker/ssl/* logs/* .env 2>/dev/null || true
log_success "清理完成"
if [[ $# -eq 1 ]]; then # 如果只有--clean参数
exit 0
fi
fi
}
# 确保 TURN 服务按需启动(当指定 --with-turn 时)
ensure_turn_running() {
if [[ "$WITH_TURN" != "true" ]]; then
return 0
fi
# 未运行则显式启用 profile 启动 coturn
if ! docker compose ps | grep -q "privydrop-coturn"; then
log_info "启动 TURN 服务 (profile: turn)..."
docker compose --profile turn up -d coturn || true
fi
}
# 环境检测和配置生成
setup_environment() {
log_info "设置环境..."
# 确保脚本可执行
chmod +x "$DOCKER_SCRIPTS_DIR"/*.sh 2>/dev/null || true
# 运行环境检测
local detect_args=""
[[ -n "$DOMAIN_NAME" ]] && detect_args="--domain $DOMAIN_NAME"
[[ -n "$DEPLOYMENT_MODE" ]] && detect_args="$detect_args --mode $DEPLOYMENT_MODE"
[[ "$WITH_SNI443" == "true" ]] && detect_args="$detect_args --enable-sni443"
if ! bash "$DOCKER_SCRIPTS_DIR/detect-environment.sh" $detect_args; then
log_error "环境检测失败"
exit 1
fi
# 生成配置文件
if ! bash "$DOCKER_SCRIPTS_DIR/generate-config.sh" $detect_args; then
log_error "配置生成失败"
exit 1
fi
log_success "环境设置完成"
}
# 构建和启动服务
deploy_services() {
log_info "构建和启动服务..."
# 确保日志目录存在并放宽权限,避免容器无法写日志(coturn/nginx 等)
mkdir -p logs logs/nginx logs/backend logs/frontend logs/coturn 2>/dev/null || true
chmod 777 -R logs 2>/dev/null || true
log_info "日志目录已准备并授权: ./logs (权限 777)"
# 停止现有服务
if docker compose ps | grep -q "Up"; then
log_info "停止现有服务..."
docker compose down
fi
# 确定启用的服务(Compose V2 需要将 --profile 放在子命令之前)
local profiles=""
if [[ "$WITH_NGINX" == "true" ]]; then
profiles="$profiles --profile nginx"
fi
if [[ "$WITH_TURN" == "true" ]]; then
profiles="$profiles --profile turn"
fi
# 构建镜像(先并行,失败则串行回退)
log_info "构建Docker镜像..."
set +e
docker compose build --parallel
local build_status=$?
set -e
if [[ $build_status -ne 0 ]]; then
log_warning "并行构建失败,回退为串行构建..."
docker compose build
fi
# 启动服务(--profile 需置于 up 之前)
log_info "启动服务..."
# shellcheck disable=SC2086
docker compose $profiles up -d
log_success "服务启动完成"
}
# 等待服务就绪
wait_for_services() {
log_info "等待服务就绪..."
local max_attempts=60
local attempt=0
local services_ready=false
while [[ $attempt -lt $max_attempts ]]; do
local backend_ready=false
local frontend_ready=false
# 检查后端健康状态
if curl -f http://localhost:3001/health &> /dev/null; then
backend_ready=true
fi
# 检查前端健康状态
if curl -f http://localhost:3002/api/health &> /dev/null; then
frontend_ready=true
fi
if [[ "$backend_ready" == "true" ]] && [[ "$frontend_ready" == "true" ]]; then
services_ready=true
break
fi
attempt=$((attempt + 1))
echo -n "."
sleep 2
done
echo ""
if [[ "$services_ready" == "true" ]]; then
log_success "所有服务已就绪"
return 0
else
log_error "服务启动超时"
log_info "查看服务状态: docker compose ps"
log_info "查看服务日志: docker compose logs -f"
return 1
fi
}
# 运行部署后检查
post_deployment_checks() {
log_info "运行部署后检查..."
# 检查容器状态
log_info "检查容器状态..."
docker compose ps
# full+nginx 场景追加 HTTPS 健康检查(若定义了域名)
if [[ -f ".env" ]]; then
local dep_mode="$(grep "DEPLOYMENT_MODE=" .env | cut -d'=' -f2)"
local dname="$(grep "DOMAIN_NAME=" .env | cut -d'=' -f2)"
if [[ "$dep_mode" == "full" && -n "$dname" ]]; then
log_info "测试: HTTPS 健康检查 https://$dname/api/health"
if curl -fsS "https://$dname/api/health" >/dev/null; then
log_success "HTTPS 健康检查通过"
else
log_warning "HTTPS 健康检查失败。若证书刚签发,请稍等或执行: bash docker/scripts/generate-config.sh --mode full --domain $dname --no-clean && docker compose exec -T nginx nginx -s reload"
fi
fi
fi
# 运行健康检查测试
if [[ -f "test-health-apis.sh" ]]; then
log_info "运行健康检查测试..."
if bash test-health-apis.sh; then
log_success "健康检查测试通过"
else
log_warning "健康检查测试失败,但服务可能仍然正常"
fi
fi
log_success "部署后检查完成"
}
# 显示部署结果
show_deployment_info() {
echo ""
echo -e "${GREEN}🎉 PrivyDrop 部署完成!${NC}"
echo ""
# 读取配置信息
local local_ip=""
local public_ip=""
local frontend_port=""
local backend_port=""
local deployment_mode=""
local network_mode=""
local domain_name=""
local turn_enabled_env=""
if [[ -f ".env" ]]; then
local_ip=$(grep "LOCAL_IP=" .env | cut -d'=' -f2)
public_ip=$(grep "PUBLIC_IP=" .env | cut -d'=' -f2)
frontend_port=$(grep "FRONTEND_PORT=" .env | cut -d'=' -f2)
backend_port=$(grep "BACKEND_PORT=" .env | cut -d'=' -f2)
deployment_mode=$(grep "DEPLOYMENT_MODE=" .env | cut -d'=' -f2)
network_mode=$(grep "NETWORK_MODE=" .env | cut -d'=' -f2)
domain_name=$(grep "DOMAIN_NAME=" .env | cut -d'=' -f2)
turn_enabled_env=$(grep "TURN_ENABLED=" .env | cut -d'=' -f2)
fi
echo -e "${BLUE}📋 访问信息:${NC}"
# 判定是否公网场景(public/full
local is_public="false"
if [[ "$deployment_mode" == "public" || "$deployment_mode" == "full" || "$network_mode" == "public" ]]; then
is_public="true"
fi
if [[ "$is_public" == "true" ]]; then
# 公网展示优先域名,其次公网IP
if [[ -n "$domain_name" ]]; then
if [[ "$WITH_NGINX" == "true" || "$deployment_mode" == "full" ]]; then
echo " 公网访问: https://$domain_name"
echo " API 地址: https://$domain_name"
else
echo " 公网访问: http://$domain_name:${frontend_port:-3002}"
echo " API 地址: http://$domain_name:${backend_port:-3001}"
fi
elif [[ -n "$public_ip" ]]; then
echo " 公网访问: http://$public_ip:${frontend_port:-3002}"
echo " API 地址: http://$public_ip:${backend_port:-3001}"
else
# 回退:无法获取公网IP时给出局域网与本机
echo " 前端应用: http://localhost:${frontend_port:-3002}"
echo " 后端API: http://localhost:${backend_port:-3001}"
fi
else
# 内网/基础模式:本机+局域网
echo " 前端应用: http://localhost:${frontend_port:-3002}"
echo " 后端API: http://localhost:${backend_port:-3001}"
if [[ -n "$local_ip" ]] && [[ "$local_ip" != "127.0.0.1" ]]; then
echo ""
echo -e "${BLUE}🌐 局域网访问:${NC}"
echo " 前端应用: http://$local_ip:${frontend_port:-3002}"
echo " 后端API: http://$local_ip:${backend_port:-3001}"
fi
fi
if [[ "$WITH_NGINX" == "true" ]]; then
echo ""
echo -e "${BLUE}🔀 Nginx代理:${NC}"
if [[ -n "$domain_name" ]]; then
echo " HTTP: http://$domain_name"
[[ -f "docker/ssl/server-cert.pem" ]] && echo " HTTPS: https://$domain_name"
elif [[ -n "$public_ip" ]]; then
echo " HTTP: http://$public_ip"
[[ -f "docker/ssl/server-cert.pem" ]] && echo " HTTPS: https://$public_ip"
else
echo " HTTP: http://localhost"
[[ -f "docker/ssl/server-cert.pem" ]] && echo " HTTPS: https://localhost"
fi
fi
echo ""
echo -e "${BLUE}🔧 管理命令:${NC}"
echo " 查看状态: docker compose ps"
echo " 查看日志: docker compose logs -f [服务名]"
echo " 重启服务: docker compose restart [服务名]"
echo " 停止服务: docker compose down"
echo " 完全清理: $0 --clean"
if [[ -f "docker/ssl/ca-cert.pem" ]]; then
echo ""
echo -e "${BLUE}🔒 SSL证书:${NC}"
echo " CA证书: docker/ssl/ca-cert.pem"
echo " 要信任HTTPS连接,请将CA证书导入浏览器"
fi
if [[ "$WITH_TURN" == "true" || "$turn_enabled_env" == "true" ]]; then
local turn_username=""
local turn_realm=""
if [[ -f ".env" ]]; then
turn_username=$(grep "TURN_USERNAME=" .env | cut -d'=' -f2)
turn_realm=$(grep "TURN_REALM=" .env | cut -d'=' -f2)
fi
echo ""
echo -e "${BLUE}🔄 TURN服务器:${NC}"
# 展示优先域名的 TURN 信息,否则展示公网IP
if [[ -n "$domain_name" ]]; then
echo " STUN: stun:${domain_name}:3478"
echo " TURN (UDP): turn:${domain_name}:3478"
echo " TURN (TLS): turns:turn.${domain_name}:443 (如已配置 443 SNI 分流)"
elif [[ -n "$public_ip" ]]; then
echo " STUN: stun:${public_ip}:3478"
echo " TURN: turn:${public_ip}:3478"
else
echo " STUN: stun:${local_ip}:3478"
echo " TURN: turn:${local_ip}:3478"
fi
echo " 用户名: ${turn_username:-privydrop}"
echo " 密码: (保存在.env文件中)"
fi
echo ""
echo -e "${YELLOW}💡 提示:${NC}"
echo " - 首次启动可能需要几分钟来下载和构建镜像"
echo " - 如遇问题,请查看日志: docker compose logs -f"
echo " - 更多帮助: $0 --help"
echo ""
# 公网场景追加:如何测试域名(HTTPS+Nginx)
if [[ "$is_public" == "true" && -z "$domain_name" ]]; then
echo -e "${BLUE}🌍 公网域名部署(HTTPS + Nginx)快速测试:${NC}"
echo " 1) 将你的域名 A 记录解析到 ${public_ip:-<server-ip>}"
echo " 可选:将 turn.<your-domain> 也解析到同一IP,用于 TURN 主机名"
echo " 2) 运行: ./deploy.sh --mode full --domain <your-domain> --with-nginx --with-turn"
echo " 3) 放行端口: 80, 443, 3478/udp, 5349/tcp, 5349/udp"
echo " 4) 验证: https://<your-domain> 正常打开,/api/health 返回 200"
echo " WebRTC: 打开 webrtc-internals,观察是否出现 relay 候选 (TURN)"
echo " 注: 目前 Docker 版本未启用 443 SNI 转发至 coturn,如需 turns:443 请后续启用 stream 分流。"
echo ""
fi
}
# 主函数
main() {
echo -e "${BLUE}=== PrivyDrop Docker 一键部署 ===${NC}"
echo ""
# 解析命令行参数
parse_arguments "$@"
# 检查依赖
check_dependencies
echo ""
# 清理模式
clean_deployment
# 若仅执行清理(未指定其它参数),直接退出,避免进入环境检测
if [[ "$CLEAN_MODE" == "true" && -z "$DEPLOYMENT_MODE" && "$WITH_NGINX" == "false" && "$WITH_TURN" == "false" && -z "$DOMAIN_NAME" ]]; then
log_success "清理完成(仅清理模式),已退出。"
exit 0
fi
# 环境设置
setup_environment
echo ""
# 部署服务
deploy_services
echo ""
# 若为 full + nginx,自动签发证书并启用 443
provision_letsencrypt_cert || true
# 确保 TURN 启动(当请求了 --with-turn 时)
ensure_turn_running || true
# 等待服务就绪
if wait_for_services; then
echo ""
post_deployment_checks
show_deployment_info
else
log_error "部署失败,请检查日志: docker compose logs"
exit 1
fi
}
# 捕获中断信号
trap 'log_warning "部署被中断"; exit 1' INT TERM
# 运行主函数
main "$@"