From 0fc49d0032a2b8bde08555cea8fff980c5209625 Mon Sep 17 00:00:00 2001 From: doomtube Date: Tue, 6 Jan 2026 04:49:00 -0500 Subject: [PATCH] Fix: Let's Encrypt status detection and auto-generate .env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminController: Detect existing SSL certificates from /etc/letsencrypt and update database status automatically (fixes status showing "none" when cert was obtained via cloud-init) - docker-compose.prod.yml: Mount /etc/letsencrypt to backend container - cloud-init: Auto-generate .env with secure random secrets on first boot (DB_PASSWORD, JWT_SECRET, REDIS_PASSWORD, OME_API_TOKEN, NAKAMA keys) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/controllers/AdminController.cpp | 56 +++++++++- docker-compose.prod.yml | 1 + .../modules/app_server/cloud-init.yaml.tpl | 102 ++++++++++++++++-- 3 files changed, 149 insertions(+), 10 deletions(-) diff --git a/backend/src/controllers/AdminController.cpp b/backend/src/controllers/AdminController.cpp index e832463..9c4fb95 100644 --- a/backend/src/controllers/AdminController.cpp +++ b/backend/src/controllers/AdminController.cpp @@ -2956,10 +2956,13 @@ void AdminController::getSSLSettings(const HttpRequestPtr &req, *dbClient << "SELECT domain, acme_email, certificate_status, certificate_expiry, " "last_renewal_attempt, last_renewal_error, auto_renewal_enabled, updated_at " "FROM ssl_settings WHERE id = 1" - >> [callback](const Result& r) { + >> [callback, dbClient](const Result& r) { Json::Value resp; resp["success"] = true; + std::string dbStatus = "none"; + std::string domain = ""; + if (r.empty()) { // Return defaults if no settings exist resp["domain"] = ""; @@ -2971,9 +2974,12 @@ void AdminController::getSSLSettings(const HttpRequestPtr &req, resp["autoRenewalEnabled"] = true; } else { const auto& row = r[0]; - resp["domain"] = row["domain"].isNull() ? "" : row["domain"].as(); + domain = row["domain"].isNull() ? "" : row["domain"].as(); + dbStatus = row["certificate_status"].as(); + + resp["domain"] = domain; resp["acmeEmail"] = row["acme_email"].isNull() ? "" : row["acme_email"].as(); - resp["certificateStatus"] = row["certificate_status"].as(); + resp["certificateStatus"] = dbStatus; if (!row["certificate_expiry"].isNull()) { resp["certificateExpiry"] = row["certificate_expiry"].as(); @@ -2996,6 +3002,50 @@ void AdminController::getSSLSettings(const HttpRequestPtr &req, resp["autoRenewalEnabled"] = row["auto_renewal_enabled"].as(); } + // Check for existing certificates on disk if DB shows "none" + // This handles the case where certbot ran during cloud-init + if (dbStatus == "none") { + try { + // Check /etc/letsencrypt/live directory for any certificates + std::string letsencryptDir = "/etc/letsencrypt/live"; + if (std::filesystem::exists(letsencryptDir) && std::filesystem::is_directory(letsencryptDir)) { + for (const auto& entry : std::filesystem::directory_iterator(letsencryptDir)) { + if (entry.is_directory()) { + std::string certPath = entry.path().string() + "/fullchain.pem"; + if (std::filesystem::exists(certPath)) { + std::string detectedDomain = entry.path().filename().string(); + LOG_INFO << "Detected existing SSL certificate for: " << detectedDomain; + + // Update response to show active certificate + resp["certificateStatus"] = "active"; + if (resp["domain"].asString().empty()) { + resp["domain"] = detectedDomain; + } + + // Update database to reflect the existing certificate + *dbClient << "INSERT INTO ssl_settings (id, domain, certificate_status, updated_at) " + "VALUES (1, $1, 'active', CURRENT_TIMESTAMP) " + "ON CONFLICT (id) DO UPDATE SET " + "certificate_status = 'active', " + "domain = CASE WHEN ssl_settings.domain = '' THEN $1 ELSE ssl_settings.domain END, " + "updated_at = CURRENT_TIMESTAMP" + << detectedDomain + >> [](const Result&) { + LOG_INFO << "Updated ssl_settings with detected certificate"; + } + >> [](const DrogonDbException& e) { + LOG_ERROR << "Failed to update ssl_settings: " << e.base().what(); + }; + break; + } + } + } + } + } catch (const std::exception& e) { + LOG_DEBUG << "Error checking for certificates: " << e.what(); + } + } + callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5bbaf84..abdfbfc 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -81,6 +81,7 @@ services: volumes: - ./config.json:/app/config.json - uploads:/app/uploads + - /etc/letsencrypt:/etc/letsencrypt:ro healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] interval: 10s diff --git a/terraform/modules/app_server/cloud-init.yaml.tpl b/terraform/modules/app_server/cloud-init.yaml.tpl index 7f7c845..8c607b8 100644 --- a/terraform/modules/app_server/cloud-init.yaml.tpl +++ b/terraform/modules/app_server/cloud-init.yaml.tpl @@ -225,6 +225,71 @@ runcmd: - mkdir -p /opt/realms - mkdir -p /opt/realms/uploads + # Generate .env with secure random secrets (only if it doesn't exist) + - | + ENV_FILE="/opt/realms/.env" + if [ ! -f "$ENV_FILE" ]; then + echo "Generating .env with secure random secrets..." + cat > "$ENV_FILE" << 'ENVEOF' +# ============================================================================= +# Realms Production Environment - Auto-generated on first deploy +# ============================================================================= + +# Database +DB_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32) + +# JWT Secret for authentication +JWT_SECRET=$(openssl rand -base64 48 | tr -d '/+=' | head -c 48) + +# Redis +REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32) + +# OvenMediaEngine API Token +OME_API_TOKEN=$(openssl rand -hex 32) + +# Nakama Game Server +NAKAMA_SERVER_KEY=$(openssl rand -hex 16) +NAKAMA_CONSOLE_PASSWORD=$(openssl rand -base64 16 | tr -d '/+=' | head -c 16) +ENVEOF + + # Generate actual random values by evaluating the file + # Read template and generate real values + DB_PASS=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32) + JWT_SEC=$(openssl rand -base64 48 | tr -d '/+=' | head -c 48) + REDIS_PASS=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32) + OME_TOKEN=$(openssl rand -hex 32) + NAKAMA_KEY=$(openssl rand -hex 16) + NAKAMA_PASS=$(openssl rand -base64 16 | tr -d '/+=' | head -c 16) + + cat > "$ENV_FILE" << ENVEOF +# ============================================================================= +# Realms Production Environment - Auto-generated on first deploy +# Generated: $(date -Iseconds) +# ============================================================================= + +# Database +DB_PASSWORD=$DB_PASS + +# JWT Secret for authentication +JWT_SECRET=$JWT_SEC + +# Redis +REDIS_PASSWORD=$REDIS_PASS + +# OvenMediaEngine API Token +OME_API_TOKEN=$OME_TOKEN + +# Nakama Game Server +NAKAMA_SERVER_KEY=$NAKAMA_KEY +NAKAMA_CONSOLE_PASSWORD=$NAKAMA_PASS +ENVEOF + + chmod 600 "$ENV_FILE" + echo ".env generated with secure random secrets" + else + echo ".env already exists, skipping generation" + fi + # Enable unattended upgrades - systemctl enable unattended-upgrades - systemctl start unattended-upgrades @@ -237,12 +302,35 @@ runcmd: # Obtain initial SSL certificate (standalone mode - no webserver running yet) # This runs before Docker services start, so port 80 is free + # Retry with delays to wait for DigitalOcean firewall propagation - | - certbot certonly --standalone \ - --non-interactive \ - --agree-tos \ - --email ${letsencrypt_email} \ - -d ${domain} \ - || echo "Certbot failed - certificate may need to be obtained manually after DNS propagates" + MAX_ATTEMPTS=5 + ATTEMPT=1 + DELAY=30 -final_message: "Realms app server ready after $UPTIME seconds. SSL cert obtained for ${domain}. Deploy via Forgejo CI/CD." + while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do + echo "Certbot attempt $ATTEMPT of $MAX_ATTEMPTS..." + + if certbot certonly --standalone \ + --non-interactive \ + --agree-tos \ + --email ${letsencrypt_email} \ + -d ${domain}; then + echo "SSL certificate obtained successfully!" + break + else + echo "Certbot failed on attempt $ATTEMPT" + if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then + echo "Waiting $${DELAY}s for firewall propagation before retry..." + sleep $DELAY + DELAY=$((DELAY * 2)) # Exponential backoff + fi + fi + ATTEMPT=$((ATTEMPT + 1)) + done + + if [ $ATTEMPT -gt $MAX_ATTEMPTS ]; then + echo "Certbot failed after $MAX_ATTEMPTS attempts - obtain certificate manually" + fi + +final_message: "Realms app server ready after $UPTIME seconds. Deploy via Forgejo CI/CD."