#!/bin/bash set -e # Exit immediately on error # Color definitions RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # Script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DOCKER_SCRIPTS_DIR="$SCRIPT_DIR/docker/scripts" # Logging helpers 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 show_help() { cat << EOF PrivyDrop Docker Deployment Script Usage: $0 [options] Options: --domain DOMAIN Specify domain (for HTTPS deployments) --mode MODE Deployment mode: lan-http|lan-tls|public|full lan-http: Intranet HTTP (fast start; no TLS) lan-tls: Intranet HTTPS (self-signed; dev/managed env only) public: Public HTTP + TURN server full: Domain + HTTPS (Let’s Encrypt) + TURN server --with-nginx Enable Nginx reverse proxy --with-turn Enable TURN server --with-sni443 Force enable 443 SNI routing (full; default enabled) --no-sni443 Disable 443 SNI routing (full; web listens directly on 443) --enable-web-https In lan-tls mode, enable self-signed HTTPS on 8443 (no HSTS) --le-email EMAIL Email for Let's Encrypt (recommended in full mode) --test-renewal Run 'certbot renew --dry-run' and reload services (verification) --clean Clean existing containers and data --help Show help Examples: $0 --mode lan-http # LAN HTTP quick start $0 --mode lan-http --with-turn # LAN HTTP with TURN (NAT-friendly) $0 --mode lan-tls --enable-web-https # LAN HTTPS (self-signed) on 8443 (dev/managed) $0 --mode public --with-turn # Public deployment + TURN server (no domain) $0 --mode full --domain example.com \\ --with-nginx --with-turn --le-email you@domain.com # Full HTTPS deployment (LE auto-issue/renew) $0 --clean # Clean deployment Requirements: - Docker Engine and Docker Compose V2 (command `docker compose`) EOF } # Parse command-line arguments parse_arguments() { DOMAIN_NAME="" DEPLOYMENT_MODE="" WITH_NGINX=false WITH_TURN=false CLEAN_MODE=false LE_EMAIL="" WITH_SNI443=false DISABLE_SNI443=false ENABLE_WEB_HTTPS=false TEST_RENEWAL=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 ;; --no-sni443) DISABLE_SNI443=true shift ;; --enable-web-https) ENABLE_WEB_HTTPS=true shift ;; --le-email) LE_EMAIL="$2" shift 2 ;; --test-renewal) TEST_RENEWAL=true shift ;; --clean) CLEAN_MODE=true shift ;; --help) show_help exit 0 ;; *) log_error "Unknown argument: $1" show_help exit 1 ;; esac done # Export variables for other scripts export DOMAIN_NAME export DEPLOYMENT_MODE export WITH_NGINX export WITH_TURN } # Check dependencies check_dependencies() { log_info "Checking dependencies..." 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 dependencies: ${missing_deps[*]}" echo "" echo "Please install the missing dependencies:" 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 plugin: 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 "Dependency checks passed" } # Install and prepare Let's Encrypt (certbot) ensure_certbot() { if command -v certbot >/dev/null 2>&1; then return 0 fi log_info "Installing certbot (requires 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 not found. Please install certbot manually or run on a supported system" exit 1 fi } # Write certbot deploy hook: copy certs and hot-reload services after renewal 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 is provided by certbot and points to live/ 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 # Hot-reload nginx; restart if it fails docker compose -f "\$COMPOSE_FILE" exec -T nginx nginx -s reload 2>/dev/null || \ docker compose -f "\$COMPOSE_FILE" restart nginx || true # Prefer sending HUP to coturn; restart if needed (ignore if disabled) 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" # Attempt to enable systemd timer if command -v systemctl >/dev/null 2>&1; then sudo systemctl enable --now certbot.timer 2>/dev/null || true fi } # Ensure renewal is scheduled daily: prefer systemd timer; fallback to cron ensure_renewal_scheduler() { if command -v systemctl >/dev/null 2>&1; then sudo systemctl enable --now certbot.timer 2>/dev/null || true return 0 fi # Fallback: cron job every 12 hours if [ -w /etc/cron.d ] || sudo test -d /etc/cron.d; then sudo bash -c 'cat > /etc/cron.d/certbot' << 'EOF' # Auto-renew Let's Encrypt certificates for PrivyDrop SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin 0 */12 * * * root certbot renew -q EOF fi } # Dry-run renewal test test_renewal() { log_info "Running certbot dry-run renewal test..." if ! command -v certbot >/dev/null 2>&1; then log_error "certbot not installed. Run full mode issuance first or install certbot manually" return 1 fi sudo certbot renew --dry-run || { log_error "certbot dry-run failed" return 1 } # Attempt hot-reload of nginx and coturn similar to deploy-hook docker compose exec -T nginx nginx -s reload 2>/dev/null || docker compose restart nginx || true docker compose exec -T coturn sh -c 'kill -HUP 1' 2>/dev/null || docker compose restart coturn || true log_success "Dry-run renewal completed; services reloaded" } # Issue via webroot and enable 443 config provision_letsencrypt_cert() { # Only in full mode with nginx enabled and domain set if [[ "$DEPLOYMENT_MODE" != "full" || "$WITH_NGINX" != "true" ]]; then return 0 fi if [[ -z "$DOMAIN_NAME" ]]; then log_warning "Full mode without --domain; skipping Let's Encrypt" return 0 fi if [[ -z "$LE_EMAIL" ]]; then log_warning "No --le-email specified; using --register-unsafely-without-email" fi ensure_certbot install_certbot_deploy_hook ensure_renewal_scheduler mkdir -p docker/letsencrypt-www docker/ssl # If certificates already exist (including -0001 lineage), skip issuance if [[ -f "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" ]] || ls -1d /etc/letsencrypt/live/${DOMAIN_NAME}* >/dev/null 2>&1; then log_info "Detected existing certificates/lineage; skipping initial issuance" else log_info "Issuing Let's Encrypt certificate via webroot..." local email_args="--email $LE_EMAIL" if [[ -z "$LE_EMAIL" ]]; then email_args="--register-unsafely-without-email" fi # Requires port 80 reachable and nginx running sudo certbot certonly --webroot -w "$(pwd)/docker/letsencrypt-www" \ -d "$DOMAIN_NAME" -d "turn.$DOMAIN_NAME" \ $email_args --agree-tos --non-interactive || { log_error "Certificate issuance failed; please check certbot output" return 1 } fi # Resolve lineage directory (supports -0001/-0002 suffixes) and copy to 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 "No valid certificate lineage directory found. Check /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 # Enable 443 config (certs ready): append only; pass SNI flag (enabled by default in full) local gen_args=(--mode full --domain "$DOMAIN_NAME" --no-clean --ssl-mode letsencrypt) [[ "$WITH_SNI443" == "true" ]] && gen_args+=(--enable-sni443) [[ "$DISABLE_SNI443" == "true" ]] && gen_args+=(--no-sni443) bash "$DOCKER_SCRIPTS_DIR/generate-config.sh" "${gen_args[@]}" || true # Hot-reload nginx to enable 443 docker compose exec -T nginx nginx -s reload || docker compose restart nginx } # Clean existing deployment clean_deployment() { if [[ "$CLEAN_MODE" == "true" ]]; then log_warning "Cleaning existing deployment..." # Stop and remove containers if [[ -f "docker-compose.yml" ]]; then docker compose down -v --remove-orphans 2>/dev/null || true fi # After graceful stop, force-clean named containers as fallback docker stop -t 10 privydrop-nginx privydrop-coturn 2>/dev/null || true docker rm -f privydrop-nginx privydrop-coturn 2>/dev/null || true # Fallback: remove project network (if present) docker network rm privydrop_privydrop-network 2>/dev/null || true # Remove images docker images | grep privydrop | awk '{print $3}' | xargs -r docker rmi -f 2>/dev/null || true # Clean configuration files rm -rf docker/nginx/conf.d/*.conf docker/ssl/* logs/* .env 2>/dev/null || true log_success "Cleanup complete" if [[ $# -eq 1 ]]; then # If only --clean parameter exit 0 fi fi } # Ensure TURN service starts when requested (--with-turn) ensure_turn_running() { if [[ "$WITH_TURN" != "true" ]]; then return 0 fi # If not running, start coturn via profile if ! docker compose ps | grep -q "privydrop-coturn"; then log_info "Starting TURN service (profile: turn)..." docker compose --profile turn up -d coturn || true fi } # Environment detection and configuration generation setup_environment() { log_info "Setting up environment..." # Ensure scripts are executable chmod +x "$DOCKER_SCRIPTS_DIR"/*.sh 2>/dev/null || true # Run environment detection local detect_args="" [[ -n "$DOMAIN_NAME" ]] && detect_args="--domain $DOMAIN_NAME" [[ -n "$DEPLOYMENT_MODE" ]] && detect_args="$detect_args --mode $DEPLOYMENT_MODE" [[ "$WITH_NGINX" == "true" ]] && detect_args="$detect_args --with-nginx" [[ "$WITH_SNI443" == "true" ]] && detect_args="$detect_args --enable-sni443" [[ "$DISABLE_SNI443" == "true" ]] && detect_args="$detect_args --no-sni443" [[ "$ENABLE_WEB_HTTPS" == "true" ]] && detect_args="$detect_args --enable-web-https" if ! bash "$DOCKER_SCRIPTS_DIR/detect-environment.sh" $detect_args; then log_error "Environment detection failed" exit 1 fi # Generate configuration files if ! bash "$DOCKER_SCRIPTS_DIR/generate-config.sh" $detect_args; then log_error "Configuration generation failed" exit 1 fi log_success "Environment setup complete" } # Build and start services deploy_services() { log_info "Building and starting services..." # Ensure log directories exist and relax permissions so containers (coturn/nginx etc.) can write logs 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 "Log directories prepared and permissions set: ./logs (mode 777)" # Stop existing services if docker compose ps | grep -q "Up"; then log_info "Stopping existing services..." docker compose down fi # Determine enabled services (Compose V2 requires --profile before the subcommand) local profiles="" if [[ "$WITH_NGINX" == "true" ]]; then profiles="$profiles --profile nginx" fi if [[ "$WITH_TURN" == "true" ]]; then profiles="$profiles --profile turn" fi # Build images (parallel first, fall back to serial on failure) log_info "Building Docker images..." set +e docker compose build --parallel local build_status=$? set -e if [[ $build_status -ne 0 ]]; then log_warning "Parallel build failed; falling back to serial build..." docker compose build fi # Start services (--profile must precede up) log_info "Starting services..." # shellcheck disable=SC2086 docker compose $profiles up -d log_success "Services started" } # Wait for services to be ready wait_for_services() { log_info "Waiting for services to be ready..." 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 # Check backend health if curl -f http://localhost:3001/health &> /dev/null; then backend_ready=true fi # Check frontend health 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 "All services are ready" return 0 else log_error "Service startup timed out" log_info "View service status: docker compose ps" log_info "View service logs: docker compose logs -f" return 1 fi } # Run post-deployment checks post_deployment_checks() { log_info "Running post-deployment checks..." # Check container status log_info "Checking container status..." docker compose ps # In full+nginx, add HTTPS health check (if domain defined) 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 "Test: HTTPS health check https://$dname/api/health" if curl -fsS "https://$dname/api/health" >/dev/null; then log_success "HTTPS health check passed" else log_warning "HTTPS health check failed. If the certificate was just issued, wait a bit or run: bash docker/scripts/generate-config.sh --mode full --domain $dname --no-clean && docker compose exec -T nginx nginx -s reload" fi fi fi # Run health-check tests if [[ -f "test-health-apis.sh" ]]; then log_info "Running health-check tests..." if bash test-health-apis.sh; then log_success "Health-check tests passed" else log_warning "Health-check tests failed, but services may still be working" fi fi log_success "Post-deployment checks complete" } # Show deployment results show_deployment_info() { echo "" echo -e "${GREEN}🎉 PrivyDrop deployment complete!${NC}" echo "" # Read configuration local local_ip="" local public_ip="" local frontend_port="" local backend_port="" local https_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) https_port=$(grep "HTTPS_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}📋 Access Info:${NC}" # Determine if public scenario (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 # For public scenarios, prefer domain, then public IP if [[ -n "$domain_name" ]]; then if [[ "$WITH_NGINX" == "true" || "$deployment_mode" == "full" ]]; then echo " Public access: https://$domain_name" echo " API: https://$domain_name" else echo " Public access: http://$domain_name:${frontend_port:-3002}" echo " API: http://$domain_name:${backend_port:-3001}" fi elif [[ -n "$public_ip" ]]; then if [[ "$WITH_NGINX" == "true" ]]; then echo " Public access: http://$public_ip" echo " API: http://$public_ip" else echo " Public access: http://$public_ip:${frontend_port:-3002}" echo " API: http://$public_ip:${backend_port:-3001}" fi else # Fallback: show LAN and localhost if public IP is unavailable echo " Frontend: http://localhost:${frontend_port:-3002}" echo " Backend API: http://localhost:${backend_port:-3001}" fi else # Private/basic: localhost + LAN if [[ "$WITH_NGINX" == "true" ]]; then # When Nginx is enabled and frontend uses same-origin API, prefer the gateway as the primary entry echo " Frontend: http://localhost" echo " API: http://localhost" else echo " Frontend: http://localhost:${frontend_port:-3002}" echo " Backend API: http://localhost:${backend_port:-3001}" fi if [[ -n "$local_ip" ]] && [[ "$local_ip" != "127.0.0.1" ]]; then echo "" echo -e "${BLUE}🌐 LAN Access:${NC}" if [[ "$WITH_NGINX" == "true" ]]; then echo " Frontend: http://$local_ip" echo " API: http://$local_ip" else echo " Frontend: http://$local_ip:${frontend_port:-3002}" echo " Backend API: http://$local_ip:${backend_port:-3001}" fi fi fi if [[ "$WITH_NGINX" == "true" ]]; then echo "" echo -e "${BLUE}🔀 Nginx Proxy:${NC}" if [[ -n "$domain_name" ]]; then echo " HTTP: http://$domain_name" if [[ -f "docker/ssl/server-cert.pem" ]]; then echo " HTTPS: https://$domain_name" fi elif [[ -n "$public_ip" ]]; then echo " HTTP: http://$public_ip" if [[ -f "docker/ssl/server-cert.pem" ]]; then # In non-domain cases, show HTTPS with explicit port (e.g., lan-tls uses 8443) if [[ -n "$https_port" && "$https_port" != "443" ]]; then echo " HTTPS: https://$public_ip:$https_port" else echo " HTTPS: https://$public_ip" fi fi else echo " HTTP: http://localhost" if [[ -f "docker/ssl/server-cert.pem" ]]; then # Show correct HTTPS endpoint based on configured port if [[ -n "$https_port" && "$https_port" != "443" ]]; then echo " HTTPS: https://localhost:$https_port" if [[ -n "$local_ip" && "$local_ip" != "127.0.0.1" ]]; then echo " HTTPS (LAN): https://$local_ip:$https_port" fi else echo " HTTPS: https://localhost" fi fi fi fi echo "" echo -e "${BLUE}🔧 Management Commands:${NC}" echo " Status: docker compose ps" echo " Logs: docker compose logs -f [service]" echo " Restart: docker compose restart [service]" echo " Stop: docker compose down" echo " Full cleanup: $0 --clean" if [[ -f "docker/ssl/ca-cert.pem" ]]; then echo "" echo -e "${BLUE}🔒 SSL Certificates:${NC}" echo " CA certificate: docker/ssl/ca-cert.pem" echo " To trust HTTPS, import the CA certificate into your browser" 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 Server:${NC}" # Prefer domain for TURN info; otherwise show public 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 (if 443 SNI split is configured)" 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 " Username: ${turn_username:-privydrop}" echo " Password: (stored in .env)" fi echo "" echo -e "${YELLOW}💡 Tips:${NC}" echo " - First run may take several minutes to download and build images" echo " - If issues occur, check logs: docker compose logs -f" echo " - More help: $0 --help" echo "" # Public scenario: for domain + HTTPS setup steps, see docs if [[ "$is_public" == "true" && -z "$domain_name" ]]; then echo -e "${BLUE}🌍 Domain + HTTPS guide:${NC} see docs/DEPLOYMENT_docker.md or docs/DEPLOYMENT_docker.zh-CN.md" fi } # Main function main() { echo -e "${BLUE}=== PrivyDrop Docker One-Click Deployment ===${NC}" echo "" # Parse command-line arguments parse_arguments "$@" # Check dependencies check_dependencies echo "" # If only testing renewal, run and exit if [[ "$TEST_RENEWAL" == "true" ]]; then test_renewal && exit 0 || exit 1 fi # Clean mode clean_deployment # If only cleaning (no other args), exit early to skip env detection if [[ "$CLEAN_MODE" == "true" && -z "$DEPLOYMENT_MODE" && "$WITH_NGINX" == "false" && "$WITH_TURN" == "false" && -z "$DOMAIN_NAME" ]]; then log_success "Cleanup complete (clean-only mode). Exiting." exit 0 fi # Environment setup setup_environment echo "" # Deploy services deploy_services echo "" # If full + nginx, automatically issue certs and enable 443 provision_letsencrypt_cert || true if [[ "$DEPLOYMENT_MODE" == "full" && "$WITH_NGINX" == "true" ]]; then log_info "Refreshing edge containers to apply generated HTTPS/SNI config..." docker compose up -d --force-recreate nginx >/dev/null if [[ "$WITH_TURN" == "true" ]]; then docker compose up -d --force-recreate coturn >/dev/null fi fi # Ensure TURN is running (when requested with --with-turn) ensure_turn_running || true # Wait for services to be ready if wait_for_services; then echo "" post_deployment_checks show_deployment_info else log_error "Deployment failed. Please check logs: docker compose logs" exit 1 fi } # Trap interrupt signals trap 'log_warning "Deployment interrupted"; exit 1' INT TERM # Run main function main "$@"