mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-16 23:55:13 +00:00
520b69a1c6
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/*.
1535 lines
75 KiB
Python
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)) |