From bc834687bfb6f39082e6dbc22b7a8f3c83120449 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sat, 8 Nov 2025 00:27:57 -0700 Subject: [PATCH] AGENT: Windows Server Compatibility Fixes --- Data/Agent/Roles/role_PlaybookExec_SYSTEM.py | 126 ++++++++++--------- Data/Agent/agent.py | 124 +++++++++++++++++- 2 files changed, 189 insertions(+), 61 deletions(-) diff --git a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py index db1df154..8babbc70 100644 --- a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py +++ b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py @@ -598,6 +598,15 @@ class Role: pass return 'http://localhost:5000' + def _http_client(self): + try: + fn = (self.ctx.hooks or {}).get('http_client') + if callable(fn): + return fn() + except Exception: + pass + return None + def _ansible_log(self, msg: str, error: bool = False, run_id: str = None): try: d = os.path.join(_project_root(), 'Agent', 'Logs') @@ -629,30 +638,31 @@ class Role: async def _fetch_service_creds(self) -> dict: if self._svc_creds and isinstance(self._svc_creds, dict): return self._svc_creds - try: - import aiohttp - url = self._server_base().rstrip('/') + '/api/agent/checkin' - payload = { - 'agent_id': self.ctx.agent_id, - 'hostname': socket.gethostname(), - 'username': DEFAULT_SERVICE_ACCOUNT, - } - self._ansible_log(f"[checkin] POST {url} agent_id={self.ctx.agent_id}") - timeout = aiohttp.ClientTimeout(total=15) - async with aiohttp.ClientSession(timeout=timeout) as sess: - async with sess.post(url, json=payload) as resp: - js = await resp.json() - u = (js or {}).get('username') or DEFAULT_SERVICE_ACCOUNT - p = (js or {}).get('password') or '' - if u in LEGACY_SERVICE_ACCOUNTS: - self._ansible_log(f"[checkin] legacy service username {u!r}; requesting rotate", error=True) - return await self._rotate_service_creds(reason='legacy_username', force_username=DEFAULT_SERVICE_ACCOUNT) - self._svc_creds = {'username': u, 'password': p} - self._ansible_log(f"[checkin] received user={u} pw_len={len(p)}") - return self._svc_creds - except Exception: - self._ansible_log(f"[checkin] failed agent_id={self.ctx.agent_id}", error=True) + url = self._server_base().rstrip('/') + '/api/agent/checkin' + payload = { + 'agent_id': self.ctx.agent_id, + 'hostname': socket.gethostname(), + 'username': DEFAULT_SERVICE_ACCOUNT, + } + self._ansible_log(f"[checkin] POST {url} agent_id={self.ctx.agent_id}") + client = self._http_client() + if client is None: + self._ansible_log(f"[checkin] http_client unavailable agent_id={self.ctx.agent_id}", error=True) return {'username': DEFAULT_SERVICE_ACCOUNT, 'password': ''} + try: + js = await client.async_post_json('/api/agent/checkin', payload, require_auth=True) + except Exception as exc: + self._ansible_log(f"[checkin] failed agent_id={self.ctx.agent_id} err={exc}", error=True) + return {'username': DEFAULT_SERVICE_ACCOUNT, 'password': ''} + js = js if isinstance(js, dict) else {} + u = js.get('username') or DEFAULT_SERVICE_ACCOUNT + p = js.get('password') or '' + if u in LEGACY_SERVICE_ACCOUNTS: + self._ansible_log(f"[checkin] legacy service username {u!r}; requesting rotate", error=True) + return await self._rotate_service_creds(reason='legacy_username', force_username=DEFAULT_SERVICE_ACCOUNT) + self._svc_creds = {'username': u, 'password': p} + self._ansible_log(f"[checkin] received user={u} pw_len={len(p)}") + return self._svc_creds def _normalize_playbook_content(self, content: str) -> str: try: @@ -675,33 +685,34 @@ class Role: return content async def _rotate_service_creds(self, reason: str = 'bad_credentials', force_username: Optional[str] = None) -> dict: - try: - import aiohttp - url = self._server_base().rstrip('/') + '/api/agent/service-account/rotate' - payload = { - 'agent_id': self.ctx.agent_id, - 'reason': reason, - } - if force_username: - payload['username'] = force_username - self._ansible_log(f"[rotate] POST {url} agent_id={self.ctx.agent_id}") - timeout = aiohttp.ClientTimeout(total=15) - async with aiohttp.ClientSession(timeout=timeout) as sess: - async with sess.post(url, json=payload) as resp: - js = await resp.json() - u = (js or {}).get('username') or force_username or DEFAULT_SERVICE_ACCOUNT - p = (js or {}).get('password') or '' - if u in LEGACY_SERVICE_ACCOUNTS and force_username != DEFAULT_SERVICE_ACCOUNT: - self._ansible_log(f"[rotate] legacy username {u!r} returned; retrying with default", error=True) - return await self._rotate_service_creds(reason='legacy_username', force_username=DEFAULT_SERVICE_ACCOUNT) - if u in LEGACY_SERVICE_ACCOUNTS: - u = DEFAULT_SERVICE_ACCOUNT - self._svc_creds = {'username': u, 'password': p} - self._ansible_log(f"[rotate] received user={u} pw_len={len(p)}") - return self._svc_creds - except Exception: - self._ansible_log(f"[rotate] failed agent_id={self.ctx.agent_id}", error=True) + url = self._server_base().rstrip('/') + '/api/agent/service-account/rotate' + payload = { + 'agent_id': self.ctx.agent_id, + 'reason': reason, + } + if force_username: + payload['username'] = force_username + self._ansible_log(f"[rotate] POST {url} agent_id={self.ctx.agent_id}") + client = self._http_client() + if client is None: + self._ansible_log(f"[rotate] http_client unavailable agent_id={self.ctx.agent_id}", error=True) return await self._fetch_service_creds() + try: + js = await client.async_post_json('/api/agent/service-account/rotate', payload, require_auth=True) + except Exception as exc: + self._ansible_log(f"[rotate] failed agent_id={self.ctx.agent_id} err={exc}", error=True) + return await self._fetch_service_creds() + js = js if isinstance(js, dict) else {} + u = js.get('username') or force_username or DEFAULT_SERVICE_ACCOUNT + p = js.get('password') or '' + if u in LEGACY_SERVICE_ACCOUNTS and force_username != DEFAULT_SERVICE_ACCOUNT: + self._ansible_log(f"[rotate] legacy username {u!r} returned; retrying with default", error=True) + return await self._rotate_service_creds(reason='legacy_username', force_username=DEFAULT_SERVICE_ACCOUNT) + if u in LEGACY_SERVICE_ACCOUNTS: + u = DEFAULT_SERVICE_ACCOUNT + self._svc_creds = {'username': u, 'password': p} + self._ansible_log(f"[rotate] received user={u} pw_len={len(p)}") + return self._svc_creds def _ps_module_path(self) -> str: # Place PS module under Roles so it's deployed with the agent @@ -827,17 +838,16 @@ try {{ return False async def _post_recap(self, payload: dict): + url = self._server_base().rstrip('/') + '/api/ansible/recap/report' + client = self._http_client() + if client is None: + self._log_local("Failed to post recap: http_client unavailable", error=True) + return try: - import aiohttp - url = self._server_base().rstrip('/') + '/api/ansible/recap/report' - timeout = aiohttp.ClientTimeout(total=30) - async with aiohttp.ClientSession(timeout=timeout) as sess: - async with sess.post(url, json=payload) as resp: - # best-effort; ignore body - await resp.read() + await client.async_post_json('/api/ansible/recap/report', payload, require_auth=True) self._log_local(f"Posted recap: run_id={payload.get('run_id')} status={payload.get('status')} bytes={len((payload.get('recap_text') or '').encode('utf-8'))}") - except Exception: - self._log_local(f"Failed to post recap for run_id={payload.get('run_id')}", error=True) + except Exception as exc: + self._log_local(f"Failed to post recap for run_id={payload.get('run_id')}: {exc}", error=True) async def _run_playbook_runner(self, run_id: str, playbook_content: str, playbook_name: str = '', activity_job_id=None, connection: str = 'local', exec_ctx: dict = None, files=None): exec_ctx = exec_ctx or {} diff --git a/Data/Agent/agent.py b/Data/Agent/agent.py index f5cc533c..c684c711 100644 --- a/Data/Agent/agent.py +++ b/Data/Agent/agent.py @@ -24,10 +24,12 @@ import threading import contextlib import errno import re +import ipaddress from urllib.parse import urlparse from typing import Any, Dict, Optional, List, Callable, Tuple import requests +from requests.adapters import HTTPAdapter try: import psutil except Exception: @@ -250,6 +252,19 @@ def _settings_dir(): return os.path.abspath(os.path.join(os.path.dirname(__file__), 'Settings')) +def _is_literal_ip(value: Optional[str]) -> bool: + try: + if not value: + return False + candidate = value.strip().strip('[]') + if '%' in candidate: + candidate = candidate.split('%', 1)[0] + ipaddress.ip_address(candidate) + return True + except Exception: + return False + + class _CrossProcessFileLock: def __init__(self, path: str) -> None: self.path = path @@ -863,6 +878,26 @@ CONFIG = ConfigManager(CONFIG_PATH) CONFIG.load() +class _HostnameFlexibleAdapter(HTTPAdapter): + """HTTPAdapter that can disable urllib3 hostname verification.""" + + def __init__(self, *, disable_hostname_check: bool = False, **kwargs): + self._disable_hostname_check = disable_hostname_check + super().__init__(**kwargs) + + def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): + if self._disable_hostname_check: + pool_kwargs = dict(pool_kwargs or {}) + pool_kwargs['assert_hostname'] = False + super().init_poolmanager(connections, maxsize, block, **pool_kwargs) + + def proxy_manager_for(self, proxy, **proxy_kwargs): + if self._disable_hostname_check: + proxy_kwargs = dict(proxy_kwargs or {}) + proxy_kwargs['assert_hostname'] = False + return super().proxy_manager_for(proxy, **proxy_kwargs) + + class AgentHttpClient: def __init__(self): self.key_store = _key_store() @@ -872,6 +907,12 @@ class AgentHttpClient: if context_label: self.session.headers.setdefault(_AGENT_CONTEXT_HEADER, context_label) self.base_url: Optional[str] = None + self._base_scheme: Optional[str] = None + self._base_netloc: Optional[str] = None + self._base_host: Optional[str] = None + self._base_host_is_ip: bool = False + self._hostname_adapter_prefix: Optional[str] = None + self._hostname_adapter_ip_active: bool = False self.guid: Optional[str] = None self.access_token: Optional[str] = None self.refresh_token: Optional[str] = None @@ -899,8 +940,12 @@ class AgentHttpClient: url = "https://localhost:5000" if url.endswith("/"): url = url[:-1] - if url != self.base_url: - self.base_url = url + self.base_url = url + try: + parsed = urlparse(url) + except Exception: + parsed = urlparse("https://localhost:5000") + self._update_base_url_state(parsed) def _configure_verify(self) -> None: cert_path = self.key_store.server_certificate_path() @@ -915,6 +960,57 @@ class AgentHttpClient: except Exception: pass + def _update_base_url_state(self, parsed) -> None: + try: + host = (parsed.hostname or '').strip() + netloc = parsed.netloc or host + scheme = (parsed.scheme or 'https').lower() + except Exception: + host = '' + netloc = '' + scheme = 'https' + host_is_ip = _is_literal_ip(host) + state_changed = ( + scheme != self._base_scheme + or netloc != self._base_netloc + or host != (self._base_host or '') + or host_is_ip != self._base_host_is_ip + ) + self._base_scheme = scheme + self._base_netloc = netloc + self._base_host = host + self._base_host_is_ip = host_is_ip + if state_changed: + self._configure_hostname_adapter() + + def _configure_hostname_adapter(self) -> None: + scheme = self._base_scheme + netloc = self._base_netloc + if not scheme or not netloc: + return + prefix = f"{scheme}://{netloc}" + if self._base_host_is_ip: + if (not self._hostname_adapter_ip_active) or (self._hostname_adapter_prefix != prefix): + adapter = _HostnameFlexibleAdapter(disable_hostname_check=True) + self.session.mount(prefix, adapter) + self._hostname_adapter_prefix = prefix + self._hostname_adapter_ip_active = True + _log_agent( + f"Hostname verification disabled for literal IP target {prefix}", + fname="agent.log", + ) + else: + if self._hostname_adapter_ip_active: + restore_prefix = self._hostname_adapter_prefix or prefix + if restore_prefix: + self.session.mount(restore_prefix, HTTPAdapter()) + self._hostname_adapter_prefix = None + self._hostname_adapter_ip_active = False + _log_agent( + "Hostname verification restored for primary agent HTTP session", + fname="agent.log", + ) + def _reload_tokens_from_disk(self) -> None: raw_guid = self.key_store.load_guid() normalized_guid = _normalize_agent_guid(raw_guid) if raw_guid else '' @@ -1010,8 +1106,8 @@ class AgentHttpClient: except Exception: pass - context = None bundle_summary = {"count": None, "fingerprint": None, "layered_default": None} + bundle_fp = None context = None if isinstance(verify, str) and os.path.isfile(verify): bundle_count, bundle_fp, layered_default = self.key_store.summarize_server_certificate() @@ -1021,6 +1117,11 @@ class AgentHttpClient: "layered_default": layered_default, } context = self.key_store.build_ssl_context() + if context is not None and self._base_host_is_ip: + try: + context.check_hostname = False + except Exception: + pass if context is not None: self._cached_ssl_context = context if bundle_summary["layered_default"] is None: @@ -1038,6 +1139,23 @@ class AgentHttpClient: "SocketIO TLS alignment failed to build context from pinned bundle", # noqa: E501 fname="agent.error.log", ) + if self._base_host_is_ip: + try: + fallback_context = ssl.create_default_context() + fallback_context.load_verify_locations(cafile=verify) + fallback_context.check_hostname = False + context = fallback_context + self._cached_ssl_context = context + _log_agent( + "SocketIO TLS alignment generated hostname-relaxed SSLContext for literal IP target", + fname="agent.log", + ) + except Exception as exc: + context = None + _log_agent( + f"SocketIO TLS fallback context creation failed: {exc}", + fname="agent.error.log", + ) if context is not None: _set_attr(engine, "ssl_context", context)