"""Ephemeral, same-host live console transport. Console text is sanitized in the execution child, kept only in a bounded memory buffer, and relayed through an owner-only Unix socket. Nothing here writes playbook output to SQLite, audit, logs, or regular files. """ from __future__ import annotations from collections import deque import json import os from pathlib import Path import re import socket import struct import threading from urllib.parse import quote ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))") CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") SENSITIVE_ASSIGNMENT = re.compile( r"(?i)(\b(?:password|passwd|passphrase|token|secret|api[_-]?key|private[_-]?key|vault_password)\b\s*[:=]\s*)" r"(?:\"[^\"]*\"|'[^']*'|[^\s,}\]]+)" ) MAX_LINE = 4096 MAX_LINES = 500 MAX_BYTES = 256 * 1024 def console_socket(settings, ident: str) -> Path: # Keep Unix-domain paths comfortably below the platform limit, including # long test/state roots. The peer credential check and owner-only socket # permissions remain the authorization boundary. return settings.state_dir / f'.console-{ident[:12]}.sock' class Redactor: def __init__(self, secrets=()): self.secrets = [] self.add(secrets) def add(self, secrets): variants = set(self.secrets) for value in secrets: if not isinstance(value, str) or not value: continue variants.add(value) variants.add(quote(value, safe='')) try: variants.add(json.dumps(value, ensure_ascii=False)[1:-1]) except (TypeError, ValueError): pass self.secrets = sorted((v for v in variants if v), key=len, reverse=True) def clean(self, value: str) -> str: text = ANSI.sub('', str(value)).replace('\r', '').rstrip('\n') text = CONTROL.sub('', text) for secret in self.secrets: text = text.replace(secret, '*** REDACTED ***') text = SENSITIVE_ASSIGNMENT.sub(r'\1*** REDACTED ***', text) if len(text) > MAX_LINE: text = text[:MAX_LINE] + ' …[truncated]' return text def classify_failure(lines) -> str: text = '\n'.join(lines).lower() if any(marker in text for marker in ( 'unreachable!', 'failed to connect to the host via ssh', 'permission denied (publickey', 'connection timed out', 'connection refused', 'winrm', 'ntlm', 'kerberos unreachable', )): return 'Remote connection or authentication failed. Review the live console and target connectivity.' if any(marker in text for marker in ( "couldn't resolve module/action", 'syntax error', 'the error appears to be in', '[error]:', 'unexpected exception', 'non-empty plugin name is required', )): return 'Ansible configuration or playbook loading failed. Review the live console and controller prerequisites.' if any(marker in text for marker in ('failed!', 'fatal:', 'failed=', 'rescue')): return 'A playbook task failed. Review the live console; completed remote changes were not rolled back.' return 'AIM/Ansible execution failed. Review the live console while the run is active or reproduce it in AIM terminal.' class ConsoleServer: """One-job console server; snapshot and live data exist only in memory.""" def __init__(self, path: Path, secrets=()): self.path = path self.redactor = Redactor(secrets) self.buffer = deque() self.buffer_bytes = 0 self.clients = set() self.lock = threading.Lock() self.stop = threading.Event() path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) os.chmod(path.parent, 0o700) path.unlink(missing_ok=True) self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.bind(str(path)) os.chmod(path, 0o600) self.sock.listen(8) self.sock.settimeout(.5) self.thread = threading.Thread(target=self._serve, name='aim-live-console', daemon=True) self.thread.start() def _serve(self): while not self.stop.is_set(): try: conn, _ = self.sock.accept() except socket.timeout: continue except OSError: break try: if hasattr(socket, 'SO_PEERCRED'): _pid, uid, _gid = struct.unpack('3i', conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) if uid != os.getuid(): conn.close(); continue conn.settimeout(2) with self.lock: snapshot = list(self.buffer) for payload in snapshot: conn.sendall(payload) self.clients.add(conn) except OSError: with self.lock: self.clients.discard(conn) try: conn.close() except OSError: pass def _publish(self, obj): payload = (json.dumps(obj, ensure_ascii=False, separators=(',', ':')) + '\n').encode('utf-8') with self.lock: self.buffer.append(payload) self.buffer_bytes += len(payload) while len(self.buffer) > MAX_LINES or self.buffer_bytes > MAX_BYTES: self.buffer_bytes -= len(self.buffer.popleft()) clients = list(self.clients) dead = [] for conn in clients: try: conn.sendall(payload) except OSError: dead.append(conn) if dead: with self.lock: for conn in dead: self.clients.discard(conn) try: conn.close() except OSError: pass def add_secrets(self, secrets): self.redactor.add(secrets) def line(self, text: str): clean = self.redactor.clean(text) if clean: self._publish({'type': 'line', 'text': clean}) def notice(self, text: str): self._publish({'type': 'notice', 'text': self.redactor.clean(text)}) def close(self): try: self._publish({'type': 'end', 'text': 'Execution ended. Live console output is not retained.'}) except Exception: pass self.stop.set() try: self.sock.close() except OSError: pass self.thread.join(timeout=2) with self.lock: clients = list(self.clients); self.clients.clear() for conn in clients: try: conn.close() except OSError: pass self.path.unlink(missing_ok=True)