AGENT: Windows Server Compatibility Fixes

This commit is contained in:
2025-11-08 00:27:57 -07:00
parent 14e306b223
commit bc834687bf
2 changed files with 189 additions and 61 deletions

View File

@@ -598,6 +598,15 @@ class Role:
pass pass
return 'http://localhost:5000' 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): def _ansible_log(self, msg: str, error: bool = False, run_id: str = None):
try: try:
d = os.path.join(_project_root(), 'Agent', 'Logs') d = os.path.join(_project_root(), 'Agent', 'Logs')
@@ -629,8 +638,6 @@ class Role:
async def _fetch_service_creds(self) -> dict: async def _fetch_service_creds(self) -> dict:
if self._svc_creds and isinstance(self._svc_creds, dict): if self._svc_creds and isinstance(self._svc_creds, dict):
return self._svc_creds return self._svc_creds
try:
import aiohttp
url = self._server_base().rstrip('/') + '/api/agent/checkin' url = self._server_base().rstrip('/') + '/api/agent/checkin'
payload = { payload = {
'agent_id': self.ctx.agent_id, 'agent_id': self.ctx.agent_id,
@@ -638,21 +645,24 @@ class Role:
'username': DEFAULT_SERVICE_ACCOUNT, 'username': DEFAULT_SERVICE_ACCOUNT,
} }
self._ansible_log(f"[checkin] POST {url} agent_id={self.ctx.agent_id}") self._ansible_log(f"[checkin] POST {url} agent_id={self.ctx.agent_id}")
timeout = aiohttp.ClientTimeout(total=15) client = self._http_client()
async with aiohttp.ClientSession(timeout=timeout) as sess: if client is None:
async with sess.post(url, json=payload) as resp: self._ansible_log(f"[checkin] http_client unavailable agent_id={self.ctx.agent_id}", error=True)
js = await resp.json() return {'username': DEFAULT_SERVICE_ACCOUNT, 'password': ''}
u = (js or {}).get('username') or DEFAULT_SERVICE_ACCOUNT try:
p = (js or {}).get('password') or '' 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: if u in LEGACY_SERVICE_ACCOUNTS:
self._ansible_log(f"[checkin] legacy service username {u!r}; requesting rotate", error=True) 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) return await self._rotate_service_creds(reason='legacy_username', force_username=DEFAULT_SERVICE_ACCOUNT)
self._svc_creds = {'username': u, 'password': p} self._svc_creds = {'username': u, 'password': p}
self._ansible_log(f"[checkin] received user={u} pw_len={len(p)}") self._ansible_log(f"[checkin] received user={u} pw_len={len(p)}")
return self._svc_creds return self._svc_creds
except Exception:
self._ansible_log(f"[checkin] failed agent_id={self.ctx.agent_id}", error=True)
return {'username': DEFAULT_SERVICE_ACCOUNT, 'password': ''}
def _normalize_playbook_content(self, content: str) -> str: def _normalize_playbook_content(self, content: str) -> str:
try: try:
@@ -675,8 +685,6 @@ class Role:
return content return content
async def _rotate_service_creds(self, reason: str = 'bad_credentials', force_username: Optional[str] = None) -> dict: 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' url = self._server_base().rstrip('/') + '/api/agent/service-account/rotate'
payload = { payload = {
'agent_id': self.ctx.agent_id, 'agent_id': self.ctx.agent_id,
@@ -685,12 +693,18 @@ class Role:
if force_username: if force_username:
payload['username'] = force_username payload['username'] = force_username
self._ansible_log(f"[rotate] POST {url} agent_id={self.ctx.agent_id}") self._ansible_log(f"[rotate] POST {url} agent_id={self.ctx.agent_id}")
timeout = aiohttp.ClientTimeout(total=15) client = self._http_client()
async with aiohttp.ClientSession(timeout=timeout) as sess: if client is None:
async with sess.post(url, json=payload) as resp: self._ansible_log(f"[rotate] http_client unavailable agent_id={self.ctx.agent_id}", error=True)
js = await resp.json() return await self._fetch_service_creds()
u = (js or {}).get('username') or force_username or DEFAULT_SERVICE_ACCOUNT try:
p = (js or {}).get('password') or '' 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: 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) 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) return await self._rotate_service_creds(reason='legacy_username', force_username=DEFAULT_SERVICE_ACCOUNT)
@@ -699,9 +713,6 @@ class Role:
self._svc_creds = {'username': u, 'password': p} self._svc_creds = {'username': u, 'password': p}
self._ansible_log(f"[rotate] received user={u} pw_len={len(p)}") self._ansible_log(f"[rotate] received user={u} pw_len={len(p)}")
return self._svc_creds return self._svc_creds
except Exception:
self._ansible_log(f"[rotate] failed agent_id={self.ctx.agent_id}", error=True)
return await self._fetch_service_creds()
def _ps_module_path(self) -> str: def _ps_module_path(self) -> str:
# Place PS module under Roles so it's deployed with the agent # Place PS module under Roles so it's deployed with the agent
@@ -827,17 +838,16 @@ try {{
return False return False
async def _post_recap(self, payload: dict): async def _post_recap(self, payload: dict):
try:
import aiohttp
url = self._server_base().rstrip('/') + '/api/ansible/recap/report' url = self._server_base().rstrip('/') + '/api/ansible/recap/report'
timeout = aiohttp.ClientTimeout(total=30) client = self._http_client()
async with aiohttp.ClientSession(timeout=timeout) as sess: if client is None:
async with sess.post(url, json=payload) as resp: self._log_local("Failed to post recap: http_client unavailable", error=True)
# best-effort; ignore body return
await resp.read() try:
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'))}") 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: except Exception as exc:
self._log_local(f"Failed to post recap for run_id={payload.get('run_id')}", error=True) 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): 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 {} exec_ctx = exec_ctx or {}

View File

@@ -24,10 +24,12 @@ import threading
import contextlib import contextlib
import errno import errno
import re import re
import ipaddress
from urllib.parse import urlparse 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
from requests.adapters import HTTPAdapter
try: try:
import psutil import psutil
except Exception: except Exception:
@@ -250,6 +252,19 @@ def _settings_dir():
return os.path.abspath(os.path.join(os.path.dirname(__file__), 'Settings')) 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: class _CrossProcessFileLock:
def __init__(self, path: str) -> None: def __init__(self, path: str) -> None:
self.path = path self.path = path
@@ -863,6 +878,26 @@ CONFIG = ConfigManager(CONFIG_PATH)
CONFIG.load() 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: class AgentHttpClient:
def __init__(self): def __init__(self):
self.key_store = _key_store() self.key_store = _key_store()
@@ -872,6 +907,12 @@ class AgentHttpClient:
if context_label: if context_label:
self.session.headers.setdefault(_AGENT_CONTEXT_HEADER, context_label) self.session.headers.setdefault(_AGENT_CONTEXT_HEADER, context_label)
self.base_url: Optional[str] = None 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.guid: Optional[str] = None
self.access_token: Optional[str] = None self.access_token: Optional[str] = None
self.refresh_token: Optional[str] = None self.refresh_token: Optional[str] = None
@@ -899,8 +940,12 @@ class AgentHttpClient:
url = "https://localhost:5000" url = "https://localhost:5000"
if url.endswith("/"): if url.endswith("/"):
url = url[:-1] 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: def _configure_verify(self) -> None:
cert_path = self.key_store.server_certificate_path() cert_path = self.key_store.server_certificate_path()
@@ -915,6 +960,57 @@ class AgentHttpClient:
except Exception: except Exception:
pass 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: def _reload_tokens_from_disk(self) -> None:
raw_guid = self.key_store.load_guid() raw_guid = self.key_store.load_guid()
normalized_guid = _normalize_agent_guid(raw_guid) if raw_guid else '' normalized_guid = _normalize_agent_guid(raw_guid) if raw_guid else ''
@@ -1010,8 +1106,8 @@ class AgentHttpClient:
except Exception: except Exception:
pass pass
context = None
bundle_summary = {"count": None, "fingerprint": None, "layered_default": None} bundle_summary = {"count": None, "fingerprint": None, "layered_default": None}
bundle_fp = None
context = None context = None
if isinstance(verify, str) and os.path.isfile(verify): if isinstance(verify, str) and os.path.isfile(verify):
bundle_count, bundle_fp, layered_default = self.key_store.summarize_server_certificate() bundle_count, bundle_fp, layered_default = self.key_store.summarize_server_certificate()
@@ -1021,6 +1117,11 @@ class AgentHttpClient:
"layered_default": layered_default, "layered_default": layered_default,
} }
context = self.key_store.build_ssl_context() 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: if context is not None:
self._cached_ssl_context = context self._cached_ssl_context = context
if bundle_summary["layered_default"] is None: 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 "SocketIO TLS alignment failed to build context from pinned bundle", # noqa: E501
fname="agent.error.log", 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: if context is not None:
_set_attr(engine, "ssl_context", context) _set_attr(engine, "ssl_context", context)