Files
mustafa.ulukaya ef26860df9 feat(logging): unified request/response log with configurable retention (v1.11.0)
Until now the only record of what happened was `user_activity_logs`, which
stores non-GET 2xx operations with no bodies. When something failed you could
see that a counter went up, never what was sent or what came back.

This adds one queryable timeline covering both directions:

- inbound: every API call, including GETs and including 4xx/5xx, with the
  user, client IP, status, duration and — redacted, size-capped — the request
  and response bodies.
- outbound: every HTTP call the backend makes, tagged with who it went to
  (ACME/Let's Encrypt, Cloudflare, GoDaddy, HAProxy stats, agents, the ACME
  diagnostics probe).

Outbound rows inherit the inbound request's id, so one operator action and the
CA/DNS calls it triggered read as a single trace: opening a failed "Request
Certificate" shows the exact POST /acme/new-order and the CA's 429 underneath.

Implementation notes:

- Capture is a pure-ASGI middleware that TEES the request and response streams
  rather than draining them. `await request.body()` inside a BaseHTTPMiddleware
  would consume the receive channel and break the raw-body agent heartbeat
  handler. Registered last so it is outermost: it then sees the final
  client-visible response and seeds correlation_id_context before the error
  handler reads it.
- Rows are written by a batching background writer with a bounded queue, so the
  request path never awaits the database and a saturated logger drops rows
  visibly (surfaced on the page) instead of blocking. Redaction runs on the
  writer, off the request coroutine.
- Secrets never land: headers are an allowlist with Authorization/Cookie kept
  only as a presence marker; body keys and value shapes are redacted
  (passwords, tokens, api_token, API keys, private-key PEMs, JWTs); the ACME
  JWS request body is never stored, because a stored protected+signature pair
  is a replayable credential — a summary is logged instead; DNS-provider errors
  record only the exception type; the ACME HTTP-01 challenge endpoint is
  excluded so key_authorization is never captured.
- Retention is operator-configurable in Settings -> Request Log: separate day
  counts for successful and failed rows (7 / 30) plus a hard row cap (500k),
  whichever is reached first. Pruned in batches under a Postgres advisory lock,
  with the day counts bound as parameters, never interpolated.
- New permissions requestlog.read / requestlog.manage. super_admin and
  security_admin get both, operator gets read, viewer gets neither.

Schema: one new table (request_logs) plus its settings seed, SCHEMA_VERSION
10 -> 11, auto-migrated. No existing table altered, no agent or rendered-config
change. Kill switches: REQUEST_LOG_ENABLED=false (middleware never registered)
or the `enabled` toggle in Settings.

Tests: 245 new (7 backend files + 1 frontend), full suite 1655 backend +
17 frontend passing.
2026-08-11 02:36:03 +03:00

1990 lines
96 KiB
Python

from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional, List, Dict
import base64
import json
import logging
import re
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
from services.dns_providers import list_providers, is_supported, get_provider, DnsProviderError
from utils.dns_credentials import encrypt_dns_credentials, decrypt_dns_credentials
# Issue #35: DNS-01 challenge methods.
_CHALLENGE_TYPES = ("http-01", "dns-01")
async def _dns01_enabled() -> bool:
"""Global kill-switch (system_settings acme.dns01_enabled, default False). Read via the ACME
settings dict so non-admins never need the admin-only /api/settings/acme endpoint."""
try:
settings = await acme_service._get_settings()
val = settings.get('dns01_enabled')
if isinstance(val, str):
return val.strip().lower() in ('1', 'true', 'yes', 'on')
return bool(val)
except Exception:
return False
# Per-user sliding-window rate limit for the manual dns-confirm action (soft anti-abuse so a user
# can't spam the CA via the confirm button). Per-process; sufficient for a manual UI action.
_DNS_CONFIRM_RL: Dict[int, list] = {}
_DNS_CONFIRM_LIMIT = 5
_DNS_CONFIRM_WINDOW = 60.0
async def _enforce_dns_confirm_rate_limit(user_id: int) -> None:
now = time.time()
bucket = [t for t in _DNS_CONFIRM_RL.get(user_id, []) if now - t < _DNS_CONFIRM_WINDOW]
if len(bucket) >= _DNS_CONFIRM_LIMIT:
raise HTTPException(status_code=429, detail="Rate limit exceeded: dns-confirm allowed 5 requests per minute")
bucket.append(now)
_DNS_CONFIRM_RL[user_id] = bucket
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/letsencrypt", tags=["Let's Encrypt / ACME"])
# RFC 1035 / RFC 5890: hostname/domain label rules. Permits wildcards (*).
_DOMAIN_REGEX = re.compile(
r'^(?:\*\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
)
class AccountCreate(BaseModel):
email: str
directory_url: Optional[str] = None
tos_agreed: bool = True
# EAB (External Account Binding) for CAs that require it (ZeroSSL, Google). The KID is opaque
# (bound only); the HMAC key must be base64url so newAccount's _b64url_decode won't raise a
# cryptic binascii error (a common copy mistake is standard-base64 '+'/'/' vs urlsafe '-'/'_').
eab_kid: Optional[str] = Field(default=None, max_length=256)
eab_hmac_key: Optional[str] = Field(default=None, max_length=512)
# Issue #35: per-account default challenge method + DNS provider (for dns-01).
challenge_type: str = "http-01"
dns_provider: Optional[str] = None
@field_validator('challenge_type')
@classmethod
def _validate_challenge_type(cls, v):
if v not in _CHALLENGE_TYPES:
raise ValueError(f"challenge_type must be one of {_CHALLENGE_TYPES}")
return v
@field_validator('eab_hmac_key')
@classmethod
def _validate_eab_hmac_key(cls, v):
if not v:
return v
try:
base64.urlsafe_b64decode(v + '=' * (-len(v) % 4))
except Exception:
raise ValueError("eab_hmac_key is not valid base64; copy it exactly from your CA account.")
return v
@field_validator('directory_url')
@classmethod
def _validate_directory_url(cls, v):
# SECURITY (GHSA-3vh4-gvxx-wm2p): reject non-https URLs and literal
# non-public IP hosts at the API boundary. The full DNS-based SSRF check
# runs at fetch time (acme_service.get_directory -> ssrf_guard).
if not v:
return v
from urllib.parse import urlparse
import ipaddress
from utils.ssrf_guard import is_public_ip
parsed = urlparse(v.strip())
if parsed.scheme.lower() != 'https':
raise ValueError("directory_url must be an https URL")
host = parsed.hostname
if not host:
raise ValueError("directory_url has no host")
try:
ipaddress.ip_address(host)
is_ip_literal = True
except ValueError:
is_ip_literal = False
if is_ip_literal and not is_public_ip(host):
raise ValueError("directory_url must not point to a private/loopback IP address")
return v
@model_validator(mode='after')
def _require_provider_for_dns01(self):
if self.challenge_type == 'dns-01' and not (self.dns_provider or '').strip():
raise ValueError("dns_provider is required when challenge_type is 'dns-01'")
return self
class DnsCredentialsUpsert(BaseModel):
dns_provider: str = Field(..., min_length=1, max_length=50)
credentials: Dict[str, str] = Field(default_factory=dict)
@field_validator('credentials')
@classmethod
def _validate_credentials(cls, v):
if len(v) > 20:
raise ValueError("Too many credential fields")
for key, val in v.items():
if not isinstance(key, str) or not re.match(r'^[a-zA-Z0-9_]{1,50}$', key):
raise ValueError(f"Invalid credential field name: {key!r}")
if not isinstance(val, str) or len(val) > 4000:
raise ValueError(f"Credential value for {key!r} is missing or too long")
return v
class CertificateRequest(BaseModel):
# Audit Tur 5 / Commit 8c-2: harden input validation.
# min_length=1: reject empty domain list at API boundary.
# max_length=100: prevent abuse / oversized SAN bundles.
# Default cluster_ids to [] (not None) to simplify downstream handling.
domains: List[str] = Field(..., min_length=1, max_length=100)
account_id: Optional[int] = None
cluster_ids: List[int] = Field(default_factory=list)
auto_renew: bool = True
# Issue #35: optional override; when None the account's default method is used.
challenge_type: Optional[str] = None
@field_validator('domains')
@classmethod
def validate_domains(cls, v):
normalized = []
for d in v:
if not d or not isinstance(d, str):
raise ValueError("Domain entries must be non-empty strings")
d_norm = d.strip().lower()
if not d_norm or len(d_norm) > 253:
raise ValueError(f"Invalid domain length: '{d}' (max 253 chars)")
if '..' in d_norm or d_norm.startswith('.') or d_norm.endswith('.'):
raise ValueError(f"Invalid domain syntax: '{d}'")
if not _DOMAIN_REGEX.match(d_norm):
raise ValueError(f"Invalid domain format: '{d}'")
normalized.append(d_norm)
# De-duplicate (case/whitespace variants normalize to the same value) while preserving order,
# so we don't submit a redundant SAN to the CA or render duplicate-keyed tags in the UI.
return list(dict.fromkeys(normalized))
@field_validator('challenge_type')
@classmethod
def _validate_challenge_type(cls, v):
if v is not None and v not in _CHALLENGE_TYPES:
raise ValueError(f"challenge_type must be one of {_CHALLENGE_TYPES}")
return v
@model_validator(mode='after')
def _wildcard_requires_dns01(self):
# Static cross-field guard: a wildcard SAN can ONLY be issued via dns-01 (the CA rejects
# wildcard over http-01). The runtime dns01_enabled gate + provider resolution happen in the
# endpoint (validators can't do async/DB). When challenge_type is None here, the effective
# method is resolved from the account in the endpoint, which re-checks this.
if any((d or '').startswith('*.') for d in (self.domains or [])):
# Only reject when the caller EXPLICITLY chose a non-dns-01 method. When challenge_type is
# None, the effective method is resolved from the account in the endpoint, which re-checks
# wildcard-requires-dns-01 — so account-default dns-01 inheritance still works for wildcards.
if self.challenge_type is not None and self.challenge_type != 'dns-01':
raise ValueError("Wildcard certificates require challenge_type 'dns-01'")
return self
# --- Account management ---
@router.get("/accounts")
async def list_accounts(authorization: str = Header(None)):
"""v1.5.0 R12: list of LE accounts is now READ-ONLY for any
authenticated user. Account *creation* / deletion remains admin-only.
The wizard ('New Site' / ACME mode) needs this list so the
user can pick which LE account to bill against when more than one is
configured. Previously the wizard silently dropped the selector
because non-admin users got 403 here (Promise.allSettled swallowed
the failure).
"""
from auth_middleware import get_current_user_from_token
# Authentication still required — get_current_user_from_token raises
# 401 if the token is missing/invalid.
await get_current_user_from_token(authorization)
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, challenge_type, dns_provider 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')
# Commit 5f: respect staging mode for non-Let's Encrypt CAs as well.
# If `acme.staging_url_override` is set in system_settings, use it when staging
# mode is active. Falls back to LE staging for the default LE production URL.
if settings.get('staging_mode'):
override = settings.get('staging_url_override') or ''
if override and override.startswith('http'):
logger.info(f"ACME: Using staging_url_override: {override}")
directory_url = override
elif '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
# Issue #35: a dns-01 account must name a supported DNS provider.
if body.challenge_type == 'dns-01':
if not await _dns01_enabled():
raise HTTPException(status_code=409, detail="DNS-01 is disabled by an administrator (enable it in Settings).")
if not is_supported((body.dns_provider or '').strip()):
raise HTTPException(status_code=422, detail=f"Unsupported DNS provider: {body.dns_provider}")
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,
challenge_type=body.challenge_type,
dns_provider=(body.dns_provider or None),
)
return result
except HTTPException:
# Preserve deliberate status codes (e.g. 409 DNS-01 disabled, 422 unsupported provider) —
# the broad except below would otherwise downgrade them all to 400.
raise
except Exception as e:
logger.error(f"ACME account registration failed: {e}")
# Humanize the common EAB-required failure (ZeroSSL/Google). The ACME error propagates as a
# string ("Account registration failed: {<dict>}"), so match the URN substring in str(e).
if 'externalaccountrequired' in str(e).lower():
raise HTTPException(status_code=400, detail=(
"This CA requires External Account Binding (EAB). Enter the EAB Key ID and HMAC Key "
"from your ZeroSSL/Google account and retry."
))
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)
# --- Issue #35: DNS-01 providers + per-account DNS credentials ---
@router.get("/dns-providers")
async def get_dns_providers(authorization: str = Header(None)):
"""List supported DNS providers + their credential-field schema (for the UI). Also returns the
global dns01_enabled gate so a non-admin cert UI can read it without the admin-only settings API.
Authenticated (any user); not admin-only."""
from auth_middleware import get_current_user_from_token
await get_current_user_from_token(authorization)
return {"dns01_enabled": await _dns01_enabled(), "providers": list_providers()}
@router.get("/accounts/{account_id}/dns-credentials")
async def get_dns_credentials(account_id: int, authorization: str = Header(None)):
"""Masked metadata only — provider + which credential fields are set + updated_at. NEVER returns
the ciphertext or any plaintext token. Read-only for any authenticated user (matches list_accounts)."""
from auth_middleware import get_current_user_from_token
await get_current_user_from_token(authorization)
conn = await get_database_connection()
try:
row = await conn.fetchrow(
"SELECT dns_provider, credentials_encrypted, updated_at FROM letsencrypt_account_dns_credentials WHERE account_id = $1",
account_id,
)
if not row:
return {"configured": False, "dns_provider": None, "credential_fields_present": [], "updated_at": None}
present = []
decrypted = decrypt_dns_credentials(row["credentials_encrypted"])
if isinstance(decrypted, dict):
present = sorted(decrypted.keys())
return {
"configured": True,
"dns_provider": row["dns_provider"],
"credential_fields_present": present,
"updated_at": row["updated_at"],
}
finally:
await close_database_connection(conn)
@router.put("/accounts/{account_id}/dns-credentials")
async def upsert_dns_credentials(account_id: int, body: DnsCredentialsUpsert, authorization: str = Header(None)):
"""Store (encrypted) DNS provider credentials for an account. Admin-only. Verifies the
credentials against the provider BEFORE persisting; returns a sanitized result (never the token)."""
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")
provider_name = body.dns_provider.strip()
if not is_supported(provider_name):
raise HTTPException(status_code=422, detail=f"Unsupported DNS provider: {provider_name}")
conn = await get_database_connection()
try:
exists = await conn.fetchval("SELECT 1 FROM letsencrypt_accounts WHERE id = $1", account_id)
if not exists:
raise HTTPException(status_code=404, detail="ACME account not found")
# Verify credentials synchronously; only persist on success. The detail is user-safe.
try:
provider = get_provider(provider_name, dict(body.credentials))
verify = await provider.verify_credentials()
except DnsProviderError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except HTTPException:
raise
except Exception:
# Defensive: never let a provider-internal exception string (which could echo creds in a
# future provider) reach the client. Always a sanitized 422.
raise HTTPException(status_code=422, detail="DNS provider credential verification failed")
if not verify.get("ok"):
raise HTTPException(status_code=422, detail=verify.get("detail") or "DNS provider credential verification failed")
token = encrypt_dns_credentials(dict(body.credentials))
await conn.execute(
"""INSERT INTO letsencrypt_account_dns_credentials (account_id, dns_provider, credentials_encrypted, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (account_id) DO UPDATE SET
dns_provider = EXCLUDED.dns_provider,
credentials_encrypted = EXCLUDED.credentials_encrypted,
updated_at = NOW()""",
account_id, provider_name, token,
)
# Keep the account's provider selection in sync.
await conn.execute(
"UPDATE letsencrypt_accounts SET dns_provider = $1, updated_at = NOW() WHERE id = $2",
provider_name, account_id,
)
return {"ok": True, "dns_provider": provider_name, "detail": verify.get("detail", "Credentials stored.")}
finally:
await close_database_connection(conn)
@router.delete("/accounts/{account_id}/dns-credentials")
async def delete_dns_credentials(account_id: int, authorization: str = Header(None)):
"""Remove an account's stored DNS credentials. Admin-only."""
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:
await conn.execute("DELETE FROM letsencrypt_account_dns_credentials WHERE account_id = $1", account_id)
return {"ok": True}
finally:
await close_database_connection(conn)
@router.post("/orders/{order_id}/dns-confirm")
async def confirm_dns_order(order_id: int, authorization: str = Header(None)):
"""Manual DNS-01 only: the user asserts the TXT record is published; tell the CA to validate.
Requires ssl.create + a per-user rate limit; acts only on a dns-01 + manual + pending order."""
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")
await _enforce_dns_confirm_rate_limit(current_user['id'])
conn = await get_database_connection()
try:
order = await conn.fetchrow(
"""SELECT o.id, o.status, o.challenge_type, a.dns_provider
FROM letsencrypt_orders o JOIN letsencrypt_accounts a ON o.account_id = a.id
WHERE o.id = $1""",
order_id,
)
finally:
await close_database_connection(conn)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order['challenge_type'] != 'dns-01' or (order['dns_provider'] or 'manual') != 'manual':
raise HTTPException(status_code=409, detail="This order is not a manual DNS-01 order")
if order['status'] not in ('pending', 'processing'):
raise HTTPException(status_code=409, detail=f"Order is '{order['status']}' and cannot be confirmed")
from services.dns01_orchestrator import confirm_manual_dns01
await confirm_manual_dns01(order_id)
return {"ok": True, "message": "DNS-01 confirmation submitted; the CA will validate shortly."}
# --- 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")
# Pydantic enforces min_length=1 — this is a defensive double-check.
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 = []
# Issue #35: resolve the effective challenge method (request override, else account default).
conn_acct = await get_database_connection()
try:
acct = await conn_acct.fetchrow(
"SELECT challenge_type, dns_provider FROM letsencrypt_accounts WHERE id = $1", account_id
)
finally:
await close_database_connection(conn_acct)
effective_challenge = (body.challenge_type or (acct['challenge_type'] if acct else None) or 'http-01')
dns_provider = (acct['dns_provider'] if acct else None)
is_dns01 = (effective_challenge == 'dns-01')
has_wildcard = any(d.startswith('*.') for d in body.domains)
if is_dns01:
if not await _dns01_enabled():
raise HTTPException(status_code=409, detail="DNS-01 is disabled by an administrator (enable it in Settings).")
if not is_supported((dns_provider or '').strip()):
raise HTTPException(status_code=422, detail="The selected ACME account has no DNS provider configured for DNS-01.")
if (dns_provider or 'manual') == 'manual':
# Manual DNS-01 cannot be renewed unattended; auto-renew is forced off on the issued
# certificate (see _complete_certificate). Tell the requester so it isn't a surprise.
warnings.append(
"Manual DNS-01 certificates cannot auto-renew unattended. Auto-renew will be disabled; "
"re-publish the TXT record and request renewal before expiry."
)
elif has_wildcard:
raise HTTPException(status_code=422, detail="Wildcard certificates require a DNS-01 account.")
# Empty cluster_ids = "global certificate". For DNS-01 no ACME Challenge Routing / port 80 is
# needed, so resolve to ALL active clusters; http-01 still requires acme_enabled clusters.
if not body.cluster_ids:
conn_resolve = await get_database_connection()
try:
if is_dns01:
resolved = await conn_resolve.fetch("SELECT id FROM haproxy_clusters WHERE is_active = TRUE")
else:
resolved = await conn_resolve.fetch("SELECT id FROM haproxy_clusters WHERE acme_enabled = TRUE AND is_active = TRUE")
finally:
await close_database_connection(conn_resolve)
if not resolved:
if is_dns01:
raise HTTPException(status_code=422, detail="Cannot issue certificate: no active clusters configured.")
raise HTTPException(
status_code=422,
detail="Cannot issue certificate: no ACME-enabled clusters configured. "
"Enable ACME Challenge Routing on at least one cluster in Cluster Management, "
"Apply the configuration change, then retry."
)
body.cluster_ids = [c['id'] for c in resolved]
warnings.append(
f"No clusters specified — applied to all {'active' if is_dns01 else 'ACME-enabled'} cluster(s) ({len(body.cluster_ids)})"
)
elif not is_dns01:
# http-01 only: warn if no cluster has ACME Challenge Routing enabled.
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:
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,
challenge_type=effective_challenge,
created_by=current_user['id'],
)
# Audit trail: record who requested the certificate + the method (esp. for DNS-01/wildcard,
# which has a wider blast radius than http-01). Never raises into the request path.
try:
from utils.activity_log import record_event
await record_event(
order['order_id'], "acme.order.requested",
message=f"Certificate requested ({effective_challenge}) by user {current_user['id']}",
details={"user_id": current_user['id'], "challenge_type": effective_challenge,
"dns_provider": dns_provider, "domains": body.domains},
)
except Exception:
pass
# http-01 posts the challenge response immediately (token served continuously). dns-01 is
# driven by the orchestrator AFTER the TXT is published (manual waits for dns-confirm), so we
# must NOT respond here.
challenges = []
if not is_dns01:
challenges = await acme_service.respond_to_challenges(order['order_id'])
conn_status = await get_database_connection()
try:
fresh_status = await conn_status.fetchval(
"SELECT status FROM letsencrypt_orders WHERE id = $1",
order['order_id']
)
finally:
await close_database_connection(conn_status)
effective_status = fresh_status or order['status']
if is_dns01:
msg = ("Order created. Publish the DNS TXT record shown for each domain, then confirm."
if dns_provider == 'manual'
else "Order created. The DNS TXT record(s) will be published automatically; waiting for CA validation.")
else:
msg = "Order created. ACME challenges have been posted. Waiting for CA validation."
logger.info(f"ACME: Order {order['order_id']} created ({effective_challenge}), status={effective_status}")
return {
"order_id": order['order_id'],
"status": effective_status,
"domains": body.domains,
"challenge_type": effective_challenge,
"dns_provider": dns_provider,
"challenges": challenges,
"message": msg,
"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, o.challenge_type, a.email as account_email,
a.dns_provider
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, o.challenge_type, a.email as account_email,
a.dns_provider
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")
# Issue #35: include challenge_type + dns_txt_value (PUBLIC DNS data — NOT key_authorization,
# NOT the API token) so the UI can render manual DNS-01 instructions. Explicit column list.
challenges = await conn.fetch(
"SELECT id, order_id, domain, token, challenge_url, status, validated_at, created_at, "
"challenge_type, dns_txt_value 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']
ch_list = []
for c in challenges:
cd = dict(c)
if cd.get('challenge_type') == 'dns-01':
# Server computes the record name (wildcard *.-stripping lives server-side).
cd['dns_record_name'] = acme_service._challenge_dns_name(cd['domain'])
ch_list.append(cd)
result['challenges'] = ch_list
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:
# Idempotency + concurrency guard. Two checks in one round-trip:
# 1. ssl_certificate_id NOT NULL -> already done, return success
# 2. updated_at touched in last 30s -> auto-completion task is actively
# processing this order. Returning a 202-style hint avoids a
# simultaneous duplicate _complete_certificate() race that would
# hit the ssl_certificates UNIQUE(name) constraint and cost an
# extra CA download.
conn = await get_database_connection()
try:
row = await conn.fetchrow(
"""SELECT ssl_certificate_id,
(updated_at IS NOT NULL AND updated_at > NOW() - INTERVAL '30 seconds')
AS recently_touched
FROM letsencrypt_orders WHERE id = $1""",
order_id
)
finally:
await close_database_connection(conn)
if not row:
raise HTTPException(status_code=404, detail="Order not found")
if row['ssl_certificate_id']:
return {
"message": "Certificate already issued for this order",
"order_id": order_id,
"certificate_id": row['ssl_certificate_id'],
}
if row['recently_touched']:
return {
"message": "Order is currently being processed by the auto-completion task. "
"Please wait ~60 seconds and refresh.",
"order_id": order_id,
"in_progress": True,
}
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':
# Issue #12 / Commit 3b: handle valid-without-certificate-url edge case.
# CA marked the order valid but our DB has no certificate_url yet
# (race between finalize and check_order_status). Trigger finalize
# if not yet done, then drive completion.
if not status_info.get('certificate_url'):
logger.info(f"ACME RETRY: Order {order_id} valid without certificate_url, attempting finalize")
try:
await acme_service.finalize_order(order_id)
except Exception as fin_err:
# Order may already be finalized server-side; re-poll status
logger.warning(f"ACME RETRY: finalize_order returned {fin_err}, re-polling status")
status_info = await acme_service.check_order_status(order_id)
current_status = status_info.get('status')
if current_status == 'valid' and status_info.get('certificate_url'):
return await _complete_certificate(order_id)
else:
return {
"message": f"Order is valid but certificate URL not yet available (status={current_status}). "
f"Auto-completion task will retry within 60 seconds.",
"order_id": order_id,
"status": current_status,
}
else:
# pending / processing — re-submit challenges
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, challenge_type 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']
challenge_type = order['challenge_type'] or 'http-01'
new_order = await acme_service.create_order(
account_id=order['account_id'], domains=domains, cluster_ids=cluster_ids,
challenge_type=challenge_type, created_by=current_user['id'],
)
# dns-01 is driven by the orchestrator after the TXT is published; only http-01 responds here.
challenges = []
if challenge_type != 'dns-01':
challenges = await acme_service.respond_to_challenges(new_order['order_id'])
return {"message": "Renewal order created", "new_order_id": new_order['order_id'], "challenge_type": challenge_type, "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 = []
# v1.11.0: each download is recorded as an outbound row. The bodies are
# public CA certificates, not secrets, and the 8 KB body cap truncates them —
# what matters here is which URL failed, with what status.
from utils.http_instrumentation import outbound_span, TARGET_LETSENCRYPT_CA
async with aiohttp.ClientSession() as session:
for url, name in ca_urls:
try:
async with outbound_span(
target=TARGET_LETSENCRYPT_CA, method="GET", url=url
) as span:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
text = await resp.text() if resp.status == 200 else None
span.set_response(resp.status, getattr(resp, "headers", None), text)
if resp.status == 200:
chain_parts.append(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:
# Issue #35: expose the challenge method/provider (via the originating order/account) so the
# UI can distinguish manual DNS-01 certs, which cannot auto-renew unattended. LEFT JOINs keep
# legacy certs (no order link / pre-DNS-01 columns) rendering as http-01.
certs = await conn.fetch("""
SELECT c.id, c.name, c.primary_domain, c.expiry_date, c.auto_renew, c.days_until_expiry,
o.challenge_type, a.dns_provider
FROM ssl_certificates c
LEFT JOIN letsencrypt_orders o ON o.id = c.letsencrypt_order_id
LEFT JOIN letsencrypt_accounts a ON a.id = o.account_id
WHERE c.source = 'letsencrypt' AND c.is_active = TRUE
ORDER BY c.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()
# Concurrency guard: PostgreSQL session-level advisory lock keyed on order_id.
# Serializes concurrent _complete_certificate(order_id) calls across this
# process and across replicas (user-clicks-Complete + auto-completion task,
# or two simultaneous user clicks). A second caller blocks here until the
# first finishes, then re-reads ssl_certificate_id below and exits via the
# idempotency guard. Released in the outer finally before connection close.
# Namespace 0x41434D45 ('ACME' ASCII) XOR'd with order_id to avoid collision.
ADVISORY_NS = 0x41434D45
await conn.execute("SELECT pg_advisory_lock($1, $2)", ADVISORY_NS, order_id)
lock_held = True
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'
# Issue #35: a manual DNS-01 certificate cannot be auto-renewed unattended (the renewal task
# skips it — see main.py), so persist auto_renew=FALSE rather than storing a misleading
# "Enabled" that the user trusts while the cert silently expires. http-01 and automated
# DNS-01 (e.g. Cloudflare) keep auto_renew=TRUE, preserving existing behaviour.
auto_renew_value = True
if order.get('challenge_type') == 'dns-01':
acct_provider = await conn.fetchval(
"SELECT dns_provider FROM letsencrypt_accounts WHERE id = $1", order['account_id']
)
if (acct_provider or 'manual') == 'manual':
auto_renew_value = False
# Commit 5g: guard against empty cert_private_key.
# Inserting an SSL certificate row with an empty private_key would silently
# produce an unusable certificate (HAProxy would fail to load on Apply, or
# SSL handshakes would fail at runtime). Fail-fast with a clear diagnostic
# so the user can re-finalize the order.
private_key_pem = order.get('cert_private_key') or ''
if not private_key_pem.strip() or '-----BEGIN' not in private_key_pem:
error_payload = json.dumps({
"stage": "_complete_certificate",
"reason": "missing_or_invalid_cert_private_key",
"private_key_present": bool(private_key_pem),
"timestamp": datetime.utcnow().isoformat(),
})
try:
await conn.execute(
"UPDATE letsencrypt_orders SET status = 'invalid', error_detail = $1, updated_at = NOW() WHERE id = $2",
error_payload, order_id
)
except Exception:
pass
raise Exception(
f"Cannot complete order {order_id}: cert_private_key is missing or invalid. "
f"This indicates finalize_order() did not persist the key correctly. "
f"Cancel this order and create a new certificate request."
)
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')
# Issue #10: ssl_certificates.expiry_date column is TIMESTAMP (timezone-naive).
# parse_ssl_certificate always returns timezone-aware UTC datetime.
# Without normalization asyncpg silently fails the INSERT/UPDATE for tz-aware
# values, leaving expiry_date NULL and breaking auto-renewal.
if expiry_date is not None and getattr(expiry_date, 'tzinfo', None) is not None:
from datetime import timezone
expiry_date = expiry_date.astimezone(timezone.utc).replace(tzinfo=None)
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}")
# v1.5.0 (Bulgu #4 fix): a wizard-staged order ALWAYS expects a fresh
# cert + post-completion actions to fire. If `post_completion_actions`
# is non-empty we must NOT match against a manually-issued cert that
# happens to share the same primary_domain — that would silently
# swallow the HTTPS frontend creation and leave the wizard host
# broken.
pca_raw_for_match = order.get("post_completion_actions")
try:
_pca_check = (
json.loads(pca_raw_for_match)
if isinstance(pca_raw_for_match, str) and pca_raw_for_match.strip()
else (pca_raw_for_match or [])
)
except Exception:
_pca_check = []
is_wizard_order = bool(_pca_check)
if is_wizard_order:
existing_cert = None
else:
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 = $11, 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,
auto_renew_value)
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, $12, 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,
auto_renew_value)
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
)
# Issue #12 / Commit 3c: idempotent ssl_certificate_clusters reconcile.
# On renewal, the order may carry a different cluster_ids list than the
# original cert's junction (user added new clusters between issue and renewal).
# We INSERT new entries (additive, ON CONFLICT DO NOTHING) but never DELETE
# existing junction rows — manually-added cluster assignments are preserved.
if cluster_ids:
existing_cluster_set = set()
if is_renewal:
existing_rows = await conn.fetch(
"SELECT cluster_id FROM ssl_certificate_clusters WHERE ssl_certificate_id = $1",
cert_id
)
existing_cluster_set = {r['cluster_id'] for r in existing_rows}
new_clusters = [cid for cid in cluster_ids if cid not in existing_cluster_set]
if new_clusters:
logger.info(
f"ACME RENEWAL: Adding {len(new_clusters)} new cluster(s) to cert {cert_id}: {new_clusters}"
)
# WARN if order mentioned clusters that were manually removed from junction
missing_in_order = existing_cluster_set - set(cluster_ids)
if missing_in_order:
logger.warning(
f"ACME RENEWAL: cert {cert_id} has manual junction entries not in order.cluster_ids: "
f"{sorted(missing_in_order)}. Preserving manual assignments."
)
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:
# Audit Tur 6 / Commit 5j: http-01 only falls back to ACME-enabled clusters.
# Issue #35: a dns-01 cert needs NO challenge routing / port 80, so it can renew on
# any active cluster — resolve to all active clusters for dns-01.
ch_type = await conn.fetchval(
"SELECT challenge_type FROM letsencrypt_orders WHERE id = $1", order_id
)
if ch_type == 'dns-01':
all_clusters = await conn.fetch("SELECT id FROM haproxy_clusters WHERE is_active = TRUE")
else:
all_clusters = await conn.fetch(
"SELECT id FROM haproxy_clusters WHERE is_active = TRUE AND acme_enabled = 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
# Issue #12 / Commit 4d: per-cluster error tracking + observability.
cluster_errors = [] # [{cluster_id, error}]
clusters_succeeded = []
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)
clusters_succeeded.append(cid)
except Exception as ve:
# Was logger.error swallowing details; now surface to API caller.
logger.error(
f"[ACME] Failed to create config version for cluster {cid} "
f"(cert {cert_id}, action {action}): {ve}",
exc_info=True
)
cluster_errors.append({"cluster_id": cid, "error": str(ve)})
if is_renewal and clusters_succeeded:
await _auto_apply_renewal(cert_id, clusters_succeeded)
# ====================================================================
# v1.5.0 Feature B (Issue #14): post_completion_actions JSONB support.
#
# The wizard staged this order with a deferred HTTPS-frontend create
# request. Now that the cert is downloaded we execute it.
#
# M2 guard: NEVER run on renewal — renewing a wizard-issued cert
# must not re-create the HTTPS frontend.
# M3 cancellation race: re-fetch the order's status before exec.
# M21/R35 collision re-check: re-validate bind_port collision.
# M24 conn reuse: pass our existing transaction conn into record_event.
# M26/R42 atomicity: wrap each action in its own conn.transaction().
# ====================================================================
post_completion_outcomes: list = []
if not is_renewal:
try:
pca_raw = order.get("post_completion_actions")
if isinstance(pca_raw, str) and pca_raw.strip():
pca = json.loads(pca_raw)
elif isinstance(pca_raw, list):
pca = pca_raw
else:
pca = []
except Exception:
pca = []
if pca:
# M3: re-check status to detect a cancellation race
fresh = await conn.fetchrow(
"SELECT status FROM letsencrypt_orders WHERE id = $1", order_id
)
if fresh and fresh["status"] == "valid":
post_completion_outcomes = await _execute_post_completion_actions(
conn, order_id, pca, cert_id
)
else:
logger.info(
f"[ACME] Skipping post_completion_actions for order {order_id}: "
f"status changed to {fresh and fresh['status']}"
)
msg = "Certificate renewed and applied" if is_renewal else "Certificate issued (pending Apply)"
if cluster_errors:
msg += f" ({len(cluster_errors)} cluster(s) failed: see cluster_errors)"
return {
"message": msg,
"certificate_id": cert_id,
"domains": domains,
"auto_applied": is_renewal,
"clusters_succeeded": clusters_succeeded,
"cluster_errors": cluster_errors,
"post_completion_outcomes": post_completion_outcomes,
}
finally:
if lock_held:
try:
await conn.execute("SELECT pg_advisory_unlock($1, $2)", ADVISORY_NS, order_id)
except Exception as unlock_err:
logger.warning(f"ACME: failed to release advisory lock for order {order_id}: {unlock_err}")
await close_database_connection(conn)
async def _execute_post_completion_actions(
conn,
order_id: int,
actions: list,
cert_id: int,
) -> list:
"""v1.5.0 Feature B: execute the deferred actions stored on a wizard
ACME order's post_completion_actions JSONB.
Each action is independently wrapped in conn.transaction() (R42/M26),
has its own try/except (per-action errors do NOT block other actions),
and an executed_at idempotency flag.
Auto-apply is triggered if any executed action set _auto_apply=true on
its frontend_config.
"""
from utils.activity_log import record_event
from services.frontend_service import (
check_bind_port_collision,
create_frontend_row,
)
outcomes = []
auto_apply_user_ids: set = set()
auto_apply_cluster_ids: set = set()
for idx, action in enumerate(actions):
if not isinstance(action, dict):
outcomes.append({"index": idx, "status": "skipped", "reason": "not a dict"})
continue
if action.get("executed_at"):
outcomes.append({"index": idx, "status": "skipped", "reason": "already executed"})
continue
action_type = action.get("type")
try:
async with conn.transaction():
if action_type == "create_frontend":
fe_cfg = action.get("frontend_config") or {}
cluster_id = fe_cfg.get("cluster_id")
bind_address = fe_cfg.get("bind_address", "*")
bind_port = fe_cfg.get("bind_port", 443)
fe_name = fe_cfg.get("name") or f"fe-{order_id}-https"
if not cluster_id:
raise ValueError("frontend_config.cluster_id required")
# Bulgu #52 (round-18 audit) — verify the cluster still
# exists before any further work.
#
# `letsencrypt_orders.cluster_ids` is JSONB (not an FK),
# so an operator can delete a cluster between
# `wizard_staged` and post-completion. With the previous
# code path:
#
# - check_bind_port_collision would find no frontends
# for the missing cluster (returns None — no
# collision)
# - the backend-existence check would correctly flag
# `backend_missing` IF a default_backend was set,
# but actions without `default_backend` (legacy
# payloads, TCP-mode wizard runs) would proceed to
# create_frontend_row pointing at a dead
# cluster_id, then fail with a FK violation that
# surfaces only in the logs.
#
# Catch this upfront with the same shape as the
# `backend_missing` outcome so the operator sees a
# clear "cluster removed — re-run the wizard" message
# in the order's activity log instead of a generic
# FK error.
cluster_row = await conn.fetchrow(
"SELECT id FROM haproxy_clusters "
"WHERE id = $1 AND is_active = TRUE",
cluster_id,
)
if cluster_row is None:
action["error"] = "cluster_missing"
action["error_detail"] = (
f"Cluster id={cluster_id} no longer exists "
"(or was deactivated) — the wizard's target "
"cluster was removed after the ACME order "
"was staged. Cert was issued but no HTTPS "
"frontend was created. Re-run the wizard "
"against an active cluster, or assign the "
"issued cert to a frontend manually."
)
await record_event(
order_id,
"post_completion_action_skipped",
severity="ERROR",
message=action["error_detail"],
details={
"action_index": idx,
"type": action_type,
"missing_cluster_id": cluster_id,
},
conn=conn,
)
outcomes.append({
"index": idx, "status": "error",
"reason": "cluster_missing",
"detail": action["error_detail"],
})
continue
# M21/R35: re-check port collision pre-INSERT
collision = await check_bind_port_collision(
conn, cluster_id, bind_address, bind_port
)
if collision:
action["error"] = "port_collision"
action["error_detail"] = (
f"bind {bind_address}:{bind_port} already used by frontend id={collision}"
)
await record_event(
order_id,
"post_completion_action_skipped",
severity="ERROR",
message=action["error_detail"],
details={"action_index": idx, "type": action_type},
conn=conn,
)
outcomes.append({
"index": idx, "status": "error",
"reason": "port_collision",
"detail": action["error_detail"],
})
continue
# Bulgu #31 (round-13 audit) — referenced default_backend
# MUST still exist before we insert the deferred HTTPS
# frontend. The wizard's HTTP frontend + backend are
# created at submit time and become part of the
# `bulk-site-create-<ts>` config version's snapshot. If
# the operator REJECTS that version between apply and
# post-completion, the snapshot rollback deletes the
# backend rows. `create_frontend_row` would still
# happily INSERT this HTTPS frontend with
# `default_backend='be_xxx'` — and HAProxy then refuses
# to load the config at the next apply with:
#
# [ALERT] : Proxy 'fe_xxx-https' references unknown
# backend 'be_xxx'.
#
# Operator sees an unrecoverable "config parse error"
# AFTER the cert was already issued and the order
# marked 'valid' — leaving an orphan cert and a
# locked-up apply queue. Bail early with a clear
# message so the operator can re-run the wizard or
# create the HTTPS frontend manually pointing at a
# different backend.
default_be_name = fe_cfg.get("default_backend")
if default_be_name:
be_row = await conn.fetchrow(
"SELECT id FROM backends "
"WHERE cluster_id = $1 AND name = $2",
cluster_id,
default_be_name,
)
if be_row is None:
action["error"] = "backend_missing"
action["error_detail"] = (
f"default_backend='{default_be_name}' no "
f"longer exists in cluster {cluster_id} "
"— the wizard's bulk-site-create version "
"was likely rejected after issuance. "
"Cert was issued but no HTTPS frontend "
"was created. Re-run the wizard or "
"create the HTTPS frontend manually."
)
await record_event(
order_id,
"post_completion_action_skipped",
severity="ERROR",
message=action["error_detail"],
details={
"action_index": idx,
"type": action_type,
"missing_backend": default_be_name,
},
conn=conn,
)
outcomes.append({
"index": idx, "status": "error",
"reason": "backend_missing",
"detail": action["error_detail"],
})
continue
# v1.5.0 R12 — Pydantic-light shim with FULL field
# surface. create_frontend_row reads every attribute
# via getattr(payload, X, None), so we MUST forward
# every advanced TLS / HSTS / header field the wizard
# may have stored on frontend_config. Earlier versions
# of this shim only listed a handful of fields, which
# silently dropped HSTS / ALPN / TLS-version /
# compression preferences for ACME-issued HTTPS
# frontends — visible to the user as "I enabled HSTS
# but the frontend doesn't have it" after the LE order
# completed.
from types import SimpleNamespace
fe_payload = SimpleNamespace(
# core
name=fe_name,
bind_address=bind_address,
bind_port=bind_port,
default_backend=fe_cfg.get("default_backend"),
mode=fe_cfg.get("mode", "http"),
ssl_enabled=True,
# routing rules
acl_rules=fe_cfg.get("acl_rules", []),
redirect_rules=fe_cfg.get("redirect_rules", []),
use_backend_rules=fe_cfg.get("use_backend_rules", []),
# tcp-mode
tcp_request_rules=fe_cfg.get("tcp_request_rules"),
# timeouts + capacity
timeout_client=fe_cfg.get("timeout_client"),
timeout_http_request=fe_cfg.get("timeout_http_request"),
maxconn=fe_cfg.get("maxconn"),
rate_limit=fe_cfg.get("rate_limit"),
# observability + traffic shaping
compression=fe_cfg.get("compression"),
log_separate=fe_cfg.get("log_separate"),
monitor_uri=fe_cfg.get("monitor_uri"),
# header injection (HSTS lands here)
request_headers=fe_cfg.get("request_headers"),
response_headers=fe_cfg.get("response_headers"),
# raw HAProxy options (free-form lines)
options=fe_cfg.get("options"),
# advanced TLS — HAProxy 2.4+ bind directives
ssl_alpn=fe_cfg.get("ssl_alpn"),
ssl_npn=fe_cfg.get("ssl_npn"),
ssl_ciphers=fe_cfg.get("ssl_ciphers"),
ssl_ciphersuites=fe_cfg.get("ssl_ciphersuites"),
ssl_min_ver=fe_cfg.get("ssl_min_ver"),
ssl_max_ver=fe_cfg.get("ssl_max_ver"),
ssl_strict_sni=fe_cfg.get("ssl_strict_sni"),
# R17 minimum-parity: ssl_verify (mTLS client auth)
# was already in the SimpleNamespace forwarding list
# but the wizard's SSLChoice now actually populates
# it. No code change here, but call out the contract:
# SimpleNamespace.ssl_verify must reach
# create_frontend_row's HAProxy bind generation.
ssl_verify=fe_cfg.get("ssl_verify"),
ssl_port=fe_cfg.get("ssl_port"),
ssl_cert_path=fe_cfg.get("ssl_cert_path"),
ssl_cert=fe_cfg.get("ssl_cert"),
)
new_fe_id = await create_frontend_row(
conn,
fe_payload,
cluster_id,
ssl_certificate_id=cert_id,
ssl_enabled=True,
mark_pending=True,
)
action["executed_at"] = datetime.utcnow().isoformat() + "Z"
action["created_frontend_id"] = new_fe_id
# Persist the executed_at flag back to the order (idempotency)
await conn.execute(
"""
UPDATE letsencrypt_orders
SET post_completion_actions = $1::jsonb, updated_at = NOW()
WHERE id = $2
""",
json.dumps(actions),
order_id,
)
# Generate a fresh PENDING config_version so the new
# HTTPS frontend can be applied.
try:
# R18c audit fix (round 1 #4 — KRITIK): pass
# the active transaction connection into the
# config generator. Pre-fix the call obtained
# a SECOND pooled connection, which under
# PostgreSQL READ COMMITTED cannot see the
# uncommitted INSERT that just created the
# HTTPS frontend in this same transaction.
# Result: the new HTTPS frontend was silently
# OMITTED from the post-completion
# config_versions snapshot, so when the
# operator (or auto-apply) deployed the
# ACME-completed config, HAProxy reloaded
# WITHOUT the HTTPS bind for the freshly-
# issued cert. Operator saw "ACME success"
# but the cert never went live until the
# next manual config consolidation.
from services.haproxy_config import generate_haproxy_config_for_cluster
cfg = await generate_haproxy_config_for_cluster(cluster_id, conn)
import hashlib
cfg_hash = hashlib.sha256(cfg.encode()).hexdigest()
ts = int(time.time())
version_name = f"acme-post-https-{cert_id}-{ts}"
await conn.execute(
"""
INSERT INTO config_versions (
cluster_id, version_name, config_content, checksum,
is_active, status, description
) VALUES ($1, $2, $3, $4, FALSE, 'PENDING', $5)
""",
cluster_id,
version_name,
cfg,
cfg_hash,
f"ACME post-completion: HTTPS frontend for cert {cert_id}",
)
except Exception as cfg_err:
logger.warning(
f"[ACME] post_completion config_version creation failed: {cfg_err}"
)
if fe_cfg.get("_auto_apply"):
auto_apply_cluster_ids.add(cluster_id)
if fe_cfg.get("_user_id"):
auto_apply_user_ids.add(fe_cfg["_user_id"])
await record_event(
order_id,
"post_completion_action_executed",
severity="INFO",
message=f"Created HTTPS frontend '{fe_name}' from post_completion_actions",
details={"action_index": idx, "frontend_id": new_fe_id},
conn=conn,
)
outcomes.append({
"index": idx, "status": "ok",
"frontend_id": new_fe_id,
"type": action_type,
})
else:
outcomes.append({
"index": idx, "status": "skipped",
"reason": f"unknown action type: {action_type}",
})
except Exception as action_err:
logger.error(f"[ACME] post_completion action {idx} failed: {action_err}", exc_info=True)
outcomes.append({"index": idx, "status": "error", "reason": str(action_err)})
try:
await record_event(
order_id,
"post_completion_action_failed",
severity="ERROR",
message=str(action_err)[:500],
details={"action_index": idx, "type": action_type},
conn=conn,
)
except Exception:
pass
# Auto-apply if requested
if auto_apply_cluster_ids:
try:
from services.apply_service import apply_cluster_pending
for cid in auto_apply_cluster_ids:
# Order's created_by lookup with admin fallback (M23/M46)
user_for_apply = None
if auto_apply_user_ids:
user_for_apply = next(iter(auto_apply_user_ids))
if user_for_apply is None:
order_row = await conn.fetchrow(
"SELECT created_by FROM letsencrypt_orders WHERE id = $1",
order_id,
)
if order_row:
user_for_apply = order_row["created_by"]
# apply_service handles None via is_admin fallback
try:
apply_res = await apply_cluster_pending(cid, user_id=user_for_apply)
await record_event(
order_id,
"post_completion_auto_apply",
severity="INFO",
message=f"Auto-applied cluster {cid} after post_completion_actions",
details={"latest_version": apply_res.get("latest_version")},
conn=conn,
)
except Exception as apply_err:
logger.error(
f"[ACME] post_completion auto-apply for cluster {cid} failed: {apply_err}"
)
await record_event(
order_id,
"post_completion_auto_apply_failed",
severity="ERROR",
message=str(apply_err)[:500],
details={"cluster_id": cid},
conn=conn,
)
except Exception as outer_apply_err:
logger.error(f"[ACME] post_completion auto-apply outer failure: {outer_apply_err}")
return outcomes
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: Stuck order detection (Issue #12 / Commit 4c).
# An order in "valid" state but without ssl_certificate_id is stuck. The
# auto-completion task should resolve it within 60s, but surface visibility
# so users notice if Pebble/CA is unreachable.
stuck_orders = await conn.fetch("""
SELECT id, domains, created_at FROM letsencrypt_orders
WHERE status = 'valid' AND ssl_certificate_id IS NULL
AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC
LIMIT 10
""")
if stuck_orders:
stuck_ids = [r['id'] for r in stuck_orders]
steps.append({
"key": "stuck_orders",
"title": "Resolve Stuck Orders",
"ok": False,
"detail": f"{len(stuck_orders)} order(s) validated by CA but certificate not yet downloaded. "
f"Auto-completion runs every 60s. Order IDs: {stuck_ids}. "
f"If this persists, check ACME backend connectivity.",
"navigate": "/ssl-certificates?tab=acme",
"stuck_order_ids": stuck_ids,
})
# Step 6: 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)