From 281e23ea275f4afacd3eba6a5e39b990369914c4 Mon Sep 17 00:00:00 2001 From: taylanbakircioglu Date: Tue, 11 Nov 2025 03:41:47 +0300 Subject: [PATCH] feat: Add SSL usage_type (Frontend/Server) with conditional private key requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/database/migrations.py | 131 +++++++++++------- backend/models/ssl.py | 61 ++++++-- backend/routers/agent.py | 3 +- backend/routers/ssl.py | 77 ++++++---- backend/utils/agent_scripts/linux_install.sh | 66 +++++++-- backend/utils/agent_scripts/macos_install.sh | 66 +++++++-- frontend/Dockerfile | 23 +-- frontend/src/components/BackendServers.js | 4 +- frontend/src/components/FrontendManagement.js | 5 +- frontend/src/components/SSLManagement.js | 97 +++++++++++-- 10 files changed, 384 insertions(+), 149 deletions(-) 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} > -