fix(ssl): add change detection and validators for SSL advanced options

Critical additions:
1. Change Detection (backend/routers/config.py)
   - Added SSL advanced options to bulk import change detection logic
   - Detects changes in ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites
   - Detects changes in ssl_min_ver, ssl_max_ver, ssl_strict_sni
   - Changes will now appear in version diff

2. Pydantic Validators (backend/models/frontend.py, backend/models/backend.py)
   - TLS version validator: Only allows valid versions (SSLv3, TLSv1.0-1.3)
   - ALPN protocol validator: Only allows h2, http/1.1, http/1.0, h2c, spdy/*
   - NPN protocol validator: Only allows http/1.1, http/1.0, spdy/*
   - Prevents invalid values from being saved to database
   - HAProxy validation will not fail due to invalid SSL options

Impact:
- Bulk import will correctly detect SSL option changes
- Apply Management diff will show SSL changes
- User cannot enter invalid TLS versions or protocols
- Improved UX with early validation errors

Previous fixes in this series:
- Frontend GET API: Added SSL fields to response
- Bulk Import UPDATE: Added SSL fields to UPDATE statement
- Backend Server GET API: Added SSL fields to response

Test: Bulk import with ALPN change → Should see change in version diff
This commit is contained in:
Taylan Bakırcıoğlu
2025-11-18 20:44:36 +03:00
committed by taylanbakircioglu
parent 3e2f3e3d9f
commit e8690245af
3 changed files with 61 additions and 0 deletions
+8
View File
@@ -25,6 +25,14 @@ class ServerConfig(BaseModel):
fall: Optional[int] = None
rise: Optional[int] = None
is_active: bool = True
@validator('ssl_min_ver', 'ssl_max_ver')
def validate_tls_version(cls, v):
if v is not None:
valid_versions = ['SSLv3', 'TLSv1.0', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']
if v not in valid_versions:
raise ValueError(f'Invalid TLS version: {v}. Must be one of: {", ".join(valid_versions)}')
return v
class BackendConfig(BaseModel):
name: str
+30
View File
@@ -233,6 +233,36 @@ class FrontendConfig(BaseModel):
if v is not None and (v < 1 or v > 100000):
raise ValueError('Max connections must be between 1 and 100000')
return v
@validator('ssl_min_ver', 'ssl_max_ver')
def validate_tls_version(cls, v):
if v is not None:
valid_versions = ['SSLv3', 'TLSv1.0', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']
if v not in valid_versions:
raise ValueError(f'Invalid TLS version: {v}. Must be one of: {", ".join(valid_versions)}')
return v
@validator('ssl_alpn')
def validate_alpn(cls, v):
if v is not None and v.strip():
# ALPN protocols are comma-separated
protocols = [p.strip() for p in v.split(',')]
valid_protocols = ['h2', 'http/1.1', 'http/1.0', 'h2c', 'spdy/3', 'spdy/2', 'spdy/1']
for proto in protocols:
if proto and proto not in valid_protocols:
raise ValueError(f'Invalid ALPN protocol: {proto}. Valid protocols: {", ".join(valid_protocols)}')
return v
@validator('ssl_npn')
def validate_npn(cls, v):
if v is not None and v.strip():
# NPN protocols are comma-separated (legacy)
protocols = [p.strip() for p in v.split(',')]
valid_protocols = ['http/1.1', 'http/1.0', 'spdy/3', 'spdy/2', 'spdy/1']
for proto in protocols:
if proto and proto not in valid_protocols:
raise ValueError(f'Invalid NPN protocol: {proto}. Valid protocols: {", ".join(valid_protocols)}')
return v
@validator('acl_rules')
def validate_acl_rules(cls, v):
+23
View File
@@ -1060,6 +1060,29 @@ async def parse_bulk_config(
has_changes = True
changes["tcp_request_rules"] = {"old": existing["tcp_request_rules"], "new": frontend["tcp_request_rules"]}
# CRITICAL: SSL Advanced Options change detection
if frontend.get("ssl_alpn") is not None and frontend.get("ssl_alpn") != existing.get("ssl_alpn"):
has_changes = True
changes["ssl_alpn"] = {"old": existing.get("ssl_alpn"), "new": frontend.get("ssl_alpn")}
if frontend.get("ssl_npn") is not None and frontend.get("ssl_npn") != existing.get("ssl_npn"):
has_changes = True
changes["ssl_npn"] = {"old": existing.get("ssl_npn"), "new": frontend.get("ssl_npn")}
if frontend.get("ssl_ciphers") is not None and frontend.get("ssl_ciphers") != existing.get("ssl_ciphers"):
has_changes = True
changes["ssl_ciphers"] = {"old": existing.get("ssl_ciphers"), "new": frontend.get("ssl_ciphers")}
if frontend.get("ssl_ciphersuites") is not None and frontend.get("ssl_ciphersuites") != existing.get("ssl_ciphersuites"):
has_changes = True
changes["ssl_ciphersuites"] = {"old": existing.get("ssl_ciphersuites"), "new": frontend.get("ssl_ciphersuites")}
if frontend.get("ssl_min_ver") is not None and frontend.get("ssl_min_ver") != existing.get("ssl_min_ver"):
has_changes = True
changes["ssl_min_ver"] = {"old": existing.get("ssl_min_ver"), "new": frontend.get("ssl_min_ver")}
if frontend.get("ssl_max_ver") is not None and frontend.get("ssl_max_ver") != existing.get("ssl_max_ver"):
has_changes = True
changes["ssl_max_ver"] = {"old": existing.get("ssl_max_ver"), "new": frontend.get("ssl_max_ver")}
if "ssl_strict_sni" in frontend and frontend.get("ssl_strict_sni") != existing.get("ssl_strict_sni", False):
has_changes = True
changes["ssl_strict_sni"] = {"old": existing.get("ssl_strict_sni", False), "new": frontend.get("ssl_strict_sni")}
# Additional frontend fields (timeout_http_request, rate_limit, compression, log_separate, monitor_uri)
if frontend.get("timeout_http_request") and frontend["timeout_http_request"] != existing.get("timeout_http_request"):
has_changes = True