mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-23 19:06:25 +00:00
36fdba52bc
- Step 3 (Enable ACME on Cluster) now shows a process icon instead of a misleading green checkmark when ACME is enabled but not yet applied. Per-cluster "(pending apply)" annotation for multi-cluster setups. - Step 4 button and all /apply-management navigation buttons now say "Apply Changes" instead of "Configure" for clearer guidance. - Setup Guide auto-selects the correct cluster before navigating to Apply Management, showing pending cluster names in alerts. - Pending ACME disable changes are now correctly detected in Step 4 even when acme_enabled is already FALSE in the database. - Entity snapshot rollback for cluster ACME settings: reject correctly restores acme_enabled/acme_backend_url to pre-change values. - Deduplication logic prevents "last wins" bug when multiple ACME toggles are rejected in sequence. - Connection leak prevention with try/finally around conn2 in ACME config version creation. - Step 4 branching uses boolean has_enabled instead of fragile string truthiness check. Made-with: Cursor
876 lines
39 KiB
Python
876 lines
39 KiB
Python
from fastapi import APIRouter, HTTPException, Header
|
|
from pydantic import BaseModel
|
|
from typing import Optional, List
|
|
import json
|
|
import logging
|
|
import time
|
|
from datetime import datetime
|
|
|
|
from database.connection import get_database_connection, close_database_connection
|
|
from services.acme_service import acme_service
|
|
from services.haproxy_config import generate_haproxy_config_for_cluster
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/letsencrypt", tags=["Let's Encrypt / ACME"])
|
|
|
|
|
|
class AccountCreate(BaseModel):
|
|
email: str
|
|
directory_url: Optional[str] = None
|
|
tos_agreed: bool = True
|
|
eab_kid: Optional[str] = None
|
|
eab_hmac_key: Optional[str] = None
|
|
|
|
|
|
class CertificateRequest(BaseModel):
|
|
domains: List[str]
|
|
account_id: Optional[int] = None
|
|
cluster_ids: Optional[List[int]] = None
|
|
auto_renew: bool = True
|
|
|
|
|
|
# --- Account management ---
|
|
|
|
@router.get("/accounts")
|
|
async def list_accounts(authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token
|
|
current_user = await get_current_user_from_token(authorization)
|
|
if not current_user.get('is_admin', False):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
conn = await get_database_connection()
|
|
try:
|
|
rows = await conn.fetch(
|
|
"SELECT id, email, directory_url, account_url, status, tos_agreed, eab_kid, created_at, updated_at FROM letsencrypt_accounts ORDER BY id"
|
|
)
|
|
return [dict(r) for r in rows]
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.post("/accounts")
|
|
async def create_account(body: AccountCreate, authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token
|
|
current_user = await get_current_user_from_token(authorization)
|
|
if not current_user.get('is_admin', False):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
try:
|
|
settings = await acme_service._get_settings()
|
|
directory_url = body.directory_url or settings.get('directory_url', 'https://acme-v02.api.letsencrypt.org/directory')
|
|
if settings.get('staging_mode'):
|
|
if 'letsencrypt' in directory_url:
|
|
directory_url = 'https://acme-staging-v02.api.letsencrypt.org/directory'
|
|
|
|
eab_kid = body.eab_kid or settings.get('eab_kid', '') or None
|
|
eab_hmac_key = body.eab_hmac_key or settings.get('eab_hmac_key', '') or None
|
|
|
|
result = await acme_service.register_account(
|
|
email=body.email,
|
|
directory_url=directory_url,
|
|
tos_agreed=body.tos_agreed,
|
|
eab_kid=eab_kid,
|
|
eab_hmac_key=eab_hmac_key,
|
|
)
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"ACME account registration failed: {e}")
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.delete("/accounts/{account_id}")
|
|
async def deactivate_account(account_id: int, authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token
|
|
current_user = await get_current_user_from_token(authorization)
|
|
if not current_user.get('is_admin', False):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
conn = await get_database_connection()
|
|
try:
|
|
active_orders = await conn.fetchval("""
|
|
SELECT COUNT(*) FROM letsencrypt_orders
|
|
WHERE account_id = $1 AND status NOT IN ('valid', 'invalid', 'cancelled')
|
|
""", account_id)
|
|
if active_orders > 0:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Cannot deactivate account with {active_orders} active order(s). Cancel them first."
|
|
)
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
try:
|
|
result = await acme_service.deactivate_account(account_id)
|
|
return result
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"ACME account deactivation failed: {e}")
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.delete("/accounts/{account_id}/permanent")
|
|
async def remove_account(account_id: int, authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token
|
|
current_user = await get_current_user_from_token(authorization)
|
|
if not current_user.get('is_admin', False):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
conn = await get_database_connection()
|
|
try:
|
|
account = await conn.fetchrow(
|
|
"SELECT id, status, email FROM letsencrypt_accounts WHERE id = $1", account_id
|
|
)
|
|
if not account:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
if account['status'] != 'deactivated':
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Only deactivated accounts can be permanently removed. Deactivate first."
|
|
)
|
|
|
|
linked_orders = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM letsencrypt_orders WHERE account_id = $1", account_id
|
|
)
|
|
if linked_orders > 0:
|
|
await conn.execute(
|
|
"DELETE FROM letsencrypt_orders WHERE account_id = $1", account_id
|
|
)
|
|
|
|
await conn.execute("DELETE FROM letsencrypt_accounts WHERE id = $1", account_id)
|
|
logger.info(f"ACME account {account['email']} (id={account_id}) permanently removed by user {current_user.get('username')}")
|
|
return {"message": f"Account {account['email']} permanently removed", "deleted_orders": linked_orders}
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
# --- Certificate operations ---
|
|
|
|
@router.post("/certificates")
|
|
async def request_certificate(body: CertificateRequest, authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
has_perm = await check_user_permission(current_user['id'], 'ssl', 'create')
|
|
if not has_perm:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.create required")
|
|
|
|
if not body.domains:
|
|
raise HTTPException(status_code=400, detail="At least one domain is required")
|
|
|
|
logger.info(f"ACME: Certificate request initiated for domains={body.domains}, account_id={body.account_id}, cluster_ids={body.cluster_ids}")
|
|
|
|
try:
|
|
account_id = body.account_id
|
|
if not account_id:
|
|
conn = await get_database_connection()
|
|
try:
|
|
account = await conn.fetchrow(
|
|
"SELECT id FROM letsencrypt_accounts WHERE status = 'valid' ORDER BY created_at DESC LIMIT 1"
|
|
)
|
|
finally:
|
|
await close_database_connection(conn)
|
|
if not account:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="No ACME account found. Please configure one in Settings > ACME first."
|
|
)
|
|
account_id = account['id']
|
|
|
|
logger.info(f"ACME: Using account_id={account_id} for certificate request")
|
|
|
|
warnings = []
|
|
try:
|
|
conn_warn = await get_database_connection()
|
|
try:
|
|
acme_clusters = await conn_warn.fetchval(
|
|
"SELECT COUNT(*) FROM haproxy_clusters WHERE acme_enabled = TRUE AND is_active = TRUE"
|
|
)
|
|
if acme_clusters == 0:
|
|
logger.warning("ACME: No clusters with ACME Challenge Routing enabled - certificate validation will likely fail")
|
|
warnings.append(
|
|
"No clusters have ACME Challenge Routing enabled. "
|
|
"Certificate validation will fail. Enable it in Cluster Management and Apply Changes first."
|
|
)
|
|
finally:
|
|
await close_database_connection(conn_warn)
|
|
except Exception:
|
|
pass
|
|
|
|
order = await acme_service.create_order(
|
|
account_id=account_id,
|
|
domains=body.domains,
|
|
cluster_ids=body.cluster_ids,
|
|
)
|
|
|
|
challenges = await acme_service.respond_to_challenges(order['order_id'])
|
|
|
|
logger.info(f"ACME: Order {order['order_id']} created, {len(challenges)} challenge(s) posted, status={order['status']}")
|
|
|
|
return {
|
|
"order_id": order['order_id'],
|
|
"status": order['status'],
|
|
"domains": body.domains,
|
|
"challenges": challenges,
|
|
"message": "Order created. ACME challenges have been posted. Waiting for CA validation.",
|
|
"warnings": warnings,
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Certificate request failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/orders")
|
|
async def list_orders(authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
has_perm = await check_user_permission(current_user['id'], 'ssl', 'read')
|
|
if not has_perm:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.read required")
|
|
conn = await get_database_connection()
|
|
try:
|
|
rows = await conn.fetch("""
|
|
SELECT o.id, o.account_id, o.order_url, o.status, o.domains,
|
|
o.ssl_certificate_id, o.cluster_ids, o.error_detail,
|
|
o.created_at, o.updated_at, a.email as account_email
|
|
FROM letsencrypt_orders o
|
|
JOIN letsencrypt_accounts a ON o.account_id = a.id
|
|
ORDER BY o.created_at DESC
|
|
""")
|
|
results = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
d['domains'] = json.loads(d['domains']) if isinstance(d['domains'], str) else d['domains']
|
|
d['cluster_ids'] = json.loads(d['cluster_ids']) if isinstance(d['cluster_ids'], str) else d['cluster_ids']
|
|
results.append(d)
|
|
return results
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.get("/orders/{order_id}")
|
|
async def get_order(order_id: int, authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
has_perm = await check_user_permission(current_user['id'], 'ssl', 'read')
|
|
if not has_perm:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.read required")
|
|
conn = await get_database_connection()
|
|
try:
|
|
order = await conn.fetchrow("""
|
|
SELECT o.id, o.account_id, o.order_url, o.status, o.domains,
|
|
o.certificate_url, o.finalize_url, o.expires_at,
|
|
o.error_detail, o.ssl_certificate_id, o.cluster_ids,
|
|
o.created_at, o.updated_at, a.email as account_email
|
|
FROM letsencrypt_orders o
|
|
JOIN letsencrypt_accounts a ON o.account_id = a.id
|
|
WHERE o.id = $1
|
|
""", order_id)
|
|
if not order:
|
|
raise HTTPException(status_code=404, detail="Order not found")
|
|
|
|
challenges = await conn.fetch(
|
|
"SELECT id, order_id, domain, token, challenge_url, status, validated_at, created_at FROM acme_challenges WHERE order_id = $1 ORDER BY domain", order_id
|
|
)
|
|
result = dict(order)
|
|
result['domains'] = json.loads(result['domains']) if isinstance(result['domains'], str) else result['domains']
|
|
result['cluster_ids'] = json.loads(result['cluster_ids']) if isinstance(result['cluster_ids'], str) else result['cluster_ids']
|
|
result['challenges'] = [dict(c) for c in challenges]
|
|
return result
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.post("/orders/{order_id}/retry")
|
|
async def retry_order(order_id: int, authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
has_perm = await check_user_permission(current_user['id'], 'ssl', 'create')
|
|
if not has_perm:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.create required")
|
|
try:
|
|
status_info = await acme_service.check_order_status(order_id)
|
|
current_status = status_info.get('status')
|
|
|
|
if current_status == 'invalid':
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="This order is invalid and cannot be retried. "
|
|
"Please cancel it and create a new certificate request."
|
|
)
|
|
if current_status == 'cancelled':
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="This order has been cancelled. Please create a new certificate request."
|
|
)
|
|
|
|
if current_status == 'ready':
|
|
result = await acme_service.finalize_order(order_id)
|
|
safe_result = {k: v for k, v in result.items() if k not in ('private_key_pem', 'cert_private_key')}
|
|
return {"message": "Order finalized", **safe_result}
|
|
elif current_status == 'valid' and status_info.get('certificate_url'):
|
|
return await _complete_certificate(order_id)
|
|
else:
|
|
challenges = await acme_service.respond_to_challenges(order_id)
|
|
return {"message": "Challenges re-submitted", "status": current_status, "challenges": challenges}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/orders/{order_id}/renew")
|
|
async def renew_order(order_id: int, authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
has_perm = await check_user_permission(current_user['id'], 'ssl', 'create')
|
|
if not has_perm:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.create required")
|
|
conn = await get_database_connection()
|
|
try:
|
|
order = await conn.fetchrow(
|
|
"SELECT account_id, domains, cluster_ids FROM letsencrypt_orders WHERE id = $1", order_id
|
|
)
|
|
if not order:
|
|
raise HTTPException(status_code=404, detail="Order not found")
|
|
domains = json.loads(order['domains']) if isinstance(order['domains'], str) else order['domains']
|
|
cluster_ids = json.loads(order['cluster_ids']) if isinstance(order['cluster_ids'], str) else order['cluster_ids']
|
|
|
|
new_order = await acme_service.create_order(
|
|
account_id=order['account_id'], domains=domains, cluster_ids=cluster_ids
|
|
)
|
|
challenges = await acme_service.respond_to_challenges(new_order['order_id'])
|
|
return {"message": "Renewal order created", "new_order_id": new_order['order_id'], "challenges": challenges}
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.delete("/orders/{order_id}")
|
|
async def cancel_order(order_id: int, authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
has_perm = await check_user_permission(current_user['id'], 'ssl', 'delete')
|
|
if not has_perm:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.delete required")
|
|
conn = await get_database_connection()
|
|
try:
|
|
result = await conn.execute(
|
|
"UPDATE letsencrypt_orders SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND status NOT IN ('valid', 'cancelled')",
|
|
order_id
|
|
)
|
|
if result == "UPDATE 0":
|
|
existing = await conn.fetchval("SELECT status FROM letsencrypt_orders WHERE id = $1", order_id)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Order not found")
|
|
raise HTTPException(status_code=409, detail=f"Order cannot be cancelled (current status: {existing})")
|
|
return {"message": "Order cancelled"}
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.post("/certificates/{cert_id}/revoke")
|
|
async def revoke_certificate(cert_id: int, authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
has_perm = await check_user_permission(current_user['id'], 'ssl', 'delete')
|
|
if not has_perm:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.delete required")
|
|
conn = await get_database_connection()
|
|
try:
|
|
cert = await conn.fetchrow(
|
|
"SELECT s.*, o.account_id FROM ssl_certificates s LEFT JOIN letsencrypt_orders o ON s.letsencrypt_order_id = o.id WHERE s.id = $1",
|
|
cert_id
|
|
)
|
|
if not cert:
|
|
raise HTTPException(status_code=404, detail="Certificate not found")
|
|
if cert.get('source') != 'letsencrypt':
|
|
raise HTTPException(status_code=400, detail="Only ACME-managed certificates can be revoked through this endpoint")
|
|
|
|
account_id = cert.get('account_id')
|
|
if not account_id:
|
|
account = await conn.fetchrow("SELECT id FROM letsencrypt_accounts ORDER BY created_at DESC LIMIT 1")
|
|
if not account:
|
|
raise HTTPException(status_code=400, detail="No ACME account found for revocation")
|
|
account_id = account['id']
|
|
|
|
success = await acme_service.revoke_certificate(
|
|
cert['certificate_content'], account_id
|
|
)
|
|
if success:
|
|
await conn.execute(
|
|
"UPDATE ssl_certificates SET auto_renew = FALSE, updated_at = NOW() WHERE id = $1", cert_id
|
|
)
|
|
return {"message": "Certificate revoked successfully"}
|
|
else:
|
|
raise HTTPException(status_code=500, detail="Revocation failed")
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.post("/import-ca-chain")
|
|
async def import_le_ca_chain(authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
has_perm = await check_user_permission(current_user['id'], 'ssl', 'create')
|
|
if not has_perm:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.create required")
|
|
import aiohttp
|
|
ca_urls = [
|
|
("https://letsencrypt.org/certs/isrgrootx1.pem", "ISRG Root X1"),
|
|
("https://letsencrypt.org/certs/r10.pem", "R10 Intermediate"),
|
|
("https://letsencrypt.org/certs/r11.pem", "R11 Intermediate"),
|
|
]
|
|
chain_parts = []
|
|
async with aiohttp.ClientSession() as session:
|
|
for url, name in ca_urls:
|
|
try:
|
|
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
|
if resp.status == 200:
|
|
chain_parts.append(await resp.text())
|
|
except Exception as e:
|
|
logger.warning(f"Failed to download {name}: {e}")
|
|
|
|
if not chain_parts:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="Failed to download CA certificates from letsencrypt.org. "
|
|
"This usually means the server cannot reach external HTTPS endpoints. "
|
|
"Check network/proxy/firewall settings."
|
|
)
|
|
|
|
full_chain = '\n'.join(chain_parts)
|
|
conn = await get_database_connection()
|
|
try:
|
|
existing = await conn.fetchrow(
|
|
"SELECT id FROM ssl_certificates WHERE name = 'letsencrypt-ca-chain'"
|
|
)
|
|
if existing:
|
|
await conn.execute(
|
|
"UPDATE ssl_certificates SET certificate_content = $1, updated_at = NOW() WHERE id = $2",
|
|
full_chain, existing['id']
|
|
)
|
|
return {"message": "LE CA chain updated", "id": existing['id']}
|
|
else:
|
|
row = await conn.fetchrow("""
|
|
INSERT INTO ssl_certificates (name, certificate_content, usage_type, source, is_active)
|
|
VALUES ('letsencrypt-ca-chain', $1, 'server', 'letsencrypt-ca', TRUE)
|
|
RETURNING id
|
|
""", full_chain)
|
|
return {"message": "LE CA chain imported", "id": row['id']}
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.get("/renewal-schedule")
|
|
async def get_renewal_schedule(authorization: str = Header(None)):
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
has_perm = await check_user_permission(current_user['id'], 'ssl', 'read')
|
|
if not has_perm:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.read required")
|
|
conn = await get_database_connection()
|
|
try:
|
|
certs = await conn.fetch("""
|
|
SELECT id, name, primary_domain, expiry_date, auto_renew, days_until_expiry
|
|
FROM ssl_certificates
|
|
WHERE source = 'letsencrypt' AND is_active = TRUE
|
|
ORDER BY expiry_date ASC NULLS LAST
|
|
""")
|
|
return [dict(c) for c in certs]
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
async def _complete_certificate(order_id: int) -> dict:
|
|
"""Download certificate and create ssl_certificate entry + config version.
|
|
|
|
Both new certificates and renewals follow the same flow as manual SSL edit:
|
|
1. Update/insert cert with last_config_status='PENDING'
|
|
2. Create PENDING config versions for affected clusters
|
|
3. For renewals: programmatically trigger the same Apply mechanism
|
|
(apply_ssl_related_configs + consolidated version + agent notify)
|
|
4. For new certs: remain PENDING for manual Apply
|
|
"""
|
|
conn = await get_database_connection()
|
|
try:
|
|
order = await conn.fetchrow("SELECT * FROM letsencrypt_orders WHERE id = $1", order_id)
|
|
if not order:
|
|
raise Exception("Order not found")
|
|
|
|
if order.get('ssl_certificate_id'):
|
|
return {
|
|
"message": "Certificate already issued for this order",
|
|
"certificate_id": order['ssl_certificate_id'],
|
|
}
|
|
|
|
if order['status'] == 'ready':
|
|
await acme_service.finalize_order(order_id)
|
|
order = await conn.fetchrow("SELECT * FROM letsencrypt_orders WHERE id = $1", order_id)
|
|
|
|
cert_data = await acme_service.download_certificate(order_id)
|
|
domains = json.loads(order['domains']) if isinstance(order['domains'], str) else order['domains']
|
|
cluster_ids = json.loads(order['cluster_ids']) if isinstance(order['cluster_ids'], str) else order['cluster_ids']
|
|
primary_domain = domains[0] if domains else 'unknown'
|
|
|
|
private_key_pem = order.get('cert_private_key') or ''
|
|
|
|
expiry_date = None
|
|
days_until_expiry = 0
|
|
issuer = None
|
|
fingerprint = None
|
|
all_domains = json.dumps(domains)
|
|
try:
|
|
from utils.ssl_parser import parse_ssl_certificate
|
|
cert_info = parse_ssl_certificate(cert_data['certificate_pem'])
|
|
if cert_info and not cert_info.get('error'):
|
|
expiry_date = cert_info.get('expiry_date')
|
|
days_until_expiry = cert_info.get('days_until_expiry', 0)
|
|
issuer = cert_info.get('issuer')
|
|
fingerprint = cert_info.get('fingerprint')
|
|
if cert_info.get('all_domains'):
|
|
all_domains = json.dumps(cert_info['all_domains'])
|
|
except Exception as parse_err:
|
|
logger.warning(f"Could not parse ACME certificate metadata: {parse_err}")
|
|
|
|
existing_cert = await conn.fetchrow("""
|
|
SELECT id FROM ssl_certificates
|
|
WHERE primary_domain = $1 AND source = 'letsencrypt' AND is_active = TRUE
|
|
ORDER BY created_at DESC LIMIT 1
|
|
""", primary_domain)
|
|
|
|
is_renewal = existing_cert is not None
|
|
|
|
if is_renewal:
|
|
cert_id = existing_cert['id']
|
|
await conn.execute("""
|
|
UPDATE ssl_certificates SET
|
|
certificate_content = $1, private_key_content = $2, chain_content = $3,
|
|
all_domains = $4::jsonb, expiry_date = $5, days_until_expiry = $6,
|
|
issuer = $7, fingerprint = $8, letsencrypt_order_id = $9,
|
|
auto_renew = TRUE, is_active = TRUE, last_config_status = 'PENDING',
|
|
updated_at = NOW()
|
|
WHERE id = $10
|
|
""", cert_data['certificate_pem'], private_key_pem,
|
|
cert_data.get('chain_pem', ''), all_domains,
|
|
expiry_date, days_until_expiry, issuer, fingerprint, order_id, cert_id)
|
|
logger.info(f"ACME RENEWAL: Updated certificate {cert_id} for {primary_domain}, status=PENDING")
|
|
else:
|
|
cert_row = await conn.fetchrow("""
|
|
INSERT INTO ssl_certificates
|
|
(name, certificate_content, private_key_content, chain_content,
|
|
primary_domain, all_domains, expiry_date, days_until_expiry, issuer, fingerprint,
|
|
usage_type, source, letsencrypt_order_id, auto_renew, is_active,
|
|
last_config_status)
|
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10,
|
|
'frontend', 'letsencrypt', $11, TRUE, TRUE, 'PENDING')
|
|
RETURNING id
|
|
""", f"le-{primary_domain}", cert_data['certificate_pem'], private_key_pem,
|
|
cert_data.get('chain_pem', ''), primary_domain, all_domains,
|
|
expiry_date, days_until_expiry, issuer, fingerprint, order_id)
|
|
cert_id = cert_row['id']
|
|
|
|
await conn.execute(
|
|
"UPDATE letsencrypt_orders SET ssl_certificate_id = $1, status = 'valid', updated_at = NOW() WHERE id = $2",
|
|
cert_id, order_id
|
|
)
|
|
|
|
if cluster_ids:
|
|
for cid in cluster_ids:
|
|
await conn.execute("""
|
|
INSERT INTO ssl_certificate_clusters (ssl_certificate_id, cluster_id)
|
|
VALUES ($1, $2) ON CONFLICT DO NOTHING
|
|
""", cert_id, cid)
|
|
|
|
effective_cluster_ids = list(cluster_ids or [])
|
|
if is_renewal and not effective_cluster_ids:
|
|
mapped = await conn.fetch(
|
|
"SELECT cluster_id FROM ssl_certificate_clusters WHERE ssl_certificate_id = $1", cert_id
|
|
)
|
|
if mapped:
|
|
effective_cluster_ids = [r['cluster_id'] for r in mapped]
|
|
else:
|
|
all_clusters = await conn.fetch(
|
|
"SELECT id FROM haproxy_clusters WHERE is_active = TRUE"
|
|
)
|
|
effective_cluster_ids = [r['id'] for r in all_clusters]
|
|
if effective_cluster_ids:
|
|
logger.info(f"ACME RENEWAL: Resolved {len(effective_cluster_ids)} cluster(s) for global cert {cert_id}")
|
|
|
|
admin_uid = await conn.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
|
|
|
|
for cid in effective_cluster_ids:
|
|
try:
|
|
config_content = await generate_haproxy_config_for_cluster(cid)
|
|
ts = int(time.time())
|
|
action = 'renew' if is_renewal else 'issue'
|
|
version_name = f"ssl-{cert_id}-letsencrypt-{action}-{ts}"
|
|
await conn.execute("""
|
|
INSERT INTO config_versions (cluster_id, version_name, config_content, status, created_by)
|
|
VALUES ($1, $2, $3, 'PENDING', $4)
|
|
""", cid, version_name, config_content, admin_uid)
|
|
except Exception as ve:
|
|
logger.error(f"Failed to create config version for cluster {cid}: {ve}")
|
|
|
|
if is_renewal:
|
|
await _auto_apply_renewal(cert_id, effective_cluster_ids)
|
|
|
|
msg = "Certificate renewed and applied" if is_renewal else "Certificate issued (pending Apply)"
|
|
return {
|
|
"message": msg,
|
|
"certificate_id": cert_id,
|
|
"domains": domains,
|
|
"auto_applied": is_renewal,
|
|
}
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
async def _auto_apply_renewal(cert_id: int, cluster_ids: list):
|
|
"""Trigger the same Apply mechanism used by manual Apply for SSL renewals.
|
|
|
|
Follows the exact same flow as cluster.py apply_pending_changes:
|
|
1. apply_ssl_related_configs (cross-cluster SSL propagation)
|
|
2. Generate consolidated config version (APPLIED, is_active=TRUE)
|
|
3. Mark PENDING SSL versions as APPLIED
|
|
4. Set ssl_certificates.last_config_status = 'APPLIED'
|
|
5. Notify agents via Redis
|
|
"""
|
|
import hashlib
|
|
|
|
applied_clusters = set()
|
|
|
|
for cluster_id in cluster_ids:
|
|
conn = await get_database_connection()
|
|
try:
|
|
pending_versions = await conn.fetch("""
|
|
SELECT id, version_name, config_content, created_at
|
|
FROM config_versions
|
|
WHERE cluster_id = $1 AND status = 'PENDING'
|
|
AND version_name LIKE $2
|
|
ORDER BY created_at
|
|
""", cluster_id, f"ssl-{cert_id}-%")
|
|
|
|
if not pending_versions:
|
|
continue
|
|
|
|
async with conn.transaction():
|
|
from routers.cluster import apply_ssl_related_configs
|
|
await apply_ssl_related_configs(conn, cluster_id)
|
|
|
|
config_content = await generate_haproxy_config_for_cluster(cluster_id, conn)
|
|
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
|
|
version_name = f"apply-consolidated-{int(time.time())}"
|
|
|
|
admin_user_id = await conn.fetchval(
|
|
"SELECT id FROM users WHERE username = 'admin' LIMIT 1"
|
|
) or 1
|
|
|
|
pre_apply_config = await conn.fetchval("""
|
|
SELECT config_content FROM config_versions
|
|
WHERE cluster_id = $1 AND status = 'APPLIED' AND is_active = TRUE
|
|
AND config_content IS NOT NULL
|
|
ORDER BY created_at DESC LIMIT 1
|
|
""", cluster_id)
|
|
|
|
metadata = json.dumps({'pre_apply_snapshot': pre_apply_config}) if pre_apply_config else None
|
|
|
|
await conn.execute("""
|
|
UPDATE config_versions SET is_active = FALSE
|
|
WHERE cluster_id = $1 AND is_active = TRUE
|
|
""", cluster_id)
|
|
|
|
await conn.fetchval("""
|
|
INSERT INTO config_versions
|
|
(cluster_id, version_name, config_content, checksum, created_by, is_active, status, metadata)
|
|
VALUES ($1, $2, $3, $4, $5, TRUE, 'APPLIED', $6)
|
|
RETURNING id
|
|
""", cluster_id, version_name, config_content, config_hash, admin_user_id, metadata)
|
|
|
|
await conn.execute("""
|
|
UPDATE config_versions SET is_active = FALSE, status = 'APPLIED'
|
|
WHERE id = ANY($1)
|
|
""", [v['id'] for v in pending_versions])
|
|
|
|
await conn.execute("""
|
|
UPDATE ssl_certificates
|
|
SET last_config_status = 'APPLIED', updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1 AND last_config_status = 'PENDING'
|
|
""", cert_id)
|
|
|
|
applied_clusters.add(cluster_id)
|
|
logger.info(f"ACME AUTO-APPLY: Applied renewal for cert {cert_id} on cluster {cluster_id}, version={version_name}")
|
|
except Exception as apply_err:
|
|
logger.error(f"ACME AUTO-APPLY: Failed to apply renewal on cluster {cluster_id}: {apply_err}")
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
for cluster_id in applied_clusters:
|
|
try:
|
|
from agent_notifications import notify_agents_config_change
|
|
sync_results = await notify_agents_config_change(cluster_id, f"acme-renewal-{cert_id}")
|
|
logger.info(f"ACME AUTO-APPLY: Notified {len(sync_results)} agents for cluster {cluster_id}")
|
|
except Exception as notify_err:
|
|
logger.error(f"ACME AUTO-APPLY: Agent notification failed for cluster {cluster_id}: {notify_err}")
|
|
|
|
|
|
@router.get("/prerequisites")
|
|
async def check_prerequisites(authorization: str = Header(None)):
|
|
"""Check ACME prerequisites and return step-by-step setup status with navigation hints."""
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
has_perm = await check_user_permission(current_user['id'], 'ssl', 'read')
|
|
if not has_perm:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.read required")
|
|
|
|
conn = await get_database_connection()
|
|
try:
|
|
steps = []
|
|
|
|
# Step 1: ACME Settings configured
|
|
dir_url = await conn.fetchval(
|
|
"SELECT value FROM system_settings WHERE key = 'acme.directory_url'"
|
|
)
|
|
dir_url_str = ""
|
|
if dir_url:
|
|
try:
|
|
parsed = json.loads(dir_url) if isinstance(dir_url, str) else dir_url
|
|
dir_url_str = str(parsed) if not isinstance(parsed, str) else parsed
|
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
dir_url_str = str(dir_url)
|
|
|
|
provider_row = await conn.fetchval(
|
|
"SELECT value FROM system_settings WHERE key = 'acme.provider'"
|
|
)
|
|
provider = ""
|
|
if provider_row:
|
|
try:
|
|
parsed = json.loads(provider_row) if isinstance(provider_row, str) else provider_row
|
|
provider = str(parsed) if not isinstance(parsed, str) else parsed
|
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
provider = str(provider_row)
|
|
|
|
settings_ok = bool(dir_url_str and dir_url_str.startswith("http"))
|
|
settings_detail = f"Provider: {provider or 'Not set'}, Directory URL configured" if settings_ok else "ACME provider and directory URL not configured"
|
|
steps.append({
|
|
"key": "acme_settings",
|
|
"title": "Configure ACME Settings",
|
|
"ok": settings_ok,
|
|
"detail": settings_detail,
|
|
"navigate": "/settings?tab=acme",
|
|
})
|
|
|
|
# Step 2: ACME Account registered
|
|
account = await conn.fetchrow(
|
|
"SELECT id, email FROM letsencrypt_accounts WHERE status = 'valid' ORDER BY created_at DESC LIMIT 1"
|
|
)
|
|
account_ok = account is not None
|
|
account_detail = f"Active account: {account['email']}" if account else "No active ACME account registered"
|
|
steps.append({
|
|
"key": "acme_account",
|
|
"title": "Register ACME Account",
|
|
"ok": account_ok,
|
|
"detail": account_detail,
|
|
"action": "register_account",
|
|
})
|
|
|
|
# Pre-fetch ACME-related pending versions (used by both Step 3 and Step 4)
|
|
pending_rows = await conn.fetch("""
|
|
SELECT c.id AS cluster_id, c.name AS cluster_name, COUNT(*) AS cnt
|
|
FROM config_versions cv
|
|
JOIN haproxy_clusters c ON c.id = cv.cluster_id
|
|
WHERE cv.status = 'PENDING'
|
|
AND c.is_active = TRUE
|
|
AND (c.acme_enabled = TRUE OR cv.version_name LIKE 'cluster-%-acme-%')
|
|
GROUP BY c.id, c.name
|
|
""")
|
|
pending_count = sum(r['cnt'] for r in pending_rows)
|
|
pending_clusters = [{"id": r['cluster_id'], "name": r['cluster_name'], "count": r['cnt']} for r in pending_rows]
|
|
pending_enable_cluster_ids = set()
|
|
pe_rows = await conn.fetch("""
|
|
SELECT DISTINCT cv.cluster_id
|
|
FROM config_versions cv
|
|
JOIN haproxy_clusters c ON c.id = cv.cluster_id
|
|
WHERE cv.status = 'PENDING'
|
|
AND c.is_active = TRUE
|
|
AND cv.version_name LIKE 'cluster-%-acme-enable-%'
|
|
""")
|
|
for r in pe_rows:
|
|
pending_enable_cluster_ids.add(r['cluster_id'])
|
|
|
|
# Step 3: Cluster ACME enabled
|
|
acme_clusters_rows = await conn.fetch(
|
|
"SELECT id, name FROM haproxy_clusters WHERE acme_enabled = TRUE AND is_active = TRUE"
|
|
)
|
|
has_enabled = len(acme_clusters_rows) > 0
|
|
any_pending_enable = has_enabled and any(r['id'] in pending_enable_cluster_ids for r in acme_clusters_rows)
|
|
if has_enabled:
|
|
name_parts = []
|
|
for r in acme_clusters_rows:
|
|
if r['id'] in pending_enable_cluster_ids:
|
|
name_parts.append(f"{r['name']} (pending apply)")
|
|
else:
|
|
name_parts.append(r['name'])
|
|
if any_pending_enable:
|
|
cluster_ok = "pending"
|
|
cluster_detail = f"Enabled on: {', '.join(name_parts)} — go to Apply Management to activate"
|
|
cluster_navigate = "/apply-management"
|
|
step3_pending_clusters = [{"id": r['cluster_id'], "name": r['cluster_name'], "count": r['cnt']} for r in pending_rows if r['cluster_id'] in pending_enable_cluster_ids]
|
|
else:
|
|
cluster_ok = True
|
|
cluster_detail = f"Enabled on: {', '.join(name_parts)}"
|
|
cluster_navigate = "/clusters"
|
|
step3_pending_clusters = []
|
|
else:
|
|
cluster_ok = False
|
|
cluster_detail = "No clusters have ACME Challenge Routing enabled"
|
|
cluster_navigate = "/clusters"
|
|
step3_pending_clusters = []
|
|
steps.append({
|
|
"key": "cluster_acme_enabled",
|
|
"title": "Enable ACME on Cluster",
|
|
"ok": cluster_ok,
|
|
"detail": cluster_detail,
|
|
"navigate": cluster_navigate,
|
|
"pending_clusters": step3_pending_clusters,
|
|
})
|
|
|
|
# Step 4: Configuration applied
|
|
if has_enabled:
|
|
config_ok = pending_count == 0
|
|
if config_ok:
|
|
config_detail = "All ACME cluster configurations are applied"
|
|
else:
|
|
names = ', '.join(r['cluster_name'] for r in pending_rows)
|
|
config_detail = f"{pending_count} pending configuration change(s) on cluster: {names}"
|
|
elif pending_count > 0:
|
|
config_ok = False
|
|
names = ', '.join(r['cluster_name'] for r in pending_rows)
|
|
config_detail = f"{pending_count} pending configuration change(s) on cluster: {names}"
|
|
else:
|
|
config_ok = None
|
|
config_detail = "Enable ACME on a cluster first, then apply changes"
|
|
pending_clusters = []
|
|
steps.append({
|
|
"key": "config_applied",
|
|
"title": "Apply Configuration Changes",
|
|
"ok": config_ok,
|
|
"detail": config_detail,
|
|
"navigate": "/apply-management",
|
|
"pending_clusters": pending_clusters,
|
|
})
|
|
|
|
# Step 5: DNS & Network (informational only)
|
|
steps.append({
|
|
"key": "network_dns",
|
|
"title": "Verify DNS and Network",
|
|
"ok": None,
|
|
"detail": "Domain DNS must point to HAProxy IP, Port 80 must be open from internet",
|
|
"navigate": None,
|
|
})
|
|
|
|
ready = all(step["ok"] is True for step in steps if step["ok"] is not None)
|
|
|
|
return {"ready": ready, "steps": steps}
|
|
finally:
|
|
await close_database_connection(conn)
|