diff --git a/backend/database/migrations.py b/backend/database/migrations.py
index 5b7fc9b..c0c61b1 100644
--- a/backend/database/migrations.py
+++ b/backend/database/migrations.py
@@ -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"""
diff --git a/backend/models/ssl.py b/backend/models/ssl.py
index 7a2fa46..f20a25b 100644
--- a/backend/models/ssl.py
+++ b/backend/models/ssl.py
@@ -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
\ No newline at end of file
diff --git a/backend/routers/agent.py b/backend/routers/agent.py
index 9577f4e..a326f51 100644
--- a/backend/routers/agent.py
+++ b/backend/routers/agent.py
@@ -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'],
diff --git a/backend/routers/ssl.py b/backend/routers/ssl.py
index b5165bf..5d00315 100644
--- a/backend/routers/ssl.py
+++ b/backend/routers/ssl.py
@@ -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:
diff --git a/backend/utils/agent_scripts/linux_install.sh b/backend/utils/agent_scripts/linux_install.sh
index f4c4f10..93aa4aa 100644
--- a/backend/utils/agent_scripts/linux_install.sh
+++ b/backend/utils/agent_scripts/linux_install.sh
@@ -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 ""
diff --git a/backend/utils/agent_scripts/macos_install.sh b/backend/utils/agent_scripts/macos_install.sh
index d1c6ac1..5f150fa 100644
--- a/backend/utils/agent_scripts/macos_install.sh
+++ b/backend/utils/agent_scripts/macos_install.sh
@@ -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 ""
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index a8caace..018c77a 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -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 && \
diff --git a/frontend/src/components/BackendServers.js b/frontend/src/components/BackendServers.js
index 48a0b21..0a49634 100644
--- a/frontend/src/components/BackendServers.js
+++ b/frontend/src/components/BackendServers.js
@@ -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',
diff --git a/frontend/src/components/FrontendManagement.js b/frontend/src/components/FrontendManagement.js
index 520724c..964ff90 100644
--- a/frontend/src/components/FrontendManagement.js
+++ b/frontend/src/components/FrontendManagement.js
@@ -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',
diff --git a/frontend/src/components/SSLManagement.js b/frontend/src/components/SSLManagement.js
index 49e10a2..78da415 100644
--- a/frontend/src/components/SSLManagement.js
+++ b/frontend/src/components/SSLManagement.js
@@ -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 (
+
+ {isFrontend ? 'Frontend SSL' : 'Server SSL'}
+
+ );
+ },
+ },
{
title: 'Sync Status',
key: 'sync_status',
@@ -809,14 +825,27 @@ const SSLManagement = () => {
{/* SSL Type and Cluster Selection */}
+
+
+
+
+
-
+
prevValues.ssl_type !== currentValues.ssl_type}>
{({ getFieldValue }) => {
const sslType = getFieldValue('ssl_type');
@@ -890,16 +919,54 @@ MIIDXTCCAkWgAwIBAgIJAKoK/OvD...
prevValues.usage_type !== currentValues.usage_type}
>
-
+ );
+ }}