176 lines
6.6 KiB
Python
176 lines
6.6 KiB
Python
"""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)
|