Terminal panel now opens a genuinely interactive shell (ttyd bound to a unix socket, proxied through Unraid's own /logterminal/ nginx location — the same mechanism Unraid's own Docker "Console" button uses) instead of one-shot exec calls, shown inline with a Disconnect action; bash is the default shell. Container/shell selectors and action buttons are now correctly bottom-aligned (root cause: Unraid's theme puts a 10px margin on every <button>, never reset before). Destructive actions (Disconnect, Compose/Template Delete, Volumes/Images/ Networks Remove) get a consistent, solid red treatment at rest instead of only tinting on hover, via new --bad-strong/--bad-contrast tokens. Settings panel restructured: a real save toolbar instead of a button buried in an empty-label row, card subtitles, a toggle switch instead of a bare checkbox, and installed-package versions shown as chips. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
158 lines
6.7 KiB
PHP
158 lines
6.7 KiB
PHP
<?php
|
|
/**
|
|
* ajax/exec.php
|
|
*
|
|
* Backs the Terminal panel — and this is the one panel where "exclusively
|
|
* via podman system service, no shell hacks" needs an honest caveat spelled
|
|
* out rather than silently glossed over (the same exception ajax/compose.php
|
|
* documents for the same underlying reason: some things have no REST
|
|
* equivalent).
|
|
*
|
|
* libpod's real exec API (POST /containers/{id}/exec, then
|
|
* POST /exec/{id}/start) works by HTTP connection hijacking: the connection
|
|
* is upgraded into a raw bidirectional byte stream for the lifetime of the
|
|
* shell session. That model assumes a long-lived process holding the socket
|
|
* open on both ends (an actual terminal emulator, or a WebSocket bridge) —
|
|
* it does not fit PHP-FPM's request/response lifecycle, where each AJAX call
|
|
* is a fresh, independent, short-lived process with no memory of any
|
|
* previous one. An earlier version of this file worked around that by
|
|
* offering one-shot "run a command, see its output" exec calls — honest
|
|
* about not being a real terminal, but not what a user expects when they
|
|
* open a "Console" tab (no history, no vim, no persistent `cd`).
|
|
*
|
|
* Unraid's own webGui already solves exactly this problem for its System
|
|
* Terminal and for `docker exec` (see
|
|
* /usr/local/emhttp/plugins/dynamix/include/OpenTerminal.php's 'docker'
|
|
* case, and /etc/nginx/conf.d/locations.conf's "logterminal" location
|
|
* block) — by spawning one `ttyd` instance per session, bound to a unix
|
|
* socket under /var/tmp, wrapping the real interactive command; nginx then
|
|
* proxies /logterminal/<name>/ 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/<sockName>/.
|
|
* 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/<name>.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<string,mixed>
|
|
*/
|
|
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);
|
|
}
|