Files
mustafa.ulukaya a6166d11b9 feat(ssl): add CSR generation and signed-certificate import (backend)
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.
2026-08-04 21:23:57 +03:00

537 lines
20 KiB
Python

"""
ssl_service: extracted helpers for SSL certificate row creation + cluster junction.
Used by:
- routers/site_wizard.py wizard (mode=upload | existing)
- (future) other SSL flows
Design (Section 4.3 of v1.5.0 plan):
- R38 schema: ssl_certificates.cluster_id always NULL — junction table
ssl_certificate_clusters is the single source of truth for cluster binding.
- M11 idempotent junction insertion via ON CONFLICT DO NOTHING.
- last_config_status='PENDING' set explicitly at INSERT time (matches ssl.py:467-477).
Phase K Phase D follow-up (Bulgu #9) — parity with SSL Management page.
Before the follow-up, the wizard's PEM upload path persisted a sparse
row: primary_domain/all_domains came from the operator-entered
FRONTEND domains (not the cert SAN); expiry_date/issuer/fingerprint
were NULL; status was hard-coded `'valid'`; days_until_expiry was
`0`; private_key / chain went un-validated; name uniqueness was
not enforced (would 500 on the DB unique constraint instead of
returning a friendly 400); soft-deleted rows could not be
reactivated. SSL Management's `/api/ssl/certificates` POST does
all of this. The wizard-created cert appeared on the SSL
Management page with empty expiry/issuer columns and a permanent
"valid" status — confusing UX and inconsistent with the dedicated
flow.
`create_cert_row` now:
- parses the certificate via `utils.ssl_parser.parse_ssl_certificate`,
- validates private_key + chain via the same helpers SSL Management uses,
- enforces name uniqueness within the target cluster (mirrors
ssl.py:392-411 but scoped to the wizard's single cluster),
- reactivates soft-deleted certs with the same name (mirrors
ssl.py:470-500), preserving the row id so existing references
do not break,
- recomputes status / days_until_expiry / timezone-normalises
expiry_date the same way ssl.py:432-460 does,
- raises `HTTPException(400)` on every parse/validation failure
(callers translate to wizard step-jumpback toasts).
"""
import hashlib
import json
import logging
import time
from datetime import datetime, timezone
from typing import Any, List, Optional
from fastapi import HTTPException
from utils.ssl_parser import (
parse_ssl_certificate,
validate_certificate_chain,
validate_private_key,
)
logger = logging.getLogger(__name__)
def _normalise_expiry_to_naive_utc(expiry: Optional[datetime]) -> Optional[datetime]:
"""Mirror ssl.py:418-460 timezone handling — DB column is
timezone-naive UTC; pre-normalisation drift caused inconsistent
`expires_in_days` math between rows created via the two flows."""
if not expiry:
return None
try:
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
else:
expiry = expiry.astimezone(timezone.utc)
return expiry.astimezone(timezone.utc).replace(tzinfo=None)
except Exception as tz_error:
logger.warning(
"ssl_service._normalise_expiry_to_naive_utc: timezone "
f"conversion failed ({tz_error}); persisting NULL"
)
return None
def _recompute_status_from_expiry(
cert_info_status: str,
expiry_date: Optional[datetime],
cert_info_days: int,
) -> tuple[str, int]:
"""Mirror ssl.py:436-454 — recompute status + days_until_expiry
from the normalised expiry date so two SSL rows created on the
same cert have identical lifecycle fields regardless of the
creation flow.
Returns (status, days_until_expiry).
"""
if not expiry_date:
return cert_info_status or "valid", cert_info_days or 0
try:
now_utc = datetime.utcnow()
days_left = (expiry_date - now_utc).days
if days_left < 0:
return "expired", days_left
if days_left < 30:
return "expiring_soon", days_left
return "valid", days_left
except Exception as calc_error:
logger.warning(
"ssl_service._recompute_status_from_expiry: failed "
f"({calc_error}); falling back to parser-provided values"
)
return cert_info_status or "valid", cert_info_days or 0
def _prepare_cert_fields(payload: Any) -> dict:
"""Parse + validate the PEM material on `payload` and derive every
ssl_certificates column value from it (v1.9.0 extraction — shared by
`create_cert_row` and the CSR import flow in services/csr_service.py,
byte-identical to the former inline body of `create_cert_row`).
payload is expected to expose:
name, certificate_content, private_key_content, chain_content,
usage_type (optional, default 'frontend').
Raises HTTPException(400) on any parse/validation failure (invalid PEM,
bad private key, cert/key mismatch, bad chain, already-expired cert).
Returns a dict with keys: cert_content, private_key_content,
chain_content, cert_info, primary_domain, all_domains, expiry_date,
issuer, fingerprint, status, days_until_expiry, usage_type.
"""
cert_content = getattr(payload, "certificate_content", None) or ""
if not cert_content.strip():
raise HTTPException(
status_code=400,
detail="ssl.certificate_content is empty — paste the PEM-encoded certificate.",
)
cert_info = parse_ssl_certificate(cert_content)
if cert_info.get("error"):
raise HTTPException(
status_code=400,
detail=f"Invalid SSL certificate: {cert_info['error']}",
)
private_key_content = getattr(payload, "private_key_content", None)
if private_key_content and not validate_private_key(private_key_content):
raise HTTPException(
status_code=400,
detail=(
"Invalid private key format — paste the PEM-encoded private "
"key. If the key is encrypted with a passphrase, decrypt it "
"first (`openssl rsa -in encrypted.key -out plain.key`) — "
"HAProxy cannot read passphrase-protected keys."
),
)
# Bulgu #23 (round-12 audit): cert and key MUST share the same
# public key. Pre-fix the upload paths validated cert and key
# independently, so mixing PEMs from different sites surfaced
# only at the agent's `haproxy -c` with an opaque
# "X509_check_private_key: key values mismatch" alert — by which
# point entities + PENDING version were already created.
if private_key_content:
from utils.ssl_parser import verify_certificate_key_match
match_result = verify_certificate_key_match(cert_content, private_key_content)
if match_result.get("match") is False:
raise HTTPException(
status_code=400,
detail=(
"SSL certificate and private key do not match — the "
"cert's public key differs from the private key's public "
"key. The pair likely belongs to two different sites or "
"a stale key was pasted. Re-export both PEM files from "
"the same issuance and try again."
),
)
chain_content = getattr(payload, "chain_content", None)
if chain_content and not validate_certificate_chain(chain_content):
raise HTTPException(
status_code=400,
detail="Invalid certificate chain format — paste the PEM-encoded chain.",
)
# Bulgu #24 (round-12 audit): refuse to create a row for an already
# EXPIRED certificate. Pre-fix the wizard / direct upload accepted
# certs with `status='expired'` from parse_ssl_certificate, the
# row was inserted, the wizard built an HTTPS frontend bound to
# it, and the agent deployed a cert that EVERY browser rejects
# at the TLS handshake. Recovery required noticing the broken
# site, rejecting the version, and re-uploading a valid cert.
# Hard-reject here so the operator sees a clear 400 at upload
# time instead of a runtime user-facing TLS failure.
if cert_info.get("status") == "expired":
days_past = cert_info.get("days_until_expiry", 0)
raise HTTPException(
status_code=400,
detail=(
f"SSL certificate is already expired ({-int(days_past) if isinstance(days_past, (int, float)) else 'unknown'} "
"days past notAfter). HAProxy will load it but every browser "
"TLS handshake will fail with NET::ERR_CERT_DATE_INVALID. "
"Replace with a non-expired certificate before deploying."
),
)
expiry_date = _normalise_expiry_to_naive_utc(cert_info.get("expiry_date"))
primary_domain = cert_info.get("primary_domain") or getattr(payload, "primary_domain", None)
all_domains = cert_info.get("all_domains") or getattr(payload, "all_domains", None) or (
[primary_domain] if primary_domain else []
)
issuer = cert_info.get("issuer") or getattr(payload, "issuer", None)
fingerprint = cert_info.get("fingerprint") or getattr(payload, "fingerprint", None)
status, days_until_expiry = _recompute_status_from_expiry(
cert_info.get("status", "valid"),
expiry_date,
cert_info.get("days_until_expiry", 0),
)
usage_type = getattr(payload, "usage_type", "frontend") or "frontend"
return {
"cert_content": cert_content,
"private_key_content": private_key_content,
"chain_content": chain_content,
"cert_info": cert_info,
"primary_domain": primary_domain,
"all_domains": all_domains,
"expiry_date": expiry_date,
"issuer": issuer,
"fingerprint": fingerprint,
"status": status,
"days_until_expiry": days_until_expiry,
"usage_type": usage_type,
}
async def create_cert_row(
conn,
payload: Any,
cluster_id: int,
) -> int:
"""Insert a row into ssl_certificates (always cluster_id=NULL) + junction
binding to the given cluster_id. Returns new ssl_certificate_id.
payload is expected to expose:
name, certificate_content, private_key_content, chain_content,
usage_type (optional, default 'frontend').
All cert metadata (primary_domain, all_domains, expiry_date,
issuer, fingerprint, status, days_until_expiry) is now parsed
FROM the PEM content via `parse_ssl_certificate` — operator-
supplied values on the payload are accepted as a graceful
fallback only when parsing fails (which itself raises 400).
"""
fields = _prepare_cert_fields(payload)
cert_content = fields["cert_content"]
private_key_content = fields["private_key_content"]
chain_content = fields["chain_content"]
expiry_date = fields["expiry_date"]
primary_domain = fields["primary_domain"]
all_domains = fields["all_domains"]
issuer = fields["issuer"]
fingerprint = fields["fingerprint"]
status = fields["status"]
days_until_expiry = fields["days_until_expiry"]
usage_type = fields["usage_type"]
existing = await conn.fetchrow(
"""
SELECT s.id, s.is_active
FROM ssl_certificates s
LEFT JOIN ssl_certificate_clusters scc ON s.id = scc.ssl_certificate_id
WHERE s.name = $1
AND (
NOT EXISTS (
SELECT 1 FROM ssl_certificate_clusters
WHERE ssl_certificate_id = s.id
)
OR scc.cluster_id = $2
)
LIMIT 1
""",
payload.name,
cluster_id,
)
if existing and existing["is_active"]:
raise HTTPException(
status_code=400,
detail=(
f"SSL certificate with name '{payload.name}' already exists in this cluster. "
"Choose a different name or remove the existing one from SSL Management first."
),
)
if existing and not existing["is_active"]:
await conn.execute(
"""
DELETE FROM ssl_certificate_clusters WHERE ssl_certificate_id = $1
""",
existing["id"],
)
await conn.execute(
"""
UPDATE ssl_certificates
SET is_active = TRUE,
last_config_status = 'PENDING',
certificate_content = $2,
private_key_content = $3,
chain_content = $4,
primary_domain = $5,
all_domains = $6::jsonb,
expiry_date = $7,
usage_type = $8,
issuer = $9,
fingerprint = $10,
status = $11,
days_until_expiry = $12,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
""",
existing["id"],
cert_content,
private_key_content,
chain_content,
primary_domain,
json.dumps(all_domains),
expiry_date,
usage_type,
issuer,
fingerprint,
status,
days_until_expiry,
)
cert_id = existing["id"]
logger.info(
"ssl_service.create_cert_row: reactivated soft-deleted "
f"cert '{payload.name}' (id={cert_id}) via wizard parity path"
)
else:
cert_id = await conn.fetchval(
"""
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, usage_type
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb,
TRUE, NULL, 'PENDING', $12
)
RETURNING id
""",
payload.name,
primary_domain,
cert_content,
private_key_content,
chain_content,
expiry_date,
issuer,
fingerprint,
status,
days_until_expiry,
json.dumps(all_domains),
usage_type,
)
await ensure_cluster_junction(conn, cert_id, cluster_id)
return cert_id
async def ensure_cluster_junction(conn, ssl_certificate_id: int, cluster_id: int) -> None:
"""Idempotent insert into ssl_certificate_clusters (M11)."""
await conn.execute(
"""
INSERT INTO ssl_certificate_clusters (ssl_certificate_id, cluster_id)
VALUES ($1, $2)
ON CONFLICT (ssl_certificate_id, cluster_id) DO NOTHING
""",
ssl_certificate_id,
cluster_id,
)
async def select_existing_cert(conn, ssl_certificate_id: int, cluster_id: int) -> Optional[int]:
"""Validate that the cert exists AND is eligible for the given
cluster, then ensure cluster junction. Returns the cert id when
valid, else None.
R18b audit fix (round 4 #C — cert RBAC bypass): pre-fix this
helper only checked `is_active=TRUE` and then UNCONDITIONALLY
attached the cluster junction row. That meant an authenticated
operator with access to cluster B could reference any
cluster-A-bound cert id (or any global-but-not-junctioned cert)
and the wizard would silently bind it to cluster B. The
`GET /api/ssl/certificates` listing already enforces the correct
eligibility predicate ("global cert OR junction already includes
this cluster"); this helper now mirrors that predicate so the
wizard cannot grant access the listing forbids.
Eligibility rule (matches ssl.py listing):
- cert is "global" (no rows in ssl_certificate_clusters), OR
- cert is already bound to `cluster_id`.
"""
row = await conn.fetchrow(
"""
SELECT sc.id
FROM ssl_certificates sc
WHERE sc.id = $1
AND sc.is_active = TRUE
AND (
NOT EXISTS (
SELECT 1 FROM ssl_certificate_clusters
WHERE ssl_certificate_id = sc.id
)
OR EXISTS (
SELECT 1 FROM ssl_certificate_clusters
WHERE ssl_certificate_id = sc.id AND cluster_id = $2
)
)
""",
ssl_certificate_id,
cluster_id,
)
if not row:
return None
await ensure_cluster_junction(conn, ssl_certificate_id, cluster_id)
return row["id"]
async def validate_server_ca_bundle_eligibility(
conn, ssl_certificate_id: int, cluster_id: int
) -> bool:
"""R18b audit fix (round 4 #C): per-server `ssl_certificate_id`
(HAProxy `ca-file` for upstream verification) skipped any cluster
eligibility check pre-R18b — only DB FK integrity. That allowed
the same cross-cluster reference primitive as `select_existing_cert`.
The wizard now calls this validator before persisting the row.
Returns True iff the cert is active AND visible to the cluster
using the same eligibility rule as `select_existing_cert`.
"""
row = await conn.fetchrow(
"""
SELECT 1
FROM ssl_certificates sc
WHERE sc.id = $1
AND sc.is_active = TRUE
AND (
NOT EXISTS (
SELECT 1 FROM ssl_certificate_clusters
WHERE ssl_certificate_id = sc.id
)
OR EXISTS (
SELECT 1 FROM ssl_certificate_clusters
WHERE ssl_certificate_id = sc.id AND cluster_id = $2
)
)
LIMIT 1
""",
ssl_certificate_id,
cluster_id,
)
return row is not None
async def stage_ssl_config_versions(
conn,
cert_id: int,
cluster_ids: List[int],
action: str = "create",
created_by: Optional[int] = None,
) -> List[dict]:
"""Stage one PENDING config version per affected cluster after an SSL
certificate mutation (v1.9.0 — distilled from the routers/ssl.py POST
/certificates staging loop; used by the CSR import flow).
Uses the EXACT `ssl-{cert_id}-{action}-{timestamp}` version-name scheme of
the manual SSL flow so Apply Management, the `has_pending_config`
LIKE-filter ('ssl-' || id || '-%'), and the agent delivery predicates
treat CSR-imported certificates identically to manually uploaded ones.
Agents are NOT notified here — the operator applies manually.
Per-cluster failures are caught and reported in the returned
sync_results list (the DB save has already succeeded — same semantics as
the manual flow, where a config-generation failure never rolls back the
certificate row).
"""
# Local import: keeps services/haproxy_config free to import ssl helpers
# without a module-level cycle.
from services.haproxy_config import generate_haproxy_config_for_cluster
sync_results: List[dict] = []
for cluster_id in cluster_ids:
try:
config_content = await generate_haproxy_config_for_cluster(cluster_id)
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
version_name = f"ssl-{cert_id}-{action}-{int(time.time())}"
version_created_by = created_by
if version_created_by is None:
version_created_by = await conn.fetchval(
"SELECT id FROM users WHERE username = 'admin' LIMIT 1"
) or 1
await conn.fetchval(
"""
INSERT INTO config_versions
(cluster_id, version_name, config_content, checksum, created_by, is_active, status)
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING')
RETURNING id
""",
cluster_id,
version_name,
config_content,
config_hash,
version_created_by,
)
logger.info(
f"APPLY WORKFLOW: Created PENDING config version {version_name} "
f"for cluster {cluster_id} (ssl_service.stage_ssl_config_versions)"
)
sync_results.append({
'node': 'pending',
'success': True,
'cluster_id': cluster_id,
'version': version_name,
'status': 'PENDING',
'message': 'SSL certificate staged. Click Apply to activate.',
})
except Exception as e:
logger.error(
f"Cluster config staging failed for SSL certificate {cert_id} "
f"on cluster {cluster_id}: {e}"
)
sync_results.append({
'node': 'cluster',
'success': False,
'cluster_id': cluster_id,
'error': str(e),
})
return sync_results