Additional Changes to Code

This commit is contained in:
2025-10-19 07:21:20 -06:00
parent 3deeb20545
commit 84569e3e9a
2 changed files with 218 additions and 20 deletions

View File

@@ -24,6 +24,7 @@ import threading
import contextlib import contextlib
import errno import errno
import re import re
from urllib.parse import urlparse
from typing import Any, Dict, Optional, List, Callable, Tuple from typing import Any, Dict, Optional, List, Callable, Tuple
import requests import requests
@@ -39,6 +40,18 @@ from security import AgentKeyStore
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519 from cryptography.hazmat.primitives.asymmetric import ed25519
def _iter_exception_chain(exc: BaseException):
seen = set()
while exc and exc not in seen:
yield exc
seen.add(exc)
if exc.__cause__ is not None:
exc = exc.__cause__
elif exc.__context__ is not None:
exc = exc.__context__
else:
break
# Centralized logging helpers (Agent) # Centralized logging helpers (Agent)
def _agent_logs_root() -> str: def _agent_logs_root() -> str:
try: try:
@@ -819,6 +832,8 @@ class AgentHttpClient:
self._auth_lock = threading.RLock() self._auth_lock = threading.RLock()
self._active_installer_code: Optional[str] = None self._active_installer_code: Optional[str] = None
self._cached_ssl_context: Optional[ssl.SSLContext] = None self._cached_ssl_context: Optional[ssl.SSLContext] = None
self._socketio_http_session = None
self._socketio_session_mode: Optional[Tuple[str, Optional[str]]] = None
self.refresh_base_url() self.refresh_base_url()
self._configure_verify() self._configure_verify()
self._reload_tokens_from_disk() self._reload_tokens_from_disk()
@@ -979,6 +994,7 @@ class AgentHttpClient:
f"layered_default={bundle_summary['layered_default']}", f"layered_default={bundle_summary['layered_default']}",
fname="agent.log", fname="agent.log",
) )
self._apply_socketio_transport(client, context, verify=True, fingerprint=bundle_summary["fingerprint"])
return return
# Fall back to boolean verification flags when we either do not # Fall back to boolean verification flags when we either do not
@@ -993,6 +1009,7 @@ class AgentHttpClient:
_set_attr(http_iface, "ssl_verify", verify_flag) _set_attr(http_iface, "ssl_verify", verify_flag)
_set_attr(http_iface, "verify_ssl", verify_flag) _set_attr(http_iface, "verify_ssl", verify_flag)
_reset_cached_session() _reset_cached_session()
self._apply_socketio_transport(client, None if verify_flag else False, verify=verify_flag, fingerprint=None)
_log_agent( _log_agent(
f"SocketIO TLS alignment fallback verify_flag={verify_flag}", f"SocketIO TLS alignment fallback verify_flag={verify_flag}",
fname="agent.log", fname="agent.log",
@@ -1005,32 +1022,155 @@ class AgentHttpClient:
_log_exception_trace("configure_socketio") _log_exception_trace("configure_socketio")
def socketio_ssl_params(self) -> Dict[str, Any]: def socketio_ssl_params(self) -> Dict[str, Any]:
verify = getattr(self.session, "verify", True) # Socket.IO AsyncClient.connect does not accept SSL kwargs; configuration is
if isinstance(verify, str) and os.path.isfile(verify): # handled via the Engine.IO client setup in configure_socketio.
context = self._cached_ssl_context return {}
if context is None:
context = self.key_store.build_ssl_context() def _schedule_socketio_session_close(self, session) -> None:
if context is not None: if session is None:
self._cached_ssl_context = context return
if context is not None: try:
return {"ssl_verify": verify} if session.closed:
return
except Exception:
return
try:
loop = asyncio.get_running_loop()
loop.create_task(session.close())
except RuntimeError:
try: try:
fallback = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) asyncio.run(session.close())
fallback.load_verify_locations(cafile=verify) except Exception:
self._cached_ssl_context = fallback pass
return {"ssl_verify": verify}
def _set_socketio_http_session(
self,
client: "socketio.AsyncClient",
session,
mode: Optional[Tuple[str, Optional[str]]],
) -> None:
engine = getattr(client, "eio", None)
if engine is None:
return
if self._socketio_http_session is not session and self._socketio_http_session is not None:
self._schedule_socketio_session_close(self._socketio_http_session)
self._socketio_http_session = session
self._socketio_session_mode = mode
if session is None:
engine.http = None
engine.external_http = False
else:
engine.http = session
engine.external_http = True
def _apply_socketio_transport(
self,
client: "socketio.AsyncClient",
ssl_context,
*,
verify: bool,
fingerprint: Optional[str],
) -> None:
engine = getattr(client, "eio", None)
if engine is None:
return
options = getattr(engine, "websocket_extra_options", {}) or {}
options = dict(options)
options.pop("ssl", None)
try:
import aiohttp # type: ignore
except Exception as exc:
_log_agent(
f"aiohttp unavailable for socket transport configuration: {exc}",
fname="agent.error.log",
)
self._set_socketio_http_session(client, None, None)
engine.ssl_verify = verify
engine.websocket_extra_options = options
return
if ssl_context is False or not verify:
options["ssl"] = False
engine.ssl_verify = False
try:
connector = aiohttp.TCPConnector(ssl=False)
session = aiohttp.ClientSession(connector=connector)
except Exception as exc: except Exception as exc:
self._cached_ssl_context = None
_log_agent( _log_agent(
f"SocketIO TLS fallback context build failed: {exc}; disabling verification", f"SocketIO TLS disabled but failed to create aiohttp session: {exc}",
fname="agent.error.log", fname="agent.error.log",
) )
return {"ssl_verify": False} self._set_socketio_http_session(client, None, None)
if verify is False: else:
self._set_socketio_http_session(client, session, ("noverify", None))
elif isinstance(ssl_context, ssl.SSLContext):
options.pop("ssl", None)
engine.ssl_verify = True
try:
connector = aiohttp.TCPConnector(ssl=ssl_context)
session = aiohttp.ClientSession(connector=connector)
except Exception as exc:
_log_agent(
f"SocketIO TLS session creation failed; falling back to default handling: {exc}",
fname="agent.error.log",
)
self._set_socketio_http_session(client, None, None)
else:
mode = ("context", fingerprint or "<unknown>")
self._set_socketio_http_session(client, session, mode)
else:
options.pop("ssl", None)
engine.ssl_verify = True
self._set_socketio_http_session(client, None, None)
engine.websocket_extra_options = options
def refresh_pinned_certificate(self) -> bool:
url = self.base_url or ""
parsed = urlparse(url)
host = parsed.hostname or "localhost"
port = parsed.port
if not port:
port = 443 if (parsed.scheme or "").lower() == "https" else 80
try:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, port), timeout=5) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
der = ssock.getpeercert(binary_form=True)
except Exception as exc:
_log_agent(
f"Server certificate probe failed host={host} port={port} error={exc}",
fname="agent.error.log",
)
return False
try:
pem_text = ssl.DER_cert_to_PEM_cert(der)
except Exception as exc:
_log_agent(
f"Unable to convert probed certificate to PEM: {exc}",
fname="agent.error.log",
)
return False
try:
self.key_store.save_server_certificate(pem_text)
self._cached_ssl_context = None self._cached_ssl_context = None
return {"ssl_verify": False} _log_agent(
self._cached_ssl_context = None "Refreshed pinned server certificate after TLS failure",
return {} fname="agent.log",
)
return True
except Exception as exc:
_log_agent(
f"Failed to persist refreshed server certificate: {exc}",
fname="agent.error.log",
)
return False
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Enrollment & token management # Enrollment & token management
@@ -2851,6 +2991,36 @@ async def connect_loop():
conn_err = None conn_err = None
if conn_err: if conn_err:
detail = f"{detail}; connection_error={conn_err!r}" detail = f"{detail}; connection_error={conn_err!r}"
tls_error = False
for exc in _iter_exception_chain(e):
if isinstance(exc, aiohttp.client_exceptions.ClientConnectorCertificateError):
tls_error = True
break
message = str(exc)
if "CERTIFICATE_VERIFY_FAILED" in message.upper():
tls_error = True
break
if not tls_error and conn_err and isinstance(conn_err, Exception):
for exc in _iter_exception_chain(conn_err):
if isinstance(exc, aiohttp.client_exceptions.ClientConnectorCertificateError):
tls_error = True
break
message = str(exc)
if "CERTIFICATE_VERIFY_FAILED" in message.upper():
tls_error = True
break
if tls_error:
if client.refresh_pinned_certificate():
_log_agent(
f"connect_loop attempt={attempt} refreshed server certificate after TLS failure; retrying immediately",
fname="agent.log",
)
await asyncio.sleep(1)
continue
message = ( message = (
f"connect_loop attempt={attempt} server unavailable: {detail}. " f"connect_loop attempt={attempt} server unavailable: {detail}. "
f"Retrying in {retry}s..." f"Retrying in {retry}s..."

View File

@@ -76,3 +76,31 @@ def ensure_runtime_dir(*parts: str) -> Path:
path = runtime_path(*parts) path = runtime_path(*parts)
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
return path return path
@lru_cache(maxsize=None)
def certificates_root() -> Path:
"""Base directory for persisted certificate material."""
env = _env_path("BOREALIS_CERT_ROOT")
if env:
env.mkdir(parents=True, exist_ok=True)
return env
root = project_root() / "Certificates"
root.mkdir(parents=True, exist_ok=True)
return root
def certificates_path(*parts: str) -> Path:
"""Return a path under the certificates root."""
return certificates_root().joinpath(*parts)
def ensure_certificates_dir(*parts: str) -> Path:
"""Create (if required) and return a certificates subdirectory."""
path = certificates_path(*parts)
path.mkdir(parents=True, exist_ok=True)
return path