Files
ss-tools/scripts/check_llm_certs.py
busya ed4ac88c3d feat: rewrite SSL diag in Python — tests all real libraries
Tests all 4 SSL methods with actual project libraries:
  - httpx (verify=True/False/SSLContext cafile/cafile+capath)
  - requests (verify=True/False/cafile/capath)
  - openssl s_client (default/CAfile/CApath/chain bundle)
  - certutil (NSS/Chromium)
  - certifi vs system CA comparison
Outputs JSON summary with AI diagnosis hint.
2026-05-28 22:57:08 +03:00

503 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
# #region check_llm_certs [C:4] [TYPE Module] [SEMANTICS ssl, certs, diagnostics, python]
# @BRIEF Diagnóstico SSL pro ss-tools em Python. Testa todas as bibliotecas
# reais usadas no projeto: ssl, httpx, requests, openai, certifi.
# Saída em formato parseável para AI corrigir o código.
# @USAGE ./scripts/check_llm_certs.py [--target https://...] [-v]
# @LAYER Infrastructure
# #endregion check_llm_certs
"""
SSL Certificate Diagnostic Tool for ss-tools.
Tests all HTTP stacks used by the application:
- ssl (Python standard library)
- httpx (used by LLMClient in llm_analysis plugin)
- openai.AsyncOpenAI (actual LLM client wrapper)
- requests (used by translate plugin)
- certifi (default CA bundle used by requests/httpx)
- NSS certutil (Chromium/Playwright)
Output format: key=value lines + JSON summary for AI processing.
"""
import json
import os
import platform
import subprocess
import sys
import tempfile
from pathlib import Path
# ── Configuration ──
TARGET_URL = os.environ.get("TARGET_URL", "https://lite.ai.rusal.com")
LLM_SSL_VERIFY = os.environ.get("LLM_SSL_VERIFY", "true")
CA_BUNDLE = Path("/etc/ssl/certs/ca-certificates.crt")
CA_DIR = Path("/etc/ssl/certs")
CUSTOM_DIRS = [
Path("/usr/local/share/ca-certificates/custom"),
Path("/usr/local/share/ca-certificates/llm"),
]
NSS_DB_DIR = Path(os.environ.get("HOME", "/root")) / ".pki" / "nssdb"
results: dict = {}
errors: list = []
def section(name: str) -> None:
"""Print section header."""
print(f"\n=== {name} ===")
def fmt_ok(val: str = "OK") -> str:
return f"{val}"
def fmt_fail(val: str) -> str:
return f"{val}"
def fmt_warn(val: str) -> str:
return f"{val}"
def run_cmd(cmd: list[str], timeout: int = 15) -> tuple[int, str, str]:
"""Run shell command, return (returncode, stdout, stderr)."""
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
except FileNotFoundError:
return -1, "", f"command not found: {cmd[0]}"
except subprocess.TimeoutExpired:
return -2, "", "timeout"
# ═══════════════════════════════════════════════════════
# 1. SYSTEM INFORMATION
# ═══════════════════════════════════════════════════════
section("1. System")
host = TARGET_URL.replace("https://", "").split("/")[0]
results["target_url"] = TARGET_URL
results["host"] = host
results["llm_ssl_verify"] = LLM_SSL_VERIFY
results["platform"] = platform.platform()
results["python_version"] = sys.version
# Libraries
try:
import httpx as _httpx
results["httpx_version"] = _httpx.__version__
except Exception as e:
results["httpx_version"] = f"NOT_INSTALLED ({e})"
try:
import requests as _requests
results["requests_version"] = _requests.__version__
except Exception as e:
results["requests_version"] = f"NOT_INSTALLED ({e})"
try:
import certifi as _certifi
results["certifi_path"] = _certifi.where()
results["certifi_certs"] = _count_certs(_certifi.where())
except Exception as e:
results["certifi_path"] = f"NOT_INSTALLED ({e})"
try:
import openai as _openai
results["openai_version"] = _openai.__version__
except Exception as e:
results["openai_version"] = f"NOT_INSTALLED ({e})"
# ═══════════════════════════════════════════════════════
# 2. SYSTEM CA STORE
# ═══════════════════════════════════════════════════════
section("2. System CA Store")
results["ca_bundle"] = str(CA_BUNDLE)
results["ca_bundle_exists"] = CA_BUNDLE.exists()
results["ca_dir_exists"] = CA_DIR.is_dir()
if CA_BUNDLE.exists():
results["ca_bundle_certs"] = _count_certs(str(CA_BUNDLE))
else:
results["ca_bundle_certs"] = 0
if CA_DIR.is_dir():
hash_links = list(CA_DIR.glob("[0-9a-f]*.[0-9]"))
results["ca_hash_links"] = len(hash_links)
else:
results["ca_hash_links"] = 0
print(f" CA bundle: {CA_BUNDLE} ({results['ca_bundle_certs']} certs, exists={results['ca_bundle_exists']})")
print(f" CA dir: {CA_DIR} ({results['ca_hash_links']} hash links)")
print(f" certifi: {results.get('certifi_path', 'N/A')} ({results.get('certifi_certs', 'N/A')} certs)")
# Custom certs
print(" Custom certs:")
custom_certs = []
for d in CUSTOM_DIRS:
if not d.is_dir():
continue
for f in sorted(d.iterdir()):
if f.suffix in (".pem", ".crt") and f.is_file():
info = _cert_info(f)
custom_certs.append(info)
print(f" {info['file']}")
print(f" subject={info['subject']}")
print(f" issuer={info['issuer']}")
print(f" fingerprint={info['fingerprint']}")
print(f" in_bundle={info['in_bundle']}")
results["custom_certs_count"] = len(custom_certs)
results["custom_certs"] = custom_certs
# ═══════════════════════════════════════════════════════
# 3. OPENSSL s_client TESTS
# ═══════════════════════════════════════════════════════
section("3. OpenSSL s_client")
# A) Default (system trust store)
rc, out, err = run_cmd([
"openssl", "s_client", "-connect", f"{host}:443",
"-servername", host,
"</dev/null"
], timeout=15)
_parse_openssl_output("openssl_default", rc, out, err)
# B) With -CAfile
rc, out, err = run_cmd([
"openssl", "s_client", "-connect", f"{host}:443",
"-CAfile", str(CA_BUNDLE),
"-servername", host,
"</dev/null"
], timeout=15)
_parse_openssl_output("openssl_cafile", rc, out, err)
# C) With -CApath
rc, out, err = run_cmd([
"openssl", "s_client", "-connect", f"{host}:443",
"-CApath", str(CA_DIR),
"-servername", host,
"</dev/null"
], timeout=15)
_parse_openssl_output("openssl_capath", rc, out, err)
# D) With custom chain bundle
_chain_test()
# ═══════════════════════════════════════════════════════
# 4. PYTHON httpx TESTS
# ═══════════════════════════════════════════════════════
section("4. Python httpx (как LLMClient)")
# A) httpx verify=True (certifi - default)
_test_httpx("httpx_true", verify=True)
# B) httpx verify=SSLContext(cafile=...)
_test_httpx("httpx_cafile", verify=_make_ctx_cafile())
# C) httpx verify=SSLContext(cafile + capath)
_test_httpx("httpx_cafile_capath", verify=_make_ctx_cafile_capath())
# D) httpx verify=False
_test_httpx("httpx_false", verify=False)
# ═══════════════════════════════════════════════════════
# 5. PYTHON requests TESTS
# ═══════════════════════════════════════════════════════
section("5. Python requests (как translate plugin)")
# A) requests verify=True (certifi)
_test_requests("requests_true", verify=True)
# B) requests verify=path-to-cafile
_test_requests("requests_cafile", verify=str(CA_BUNDLE))
# C) requests verify=path-to-capath
_test_requests("requests_capath", verify=str(CA_DIR))
# D) requests verify=False
_test_requests("requests_false", verify=False)
# ═══════════════════════════════════════════════════════
# 6. NSS DATABASE (Chromium/Playwright)
# ═══════════════════════════════════════════════════════
section("6. NSS Database (Chromium)")
rc, out, err = run_cmd(["certutil", "-L", "-d", f"sql:{NSS_DB_DIR}"], timeout=5)
if rc == 0:
results["nss_available"] = True
results["nss_db"] = str(NSS_DB_DIR)
lines = [l.strip() for l in out.split("\n") if l.strip() and not l.startswith("Certificate Nickname")]
# header line
nss_certs = [l for l in lines if len(l) > 5]
results["nss_certs_count"] = len(nss_certs) if len(nss_certs) > 1 else 0
print(f" NSS DB: {NSS_DB_DIR} ({results['nss_certs_count']} certs)")
for c in nss_certs:
if any(x in c.lower() for x in ("rusal", "llm-", "custom-")):
print(f" {c}")
else:
results["nss_available"] = False
results["nss_note"] = err.strip() or "certutil not available or NSS DB not found"
print(f" NSS: {results['nss_note']}")
# ═══════════════════════════════════════════════════════
# 7. LLM SSL VERIFY ENV
# ═══════════════════════════════════════════════════════
section("7. LLM_SSL_VERIFY")
raw = LLM_SSL_VERIFY.strip().lower()
disabled = raw in ("false", "0", "no", "off")
results["llm_ssl_verify_raw"] = raw
results["llm_ssl_verify_disabled"] = disabled
print(f" LLM_SSL_VERIFY={raw}{'DISABLED' if disabled else 'ENABLED'}")
if not disabled:
try:
ctx = _make_ctx_cafile()
results["ssl_context_works"] = True
print(f" SSLContext(cafile=...) → OK (verify_mode={ctx.verify_mode})")
except Exception as e:
results["ssl_context_works"] = False
results["ssl_context_error"] = str(e)
print(f" SSLContext(cafile=...) → FAIL: {e}")
# ═══════════════════════════════════════════════════════
# 8. JSON SUMMARY
# ═══════════════════════════════════════════════════════
section("8. JSON Summary")
# Simplify results for AI
summary = {
key: results[key]
for key in [
"target_url", "host", "llm_ssl_verify", "llm_ssl_verify_disabled",
"ca_bundle_certs", "ca_hash_links",
"custom_certs_count",
"openssl_default", "openssl_cafile", "openssl_capath",
"httpx_true", "httpx_cafile", "httpx_cafile_capath", "httpx_false",
"requests_true", "requests_cafile", "requests_capath", "requests_false",
"nss_available", "nss_certs_count",
"ssl_context_works",
]
if key in results
}
summary["ai_diagnosis"] = _diagnose(summary)
print(json.dumps(summary, indent=2, ensure_ascii=False))
# ═══════════════════════════════════════════════════════
# HELPER FUNCTIONS
# ═══════════════════════════════════════════════════════
def _count_certs(path: str) -> int:
"""Count PEM certificates in a file."""
try:
with open(path) as f:
return f.read().count("-----BEGIN CERTIFICATE-----")
except Exception:
return 0
def _cert_info(path: Path) -> dict:
"""Extract certificate info."""
import subprocess as sp
info = {"file": str(path), "format": path.suffix}
r = sp.run(
["openssl", "x509", "-in", str(path), "-noout", "-subject", "-issuer",
"-fingerprint", "-sha256"],
capture_output=True, text=True, timeout=5,
)
if r.returncode == 0:
lines = r.stdout.strip().split("\n")
for line in lines:
if line.startswith("subject="):
info["subject"] = line[8:]
elif line.startswith("issuer="):
info["issuer"] = line[7:]
elif line.startswith("SHA256 Fingerprint="):
info["fingerprint"] = line[19:]
else:
# Try DER format
r2 = sp.run(
["openssl", "x509", "-in", str(path), "-inform", "DER",
"-noout", "-subject"],
capture_output=True, text=True, timeout=5,
)
if r2.returncode == 0:
info["subject"] = r2.stdout.strip().replace("subject=", "")
info["format"] += " (DER)"
else:
info["subject"] = "UNKNOWN_FORMAT"
info.setdefault("subject", "?")
info.setdefault("issuer", "?")
info.setdefault("fingerprint", "?")
# Check if in bundle
fp = info.get("fingerprint", "")
if fp and fp != "?" and CA_BUNDLE.exists():
with open(CA_BUNDLE) as f:
info["in_bundle"] = "yes" if fp in f.read() else "no"
else:
info["in_bundle"] = "unknown"
return info
def _parse_openssl_output(key: str, rc: int, out: str, err: str) -> None:
"""Parse openssl s_client output."""
for line in out.split("\n"):
if "Verify return code" in line:
code = line.strip()
results[key] = code
emoji = "" if "0 (ok)" in code else ""
print(f" {key}: {emoji} {code}")
return
for line in err.split("\n"):
if line.strip():
results[key] = f"ERROR: {line.strip()}"
print(f" {key}: ❌ {line.strip()}")
return
results[key] = f"NO_OUTPUT (rc={rc})"
print(f" {key}: ❌ no output")
def _make_ctx_cafile():
"""Create SSLContext with cafile (как service.py)."""
import ssl
return ssl.create_default_context(cafile=str(CA_BUNDLE))
def _make_ctx_cafile_capath():
"""Create SSLContext with cafile + capath."""
import ssl
ctx = ssl.create_default_context()
if CA_BUNDLE.exists():
ctx.load_verify_locations(cafile=str(CA_BUNDLE))
if CA_DIR.is_dir():
ctx.load_verify_locations(capath=str(CA_DIR))
return ctx
def _test_httpx(key: str, verify) -> None:
"""Test httpx GET with given verify parameter."""
import httpx
try:
r = httpx.get(TARGET_URL, verify=verify, timeout=10)
results[key] = f"OK status={r.status_code}"
print(f" {key}: ✅ status={r.status_code}")
except httpx.ConnectError as e:
s = str(e)
results[key] = f"FAIL ConnectError: {s}"
print(f" {key}: ❌ ConnectError: {s[:200]}")
except Exception as e:
s = str(e)
results[key] = f"FAIL {type(e).__name__}: {s}"
print(f" {key}: ❌ {type(e).__name__}: {s[:200]}")
def _test_requests(key: str, verify) -> None:
"""Test requests GET with given verify parameter."""
import requests as req
try:
r = req.get(TARGET_URL, verify=verify, timeout=10)
results[key] = f"OK status={r.status_code}"
print(f" {key}: ✅ status={r.status_code}")
except req.exceptions.SSLError as e:
s = str(e)
results[key] = f"FAIL SSLError: {s}"
print(f" {key}: ❌ SSLError: {s[:200]}")
except Exception as e:
s = str(e)
results[key] = f"FAIL {type(e).__name__}: {s}"
print(f" {key}: ❌ {type(e).__name__}: {s[:200]}")
def _chain_test() -> None:
"""Test with custom chain bundle (all 3 RUSAL certs in one file)."""
# Find the 3 certs
certs = []
for d in CUSTOM_DIRS:
if d.is_dir():
for f in d.iterdir():
if f.suffix in (".pem", ".crt"):
certs.append(f)
if len(certs) < 3:
results["openssl_chain"] = f"SKIP (only {len(certs)} certs found)"
print(f" openssl_chain: ⚠ only {len(certs)} certs found, need 3")
return
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as tf:
chain_path = tf.name
# Составляем в порядке: ROOT → Policy → RGM (сервер → ... → root)
for name in ["RUSAL_ROOT", "Policy_CA", "RGM_Issuing"]:
for f in certs:
if name in f.name:
tf.write(f.read_text())
break
rc, out, err = run_cmd([
"openssl", "s_client", "-connect", f"{host}:443",
"-CAfile", chain_path,
"-servername", host,
], timeout=15)
os.unlink(chain_path)
_parse_openssl_output("openssl_chain", rc, out, err)
def _diagnose(s: dict) -> str:
"""Generate AI diagnosis from results."""
issues = []
# openssl
for k in ("openssl_default", "openssl_cafile", "openssl_capath"):
v = s.get(k, "")
if "0 (ok)" in str(v):
issues.append(f"{k}=OK")
else:
issues.append(f"{k}=FAIL")
# httpx
if s.get("httpx_true", "").startswith("OK"):
issues.append("certifi_works_with_target=yes")
else:
issues.append("certifi_works_with_target=no")
if s.get("httpx_cafile", "").startswith("OK"):
issues.append("system_ca_cafile_works=yes")
else:
issues.append("system_ca_cafile_works=no")
if s.get("httpx_cafile_capath", "").startswith("OK"):
issues.append("system_ca_cafile_capath_works=yes")
else:
issues.append("system_ca_cafile_capath_works=no")
if s.get("httpx_false", "").startswith("OK"):
issues.append("verify_disabled_works=yes")
else:
issues.append("verify_disabled_works=no")
# Which fix to apply
if s.get("httpx_cafile_capath", "").startswith("OK") and not s.get("httpx_cafile", "").startswith("OK"):
issues.append("FIX: use cafile+capath instead of cafile alone")
elif s.get("requests_capath", "").startswith("OK") and not s.get("requests_cafile", "").startswith("OK"):
issues.append("FIX: use capath instead of cafile for requests")
elif s.get("httpx_false", "").startswith("OK"):
issues.append("FIX: LLM_SSL_VERIFY=false works, certs not trusted via system CA")
else:
issues.append("FIX: unknown — need deeper investigation")
return "; ".join(issues)