feat: Add SSL usage_type (Frontend/Server) with conditional private key requirement

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
This commit is contained in:
taylanbakircioglu
2025-11-11 03:41:47 +03:00
parent ab4dac78d6
commit 281e23ea27
10 changed files with 384 additions and 149 deletions
+82 -49
View File
@@ -168,7 +168,8 @@ async def ensure_agents_table():
'ssl_certificates': {
'expires_at': "ALTER TABLE ssl_certificates ADD COLUMN expires_at TIMESTAMP;",
'auto_renew': "ALTER TABLE ssl_certificates ADD COLUMN auto_renew BOOLEAN DEFAULT FALSE;",
'cluster_id': "ALTER TABLE ssl_certificates ADD COLUMN cluster_id INTEGER;"
'cluster_id': "ALTER TABLE ssl_certificates ADD COLUMN cluster_id INTEGER;",
'usage_type': "ALTER TABLE ssl_certificates ADD COLUMN usage_type VARCHAR(50) DEFAULT 'frontend';"
},
'agents': {
'architecture': "ALTER TABLE agents ADD COLUMN architecture VARCHAR(50) DEFAULT 'amd64';",
@@ -202,6 +203,16 @@ async def ensure_agents_table():
for table_name, columns in missing_columns.items():
for col_name, alter_query in columns.items():
try:
# Check if table exists first
table_exists = await conn.fetchval(f"""
SELECT 1 FROM information_schema.tables
WHERE table_name='{table_name}'
""")
if not table_exists:
logger.warning(f"Table '{table_name}' does not exist yet, skipping column '{col_name}'")
continue
column_exists = await conn.fetchval(f"""
SELECT 1 FROM information_schema.columns
WHERE table_name='{table_name}' AND column_name='{col_name}'
@@ -209,9 +220,14 @@ async def ensure_agents_table():
if not column_exists:
logger.info(f"Adding missing column '{col_name}' to table '{table_name}'...")
await conn.execute(alter_query)
logger.info(f"Successfully added column '{col_name}' to '{table_name}'.")
logger.info(f"✅ Successfully added column '{col_name}' to '{table_name}'.")
else:
logger.debug(f"Column '{col_name}' already exists in '{table_name}'")
except Exception as col_error:
logger.warning(f"Could not add column '{col_name}' to '{table_name}': {col_error}")
# Log error but don't crash - some columns may be optional
logger.error(f"❌ Could not add column '{col_name}' to '{table_name}': {col_error}")
# For critical columns like usage_type, we should still try to continue
# The error will be visible in logs for manual intervention
# Fix agents table ID issue - recreate if needed
try:
@@ -363,10 +379,11 @@ async def ensure_agents_table():
name VARCHAR(100) NOT NULL UNIQUE,
domain VARCHAR(255) NOT NULL,
certificate_content TEXT NOT NULL,
private_key_content TEXT NOT NULL,
private_key_content TEXT,
chain_content TEXT,
expires_at TIMESTAMP,
expiry_date DATE,
usage_type VARCHAR(50) DEFAULT 'frontend',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -750,13 +767,30 @@ async def ensure_ssl_certificates_new_columns():
logger.info("ssl_certificates table does not exist, skipping migration")
return
# CRITICAL: Make private_key_content nullable for server SSL support
# Check if private_key_content exists and is NOT NULL
private_key_not_null = await conn.fetchval("""
SELECT is_nullable = 'NO'
FROM information_schema.columns
WHERE table_name = 'ssl_certificates' AND column_name = 'private_key_content'
""")
if private_key_not_null:
logger.info("🔧 Altering private_key_content to allow NULL (for server SSL)...")
await conn.execute("""
ALTER TABLE ssl_certificates
ALTER COLUMN private_key_content DROP NOT NULL
""")
logger.info("✅ private_key_content is now nullable (server SSL support)")
# Add missing columns if they don't exist
columns_to_add = [
("issuer", "VARCHAR(255)"),
("fingerprint", "VARCHAR(128)"),
("status", "VARCHAR(20) DEFAULT 'valid'"),
("days_until_expiry", "INTEGER DEFAULT 0"),
("all_domains", "JSONB DEFAULT '[]'")
("all_domains", "JSONB DEFAULT '[]'"),
("usage_type", "VARCHAR(50) DEFAULT 'frontend'") # CRITICAL: Frontend/Server SSL differentiation
]
for column_name, column_def in columns_to_add:
@@ -1681,41 +1715,54 @@ async def create_initial_system_data(conn):
]
for role_data in system_roles:
await conn.execute("""
INSERT INTO roles (name, display_name, description, permissions, is_active, is_system)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (name) DO NOTHING
""",
role_data['name'],
role_data['display_name'],
role_data['description'],
role_data['permissions'], # Direct list, not JSON string
True,
True
)
# Check if role exists first (safer than ON CONFLICT)
role_exists = await conn.fetchval("SELECT id FROM roles WHERE name = $1", role_data['name'])
if not role_exists:
await conn.execute("""
INSERT INTO roles (name, display_name, description, permissions, is_active, is_system)
VALUES ($1, $2, $3, $4, $5, $6)
""",
role_data['name'],
role_data['display_name'],
role_data['description'],
json.dumps(role_data['permissions']), # FIXED: Convert list to JSON string for JSONB column
True,
True
)
logger.info("✅ System roles created")
# Create default admin user
import bcrypt
password_hash = bcrypt.hashpw('admin123'.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
admin_id = await conn.fetchval("""
INSERT INTO users (username, email, password_hash, full_name, is_active, is_admin, is_verified)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (username) DO NOTHING
RETURNING id
""", 'admin', 'admin@haproxy-openmanager.local', password_hash, 'System Administrator', True, True, True)
# Check if admin user already exists first (safer than ON CONFLICT)
admin_id = await conn.fetchval("SELECT id FROM users WHERE username = 'admin'")
if not admin_id:
password_hash = bcrypt.hashpw('admin123'.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
admin_id = await conn.fetchval("""
INSERT INTO users (username, email, password_hash, full_name, is_active, is_admin, is_verified)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
""", 'admin', 'admin@haproxy-openmanager.local', password_hash, 'System Administrator', True, True, True)
if admin_id:
# Assign super_admin role to admin user
super_admin_role = await conn.fetchval("SELECT id FROM roles WHERE name = 'super_admin'")
if super_admin_role:
await conn.execute("""
INSERT INTO user_roles (user_id, role_id, assigned_by)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, role_id) DO NOTHING
""", admin_id, super_admin_role, admin_id)
# Check if role assignment exists
role_assigned = await conn.fetchval("""
SELECT id FROM user_roles
WHERE user_id = $1 AND role_id = $2
""", admin_id, super_admin_role)
if not role_assigned:
await conn.execute("""
INSERT INTO user_roles (user_id, role_id, assigned_by)
VALUES ($1, $2, $3)
""", admin_id, super_admin_role, admin_id)
logger.info("✅ Default admin user created")
else:
@@ -2073,7 +2120,7 @@ async def create_essential_tables(conn):
name VARCHAR(100) NOT NULL,
primary_domain VARCHAR(255),
certificate_content TEXT NOT NULL,
private_key_content TEXT NOT NULL,
private_key_content TEXT,
chain_content TEXT,
expiry_date TIMESTAMP,
issuer TEXT,
@@ -2083,6 +2130,7 @@ async def create_essential_tables(conn):
all_domains JSONB DEFAULT '[]'::jsonb,
cluster_id INTEGER REFERENCES haproxy_clusters(id) ON DELETE SET NULL,
last_config_status config_status DEFAULT 'APPLIED',
usage_type VARCHAR(50) DEFAULT 'frontend',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -2184,25 +2232,10 @@ async def create_essential_tables(conn):
logger.error(f"Failed to create essential tables: {e}")
raise
async def ensure_agents_table():
"""Ensures that all necessary tables, columns, and types exist in the database."""
conn = None
try:
conn = await get_database_connection()
# First, create all essential tables if they don't exist
await create_essential_tables(conn)
# Then, ensure additional columns exist in existing tables
# Status column is already created in create_essential_tables
logger.info("✅ Database schema initialization completed")
except Exception as e:
logger.error(f"Database schema initialization failed: {e}")
raise
finally:
if conn:
await close_database_connection(conn)
# REMOVED: Duplicate function that was overriding the correct ensure_agents_table() at line 33
# The detailed version (line 33) includes missing_columns loop with usage_type migration
# This basic version only called create_essential_tables() without column additions
# Keeping it removed to ensure proper migration execution
async def ensure_frontends_ssl_columns():
"""Add missing SSL columns to frontends table for proper SSL certificate support"""
+47 -14
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, validator
from pydantic import BaseModel, field_validator, model_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
import logging
@@ -8,12 +8,21 @@ logger = logging.getLogger(__name__)
class SSLCertificateCreate(BaseModel):
name: str
certificate_content: str # PEM format certificate
private_key_content: str # PEM format private key
private_key_content: Optional[str] = None # PEM format private key (optional for server SSL)
chain_content: Optional[str] = None # PEM format certificate chain (optional)
cluster_ids: Optional[List[int]] = None # List of cluster IDs for multi-cluster support
is_global: bool = False # True for global SSL certificates
usage_type: str = "frontend" # "frontend" or "server" - determines if private key is required
@validator('certificate_content')
@field_validator('usage_type')
@classmethod
def validate_usage_type(cls, v):
if v not in ['frontend', 'server']:
raise ValueError('usage_type must be either "frontend" or "server"')
return v
@field_validator('certificate_content')
@classmethod
def validate_certificate(cls, v):
if not v or not v.strip():
raise ValueError('Certificate content is required')
@@ -25,19 +34,33 @@ class SSLCertificateCreate(BaseModel):
return v
@validator('private_key_content')
def validate_private_key(cls, v):
if not v or not v.strip():
raise ValueError('Private key content is required')
@model_validator(mode='after')
def validate_private_key_based_on_usage(self):
"""Private key is required for frontend SSL, optional for server SSL"""
usage_type = self.usage_type
private_key = self.private_key_content
# Basic PEM format check
v = v.strip()
if '-----BEGIN' not in v or '-----END' not in v:
raise ValueError('Private key must be in PEM format')
if usage_type == 'frontend':
# Frontend SSL requires private key
if not private_key or not private_key.strip():
raise ValueError('Private key is required for frontend SSL certificates')
# Basic PEM format check
private_key = private_key.strip()
if '-----BEGIN' not in private_key or '-----END' not in private_key:
raise ValueError('Private key must be in PEM format')
elif usage_type == 'server':
# Server SSL - private key is optional
if private_key and private_key.strip():
# If provided, validate format
private_key = private_key.strip()
if '-----BEGIN' not in private_key or '-----END' not in private_key:
raise ValueError('Private key must be in PEM format')
return v
return self
@validator('chain_content')
@field_validator('chain_content')
@classmethod
def validate_chain(cls, v):
if v and v.strip():
# Basic PEM format check for chain
@@ -52,6 +75,14 @@ class SSLCertificateUpdate(BaseModel):
private_key_content: Optional[str] = None
chain_content: Optional[str] = None
cluster_id: Optional[int] = None
usage_type: Optional[str] = None # "frontend" or "server"
@field_validator('usage_type')
@classmethod
def validate_usage_type(cls, v):
if v is not None and v not in ['frontend', 'server']:
raise ValueError('usage_type must be either "frontend" or "server"')
return v
class SSLCertificate(BaseModel):
id: int
@@ -59,7 +90,7 @@ class SSLCertificate(BaseModel):
domain: str # Auto-parsed from certificate
all_domains: List[str] = [] # All domains from SAN + CN
certificate_content: str
private_key_content: str
private_key_content: Optional[str] = None # Optional for server SSL
chain_content: Optional[str] = None
expiry_date: Optional[datetime] = None # Auto-parsed from certificate
issuer: Optional[str] = None # Auto-parsed from certificate
@@ -67,6 +98,7 @@ class SSLCertificate(BaseModel):
days_until_expiry: int = 0
fingerprint: Optional[str] = None
cluster_id: Optional[int] = None
usage_type: str = "frontend" # "frontend" or "server"
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
has_pending_config: bool = False
@@ -86,6 +118,7 @@ class SSLCertificateResponse(BaseModel):
issuer: Optional[str] = None
fingerprint: Optional[str] = None
cluster_id: Optional[int] = None
usage_type: str = "frontend" # "frontend" or "server"
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
has_pending_config: bool = False
+2 -1
View File
@@ -1524,7 +1524,7 @@ async def get_agent_ssl_certificates(agent_name: str, since: Optional[str] = Non
ssl_query = """
SELECT DISTINCT s.id, s.name, s.primary_domain as domain, s.certificate_content,
s.private_key_content, s.chain_content, s.expiry_date, s.status, s.fingerprint,
s.created_at, s.updated_at, s.last_config_status
s.usage_type, s.created_at, s.updated_at, s.last_config_status
FROM ssl_certificates s
LEFT JOIN ssl_certificate_clusters scc ON s.id = scc.ssl_certificate_id
WHERE s.is_active = TRUE
@@ -1584,6 +1584,7 @@ async def get_agent_ssl_certificates(agent_name: str, since: Optional[str] = Non
"certificate_content": cert['certificate_content'],
"private_key_content": cert['private_key_content'],
"chain_content": cert['chain_content'],
"usage_type": cert.get('usage_type', 'frontend'), # Default to frontend for backward compatibility
"file_path": f"/etc/ssl/haproxy/{cert['name']}.pem",
"expiry_date": cert['expiry_date'].isoformat() if cert['expiry_date'] else None,
"status": cert['status'],
+52 -25
View File
@@ -90,7 +90,7 @@ async def validate_user_cluster_access(user_id: int, cluster_id: int, conn):
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):
async def get_ssl_certificates(cluster_id: Optional[int] = None, usage_type: Optional[str] = None):
"""
# Get SSL Certificates
@@ -140,9 +140,23 @@ async def get_ssl_certificates(cluster_id: Optional[int] = None):
# Query with new schema fields - show cluster-specific + global SSLs
if cluster_id:
certificates = await conn.fetch("""
# 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.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'
@@ -169,24 +183,30 @@ async def get_ssl_certificates(cluster_id: Optional[int] = None):
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 s.is_active = TRUE
AND (
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
)
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.created_at, s.updated_at
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
""", cluster_id)
""", *params)
else:
certificates = await conn.fetch("""
# 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,
days_until_expiry, all_domains, is_active, cluster_id, usage_type,
created_at, updated_at
FROM ssl_certificates
WHERE is_active = TRUE
WHERE {where_clause}
ORDER BY created_at DESC
""")
""", *params)
await close_database_connection(conn)
@@ -215,6 +235,7 @@ async def get_ssl_certificates(cluster_id: Optional[int] = None):
'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,
@@ -282,8 +303,8 @@ async def create_ssl_certificate(certificate: SSLCertificateCreate, request: Req
detail=f"SSL certificate parsing error: {str(parse_error)}"
)
# Validate private key
if not validate_private_key(certificate.private_key_content):
# 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")
@@ -336,10 +357,11 @@ async def create_ssl_certificate(certificate: SSLCertificateCreate, request: Req
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.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")
@@ -411,13 +433,13 @@ async def create_ssl_certificate(certificate: SSLCertificateCreate, request: Req
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
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') # Always NULL for cluster_id, use junction table
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:
@@ -561,7 +583,7 @@ async def get_ssl_certificate(cert_id: int, authorization: str = Header(None)):
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.created_at, s.updated_at,
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
@@ -575,7 +597,7 @@ async def get_ssl_certificate(cert_id: int, authorization: str = Header(None)):
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.created_at, s.updated_at
s.days_until_expiry, s.fingerprint, s.cluster_id, s.usage_type, s.created_at, s.updated_at
""", cert_id)
if not certificate:
@@ -677,7 +699,7 @@ async def update_ssl_certificate(cert_id: int, certificate: SSLCertificateUpdate
# 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,
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
@@ -731,8 +753,8 @@ async def update_ssl_certificate(cert_id: int, certificate: SSLCertificateUpdate
detail=f"SSL certificate parsing error: {str(parse_error)}"
)
# Validate private key
if not validate_private_key(key_content):
# 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")
@@ -846,6 +868,11 @@ async def update_ssl_certificate(cert_id: int, certificate: SSLCertificateUpdate
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:
+51 -15
View File
@@ -1204,8 +1204,9 @@ deploy_ssl_certificates() {
local key_content=$(echo "$cert_data" | jq -r '.private_key_content // ""')
local chain_content=$(echo "$cert_data" | jq -r '.chain_content // ""')
local cert_status=$(echo "$cert_data" | jq -r '.status // "unknown"')
local usage_type=$(echo "$cert_data" | jq -r '.usage_type // "frontend"')
log "INFO" "Deploying SSL certificate: $cert_name ($cert_domain) - Status: $cert_status"
log "INFO" "Deploying SSL certificate: $cert_name ($cert_domain) - Usage: $usage_type - Status: $cert_status"
# Validate certificate content
if [[ -z "$cert_content" || "$cert_content" == "null" ]]; then
@@ -1213,9 +1214,14 @@ deploy_ssl_certificates() {
continue
fi
if [[ -z "$key_content" || "$key_content" == "null" ]]; then
log "ERROR" "Private key content is empty for $cert_name, skipping"
continue
# CRITICAL: Private key validation depends on usage_type
# - Frontend SSL: private key REQUIRED (for HAProxy bind)
# - Server SSL: private key OPTIONAL (CA certificate only for backend verification)
if [[ "$usage_type" == "frontend" ]]; then
if [[ -z "$key_content" || "$key_content" == "null" ]]; then
log "ERROR" "Private key is required for Frontend SSL: $cert_name, skipping"
continue
fi
fi
# Create combined PEM file (HAProxy format: cert + key + chain)
@@ -1224,9 +1230,11 @@ deploy_ssl_certificates() {
# Write certificate
echo "$cert_content" > "$temp_cert_file"
# Append private key
echo "" >> "$temp_cert_file"
echo "$key_content" >> "$temp_cert_file"
# Append private key (only if provided - server SSL may not have it)
if [[ -n "$key_content" && "$key_content" != "null" ]]; then
echo "" >> "$temp_cert_file"
echo "$key_content" >> "$temp_cert_file"
fi
# Append certificate chain if present
if [[ -n "$chain_content" && "$chain_content" != "null" ]]; then
@@ -2464,8 +2472,18 @@ CONFIG_RESPONSE_EOF
key_content=$(echo "$cert_data" | jq -r '.private_key_content // ""' 2>/dev/null)
chain_content=$(echo "$cert_data" | jq -r '.chain_content // ""' 2>/dev/null)
cert_file_path=$(echo "$cert_data" | jq -r '.file_path // ""' 2>/dev/null)
usage_type=$(echo "$cert_data" | jq -r '.usage_type // "frontend"' 2>/dev/null)
if [[ -n "$cert_content" && "$cert_content" != "null" && -n "$key_content" && "$key_content" != "null" ]]; then
# CRITICAL: Certificate content is always required, but private key depends on usage_type
# - Frontend SSL: requires both cert AND private key (for bind ssl crt)
# - Server SSL: requires only cert (CA file for backend verification)
if [[ -n "$cert_content" && "$cert_content" != "null" ]]; then
# For frontend SSL, private key is required
if [[ "$usage_type" == "frontend" && ( -z "$key_content" || "$key_content" == "null" ) ]]; then
log "WARN" "Skipping Frontend SSL $cert_name: private key missing"
continue
fi
# Use original path or fallback
if [[ -n "$cert_file_path" ]]; then
ssl_file="$cert_file_path"
@@ -2475,11 +2493,23 @@ CONFIG_RESPONSE_EOF
# Check if certificate already exists and is unchanged
# IMPORTANT: Calculate checksum with EXACT same format as file will be written
# Format: cert + blank line + key [+ blank line + chain if exists]
if [[ -n "$chain_content" && "$chain_content" != "null" ]]; then
new_content=$(printf "%s\n\n%s\n\n%s" "$cert_content" "$key_content" "$chain_content")
# Format depends on what content is available:
# - Frontend SSL: cert + key [+ chain]
# - Server SSL: cert [+ chain] (no private key)
if [[ -n "$key_content" && "$key_content" != "null" ]]; then
# Has private key - frontend SSL
if [[ -n "$chain_content" && "$chain_content" != "null" ]]; then
new_content=$(printf "%s\n\n%s\n\n%s" "$cert_content" "$key_content" "$chain_content")
else
new_content=$(printf "%s\n\n%s" "$cert_content" "$key_content")
fi
else
new_content=$(printf "%s\n\n%s" "$cert_content" "$key_content")
# No private key - server SSL (CA cert only)
if [[ -n "$chain_content" && "$chain_content" != "null" ]]; then
new_content=$(printf "%s\n\n%s" "$cert_content" "$chain_content")
else
new_content="$cert_content"
fi
fi
existing_checksum=""
@@ -2491,11 +2521,17 @@ CONFIG_RESPONSE_EOF
# Only deploy if certificate is new or changed
if [[ "$existing_checksum" != "$new_checksum" ]]; then
# Create combined PEM file (HAProxy format: cert + key + chain)
# Create combined PEM file
# Format depends on usage_type:
# - Frontend SSL: cert + key + [chain]
# - Server SSL: cert + [chain] (CA cert only, no private key)
{
echo "$cert_content"
echo ""
echo "$key_content"
# Only add private key if provided (server SSL may not have it)
if [[ -n "$key_content" && "$key_content" != "null" ]]; then
echo ""
echo "$key_content"
fi
# Add chain if present
if [[ -n "$chain_content" && "$chain_content" != "null" ]]; then
echo ""
+51 -15
View File
@@ -1193,8 +1193,9 @@ deploy_ssl_certificates() {
local key_content=$(echo "$cert_data" | jq -r '.private_key_content // ""')
local chain_content=$(echo "$cert_data" | jq -r '.chain_content // ""')
local cert_status=$(echo "$cert_data" | jq -r '.status // "unknown"')
local usage_type=$(echo "$cert_data" | jq -r '.usage_type // "frontend"')
log "INFO" "Deploying SSL certificate: $cert_name ($cert_domain) - Status: $cert_status"
log "INFO" "Deploying SSL certificate: $cert_name ($cert_domain) - Usage: $usage_type - Status: $cert_status"
# Validate certificate content
if [[ -z "$cert_content" || "$cert_content" == "null" ]]; then
@@ -1202,9 +1203,14 @@ deploy_ssl_certificates() {
continue
fi
if [[ -z "$key_content" || "$key_content" == "null" ]]; then
log "ERROR" "Private key content is empty for $cert_name, skipping"
continue
# CRITICAL: Private key validation depends on usage_type
# - Frontend SSL: private key REQUIRED (for HAProxy bind)
# - Server SSL: private key OPTIONAL (CA certificate only for backend verification)
if [[ "$usage_type" == "frontend" ]]; then
if [[ -z "$key_content" || "$key_content" == "null" ]]; then
log "ERROR" "Private key is required for Frontend SSL: $cert_name, skipping"
continue
fi
fi
# Create combined PEM file (HAProxy format: cert + key + chain)
@@ -1213,9 +1219,11 @@ deploy_ssl_certificates() {
# Write certificate
echo "$cert_content" > "$temp_cert_file"
# Append private key
echo "" >> "$temp_cert_file"
echo "$key_content" >> "$temp_cert_file"
# Append private key (only if provided - server SSL may not have it)
if [[ -n "$key_content" && "$key_content" != "null" ]]; then
echo "" >> "$temp_cert_file"
echo "$key_content" >> "$temp_cert_file"
fi
# Append certificate chain if present
if [[ -n "$chain_content" && "$chain_content" != "null" ]]; then
@@ -2421,8 +2429,18 @@ CONFIG_RESPONSE_EOF
key_content=$(echo "$cert_data" | jq -r '.private_key_content // ""' 2>/dev/null)
chain_content=$(echo "$cert_data" | jq -r '.chain_content // ""' 2>/dev/null)
cert_file_path=$(echo "$cert_data" | jq -r '.file_path // ""' 2>/dev/null)
usage_type=$(echo "$cert_data" | jq -r '.usage_type // "frontend"' 2>/dev/null)
if [[ -n "$cert_content" && "$cert_content" != "null" && -n "$key_content" && "$key_content" != "null" ]]; then
# CRITICAL: Certificate content is always required, but private key depends on usage_type
# - Frontend SSL: requires both cert AND private key (for bind ssl crt)
# - Server SSL: requires only cert (CA file for backend verification)
if [[ -n "$cert_content" && "$cert_content" != "null" ]]; then
# For frontend SSL, private key is required
if [[ "$usage_type" == "frontend" && ( -z "$key_content" || "$key_content" == "null" ) ]]; then
log "WARN" "Skipping Frontend SSL $cert_name: private key missing"
continue
fi
# Use alternative path if original fails
if [[ -n "$cert_file_path" ]]; then
ssl_file="$SSL_DIR/$(basename "$cert_file_path")"
@@ -2432,11 +2450,23 @@ CONFIG_RESPONSE_EOF
# Check if certificate already exists and is unchanged
# IMPORTANT: Calculate checksum with EXACT same format as file will be written
# Format: cert + blank line + key [+ blank line + chain if exists]
if [[ -n "$chain_content" && "$chain_content" != "null" ]]; then
new_content=$(printf "%s\n\n%s\n\n%s" "$cert_content" "$key_content" "$chain_content")
# Format depends on what content is available:
# - Frontend SSL: cert + key [+ chain]
# - Server SSL: cert [+ chain] (no private key)
if [[ -n "$key_content" && "$key_content" != "null" ]]; then
# Has private key - frontend SSL
if [[ -n "$chain_content" && "$chain_content" != "null" ]]; then
new_content=$(printf "%s\n\n%s\n\n%s" "$cert_content" "$key_content" "$chain_content")
else
new_content=$(printf "%s\n\n%s" "$cert_content" "$key_content")
fi
else
new_content=$(printf "%s\n\n%s" "$cert_content" "$key_content")
# No private key - server SSL (CA cert only)
if [[ -n "$chain_content" && "$chain_content" != "null" ]]; then
new_content=$(printf "%s\n\n%s" "$cert_content" "$chain_content")
else
new_content="$cert_content"
fi
fi
existing_checksum=""
@@ -2448,11 +2478,17 @@ CONFIG_RESPONSE_EOF
# Only deploy if certificate is new or changed
if [[ "$existing_checksum" != "$new_checksum" ]]; then
# Create combined PEM file (HAProxy format: cert + key + chain)
# Create combined PEM file
# Format depends on usage_type:
# - Frontend SSL: cert + key + [chain]
# - Server SSL: cert + [chain] (CA cert only, no private key)
{
echo "$cert_content"
echo ""
echo "$key_content"
# Only add private key if provided (server SSL may not have it)
if [[ -n "$key_content" && "$key_content" != "null" ]]; then
echo ""
echo "$key_content"
fi
# Add chain if present
if [[ -n "$chain_content" && "$chain_content" != "null" ]]; then
echo ""
+12 -11
View File
@@ -12,15 +12,15 @@ ENV npm_config_fund=false \
# Copy package files
COPY package*.json ./
# Install dependencies with network timeout optimization
# Install dependencies with aggressive network timeout optimization
RUN npm cache clean --force \
&& npm config set maxsockets 3 \
&& npm config set fetch-retries 5 \
&& npm config set fetch-retry-mintimeout 20000 \
&& npm config set fetch-retry-maxtimeout 120000 \
&& npm config set fetch-timeout 300000 \
&& npm config set maxsockets 1 \
&& npm config set fetch-retries 10 \
&& npm config set fetch-retry-mintimeout 30000 \
&& npm config set fetch-retry-maxtimeout 300000 \
&& npm config set fetch-timeout 600000 \
&& npm config set registry https://registry.npmjs.org/ \
&& npm install --no-audit --no-fund --progress=false --legacy-peer-deps --timeout=300000 \
&& npm install --no-audit --no-fund --progress=false --legacy-peer-deps --timeout=600000 \
&& npm cache clean --force
# Copy source code
@@ -42,10 +42,11 @@ WORKDIR /app
# Keep npm quiet and reduce file operations in runtime image too
ENV npm_config_fund=false npm_config_audit=false npm_config_progress=false
# Install serve to serve static files with timeout optimization
RUN npm config set fetch-timeout 300000 \
&& npm config set fetch-retries 5 \
&& npm install -g serve --no-fund --no-audit --progress=false --timeout=300000
# Install serve to serve static files with aggressive timeout optimization
RUN npm config set fetch-timeout 600000 \
&& npm config set fetch-retries 10 \
&& npm config set maxsockets 1 \
&& npm install -g serve --no-fund --no-audit --progress=false --timeout=600000
# Create non-root user for OpenShift compatibility (Alpine-based)
RUN addgroup -g 1001 nodejs && \
+2 -2
View File
@@ -184,8 +184,8 @@ const BackendServers = () => {
try {
const token = localStorage.getItem('token');
// CRITICAL FIX: Use same endpoint as Frontend (/api/ssl/certificates not /api/ssl-certificates)
const response = await axios.get(`/api/ssl/certificates?cluster_id=${selectedCluster.id}`, {
// CRITICAL: Only fetch server SSL certificates (usage_type=server) for backend servers
const response = await axios.get(`/api/ssl/certificates?cluster_id=${selectedCluster.id}&usage_type=server`, {
headers: {
'Authorization': `Bearer ${token}`,
'Cache-Control': 'no-cache, no-store, must-revalidate',
@@ -290,12 +290,13 @@ const FrontendManagement = () => {
setSslLoading(true);
try {
console.log('🔍 SSL FETCH DEBUG: Fetching certificates for cluster:', selectedCluster.id);
console.log('🔍 SSL FETCH DEBUG: Fetching FRONTEND certificates for cluster:', selectedCluster.id);
const token = localStorage.getItem('token');
console.log('🔍 SSL FETCH DEBUG: Token exists:', token ? 'Yes' : 'No');
const response = await axios.get(`/api/ssl/certificates?cluster_id=${selectedCluster.id}`, {
// CRITICAL: Only fetch frontend SSL certificates (usage_type=frontend)
const response = await axios.get(`/api/ssl/certificates?cluster_id=${selectedCluster.id}&usage_type=frontend`, {
headers: {
'Authorization': `Bearer ${token}`,
'Cache-Control': 'no-cache, no-store, must-revalidate',
+82 -15
View File
@@ -221,7 +221,8 @@ const SSLManagement = () => {
form.resetFields();
// Set default values for new certificate
form.setFieldsValue({
ssl_type: 'cluster'
ssl_type: 'cluster',
usage_type: 'frontend' // Default to frontend SSL
});
setSelectedCertificate(null);
setModalVisible(true);
@@ -249,7 +250,8 @@ const SSLManagement = () => {
private_key_content: cert.private_key_content,
chain_content: cert.chain_content,
ssl_type: cert.is_global ? 'global' : 'cluster',
cluster_ids: cert.is_global ? null : cert.cluster_ids
cluster_ids: cert.is_global ? null : cert.cluster_ids,
usage_type: cert.usage_type || 'frontend'
});
setSelectedCertificate(cert);
@@ -313,7 +315,8 @@ const SSLManagement = () => {
private_key_content: values.private_key_content,
chain_content: values.chain_content,
is_global: values.ssl_type === 'global',
cluster_ids: values.ssl_type === 'global' ? null : values.cluster_ids
cluster_ids: values.ssl_type === 'global' ? null : values.cluster_ids,
usage_type: values.usage_type || 'frontend'
};
const response = isEditing
? await axios.put(`/api/ssl/certificates/${selectedCertificate.id}`, payload)
@@ -441,7 +444,7 @@ const SSLManagement = () => {
),
},
{
title: 'Type',
title: 'Scope',
dataIndex: 'ssl_type',
key: 'ssl_type',
render: (type, record) => {
@@ -453,6 +456,19 @@ const SSLManagement = () => {
);
},
},
{
title: 'Usage',
dataIndex: 'usage_type',
key: 'usage_type',
render: (usage_type) => {
const isFrontend = usage_type === 'frontend';
return (
<Tag color={isFrontend ? 'purple' : 'orange'}>
{isFrontend ? 'Frontend SSL' : 'Server SSL'}
</Tag>
);
},
},
{
title: 'Sync Status',
key: 'sync_status',
@@ -809,14 +825,27 @@ const SSLManagement = () => {
{/* SSL Type and Cluster Selection */}
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="usage_type"
label="SSL Usage Type"
rules={[{ required: true, message: 'Please select usage type' }]}
tooltip="Frontend SSL requires private key, Server SSL does not"
>
<Select placeholder="Select usage type">
<Select.Option value="frontend">Frontend SSL (HAProxy Listen)</Select.Option>
<Select.Option value="server">Server SSL (Backend Verification)</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="ssl_type"
label="SSL Certificate Type"
rules={[{ required: true, message: 'Please select SSL type' }]}
label="SSL Certificate Scope"
rules={[{ required: true, message: 'Please select SSL scope' }]}
>
<Select
placeholder="Select SSL type"
placeholder="Select SSL scope"
onChange={(value) => {
form.setFieldsValue({ cluster_ids: undefined });
}}
@@ -826,7 +855,7 @@ const SSLManagement = () => {
</Select>
</Form.Item>
</Col>
<Col span={16}>
<Col span={8}>
<Form.Item shouldUpdate={(prevValues, currentValues) => prevValues.ssl_type !== currentValues.ssl_type}>
{({ getFieldValue }) => {
const sslType = getFieldValue('ssl_type');
@@ -890,16 +919,54 @@ MIIDXTCCAkWgAwIBAgIJAKoK/OvD...
</Form.Item>
<Form.Item
name="private_key_content"
label="Private Key Content (PEM Format)"
rules={[{ required: true, message: 'Please enter private key content' }]}
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.usage_type !== currentValues.usage_type}
>
<TextArea
rows={8}
placeholder="-----BEGIN PRIVATE KEY-----
{({ getFieldValue }) => {
const usageType = getFieldValue('usage_type');
const isRequired = usageType === 'frontend';
return (
<Form.Item
name="private_key_content"
label={
<span>
Private Key Content (PEM Format)
{isRequired && <span style={{ color: 'red' }}> *</span>}
{usageType === 'server' && <span style={{ color: '#999', fontWeight: 'normal' }}> - Optional</span>}
</span>
}
rules={[
{
required: isRequired,
message: 'Private key is required for Frontend SSL'
},
{
validator: (_, value) => {
// If value provided, validate format
if (value && value.trim()) {
if (!value.includes('-----BEGIN') || !value.includes('-----END')) {
return Promise.reject('Private key must be in PEM format');
}
}
return Promise.resolve();
}
}
]}
extra={isRequired ?
<span style={{ color: '#ff4d4f' }}>⚠️ Required for Frontend SSL</span> :
<span style={{ color: '#52c41a' }}>✓ Optional for Server SSL (used for backend verification)</span>
}
>
<TextArea
rows={8}
placeholder="-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEF...
-----END PRIVATE KEY-----"
/>
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item