Rework Terminal into a real live console; polish danger buttons and Settings
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>
This commit is contained in:
@@ -3,35 +3,49 @@
|
||||
* 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:
|
||||
* 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) is used here — PodmanClient::execRun() never
|
||||
* shells out to the `podman` binary. But that API's interactive mode works
|
||||
* by HTTP connection hijacking: the HTTP 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.
|
||||
* 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`).
|
||||
*
|
||||
* Rather than fake interactivity with something that would break on the
|
||||
* first multi-line prompt, `sudo`, or interactive editor, this endpoint
|
||||
* offers a deliberately simpler, honest contract: one command in, its
|
||||
* complete output back, using Tty=true so output reads like a real
|
||||
* terminal (colors, prompts-in-output, etc. survive) but with no
|
||||
* persistent shell state (`cd` does not carry over between calls — see
|
||||
* the "cwd" parameter below, which javascript/terminal.js tracks
|
||||
* client-side and resends every time instead).
|
||||
* 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.
|
||||
*
|
||||
* A true interactive PTY (arrow-key history, tab completion, vim, ...)
|
||||
* would need a WebSocket-capable process sitting between the browser and
|
||||
* podman.sock — out of scope for this PHP/AJAX stack; tracked as a
|
||||
* follow-up rather than implemented as a shell-out workaround.
|
||||
* 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=...):
|
||||
* run POST {"id": "...", "cmd": "ls -la", "cwd": "/config"}
|
||||
* 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);
|
||||
@@ -41,27 +55,103 @@ require __DIR__ . '/../include/bootstrap.php';
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'run':
|
||||
case 'open':
|
||||
$body = podman_read_json_body();
|
||||
$id = (string) ($body['id'] ?? '');
|
||||
$commandLine = (string) ($body['cmd'] ?? '');
|
||||
$cwd = (string) ($body['cwd'] ?? '');
|
||||
$name = (string) ($body['name'] ?? '');
|
||||
$shell = (string) ($body['shell'] ?? 'sh');
|
||||
|
||||
if ($id === '' || trim($commandLine) === '') {
|
||||
podman_json_error('Missing id or cmd in request body', 400);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// The command line is run through the container's own shell
|
||||
// (sh -c) so the user can type ordinary shell syntax (pipes,
|
||||
// globs, env vars) in the terminal box, exactly like a real
|
||||
// shell prompt would accept — still one real exec API call, just
|
||||
// with /bin/sh as the interpreter instead of us parsing shell
|
||||
// syntax ourselves in PHP.
|
||||
$output = $client->execRun($id, ['/bin/sh', '-c', $commandLine], $cwd);
|
||||
podman_json_response(open_terminal($name, $shell));
|
||||
break;
|
||||
|
||||
podman_json_response(['output' => $output]);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user