aim-web2.1.0rc9
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user