mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-25 11:52:06 +00:00
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:
@@ -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
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
@@ -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 && \
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user