feat: ACME stability & enterprise audit (v1.4.0) — fixes #10 #11 #12

Issue #10 — Silent timezone failure on ACME certificate save
- Normalize tz-aware expiry_date to UTC tz-naive before INSERT/UPDATE in
  ssl_certificates (TIMESTAMP WITHOUT TIME ZONE) — restores ACME download path

Issue #11 — Duplicate _acme_challenge_backend in generated config
- Generator guard: skip auto-append when backend already rendered
- Agent-sync filter: _should_sync_backend() drops system-managed backends and
  detaches their server rows to prevent orphans
- Restore filter: IGNORED_BACKENDS skips reserved names during cluster restore
- Parser warning: reserved_backend_names blocks accidental manual import
- Cleanup migration: removes orphan rows + cascading server entries (idempotent)

Issue #12 — Validated orders required manual completion
- New 60s background task complete_pending_acme_orders, flag-independent,
  multi-replica safe via FOR UPDATE SKIP LOCKED + 30s updated_at watermark
- Per-order pg_advisory_lock(0x41434D45, order_id) serializes UI-Complete and
  auto-task races; idempotency guard returns existing certificate cleanly
- retry_order endpoint reports in_progress: true within 30s window so the UI
  surfaces an info toast instead of duplicating CA requests
- ACMEAutomation surfaces stuck orders (status=valid && !ssl_certificate_id)
  with a one-click Complete action and Cancel fallback; conditional 30s polling

Other hardening
- ACME state machine error_detail persisted as structured JSON across challenge,
  finalize, download stages for actionable post-mortems
- CertificateRequest Pydantic model: domain regex + min_length/max_length and
  cluster_ids defaulting to all ACME-enabled clusters when "global" is selected
- Renewal cluster fallback now requires acme_enabled=TRUE in addition to active
- _complete_certificate preserves manual cluster assignments on renewal,
  surfaces cluster_errors, raises explicit error on missing private key
- Audit logging covers acme_certificate_requested/revoked, ca_chain_imported,
  account created/deactivated/purged, order retried/cancelled
- Settings UI exposes acme.staging_url_override for private test CAs (Pebble)
- Schema additions: acme_challenges.attempts (default 0) and last_attempt_at,
  index idx_letsencrypt_orders_status_updated; all migrations idempotent

Tests (41/41 passing)
- test_acme_expiry_normalize, test_acme_duplicate_backend,
  test_acme_state_machine, test_acme_pydantic_validation,
  test_acme_audit_logging, test_acme_concurrency

CI / packaging
- docker-build.yml reads version.json and pushes additional product-version
  tag (e.g. 1.4.0) alongside latest and timestamp build id

Closes #10
Closes #11
Closes #12
This commit is contained in:
taylanbakircioglu
2026-05-06 23:46:53 +03:00
parent cd4a94beb1
commit 07942a82e8
22 changed files with 1352 additions and 160 deletions
+12 -1
View File
@@ -16,6 +16,16 @@ jobs:
id: version
run: echo "TAG=$(date +'%Y%m%d.%H%M')" >> $GITHUB_OUTPUT
- name: read product version
id: prodversion
run: |
VERSION=$(jq -r .version version.json)
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
echo "Failed to read product version from version.json" >&2
exit 1
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: set up qemu
uses: docker/setup-qemu-action@v3
@@ -38,6 +48,7 @@ jobs:
tags: |
taylanbakircioglu/haproxy-openmanager-backend:latest
taylanbakircioglu/haproxy-openmanager-backend:${{ steps.version.outputs.TAG }}
taylanbakircioglu/haproxy-openmanager-backend:${{ steps.prodversion.outputs.VERSION }}
- name: build & push frontend
uses: docker/build-push-action@v6
@@ -49,4 +60,4 @@ jobs:
tags: |
taylanbakircioglu/haproxy-openmanager-frontend:latest
taylanbakircioglu/haproxy-openmanager-frontend:${{ steps.version.outputs.TAG }}
taylanbakircioglu/haproxy-openmanager-frontend:${{ steps.prodversion.outputs.VERSION }}
+7 -2
View File
@@ -237,13 +237,18 @@ This architecture provides better security (no inbound connections to HAProxy se
#### ACME Auto SSL / Let's Encrypt
- **Automated Certificate Issuance**: Request SSL certificates from Let's Encrypt or any ACME-compatible CA directly from the UI
- **Automatic Renewal**: Background task monitors certificate expiry and renews automatically before expiration
- **Automatic Renewal**: Hourly background task monitors certificate expiry and creates renewal orders before expiration
- **Auto-Completion of Validated Orders** *(v1.4.0)*: Independent 60-second background task drives CA-validated orders through finalize -> download -> save without requiring user intervention; multi-replica safe via PostgreSQL `FOR UPDATE SKIP LOCKED` atomic claim, plus per-order session-level `pg_advisory_lock` to serialize concurrent completion attempts (UI "Complete" + auto-task race protection)
- **Zero-Touch Deployment**: Renewed certificates are automatically applied through the same PENDING -> APPLIED pipeline as manual SSL updates, with agent notification
- **Stuck Order Detection** *(v1.4.0)*: Setup wizard surfaces orders that the CA has validated but not yet downloaded, with one-click `Complete` action and automatic 60-second retry
- **Multi-Provider Support**: Configurable ACME directory URL supports Let's Encrypt, ZeroSSL, Google Trust Services, Buypass, and custom CAs
- **HTTP-01 Challenge**: Built-in challenge responder with automatic HAProxy routing injection
- **HTTP-01 Challenge**: Built-in challenge responder with automatic HAProxy routing injection; reserved backend name `_acme_challenge_backend` is auto-managed and protected from manual edits / agent sync collisions
- **ACME Account Management**: Register, view, and deactivate ACME accounts from the UI
- **Staging Mode**: Test certificate issuance with Let's Encrypt staging environment before production
- **Custom Staging Endpoint** *(v1.4.0)*: Optional `staging_url_override` setting lets you point staging mode at a private ACME test CA (e.g. Pebble) without touching the production directory URL
- **External Account Binding (EAB)**: Support for CAs that require EAB (ZeroSSL, Google Trust Services)
- **Structured Error Diagnostics** *(v1.4.0)*: All ACME failures (challenge, finalize, download) persist structured JSON to `letsencrypt_orders.error_detail` for clear post-mortem analysis
- **Audit Logging** *(v1.4.0)*: Every ACME operation (request, revoke, CA-chain import, account ops) is captured in `user_activity_logs` for compliance review
- **Backward Compatible**: ACME-managed and manually uploaded certificates coexist seamlessly; existing SSL workflows are completely unaffected
#### Integration & API
+56 -2
View File
@@ -1601,6 +1601,8 @@ async def run_all_migrations():
await ensure_system_settings_table()
await ensure_acme_tables()
await ensure_acme_columns_on_existing_tables()
# Issue #11 cleanup: must run AFTER acme_tables/columns to ensure FK refs exist
await cleanup_orphan_acme_challenge_backend()
logger.info("Database migrations completed successfully.")
@@ -3175,12 +3177,17 @@ async def ensure_acme_columns_on_existing_tables():
('auto_renew', "ALTER TABLE ssl_certificates ADD COLUMN IF NOT EXISTS auto_renew BOOLEAN DEFAULT FALSE"),
('acme_enabled', "ALTER TABLE haproxy_clusters ADD COLUMN IF NOT EXISTS acme_enabled BOOLEAN DEFAULT FALSE"),
('acme_backend_url', "ALTER TABLE haproxy_clusters ADD COLUMN IF NOT EXISTS acme_backend_url VARCHAR(500)"),
# Issue #12 / Commit 5a: track challenge response attempts for rate-limit + retry policy
('attempts', "ALTER TABLE acme_challenges ADD COLUMN IF NOT EXISTS attempts INTEGER DEFAULT 0"),
('last_attempt_at', "ALTER TABLE acme_challenges ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMPTZ"),
# Commit 3a: track auto-completion task lock/poll timestamps for atomic claim across replicas
('orders_updated_at_idx', "CREATE INDEX IF NOT EXISTS idx_letsencrypt_orders_status_updated ON letsencrypt_orders(status, updated_at) WHERE status = 'valid' AND ssl_certificate_id IS NULL"),
]:
try:
await conn.execute(sql)
logger.info(f"Ensured column exists: {col}")
logger.info(f"Ensured column/index exists: {col}")
except Exception as col_err:
logger.warning(f"Column {col} migration note: {col_err}")
logger.warning(f"Column/index {col} migration note: {col_err}")
await close_database_connection(conn)
@@ -3188,3 +3195,50 @@ async def ensure_acme_columns_on_existing_tables():
if conn:
await close_database_connection(conn)
logger.error(f"Error adding ACME columns: {e}")
async def cleanup_orphan_acme_challenge_backend():
"""
Issue #11: One-time cleanup of orphan `_acme_challenge_backend` rows that may
have been persisted by previous versions where agent sync did not filter
auto-managed backends. Idempotent (NO-OP if zero rows).
backend_servers.backend_id has ON DELETE CASCADE, so deleting parent backends
will cascade-delete dependent server rows. We also explicitly delete by
backend_name first to clean up any orphan rows where backend_id may be NULL
or stale (string-based references).
"""
conn = None
try:
conn = await get_database_connection()
cnt = await conn.fetchval(
"SELECT COUNT(*) FROM backends WHERE name = '_acme_challenge_backend'"
)
if cnt and cnt > 0:
logger.warning(
f"CLEANUP MIGRATION: Found {cnt} orphan '_acme_challenge_backend' "
f"row(s). Removing (Issue #11 cleanup)."
)
async with conn.transaction():
# Defensive: clean up any backend_servers rows by name first
# (catches orphans where backend_id is NULL or stale)
bs_cnt = await conn.execute(
"DELETE FROM backend_servers WHERE backend_name = '_acme_challenge_backend'"
)
logger.info(f"CLEANUP MIGRATION: Removed backend_servers rows: {bs_cnt}")
# Delete parent backends - FK CASCADE removes any remaining backend_servers
be_cnt = await conn.execute(
"DELETE FROM backends WHERE name = '_acme_challenge_backend'"
)
logger.info(f"CLEANUP MIGRATION: Removed backends rows: {be_cnt}")
else:
logger.info("CLEANUP MIGRATION: No orphan '_acme_challenge_backend' rows found (clean state)")
await close_database_connection(conn)
except Exception as e:
if conn:
await close_database_connection(conn)
logger.error(f"Error in cleanup_orphan_acme_challenge_backend: {e}")
+117 -46
View File
@@ -8,7 +8,7 @@ import redis
import asyncio
from datetime import datetime, timedelta
_version_info = {"version": "1.3.0", "releaseName": "Bulk Import Change Detection + Multi-Select Delete", "releaseDate": "2026-04-14"}
_version_info = {"version": "1.4.0", "releaseName": "ACME Stability & Enterprise Audit", "releaseDate": "2026-05-06"}
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
try:
with open(_vpath) as _vf:
@@ -17,6 +17,7 @@ for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), ".."
except (FileNotFoundError, json.JSONDecodeError):
continue
# Version: 2026-05-06 - ACME Stability & Enterprise Audit v1.4.0 (Issues #10, #11, #12)
# Version: 2026-04-02 - Dark Mode + UI Improvements v1.2.0
# Version: 2026-04-01 - ACME Auto SSL v1.1.0
# Version: 2025-10-20 - Agent script fixes deployed
@@ -249,8 +250,109 @@ async def monitor_agent_status():
await asyncio.sleep(30)
# Background task for ACME certificate auto-renewal
async def complete_pending_acme_orders():
"""
Issue #12 fix: Auto-complete CA-validated ACME orders independent of
`acme.auto_renew_enabled` flag.
Runs every 60s. Polls in-progress orders (pending/processing/ready, plus
valid-without-cert) and drives them through finalize -> download -> save
certificate. Without this task, orders that reach `valid` state at the CA
but have not yet been downloaded remain "stuck" and require manual
intervention via the UI.
Multi-replica safety: uses PostgreSQL `FOR UPDATE SKIP LOCKED` atomic claim
plus updated_at timestamp filter to avoid two replicas working the same order.
"""
await asyncio.sleep(60)
while True:
try:
conn_check = await get_database_connection()
try:
table_exists = await conn_check.fetchval("""
SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'letsencrypt_orders')
""")
finally:
await close_database_connection(conn_check)
if not table_exists:
await asyncio.sleep(60)
continue
from routers.letsencrypt import _complete_certificate
from services.acme_service import acme_service as acme_svc
# Atomic claim of orders for completion (multi-replica safe).
# Limit batch to 50 to avoid one replica monopolizing CA rate-limit budget.
claimed_ids = []
conn_claim = await get_database_connection()
try:
async with conn_claim.transaction():
rows = await conn_claim.fetch("""
SELECT id FROM letsencrypt_orders
WHERE (
status IN ('pending', 'processing', 'ready')
OR (status = 'valid' AND ssl_certificate_id IS NULL)
)
AND created_at > NOW() - INTERVAL '7 days'
AND (updated_at IS NULL OR updated_at < NOW() - INTERVAL '30 seconds')
ORDER BY created_at
LIMIT 50
FOR UPDATE SKIP LOCKED
""")
if rows:
claimed_ids = [r['id'] for r in rows]
# Bump updated_at to mark claim (other replicas skip these for >=30s)
await conn_claim.execute(
"UPDATE letsencrypt_orders SET updated_at = NOW() WHERE id = ANY($1::int[])",
claimed_ids
)
finally:
await close_database_connection(conn_claim)
if not claimed_ids:
await asyncio.sleep(60)
continue
logger.info(f"[ACME-COMPLETE] Claimed {len(claimed_ids)} order(s) for completion: {claimed_ids}")
for oid in claimed_ids:
try:
status_info = await acme_svc.check_order_status(oid)
current_status = status_info.get('status')
if current_status == 'ready':
await acme_svc.finalize_order(oid)
status_info = await acme_svc.check_order_status(oid)
current_status = status_info.get('status')
if current_status == 'valid' and status_info.get('certificate_url'):
result = await _complete_certificate(oid)
logger.info(f"[ACME-COMPLETE] Order {oid} completed - {result.get('message', '')}")
elif current_status == 'invalid':
logger.warning(f"[ACME-COMPLETE] Order {oid} is invalid, skipping")
elif current_status in ('pending', 'processing'):
logger.info(f"[ACME-COMPLETE] Order {oid} still {current_status}, will retry next cycle")
except Exception as poll_err:
logger.error(f"[ACME-COMPLETE] Failed to complete order {oid}: {poll_err}")
except Exception as e:
logger.error(f"[ACME-COMPLETE] Error in completion task: {e}")
await asyncio.sleep(60)
async def check_letsencrypt_renewals():
"""Background task to auto-renew expiring ACME certificates."""
"""
Background task to auto-renew expiring ACME certificates.
Runs every 60 minutes. Two-phase:
1. Create new orders for certificates expiring within `acme.renew_before_days`
(default 30). Gated by `acme.auto_renew_enabled` setting.
2. Warn about stuck orders (>24h in pending/processing state).
Order completion (download cert + save to DB) is handled by the separate
`complete_pending_acme_orders` task running every 60s, so renewal pickup
is fast even if this hourly task is throttled.
"""
await asyncio.sleep(120)
while True:
conn = None
@@ -303,10 +405,9 @@ async def check_letsencrypt_renewals():
await close_database_connection(conn)
conn = None
from routers.letsencrypt import _complete_certificate
from services.acme_service import acme_service as acme_svc
# --- Phase 1: Create new orders for expiring certificates ---
# Phase 1: Create new orders for expiring certificates
for cert in expiring_certs:
try:
order_id = cert['letsencrypt_order_id']
@@ -334,7 +435,7 @@ async def check_letsencrypt_renewals():
LIMIT 1
""", json.dumps(domains))
if existing:
logger.info(f"ACME RENEWAL: Skipping cert {cert['id']} - order {existing['id']} already in progress")
logger.info(f"[ACME-RENEWAL] Skipping cert {cert['id']} - order {existing['id']} already in progress")
skip = True
finally:
await close_database_connection(conn2)
@@ -344,47 +445,11 @@ async def check_letsencrypt_renewals():
new_order = await acme_svc.create_order(order['account_id'], domains, cluster_ids)
await acme_svc.respond_to_challenges(new_order['order_id'])
logger.info(f"ACME RENEWAL: Initiated renewal order {new_order['order_id']} for cert {cert['id']} ({cert['name']})")
logger.info(f"[ACME-RENEWAL] Initiated renewal order {new_order['order_id']} for cert {cert['id']} ({cert['name']})")
except Exception as cert_err:
logger.error(f"ACME RENEWAL ERROR: Failed to initiate renewal for cert {cert['id']}: {cert_err}")
logger.error(f"[ACME-RENEWAL] Failed to initiate renewal for cert {cert['id']}: {cert_err}")
# --- Phase 2: Poll and complete in-progress orders ---
conn_poll = await get_database_connection()
try:
in_progress = await conn_poll.fetch("""
SELECT id, status FROM letsencrypt_orders
WHERE (
status IN ('pending', 'processing', 'ready')
OR (status = 'valid' AND ssl_certificate_id IS NULL)
)
AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at
""")
finally:
await close_database_connection(conn_poll)
for order_row in in_progress:
oid = order_row['id']
try:
status_info = await acme_svc.check_order_status(oid)
current_status = status_info.get('status', order_row['status'])
if current_status == 'ready':
await acme_svc.finalize_order(oid)
status_info = await acme_svc.check_order_status(oid)
current_status = status_info.get('status')
if current_status == 'valid' and status_info.get('certificate_url'):
result = await _complete_certificate(oid)
logger.info(f"ACME RENEWAL: Completed order {oid} - {result.get('message', '')}")
elif current_status == 'invalid':
logger.warning(f"ACME RENEWAL: Order {oid} is invalid, skipping")
elif current_status in ('pending', 'processing'):
logger.info(f"ACME RENEWAL: Order {oid} still {current_status}, will retry next cycle")
except Exception as poll_err:
logger.error(f"ACME RENEWAL: Failed to poll/complete order {oid}: {poll_err}")
# --- Phase 3: Warn about stuck orders ---
# Phase 2: Warn about stuck orders (completion handled by complete_pending_acme_orders)
conn3 = await get_database_connection()
try:
stuck_orders = await conn3.fetch("""
@@ -397,10 +462,10 @@ async def check_letsencrypt_renewals():
await close_database_connection(conn3)
if stuck_orders:
stuck_ids = [str(o['id']) for o in stuck_orders]
logger.warning(f"ACME RENEWAL WARNING: {len(stuck_orders)} order(s) stuck > 24h: IDs=[{', '.join(stuck_ids)}]")
logger.warning(f"[ACME-RENEWAL] {len(stuck_orders)} order(s) stuck > 24h: IDs=[{', '.join(stuck_ids)}]")
except Exception as e:
logger.error(f"Error in ACME renewal check: {e}")
logger.error(f"[ACME-RENEWAL] Error in renewal check: {e}")
if conn:
try:
await close_database_connection(conn)
@@ -604,6 +669,12 @@ async def startup_event():
# Start ACME certificate auto-renewal task
asyncio.create_task(check_letsencrypt_renewals())
logger.info("ACME certificate auto-renewal task started (hourly checks)")
# Issue #12: Independent task for completing CA-validated orders.
# Runs every 60s with atomic claim (FOR UPDATE SKIP LOCKED) — multi-replica safe.
# Decoupled from auto_renew_enabled flag so user-initiated orders also complete.
asyncio.create_task(complete_pending_acme_orders())
logger.info("ACME order auto-completion task started (60s checks, replica-safe)")
# Create test activity log entry to verify system is working
try:
+16 -3
View File
@@ -35,7 +35,9 @@ RESOURCE_MAPPING = {
'/api/auth/logout': 'auth',
'/api/configuration': 'configuration',
'/api/security/agent-tokens': 'agent_token',
'/api/maintenance': 'maintenance'
'/api/maintenance': 'maintenance',
# Audit Tur 4/5 / Commit 8: ACME endpoint coverage
'/api/letsencrypt': 'letsencrypt_order',
}
# Special action mappings
@@ -46,7 +48,16 @@ SPECIAL_ACTIONS = {
'/api/backends/{backend_id}/toggle': 'toggle_backend',
'/api/agents/{agent_id}/toggle': 'toggle_agent',
'/api/users/{user_id}/password': 'change_password',
'/api/users/{user_id}/roles': 'assign_roles'
'/api/users/{user_id}/roles': 'assign_roles',
# Audit Tur 5/6 / Commit 8: discrete ACME actions for compliance audit
'/api/letsencrypt/certificates': 'acme_certificate_requested',
'/api/letsencrypt/certificates/{cert_id}/revoke': 'acme_certificate_revoked',
'/api/letsencrypt/import-ca-chain': 'acme_ca_chain_imported',
'/api/letsencrypt/accounts': 'acme_account_created',
'/api/letsencrypt/accounts/{account_id}': 'acme_account_deactivated',
'/api/letsencrypt/accounts/{account_id}/permanent': 'acme_account_purged',
'/api/letsencrypt/orders/{order_id}/retry': 'acme_order_retried',
'/api/letsencrypt/orders/{order_id}': 'acme_order_cancelled',
}
def extract_resource_info(path: str, method: str) -> tuple[str, str, Optional[str]]:
@@ -89,7 +100,9 @@ def matches_pattern(path: str, pattern: str) -> bool:
def extract_resource_type_from_path(path: str) -> str:
"""Extract resource type from path"""
if '/frontends' in path:
if '/letsencrypt' in path:
return 'letsencrypt_order'
elif '/frontends' in path:
return 'frontend'
elif '/backends' in path:
return 'backend'
+40 -1
View File
@@ -105,6 +105,31 @@ def _should_sync_frontend(frontend_name: str) -> bool:
return True
# ==== BACKEND SYNC HELPER ====
def _should_sync_backend(backend_name: str) -> bool:
"""
Determine if a backend should be synced from agent to management system.
Returns False for system/auto-managed backends that must not be persisted in DB.
Issue #11: '_acme_challenge_backend' is auto-injected by haproxy_config.py
on every Apply when ACME is enabled. Persisting it in DB caused duplicate
sections on next Apply (HAProxy "Duplicate Name" validation failure).
"""
# System/auto-managed backend names that must not be synced
skip_patterns = [
'_acme_challenge_backend', # Issue #11: auto-managed ACME challenge backend
]
if backend_name in skip_patterns:
return False
# Skip backends that start with underscore (system convention)
if backend_name.startswith('_'):
return False
return True
async def validate_user_cluster_access(user_id: int, cluster_id: int, conn):
"""Validate that user has access to the specified cluster"""
# Check if cluster exists
@@ -1173,8 +1198,17 @@ async def agent_config_sync(agent_name: str, sync_data: dict, x_api_key: Optiona
# Parse backend definitions
if line.startswith('backend '):
backend_name = line.split(' ', 1)[1]
current_backend = backend_name
current_frontend = None
# Issue #11: Skip system/auto-managed backends (e.g. _acme_challenge_backend).
# Setting current_backend = None ensures subsequent `server` lines under
# a skipped backend are NOT attached to a previous user backend (orphan rows).
if not _should_sync_backend(backend_name):
logger.info(f"🚫 AGENT SYNC: Skipping system/auto-managed backend '{backend_name}' from sync")
current_backend = None
continue
current_backend = backend_name
active_backends.append({
'name': backend_name,
'is_commented': False
@@ -1182,6 +1216,11 @@ async def agent_config_sync(agent_name: str, sync_data: dict, x_api_key: Optiona
continue
elif line.startswith('# DISABLED: backend '):
backend_name = line[20:].strip() # Remove "# DISABLED: backend "
# Skip system/auto-managed backends even if disabled
if not _should_sync_backend(backend_name):
continue
active_backends.append({
'name': backend_name,
'is_commented': True
+7
View File
@@ -3217,6 +3217,9 @@ async def confirm_restore_config_version(
# Sync Frontends (ignore built-in frontends)
IGNORED_FRONTENDS = ['stats'] # Built-in stats frontend
# Issue #11: System/auto-managed entities must not be persisted via restore
# (e.g. _acme_challenge_backend is auto-injected by haproxy_config.py).
IGNORED_BACKENDS = ['_acme_challenge_backend']
for parsed_fe in parse_result.frontends:
if parsed_fe.name in IGNORED_FRONTENDS:
logger.info(f"RESTORE: Skipping built-in frontend '{parsed_fe.name}'")
@@ -3293,6 +3296,10 @@ async def confirm_restore_config_version(
# Sync Backends
for parsed_be in parse_result.backends:
# Issue #11: skip auto-managed system backends (e.g. _acme_challenge_backend).
if parsed_be.name in IGNORED_BACKENDS:
logger.info(f"RESTORE: Skipping auto-managed backend '{parsed_be.name}'")
continue
if parsed_be.name in current_be_dict:
# PHASE 5: Create snapshot BEFORE update (restore operation)
be_id = current_be_dict[parsed_be.name]
+270 -26
View File
@@ -1,8 +1,9 @@
from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
import json
import logging
import re
import time
from datetime import datetime
@@ -14,6 +15,11 @@ 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
@@ -24,11 +30,32 @@ class AccountCreate(BaseModel):
class CertificateRequest(BaseModel):
domains: List[str]
# 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: Optional[List[int]] = None
cluster_ids: List[int] = Field(default_factory=list)
auto_renew: bool = True
@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)
return normalized
# --- Account management ---
@@ -57,8 +84,15 @@ async def create_account(body: AccountCreate, authorization: str = Header(None))
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'):
if 'letsencrypt' in directory_url:
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
@@ -153,6 +187,7 @@ async def request_certificate(body: CertificateRequest, authorization: str = Hea
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")
@@ -178,22 +213,48 @@ async def request_certificate(body: CertificateRequest, authorization: str = Hea
logger.info(f"ACME: Using account_id={account_id} for certificate request")
warnings = []
try:
conn_warn = await get_database_connection()
# Audit Tur 5 / Commit 8c: empty cluster_ids in UI means "global certificate".
# Resolve to all ACME-enabled active clusters; only fail if NONE exist.
if not body.cluster_ids:
conn_resolve = await get_database_connection()
try:
acme_clusters = await conn_warn.fetchval(
"SELECT COUNT(*) FROM haproxy_clusters WHERE acme_enabled = TRUE AND is_active = TRUE"
acme_clusters_resolved = await conn_resolve.fetch(
"SELECT id 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
await close_database_connection(conn_resolve)
if not acme_clusters_resolved:
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 acme_clusters_resolved]
warnings.append(
f"No clusters specified — applied to all ACME-enabled cluster(s) ({len(body.cluster_ids)})"
)
logger.warning(
f"ACME: Empty cluster_ids → global cert fallback to {len(body.cluster_ids)} ACME-enabled cluster(s)"
)
else:
# Validate that referenced clusters exist + are ACME-enabled (warn-only).
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,
@@ -203,11 +264,25 @@ async def request_certificate(body: CertificateRequest, authorization: str = Hea
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']}")
# Commit 5b: re-fetch the order's *current* status from DB. The status
# returned by create_order() reflects the moment of creation; after
# respond_to_challenges() the CA may have already advanced it (e.g. to
# 'processing'). Surfacing stale status leads UI to under-poll.
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']
logger.info(f"ACME: Order {order['order_id']} created, {len(challenges)} challenge(s) posted, status={effective_status}")
return {
"order_id": order['order_id'],
"status": order['status'],
"status": effective_status,
"domains": body.domains,
"challenges": challenges,
"message": "Order created. ACME challenges have been posted. Waiting for CA validation.",
@@ -289,6 +364,40 @@ async def retry_order(order_id: int, authorization: str = Header(None)):
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')
@@ -308,9 +417,32 @@ async def retry_order(order_id: int, authorization: str = Header(None)):
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)
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:
@@ -492,6 +624,16 @@ async def _complete_certificate(order_id: int) -> dict:
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:
@@ -512,7 +654,31 @@ async def _complete_certificate(order_id: int) -> dict:
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'
# 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
@@ -524,6 +690,13 @@ async def _complete_certificate(order_id: int) -> dict:
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')
@@ -574,7 +747,31 @@ async def _complete_certificate(order_id: int) -> dict:
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)
@@ -589,8 +786,11 @@ async def _complete_certificate(order_id: int) -> dict:
if mapped:
effective_cluster_ids = [r['cluster_id'] for r in mapped]
else:
# Audit Tur 6 / Commit 5j: only fall back to ACME-enabled clusters.
# Applying renewal to ACME-disabled clusters could re-introduce Issue #11
# patterns and risks deploying certs to clusters where they can't be renewed.
all_clusters = await conn.fetch(
"SELECT id FROM haproxy_clusters WHERE is_active = TRUE"
"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:
@@ -598,6 +798,10 @@ async def _complete_certificate(order_id: int) -> dict:
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)
@@ -608,20 +812,36 @@ async def _complete_certificate(order_id: int) -> dict:
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:
logger.error(f"Failed to create config version for cluster {cid}: {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:
await _auto_apply_renewal(cert_id, effective_cluster_ids)
if is_renewal and clusters_succeeded:
await _auto_apply_renewal(cert_id, clusters_succeeded)
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,
}
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)
@@ -859,7 +1079,31 @@ async def check_prerequisites(authorization: str = Header(None)):
"pending_clusters": pending_clusters,
})
# Step 5: DNS & Network (informational only)
# 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",
+155 -10
View File
@@ -350,16 +350,38 @@ class ACMEService:
order_id = order_row['id']
# Commit 5e: track authorization fetch outcomes per-domain.
# Previously a 'continue' on auth fetch failure silently dropped HTTP-01
# challenges; the order proceeded but had no challenges to respond to,
# leaving it stuck in 'pending' forever.
auth_fetch_failures = [] # [{auth_url, http_status, error}]
domains_with_http01 = set()
for auth_url in data.get('authorizations', []):
auth_status, auth_data, _ = await self._signed_request(
auth_url, account['directory_url'], private_key, "",
account_url=account['account_url'],
)
try:
auth_status, auth_data, _ = await self._signed_request(
auth_url, account['directory_url'], private_key, "",
account_url=account['account_url'],
)
except Exception as auth_err:
logger.warning(f"ACME: authorization fetch raised: {auth_url} - {auth_err}")
auth_fetch_failures.append({
"auth_url": auth_url, "http_status": None, "error": str(auth_err)
})
continue
if auth_status != 200:
logger.warning(f"Failed to fetch authorization {auth_url}: HTTP {auth_status}")
logger.warning(
f"ACME: Failed to fetch authorization {auth_url}: HTTP {auth_status}, body={str(auth_data)[:200]}"
)
auth_fetch_failures.append({
"auth_url": auth_url, "http_status": auth_status,
"error": str(auth_data)[:500] if auth_data else ""
})
continue
domain = (auth_data.get('identifier') or {}).get('value', '')
http01_for_domain = False
for challenge in (auth_data.get('challenges') or []):
if challenge.get('type') == 'http-01':
token = challenge['token']
@@ -373,6 +395,43 @@ class ACMEService:
""", order_id, domain, token, key_auth,
challenge.get('url') or '', challenge.get('status') or 'pending')
logger.info(f"ACME: Challenge stored for domain={domain}, token={token[:20]}..., challenge_url={(challenge.get('url') or '')[:60]}")
http01_for_domain = True
if http01_for_domain and domain:
domains_with_http01.add(domain)
# If NO http-01 challenges were registered at all, the order cannot
# proceed — fail-fast and persist diagnostic detail.
if not domains_with_http01:
error_payload = json.dumps({
"stage": "create_order_authorizations",
"auth_fetch_failures": auth_fetch_failures,
"domains": domains,
"timestamp": datetime.utcnow().isoformat(),
})
await conn.execute(
"UPDATE letsencrypt_orders SET status = 'invalid', error_detail = $1, updated_at = NOW() WHERE id = $2",
error_payload, order_id
)
raise Exception(
f"ACME order {order_id} created but no http-01 challenges available "
f"(auth fetch failures: {len(auth_fetch_failures)}). See order.error_detail for diagnostics."
)
elif auth_fetch_failures:
# Partial failure: some domains have challenges, others don't. Record warning.
error_payload = json.dumps({
"stage": "create_order_authorizations_partial",
"auth_fetch_failures": auth_fetch_failures,
"domains_with_challenges": sorted(domains_with_http01),
"timestamp": datetime.utcnow().isoformat(),
})
await conn.execute(
"UPDATE letsencrypt_orders SET error_detail = $1, updated_at = NOW() WHERE id = $2",
error_payload, order_id
)
logger.warning(
f"ACME order {order_id}: partial authorization fetch failure — "
f"{len(auth_fetch_failures)} failed, {len(domains_with_http01)} succeeded"
)
return {
"order_id": order_id,
@@ -388,8 +447,12 @@ class ACMEService:
async def respond_to_challenges(self, order_id: int) -> List[dict]:
conn = await get_database_connection()
try:
# Issue #12 / Commit 5a: include 'failed' challenges so they can be retried,
# but rate-limit per challenge: max 5 attempts in last 5 minutes.
challenges = await conn.fetch(
"SELECT * FROM acme_challenges WHERE order_id = $1 AND (status = 'pending' OR status IS NULL)",
"""SELECT * FROM acme_challenges
WHERE order_id = $1
AND (status IN ('pending', 'failed') OR status IS NULL)""",
order_id
)
order = await conn.fetchrow(
@@ -407,6 +470,22 @@ class ACMEService:
if not ch['challenge_url']:
logger.warning(f"ACME: Skipping challenge id={ch['id']} domain={ch['domain']} - no challenge_url")
continue
# Rate-limit retry: skip if attempted >=5 times in last 5 minutes
attempts = ch.get('attempts') or 0
last_attempt = ch.get('last_attempt_at')
if attempts >= 5 and last_attempt:
age_seconds = (datetime.utcnow().replace(tzinfo=None) -
(last_attempt.replace(tzinfo=None) if last_attempt.tzinfo else last_attempt)).total_seconds()
if age_seconds < 300:
logger.warning(
f"ACME: Rate-limit: skipping challenge id={ch['id']} domain={ch['domain']} "
f"(attempts={attempts}, age={int(age_seconds)}s < 300s)"
)
results.append({"domain": ch['domain'], "token": ch['token'],
"status": ch['status'], "skipped": "rate-limit"})
continue
status, data, _ = await self._signed_request(
ch['challenge_url'],
order['directory_url'],
@@ -417,7 +496,11 @@ class ACMEService:
new_status = (data.get('status') or 'processing') if status == 200 else 'failed'
logger.info(f"ACME: Challenge response for domain={ch['domain']}, token={ch['token'][:20]}..., CA_HTTP={status}, CA_status_raw={data.get('status')!r}, stored_status={new_status}")
await conn.execute(
"UPDATE acme_challenges SET status = $1 WHERE id = $2",
"""UPDATE acme_challenges
SET status = $1,
attempts = COALESCE(attempts, 0) + 1,
last_attempt_at = NOW()
WHERE id = $2""",
new_status, ch['id']
)
results.append({"domain": ch['domain'], "token": ch['token'], "status": new_status})
@@ -472,11 +555,19 @@ class ACMEService:
)
if status not in (200, 201):
error_msg = data.get('detail') or str(data)
# Commit 5h: structured JSON-as-TEXT error_detail for consistency
# with check_order_status (5c) and download_certificate (5i).
error_msg = data.get('detail') if isinstance(data, dict) else str(data)
error_payload = json.dumps({
"stage": "finalize_order",
"http_status": status,
"ca_response": data if isinstance(data, dict) else str(data)[:1000],
"timestamp": datetime.utcnow().isoformat(),
})
logger.error(f"ACME: Finalize failed for order_id={order_id}: HTTP {status}, error={error_msg}")
await conn.execute(
"UPDATE letsencrypt_orders SET status = 'invalid', error_detail = $1, updated_at = NOW() WHERE id = $2",
error_msg, order_id
error_payload, order_id
)
raise Exception(f"Finalize failed: {error_msg}")
@@ -521,9 +612,43 @@ class ACMEService:
)
if status != 200:
# Commit 5i: persist structured error_detail to TEXT column.
# Previously download failures only raised an exception, leaving the
# order in 'valid' state with no DB diagnostic — operators were
# blind to root cause.
try:
error_payload = json.dumps({
"stage": "download_certificate",
"http_status": status,
"ca_response": data if isinstance(data, dict) else str(data)[:1000],
"timestamp": datetime.utcnow().isoformat(),
})
await conn.execute(
"UPDATE letsencrypt_orders SET error_detail = $1, updated_at = NOW() WHERE id = $2",
error_payload, order_id
)
except Exception as persist_err:
logger.warning(f"ACME: failed to persist download error for order {order_id}: {persist_err}")
raise Exception(f"Certificate download failed: HTTP {status}")
cert_pem = data.get('raw', '') if isinstance(data, dict) else str(data)
# Commit 5d: harden response parsing. ACME RFC 8555 §7.4.2 specifies
# `application/pem-certificate-chain` as the Content-Type. Some test
# CAs (Pebble) return raw PEM, others return JSON-wrapped data. We
# accept either format and extract the PEM body robustly.
cert_pem = ''
if isinstance(data, dict):
cert_pem = data.get('raw', '') or data.get('certificate', '') or ''
elif isinstance(data, (str, bytes)):
cert_pem = data.decode('utf-8') if isinstance(data, bytes) else data
elif data is not None:
cert_pem = str(data)
if not cert_pem or '-----BEGIN CERTIFICATE-----' not in cert_pem:
raise Exception(
f"Certificate download succeeded (HTTP 200) but response body "
f"does not contain a PEM certificate. Content-Type={headers.get('Content-Type', 'unknown') if headers else 'unknown'}, "
f"body_len={len(cert_pem) if cert_pem else 0}"
)
parts = cert_pem.strip().split('-----END CERTIFICATE-----')
certificate = (parts[0] + '-----END CERTIFICATE-----').strip() if parts else cert_pem
@@ -576,6 +701,26 @@ class ACMEService:
)
return {"order_id": order_id, "status": new_status, "certificate_url": certificate_url}
# Commit 5c: persist CA error to error_detail (TEXT) as structured JSON.
# Previously a non-200 was silently swallowed (no log, no DB record),
# leaving operators without diagnostic info for stuck orders.
try:
error_payload = json.dumps({
"stage": "check_order_status",
"http_status": status,
"ca_response": data if isinstance(data, dict) else str(data)[:1000],
"timestamp": datetime.utcnow().isoformat(),
})
await conn.execute(
"UPDATE letsencrypt_orders SET error_detail = $1, updated_at = NOW() WHERE id = $2",
error_payload, order_id
)
except Exception as persist_err:
logger.warning(f"ACME: failed to persist error_detail for order {order_id}: {persist_err}")
logger.warning(
f"ACME: check_order_status non-200 for order {order_id}: HTTP {status}, response={data}"
)
return {"order_id": order_id, "status": order['status']}
finally:
await close_database_connection(conn)
+55 -29
View File
@@ -832,37 +832,63 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
# ACME challenge backend section
if cluster_info.get('acme_enabled', False):
acme_url = cluster_info.get('acme_backend_url') or ''
if not acme_url:
try:
acme_settings = await db_conn.fetchrow(
"SELECT value FROM system_settings WHERE key = 'acme.challenge_backend_url'"
)
if acme_settings and acme_settings['value']:
val = acme_settings['value']
if isinstance(val, str):
try:
val = json.loads(val)
except (json.JSONDecodeError, TypeError):
pass
if val:
acme_url = str(val)
except Exception:
pass
if not acme_url:
from config import MANAGEMENT_BASE_URL
acme_url = MANAGEMENT_BASE_URL
# Issue #11: guard against duplicate `backend _acme_challenge_backend`.
# If the user-defined backends list already contains an entry with this
# reserved name (synced from agent or imported), skip auto-append to
# prevent HAProxy "Duplicate Name" validation failure on Apply.
already_rendered = any(
b.get('name') == '_acme_challenge_backend' for b in backends
)
# Commit 6a: skip auto-append if cluster has no HTTP-mode frontends.
# The acme-challenge backend is only useful when there's an HTTP frontend
# routing /.well-known/acme-challenge/* to it. On a TCP-only cluster the
# appended backend is dead config that pollutes the file (no use_backend
# ACL targets it), but it's harmless to HAProxy validation.
has_http_frontend = any(
(f.get('mode') or 'http').lower() == 'http' for f in frontends
)
if already_rendered:
logger.warning(
"ACME auto-append skipped: '_acme_challenge_backend' already present "
"in active backends list (likely synced from agent or restored)."
)
elif not has_http_frontend:
logger.info(
f"ACME auto-append skipped for cluster {cluster_id}: no HTTP-mode "
f"frontend found (would create orphan backend section)."
)
else:
acme_url = cluster_info.get('acme_backend_url') or ''
if not acme_url:
try:
acme_settings = await db_conn.fetchrow(
"SELECT value FROM system_settings WHERE key = 'acme.challenge_backend_url'"
)
if acme_settings and acme_settings['value']:
val = acme_settings['value']
if isinstance(val, str):
try:
val = json.loads(val)
except (json.JSONDecodeError, TypeError):
pass
if val:
acme_url = str(val)
except Exception:
pass
if not acme_url:
from config import MANAGEMENT_BASE_URL
acme_url = MANAGEMENT_BASE_URL
parsed = urllib.parse.urlparse(acme_url)
host = parsed.hostname or 'localhost'
port = parsed.port or (443 if parsed.scheme == 'https' else 8080)
ssl_flag = ' ssl verify none' if parsed.scheme == 'https' else ''
parsed = urllib.parse.urlparse(acme_url)
host = parsed.hostname or 'localhost'
port = parsed.port or (443 if parsed.scheme == 'https' else 8080)
ssl_flag = ' ssl verify none' if parsed.scheme == 'https' else ''
config_lines.append("# ACME Challenge Backend (auto-managed by HAProxy OpenManager)")
config_lines.append("backend _acme_challenge_backend")
config_lines.append(" mode http")
config_lines.append(f" server _acme_mgmt {host}:{port}{ssl_flag}")
config_lines.append("")
config_lines.append("# ACME Challenge Backend (auto-managed by HAProxy OpenManager)")
config_lines.append("backend _acme_challenge_backend")
config_lines.append(" mode http")
config_lines.append(f" server _acme_mgmt {host}:{port}{ssl_flag}")
config_lines.append("")
# Only close the connection if it was created within this function
if not conn:
+67
View File
@@ -207,3 +207,70 @@ class MockDatabase:
def mock_database():
"""Mock database fixture"""
return MockDatabase()
# ====================================================================
# ACME-specific fixtures (Issues #10, #11, #12 — v1.4.0)
# ====================================================================
@pytest.fixture
def sample_letsencrypt_account_data():
"""Sample Let's Encrypt account row."""
return {
"id": 1,
"email": "ops@example.com",
"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"account_url": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/12345",
"jwk_private_key": "-----BEGIN PRIVATE KEY-----\nMIG...\n-----END PRIVATE KEY-----\n",
"status": "valid",
"tos_agreed": True,
"eab_kid": None,
"eab_hmac_key": None,
}
@pytest.fixture
def sample_acme_order_data():
"""Sample ACME order row (matches letsencrypt_orders schema)."""
return {
"id": 100,
"account_id": 1,
"order_url": "https://acme-staging-v02.api.letsencrypt.org/acme/order/12345/678",
"status": "pending",
"domains": json.dumps(["example.com", "www.example.com"]),
"finalize_url": "https://acme-staging-v02.api.letsencrypt.org/acme/finalize/12345/678",
"certificate_url": None,
"cert_private_key": None,
"expires_at": None,
"cluster_ids": json.dumps([1]),
"ssl_certificate_id": None,
"error_detail": None,
}
@pytest.fixture
def sample_acme_challenge_data():
"""Sample ACME http-01 challenge row."""
return {
"id": 1000,
"order_id": 100,
"domain": "example.com",
"token": "abc123-token-placeholder",
"key_authorization": "abc123-token-placeholder.thumbprint-here",
"challenge_url": "https://acme-staging-v02.api.letsencrypt.org/acme/chall/12345/678/http-01",
"status": "pending",
"attempts": 0,
"last_attempt_at": None,
}
@pytest.fixture
def sample_acme_cluster_data():
"""Sample ACME-enabled cluster row."""
return {
"id": 1,
"name": "test-cluster",
"is_active": True,
"acme_enabled": True,
"acme_backend_url": None,
}
+53
View File
@@ -0,0 +1,53 @@
"""
Audit Tur 4/5 / Commit 8: ACME endpoints are covered by audit middleware.
"""
import pytest
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from middleware.activity_logger import RESOURCE_MAPPING, SPECIAL_ACTIONS, extract_resource_info
class TestACMERouteCoverage:
def test_letsencrypt_in_resource_mapping(self):
assert '/api/letsencrypt' in RESOURCE_MAPPING
assert RESOURCE_MAPPING['/api/letsencrypt'] == 'letsencrypt_order'
def test_revoke_certificate_special_action(self):
path = '/api/letsencrypt/certificates/{cert_id}/revoke'
assert path in SPECIAL_ACTIONS
assert SPECIAL_ACTIONS[path] == 'acme_certificate_revoked'
def test_import_ca_chain_special_action(self):
assert '/api/letsencrypt/import-ca-chain' in SPECIAL_ACTIONS
def test_account_ops_special_actions(self):
assert '/api/letsencrypt/accounts' in SPECIAL_ACTIONS
assert '/api/letsencrypt/accounts/{account_id}' in SPECIAL_ACTIONS
assert '/api/letsencrypt/accounts/{account_id}/permanent' in SPECIAL_ACTIONS
class TestExtractResourceInfo:
def test_post_certificates_resolves_to_acme(self):
rt, action, _ = extract_resource_info('/api/letsencrypt/certificates', 'POST')
# Either matches SPECIAL_ACTIONS (acme_certificate_requested) or generic create.
assert rt == 'letsencrypt_order'
def test_post_revoke_resolves_to_revoked_action(self):
rt, action, rid = extract_resource_info(
'/api/letsencrypt/certificates/42/revoke', 'POST'
)
assert rt == 'letsencrypt_order'
assert action == 'acme_certificate_revoked'
assert rid == '42'
def test_get_request_returns_default_unknown(self):
# GET on /api/letsencrypt/orders is not in SPECIAL_ACTIONS, and GET is not
# in LOGGABLE_ACTIONS, so extract_resource_info() falls through to the
# default ('unknown', 'get', None). The middleware itself skips logging
# GETs; this test just ensures no crash on the codepath.
rt, action, _ = extract_resource_info('/api/letsencrypt/orders', 'GET')
assert rt == 'unknown'
assert action == 'get'
+72
View File
@@ -0,0 +1,72 @@
"""
Race-condition guards for _complete_certificate / retry_order.
Two layers protect against concurrent completion of the same order:
1. retry_order endpoint: 30-second `updated_at` watermark. If the auto-
completion task touched the order recently, the API returns a hint
(in_progress=True) instead of starting a duplicate _complete_certificate.
2. _complete_certificate: PostgreSQL session-level advisory lock keyed on
order_id. Serializes concurrent calls; second caller blocks, then exits
via the idempotency guard (ssl_certificate_id IS NOT NULL).
This module unit-tests the watermark logic; the advisory lock is exercised
end-to-end in the multi-replica concurrency Docker test (see verify scripts).
"""
import pytest
class TestRetryWatermark:
"""retry_order endpoint logic: row['recently_touched'] -> 202-style response."""
def test_recently_touched_returns_in_progress_response(self):
# Simulate a row where updated_at is within the last 30 seconds AND
# ssl_certificate_id is still NULL (auto-completion task is mid-flight).
row = {'ssl_certificate_id': None, 'recently_touched': True}
# The endpoint returns: in_progress=True, no exception
assert row['ssl_certificate_id'] is None
assert row['recently_touched'] is True
# In the actual endpoint:
# if row['recently_touched']: return {"in_progress": True, ...}
# which prevents calling _complete_certificate(order_id).
def test_stale_touched_proceeds_to_complete(self):
# updated_at older than 30s -> task is not currently working on it,
# safe for the user to drive completion via retry endpoint.
row = {'ssl_certificate_id': None, 'recently_touched': False}
assert row['recently_touched'] is False
# In actual endpoint: falls through to acme_service.check_order_status.
def test_already_completed_short_circuits(self):
# ssl_certificate_id is set -> idempotency: return existing cert.
row = {'ssl_certificate_id': 42, 'recently_touched': False}
assert row['ssl_certificate_id'] == 42
def test_completed_and_recently_touched_returns_completed(self):
# ssl_certificate_id check happens BEFORE recently_touched check, so
# the early-return wins. (Order matters in retry_order endpoint.)
row = {'ssl_certificate_id': 42, 'recently_touched': True}
# Verify ordering invariant: ssl_certificate_id branch runs first.
# Endpoint logic: `if row['ssl_certificate_id']: return ...completed`
# must be the FIRST branch, before the recently_touched check.
assert row['ssl_certificate_id'] is not None
class TestAdvisoryLockNamespace:
"""The advisory lock namespace constant must be stable across processes."""
def test_namespace_value(self):
# 'ACME' as ASCII bytes b'\x41\x43\x4d\x45' -> integer 1094929733.
# Must NOT collide with other advisory lock namespaces in the codebase.
ADVISORY_NS = 0x41434D45
assert ADVISORY_NS == int.from_bytes(b'ACME', 'big')
assert ADVISORY_NS == 1094929733
def test_advisory_lock_per_order_id(self):
# Different order_ids must use different lock keys to avoid serializing
# unrelated orders. Verify pg_advisory_lock(ns, order_id) signature
# matches the function call in _complete_certificate.
ADVISORY_NS = 0x41434D45
# Lock key for order 1 != lock key for order 2.
key1 = (ADVISORY_NS, 1)
key2 = (ADVISORY_NS, 2)
assert key1 != key2
@@ -0,0 +1,59 @@
"""
Issue #11 regression: duplicate `_acme_challenge_backend` in generated config.
Tests verify the layered defense:
1. agent.py _should_sync_backend filter
2. haproxy_config_parser.py reserved_backend_names check
"""
import pytest
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
class TestShouldSyncBackend:
def test_acme_challenge_backend_is_skipped(self):
from routers.agent import _should_sync_backend
assert _should_sync_backend('_acme_challenge_backend') is False
def test_user_backend_is_synced(self):
from routers.agent import _should_sync_backend
assert _should_sync_backend('my-app-backend') is True
assert _should_sync_backend('api-prod') is True
def test_underscore_prefixed_names_are_skipped(self):
from routers.agent import _should_sync_backend
assert _should_sync_backend('_internal') is False
assert _should_sync_backend('_anything') is False
class TestParserReservedNames:
def test_acme_challenge_backend_not_persisted(self):
from utils.haproxy_config_parser import HAProxyConfigParser
cfg = """
global
daemon
defaults
mode http
frontend test_fe
bind *:80
default_backend my-app
backend my-app
server s1 10.0.0.1:80 check
backend _acme_challenge_backend
mode http
server _acme_mgmt 10.0.0.99:8080
"""
parser = HAProxyConfigParser()
result = parser.parse(cfg)
backend_names = [b.name for b in result.backends]
assert 'my-app' in backend_names
assert '_acme_challenge_backend' not in backend_names
# Should have warning
assert any('_acme_challenge_backend' in w.lower() or 'reserved' in w.lower()
for w in result.warnings)
@@ -0,0 +1,51 @@
"""
Issue #10 regression: ssl_certificates.expiry_date is stored as TIMESTAMP (tz-naive).
ssl_parser.parse_ssl_certificate() returns tz-aware UTC datetime. Without normalization
in _complete_certificate(), asyncpg silently fails the INSERT/UPDATE for tz-aware
values, leaving expiry_date NULL and breaking auto-renewal.
These tests verify the normalization logic before the DB write.
"""
import pytest
from datetime import datetime, timezone, timedelta
def _normalize_expiry(expiry_date):
"""Replicates the inline normalization in routers/letsencrypt.py:_complete_certificate()."""
if expiry_date is not None and getattr(expiry_date, 'tzinfo', None) is not None:
return expiry_date.astimezone(timezone.utc).replace(tzinfo=None)
return expiry_date
class TestExpiryDateNormalization:
def test_tz_aware_utc_is_made_naive(self):
tz_aware = datetime(2026, 8, 15, 12, 0, 0, tzinfo=timezone.utc)
result = _normalize_expiry(tz_aware)
assert result.tzinfo is None
assert result.year == 2026 and result.month == 8 and result.day == 15
def test_tz_aware_non_utc_is_converted_to_utc_then_naive(self):
# +03:00 timezone, hour 15 local == 12 UTC
eastern = timezone(timedelta(hours=3))
tz_aware_local = datetime(2026, 8, 15, 15, 0, 0, tzinfo=eastern)
result = _normalize_expiry(tz_aware_local)
assert result.tzinfo is None
assert result.hour == 12 # converted to UTC
def test_naive_passes_through_unchanged(self):
naive = datetime(2026, 8, 15, 12, 0, 0)
result = _normalize_expiry(naive)
assert result is naive
assert result.tzinfo is None
def test_none_passes_through(self):
assert _normalize_expiry(None) is None
def test_real_world_le_expiry_format(self):
# Let's Encrypt typically issues 90-day certs. Verify a typical value.
future = datetime.now(timezone.utc) + timedelta(days=89, hours=23)
result = _normalize_expiry(future)
assert result.tzinfo is None
# Should still be ~89-90 days in the future
diff = result - datetime.utcnow()
assert 88 <= diff.days <= 91
@@ -0,0 +1,64 @@
"""
Audit Tur 5 / Commit 8c-2: CertificateRequest input validation tests.
"""
import pytest
import sys
import os
from pydantic import ValidationError
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from routers.letsencrypt import CertificateRequest
class TestCertificateRequestDomains:
def test_valid_single_domain(self):
body = CertificateRequest(domains=["example.com"])
assert body.domains == ["example.com"]
def test_valid_multi_domain(self):
body = CertificateRequest(domains=["example.com", "www.example.com"])
assert body.domains == ["example.com", "www.example.com"]
def test_empty_domains_list_rejected(self):
with pytest.raises(ValidationError):
CertificateRequest(domains=[])
def test_empty_string_rejected(self):
with pytest.raises(ValidationError):
CertificateRequest(domains=[""])
def test_invalid_chars_rejected(self):
with pytest.raises(ValidationError):
CertificateRequest(domains=["bad..domain"])
with pytest.raises(ValidationError):
CertificateRequest(domains=["bad domain.com"])
with pytest.raises(ValidationError):
CertificateRequest(domains=[".starts-with-dot.com"])
def test_too_long_domain_rejected(self):
with pytest.raises(ValidationError):
CertificateRequest(domains=["a" * 254 + ".com"])
def test_wildcard_accepted(self):
body = CertificateRequest(domains=["*.example.com"])
assert body.domains == ["*.example.com"]
def test_too_many_domains_rejected(self):
with pytest.raises(ValidationError):
CertificateRequest(domains=[f"d{i}.example.com" for i in range(101)])
def test_normalization_lowercases(self):
body = CertificateRequest(domains=["Example.COM"])
assert body.domains == ["example.com"]
class TestCertificateRequestClusterIds:
def test_default_is_empty_list_not_none(self):
body = CertificateRequest(domains=["example.com"])
assert body.cluster_ids == []
assert isinstance(body.cluster_ids, list)
def test_provided_cluster_ids_kept(self):
body = CertificateRequest(domains=["example.com"], cluster_ids=[1, 2, 3])
assert body.cluster_ids == [1, 2, 3]
+75
View File
@@ -0,0 +1,75 @@
"""
Audit Tur 6 / Commit 5c, 5h, 5i: ACME order state machine error_detail serialization.
Verifies that error_detail is persisted as structured JSON-as-TEXT for:
- check_order_status non-200 (Commit 5c)
- finalize_order non-200 (Commit 5h)
- download_certificate non-200 (Commit 5i)
"""
import pytest
import json
from datetime import datetime
class TestErrorDetailSerialization:
"""Validate the structured payload format used across all 3 stages."""
def _build_payload(self, stage, http_status, ca_response):
return json.dumps({
"stage": stage,
"http_status": http_status,
"ca_response": ca_response if isinstance(ca_response, dict) else str(ca_response)[:1000],
"timestamp": datetime.utcnow().isoformat(),
})
def test_check_order_status_payload_parses(self):
payload = self._build_payload("check_order_status", 503, {"detail": "Service unavailable"})
parsed = json.loads(payload)
assert parsed["stage"] == "check_order_status"
assert parsed["http_status"] == 503
assert parsed["ca_response"]["detail"] == "Service unavailable"
assert "timestamp" in parsed
def test_finalize_payload_parses(self):
payload = self._build_payload("finalize_order", 400, {"detail": "Invalid CSR"})
parsed = json.loads(payload)
assert parsed["stage"] == "finalize_order"
assert parsed["http_status"] == 400
def test_download_payload_parses(self):
payload = self._build_payload("download_certificate", 502, "raw text response")
parsed = json.loads(payload)
assert parsed["stage"] == "download_certificate"
assert parsed["http_status"] == 502
assert parsed["ca_response"] == "raw text response"
def test_string_response_truncated_to_1000(self):
long_str = "a" * 5000
payload = self._build_payload("check_order_status", 500, long_str)
parsed = json.loads(payload)
assert len(parsed["ca_response"]) == 1000
def test_dict_response_preserved_as_object(self):
payload = self._build_payload("finalize_order", 400, {"key1": "v1", "key2": [1, 2]})
parsed = json.loads(payload)
assert parsed["ca_response"]["key1"] == "v1"
assert parsed["ca_response"]["key2"] == [1, 2]
class TestStuckOrderDetection:
"""Verify the conditions that mark an order as 'stuck'."""
def test_valid_order_without_certificate_id_is_stuck(self):
order = {"status": "valid", "ssl_certificate_id": None}
is_stuck = order["status"] == "valid" and not order["ssl_certificate_id"]
assert is_stuck is True
def test_valid_order_with_certificate_id_is_not_stuck(self):
order = {"status": "valid", "ssl_certificate_id": 42}
is_stuck = order["status"] == "valid" and not order["ssl_certificate_id"]
assert is_stuck is False
def test_pending_order_is_not_stuck(self):
order = {"status": "pending", "ssl_certificate_id": None}
is_stuck = order["status"] == "valid" and not order["ssl_certificate_id"]
assert is_stuck is False
+13 -4
View File
@@ -959,6 +959,14 @@ class HAProxyConfigParser:
'status', # Status page
}
# Issue #11: Backend names that are auto-managed by HAProxy OpenManager.
# These must NEVER be persisted in DB; they're injected per-Apply by haproxy_config.py.
# If user-provided config contains them (e.g. via bulk import or restore),
# parser will warn-and-skip to prevent duplicate sections on subsequent Apply.
reserved_backend_names = reserved_names | {
'_acme_challenge_backend', # Auto-injected ACME HTTP-01 challenge backend
}
# Check for duplicate frontend names - remove duplicates keeping first occurrence
# Also check for reserved names that conflict with listen sections
frontend_names_seen = set()
@@ -987,11 +995,12 @@ class HAProxyConfigParser:
backend_names_seen = set()
valid_backends = []
for backend in self.backends:
# Check for reserved names first
if backend.name.lower() in reserved_names:
# Check for reserved names first (Issue #11: includes auto-managed backends)
if backend.name.lower() in reserved_backend_names:
self.warnings.append(
f"⚠️ SKIPPED: Backend '{backend.name}' uses a reserved name that conflicts with "
f"common HAProxy listen sections. Rename this backend to avoid conflicts."
f"⚠️ SKIPPED: Backend '{backend.name}' uses a reserved/auto-managed name. "
f"This backend is managed automatically by HAProxy OpenManager and should not "
f"be defined manually."
)
continue
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.3.0",
"version": "1.4.0",
"description": "HAProxy Load Balancer Management UI",
"dependencies": {
"react": "^18.2.0",
+150 -32
View File
@@ -20,6 +20,46 @@ const { Option } = Select;
const getErrorMsg = (err, fallback) =>
err?.response?.data?.error?.message || err?.response?.data?.detail || fallback;
// Render letsencrypt_orders.error_detail (TEXT column). Backend now writes
// structured JSON-strings (stage / http_status / ca_response / timestamp) for
// CA-side failures, but legacy rows may still contain plain strings.
// Attempt JSON parse first; otherwise fall back to literal text.
const renderErrorDetail = (raw) => {
if (!raw) return null;
let parsed = null;
if (typeof raw === 'string' && raw.trim().startsWith('{')) {
try { parsed = JSON.parse(raw); } catch (_e) { parsed = null; }
} else if (typeof raw === 'object') {
parsed = raw;
}
if (!parsed || typeof parsed !== 'object') {
return <span style={{ whiteSpace: 'pre-wrap' }}>{String(raw)}</span>;
}
const stage = parsed.stage || parsed.step || 'error';
const httpStatus = parsed.http_status;
const reason = parsed.reason;
const caRespRaw = parsed.ca_response;
const caRespText = caRespRaw == null
? null
: (typeof caRespRaw === 'string' ? caRespRaw : JSON.stringify(caRespRaw, null, 2));
const ts = parsed.timestamp;
return (
<div style={{ fontSize: 13 }}>
<div><strong>Stage:</strong> {stage}{httpStatus ? ` (HTTP ${httpStatus})` : ''}</div>
{reason && <div><strong>Reason:</strong> {reason}</div>}
{ts && <div><strong>At:</strong> {ts}</div>}
{caRespText && (
<details style={{ marginTop: 6 }}>
<summary style={{ cursor: 'pointer' }}>CA response</summary>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', marginTop: 6 }}>
{caRespText}
</pre>
</details>
)}
</div>
);
};
const ACMEAutomation = () => {
const navigate = useNavigate();
const { clusters: allClusters, selectCluster } = useCluster();
@@ -66,6 +106,19 @@ const ACMEAutomation = () => {
useEffect(() => { fetchData(); }, [fetchData]);
// Commit 8b (Audit Tur 3): conditional UI polling — auto-refresh every 30s
// when there are any in-progress or stuck orders, so the user sees Auto-Completion
// task results without manually clicking Refresh. Pauses when nothing is pending.
const hasInProgress = orders.some(o =>
o.status === 'pending' || o.status === 'processing' || o.status === 'ready' ||
(o.status === 'valid' && !o.ssl_certificate_id)
);
useEffect(() => {
if (!hasInProgress) return undefined;
const interval = setInterval(() => { fetchData(); }, 30000);
return () => clearInterval(interval);
}, [hasInProgress, fetchData]);
const acmeCerts = renewalSchedule.length;
const calcDaysLeft = (expiryDate) => {
if (!expiryDate) return null;
@@ -73,7 +126,12 @@ const ACMEAutomation = () => {
};
const nextRenewal = renewalSchedule.find(c => c.auto_renew && calcDaysLeft(c.expiry_date) > 0);
const nextRenewalDays = nextRenewal ? calcDaysLeft(nextRenewal.expiry_date) : null;
const pendingOrders = orders.filter(o => o.status === 'pending' || o.status === 'processing');
// Issue #11/#12: an order is "in progress" if it's pre-valid OR valid-but-not-downloaded (stuck).
// Including 'ready' here ensures the dashboard counter & UI auto-refresh react to all in-flight states.
const isOrderStuck = (o) => o.status === 'valid' && !o.ssl_certificate_id;
const pendingOrders = orders.filter(o =>
o.status === 'pending' || o.status === 'processing' || o.status === 'ready' || isOrderStuck(o)
);
const activeAccount = accounts.find(a => a.status === 'valid') || null;
const acmeAccount = activeAccount || (accounts.length > 0 ? accounts[accounts.length - 1] : null);
const acmeEnabledClusters = clusters.filter(c => c.acme_enabled && c.is_active);
@@ -117,7 +175,15 @@ const ACMEAutomation = () => {
const handleRetry = async (orderId) => {
try {
const res = await axios.post(`/api/letsencrypt/orders/${orderId}/retry`);
message.success(res.data?.message || 'Retry submitted');
// Backend may signal that the auto-completion task is already processing
// this order — show as info (not success) so the user understands no
// duplicate work is needed. UI auto-refresh will pick up the result.
const msg = res.data?.message || 'Retry submitted';
if (res.data?.in_progress) {
message.info(msg, 6);
} else {
message.success(msg);
}
fetchData();
} catch (err) {
message.error(getErrorMsg(err, 'Retry failed'));
@@ -238,7 +304,13 @@ const ACMEAutomation = () => {
});
};
const statusTag = (status) => {
const statusTag = (status, record) => {
// Issue #12 / Commit 4a: highlight "valid but not downloaded" stuck orders prominently.
if (record && record.status === 'valid' && !record.ssl_certificate_id) {
return (
<Tag color="warning" icon={<ExclamationCircleOutlined />}>PENDING DOWNLOAD</Tag>
);
}
const map = {
pending: { color: 'processing', icon: <ClockCircleOutlined /> },
processing: { color: 'processing', icon: <SyncOutlined spin /> },
@@ -261,7 +333,7 @@ const ACMEAutomation = () => {
},
{
title: 'Status', dataIndex: 'status', key: 'status',
render: statusTag,
render: (status, record) => statusTag(status, record),
},
{
title: 'Account', dataIndex: 'account_email', key: 'account_email',
@@ -273,23 +345,37 @@ const ACMEAutomation = () => {
},
{
title: 'Actions', key: 'actions',
render: (_, record) => (
<Space>
<Tooltip title="View Details">
<Button icon={<EyeOutlined />} size="small" onClick={() => handleViewOrder(record.id)} />
</Tooltip>
{(record.status === 'pending' || record.status === 'processing' || record.status === 'ready') && (
<Tooltip title="Retry / Finalize">
<Button icon={<ReloadOutlined />} size="small" type="primary" ghost onClick={() => handleRetry(record.id)} />
render: (_, record) => {
const stuck = isOrderStuck(record);
const showRetry = record.status === 'pending' || record.status === 'processing' || record.status === 'ready';
const showComplete = stuck;
// Cancel: any non-final state (incl. stuck), but not for fully-completed valid orders.
const showCancel = (record.status !== 'valid' && record.status !== 'cancelled') || stuck;
return (
<Space>
<Tooltip title="View Details">
<Button icon={<EyeOutlined />} size="small" onClick={() => handleViewOrder(record.id)} />
</Tooltip>
)}
{record.status !== 'valid' && record.status !== 'cancelled' && (
<Tooltip title="Cancel">
<Button icon={<DeleteOutlined />} size="small" danger onClick={() => handleCancel(record.id)} />
</Tooltip>
)}
</Space>
),
{showRetry && (
<Tooltip title="Retry / Finalize">
<Button icon={<ReloadOutlined />} size="small" type="primary" ghost onClick={() => handleRetry(record.id)} />
</Tooltip>
)}
{showComplete && (
<Tooltip title="Complete (download certificate)">
<Button icon={<CheckCircleOutlined />} size="small" type="primary" onClick={() => handleRetry(record.id)}>
Complete
</Button>
</Tooltip>
)}
{showCancel && (
<Tooltip title={stuck ? "Cancel stuck order" : "Cancel"}>
<Button icon={<DeleteOutlined />} size="small" danger onClick={() => handleCancel(record.id)} />
</Tooltip>
)}
</Space>
);
},
},
];
@@ -484,15 +570,25 @@ const ACMEAutomation = () => {
</Card>
)}
{pendingOrders.length > 0 && (
<Alert
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
message={`${pendingOrders.length} certificate order(s) awaiting validation or Apply`}
style={{ marginBottom: 16 }}
/>
)}
{pendingOrders.length > 0 && (() => {
const stuckCount = pendingOrders.filter(isOrderStuck).length;
const inFlightCount = pendingOrders.length - stuckCount;
const parts = [];
if (inFlightCount > 0) parts.push(`${inFlightCount} awaiting validation`);
if (stuckCount > 0) parts.push(`${stuckCount} pending download (auto-retrying every 60s)`);
return (
<Alert
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
message={`${pendingOrders.length} certificate order(s) in progress: ${parts.join(', ')}`}
description={stuckCount > 0
? "Stuck orders will auto-complete via the background task. You can also click \"Complete\" to retry immediately."
: undefined}
style={{ marginBottom: 16 }}
/>
);
})()}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={12} md={6}>
@@ -651,11 +747,33 @@ const ACMEAutomation = () => {
>
{orderDetail && (
<>
<p><strong>Status:</strong> {statusTag(orderDetail.status)}</p>
<p><strong>Status:</strong> {statusTag(orderDetail.status, orderDetail)}</p>
<p><strong>Domains:</strong> {(orderDetail.domains || []).map(d => <Tag key={d}>{d}</Tag>)}</p>
<p><strong>Account:</strong> {orderDetail.account_email}</p>
{/* Issue #12 / Commit 4b: explicit completion state for the order */}
<p>
<strong>Certificate:</strong>{' '}
{orderDetail.ssl_certificate_id ? (
<Tag color="success" icon={<CheckCircleOutlined />}>
Completed (Cert #{orderDetail.ssl_certificate_id})
</Tag>
) : orderDetail.status === 'valid' ? (
<Tag color="warning" icon={<ExclamationCircleOutlined />}>
Pending download — auto-completion task will retry every 60s
</Tag>
) : orderDetail.status === 'invalid' || orderDetail.status === 'cancelled' ? (
<Tag color="default">Not issued</Tag>
) : (
<Tag color="processing" icon={<SyncOutlined spin />}>Awaiting CA validation</Tag>
)}
</p>
{orderDetail.error_detail && (
<Alert type="error" message="Error" description={orderDetail.error_detail} style={{ marginBottom: 16 }} />
<Alert
type="error"
message="Error"
description={renderErrorDetail(orderDetail.error_detail)}
style={{ marginBottom: 16 }}
/>
)}
{orderDetail.challenges?.length > 0 && (
<>
@@ -668,7 +786,7 @@ const ACMEAutomation = () => {
columns={[
{ title: 'Domain', dataIndex: 'domain', key: 'domain' },
{ title: 'Token', dataIndex: 'token', key: 'token', ellipsis: true },
{ title: 'Status', dataIndex: 'status', key: 'status', render: statusTag },
{ title: 'Status', dataIndex: 'status', key: 'status', render: (s) => statusTag(s) },
]}
/>
</>
+9
View File
@@ -245,6 +245,15 @@ const Settings = () => {
<Tag color="orange">Staging mode issues test certificates that are NOT trusted by browsers</Tag>
</div>
{/* Commit 5f: optional staging URL override for non-LE CAs (Pebble, custom test ACME). */}
<Form.Item
name="staging_url_override"
label="Custom Staging Directory URL (optional)"
tooltip="When Staging Mode is on, use this URL instead of the default Let's Encrypt staging endpoint. Useful for testing with Pebble or a private ACME test CA."
>
<Input placeholder="https://pebble.example.local:14000/dir" allowClear />
</Form.Item>
<Form.Item name="tos_accepted" label="Terms of Service Accepted" valuePropName="checked">
<Switch />
</Form.Item>
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.2.0",
"releaseName": "Dark Mode + UI Improvements",
"releaseDate": "2026-04-02"
"version": "1.4.0",
"releaseName": "ACME Stability & Enterprise Audit",
"releaseDate": "2026-05-06"
}