Files
autarch/core/daemon.py

709 lines
24 KiB
Python
Raw Normal View History

#!/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()