Files
podman-mvp/podman-helper/podman-helper.py
T
kodi 5196e7840f fix (helper): verplaats socket naar dedicated submap /run/podman-mvp/
Vervangt file bind-mount door directory mount om stale inode probleem
op te lossen: bij file bind-mounts bindt Podman de inode op run-tijd;
als podman-helper stopt en de socket verwijdert, wijst de container
nog steeds naar de verwijderde inode. Een directory mount lost altijd
op naar de huidige mapinhoud inclusief nieuwe inodes.

Wijzigingen:
- podman-helper.py: SOCKET_PATH → XDG_RUNTIME_DIR/podman-mvp/podman-helper.sock
- common.py: HELPER_SOCKET → /run/podman-mvp/podman-helper.sock
- CLAUDE.md: run-commando gebruikt -v /run/user/1000/podman-mvp:/run/podman-mvp

Deploy: kopieer podman-helper.py naar host, daemon-reload, restart helper,
rebuild backend image, herstart container met nieuwe mount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 09:50:31 +01:00

180 lines
6.4 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 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-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"}
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 <action> <unit> 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)