#!/usr/bin/env python3 """ podman-helper.py ---------------- Unix socket helper die op de HOST draait als de gewone gebruiker. Ontvangt JSON verzoeken van de API container en voert systemctl --user uit. Beveiligingsmodel: - Alleen start / stop / restart toegestaan - Alleen .service units - Unit naam mag alleen letters, cijfers, punt, koppelteken en underscore bevatten - Meerdere gelijktijdige verbindingen worden afgehandeld via asyncio Protocol: Inkomend: {"action": "start"|"stop"|"restart", "unit": "naam.service"} Uitkomend: {"ok": true, "output": "..."} of {"ok": false, "error": "..."} """ import asyncio import json import logging import os import re import subprocess import sys # ── Configuratie ───────────────────────────────────────────────────────────── SOCKET_PATH = os.getenv( "HELPER_SOCKET", os.path.join(os.getenv("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"), "podman-helper.sock") ) LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") TIMEOUT = 30 # seconden maximaal per systemctl aanroep # ── Logging ─────────────────────────────────────────────────────────────────── logging.basicConfig( level = getattr(logging, LOG_LEVEL.upper(), logging.INFO), format = "%(asctime)s [%(levelname)s] %(message)s", datefmt = "%Y-%m-%d %H:%M:%S", stream = sys.stdout, ) log = logging.getLogger("podman-helper") # ── Whitelist ───────────────────────────────────────────────────────────────── ALLOWED_ACTIONS = {"start", "stop", "restart"} UNIT_PATTERN = re.compile(r'^[a-zA-Z0-9._\-]+\.service$') def validate(action: str, unit: str) -> str | None: """Geeft een foutmelding terug als het verzoek niet toegestaan is, anders None.""" if action not in ALLOWED_ACTIONS: return f"Actie '{action}' niet toegestaan. Gebruik: {', '.join(sorted(ALLOWED_ACTIONS))}" if not UNIT_PATTERN.match(unit): return f"Ongeldige unit naam '{unit}'. Alleen .service units met veilige tekens." return None async def run_systemctl(action: str, unit: str) -> dict: """Voert systemctl --user uit en geeft het resultaat terug.""" cmd = ["systemctl", "--user", action, unit] log.info("Uitvoeren: %s", " ".join(cmd)) try: proc = await asyncio.create_subprocess_exec( *cmd, stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.PIPE, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT) rc = proc.returncode output = stdout.decode().strip() or stderr.decode().strip() if rc == 0: log.info("OK: %s %s (rc=0)", action, unit) return {"ok": True, "output": output or f"{unit} {action} geslaagd"} else: log.warning("Mislukt: %s %s (rc=%d) %s", action, unit, rc, output) return {"ok": False, "error": output or f"{unit} {action} mislukt (rc={rc})"} except asyncio.TimeoutError: log.error("Timeout na %ds: %s %s", TIMEOUT, action, unit) return {"ok": False, "error": f"Timeout na {TIMEOUT} seconden"} except Exception as e: log.error("Onverwachte fout: %s", e) return {"ok": False, "error": str(e)} async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: """Verwerkt één client verbinding.""" peer = writer.get_extra_info("peername") or "onbekend" log.debug("Verbinding van: %s", peer) try: # Lees tot maximaal 4KB (meer dan genoeg voor een JSON verzoek) data = await asyncio.wait_for(reader.read(4096), timeout=10) if not data: return # JSON parsen try: request = json.loads(data.decode()) except json.JSONDecodeError as e: log.warning("Ongeldige JSON: %s", e) response = {"ok": False, "error": f"Ongeldige JSON: {e}"} writer.write(json.dumps(response).encode()) await writer.drain() return action = str(request.get("action", "")).strip().lower() unit = str(request.get("unit", "")).strip() # Valideren error = validate(action, unit) if error: log.warning("Afgewezen: %s", error) response = {"ok": False, "error": error} writer.write(json.dumps(response).encode()) await writer.drain() return # Uitvoeren response = await run_systemctl(action, unit) writer.write(json.dumps(response).encode()) await writer.drain() except asyncio.TimeoutError: log.warning("Client timeout bij lezen") response = {"ok": False, "error": "Timeout bij lezen verzoek"} try: writer.write(json.dumps(response).encode()) await writer.drain() except Exception: pass except Exception as e: log.error("Fout bij verwerken verbinding: %s", e) try: response = {"ok": False, "error": str(e)} writer.write(json.dumps(response).encode()) await writer.drain() except Exception: pass finally: try: writer.close() await writer.wait_closed() except Exception: pass async def main() -> None: # Ruim oude socket op als die nog bestaat if os.path.exists(SOCKET_PATH): os.unlink(SOCKET_PATH) log.info("Oude socket verwijderd: %s", SOCKET_PATH) # Zorg dat de map bestaat os.makedirs(os.path.dirname(SOCKET_PATH), exist_ok=True) server = await asyncio.start_unix_server(handle_client, path=SOCKET_PATH) # Socket alleen leesbaar voor eigenaar (de kodi user) os.chmod(SOCKET_PATH, 0o600) log.info("podman-helper gestart op %s", SOCKET_PATH) log.info("Toegestane acties: %s", ", ".join(sorted(ALLOWED_ACTIONS))) async with server: await server.serve_forever() if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: log.info("Gestopt") finally: if os.path.exists(SOCKET_PATH): os.unlink(SOCKET_PATH)