Dynamic inventory plugin — queries Bastion for managed devices, groups by OS and compliance state, bastion_* host vars, zero credentials in inventory. Credential lookup plugin — resolves short-lived credentials from Bastion's CredentialResolver at execution time. Graceful degradation when broker unavailable. Chronicle callback plugin — reports playbook lifecycle events (started, task completed, completed) to Chronicle. Optionally triggers compliance re-evaluation after playbook completion. Shared BastionClient for all plugins using stdlib urllib. Signed-off-by: Tyler King <tking@guildhouse.dev>
113 lines
3.9 KiB
Python
113 lines
3.9 KiB
Python
# 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}/")
|