Fix: Let's Encrypt status detection and auto-generate .env
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
118629549e
commit
0fc49d0032
3 changed files with 149 additions and 10 deletions
|
|
@ -2956,10 +2956,13 @@ void AdminController::getSSLSettings(const HttpRequestPtr &req,
|
||||||
*dbClient << "SELECT domain, acme_email, certificate_status, certificate_expiry, "
|
*dbClient << "SELECT domain, acme_email, certificate_status, certificate_expiry, "
|
||||||
"last_renewal_attempt, last_renewal_error, auto_renewal_enabled, updated_at "
|
"last_renewal_attempt, last_renewal_error, auto_renewal_enabled, updated_at "
|
||||||
"FROM ssl_settings WHERE id = 1"
|
"FROM ssl_settings WHERE id = 1"
|
||||||
>> [callback](const Result& r) {
|
>> [callback, dbClient](const Result& r) {
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
|
|
||||||
|
std::string dbStatus = "none";
|
||||||
|
std::string domain = "";
|
||||||
|
|
||||||
if (r.empty()) {
|
if (r.empty()) {
|
||||||
// Return defaults if no settings exist
|
// Return defaults if no settings exist
|
||||||
resp["domain"] = "";
|
resp["domain"] = "";
|
||||||
|
|
@ -2971,9 +2974,12 @@ void AdminController::getSSLSettings(const HttpRequestPtr &req,
|
||||||
resp["autoRenewalEnabled"] = true;
|
resp["autoRenewalEnabled"] = true;
|
||||||
} else {
|
} else {
|
||||||
const auto& row = r[0];
|
const auto& row = r[0];
|
||||||
resp["domain"] = row["domain"].isNull() ? "" : row["domain"].as<std::string>();
|
domain = row["domain"].isNull() ? "" : row["domain"].as<std::string>();
|
||||||
|
dbStatus = row["certificate_status"].as<std::string>();
|
||||||
|
|
||||||
|
resp["domain"] = domain;
|
||||||
resp["acmeEmail"] = row["acme_email"].isNull() ? "" : row["acme_email"].as<std::string>();
|
resp["acmeEmail"] = row["acme_email"].isNull() ? "" : row["acme_email"].as<std::string>();
|
||||||
resp["certificateStatus"] = row["certificate_status"].as<std::string>();
|
resp["certificateStatus"] = dbStatus;
|
||||||
|
|
||||||
if (!row["certificate_expiry"].isNull()) {
|
if (!row["certificate_expiry"].isNull()) {
|
||||||
resp["certificateExpiry"] = row["certificate_expiry"].as<std::string>();
|
resp["certificateExpiry"] = row["certificate_expiry"].as<std::string>();
|
||||||
|
|
@ -2996,6 +3002,50 @@ void AdminController::getSSLSettings(const HttpRequestPtr &req,
|
||||||
resp["autoRenewalEnabled"] = row["auto_renewal_enabled"].as<bool>();
|
resp["autoRenewalEnabled"] = row["auto_renewal_enabled"].as<bool>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(jsonResp(resp));
|
||||||
}
|
}
|
||||||
>> [callback](const DrogonDbException& e) {
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.json:/app/config.json
|
- ./config.json:/app/config.json
|
||||||
- uploads:/app/uploads
|
- uploads:/app/uploads
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,71 @@ runcmd:
|
||||||
- mkdir -p /opt/realms
|
- mkdir -p /opt/realms
|
||||||
- mkdir -p /opt/realms/uploads
|
- 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
|
# Enable unattended upgrades
|
||||||
- systemctl enable unattended-upgrades
|
- systemctl enable unattended-upgrades
|
||||||
- systemctl start unattended-upgrades
|
- systemctl start unattended-upgrades
|
||||||
|
|
@ -237,12 +302,35 @@ runcmd:
|
||||||
|
|
||||||
# Obtain initial SSL certificate (standalone mode - no webserver running yet)
|
# Obtain initial SSL certificate (standalone mode - no webserver running yet)
|
||||||
# This runs before Docker services start, so port 80 is free
|
# This runs before Docker services start, so port 80 is free
|
||||||
|
# Retry with delays to wait for DigitalOcean firewall propagation
|
||||||
- |
|
- |
|
||||||
certbot certonly --standalone \
|
MAX_ATTEMPTS=5
|
||||||
--non-interactive \
|
ATTEMPT=1
|
||||||
--agree-tos \
|
DELAY=30
|
||||||
--email ${letsencrypt_email} \
|
|
||||||
-d ${domain} \
|
|
||||||
|| echo "Certbot failed - certificate may need to be obtained manually after DNS propagates"
|
|
||||||
|
|
||||||
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."
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue