mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-03 12:10:44 +00:00
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:
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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) },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user