aim-web2.1.0rc9

This commit is contained in:
admin_rb
2026-09-22 19:23:17 +02:00
parent d095887d2e
commit 3dfc80b782
438 changed files with 31613 additions and 1510 deletions
@@ -0,0 +1,175 @@
"""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)