From e922cea16700d4e88ddabda42e68c74859911a14 Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 22 Mar 2026 14:39:38 +0100 Subject: [PATCH] added helper --- chat_export_2026-03-04.txt | 143 ---------------------- podman-helper/podman-helper.py | 179 ++++++++++++++++++++++++++++ podman-helper/podman-helper.service | 21 ++++ podman-helper/test-helper.sh | 84 +++++++++++++ 4 files changed, 284 insertions(+), 143 deletions(-) delete mode 100644 chat_export_2026-03-04.txt create mode 100644 podman-helper/podman-helper.py create mode 100644 podman-helper/podman-helper.service create mode 100755 podman-helper/test-helper.sh diff --git a/chat_export_2026-03-04.txt b/chat_export_2026-03-04.txt deleted file mode 100644 index 484b2ee..0000000 --- a/chat_export_2026-03-04.txt +++ /dev/null @@ -1,143 +0,0 @@ -Chat export (zichtbare user-assistant conversatie) -Datum: 2026-03-04 - -User: -Onderzoek de mogelikhein om in het container overzicht in het three-dot menu naast elke container(dus niet pod) De podman exec -it functionaliteit toe te voegen. Je hebt mijn toestemming om zelf curls op http://host.containers.internal:8081 uit te voeren met escalated execution - -Assistant: -Ik ga eerst de huidige backend- en WebUI-implementatie van het container-overzicht en het three-dot menu in kaart brengen, daarna test ik met curl welke API-capaciteiten al beschikbaar zijn voor exec zodat ik een minimale, backward-compatible aanpak kan voorstellen. - -Assistant (samenvatting): -- Relevante code gevonden in control/app_containers.py en webui/html/assets/js/tabs/containers.js. -- OpenAPI en live endpoints gecontroleerd via curl. -- Bevestigd dat er nog geen exec-endpoint bestond. - -User: -Ik wil eerst backend-sessie + curl-werkend, doen nog helemaal niets aan de ui. Maak een kort implementatie plan en toen welke bestande je gaat aanpassen. Nog geen apply, nog niet doorvoeren - -Assistant: -Korte plan + bestanden: -1. Scope backend-only. -2. Nieuwe endpoints voor exec start/stream/input/resize/stop. -3. In-memory sessiemanager. -4. Foutafhandeling/guardrails. -5. Curl-validatie. -Bestanden: control/app_containers.py (primair), control/common.py (alleen indien nodig), control/app.py (liefst niet). - -User: -Doe nog een keer goed odnerzoek of het wel haalbaar is want "podman auto-update --dry-run" werkt bijvoorbeeld niet in de api container - -Assistant (samenvatting onderzoek): -- podman CLI ontbreekt in backend image/runtime (command not found). -- Backend kan wel met Podman REST via socket praten (health/test-hybrid/containers endpoints). -- Conclusie: CLI-route niet haalbaar zonder imagewijziging; REST-gebaseerde exec-sessie wel haalbaar. - -User: -Nog geen akkoord, vertel eerst wat je gaat doen met de REST-gebaseerde exec-sessie (backend + curl) - -Assistant: -Plan REST-gebaseerd: -1. In-memory sessies. -2. POST /containers/{name}/exec/start. -3. GET /containers/exec/{session_id}/stream (SSE), POST input, POST resize. -4. POST stop. -5. Strikte validatie + backward compatibility. -6. Curl tests. - -User: -Doe eerst de validatie nog geen implementatie akkoord - -Assistant (samenvatting validatie): -- Socketpad en runtime bevestigd via inspect. -- Directe host-socket test vanuit agent-namespace beperkt. -- Conclusie bleef: REST-route haalbaar, maar laatste 100%-check via host-curl gewenst. - -User: -"Als je wilt, doe ik nu alleen die laatste read-only socket-validatie (nog steeds zonder implementatie)." Ja - -Assistant: -Read-only socket-curl set gegeven voor host: -- _ping/version/info -- containers list -- exec create -- exec inspect -- exec resize -(optioneel exec start) - -User (host outputs): -- Exec create gaf Id. -- Exec inspect werkte. -- Resize vóór start gaf expected 500 state improper. -- Exec start gaf 200 raw-stream. - -Assistant: -Conclusie: haalbaarheid bevestigd. Start->resize volgorde belangrijk. Klaar voor implementatie. - -User: -akkoord - -Assistant (implementatie): -- Backend-only exec sessie toegevoegd in control/app_containers.py. -- Nieuwe endpoints: - - POST /containers/{name}/exec/start - - GET /containers/exec/{session_id} - - GET /containers/exec/{session_id}/stream - - POST /containers/exec/{session_id}/input - - POST /containers/exec/{session_id}/resize - - POST /containers/exec/{session_id}/stop -- Compile checks OK. -- Op actieve runtime eerst 404 tot backend redeploy (verwacht). - -User: -ja - -Assistant: -Deploy/run stappen gegeven (build image, restart backend container, curl tests). - -User (testresultaten): -- OpenAPI toont nieuwe exec routes. -- start/stream/input/resize/stop werken. -- Ping bleef doorlopen in terminal 1. - -Assistant: -Uitleg: twee verschillende session_id’s gebruikt; stop de juiste sessie vanuit andere terminal. - -User: -Kan niet in terminal 1 want ik kan niets invoeren, ping zit in soort loop - -Assistant: -Gebruik andere terminal of Ctrl+C in stream-terminal. - -User: -Ja curl .../stop werkt in een andere terminal :) - -Assistant: -Bevestigd. Volgende stap: UI (1 menu-item, 2 modal terminal, 3 resize). - -User: -Als 1, 2 en drie klaar zijn kan ik dan in een terminal windows vanuit de webui? (nog niets implementeren) - -Assistant: -Ja, met 1/2/3 heb je functionele interactieve web-terminal (basis), met optionele latere polish. - -User: -Aplly 1, 2 en 3 - -Assistant (UI implementatie): -- Exec shell toegevoegd aan container three-dot menu (niet pod). -- Terminal modal toegevoegd in index.html. -- Frontend logic toegevoegd in containers.js: - - session start - - SSE stream - - input verzenden - - stop bij modal sluiten - - resize via ResizeObserver -- CSS styling toegevoegd in app.css. -- JS syntax check OK. - -User: -Kun je deze gehel chat in en text bestand zetten, zodat ik deze kan bewaren? - -Assistant: -Ik zet een export in een tekstbestand. - diff --git a/podman-helper/podman-helper.py b/podman-helper/podman-helper.py new file mode 100644 index 0000000..c9df758 --- /dev/null +++ b/podman-helper/podman-helper.py @@ -0,0 +1,179 @@ +#!/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) diff --git a/podman-helper/podman-helper.service b/podman-helper/podman-helper.service new file mode 100644 index 0000000..0612289 --- /dev/null +++ b/podman-helper/podman-helper.service @@ -0,0 +1,21 @@ +[Unit] +Description=Podman systemctl helper socket service +Documentation=man:systemctl(1) +After=default.target + +[Service] +Type=simple +Restart=on-failure +RestartSec=3s + +Environment=XDG_RUNTIME_DIR=/run/user/%U +Environment=LOG_LEVEL=INFO + +ExecStart=/usr/bin/python3 %h/.config/podman-mvp/podman-helper/podman-helper.py +ExecStopPost=-/bin/rm -f ${XDG_RUNTIME_DIR}/podman-helper.sock + +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target diff --git a/podman-helper/test-helper.sh b/podman-helper/test-helper.sh new file mode 100755 index 0000000..89a959f --- /dev/null +++ b/podman-helper/test-helper.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# test-helper.sh — Test de podman-helper direct op de host +# Gebruik: ./test-helper.sh (standaard: test-web.service) +set -euo pipefail + +UNIT="${1:-test-web.service}" +SOCKET="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/podman-helper.sock" + +GREEN='\033[0;32m'; RED='\033[0;31m'; NC='\033[0m' +ok() { echo -e "${GREEN}✓${NC} $*"; } +fail() { echo -e "${RED}✗${NC} $*"; } + +send() { + local action="$1" + local result + result=$(echo "{\"action\": \"$action\", \"unit\": \"$UNIT\"}" | \ + socat - UNIX-CONNECT:"$SOCKET" 2>/dev/null) + echo "$result" +} + +echo "Socket: $SOCKET" +echo "Unit: $UNIT" +echo "" + +# Check socat +command -v socat &>/dev/null || { echo "socat niet gevonden — installeer: sudo apt install socat"; exit 1; } + +# Check socket +[[ -S "$SOCKET" ]] || { fail "Socket niet gevonden. Is podman-helper.service actief?"; exit 1; } + +# ── Test 1: stop ────────────────────────────────────────────────────────────── +echo "Test 1: stop" +systemctl --user start "$UNIT" 2>/dev/null || true +sleep 2 +RESULT=$(send "stop") +echo " Response: $RESULT" +sleep 5 +STATE=$(systemctl --user is-active "$UNIT" 2>/dev/null || true) +echo " State na stop: $STATE" +[[ "$STATE" == "inactive" ]] && ok "Stop werkt" || fail "Stop mislukt (state: $STATE)" +echo "" + +# ── Test 2: start ───────────────────────────────────────────────────────────── +echo "Test 2: start" +RESULT=$(send "start") +echo " Response: $RESULT" +sleep 5 +STATE=$(systemctl --user is-active "$UNIT" 2>/dev/null || true) +echo " State na start: $STATE" +[[ "$STATE" == "active" ]] && ok "Start werkt" || fail "Start mislukt (state: $STATE)" +echo "" + +# ── Test 3: restart ─────────────────────────────────────────────────────────── +echo "Test 3: restart" +RESULT=$(send "restart") +echo " Response: $RESULT" +sleep 5 +STATE=$(systemctl --user is-active "$UNIT" 2>/dev/null || true) +echo " State na restart: $STATE" +[[ "$STATE" == "active" ]] && ok "Restart werkt" || fail "Restart mislukt (state: $STATE)" +echo "" + +# ── Test 4: ongeldige actie (whitelist check) ───────────────────────────────── +echo "Test 4: ongeldige actie (whitelist)" +RESULT=$(echo '{"action": "kill", "unit": "'"$UNIT"'"}' | \ + socat - UNIX-CONNECT:"$SOCKET" 2>/dev/null) +echo " Response: $RESULT" +echo "$RESULT" | grep -q '"ok": false' && ok "Whitelist werkt" || fail "Whitelist werkt NIET" +echo "" + +# ── Test 5: gelijktijdige aanvragen ─────────────────────────────────────────── +echo "Test 5: gelijktijdig (5 status aanvragen)" +for i in {1..5}; do + echo '{"action": "restart", "unit": "'"$UNIT"'"}' | \ + socat - UNIX-CONNECT:"$SOCKET" 2>/dev/null & +done +wait +sleep 5 +STATE=$(systemctl --user is-active "$UNIT" 2>/dev/null || true) +echo " State na gelijktijdige aanvragen: $STATE" +[[ "$STATE" == "active" ]] && ok "Gelijktijdig werkt" || fail "Gelijktijdig mislukt (state: $STATE)" +echo "" + +echo "Tests klaar."