Files
ss-tools/scripts/diag_container.py
busya 8fd23f7ea1 refactor(ssl): centralize SSL trust management, remove LLM_SSL_VERIFY
Centralized SSL via one contract: CERTS_PATH=/opt/certs mounted into all containers.

Backend:
  - NEW: backend/src/core/ssl.py — system_ssl_context(), httpx_verify(),
    cert_dir_inventory()
  - LLMClient._get_ssl_verify() → delegates to core.ssl
  - _llm_async_http._get_verify() → delegates to core.ssl
  - Removed LLM_SSL_VERIFY env reading from all runtime code

Docker:
  - NEW: docker/certs.sh — shared cert installer (PEM/DER/cer to .crt conversion,
    update-ca-certificates, hash symlinks, NSS import)
  - NEW: docker/agent.entrypoint.sh — agent entrypoint with cert installation
  - backend.entrypoint.sh → uses certs.sh instead of install_llm_ca_certs
  - Dockerfile.agent → adds ca-certificates, openssl, entrypoint

Compose:
  - Removed LLM_CA_CERT_URLS and LLM_SSL_VERIFY from all compose files
  - Added CERTS_PATH volume mount to agent (dev + enterprise)
  - Added certs volume mount to backend/agent in dev compose

Env examples:
  - Removed LLM_SSL_VERIFY, LLM_CA_CERT_URLS from .env.example,
    .env.enterprise-clean.example, .env.current.example, .env.master.example,
    backend/.env.example
  - Enhanced CERTS_PATH comments with accepted formats

Diagnostics:
  - diag_container.py: removed LLM_* checks, added CERTS_PATH inventory,
    uses core.ssl for context creation

Tests:
  - Updated test_llm_analysis_service, test_llm_async_http,
    test_client_headers to verify centralized ssl context (no env disable)
  - 4/4 SSL tests pass
2026-07-06 21:00:28 +03:00

298 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""Diagnostic script: verify SSL cert installation and encryption key health.
Usage:
docker cp scripts/diag_container.py superset-tools-backend-1:/tmp/
docker compose exec backend python3 /tmp/diag_container.py [--target https://lite.ai.rusal.com]
"""
import hashlib
import os
import ssl
import subprocess
import sys
import traceback
from pathlib import Path
# ── Console helpers ──────────────────────────────────────────────────
GREEN = "\033[32m"
RED = "\033[31m"
YELLOW = "\033[33m"
BOLD = "\033[1m"
RESET = "\033[0m"
def ok(msg: str) -> None:
print(f" {GREEN}{RESET} {msg}")
def fail(msg: str) -> None:
print(f" {RED}{RESET} {msg}")
def warn(msg: str) -> None:
print(f" {YELLOW}⚠️ {RESET} {msg}")
def section(title: str) -> None:
print(f"\n{BOLD}{'=' * 60}{RESET}")
print(f"{BOLD} {title}{RESET}")
print(f"{BOLD}{'=' * 60}{RESET}")
# ── Section 1: Environment ───────────────────────────────────────────
def check_env() -> None:
section("1. Environment variables")
for var in ("ENCRYPTION_KEY", "AUTH_SECRET_KEY", "CERTS_PATH"):
val = os.getenv(var, "")
if not val:
warn(f"{var} is NOT set")
elif var in ("ENCRYPTION_KEY", "AUTH_SECRET_KEY"):
fp = hashlib.sha256(val.encode()).hexdigest()[:8]
ok(f"{var} is set (sha256:{fp})")
else:
ok(f"{var} = {val}")
fp = hashlib.sha256(os.getenv("ENCRYPTION_KEY", "").encode()).hexdigest()[:8]
print(f"\n Current key fingerprint: {BOLD}sha256:{fp}{RESET}")
# ── Section 2: CERTS_PATH inventory ──────────────────────────────────
def check_certs_inventory() -> None:
section("2. CERTS_PATH inventory (/opt/certs)")
from src.core.ssl import cert_dir_inventory
inv = cert_dir_inventory("/opt/certs")
if not inv["trust_candidates"] and not inv["server_files"]:
warn("CERTS_PATH is empty or not mounted — no corporate CA certs found")
print(
" Put .crt/.pem/.cer/.der CA files in ./certs/ on the host and restart containers."
)
return
for name in inv["trust_candidates"]:
ok(f"Trust candidate: {Path(name).name}")
for name in inv["server_files"]:
ok(f"Server file (skipped as CA): {Path(name).name}")
for name in inv["invalid"]:
warn(f"Unrecognized file (skipped): {Path(name).name}")
# ── Section 3: System CA store ──────────────────────────────────────
def check_system_certs() -> None:
section("3. System CA store (/etc/ssl/certs/)")
bundle = Path("/etc/ssl/certs/ca-certificates.crt")
if not bundle.exists():
fail("ca-certificates.crt NOT found — cert installation failed fatally")
return
cert_count = subprocess.run(
["grep", "-c", "BEGIN CERTIFICATE", str(bundle)],
capture_output=True,
text=True,
).stdout.strip()
ok(f"ca-certificates.crt exists, {cert_count} certificates in bundle")
custom_pems = list(Path("/usr/local/share/ca-certificates/custom").rglob("*.crt"))
if custom_pems:
names = [p.name for p in custom_pems]
ok(f"Installed CA certs: {', '.join(names)}")
else:
warn("No custom .crt files in /usr/local/share/ca-certificates/custom/")
certs_dir = Path("/etc/ssl/certs")
hash_links = [p for p in certs_dir.iterdir() if p.is_symlink()]
custom_links = [l for l in hash_links if "custom" in str(Path(os.readlink(str(l))))]
if custom_links:
ok(f"Hash symlinks for custom certs: {len(custom_links)}")
else:
warn("No hash symlinks for custom certs — capath may not find them")
# ── Section 4: OpenSSL connectivity ──────────────────────────────────
def check_openssl(target: str) -> None:
section(f"4. OpenSSL connectivity ({target})")
capath = "/etc/ssl/certs/"
host, port = _parse_target(target)
r = subprocess.run(
[
"openssl",
"s_client",
"-connect",
f"{host}:{port}",
"-servername",
host,
"-CApath",
capath,
],
input="",
capture_output=True,
text=True,
timeout=15,
)
if "Verify return code: 0" in (r.stderr + r.stdout):
ok(f"capath: verify OK — cert chain trusted")
else:
fail(
f"capath: verify FAILED.\n"
f" The CA certificate for {host} is NOT in /etc/ssl/certs/.\n"
f" Put the issuing/root CA .crt into CERTS_PATH (./certs)\n"
f" and restart the container."
)
def _parse_target(target: str) -> tuple[str, int]:
if "://" in target:
target = target.split("://")[1]
if ":" in target:
host, port = target.rsplit(":", 1)
return host, int(port)
return target, 443
# ── Section 5: Python SSL context ────────────────────────────────────
def check_python_ssl(target: str) -> None:
section(f"5. Python SSL context ({target})")
from src.core.ssl import system_ssl_context
ctx = system_ssl_context()
host, port = _parse_target(target)
try:
with ctx.wrap_socket(__import__("socket").socket(), server_hostname=host) as s:
s.settimeout(10)
s.connect((host, port))
cert = s.getpeercert()
cn = dict(x[0] for x in cert.get("subject", []))
ok(f"SSLContext(capath): connected OK — CN={cn.get('commonName', '?')}")
except Exception as e:
fail(f"SSLContext(capath): FAILED — {e}")
# ── Section 6: httpx connectivity ────────────────────────────────────
def check_httpx(target: str) -> None:
section(f"6. httpx connectivity ({target})")
try:
import httpx
from src.core.ssl import httpx_verify
ctx = httpx_verify()
url = f"https://{target}" if not target.startswith("http") else target
r = httpx.get(url, verify=ctx, timeout=10, follow_redirects=True)
ok(f"httpx(capath): HTTP {r.status_code}")
except ImportError:
warn("httpx not installed — skipping")
except Exception as e:
fail(f"httpx(capath): FAILED — {e}")
# ── Section 7: Encryption health ─────────────────────────────────────
def check_encryption_health() -> None:
section("7. Encryption key health (internal)")
sys.path.insert(0, "/app/backend")
try:
from src.core.encryption import get_encryption_manager, is_fernet_token
from src.core.database import SessionLocal
from src.models.llm import LLMProvider
mgr = get_encryption_manager()
db = SessionLocal()
try:
providers = db.query(LLMProvider).all()
ok(f"LLM providers found: {len(providers)}")
for p in providers:
if not p.api_key:
warn(f" {p.name}: api_key is EMPTY")
continue
if not is_fernet_token(p.api_key):
warn(f" {p.name}: api_key is NOT a fernet token")
continue
try:
mgr.decrypt(p.api_key)
ok(f" {p.name}: api_key decrypts OK (healthy)")
except Exception:
fail(
f" {p.name}: api_key decrypt FAILED — "
f"encrypted with different ENCRYPTION_KEY"
)
finally:
db.close()
except ImportError as e:
warn(f"Cannot import backend modules: {e}")
except Exception as e:
fail(f"Encryption health check failed: {e}")
# ── Main ─────────────────────────────────────────────────────────────
def main() -> None:
target = "lite.ai.rusal.com:443"
args = sys.argv[1:]
i = 0
while i < len(args):
if args[i] == "--target" and i + 1 < len(args):
target = args[i + 1]
i += 2
elif args[i].startswith("--"):
print(f"Unknown flag: {args[i]}")
print(f"Usage: {sys.argv[0]} [--target host:port]")
sys.exit(1)
else:
i += 1
print(f"\n{BOLD}superset-tools container diagnostic{RESET}")
print(f"Target: {target}\n")
check_env()
check_certs_inventory()
check_system_certs()
check_openssl(target)
check_python_ssl(target)
check_httpx(target)
check_encryption_health()
section("Summary")
print(
"\nIf cert verification failures:\n"
" → Put the issuing/root CA for target into CERTS_PATH (./certs)\n"
" → Restart affected containers\n"
" → Rerun this diagnostic\n"
)
print(
"\nIf ENCRYPTION_KEY health failures:\n"
" → Stored secrets encrypted with old key\n"
" → Open Settings → System → 'Encryption Key Recovery'\n"
" → Re-enter LLM API keys and DB passwords\n"
)
if __name__ == "__main__":
try:
main()
except Exception:
print(f"\n{RED}Script crashed:{RESET}")
traceback.print_exc()
sys.exit(1)