Files
haproxy-openmanager/backend/models/ssl.py
T
taylanbakircioglu 281e23ea27 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
2025-11-11 03:41:47 +03:00

124 lines
4.9 KiB
Python

from pydantic import BaseModel, field_validator, model_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class SSLCertificateCreate(BaseModel):
name: str
certificate_content: str # PEM format certificate
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
@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')
# Basic PEM format check
v = v.strip()
if '-----BEGIN CERTIFICATE-----' not in v or '-----END CERTIFICATE-----' not in v:
raise ValueError('Certificate must be in PEM format')
return v
@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
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 self
@field_validator('chain_content')
@classmethod
def validate_chain(cls, v):
if v and v.strip():
# Basic PEM format check for chain
if '-----BEGIN CERTIFICATE-----' not in v or '-----END CERTIFICATE-----' not in v:
raise ValueError('Certificate chain must be in PEM format')
return v
class SSLCertificateUpdate(BaseModel):
name: Optional[str] = None
certificate_content: Optional[str] = None
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
name: str
domain: str # Auto-parsed from certificate
all_domains: List[str] = [] # All domains from SAN + CN
certificate_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
status: str # 'valid', 'expiring_soon', 'expired', 'invalid'
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
# Certificate details for UI display
certificate_info: Optional[Dict[str, Any]] = None
class SSLCertificateResponse(BaseModel):
"""Response model for SSL certificate API endpoints"""
id: int
name: str
domain: str
all_domains: List[str] = []
status: str
days_until_expiry: int = 0
expiry_date: Optional[datetime] = None
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