Files
taylanbakircioglu 520b69a1c6 fix(security): remediate RCE, missing-auth and SSRF advisories (backend-only, no agent changes)
Addresses three reported advisories, all verified against the code. Fixes are
entirely server-side — deployed agents already send a valid X-API-Key on every
call, so enforcing it does not require any agent-script change or upgrade.

GHSA-7rhv-c5pc-69r8 (CRITICAL RCE — agent script-template poisoning):
- POST/GET /api/agents/script-templates/{platform} now require the agents.version
  permission (was authentication-only), matching POST /versions. Blocks a viewer
  JWT from overwriting the root install/upgrade script.

GHSA-3p5c-m5m4-mjpx (missing authentication):
- Agent data-plane endpoints now REQUIRE a valid X-API-Key (was optional/skipped
  when the header was absent), checked before any DB access: config,
  ssl-certificates (private keys!), upgrade-status, heartbeat (by-name and the
  previously auth-less by-id), configuration pending-requests. Removes keyless
  heartbeat spoofing and keyless rogue-agent auto-registration.
- Operator/UI endpoints now require a JWT: GET /api/agents, the entire
  /api/dashboard-stats router, /api/health/{deep,agents,clusters}, and
  /api/ssl/certificates/{id}/config-versions. The simple /api/health liveness
  probe stays public. Adds shared auth_middleware.require_authenticated_user.

GHSA-3vh4-gvxx-wm2p (SSRF via ACME directory_url):
- New utils/ssrf_guard.py (https-only + public-IP-only, IPv4-pinned, no redirects),
  applied to settings test-connection, acme_service.get_directory and
  _signed_request, and validated at Let's Encrypt account creation. The
  test-connection response no longer reflects arbitrary upstream JSON keys
  (information-disclosure oracle) — only fixed ACME field names.

Verified: full pytest tests/ (1128 passed, 0 failed) + live localtest stack smoke
(valid JWT/key paths return 200/404 as expected; anonymous requests 401; SSRF to
metadata/private/loopback refused). No changes to backend/utils/agent_scripts/*.
2026-07-20 12:45:28 +03:00

1535 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, require_authenticated_user
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",
dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): was unauthenticated
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))