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
298 lines
9.6 KiB
Python
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)
|