mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-22 18:43:26 +00:00
281e23ea27
This is a comprehensive update that adds SSL certificate differentiation for frontend (HAProxy bind) and server (backend verification) use cases. FEATURES: - SSL certificates can be marked as 'frontend' or 'server' usage type - Frontend SSL: Private key REQUIRED (for HAProxy bind ssl crt) - Server SSL: Private key OPTIONAL (CA cert only for backend verification) - UI dropdown for usage type selection - Dynamic form validation based on usage type - Filtering: Frontends see only Frontend SSL, Backends see only Server SSL DATABASE: - Added usage_type column to ssl_certificates (default: 'frontend') - Made private_key_content nullable for server SSL support - Migration automatically runs on pod restart BACKEND: - Pydantic v2 compatibility (@field_validator, @model_validator) - SSL router: usage_type filtering support - Agent endpoint: usage_type field included - Improved migration robustness with better error handling - Fixed duplicate ensure_agents_table() function - Fixed JSONB permissions insert with json.dumps() - Fixed ON CONFLICT constraints with explicit checks FRONTEND: - SSL Management: Usage Type dropdown with visual feedback - Frontend Management: Filters only Frontend SSL certificates - Backend Servers: Filters only Server SSL certificates - Dynamic private key validation (required for Frontend, optional for Server) - Improved form UX with color-coded hints AGENT SCRIPTS (Linux & macOS): - Support for Server SSL without private key - Conditional PEM file creation (cert+key vs cert-only) - usage_type awareness in SSL deployment - Backward compatible with existing Frontend SSL certificates DOCKER: - Increased npm timeout for slow networks (300s → 600s) - Increased fetch-retries (5 → 10) - Reduced maxsockets for stability (3 → 1) All changes are backward compatible. Existing SSL certificates default to 'frontend' type and continue working unchanged. Tested with: HAProxy 2.8+, PostgreSQL 15, React 18
1173 lines
58 KiB
Python
1173 lines
58 KiB
Python
from fastapi import APIRouter, HTTPException, Request, Header, Depends
|
||
from typing import List, Optional
|
||
import logging
|
||
import hashlib
|
||
import time
|
||
import json
|
||
from datetime import datetime, timezone
|
||
|
||
# Import database and models
|
||
from database.connection import get_database_connection, close_database_connection
|
||
from auth_middleware import get_current_user_from_token
|
||
from models.ssl import SSLCertificate, SSLCertificateCreate, SSLCertificateUpdate, SSLCertificateResponse
|
||
from utils.ssl_parser import parse_ssl_certificate, validate_private_key, validate_certificate_chain, format_certificate_info
|
||
from utils.activity_log import log_user_activity
|
||
from services.haproxy_config import generate_haproxy_config_for_cluster
|
||
|
||
router = APIRouter(prefix="/api/ssl", tags=["SSL Certificates"])
|
||
logger = logging.getLogger(__name__)
|
||
|
||
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):
|
||
"""
|
||
# 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]
|
||
}
|
||
]
|
||
```
|
||
"""
|
||
try:
|
||
conn = await get_database_connection()
|
||
|
||
# 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:
|
||
await close_database_connection(conn)
|
||
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.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
|
||
(SELECT cv.status
|
||
FROM config_versions cv
|
||
WHERE cv.cluster_id = $1
|
||
AND cv.version_name LIKE 'ssl-' || s.id || '-%'
|
||
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
|
||
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.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, domain, expiry_date, issuer, fingerprint, status,
|
||
days_until_expiry, all_domains, is_active, cluster_id, usage_type,
|
||
created_at, updated_at
|
||
FROM ssl_certificates
|
||
WHERE {where_clause}
|
||
ORDER BY created_at DESC
|
||
""", *params)
|
||
|
||
await close_database_connection(conn)
|
||
|
||
# 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,
|
||
'last_config_status': cert.get('last_config_status'),
|
||
'has_pending_config': cert.get('has_pending_config', False)
|
||
}
|
||
result.append(cert_dict)
|
||
|
||
return result
|
||
|
||
except Exception as table_error:
|
||
logger.warning(f"Error checking/querying SSL certificates table: {table_error}")
|
||
await close_database_connection(conn)
|
||
return [] # Return empty list if table doesn't exist or has issues
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error fetching SSL certificates: {e}")
|
||
try:
|
||
await close_database_connection(conn)
|
||
except:
|
||
pass
|
||
# Return empty list instead of error to prevent frontend crashes
|
||
return []
|
||
|
||
@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"
|
||
)
|
||
|
||
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
|
||
if existing:
|
||
if existing['is_active']:
|
||
# Active certificate with same name exists
|
||
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 instead of creating new one
|
||
logger.info(f"SSL REACTIVATE: Found soft-deleted SSL '{certificate.name}', reactivating instead of creating new")
|
||
|
||
# Update the existing soft-deleted certificate with new content
|
||
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,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $1
|
||
""", existing['id'], certificate.certificate_content, certificate.private_key_content,
|
||
certificate.chain_content, primary_domain, all_domains, expiry_date, certificate.usage_type)
|
||
|
||
cert_id = existing['id']
|
||
logger.info(f"SSL REACTIVATED: SSL certificate '{certificate.name}' (ID: {cert_id}) reactivated successfully")
|
||
|
||
# Continue with the rest of the creation logic (cluster associations, config versions, etc.)
|
||
# Skip the INSERT part since we're reusing existing certificate
|
||
|
||
# Extract parsed information
|
||
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:
|
||
# Convert to UTC if it has different timezone
|
||
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}")
|
||
# Fallback: set to None if timezone conversion fails
|
||
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
|
||
|
||
# Recalculate status based on 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:
|
||
# Convert timezone-aware datetime to UTC and then make it naive
|
||
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
|
||
|
||
# Debug all values before database insertion
|
||
# 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.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.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
|
||
|
||
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
|
||
|
||
return cert_dict
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error getting SSL certificate: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
@router.get("/certificates/{cert_id}/config-versions")
|
||
async def get_ssl_certificate_config_versions(cert_id: int):
|
||
"""Get config version history for specific SSL certificate"""
|
||
try:
|
||
conn = await get_database_connection()
|
||
|
||
# Get certificate info first
|
||
certificate = await conn.fetchrow("SELECT name, cluster_id FROM ssl_certificates WHERE id = $1", cert_id)
|
||
if not certificate:
|
||
await close_database_connection(conn)
|
||
raise HTTPException(status_code=404, detail="SSL certificate not found")
|
||
|
||
# Get all APPLIED config versions that are related to this SSL certificate
|
||
versions = await conn.fetch("""
|
||
SELECT cv.id, cv.version_name, cv.description, cv.status, cv.is_active,
|
||
cv.created_at, cv.file_size, cv.checksum,
|
||
u.username as created_by_username
|
||
FROM config_versions cv
|
||
LEFT JOIN users u ON cv.created_by = u.id
|
||
WHERE cv.cluster_id = $1 AND cv.status = 'APPLIED'
|
||
AND cv.version_name ~ $2
|
||
ORDER BY cv.created_at DESC
|
||
""", certificate['cluster_id'], f"ssl-{cert_id}-")
|
||
|
||
await close_database_connection(conn)
|
||
|
||
# Convert to list of dicts
|
||
result = []
|
||
for version in versions:
|
||
version_dict = dict(version)
|
||
version_dict['created_at'] = version['created_at'].isoformat().replace('+00:00', 'Z') if version['created_at'] else None
|
||
result.append(version_dict)
|
||
|
||
return {
|
||
"certificate_name": certificate['name'],
|
||
"cluster_id": certificate['cluster_id'],
|
||
"versions": result
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error fetching SSL certificate config versions: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
@router.put("/certificates/{cert_id}")
|
||
async def update_ssl_certificate(cert_id: int, certificate: SSLCertificateUpdate, request: Request, authorization: str = Header(None)):
|
||
"""Update existing SSL certificate with content parsing and multi-cluster support"""
|
||
try:
|
||
# Get current user for activity logging
|
||
from auth_middleware import get_current_user_from_token, check_user_permission
|
||
current_user = await get_current_user_from_token(authorization)
|
||
|
||
# Check permission for SSL update
|
||
has_permission = await check_user_permission(current_user["id"], "ssl", "update")
|
||
if not has_permission:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Insufficient permissions: ssl.update required"
|
||
)
|
||
|
||
conn = await get_database_connection()
|
||
|
||
# Get existing certificate with all details including cluster associations
|
||
existing = await conn.fetchrow("""
|
||
SELECT s.id, s.name, s.cluster_id, s.certificate_content, s.private_key_content, s.chain_content, s.usage_type,
|
||
CASE
|
||
WHEN NOT EXISTS (SELECT 1 FROM ssl_certificate_clusters WHERE ssl_certificate_id = s.id) THEN TRUE
|
||
ELSE FALSE
|
||
END as is_global
|
||
FROM ssl_certificates s
|
||
WHERE s.id = $1
|
||
""", cert_id)
|
||
|
||
if not existing:
|
||
await close_database_connection(conn)
|
||
raise HTTPException(status_code=404, detail="SSL certificate not found")
|
||
|
||
logger.info(f"SSL UPDATE: Updating certificate '{existing['name']}' (ID: {cert_id}, is_global: {existing['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 existing['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)
|
||
|
||
# Create metadata with old config for diff display
|
||
metadata = {
|
||
'entity_type': 'ssl_certificate',
|
||
'entity_id': cert_id,
|
||
'entity_name': existing['name'],
|
||
'pre_apply_snapshot': old_config_content,
|
||
'change_reason': 'SSL certificate content updated'
|
||
}
|
||
|
||
# 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': existing['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, authorization: str = Header(None)):
|
||
"""Delete SSL certificate"""
|
||
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}")
|
||
|
||
# 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)) |