added helper
This commit is contained in:
@@ -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 <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)
|
||||
@@ -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
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-helper.sh — Test de podman-helper direct op de host
|
||||
# Gebruik: ./test-helper.sh <unit> (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."
|
||||
Reference in New Issue
Block a user