/ to that socket with a WebSocket upgrade, * generically, for ANY name. That proxy rule is already installed and * already generic — this endpoint reuses it exactly the same way Unraid's * own docker integration does, just with `podman exec -it` instead of * `docker exec -it` as the wrapped command. `ttyd-exec` itself is a small * wrapper script Unraid ships system-wide (sources /etc/default/ttyd for * common xterm.js options, then execs ttyd in the background) — not * something this plugin needs to vendor. * * This is the one place in the plugin that shells out to the `podman` * binary via proc invocation rather than the REST API — container names * are validated against a fixed safe pattern and passed through * escapeshellarg(), never concatenated into a shell string. * * Actions (?action=...): * open POST {"name": "...", "shell": "sh"|"bash"} -> {"sockName": "..."} * Caller then points an iframe/window at /logterminal//. * close POST {"name": "..."} -> {"status": "closed"} * Kills the ttyd instance (and, via it, the `podman exec` it * wraps) for that container, if one is running. */ declare(strict_types=1); require __DIR__ . '/../include/bootstrap.php'; $action = $_GET['action'] ?? ''; switch ($action) { case 'open': $body = podman_read_json_body(); $name = (string) ($body['name'] ?? ''); $shell = (string) ($body['shell'] ?? 'sh'); // Same character set libpod itself allows in container names — // rejecting anything else here (BEFORE it's ever used to build a // socket path or shell command) is what makes escapeshellarg() on // top of it a defense in depth rather than the only line of // defense. if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) { podman_json_error('Missing or invalid container name', 400); } if (!in_array($shell, ['sh', 'bash'], true)) { podman_json_error('Invalid shell', 400); } podman_json_response(open_terminal($name, $shell)); break; case 'close': $body = podman_read_json_body(); $name = (string) ($body['name'] ?? ''); if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) { podman_json_error('Missing or invalid container name', 400); } close_terminal($name); podman_json_response(['status' => 'closed']); break; default: podman_json_error("Unknown action '{$action}'", 400); } function sock_path_for(string $containerName): string { // "podman." prefix keeps this plugin's per-container sockets under // /var/tmp from ever colliding with Unraid's own docker-exec sockets // (/var/tmp/.sock), which are named after the same container // names a user might also give their podman containers. return '/var/tmp/podman.' . $containerName . '.sock'; } /** * @return array */ function open_terminal(string $containerName, string $shell): array { // Close out any previous session for this container first — sockets // are named deterministically per-container (not per-open-call), so // without this, re-opening the same container's terminal (or switching // shells) would try to bind a second ttyd to the same path and leave // the first one orphaned, still running, holding /dev resources for a // client that will never come. close_terminal($containerName); $sockPath = sock_path_for($containerName); // -s9: send SIGKILL to the wrapped command when the client disconnects // (no orphaned `podman exec` process lingering after the window is // closed). -o -m1: accept exactly one client, then exit instead of // staying resident waiting for a next one — matching exactly the // options Unraid's own OpenTerminal.php uses for `docker exec` (see // that file's 'docker' case). $cmd = sprintf( 'ttyd-exec -s9 -o -m1 -i %s podman exec -it %s %s', escapeshellarg($sockPath), escapeshellarg($containerName), escapeshellarg($shell) ); exec($cmd, $output, $exitCode); if ($exitCode !== 0) { podman_json_error('Could not start terminal session', 500); } return ['sockName' => 'podman.' . $containerName]; } /** * Kills the ttyd instance (if any) bound to this container's socket, and * removes the socket file. Matched via `pgrep -f` against the socket path * embedded in ttyd's own argv (the -i flag passed in open_terminal()) — * that's a stable, unique needle since it includes the "podman." prefix * and the validated container name. Killing ttyd itself (rather than * just closing a client connection nothing is holding) tears down the * `podman exec` child with it, same as closing a real terminal window * would once a client was attached. */ function close_terminal(string $containerName): void { $sockPath = sock_path_for($containerName); exec('pgrep -f ' . escapeshellarg($sockPath) . ' 2>/dev/null', $pids); foreach ($pids as $pid) { if (ctype_digit($pid)) { exec('kill ' . escapeshellarg($pid) . ' 2>/dev/null'); } } @unlink($sockPath); }