ed94ee31f4
- podman-helper: voeg daemon-reload toe aan ALLOWED_ACTIONS; actions in NO_UNIT_ACTIONS slaan unit-validatie over en bouwen cmd zonder unit argument - app_system: /daemon-reload endpoint gebruikt nu _helper_call in plaats van directe subprocess; verwijder subprocess import - app_system: health check legt systemd_reachable af van helper_ok in plaats van systemctl --user list-units — de helper draait als host-user en impliceert systemd bereikbaarheid - CLAUDE.md: verwijder DBUS_SESSION_BUS_ADDRESS env var; D-Bus mount is niet meer nodig Deploy: kopieer podman-helper.py naar host, daemon-reload, restart helper, rebuild backend image, herstart container zonder bus mount. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
185 lines
6.6 KiB
Python
185 lines
6.6 KiB
Python
#!/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 / daemon-reload toegestaan
|
|
- start/stop/restart: alleen .service units met veilige tekens
|
|
- daemon-reload: geen unit naam, wordt genegeerd
|
|
- Meerdere gelijktijdige verbindingen worden afgehandeld via asyncio
|
|
|
|
Protocol:
|
|
Inkomend: {"action": "start"|"stop"|"restart", "unit": "naam.service"}
|
|
{"action": "daemon-reload", "unit": ""}
|
|
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-mvp", "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", "daemon-reload"}
|
|
UNIT_PATTERN = re.compile(r'^[a-zA-Z0-9._\-]+\.service$')
|
|
NO_UNIT_ACTIONS = {"daemon-reload"}
|
|
|
|
|
|
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 action not in NO_UNIT_ACTIONS and 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 <action> [unit] uit en geeft het resultaat terug."""
|
|
if action in NO_UNIT_ACTIONS:
|
|
cmd = ["systemctl", "--user", action]
|
|
else:
|
|
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)
|