mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-16 07:35:16 +00:00
a6166d11b9
New /api/ssl/csrs endpoint group: generate a private key + CSR server-side
(RSA 2048/4096, ECDSA P-256/P-384; full subject + DNS SANs with wildcard
support), list/detail/delete CSRs, and import the CA-signed certificate.
- New ssl_csrs table (SCHEMA_VERSION 9 -> 10, additive + idempotent); the
migration re-raises on failure so a failed run is retried instead of being
stamped as applied.
- Import verifies the certificate against the stored key as a hard gate
(match=None is treated as an integrity error, not a lenient pass), rejects
malformed and expired certificates with 400, warns on SAN drift, and
creates a normal ssl_certificates row (source=csr, cluster_id=NULL,
last_config_status=PENDING) so it flows through the standard
Apply Management -> agent pull pipeline.
- Concurrency: FOR UPDATE row lock serialises double-import and
delete-during-import; a partial unique index reserves pending CSR names;
soft-deleted same-name certs are reactivated preserving the row id.
- Security: no CSR endpoint ever returns the private key (explicit column
lists, enforced by a static test); the key copy on the CSR row is NULLed
after import; ssl.create/read/delete permissions enforced on every
endpoint incl. reads; per-user rate limit on key generation, which runs
in a worker thread; csr_id and cluster_ids are int32-guarded.
- ssl_service: extract _prepare_cert_fields from create_cert_row (behaviour
unchanged, extraction tests untouched) and add stage_ssl_config_versions
reusing the exact ssl-{id}-create-{ts} version-name scheme.
- Tests: crypto round-trip for all four algorithms, model validation,
import-flow unit tests, endpoint auth/permission pinning, migration and
key-non-exposure static assertions.
252 lines
9.7 KiB
Python
252 lines
9.7 KiB
Python
"""
|
|
Pydantic models for the CSR (Certificate Signing Request) feature (v1.9.0).
|
|
|
|
A CSR row is the precursor of an ssl_certificates row: the backend generates
|
|
the private key + CSR locally, the operator has the CSR signed by an external
|
|
CA and then imports the signed certificate. The CSR `name` therefore obeys the
|
|
exact same path-traversal contract as the SSL certificate name (Bulgu #21) —
|
|
at import time it becomes /etc/ssl/haproxy/{name}.pem on every agent and is
|
|
shell-processed by the agent script as root.
|
|
|
|
The import model deliberately has NO private key field: the key never leaves
|
|
the server. It is stored on the ssl_csrs row at generation time and paired
|
|
with the signed certificate server-side.
|
|
"""
|
|
|
|
import re
|
|
from typing import List, Optional
|
|
|
|
from pydantic import BaseModel, field_validator, model_validator
|
|
|
|
KEY_ALGORITHMS = ('rsa-2048', 'rsa-4096', 'ecdsa-p256', 'ecdsa-p384')
|
|
|
|
# RFC 1035 LDH hostname, lowercase, optional single leftmost wildcard label.
|
|
# Single-label names are allowed (internal CAs routinely sign bare hostnames).
|
|
_DNS_NAME_PATTERN = re.compile(
|
|
r'^(\*\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?'
|
|
r'(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$'
|
|
)
|
|
|
|
# Reject control characters in free-text subject fields: they would be
|
|
# persisted, echoed into the UI / issuer column, and printed into agent logs
|
|
# via `openssl -subject` output.
|
|
_CONTROL_CHARS_PATTERN = re.compile(r'[\x00-\x1f\x7f]')
|
|
|
|
_MAX_SANS = 100
|
|
_MAX_CERT_PEM_BYTES = 64 * 1024 # a leaf certificate is ~2 KB; 64 KB is generous
|
|
_MAX_CHAIN_PEM_BYTES = 256 * 1024 # agents re-download all cert content every poll
|
|
|
|
|
|
def _validate_dns_name(value: str, field_label: str) -> str:
|
|
v = (value or '').strip().lower()
|
|
if not v:
|
|
raise ValueError(f'{field_label} must not be empty')
|
|
if len(v) > 253:
|
|
raise ValueError(f'{field_label} must be 253 characters or fewer')
|
|
if not _DNS_NAME_PATTERN.match(v):
|
|
raise ValueError(
|
|
f'{field_label} {value!r} is not a valid DNS name — lowercase '
|
|
'letters, digits, hyphens and dots only; a wildcard is allowed '
|
|
'only as the leftmost label (e.g. *.example.com).'
|
|
)
|
|
return v
|
|
|
|
|
|
def _validate_subject_text(value: Optional[str], field_label: str, max_len: int = 64) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
v = value.strip()
|
|
if not v:
|
|
return None
|
|
if len(v) > max_len:
|
|
raise ValueError(f'{field_label} must be {max_len} characters or fewer')
|
|
if _CONTROL_CHARS_PATTERN.search(v):
|
|
raise ValueError(f'{field_label} must not contain control characters')
|
|
return v
|
|
|
|
|
|
def _validate_csr_name(v: str) -> str:
|
|
"""Mirror of SSLCertificateCreate.validate_name_no_path_traversal (Bulgu #21)
|
|
with one deliberate tightening: max length 100, matching the
|
|
ssl_certificates.name VARCHAR(100) column (the historical 200-char limit
|
|
overflows the column and 500s — not replicated here)."""
|
|
if v is None:
|
|
raise ValueError('CSR name is required')
|
|
stripped = v.strip()
|
|
if not stripped:
|
|
raise ValueError('CSR name must not be empty')
|
|
if stripped != v:
|
|
raise ValueError('CSR name must not contain leading/trailing whitespace')
|
|
if len(stripped) > 100:
|
|
raise ValueError('CSR name must be 100 characters or fewer')
|
|
if not re.match(r'^[A-Za-z0-9_.-]+$', stripped):
|
|
raise ValueError(
|
|
f'CSR name={v!r} contains forbidden characters — only letters, '
|
|
'digits, underscore, hyphen, and dot are allowed (the name becomes '
|
|
'a filename component under /etc/ssl/haproxy/ at import).'
|
|
)
|
|
if '..' in stripped:
|
|
raise ValueError(f'CSR name={v!r} must not contain ".." (path traversal)')
|
|
if stripped.startswith('.'):
|
|
raise ValueError(f'CSR name={v!r} must not start with "." (hidden filename)')
|
|
if stripped.startswith('-'):
|
|
raise ValueError(f'CSR name={v!r} must not start with "-" (CLI flag confusion)')
|
|
return stripped
|
|
|
|
|
|
class SSLCSRCreate(BaseModel):
|
|
name: str # becomes the certificate name at import
|
|
common_name: str
|
|
organization: Optional[str] = None # O
|
|
organizational_unit: Optional[str] = None # OU
|
|
locality: Optional[str] = None # L
|
|
state: Optional[str] = None # ST
|
|
country: Optional[str] = None # C — exactly 2 letters
|
|
email: Optional[str] = None # emailAddress
|
|
sans: List[str] = [] # DNS names; CN is auto-added server-side
|
|
key_algorithm: str = 'rsa-2048'
|
|
|
|
@field_validator('name')
|
|
@classmethod
|
|
def validate_name(cls, v):
|
|
return _validate_csr_name(v)
|
|
|
|
@field_validator('common_name')
|
|
@classmethod
|
|
def validate_common_name(cls, v):
|
|
v = _validate_dns_name(v, 'Common Name')
|
|
# RFC 5280 ub-common-name — many CAs reject CNs longer than 64 chars.
|
|
if len(v) > 64:
|
|
raise ValueError(
|
|
'Common Name must be 64 characters or fewer (RFC 5280 upper '
|
|
'bound) — put longer names in the SAN list instead.'
|
|
)
|
|
return v
|
|
|
|
@field_validator('sans')
|
|
@classmethod
|
|
def validate_sans(cls, v):
|
|
if not v:
|
|
return []
|
|
if len(v) > _MAX_SANS:
|
|
raise ValueError(f'At most {_MAX_SANS} SAN entries are allowed')
|
|
seen = set()
|
|
result = []
|
|
for entry in v:
|
|
normalised = _validate_dns_name(entry, 'SAN entry')
|
|
if normalised not in seen:
|
|
seen.add(normalised)
|
|
result.append(normalised)
|
|
return result
|
|
|
|
@field_validator('organization')
|
|
@classmethod
|
|
def validate_organization(cls, v):
|
|
return _validate_subject_text(v, 'Organization (O)')
|
|
|
|
@field_validator('organizational_unit')
|
|
@classmethod
|
|
def validate_organizational_unit(cls, v):
|
|
return _validate_subject_text(v, 'Organizational Unit (OU)')
|
|
|
|
@field_validator('locality')
|
|
@classmethod
|
|
def validate_locality(cls, v):
|
|
return _validate_subject_text(v, 'Locality (L)')
|
|
|
|
@field_validator('state')
|
|
@classmethod
|
|
def validate_state(cls, v):
|
|
return _validate_subject_text(v, 'State/Province (ST)')
|
|
|
|
@field_validator('country')
|
|
@classmethod
|
|
def validate_country(cls, v):
|
|
# cryptography raises a bare ValueError for a non-2-char COUNTRY_NAME;
|
|
# pre-validate so the operator gets a friendly 422 instead of a 500.
|
|
if v is None:
|
|
return None
|
|
v = v.strip()
|
|
if not v:
|
|
return None
|
|
if not re.match(r'^[A-Za-z]{2}$', v):
|
|
raise ValueError('Country (C) must be exactly 2 letters (ISO 3166-1 alpha-2, e.g. TR, US)')
|
|
return v.upper()
|
|
|
|
@field_validator('email')
|
|
@classmethod
|
|
def validate_email(cls, v):
|
|
v = _validate_subject_text(v, 'Email', max_len=254)
|
|
if v is not None and ('@' not in v or v.startswith('@') or v.endswith('@')):
|
|
raise ValueError('Email must be a valid address (missing or misplaced "@")')
|
|
return v
|
|
|
|
@field_validator('key_algorithm')
|
|
@classmethod
|
|
def validate_key_algorithm(cls, v):
|
|
if v not in KEY_ALGORITHMS:
|
|
raise ValueError(
|
|
f'key_algorithm must be one of: {", ".join(KEY_ALGORITHMS)}'
|
|
)
|
|
return v
|
|
|
|
|
|
class SSLCSRImport(BaseModel):
|
|
"""Import the CA-signed certificate for a pending CSR. The private key is
|
|
NOT part of the request — it is already stored on the CSR row."""
|
|
certificate_content: str # PEM
|
|
chain_content: Optional[str] = None # PEM, optional
|
|
usage_type: str = 'frontend' # "frontend" or "server"
|
|
is_global: bool = False
|
|
cluster_ids: Optional[List[int]] = None
|
|
# Escape hatch for name collisions that appeared AFTER the CSR was
|
|
# created: overrides the CSR's reserved name for the certificate row.
|
|
name: Optional[str] = None
|
|
|
|
@field_validator('certificate_content')
|
|
@classmethod
|
|
def validate_certificate(cls, v):
|
|
if not v or not v.strip():
|
|
raise ValueError('Certificate content is required')
|
|
v = v.strip()
|
|
if len(v.encode('utf-8', errors='ignore')) > _MAX_CERT_PEM_BYTES:
|
|
raise ValueError('Certificate content exceeds the 64 KB limit')
|
|
if '-----BEGIN CERTIFICATE-----' not in v or '-----END CERTIFICATE-----' not in v:
|
|
raise ValueError('Certificate must be in PEM format')
|
|
return v
|
|
|
|
@field_validator('chain_content')
|
|
@classmethod
|
|
def validate_chain(cls, v):
|
|
if v and v.strip():
|
|
v = v.strip()
|
|
if len(v.encode('utf-8', errors='ignore')) > _MAX_CHAIN_PEM_BYTES:
|
|
raise ValueError('Certificate chain exceeds the 256 KB limit')
|
|
if '-----BEGIN CERTIFICATE-----' not in v or '-----END CERTIFICATE-----' not in v:
|
|
raise ValueError('Certificate chain must be in PEM format')
|
|
return v
|
|
return None
|
|
|
|
@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('name')
|
|
@classmethod
|
|
def validate_name(cls, v):
|
|
if v is None or not str(v).strip():
|
|
return None
|
|
return _validate_csr_name(v)
|
|
|
|
@model_validator(mode='after')
|
|
def validate_cluster_selection(self):
|
|
if not self.is_global and not self.cluster_ids:
|
|
raise ValueError(
|
|
'cluster_ids is required when is_global is false — pick at '
|
|
'least one cluster or import the certificate as global.'
|
|
)
|
|
return self
|