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
This commit is contained in:
2026-07-06 21:00:28 +03:00
parent 43e3f4135e
commit 8fd23f7ea1
16 changed files with 1200 additions and 1048 deletions

View File

@@ -46,10 +46,11 @@ DEFAULT_MAX_TOKENS: int = 8192
# строки в verify=, требует SSLContext.
# @POST Returns ssl.SSLContext with capath when enabled, False when disabled.
def _get_verify() -> ssl.SSLContext | bool:
raw = os.getenv("LLM_SSL_VERIFY", "true").strip().lower()
if raw in ("false", "0", "no", "off"):
return False
return ssl.create_default_context(capath="/etc/ssl/certs/")
from ...core.ssl import httpx_verify
return httpx_verify()
# #endregion _get_verify
@@ -61,14 +62,14 @@ async def _get_http_client() -> httpx.AsyncClient:
if _http_client is None:
ssl_verify = _get_verify()
if ssl_verify is False:
log("LLMAsyncHttpClient", "EXPLORE",
"TLS verification disabled via LLM_SSL_VERIFY=false",
error="TLS verification disabled — traffic to LLM provider is unencrypted")
log("LLMAsyncHttpClient", "EXPLORE", "TLS verification disabled via LLM_SSL_VERIFY=false", error="TLS verification disabled — traffic to LLM provider is unencrypted")
_http_client = httpx.AsyncClient(
verify=ssl_verify,
timeout=httpx.Timeout(180.0),
)
return _http_client
# #endregion _get_http_client
@@ -100,13 +101,7 @@ async def call_openai_compatible(
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
system_content = (
"You are a database content translation assistant. "
"Translate the provided text accurately, preserving data semantics. "
"Respond directly with ONLY the JSON result. "
"Do NOT include any reasoning, thinking, chain-of-thought, analysis, "
"or explanation. Output ONLY valid JSON."
)
system_content = "You are a database content translation assistant. Translate the provided text accurately, preserving data semantics. Respond directly with ONLY the JSON result. Do NOT include any reasoning, thinking, chain-of-thought, analysis, or explanation. Output ONLY valid JSON."
payload: dict[str, Any] = {
"model": model,
@@ -127,43 +122,43 @@ async def call_openai_compatible(
payload["reasoning_effort"] = "none"
payload["max_tokens"] = max_tokens
logger.reason(
f"LLM request url={_sanitize_url(base_url)} model={payload.get('model')} "
f"provider_type={provider_type} "
f"response_format={'yes' if 'response_format' in payload else 'no'} "
f"prompt_len={len(prompt)}"
)
logger.reason(f"LLM request url={_sanitize_url(base_url)} model={payload.get('model')} provider_type={provider_type} response_format={'yes' if 'response_format' in payload else 'no'} prompt_len={len(prompt)}")
response, response_text = await _do_http_request(url, headers, payload)
await _handle_response_format_fallback(response, response_text, payload, url, headers)
if not response.is_success:
logger.explore(
f"LLM API error status={response.status_code} "
f"model={payload.get('model')} "
f"body={response_text[:2000]}"
)
logger.explore(f"LLM API error status={response.status_code} model={payload.get('model')} body={response_text[:2000]}")
response.raise_for_status()
data = response.json()
choices = data.get("choices", [])
if not choices:
logger.explore("LLM returned no choices", extra={
"src": "executor", "response_keys": list(data.keys()),
"response_preview": str(data)[:2000],
})
logger.explore(
"LLM returned no choices",
extra={
"src": "executor",
"response_keys": list(data.keys()),
"response_preview": str(data)[:2000],
},
)
raise ValueError("LLM returned no choices")
try:
finish_reason = choices[0].get("finish_reason") or "none"
msg = choices[0].get("message") or {}
except (TypeError, AttributeError) as e:
logger.explore("TypeError processing LLM response choices", extra={
"src": "executor_diag", "error": str(e),
"choices_0_type": type(choices[0]).__name__ if choices else "N/A",
"choices_0_repr": repr(choices[0])[:2000] if choices else "N/A",
"data_type": type(data).__name__, "data_preview": str(data)[:2000],
})
logger.explore(
"TypeError processing LLM response choices",
extra={
"src": "executor_diag",
"error": str(e),
"choices_0_type": type(choices[0]).__name__ if choices else "N/A",
"choices_0_repr": repr(choices[0])[:2000] if choices else "N/A",
"data_type": type(data).__name__,
"data_preview": str(data)[:2000],
},
)
raise ValueError(f"LLM response processing failed: {e}")
# Log provider token usage for batch sizing calibration
@@ -183,28 +178,36 @@ async def call_openai_compatible(
refusal = msg.get("refusal") if isinstance(msg, dict) else None
if refusal:
logger.explore("LLM refused to respond", extra={
"src": "executor", "refusal": str(refusal)[:500], "finish_reason": finish_reason,
})
logger.explore(
"LLM refused to respond",
extra={
"src": "executor",
"refusal": str(refusal)[:500],
"finish_reason": finish_reason,
},
)
raise ValueError(f"LLM refused to respond: {refusal}")
content = msg.get("content") if isinstance(msg, dict) else ""
if not content and isinstance(msg, dict):
content = msg.get("content") or ""
logger.reason(
f"LLM response finish_reason={finish_reason} content_len={len(content)} "
f"msg_keys={list(msg.keys()) if isinstance(msg, dict) else []}"
)
logger.reason(f"LLM response finish_reason={finish_reason} content_len={len(content)} msg_keys={list(msg.keys()) if isinstance(msg, dict) else []}")
if not content:
logger.explore("LLM returned empty content", extra={
"src": "executor", "finish_reason": finish_reason,
"msg_keys": list(msg.keys()) if isinstance(msg, dict) else [],
"response_preview": str(data)[:2000],
})
logger.explore(
"LLM returned empty content",
extra={
"src": "executor",
"finish_reason": finish_reason,
"msg_keys": list(msg.keys()) if isinstance(msg, dict) else [],
"response_preview": str(data)[:2000],
},
)
raise ValueError("LLM returned empty content")
return content, finish_reason
# #endregion call_openai_compatible
@@ -224,34 +227,34 @@ async def _do_http_request(url: str, headers: dict, payload: dict) -> tuple[http
try:
wait = int(retry_after)
except (ValueError, TypeError):
wait = 2 ** _retry_count_429
wait = 2**_retry_count_429
else:
wait = 2 ** _retry_count_429
logger.explore(f"Rate limited (429), retry {_retry_count_429}/{_max_retry_429} after {wait}s",
extra={"src": "executor", "retry_after": retry_after, "wait": wait})
wait = 2**_retry_count_429
logger.explore(f"Rate limited (429), retry {_retry_count_429}/{_max_retry_429} after {wait}s", extra={"src": "executor", "retry_after": retry_after, "wait": wait})
await asyncio.sleep(wait)
if _retry_count_429 >= _max_retry_429:
break
else:
break
return response, response_text
# #endregion _do_http_request
# #region _handle_response_format_fallback [C:1] [TYPE Function]
async def _handle_response_format_fallback(
response: httpx.Response, response_text: str, payload: dict, url: str, headers: dict,
response: httpx.Response,
response_text: str,
payload: dict,
url: str,
headers: dict,
) -> None:
"""Handle 400 errors from structured_outputs not being supported."""
_patterns = ("response_format", "structured_outputs", "structured", "json_object")
if (
not response.is_success
and response.status_code == 400
and any(p in (response_text or "").lower() for p in _patterns)
):
if not response.is_success and response.status_code == 400 and any(p in (response_text or "").lower() for p in _patterns):
client = await _get_http_client()
logger.explore("Structured outputs not supported, retrying without response_format",
extra={"src": "executor"})
logger.explore("Structured outputs not supported, retrying without response_format", extra={"src": "executor"})
payload.pop("response_format", None)
new_response = await client.post(url, headers=headers, json=payload)
# Mutate the original response object with new data
@@ -259,5 +262,7 @@ async def _handle_response_format_fallback(
response._content = new_response.content
response.encoding = new_response.encoding
response.headers = new_response.headers
# #endregion _handle_response_format_fallback
# #endregion LLMAsyncHttpClient