#!/usr/bin/env python3 """ AUTARCH Privileged Daemon Runs as root, accepts commands from the unprivileged AUTARCH web process over a Unix domain socket. This allows Flask to run as a normal user while still executing privileged operations (iptables, sysctl, iwlist scanning, systemctl, ARP manipulation, etc.) Start: sudo python3 core/daemon.py Socket: /var/run/autarch-daemon.sock Protocol: newline-delimited JSON over Unix socket Request: {"cmd": ["iptables", "-A", "INPUT", ...], "timeout": 15} Response: {"ok": true, "stdout": "...", "stderr": "...", "code": 0} """ import hashlib import hmac import json import logging import os import secrets import signal import socket import subprocess import sys import threading import time from pathlib import Path SOCKET_PATH = '/var/run/autarch-daemon.sock' PID_FILE = '/var/run/autarch-daemon.pid' LOG_FILE = '/var/log/autarch-daemon.log' SECRET_FILE = '/var/run/autarch-daemon.secret' MAX_MSG_SIZE = 1024 * 1024 # 1MB NONCE_EXPIRY = 30 # Nonces valid for 30 seconds # ── HMAC Authentication ─────────────────────────────────────────────────────── # The daemon generates a shared secret on startup and writes it to SECRET_FILE. # The client reads the secret and signs every request with HMAC-SHA256. # This prevents other users/processes from injecting commands. _daemon_secret = b'' _used_nonces = set() # Replay protection def _generate_daemon_secret() -> bytes: """Generate a random secret and write it to the secret file.""" secret = secrets.token_bytes(32) with open(SECRET_FILE, 'wb') as f: f.write(secret) # Readable only by the autarch user's group try: autarch_dir = Path(__file__).parent.parent owner_gid = autarch_dir.stat().st_gid os.chown(SECRET_FILE, 0, owner_gid) except Exception: pass os.chmod(SECRET_FILE, 0o640) # root can read/write, group can read return secret def _load_daemon_secret() -> bytes: """Load the shared secret from the secret file (client side).""" try: with open(SECRET_FILE, 'rb') as f: return f.read() except (OSError, PermissionError): return b'' def _sign_request(payload_bytes: bytes, secret: bytes) -> str: """Create HMAC-SHA256 signature for a request.""" return hmac.new(secret, payload_bytes, hashlib.sha256).hexdigest() def _verify_request(payload_bytes: bytes, signature: str, secret: bytes) -> bool: """Verify HMAC-SHA256 signature.""" expected = hmac.new(secret, payload_bytes, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) # No allowlist — any command can run EXCEPT those in the blocklist below. # The daemon runs as root and is protected by HMAC auth + SO_PEERCRED, # so only AUTARCH can talk to it. The blocklist catches destructive commands. # Commands that are NEVER allowed, even if they match an allowed prefix BLOCKED_COMMANDS = { # ── Bricks the system (irreversible) ── 'rm -rf /', 'rm -rf /*', 'rm -rf /home', 'rm -rf /etc', 'rm -rf /var', 'rm -rf /usr', 'rm -rf /boot', 'mkfs /dev/sd', 'mkfs /dev/nvme', 'mkfs /dev/mmc', 'dd if=/dev/zero of=/dev/sd', 'dd if=/dev/zero of=/dev/nvme', 'dd if=/dev/zero of=/dev/mmc', 'dd if=/dev/random of=/dev/sd', 'shred /dev/sd', 'shred /dev/nvme', 'shred /dev/mmc', 'wipefs /dev/sd', 'wipefs /dev/nvme', # ── Fork bombs ── ':(){', ':()', # ── Reboot / shutdown (human decision only) ── 'reboot', 'shutdown', 'poweroff', 'halt', 'init 0', 'init 6', 'systemctl reboot', 'systemctl poweroff', 'systemctl halt', # ── Bootloader (unrecoverable if wrong) ── 'update-grub', 'grub-install', # ── Root account destruction ── 'passwd root', 'userdel root', 'deluser root', 'usermod -L root', # ── Loopback kill (breaks everything including the daemon) ── 'ip link set lo down', 'ifconfig lo down', # ── Partition table (destroys disk layout) ── 'fdisk /dev/sd', 'fdisk /dev/nvme', 'fdisk /dev/mmc', 'parted /dev/sd', 'parted /dev/nvme', 'cfdisk', 'sfdisk', } _log = logging.getLogger('autarch.daemon') def is_command_allowed(cmd_parts: list) -> tuple: """Check if a command is allowed. Args: cmd_parts: Command as list of strings Returns: (allowed: bool, reason: str) """ if not cmd_parts: return False, 'Empty command' # Get the base command (strip path) base_cmd = os.path.basename(cmd_parts[0]) # Remove 'sudo' prefix if present (we're already root) if base_cmd == 'sudo' and len(cmd_parts) > 1: cmd_parts = cmd_parts[1:] base_cmd = os.path.basename(cmd_parts[0]) # Check against blocklist only full_cmd = ' '.join(cmd_parts) for blocked in BLOCKED_COMMANDS: if blocked in full_cmd: return False, f'Blocked: {blocked}' return True, 'OK' def execute_command(cmd_parts: list, timeout: int = 30, stdin_data: str = None) -> dict: """Execute a command and return the result. Args: cmd_parts: Command as list of strings timeout: Maximum execution time in seconds stdin_data: Optional data to send to stdin Returns: dict with ok, stdout, stderr, code """ # Strip sudo prefix — we're already root if cmd_parts and cmd_parts[0] == 'sudo': cmd_parts = cmd_parts[1:] allowed, reason = is_command_allowed(cmd_parts) if not allowed: return {'ok': False, 'stdout': '', 'stderr': reason, 'code': -1} try: result = subprocess.run( cmd_parts, capture_output=True, text=True, timeout=timeout, stdin=subprocess.PIPE if stdin_data else None, input=stdin_data, ) return { 'ok': result.returncode == 0, 'stdout': result.stdout, 'stderr': result.stderr, 'code': result.returncode, } except subprocess.TimeoutExpired: return {'ok': False, 'stdout': '', 'stderr': f'Timeout after {timeout}s', 'code': -2} except FileNotFoundError: return {'ok': False, 'stdout': '', 'stderr': f'Command not found: {cmd_parts[0]}', 'code': -3} except Exception as e: return {'ok': False, 'stdout': '', 'stderr': str(e), 'code': -4} def _verify_peer(conn: socket.socket) -> tuple: """Verify the connecting process is owned by the AUTARCH user. Uses SO_PEERCRED on Linux to get the peer's UID/PID. Returns (allowed: bool, info: str).""" try: import struct # SO_PEERCRED returns (pid, uid, gid) as 3 unsigned ints cred = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')) pid, uid, gid = struct.unpack('3i', cred) # Allow: root (uid 0), or the user who owns the autarch directory autarch_dir = Path(__file__).parent.parent owner_uid = autarch_dir.stat().st_uid owner_gid = autarch_dir.stat().st_gid if uid == 0 or uid == owner_uid or gid == owner_gid: return True, f'pid={pid} uid={uid} gid={gid}' else: return False, f'Rejected: pid={pid} uid={uid} gid={gid} (expected uid={owner_uid} or gid={owner_gid})' except (AttributeError, OSError): # SO_PEERCRED not available (non-Linux) — fall back to HMAC-only auth return True, 'peercred not available' def _builtin_capture(request: dict) -> dict: """Run scapy packet capture as root. Called by the daemon directly.""" try: from scapy.all import sniff, wrpcap except ImportError: return {'ok': False, 'error': 'scapy not available'} interface = request.get('interface', '') bpf_filter = request.get('filter', '') duration = min(int(request.get('duration', 30)), 300) max_packets = int(request.get('max_packets', 1000)) output_file = request.get('file', '') if not output_file: output_file = f'/tmp/autarch_capture_{os.getpid()}.pcap' _log.info(f'[Capture] Starting: iface={interface or "any"} duration={duration}s filter={bpf_filter or "none"} file={output_file}') try: kwargs = {'timeout': duration, 'count': max_packets, 'store': True} if interface: kwargs['iface'] = interface if bpf_filter: kwargs['filter'] = bpf_filter packets = sniff(**kwargs) count = len(packets) if packets and output_file: wrpcap(output_file, packets) os.chmod(output_file, 0o644) # Make readable by non-root _log.info(f'[Capture] Done: {count} packets captured') return { 'ok': True, 'packet_count': count, 'file': output_file if count > 0 else '', 'duration': duration, } except Exception as e: _log.error(f'[Capture] Failed: {e}') return {'ok': False, 'error': str(e)} def _builtin_wifi_scan() -> dict: """Run WiFi scan as root using iw or nmcli.""" networks = [] try: # Find wireless interface iface = None for name in os.listdir('/sys/class/net/'): if os.path.isdir(f'/sys/class/net/{name}/wireless'): iface = name break if not iface: return {'ok': False, 'error': 'No wireless interface'} # Try iw scan (needs root) r = subprocess.run(['iw', 'dev', iface, 'scan'], capture_output=True, text=True, timeout=20) if r.returncode == 0: import re current = {} for line in r.stdout.split('\n'): line = line.strip() if line.startswith('BSS '): if current.get('bssid'): networks.append(current) m = re.match(r'BSS ([\da-f:]+)', line) current = {'bssid': m.group(1) if m else '', 'ssid': '', 'channel': '', 'signal': '', 'security': ''} elif line.startswith('SSID:'): current['ssid'] = line.split(':', 1)[1].strip() or '(Hidden)' elif 'primary channel:' in line.lower(): m = re.search(r'(\d+)', line) if m: current['channel'] = m.group(1) elif 'signal:' in line.lower(): m = re.search(r'(-?\d+)', line) if m: current['signal'] = m.group(1) elif 'RSN' in line: current['security'] = 'WPA2' elif 'WPA' in line and current.get('security') != 'WPA2': current['security'] = 'WPA' if current.get('bssid'): networks.append(current) return {'ok': True, 'networks': networks, 'interface': iface} except Exception as e: return {'ok': False, 'error': str(e)} return {'ok': False, 'error': 'WiFi scan failed'} def handle_client(conn: socket.socket, addr): """Handle a single client connection.""" # Verify the peer is an authorized process allowed, peer_info = _verify_peer(conn) if not allowed: _log.warning(f'Connection rejected: {peer_info}') try: conn.sendall(json.dumps({'ok': False, 'stderr': 'Unauthorized process'}).encode() + b'\n') except Exception: pass conn.close() return try: data = b'' while True: chunk = conn.recv(4096) if not chunk: break data += chunk if b'\n' in data: break if len(data) > MAX_MSG_SIZE: conn.sendall(json.dumps({'ok': False, 'stderr': 'Message too large'}).encode() + b'\n') return if not data: return # Parse request — format: {"payload": {...}, "sig": "hmac-hex", "nonce": "..."} try: envelope = json.loads(data.decode('utf-8').strip()) except json.JSONDecodeError as e: conn.sendall(json.dumps({'ok': False, 'stderr': f'Invalid JSON: {e}'}).encode() + b'\n') return # ── HMAC Verification ── if _daemon_secret: sig = envelope.get('sig', '') nonce = envelope.get('nonce', '') payload_str = envelope.get('payload', '') if not sig or not nonce or not payload_str: _log.warning('Rejected: missing sig/nonce/payload') conn.sendall(json.dumps({'ok': False, 'stderr': 'Authentication required'}).encode() + b'\n') return # Verify signature payload_bytes = payload_str.encode() if isinstance(payload_str, str) else payload_str if not _verify_request(payload_bytes, sig, _daemon_secret): _log.warning('Rejected: invalid HMAC signature') conn.sendall(json.dumps({'ok': False, 'stderr': 'Invalid signature'}).encode() + b'\n') return # Replay protection — check nonce hasn't been used and isn't too old try: nonce_time = float(nonce.split(':')[0]) if abs(time.time() - nonce_time) > NONCE_EXPIRY: conn.sendall(json.dumps({'ok': False, 'stderr': 'Nonce expired'}).encode() + b'\n') return except (ValueError, IndexError): conn.sendall(json.dumps({'ok': False, 'stderr': 'Invalid nonce'}).encode() + b'\n') return if nonce in _used_nonces: conn.sendall(json.dumps({'ok': False, 'stderr': 'Nonce reused (replay detected)'}).encode() + b'\n') return _used_nonces.add(nonce) # Prune old nonces periodically if len(_used_nonces) > 10000: _used_nonces.clear() request = json.loads(payload_str) else: # No secret configured — accept unsigned (backwards compat during setup) request = envelope cmd = request.get('cmd') timeout = min(request.get('timeout', 30), 300) # Cap at 5 minutes stdin_data = request.get('stdin') if not cmd: conn.sendall(json.dumps({'ok': False, 'stderr': 'No cmd provided'}).encode() + b'\n') return # Handle string commands (split them) if isinstance(cmd, str): import shlex cmd = shlex.split(cmd) # ── Built-in actions (run Python directly as root, no shell) ── if cmd and cmd[0] == '__capture__': result = _builtin_capture(request) response = json.dumps(result).encode() + b'\n' conn.sendall(response) return if cmd and cmd[0] == '__wifi_scan__': result = _builtin_wifi_scan() response = json.dumps(result).encode() + b'\n' conn.sendall(response) return _log.info(f'Executing: {" ".join(cmd[:6])}{"..." if len(cmd) > 6 else ""}') # Execute result = execute_command(cmd, timeout=timeout, stdin_data=stdin_data) # Send response response = json.dumps(result).encode() + b'\n' conn.sendall(response) except BrokenPipeError: pass except Exception as e: _log.error(f'Client handler error: {e}', exc_info=True) try: conn.sendall(json.dumps({'ok': False, 'stderr': str(e)}).encode() + b'\n') except Exception: pass finally: conn.close() def run_daemon(): """Run the privileged daemon.""" global _daemon_secret # Must be root if os.geteuid() != 0: print('ERROR: autarch-daemon must run as root', file=sys.stderr) sys.exit(1) # Generate shared secret for HMAC authentication _daemon_secret = _generate_daemon_secret() # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.FileHandler(LOG_FILE), logging.StreamHandler(), ] ) # Remove stale socket if os.path.exists(SOCKET_PATH): os.unlink(SOCKET_PATH) # Create socket server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) server.bind(SOCKET_PATH) # Allow the autarch user (and group) to connect os.chmod(SOCKET_PATH, 0o770) # Try to set group ownership to the autarch user's group try: import pwd # Find the user who owns the autarch directory autarch_dir = Path(__file__).parent.parent owner_uid = autarch_dir.stat().st_uid owner_gid = autarch_dir.stat().st_gid os.chown(SOCKET_PATH, 0, owner_gid) # root:snake except Exception: # Fallback: world-accessible (less secure but works) os.chmod(SOCKET_PATH, 0o777) server.listen(10) # Write PID file with open(PID_FILE, 'w') as f: f.write(str(os.getpid())) # Handle shutdown def shutdown(signum, frame): _log.info('Shutting down...') server.close() for f in (SOCKET_PATH, PID_FILE, SECRET_FILE): if os.path.exists(f): os.unlink(f) sys.exit(0) signal.signal(signal.SIGTERM, shutdown) signal.signal(signal.SIGINT, shutdown) _log.info(f'AUTARCH daemon started on {SOCKET_PATH} (PID {os.getpid()})') _log.info(f'Blocked commands: {len(BLOCKED_COMMANDS)}') while True: try: conn, addr = server.accept() t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True) t.start() except OSError: break # Socket closed during shutdown # ── Client API (used by Flask) ──────────────────────────────────────────────── def root_exec(cmd, timeout=30, stdin=None) -> dict: """Execute a command via the privileged daemon. This is the function Flask routes call instead of subprocess.run() when they need root privileges. Args: cmd: Command as string or list timeout: Max execution time stdin: Optional stdin data Returns: dict: {'ok': bool, 'stdout': str, 'stderr': str, 'code': int} Falls back to direct subprocess if daemon is not running. """ if isinstance(cmd, str): import shlex cmd = shlex.split(cmd) # Try daemon first if os.path.exists(SOCKET_PATH): try: return _send_to_daemon(cmd, timeout, stdin) except (ConnectionRefusedError, FileNotFoundError, OSError): pass # Daemon not running, fall through # Fallback: direct execution (works if we're already root) if os.geteuid() == 0: return execute_command(cmd, timeout=timeout, stdin_data=stdin) # Fallback: try with sudo (use original subprocess.run to avoid hook recursion) sudo_cmd = ['sudo', '-n'] + cmd # -n = non-interactive run_fn = _original_subprocess_run or subprocess.run try: result = run_fn( sudo_cmd, capture_output=True, text=True, timeout=timeout ) return { 'ok': result.returncode == 0, 'stdout': result.stdout, 'stderr': result.stderr, 'code': result.returncode, } except Exception as e: return {'ok': False, 'stdout': '', 'stderr': f'No daemon, not root, sudo failed: {e}', 'code': -5} def _send_to_daemon(cmd, timeout, stdin) -> dict: """Send a signed command to the daemon via Unix socket.""" sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(timeout + 5) # Extra time for daemon processing sock.connect(SOCKET_PATH) payload = json.dumps({'cmd': cmd, 'timeout': timeout, 'stdin': stdin}) # Sign the request with HMAC if we have the shared secret secret = _load_daemon_secret() if secret: nonce = f"{time.time()}:{secrets.token_hex(8)}" sig = _sign_request(payload.encode(), secret) envelope = json.dumps({'payload': payload, 'sig': sig, 'nonce': nonce}) else: envelope = payload # Unsigned fallback sock.sendall((envelope + '\n').encode()) # Read response data = b'' while True: chunk = sock.recv(4096) if not chunk: break data += chunk if b'\n' in data: break sock.close() if not data: return {'ok': False, 'stdout': '', 'stderr': 'Empty response from daemon', 'code': -6} return json.loads(data.decode().strip()) # ── Global subprocess.run patch ─────────────────────────────────────────────── # Call install_subprocess_hook() once at startup to make ALL subprocess.run() # calls with ['sudo', ...] auto-route through the daemon. This means we never # miss a sudo call — even in third-party code or modules we haven't touched. _original_subprocess_run = None _hook_installed = False def _patched_subprocess_run(cmd, *args, **kwargs): """Drop-in replacement for subprocess.run that intercepts sudo commands.""" # Only intercept list commands starting with 'sudo' if isinstance(cmd, (list, tuple)) and len(cmd) > 1 and cmd[0] == 'sudo': actual_cmd = list(cmd[1:]) # Strip -n flag if present (we don't need it, daemon is root) if actual_cmd and actual_cmd[0] == '-n': actual_cmd = actual_cmd[1:] if actual_cmd and actual_cmd[0] == '-E': actual_cmd = actual_cmd[1:] timeout = kwargs.get('timeout', 30) input_data = kwargs.get('input') r = root_exec(actual_cmd, timeout=timeout, stdin=input_data) # Return a subprocess.CompletedProcess to match the expected interface result = subprocess.CompletedProcess( args=cmd, returncode=r['code'], stdout=r['stdout'] if kwargs.get('text') or kwargs.get('capture_output') else r['stdout'].encode(), stderr=r['stderr'] if kwargs.get('text') or kwargs.get('capture_output') else r['stderr'].encode(), ) # If check=True was passed, raise on non-zero if kwargs.get('check') and result.returncode != 0: raise subprocess.CalledProcessError( result.returncode, cmd, result.stdout, result.stderr ) return result # Not a sudo command — pass through to original subprocess.run return _original_subprocess_run(cmd, *args, **kwargs) def install_subprocess_hook(): """Install the global subprocess.run hook that intercepts sudo calls. Call this once at startup (e.g., in autarch.py or web/app.py). Safe to call multiple times — only installs once. """ global _original_subprocess_run, _hook_installed if _hook_installed: return _original_subprocess_run = subprocess.run subprocess.run = _patched_subprocess_run _hook_installed = True _log.info('[Daemon] subprocess.run hook installed — sudo calls auto-route through daemon') def uninstall_subprocess_hook(): """Remove the hook and restore original subprocess.run.""" global _hook_installed if _original_subprocess_run and _hook_installed: subprocess.run = _original_subprocess_run _hook_installed = False def is_daemon_running() -> bool: """Check if the daemon is running.""" if not os.path.exists(SOCKET_PATH): return False try: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(2) sock.connect(SOCKET_PATH) sock.close() return True except (ConnectionRefusedError, FileNotFoundError, OSError): return False if __name__ == '__main__': run_daemon()