mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-20 01:25:12 +00:00
02b1cb2bca
Closes #13, Closes #14. This release squashes the v1.4.0 → v1.5.0 development line. v1.4.0 shipped the ACME stability & enterprise audit (Issues #10/#11/#12). v1.5.0 builds on that foundation with two co-equal headline features plus a 22-round audit campaign hardening the prior configuration surface. License remains MIT for v1.5.0 (relicense to AGPL-3.0 lands in v1.5.2). ------------------------------------------------------------------ HEADLINE FEATURE A — ACME Diagnostic Panel (Issue #13) ------------------------------------------------------------------ A live pre-flight + post-failure diagnostic surface for every ACME order, reachable from the ACME Automation page. The panel exists to make ACME failures legible to operators who do NOT have shell access to the API host. Endpoints (`backend/routers/acme_diagnostics.py`): POST /api/letsencrypt/orders/{order_id}/diagnostics Run the full 5-check suite (DNS / port-80 / routing / account / agents) and humanize the order's `error_detail` (>=11 RFC-8555 problem types, backwards compatible with legacy plain-string failures). POST /api/letsencrypt/orders/{order_id}/diagnostics/ {check_id}/rerun Re-run a single check in place — used by the "Re-run" button on every row of the modal's pre-flight table. GET /api/letsencrypt/orders/{order_id}/events Merged event timeline combining the typed `acme_order_events` rows with correlated `user_activity_logs` entries (resource_type = 'letsencrypt_order' AND resource_id = order_id). The diagnostic modal auto-tails this timeline every 5 seconds while open. Service-level checks (`backend/services/acme_diagnostics.py`): * DNS resolution via stdlib socket.gethostbyname_ex through run_in_executor (intentionally avoiding an aiodns runtime dep for v1.5.0). * Port-80 HEAD probe, target locked to the order's domains, success on HTTP 200 OR 404, warns on egress timeout (corp egress policies routinely blackhole outbound 80 — fail-hard would be too noisy). * SSRF guard: probe refuses non-public IPs and surfaces the skip in the diagnostic result; IPv4-mapped IPv6 normalisation closes the `::ffff:169.254.169.254` cloud-metadata vector. * HAProxy routing presence check: matches the order's cluster_ids to a port-80 HTTP frontend. * ACME account validity check against `letsencrypt_accounts`. * Agent presence check (>=1 active agent in target cluster). * Every sub-check wrapped in a wall-clock timeout to bound impact on the API event loop. RBAC: ssl.read for run, ssl.read for events. Per-user 5/min rate limit on both run and rerun, backed by the (user_id, action, created_at DESC) composite index. Frontend (`frontend/src/components/ACMEAutomation.js`): * "Diagnose" button on every order row + the existing "stuck order" warning row. * Modal with two tabs: - Pre-flight Checks (Antd Table with status pills + Re-run buttons + humanized error banner) - Event Log (Antd Timeline with auto-tail polling, scroll- to-bottom, pause-on-hover) * Correlation IDs surfaced in error banners and individual check fail details for backend-log lookup. ------------------------------------------------------------------ HEADLINE FEATURE B — Site Setup Wizard (Issue #14) ------------------------------------------------------------------ A single guided flow that creates a Backend + Servers + HTTP Frontend (and optional HTTPS Frontend) in one atomic transaction. Endpoints (`backend/routers/site_wizard.py`): POST /api/site-wizard/preview — diff-preview the changeset POST /api/site-wizard/create — atomic execute POST /api/site-wizard/reject — clean rollback (including any wizard_staged ACME orders) GET /api/site-wizard/drafts — draft persistence PUT /api/site-wizard/drafts/{id} — save/update DELETE /api/site-wizard/drafts/{id} Feature surface: * One screen captures both backend (mode + servers) AND frontend (http + optional https + SSL mode) inputs. * SSL modes: ACME (new order, HTTP-01 only for v1.5.0), Upload (existing PEM), Existing (link to a stored cert), or None. * ACME-staged path: wizard_staged_until watermark on the `letsencrypt_orders` row defers finalisation until agent confirmation; per-mode reject cleanly cancels and rolls back the staged order. * Live diff preview against the cluster's current generated config (renderer-evolution noise stripped — track-sc<N> dedup, per-server cookie strip, defaults-cookie inheritance, listen-block flattening). * Draft persistence with PEM stripped at save time (private keys never round-trip through the drafts table). * Per-cluster multi-tenancy: drafts and wizard_staged orders are isolated to the creating user's cluster scope. Frontend (`frontend/src/components/SiteWizard.js`): * 4-step Antd Steps flow: Backend → Frontend → SSL → Review. * Render the live diff preview inline before commit. * Antd Form-level validation mirrors backend Pydantic validators (numeric bounds, HAProxy reserved keywords, ALPN consistency, IPv6 scope-id, domain regex, server name dedup). ------------------------------------------------------------------ AUDIT CAMPAIGN — Rounds 1 → 22 (Bulgu #1 → #82) ------------------------------------------------------------------ v1.5.0 includes 22 adversarial review passes. Each round produced its own commit set in the corporate development line; this squash collapses those into the v1.5.0 release artefact. Highlights: Round 1-4 Site Wizard core: dry-run parity, single-line value injection guard, ACL -f pattern-file block, SSL parity, timeout regex, form-state pin. Round 5-7 defaults-cookie inheritance, server-named-cookie guard, fe/be mode mismatch, duplicate server names, health_check_uri + server_address validators. Round 8-10 cookie_name / cookie_options newline-injection guard, dry-run parity (round 9), TCP-mode HTTP-only feature blockers. Round 11 SSL name path traversal + health-check >= 1. Round 12-13 SSL & ACME deep dive (Bulgu #23-#32). Round 14 single-line value injection (Bulgu #33). Round 15-17 ACME multi-tenant UX, numeric bounds, HAProxy reserved keywords, ALPN/TLS consistency, all-backup, multi-domain & multi-user enterprise edges, drain/HSTS/post-completion (Bulgu #34-#53). Round 18-21 concurrency, agent state, TCP-mode HTTP-only, list size caps, IPv6 scope-id, preview account validation, TCP backend + balance uri reject (Bulgu #54-#61). Round 22 FE error visibility + 3x stale-data lockouts, referential integrity + cascade safety, authentication & authorization, multi-cluster isolation, apply_pending_changes concurrency, script injection + bulk import multi-tenancy, prefix-stripped signature comparison (Bulgu #62-#82). ------------------------------------------------------------------ NO CORPORATE-SPECIFIC ARTIFACTS ------------------------------------------------------------------ This squash deliberately sanitises corporate hostnames, container registry references, and TLS secret names into generic placeholders (`your-registry.example.com/your-org`, `haproxy-openmanager*.example.com`, `wildcard-tls`, `taylanbakircioglu/haproxy-openmanager-*`) so the public artefact contains no internal infrastructure detail. Pilot / development history that retained those values stays in the corporate fork and is NOT part of this commit.
1534 lines
75 KiB
Python
1534 lines
75 KiB
Python
from fastapi import APIRouter, HTTPException, Request, Header, Depends
|
|
from typing import List, Optional
|
|
import logging
|
|
import hashlib
|
|
import re
|
|
import time
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
# Import database and models
|
|
from database.connection import get_database_connection, close_database_connection
|
|
from auth_middleware import get_current_user_from_token
|
|
from models.ssl import SSLCertificate, SSLCertificateCreate, SSLCertificateUpdate, SSLCertificateResponse
|
|
from utils.ssl_parser import parse_ssl_certificate, validate_private_key, validate_certificate_chain, format_certificate_info
|
|
from utils.activity_log import log_user_activity
|
|
from services.haproxy_config import generate_haproxy_config_for_cluster
|
|
|
|
router = APIRouter(prefix="/api/ssl", tags=["SSL Certificates"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Bulgu #63 (round-22 audit) — handler-level enforcement of the
|
|
# SSL certificate name path-traversal guard. Previously lived as a
|
|
# Pydantic validator on `SSLCertificateUpdate.name` (Bulgu #21,
|
|
# round-11). Operators with legacy certificate names containing
|
|
# forbidden characters (e.g. `*.example.com`, `cert (1).pem`,
|
|
# `wildcard ssl.pem`) were locked out of updating any other field
|
|
# — the model validator fired before the route body even ran. The
|
|
# create + update routes now invoke `_assert_safe_cert_name` with
|
|
# explicit grandfathering on UPDATE.
|
|
|
|
_SAFE_CERT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
|
|
|
|
def _assert_safe_cert_name(name: Optional[str]) -> None:
|
|
"""Strict path-traversal guard for SSL certificate names.
|
|
|
|
Identical contract to the original Bulgu #21 validator:
|
|
|
|
* trimmed-non-empty, length <= 200
|
|
* only [A-Za-z0-9_.-]
|
|
* no ``..`` sequence
|
|
* does not start with ``.`` or ``-``
|
|
|
|
Raises ``HTTPException(400)`` so the caller can let FastAPI
|
|
surface the actionable message. Callers that want to skip the
|
|
check (e.g. UPDATE with unchanged name) simply omit the call.
|
|
"""
|
|
if name is None:
|
|
return
|
|
stripped = name.strip()
|
|
if not stripped:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="SSL certificate name must not be empty",
|
|
)
|
|
if stripped != name:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="SSL certificate name must not contain leading/trailing whitespace",
|
|
)
|
|
if len(stripped) > 200:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="SSL certificate name must be 200 characters or fewer",
|
|
)
|
|
if not _SAFE_CERT_NAME_PATTERN.match(stripped):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"SSL certificate name={name!r} contains forbidden "
|
|
"characters — only letters, digits, underscore, "
|
|
"hyphen, and dot are allowed."
|
|
),
|
|
)
|
|
if ".." in stripped:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f'SSL certificate name={name!r} must not contain '
|
|
f'".." (path traversal)'
|
|
),
|
|
)
|
|
if stripped.startswith("."):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f'SSL certificate name={name!r} must not start with "."',
|
|
)
|
|
if stripped.startswith("-"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f'SSL certificate name={name!r} must not start with "-"',
|
|
)
|
|
|
|
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
|
|
cluster_exists = await conn.fetchval("""
|
|
SELECT id FROM haproxy_clusters WHERE id = $1
|
|
""", cluster_id)
|
|
|
|
if not cluster_exists:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Cluster not found"
|
|
)
|
|
|
|
# Check if user is admin - admins can access everything
|
|
is_admin = await conn.fetchval("""
|
|
SELECT is_admin FROM users WHERE id = $1
|
|
""", user_id)
|
|
|
|
if is_admin:
|
|
logger.info(f"Admin user {user_id} granted access to cluster {cluster_id}")
|
|
return True
|
|
|
|
# Check if user_pool_access table exists (for backward compatibility)
|
|
table_exists = await conn.fetchval("""
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_name = 'user_pool_access'
|
|
)
|
|
""")
|
|
|
|
if not table_exists:
|
|
# Fallback to basic validation if table doesn't exist yet
|
|
logger.warning("user_pool_access table not found, using basic cluster validation")
|
|
return True
|
|
|
|
# Check if expires_at column exists (for backward compatibility)
|
|
expires_at_exists = await conn.fetchval("""
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_name = 'user_pool_access' AND column_name = 'expires_at'
|
|
)
|
|
""")
|
|
|
|
# Proper user-pool access validation
|
|
if expires_at_exists:
|
|
user_access = await conn.fetchrow("""
|
|
SELECT upa.access_level, hc.id, hc.name
|
|
FROM haproxy_clusters hc
|
|
JOIN haproxy_cluster_pools hcp ON hc.pool_id = hcp.id
|
|
JOIN user_pool_access upa ON hcp.id = upa.pool_id
|
|
WHERE upa.user_id = $1 AND hc.id = $2 AND upa.is_active = TRUE
|
|
AND (upa.expires_at IS NULL OR upa.expires_at > CURRENT_TIMESTAMP)
|
|
""", user_id, cluster_id)
|
|
else:
|
|
# Fallback query without expires_at column
|
|
user_access = await conn.fetchrow("""
|
|
SELECT upa.access_level, hc.id, hc.name
|
|
FROM haproxy_clusters hc
|
|
JOIN haproxy_cluster_pools hcp ON hc.pool_id = hcp.id
|
|
JOIN user_pool_access upa ON hcp.id = upa.pool_id
|
|
WHERE upa.user_id = $1 AND hc.id = $2 AND upa.is_active = TRUE
|
|
""", user_id, cluster_id)
|
|
|
|
if not user_access:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="You don't have access to this cluster. Please contact your administrator."
|
|
)
|
|
|
|
logger.info(f"User {user_id} granted {user_access['access_level']} access to cluster {cluster_id}")
|
|
return True
|
|
|
|
@router.get("/certificates", response_model=List[dict], summary="Get SSL Certificates", response_description="List of SSL certificates")
|
|
async def get_ssl_certificates(
|
|
cluster_id: Optional[int] = None,
|
|
usage_type: Optional[str] = None,
|
|
authorization: str = Header(None),
|
|
):
|
|
"""
|
|
# Get SSL Certificates
|
|
|
|
Retrieve all SSL/TLS certificates with validity information.
|
|
|
|
## Query Parameters
|
|
- **cluster_id** (optional): Filter by cluster
|
|
|
|
## Example Request
|
|
```bash
|
|
curl -X GET "{BASE_URL}/api/ssl/certificates?cluster_id=1" \\
|
|
-H "Authorization: Bearer eyJhbGciOiJIUz..."
|
|
```
|
|
|
|
## Example Response
|
|
```json
|
|
[
|
|
{
|
|
"id": 1,
|
|
"name": "example.com-cert",
|
|
"common_name": "*.example.com",
|
|
"domains": ["example.com", "*.example.com"],
|
|
"issuer": "Let's Encrypt",
|
|
"valid_from": "2024-01-01T00:00:00Z",
|
|
"valid_until": "2024-12-31T23:59:59Z",
|
|
"cluster_ids": [1, 2]
|
|
}
|
|
]
|
|
```
|
|
|
|
R18 audit fix: enforce authentication on the LIST endpoint and
|
|
cluster-scoped authorization when `cluster_id` is provided. Pre-R18
|
|
this route was anonymously accessible — any client could enumerate
|
|
cert metadata across the whole installation, which broke the
|
|
multi-tenant guarantee in `validate_user_cluster_access`. The
|
|
detail / create / update / delete routes were already authenticated
|
|
individually; this fix closes the LIST gap.
|
|
"""
|
|
# R18 audit (round 4 fix): authenticate BEFORE opening any DB
|
|
# connection. Pre-fix the endpoint opened the pool first then
|
|
# checked auth, which produced confusing 500s on transient DB
|
|
# issues for unauthenticated callers (and worse: leaked the
|
|
# presence of the pool to anonymous probes).
|
|
current_user = await get_current_user_from_token(authorization)
|
|
# R18 audit (round 6 fix): single try/finally lifecycle for the
|
|
# connection. Pre-fix the function had nested try blocks that each
|
|
# called `close_database_connection(conn)` — releasing the same
|
|
# asyncpg handle twice if any exception bubbled past the inner
|
|
# release. Pattern now: one acquire, one release in finally,
|
|
# regardless of which branch raises or returns. `conn = None`
|
|
# pre-binding still required so the finally is safe when the
|
|
# acquire itself raises (DB pool down / pool typo).
|
|
conn = None
|
|
try:
|
|
conn = await get_database_connection()
|
|
if current_user and cluster_id:
|
|
# Cluster-scoped enumeration must respect cluster access.
|
|
# Re-raises HTTPException(403/404) cleanly; finally below
|
|
# releases the connection.
|
|
await validate_user_cluster_access(current_user["id"], cluster_id, conn)
|
|
|
|
# First check if ssl_certificates table exists
|
|
try:
|
|
table_exists = await conn.fetchval("""
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_name = 'ssl_certificates'
|
|
)
|
|
""")
|
|
|
|
if not table_exists:
|
|
logger.info("SSL certificates table does not exist yet - returning empty list")
|
|
return []
|
|
|
|
# Query with new schema fields - show cluster-specific + global SSLs
|
|
if cluster_id:
|
|
# Build WHERE clause with optional usage_type filter
|
|
where_clauses = ["s.is_active = TRUE"]
|
|
where_clauses.append("""(
|
|
NOT EXISTS (SELECT 1 FROM ssl_certificate_clusters WHERE ssl_certificate_id = s.id) -- Global SSLs (no cluster associations)
|
|
OR scc.cluster_id = $1 -- Cluster-specific SSLs for this cluster
|
|
)""")
|
|
|
|
params = [cluster_id]
|
|
if usage_type:
|
|
where_clauses.append(f"s.usage_type = ${len(params) + 1}")
|
|
params.append(usage_type)
|
|
|
|
where_clause = " AND ".join(where_clauses)
|
|
|
|
certificates = await conn.fetch(f"""
|
|
SELECT DISTINCT s.id, s.name, s.primary_domain as domain, s.expiry_date, s.issuer, s.fingerprint, s.status,
|
|
s.days_until_expiry, s.all_domains, s.is_active, s.cluster_id, s.usage_type,
|
|
s.source, s.created_at, s.updated_at,
|
|
CASE
|
|
WHEN NOT EXISTS (SELECT 1 FROM ssl_certificate_clusters WHERE ssl_certificate_id = s.id) THEN 'Global'
|
|
ELSE 'Cluster-specific'
|
|
END as ssl_type,
|
|
COALESCE(
|
|
array_agg(DISTINCT c.name ORDER BY c.name) FILTER (WHERE c.name IS NOT NULL),
|
|
ARRAY[]::text[]
|
|
) as cluster_names,
|
|
-- Get latest config status for SSL-related versions (REJECTED filtered out)
|
|
(SELECT cv.status
|
|
FROM config_versions cv
|
|
WHERE cv.cluster_id = $1
|
|
AND cv.version_name LIKE 'ssl-' || s.id || '-%'
|
|
AND cv.status != 'REJECTED'
|
|
ORDER BY cv.created_at DESC
|
|
LIMIT 1) as last_config_status,
|
|
-- Check if SSL has pending config changes
|
|
EXISTS (
|
|
SELECT 1 FROM config_versions cv2
|
|
WHERE cv2.cluster_id = $1
|
|
AND cv2.version_name LIKE 'ssl-' || s.id || '-%'
|
|
AND cv2.status = 'PENDING'
|
|
) as has_pending_config,
|
|
-- Get cluster names where SSL has PENDING config versions
|
|
(SELECT array_agg(DISTINCT hc.name)
|
|
FROM config_versions cv3
|
|
JOIN haproxy_clusters hc ON hc.id = cv3.cluster_id
|
|
WHERE cv3.version_name LIKE 'ssl-' || s.id || '-%'
|
|
AND cv3.status = 'PENDING'
|
|
) as pending_cluster_names,
|
|
(SELECT COUNT(*) FROM frontends f
|
|
WHERE f.is_active = TRUE AND (f.ssl_certificate_id = s.id OR f.ssl_certificate_ids @> to_jsonb(s.id)))
|
|
+
|
|
(SELECT COUNT(*) FROM backend_servers bs
|
|
WHERE bs.is_active = TRUE AND bs.ssl_certificate_id = s.id)
|
|
as usage_count
|
|
FROM ssl_certificates s
|
|
LEFT JOIN ssl_certificate_clusters scc ON s.id = scc.ssl_certificate_id
|
|
LEFT JOIN haproxy_clusters c ON scc.cluster_id = c.id
|
|
WHERE {where_clause}
|
|
GROUP BY s.id, s.name, s.primary_domain, s.expiry_date, s.issuer, s.fingerprint, s.status,
|
|
s.days_until_expiry, s.all_domains, s.is_active, s.cluster_id, s.usage_type, s.source, s.created_at, s.updated_at
|
|
ORDER BY s.created_at DESC
|
|
""", *params)
|
|
else:
|
|
# Build WHERE clause with optional usage_type filter
|
|
where_clauses = ["is_active = TRUE"]
|
|
params = []
|
|
|
|
if usage_type:
|
|
where_clauses.append(f"usage_type = ${len(params) + 1}")
|
|
params.append(usage_type)
|
|
|
|
where_clause = " AND ".join(where_clauses)
|
|
|
|
certificates = await conn.fetch(f"""
|
|
SELECT id, name, primary_domain as domain,
|
|
expiry_date, issuer, fingerprint, status,
|
|
days_until_expiry, all_domains, is_active, cluster_id, usage_type,
|
|
source, created_at, updated_at,
|
|
(SELECT COUNT(*) FROM frontends f
|
|
WHERE f.is_active = TRUE AND (f.ssl_certificate_id = ssl_certificates.id OR f.ssl_certificate_ids @> to_jsonb(ssl_certificates.id)))
|
|
+
|
|
(SELECT COUNT(*) FROM backend_servers bs
|
|
WHERE bs.is_active = TRUE AND bs.ssl_certificate_id = ssl_certificates.id)
|
|
as usage_count
|
|
FROM ssl_certificates
|
|
WHERE {where_clause}
|
|
ORDER BY created_at DESC
|
|
""", *params)
|
|
|
|
# R18 audit (round 6 fix): defer the connection release to
|
|
# the outer `finally` — the post-query `for cert in
|
|
# certificates:` loop must not run on a released handle,
|
|
# but if it raises we don't want a double-release on the
|
|
# outer handler either.
|
|
|
|
# Convert to list of dicts with new schema fields
|
|
result = []
|
|
for cert in certificates:
|
|
# Parse all_domains JSON safely
|
|
all_domains = []
|
|
try:
|
|
if cert.get('all_domains'):
|
|
all_domains = json.loads(cert['all_domains']) if isinstance(cert['all_domains'], str) else cert['all_domains']
|
|
except (json.JSONDecodeError, TypeError):
|
|
all_domains = [cert['domain']] if cert['domain'] else []
|
|
|
|
# Debug expiry information
|
|
logger.info(f"SSL {cert['name']}: expiry_date={cert['expiry_date']}, status={cert.get('status')}, days_until_expiry={cert.get('days_until_expiry')}")
|
|
|
|
cert_dict = {
|
|
'id': cert['id'],
|
|
'name': cert['name'],
|
|
'domain': cert['domain'],
|
|
'all_domains': all_domains,
|
|
'expiry_date': cert['expiry_date'].isoformat().replace('+00:00', 'Z') if cert['expiry_date'] else None,
|
|
'issuer': cert.get('issuer'),
|
|
'fingerprint': cert.get('fingerprint'),
|
|
'status': cert.get('status', 'valid'),
|
|
'days_until_expiry': cert.get('days_until_expiry', 0),
|
|
'cluster_id': cert['cluster_id'],
|
|
'usage_type': cert.get('usage_type', 'frontend'),
|
|
'ssl_type': cert.get('ssl_type', 'Global' if cert['cluster_id'] is None else 'Cluster-specific'),
|
|
'cluster_names': cert.get('cluster_names', []),
|
|
'created_at': cert['created_at'].isoformat().replace('+00:00', 'Z') if cert['created_at'] else None,
|
|
'updated_at': cert['updated_at'].isoformat().replace('+00:00', 'Z') if cert['updated_at'] else None,
|
|
'is_active': cert['is_active'],
|
|
'usage_count': cert.get('usage_count', 0),
|
|
'source': cert.get('source', 'manual'),
|
|
'last_config_status': cert.get('last_config_status'),
|
|
'has_pending_config': cert.get('has_pending_config', False),
|
|
'pending_cluster_names': list(cert.get('pending_cluster_names') or [])
|
|
}
|
|
result.append(cert_dict)
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
# Re-raise typed HTTP errors (e.g. cluster access 403/404)
|
|
# without the broad-except remap below. The outer finally
|
|
# still releases the connection.
|
|
raise
|
|
except Exception as table_error:
|
|
logger.error(f"SSL LIST ERROR: Query failed for cluster_id={cluster_id}, usage_type={usage_type}: {table_error}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to fetch SSL certificates. Please check server logs. Error: {str(table_error)}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"SSL LIST ERROR: Connection/setup failed: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to fetch SSL certificates: {str(e)}"
|
|
)
|
|
finally:
|
|
# R18 audit (round 6 fix): single canonical release point.
|
|
# Idempotent because of the `conn is not None` guard — a no-op
|
|
# if `get_database_connection()` itself raised before assignment.
|
|
if conn is not None:
|
|
try:
|
|
await close_database_connection(conn)
|
|
except Exception:
|
|
pass
|
|
|
|
@router.post("/certificates")
|
|
async def create_ssl_certificate(certificate: SSLCertificateCreate, request: Request, authorization: str = Header(None)):
|
|
"""Create new SSL certificate with automatic domain and expiry parsing"""
|
|
try:
|
|
# Get current user for activity logging
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
|
|
# Check permission for SSL create
|
|
has_permission = await check_user_permission(current_user["id"], "ssl", "create")
|
|
if not has_permission:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Insufficient permissions: ssl.create required"
|
|
)
|
|
|
|
# Bulgu #63 (round-22 audit) — strict path-traversal guard on
|
|
# CREATE. Mirrors the original Bulgu #21 validator; the
|
|
# equivalent check was moved out of the model so the UPDATE
|
|
# path can grandfather legacy names.
|
|
_assert_safe_cert_name(certificate.name)
|
|
|
|
conn = await get_database_connection()
|
|
|
|
# Validate cluster access for multi-cluster security
|
|
if certificate.cluster_ids:
|
|
for cluster_id in certificate.cluster_ids:
|
|
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
|
|
|
|
# Parse SSL certificate to extract domain and expiry information
|
|
logger.info(f"SSL CREATE: Parsing certificate content for '{certificate.name}'")
|
|
try:
|
|
cert_info = parse_ssl_certificate(certificate.certificate_content)
|
|
|
|
if cert_info.get("error"):
|
|
await close_database_connection(conn)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid SSL certificate: {cert_info['error']}"
|
|
)
|
|
except Exception as parse_error:
|
|
logger.error(f"SSL parsing failed: {parse_error}")
|
|
await close_database_connection(conn)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"SSL certificate parsing error: {str(parse_error)}"
|
|
)
|
|
|
|
# Validate private key (only if provided - server SSL may not have private key)
|
|
if certificate.private_key_content and not validate_private_key(certificate.private_key_content):
|
|
await close_database_connection(conn)
|
|
raise HTTPException(status_code=400, detail="Invalid private key format")
|
|
|
|
# Validate certificate chain (if provided)
|
|
if certificate.chain_content and not validate_certificate_chain(certificate.chain_content):
|
|
await close_database_connection(conn)
|
|
raise HTTPException(status_code=400, detail="Invalid certificate chain format")
|
|
|
|
# Check if certificate name already exists (including soft-deleted ones)
|
|
# SSL names must be globally unique to prevent conflicts
|
|
if certificate.is_global:
|
|
# For global certificates, check if name already exists globally (including soft-deleted)
|
|
existing = await conn.fetchrow("""
|
|
SELECT id, is_active FROM ssl_certificates
|
|
WHERE name = $1 AND cluster_id IS NULL
|
|
""", certificate.name)
|
|
else:
|
|
# For cluster-specific certificates, check if name already exists in any of the target clusters
|
|
existing = None
|
|
if certificate.cluster_ids:
|
|
for cluster_id in certificate.cluster_ids:
|
|
cluster_existing = await conn.fetchrow("""
|
|
SELECT s.id, s.is_active FROM ssl_certificates s
|
|
LEFT JOIN ssl_certificate_clusters scc ON s.id = scc.ssl_certificate_id
|
|
WHERE s.name = $1 AND (s.cluster_id = $2 OR scc.cluster_id = $2)
|
|
""", certificate.name, cluster_id)
|
|
if cluster_existing:
|
|
existing = cluster_existing
|
|
break
|
|
# Extract parsed information before any DB operations that need them
|
|
primary_domain = cert_info["primary_domain"]
|
|
all_domains = cert_info["all_domains"]
|
|
expiry_date = cert_info["expiry_date"]
|
|
|
|
# Ensure expiry_date is timezone-aware for database insertion
|
|
if expiry_date:
|
|
try:
|
|
if expiry_date.tzinfo is None:
|
|
expiry_date = expiry_date.replace(tzinfo=timezone.utc)
|
|
logger.info(f"SSL CREATE: Fixed timezone for expiry_date: {expiry_date}")
|
|
else:
|
|
expiry_date = expiry_date.astimezone(timezone.utc)
|
|
logger.info(f"SSL CREATE: Converted expiry_date to UTC: {expiry_date}")
|
|
except Exception as tz_error:
|
|
logger.error(f"Timezone conversion failed: {tz_error}")
|
|
expiry_date = None
|
|
logger.warning("SSL CREATE: Using NULL expiry_date due to timezone error")
|
|
|
|
issuer = cert_info["issuer"]
|
|
status = cert_info["status"]
|
|
fingerprint = cert_info["fingerprint"]
|
|
|
|
# Recalculate days_until_expiry safely in SSL router
|
|
if expiry_date:
|
|
try:
|
|
now = datetime.now(timezone.utc)
|
|
days_until_expiry = (expiry_date - now).days
|
|
|
|
if days_until_expiry < 0:
|
|
status = "expired"
|
|
elif days_until_expiry < 30:
|
|
status = "expiring_soon"
|
|
else:
|
|
status = "valid"
|
|
|
|
logger.info(f"SSL CREATE: Recalculated days_until_expiry={days_until_expiry}, status={status}")
|
|
except Exception as calc_error:
|
|
logger.error(f"Days calculation failed: {calc_error}")
|
|
days_until_expiry = cert_info.get("days_until_expiry", 0)
|
|
else:
|
|
days_until_expiry = cert_info.get("days_until_expiry", 0)
|
|
|
|
# Convert timezone-aware datetime to timezone-naive UTC for database insertion
|
|
if expiry_date and hasattr(expiry_date, 'tzinfo') and expiry_date.tzinfo is not None:
|
|
expiry_date_utc = expiry_date.astimezone(timezone.utc).replace(tzinfo=None)
|
|
logger.info(f"SSL CREATE: Converted timezone-aware {expiry_date} to timezone-naive UTC {expiry_date_utc}")
|
|
expiry_date = expiry_date_utc
|
|
|
|
if existing:
|
|
if existing['is_active']:
|
|
await close_database_connection(conn)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"SSL certificate with name '{certificate.name}' already exists. Please choose a different name or delete the existing certificate first."
|
|
)
|
|
else:
|
|
# Soft-deleted certificate exists - reactivate it
|
|
logger.info(f"SSL REACTIVATE: Found soft-deleted SSL '{certificate.name}', reactivating instead of creating new")
|
|
|
|
# Clean up old junction table entries before re-creating
|
|
await conn.execute("""
|
|
DELETE FROM ssl_certificate_clusters WHERE ssl_certificate_id = $1
|
|
""", existing['id'])
|
|
|
|
await conn.execute("""
|
|
UPDATE ssl_certificates
|
|
SET is_active = TRUE,
|
|
last_config_status = 'PENDING',
|
|
certificate_content = $2,
|
|
private_key_content = $3,
|
|
chain_content = $4,
|
|
primary_domain = $5,
|
|
all_domains = $6,
|
|
expiry_date = $7,
|
|
usage_type = $8,
|
|
issuer = $9,
|
|
fingerprint = $10,
|
|
status = $11,
|
|
days_until_expiry = $12,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
""", existing['id'], certificate.certificate_content, certificate.private_key_content,
|
|
certificate.chain_content, primary_domain, json.dumps(all_domains), expiry_date,
|
|
certificate.usage_type, issuer, fingerprint, status, days_until_expiry)
|
|
|
|
cert_id = existing['id']
|
|
logger.info(f"SSL REACTIVATED: SSL certificate '{certificate.name}' (ID: {cert_id}) reactivated successfully")
|
|
|
|
# Only insert if not reactivating existing certificate
|
|
if not existing or existing['is_active']:
|
|
logger.info(f"SSL CREATE: About to insert - expiry_date={expiry_date}, type={type(expiry_date)}")
|
|
logger.info(f"SSL CREATE: About to insert - days_until_expiry={days_until_expiry}, status={status}")
|
|
|
|
# Insert new SSL certificate with parsed information
|
|
cert_id = await conn.fetchval("""
|
|
INSERT INTO ssl_certificates
|
|
(name, primary_domain, certificate_content, private_key_content, chain_content,
|
|
expiry_date, issuer, fingerprint, status, days_until_expiry, all_domains,
|
|
is_active, cluster_id, last_config_status, usage_type)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
RETURNING id
|
|
""", certificate.name, primary_domain, certificate.certificate_content,
|
|
certificate.private_key_content, certificate.chain_content, expiry_date,
|
|
issuer, fingerprint, status, days_until_expiry, json.dumps(all_domains),
|
|
True, None, 'PENDING', certificate.usage_type) # Always NULL for cluster_id, use junction table
|
|
|
|
# If not global, insert cluster associations in junction table
|
|
if not certificate.is_global and certificate.cluster_ids:
|
|
for cluster_id in certificate.cluster_ids:
|
|
await conn.execute("""
|
|
INSERT INTO ssl_certificate_clusters (ssl_certificate_id, cluster_id)
|
|
VALUES ($1, $2)
|
|
""", cert_id, cluster_id)
|
|
|
|
# Create config versions for affected clusters
|
|
sync_results = []
|
|
|
|
if certificate.is_global:
|
|
# For global SSL certificates, create PENDING versions for ALL active clusters
|
|
affected_clusters = await conn.fetch("SELECT id FROM haproxy_clusters WHERE is_active = TRUE")
|
|
affected_clusters = [cluster['id'] for cluster in affected_clusters]
|
|
logger.info(f"GLOBAL SSL: Creating PENDING versions for {len(affected_clusters)} clusters")
|
|
else:
|
|
# For cluster-specific SSL certificates
|
|
affected_clusters = certificate.cluster_ids or []
|
|
logger.info(f"CLUSTER SSL: Creating PENDING versions for specific clusters: {affected_clusters}")
|
|
|
|
for cluster_id in affected_clusters:
|
|
try:
|
|
# Generate new HAProxy config
|
|
config_content = await generate_haproxy_config_for_cluster(cluster_id)
|
|
logger.info(f"SSL CONFIG DEBUG: Generated config for cluster {cluster_id}, length: {len(config_content)} chars")
|
|
logger.info(f"SSL CONFIG DEBUG: First 500 chars: {config_content[:500]}")
|
|
|
|
# Check if SSL is actually used in any frontends for this cluster
|
|
ssl_frontends = await conn.fetch("""
|
|
SELECT name, ssl_enabled, ssl_certificate_id
|
|
FROM frontends
|
|
WHERE cluster_id = $1 AND is_active = TRUE AND ssl_enabled = TRUE
|
|
""", cluster_id)
|
|
|
|
logger.info(f"SSL CONFIG DEBUG: Found {len(ssl_frontends)} SSL-enabled frontends in cluster {cluster_id}")
|
|
for fe in ssl_frontends:
|
|
logger.info(f"SSL CONFIG DEBUG: Frontend '{fe['name']}' - ssl_enabled: {fe['ssl_enabled']}, ssl_certificate_id: {fe['ssl_certificate_id']}")
|
|
|
|
# Create new config version
|
|
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
|
|
version_name = f"ssl-{cert_id}-create-{int(time.time())}"
|
|
logger.info(f"SSL CONFIG DEBUG: Creating version '{version_name}' with hash {config_hash[:8]}...")
|
|
|
|
# Also log if this SSL certificate will be used
|
|
ssl_usage = await conn.fetch("""
|
|
SELECT f.name as frontend_name, f.ssl_enabled, f.ssl_certificate_id
|
|
FROM frontends f
|
|
WHERE f.cluster_id = $1 AND f.is_active = TRUE
|
|
AND (f.ssl_certificate_id = $2 OR ($3 = TRUE))
|
|
""", cluster_id, cert_id, certificate.is_global)
|
|
|
|
logger.info(f"SSL CONFIG DEBUG: This SSL certificate will be used by {len(ssl_usage)} frontends:")
|
|
for usage in ssl_usage:
|
|
logger.info(f"SSL CONFIG DEBUG: - Frontend '{usage['frontend_name']}' (ssl_enabled: {usage['ssl_enabled']}, cert_id: {usage['ssl_certificate_id']})")
|
|
|
|
# Get system admin user ID for created_by
|
|
admin_user_id = await conn.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
|
|
|
|
# Try with status field first, fallback to old behavior if field doesn't exist
|
|
try:
|
|
config_version_id = await conn.fetchval("""
|
|
INSERT INTO config_versions
|
|
(cluster_id, version_name, config_content, checksum, created_by, is_active, status)
|
|
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING')
|
|
RETURNING id
|
|
""", cluster_id, version_name, config_content, config_hash, admin_user_id)
|
|
|
|
logger.info(f"APPLY WORKFLOW: Created PENDING config version {version_name} for cluster {cluster_id}")
|
|
|
|
# Don't notify agents yet - wait for manual Apply
|
|
sync_results = [{'node': 'pending', 'success': True, 'version': version_name, 'status': 'PENDING', 'message': 'SSL certificate created. Click Apply to activate.'}]
|
|
|
|
except Exception as status_error:
|
|
logger.warning(f"FALLBACK: Status field not available, using old immediate-apply behavior: {status_error}")
|
|
# Fallback to old behavior without status field
|
|
config_version_id = await conn.fetchval("""
|
|
INSERT INTO config_versions
|
|
(cluster_id, version_name, config_content, checksum, created_by, is_active)
|
|
VALUES ($1, $2, $3, $4, $5, TRUE)
|
|
RETURNING id
|
|
""", certificate.cluster_id, version_name, config_content, config_hash, admin_user_id)
|
|
|
|
# Deactivate previous versions for this cluster
|
|
await conn.execute("""
|
|
UPDATE config_versions
|
|
SET is_active = FALSE
|
|
WHERE cluster_id = $1 AND id != $2
|
|
""", certificate.cluster_id, config_version_id)
|
|
|
|
sync_results = [{'node': f'cluster-{certificate.cluster_id}', 'success': True, 'version': version_name, 'message': 'SSL certificate created immediately (fallback mode)'}]
|
|
logger.info(f"FALLBACK: Using immediate-apply, agents notified")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Cluster config update failed for SSL certificate {certificate.name}: {e}")
|
|
# Still return success for database save, but with sync warning
|
|
sync_results = [{'node': 'cluster', 'success': False, 'error': str(e)}]
|
|
|
|
await close_database_connection(conn)
|
|
|
|
# Log user activity
|
|
if current_user and current_user.get('id'):
|
|
await log_user_activity(
|
|
user_id=current_user['id'],
|
|
action='create',
|
|
resource_type='ssl_certificate',
|
|
resource_id=str(cert_id),
|
|
details={
|
|
'certificate_name': certificate.name,
|
|
'domain': cert_info.get('primary_domain', 'unknown'),
|
|
'is_global': certificate.is_global,
|
|
'cluster_ids': certificate.cluster_ids,
|
|
'sync_results': len(sync_results)
|
|
},
|
|
ip_address=str(request.client.host) if request.client else None,
|
|
user_agent=request.headers.get('user-agent')
|
|
)
|
|
|
|
return {
|
|
"message": f"SSL certificate '{certificate.name}' created successfully",
|
|
"certificate_id": cert_id,
|
|
"sync_results": sync_results
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error creating SSL certificate: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.get("/certificates/{cert_id}")
|
|
async def get_ssl_certificate(cert_id: int, authorization: str = Header(None)):
|
|
"""Get specific SSL certificate details"""
|
|
try:
|
|
# Get current user for authentication
|
|
current_user = await get_current_user_from_token(authorization)
|
|
|
|
conn = await get_database_connection()
|
|
|
|
# Get certificate details with cluster associations
|
|
certificate = await conn.fetchrow("""
|
|
SELECT s.id, s.name, s.primary_domain as domain, s.all_domains, s.certificate_content,
|
|
s.private_key_content, s.chain_content, s.expiry_date, s.issuer, s.status,
|
|
s.days_until_expiry, s.fingerprint, s.cluster_id, s.usage_type, s.source, s.is_active, s.created_at, s.updated_at,
|
|
CASE
|
|
WHEN NOT EXISTS (SELECT 1 FROM ssl_certificate_clusters WHERE ssl_certificate_id = s.id) THEN TRUE
|
|
ELSE FALSE
|
|
END as is_global,
|
|
COALESCE(
|
|
array_agg(DISTINCT scc.cluster_id ORDER BY scc.cluster_id) FILTER (WHERE scc.cluster_id IS NOT NULL),
|
|
ARRAY[]::int[]
|
|
) as cluster_ids
|
|
FROM ssl_certificates s
|
|
LEFT JOIN ssl_certificate_clusters scc ON s.id = scc.ssl_certificate_id
|
|
WHERE s.id = $1
|
|
GROUP BY s.id, s.name, s.primary_domain, s.all_domains, s.certificate_content,
|
|
s.private_key_content, s.chain_content, s.expiry_date, s.issuer, s.status,
|
|
s.days_until_expiry, s.fingerprint, s.cluster_id, s.usage_type, s.source, s.is_active, s.created_at, s.updated_at
|
|
""", cert_id)
|
|
|
|
if not certificate:
|
|
await close_database_connection(conn)
|
|
raise HTTPException(status_code=404, detail="SSL certificate not found")
|
|
|
|
# Validate user access to cluster
|
|
if certificate['cluster_id']:
|
|
await validate_user_cluster_access(current_user['id'], certificate['cluster_id'], conn)
|
|
|
|
# For now, assume no pending changes (SSL config versions need separate implementation)
|
|
pending_versions = 0
|
|
|
|
# Query usage: which frontends reference this certificate
|
|
used_by_frontends = await conn.fetch("""
|
|
SELECT f.id, f.name, hc.name as cluster_name
|
|
FROM frontends f
|
|
LEFT JOIN haproxy_clusters hc ON f.cluster_id = hc.id
|
|
WHERE f.is_active = TRUE AND (
|
|
f.ssl_certificate_id = $1
|
|
OR f.ssl_certificate_ids @> to_jsonb($1)
|
|
)
|
|
ORDER BY f.name
|
|
""", cert_id)
|
|
|
|
# Query usage: which backend servers reference this certificate
|
|
used_by_servers = await conn.fetch("""
|
|
SELECT bs.id, bs.server_name, bs.backend_name, hc.name as cluster_name
|
|
FROM backend_servers bs
|
|
LEFT JOIN haproxy_clusters hc ON bs.cluster_id = hc.id
|
|
WHERE bs.is_active = TRUE AND bs.ssl_certificate_id = $1
|
|
ORDER BY bs.backend_name, bs.server_name
|
|
""", cert_id)
|
|
|
|
await close_database_connection(conn)
|
|
|
|
# Convert record to dict and handle JSON fields safely
|
|
cert_dict = dict(certificate)
|
|
|
|
# Parse all_domains JSON safely
|
|
if cert_dict.get('all_domains'):
|
|
try:
|
|
if isinstance(cert_dict['all_domains'], str):
|
|
cert_dict['all_domains'] = json.loads(cert_dict['all_domains'])
|
|
except (json.JSONDecodeError, TypeError):
|
|
cert_dict['all_domains'] = []
|
|
else:
|
|
cert_dict['all_domains'] = []
|
|
|
|
cert_dict['has_pending_config'] = pending_versions > 0
|
|
|
|
cert_dict['usage_count'] = len(used_by_frontends) + len(used_by_servers)
|
|
cert_dict['used_by_frontends'] = [
|
|
{'id': f['id'], 'name': f['name'], 'cluster_name': f['cluster_name']}
|
|
for f in used_by_frontends
|
|
]
|
|
cert_dict['used_by_servers'] = [
|
|
{'id': s['id'], 'server_name': s['server_name'],
|
|
'backend_name': s['backend_name'], 'cluster_name': s['cluster_name']}
|
|
for s in used_by_servers
|
|
]
|
|
|
|
return cert_dict
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting SSL certificate: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.get("/certificates/{cert_id}/config-versions")
|
|
async def get_ssl_certificate_config_versions(cert_id: int):
|
|
"""Get config version history for specific SSL certificate"""
|
|
try:
|
|
conn = await get_database_connection()
|
|
|
|
# Get certificate info first
|
|
certificate = await conn.fetchrow("SELECT name, cluster_id FROM ssl_certificates WHERE id = $1", cert_id)
|
|
if not certificate:
|
|
await close_database_connection(conn)
|
|
raise HTTPException(status_code=404, detail="SSL certificate not found")
|
|
|
|
# Get all APPLIED config versions that are related to this SSL certificate
|
|
versions = await conn.fetch("""
|
|
SELECT cv.id, cv.version_name, cv.description, cv.status, cv.is_active,
|
|
cv.created_at, cv.file_size, cv.checksum,
|
|
u.username as created_by_username
|
|
FROM config_versions cv
|
|
LEFT JOIN users u ON cv.created_by = u.id
|
|
WHERE cv.cluster_id = $1 AND cv.status = 'APPLIED'
|
|
AND cv.version_name ~ $2
|
|
ORDER BY cv.created_at DESC
|
|
""", certificate['cluster_id'], f"ssl-{cert_id}-")
|
|
|
|
await close_database_connection(conn)
|
|
|
|
# Convert to list of dicts
|
|
result = []
|
|
for version in versions:
|
|
version_dict = dict(version)
|
|
version_dict['created_at'] = version['created_at'].isoformat().replace('+00:00', 'Z') if version['created_at'] else None
|
|
result.append(version_dict)
|
|
|
|
return {
|
|
"certificate_name": certificate['name'],
|
|
"cluster_id": certificate['cluster_id'],
|
|
"versions": result
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching SSL certificate config versions: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.put("/certificates/{cert_id}")
|
|
async def update_ssl_certificate(cert_id: int, certificate: SSLCertificateUpdate, request: Request, authorization: str = Header(None)):
|
|
"""Update existing SSL certificate with content parsing and multi-cluster support"""
|
|
try:
|
|
# Get current user for activity logging
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
|
|
# Check permission for SSL update
|
|
has_permission = await check_user_permission(current_user["id"], "ssl", "update")
|
|
if not has_permission:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Insufficient permissions: ssl.update required"
|
|
)
|
|
|
|
conn = await get_database_connection()
|
|
|
|
# PHASE 2: Get FULL SSL certificate record for snapshot (ALL fields)
|
|
# CRITICAL: Use SELECT * to capture all fields for rollback
|
|
existing = await conn.fetchrow("""
|
|
SELECT * FROM ssl_certificates WHERE id = $1
|
|
""", cert_id)
|
|
|
|
# Also check if certificate is global (for logging)
|
|
is_global = await conn.fetchval("""
|
|
SELECT NOT EXISTS (SELECT 1 FROM ssl_certificate_clusters WHERE ssl_certificate_id = $1)
|
|
""", cert_id)
|
|
|
|
if not existing:
|
|
await close_database_connection(conn)
|
|
raise HTTPException(status_code=404, detail="SSL certificate not found")
|
|
|
|
# Bulgu #63 (round-22 audit) — grandfather the existing
|
|
# certificate name. Only enforce the path-traversal guard
|
|
# when the operator actually renames the cert. If they
|
|
# leave `name` at its current value (or omit it), let the
|
|
# update proceed regardless of whether the legacy name
|
|
# conforms to the post-Bulgu-#21 character set. Otherwise
|
|
# legacy uploads with `cert (1).pem` / `*.example.com` etc.
|
|
# would be permanently un-updatable from the manual SSL UI.
|
|
if certificate.name is not None and certificate.name != existing["name"]:
|
|
_assert_safe_cert_name(certificate.name)
|
|
|
|
# Protect ACME-managed certificates from manual content edits
|
|
if existing.get('source') == 'letsencrypt':
|
|
content_fields_changed = any([
|
|
certificate.certificate_content,
|
|
certificate.private_key_content,
|
|
certificate.chain_content
|
|
])
|
|
if content_fields_changed:
|
|
await close_database_connection(conn)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="ACME-managed certificates cannot be manually edited. Use the ACME Automation page to renew this certificate."
|
|
)
|
|
|
|
logger.info(f"SSL UPDATE: Updating certificate '{existing['name']}' (ID: {cert_id}, is_global: {is_global})")
|
|
|
|
# Validate cluster access for multi-cluster security
|
|
cluster_id = existing['cluster_id'] or certificate.cluster_id
|
|
if cluster_id:
|
|
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
|
|
|
|
# Track if content is being updated (important for parsing and agent sync)
|
|
content_updated = False
|
|
|
|
# Parse certificate if content is being updated
|
|
cert_info = None
|
|
if certificate.certificate_content or certificate.private_key_content:
|
|
content_updated = True
|
|
logger.info(f"SSL UPDATE: Content update detected, will parse certificate")
|
|
|
|
# Use new content if provided, otherwise use existing
|
|
cert_content = certificate.certificate_content or existing['certificate_content']
|
|
key_content = certificate.private_key_content or existing['private_key_content']
|
|
chain_content = certificate.chain_content if certificate.chain_content is not None else existing['chain_content']
|
|
|
|
# Parse SSL certificate to extract domain and expiry information
|
|
try:
|
|
cert_info = parse_ssl_certificate(cert_content)
|
|
|
|
if cert_info.get("error"):
|
|
await close_database_connection(conn)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid SSL certificate: {cert_info['error']}"
|
|
)
|
|
|
|
logger.info(f"SSL UPDATE: Certificate parsed - domain: {cert_info['primary_domain']}, expiry: {cert_info['expiry_date']}")
|
|
except Exception as parse_error:
|
|
logger.error(f"SSL parsing failed: {parse_error}")
|
|
await close_database_connection(conn)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"SSL certificate parsing error: {str(parse_error)}"
|
|
)
|
|
|
|
# Validate private key (only if provided - server SSL may not have private key)
|
|
if key_content and not validate_private_key(key_content):
|
|
await close_database_connection(conn)
|
|
raise HTTPException(status_code=400, detail="Invalid private key format")
|
|
|
|
# Validate certificate chain (if provided)
|
|
if chain_content and not validate_certificate_chain(chain_content):
|
|
await close_database_connection(conn)
|
|
raise HTTPException(status_code=400, detail="Invalid certificate chain format")
|
|
|
|
# Build dynamic update query
|
|
update_fields = []
|
|
update_values = []
|
|
param_count = 1
|
|
|
|
# SECURITY: SSL certificate name is IMMUTABLE after creation
|
|
# Name is used as filesystem path (/etc/ssl/haproxy/{name}.pem)
|
|
# Changing name would break file system references and HAProxy config
|
|
if certificate.name and certificate.name != existing["name"]:
|
|
await close_database_connection(conn)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="SSL certificate name cannot be changed after creation. The name is used as file path on agent servers. Only certificate content (certificate, private key, chain) can be updated."
|
|
)
|
|
|
|
# FIXED: Use correct field names from model
|
|
if certificate.certificate_content:
|
|
update_fields.append(f"certificate_content = ${param_count}")
|
|
update_values.append(certificate.certificate_content)
|
|
param_count += 1
|
|
|
|
if certificate.private_key_content:
|
|
update_fields.append(f"private_key_content = ${param_count}")
|
|
update_values.append(certificate.private_key_content)
|
|
param_count += 1
|
|
|
|
if certificate.chain_content is not None:
|
|
update_fields.append(f"chain_content = ${param_count}")
|
|
update_values.append(certificate.chain_content)
|
|
param_count += 1
|
|
|
|
# If content was updated and parsed, update derived fields
|
|
if cert_info:
|
|
# Extract parsed information
|
|
primary_domain = cert_info["primary_domain"]
|
|
all_domains = cert_info["all_domains"]
|
|
expiry_date = cert_info["expiry_date"]
|
|
issuer = cert_info["issuer"]
|
|
status = cert_info["status"]
|
|
fingerprint = cert_info["fingerprint"]
|
|
|
|
# Ensure expiry_date is timezone-aware and convert to naive UTC
|
|
if expiry_date:
|
|
try:
|
|
if expiry_date.tzinfo is None:
|
|
expiry_date = expiry_date.replace(tzinfo=timezone.utc)
|
|
expiry_date = expiry_date.astimezone(timezone.utc).replace(tzinfo=None)
|
|
except Exception as tz_error:
|
|
logger.error(f"Timezone conversion failed: {tz_error}")
|
|
expiry_date = None
|
|
|
|
# Recalculate days_until_expiry and status
|
|
if expiry_date:
|
|
try:
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
days_until_expiry = (expiry_date - now).days
|
|
|
|
if days_until_expiry < 0:
|
|
status = "expired"
|
|
elif days_until_expiry < 30:
|
|
status = "expiring_soon"
|
|
else:
|
|
status = "valid"
|
|
|
|
logger.info(f"SSL UPDATE: Recalculated days_until_expiry={days_until_expiry}, status={status}")
|
|
except Exception as calc_error:
|
|
logger.error(f"Days calculation failed: {calc_error}")
|
|
days_until_expiry = 0
|
|
else:
|
|
days_until_expiry = 0
|
|
|
|
# Add parsed fields to update
|
|
update_fields.append(f"primary_domain = ${param_count}")
|
|
update_values.append(primary_domain)
|
|
param_count += 1
|
|
|
|
update_fields.append(f"all_domains = ${param_count}")
|
|
update_values.append(json.dumps(all_domains))
|
|
param_count += 1
|
|
|
|
update_fields.append(f"expiry_date = ${param_count}")
|
|
update_values.append(expiry_date)
|
|
param_count += 1
|
|
|
|
update_fields.append(f"issuer = ${param_count}")
|
|
update_values.append(issuer)
|
|
param_count += 1
|
|
|
|
update_fields.append(f"status = ${param_count}")
|
|
update_values.append(status)
|
|
param_count += 1
|
|
|
|
update_fields.append(f"fingerprint = ${param_count}")
|
|
update_values.append(fingerprint)
|
|
param_count += 1
|
|
|
|
update_fields.append(f"days_until_expiry = ${param_count}")
|
|
update_values.append(days_until_expiry)
|
|
param_count += 1
|
|
|
|
if certificate.cluster_id is not None:
|
|
update_fields.append(f"cluster_id = ${param_count}")
|
|
update_values.append(certificate.cluster_id)
|
|
param_count += 1
|
|
|
|
if certificate.usage_type is not None:
|
|
update_fields.append(f"usage_type = ${param_count}")
|
|
update_values.append(certificate.usage_type)
|
|
param_count += 1
|
|
|
|
# CRITICAL: If content was updated, set last_config_status to PENDING
|
|
# This signals agents that they need to fetch the updated SSL certificate
|
|
if content_updated:
|
|
update_fields.append(f"last_config_status = ${param_count}")
|
|
update_values.append('PENDING')
|
|
param_count += 1
|
|
logger.info(f"SSL UPDATE: Setting last_config_status to PENDING for agent sync")
|
|
|
|
# Always update timestamp (critical for agent incremental sync)
|
|
update_fields.append("updated_at = CURRENT_TIMESTAMP")
|
|
|
|
if update_fields:
|
|
# Update certificate
|
|
update_values.append(cert_id)
|
|
await conn.execute(f"""
|
|
UPDATE ssl_certificates SET {', '.join(update_fields)}
|
|
WHERE id = ${param_count}
|
|
""", *update_values)
|
|
logger.info(f"SSL UPDATE: Database updated for certificate ID {cert_id}")
|
|
|
|
# Determine affected clusters for config version creation
|
|
affected_clusters = []
|
|
|
|
if is_global:
|
|
# For global SSL certificates, update ALL active clusters
|
|
clusters = await conn.fetch("SELECT id FROM haproxy_clusters WHERE is_active = TRUE")
|
|
affected_clusters = [cluster['id'] for cluster in clusters]
|
|
logger.info(f"SSL UPDATE (GLOBAL): Will create config versions for {len(affected_clusters)} clusters")
|
|
else:
|
|
# For cluster-specific SSL certificates, get associated clusters
|
|
clusters = await conn.fetch("""
|
|
SELECT cluster_id FROM ssl_certificate_clusters
|
|
WHERE ssl_certificate_id = $1
|
|
""", cert_id)
|
|
affected_clusters = [cluster['cluster_id'] for cluster in clusters]
|
|
logger.info(f"SSL UPDATE (CLUSTER): Will create config versions for specific clusters: {affected_clusters}")
|
|
|
|
# Create config versions for affected clusters (only if content was updated)
|
|
sync_results = []
|
|
if content_updated and affected_clusters:
|
|
logger.info(f"SSL UPDATE: Creating config versions for {len(affected_clusters)} affected clusters")
|
|
|
|
for cluster_id in affected_clusters:
|
|
try:
|
|
# Get current active config for diff comparison
|
|
# First try: Most recent APPLIED config
|
|
old_config = await conn.fetchrow("""
|
|
SELECT config_content, version_name, status
|
|
FROM config_versions
|
|
WHERE cluster_id = $1 AND status = 'APPLIED' AND config_content IS NOT NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
""", cluster_id)
|
|
|
|
# Fallback: If no APPLIED config, get most recent consolidated config (any status)
|
|
if not old_config:
|
|
old_config = await conn.fetchrow("""
|
|
SELECT config_content, version_name, status
|
|
FROM config_versions
|
|
WHERE cluster_id = $1
|
|
AND config_content IS NOT NULL
|
|
AND version_name LIKE 'apply-consolidated-%'
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
""", cluster_id)
|
|
if old_config:
|
|
logger.info(f"SSL UPDATE DIFF: No APPLIED config, using most recent consolidated: {old_config['version_name']} (status: {old_config['status']})")
|
|
|
|
old_config_content = old_config['config_content'] if old_config else ""
|
|
|
|
if old_config:
|
|
logger.info(f"SSL UPDATE DIFF: Captured pre-apply snapshot for cluster {cluster_id}, version: {old_config['version_name']}, length: {len(old_config_content)}")
|
|
else:
|
|
logger.warning(f"SSL UPDATE DIFF: No previous config found for cluster {cluster_id}! This is likely the first configuration.")
|
|
|
|
# Generate new HAProxy config
|
|
config_content = await generate_haproxy_config_for_cluster(cluster_id)
|
|
|
|
# PHASE 2: Create entity snapshot for rollback
|
|
from utils.entity_snapshot import save_entity_snapshot
|
|
|
|
# Prepare new values (all fields being updated)
|
|
new_values = {
|
|
"certificate_content": certificate.certificate_content,
|
|
"private_key_content": certificate.private_key_content,
|
|
"chain_content": certificate.chain_content,
|
|
}
|
|
# Add parsed fields if content was parsed
|
|
if cert_info:
|
|
new_values["primary_domain"] = cert_info["primary_domain"]
|
|
new_values["all_domains"] = cert_info["all_domains"]
|
|
new_values["expiry_date"] = cert_info["expiry_date"]
|
|
new_values["issuer"] = cert_info.get("issuer")
|
|
new_values["fingerprint"] = cert_info.get("fingerprint")
|
|
new_values["days_until_expiry"] = cert_info.get("days_until_expiry")
|
|
|
|
entity_snapshot_metadata = await save_entity_snapshot(
|
|
conn=conn,
|
|
entity_type="ssl_certificate",
|
|
entity_id=cert_id,
|
|
old_values=existing, # Full record from line 703
|
|
new_values=new_values,
|
|
operation="UPDATE"
|
|
)
|
|
|
|
# Create metadata with old config for diff display + entity snapshot for rollback
|
|
metadata = {
|
|
'pre_apply_snapshot': old_config_content, # For diff viewer
|
|
**entity_snapshot_metadata # For rollback
|
|
}
|
|
|
|
# Create new config version
|
|
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
|
|
version_name = f"ssl-{cert_id}-update-{int(time.time())}"
|
|
|
|
# Get system admin user ID for created_by
|
|
admin_user_id = await conn.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
|
|
|
|
# Try with status field first, fallback to old behavior if field doesn't exist
|
|
try:
|
|
config_version_id = await conn.fetchval("""
|
|
INSERT INTO config_versions
|
|
(cluster_id, version_name, config_content, checksum, created_by, is_active, status, metadata)
|
|
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING', $6)
|
|
RETURNING id
|
|
""", cluster_id, version_name, config_content, config_hash, admin_user_id, json.dumps(metadata))
|
|
|
|
logger.info(f"APPLY WORKFLOW: Created PENDING config version {version_name} for cluster {cluster_id}")
|
|
sync_results.append({
|
|
'cluster_id': cluster_id,
|
|
'node': 'pending',
|
|
'success': True,
|
|
'version': version_name,
|
|
'status': 'PENDING',
|
|
'message': 'SSL certificate updated. Click Apply to activate.'
|
|
})
|
|
|
|
except Exception as status_error:
|
|
logger.warning(f"FALLBACK: Status field not available for cluster {cluster_id}, using old immediate-apply behavior")
|
|
# Fallback to old behavior without status field (still save metadata for diff)
|
|
config_version_id = await conn.fetchval("""
|
|
INSERT INTO config_versions
|
|
(cluster_id, version_name, config_content, checksum, created_by, is_active, metadata)
|
|
VALUES ($1, $2, $3, $4, $5, TRUE, $6)
|
|
RETURNING id
|
|
""", cluster_id, version_name, config_content, config_hash, admin_user_id, json.dumps(metadata))
|
|
|
|
# Deactivate previous versions for this cluster
|
|
await conn.execute("""
|
|
UPDATE config_versions
|
|
SET is_active = FALSE
|
|
WHERE cluster_id = $1 AND id != $2
|
|
""", cluster_id, config_version_id)
|
|
|
|
sync_results.append({
|
|
'cluster_id': cluster_id,
|
|
'node': f'cluster-{cluster_id}',
|
|
'success': True,
|
|
'version': version_name,
|
|
'message': 'SSL certificate updated immediately (fallback mode)'
|
|
})
|
|
logger.info(f"FALLBACK: Using immediate-apply for cluster {cluster_id}, agents notified")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Cluster {cluster_id} config update failed for SSL certificate update: {e}")
|
|
sync_results.append({
|
|
'cluster_id': cluster_id,
|
|
'node': f'cluster-{cluster_id}',
|
|
'success': False,
|
|
'error': str(e)
|
|
})
|
|
elif not content_updated:
|
|
logger.info(f"SSL UPDATE: Only metadata updated (no content change), skipping config version creation")
|
|
sync_results = [{'message': 'SSL metadata updated (no content change, no config version needed)'}]
|
|
else:
|
|
logger.info(f"SSL UPDATE: No affected clusters found")
|
|
sync_results = [{'message': 'SSL updated but no clusters affected'}]
|
|
|
|
await close_database_connection(conn)
|
|
|
|
# Log user activity
|
|
if current_user and current_user.get('id'):
|
|
await log_user_activity(
|
|
user_id=current_user['id'],
|
|
action='update',
|
|
resource_type='ssl_certificate',
|
|
resource_id=str(cert_id),
|
|
details={
|
|
'certificate_name': certificate.name or existing['name'],
|
|
'is_global': is_global,
|
|
'content_updated': content_updated,
|
|
'affected_clusters': len(affected_clusters) if affected_clusters else 0,
|
|
'sync_results': len(sync_results)
|
|
},
|
|
ip_address=str(request.client.host) if request.client else None,
|
|
user_agent=request.headers.get('user-agent')
|
|
)
|
|
|
|
return {
|
|
"message": f"SSL certificate updated successfully",
|
|
"content_updated": content_updated,
|
|
"affected_clusters": len(affected_clusters) if affected_clusters else 0,
|
|
"sync_results": sync_results
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error updating SSL certificate: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.delete("/certificates/{cert_id}")
|
|
async def delete_ssl_certificate(
|
|
cert_id: int,
|
|
request: Request,
|
|
force: bool = False,
|
|
authorization: str = Header(None),
|
|
):
|
|
"""Delete SSL certificate.
|
|
|
|
Bulgu #74 (round-22 audit) — pre-fix this handler did a hard
|
|
`DELETE FROM ssl_certificates WHERE id=$1` without ANY
|
|
referential check. The `frontends` table carries the cert
|
|
reference in two places — `ssl_certificate_id` (legacy
|
|
single-cert column) and `ssl_certificate_ids` JSONB array
|
|
(multi-cert support) — and NEITHER has a database-level
|
|
foreign-key constraint, so the cert vanished and the
|
|
referencing rows kept the now-dangling integer. The next
|
|
config regen then either:
|
|
* silently dropped the bind line and the frontend went from
|
|
HTTPS to HTTP (silent security downgrade), OR
|
|
* rendered `bind :443 ssl crt /etc/ssl/haproxy/<gone>.pem`
|
|
which the agent's `haproxy -c` rejected at reload time,
|
|
breaking the entire cluster's config-apply pipeline.
|
|
Either failure mode was hard to attribute back to the cert
|
|
deletion long after the fact.
|
|
|
|
The new contract:
|
|
* **default**: 409 Conflict if any active frontend / backend
|
|
server still references the cert; the response body lists
|
|
the offending entities so the operator can detach the
|
|
cert from each one first.
|
|
* **`?force=true`**: NULL out the references (both legacy
|
|
column and JSONB array) BEFORE deleting the row,
|
|
emitting a clear audit-log warning per affected
|
|
frontend. The frontends are marked PENDING so the next
|
|
apply re-renders without the cert.
|
|
ACME-managed certs (`letsencrypt_order_id IS NOT NULL`) keep
|
|
their `ON DELETE SET NULL` FK on `letsencrypt_orders`, but we
|
|
also surface a warning so the operator knows the renewal
|
|
loop will re-issue if the order is still active.
|
|
"""
|
|
try:
|
|
# Get current user for activity logging
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
current_user = await get_current_user_from_token(authorization)
|
|
|
|
# Check permission for SSL delete
|
|
has_permission = await check_user_permission(current_user["id"], "ssl", "delete")
|
|
if not has_permission:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Insufficient permissions: ssl.delete required"
|
|
)
|
|
|
|
conn = await get_database_connection()
|
|
|
|
# Check if certificate exists and get cluster_id
|
|
certificate = await conn.fetchrow("SELECT name, cluster_id FROM ssl_certificates WHERE id = $1", cert_id)
|
|
if not certificate:
|
|
await close_database_connection(conn)
|
|
raise HTTPException(status_code=404, detail="SSL certificate not found")
|
|
|
|
cluster_id = certificate['cluster_id']
|
|
cert_name = certificate['name']
|
|
logger.info(f"SSL certificate delete: cert_id={cert_id}, name={cert_name}, cluster_id={cluster_id}, force={force}")
|
|
|
|
# Bulgu #74 — referential check across both legacy and
|
|
# multi-cert columns + backend-server SSL references.
|
|
frontend_refs = await conn.fetch("""
|
|
SELECT id, name, cluster_id
|
|
FROM frontends
|
|
WHERE is_active = TRUE
|
|
AND (ssl_certificate_id = $1
|
|
OR ssl_certificate_ids @> to_jsonb($1::int))
|
|
ORDER BY cluster_id, name
|
|
""", cert_id)
|
|
backend_server_refs = await conn.fetch("""
|
|
SELECT id, server_name, backend_name, cluster_id
|
|
FROM backend_servers
|
|
WHERE is_active = TRUE AND ssl_certificate_id = $1
|
|
ORDER BY cluster_id, backend_name, server_name
|
|
""", cert_id)
|
|
|
|
if (frontend_refs or backend_server_refs) and not force:
|
|
await close_database_connection(conn)
|
|
fe_list = [
|
|
{"id": r["id"], "name": r["name"], "cluster_id": r["cluster_id"]}
|
|
for r in frontend_refs
|
|
]
|
|
be_list = [
|
|
{
|
|
"id": r["id"], "server_name": r["server_name"],
|
|
"backend_name": r["backend_name"],
|
|
"cluster_id": r["cluster_id"],
|
|
}
|
|
for r in backend_server_refs
|
|
]
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"message": (
|
|
f"SSL certificate '{cert_name}' is still in use by "
|
|
f"{len(fe_list)} frontend(s) and {len(be_list)} backend "
|
|
f"server(s). Detach the certificate from each one first, "
|
|
f"or call DELETE again with ?force=true to NULL the "
|
|
f"references and proceed (this will mark every affected "
|
|
f"entity as PENDING and silently drop the HTTPS bind "
|
|
f"on `force` — only use force when you've verified "
|
|
f"the certificate is no longer needed)."
|
|
),
|
|
"frontends": fe_list,
|
|
"backend_servers": be_list,
|
|
},
|
|
)
|
|
|
|
if force and (frontend_refs or backend_server_refs):
|
|
logger.warning(
|
|
f"SSL DELETE FORCE: cert_id={cert_id} name={cert_name!r} — "
|
|
f"nulling references in {len(frontend_refs)} frontend(s) "
|
|
f"and {len(backend_server_refs)} backend server(s)"
|
|
)
|
|
# Clear legacy single-cert column.
|
|
await conn.execute("""
|
|
UPDATE frontends
|
|
SET ssl_certificate_id = NULL,
|
|
last_config_status = 'PENDING',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE ssl_certificate_id = $1
|
|
""", cert_id)
|
|
# Clear the multi-cert JSONB array entry. `-` operator
|
|
# on JSONB removes ALL occurrences of the integer.
|
|
await conn.execute("""
|
|
UPDATE frontends
|
|
SET ssl_certificate_ids = COALESCE(ssl_certificate_ids, '[]'::jsonb)
|
|
- $1::text,
|
|
last_config_status = 'PENDING',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE ssl_certificate_ids @> to_jsonb($1::int)
|
|
""", str(cert_id))
|
|
# backend_servers.ssl_certificate_id has an
|
|
# `ON DELETE SET NULL` FK constraint, so the DELETE
|
|
# below will null it. We still bump
|
|
# `last_config_status` so the next apply re-renders.
|
|
for srv in backend_server_refs:
|
|
await conn.execute("""
|
|
UPDATE backend_servers
|
|
SET last_config_status = 'PENDING',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
""", srv['id'])
|
|
|
|
# Delete certificate
|
|
await conn.execute("DELETE FROM ssl_certificates WHERE id = $1", cert_id)
|
|
|
|
# If cluster_id provided, create new config version for agents
|
|
sync_results = []
|
|
if cluster_id:
|
|
try:
|
|
# Generate new HAProxy config without this certificate
|
|
config_content = await generate_haproxy_config_for_cluster(cluster_id)
|
|
|
|
# Create new config version
|
|
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
|
|
version_name = f"ssl-{cert_id}-delete-{int(time.time())}"
|
|
|
|
# Get system admin user ID for created_by
|
|
admin_user_id = await conn.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
|
|
|
|
# Try with status field first, fallback to old behavior if field doesn't exist
|
|
try:
|
|
config_version_id = await conn.fetchval("""
|
|
INSERT INTO config_versions
|
|
(cluster_id, version_name, config_content, checksum, created_by, is_active, status)
|
|
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING')
|
|
RETURNING id
|
|
""", cluster_id, version_name, config_content, config_hash, admin_user_id)
|
|
|
|
logger.info(f"APPLY WORKFLOW: Created PENDING config version {version_name} for cluster {cluster_id}")
|
|
|
|
# Don't notify agents yet - wait for manual Apply
|
|
sync_results = [{'node': 'pending', 'success': True, 'version': version_name, 'status': 'PENDING', 'message': 'SSL certificate deletion created. Click Apply to activate.'}]
|
|
|
|
except Exception as status_error:
|
|
logger.warning(f"FALLBACK: Status field not available, using old immediate-apply behavior: {status_error}")
|
|
# Fallback to old behavior without status field
|
|
config_version_id = await conn.fetchval("""
|
|
INSERT INTO config_versions
|
|
(cluster_id, version_name, config_content, checksum, created_by, is_active)
|
|
VALUES ($1, $2, $3, $4, $5, TRUE)
|
|
RETURNING id
|
|
""", cluster_id, version_name, config_content, config_hash, admin_user_id)
|
|
|
|
# Deactivate previous versions for this cluster
|
|
await conn.execute("""
|
|
UPDATE config_versions
|
|
SET is_active = FALSE
|
|
WHERE cluster_id = $1 AND id != $2
|
|
""", cluster_id, config_version_id)
|
|
|
|
# Use old notification behavior - notify agents immediately
|
|
from agent_notifications import notify_agents_config_change
|
|
sync_results = await notify_agents_config_change(cluster_id, version_name)
|
|
logger.info(f"FALLBACK: Using immediate-apply, agents notified")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Cluster config update failed after deleting SSL certificate {cert_name}: {e}")
|
|
# Still return success for database save, but with sync warning
|
|
sync_results = [{'node': 'cluster', 'success': False, 'error': str(e)}]
|
|
|
|
await close_database_connection(conn)
|
|
|
|
# Log user activity
|
|
if current_user and current_user.get('id'):
|
|
await log_user_activity(
|
|
user_id=current_user['id'],
|
|
action='delete',
|
|
resource_type='ssl_certificate',
|
|
resource_id=str(cert_id),
|
|
details={
|
|
'certificate_name': cert_name,
|
|
'cluster_id': cluster_id,
|
|
'sync_results': len(sync_results)
|
|
},
|
|
ip_address=str(request.client.host) if request.client else None,
|
|
user_agent=request.headers.get('user-agent')
|
|
)
|
|
|
|
return {
|
|
"message": f"SSL certificate '{cert_name}' deleted successfully",
|
|
"sync_results": sync_results
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e)) |