# Copyright 2026 Guildhouse Dev # SPDX-License-Identifier: Apache-2.0 """Shared HTTP client for Bastion broker API. Used by all plugins (inventory, credential, callback) to communicate with the Bastion broker. Handles authentication, base URL configuration, and error handling. Configuration via environment variables: BASTION_URL -- broker base URL (e.g. http://localhost:8000) BASTION_TOKEN -- bearer token for API authentication """ import json import logging import os from urllib.request import Request, urlopen from urllib.error import URLError logger = logging.getLogger(__name__) # Uses stdlib urllib to avoid requiring httpx/requests as a # dependency for the Ansible collection. Ansible control nodes # always have Python stdlib available. class BastionClient: """HTTP client for the Bastion broker API.""" def __init__(self, url=None, token=None): self.url = (url or os.environ.get("BASTION_URL", "http://localhost:8000")).rstrip("/") self.token = token or os.environ.get("BASTION_TOKEN", "") def _headers(self): h = {"Content-Type": "application/json"} if self.token: h["Authorization"] = f"Bearer {self.token}" return h def _get(self, path, params=None): """HTTP GET with auth headers.""" url = f"{self.url}{path}" if params: qs = "&".join(f"{k}={v}" for k, v in params.items()) url = f"{url}?{qs}" req = Request(url, headers=self._headers(), method="GET") try: with urlopen(req, timeout=15) as resp: return json.loads(resp.read()) except URLError as e: logger.warning("Bastion API GET %s failed: %s", path, e) return None def _post(self, path, body=None): """HTTP POST with auth headers.""" url = f"{self.url}{path}" data = json.dumps(body or {}).encode() req = Request(url, data=data, headers=self._headers(), method="POST") try: with urlopen(req, timeout=15) as resp: return json.loads(resp.read()) except URLError as e: logger.warning("Bastion API POST %s failed: %s", path, e) return None def get_devices(self, filters=None): """Query Bastion for managed device inventory.""" result = self._post( "/connectors/intune/invoke/", {"operation": "list_devices", "parameters": filters or {}}, ) if result and result.get("success"): return result.get("data", []) return [] def get_device_compliance(self, device_id): """Get compliance state for a specific device.""" result = self._post( "/connectors/intune/invoke/", {"operation": "get_compliance", "parameters": {"device_id": device_id}}, ) if result and result.get("success"): return result.get("data", {}) return None def resolve_credential(self, credential_type, target, ac_id=None): """Resolve a short-lived credential for a target host. NOTE: The broker does not yet expose a /credentials/resolve HTTP endpoint. Returns None for now -- Ansible falls back to its own credential resolution. The endpoint will be added when the CredentialResolver gets an HTTP API. """ logger.debug( "Credential resolution not yet available via HTTP API " "(type=%s, target=%s)", credential_type, target ) return None def emit_chronicle(self, event_type, event_data): """Emit a Chronicle event via the broker.""" return self._post( "/connectors/echo/invoke/", { "operation": "chronicle_proxy", "parameters": {"kind": event_type, **event_data}, }, ) def get_ac(self, ac_id): """Verify an AC is active.""" return self._get(f"/governance/session/{ac_id}/")