From 46a8503498bb81691778c711b929c561f78a8050 Mon Sep 17 00:00:00 2001 From: magges Date: Sun, 12 Jul 2026 21:19:47 +0000 Subject: [PATCH] Rework Terminal into a real live console; polish danger buttons and Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + + +
+

Pick a running container and click "Open Terminal" — the same live, fully interactive terminal Unraid's own Docker "Console" button opens (arrow-key history, tab completion, vim, etc. all work).

-
- @@ -207,7 +217,7 @@ function podman_asset_version(string $relPath): string
- + @@ -221,9 +231,15 @@ function podman_asset_version(string $relPath): string
+
+ Changes to storage/enabled/timeout need rc.podman restart to take effect. + +
-

Storage

+
+

Storage

Where podman keeps images, containers and volumes on disk.
+
@@ -233,40 +249,52 @@ function podman_asset_version(string $relPath): string
-
GB
+
+
GB
+
Overlay filesystem image size. Only applies the first time podman initializes storage at this path.
+
-

Autostart & Lifecycle

+
+

Autostart & Lifecycle

What runs when the array starts, and how containers shut down.
+
-
-
-
- -
seconds
-
-
- -
- - - -
#Container
+
+
- -
+ +
+
seconds
+
Grace period before a stop/restart escalates to SIGKILL.
+
+
+
+ +
+
+ + + +
#Container
+
+
Saved immediately on reorder/remove — no separate save step.
+
-

Installed Packages

-
- -
+
+

Installed Packages

Versions currently installed on this system.
+
+
+
diff --git a/webui/plugins/podman/ajax/exec.php b/webui/plugins/podman/ajax/exec.php index d5bed62..f627328 100644 --- a/webui/plugins/podman/ajax/exec.php +++ b/webui/plugins/podman/ajax/exec.php @@ -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// 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//. + * 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/.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); +} diff --git a/webui/plugins/podman/include/PodmanClient.php b/webui/plugins/podman/include/PodmanClient.php index b4e7030..a7ad1ed 100644 --- a/webui/plugins/podman/include/PodmanClient.php +++ b/webui/plugins/podman/include/PodmanClient.php @@ -193,41 +193,6 @@ final class PodmanClient // have — see that file's header comment for the full explanation). // ------------------------------------------------------------------- - /** - * Creates and immediately runs one command inside a container via the - * real libpod exec API (POST /containers/{id}/exec, then - * POST /exec/{id}/start) and returns its combined stdout+stderr output. - * Tty=true is used deliberately so the response is a plain byte stream - * with no frame-header demultiplexing needed (see containerLogs() for - * the non-TTY case, which does need it). - */ - public function execRun(string $containerId, array $cmd, string $workingDir = ''): string - { - $createBody = [ - 'AttachStdin' => false, - 'AttachStdout' => true, - 'AttachStderr' => true, - 'Tty' => true, - 'Cmd' => $cmd, - ]; - if ($workingDir !== '') { - $createBody['WorkingDir'] = $workingDir; - } - - $created = $this->request('POST', '/containers/' . rawurlencode($containerId) . '/exec', [], false, $createBody); - $execId = $created['Id'] ?? null; - if (!is_string($execId) || $execId === '') { - throw new PodmanApiException('exec create response did not include an Id'); - } - - $output = $this->requestRaw('POST', '/exec/' . rawurlencode($execId) . '/start', [], [ - 'Detach' => false, - 'Tty' => true, - ]); - - return $output; - } - // ------------------------------------------------------------------- // Pods // ------------------------------------------------------------------- diff --git a/webui/plugins/podman/javascript/images.js b/webui/plugins/podman/javascript/images.js index b09c740..6b56d15 100644 --- a/webui/plugins/podman/javascript/images.js +++ b/webui/plugins/podman/javascript/images.js @@ -20,7 +20,7 @@ '' + img.usedBy + '' + '
' + '' + - '
' + ''; } diff --git a/webui/plugins/podman/javascript/networks.js b/webui/plugins/podman/javascript/networks.js index 1ca8335..4063e0c 100644 --- a/webui/plugins/podman/javascript/networks.js +++ b/webui/plugins/podman/javascript/networks.js @@ -20,7 +20,7 @@ '' + P.escapeHtml(n.subnet || '—') + '' + '' + P.escapeHtml(n.gateway || '—') + '' + '' + n.containers + '' + - '
' + ''; } diff --git a/webui/plugins/podman/javascript/settings.js b/webui/plugins/podman/javascript/settings.js index 20b42fd..c32c255 100644 --- a/webui/plugins/podman/javascript/settings.js +++ b/webui/plugins/podman/javascript/settings.js @@ -43,9 +43,12 @@ const versions = settings.packageVersions || {}; const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS']; - P.el('settings-package-versions').textContent = order - .map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); }) - .join(' · '); + const chipsHtml = order.map(function (k) { + const name = k.toLowerCase().replace(/_/g, '-'); + const version = versions[k + '_INSTALLED_VERSION']; + return '' + P.escapeHtml(name) + ' ' + P.escapeHtml(version || '?') + ''; + }).join(''); + P.el('settings-package-versions').innerHTML = chipsHtml || 'No version manifest found.'; } function load() { diff --git a/webui/plugins/podman/javascript/terminal.js b/webui/plugins/podman/javascript/terminal.js index 262637e..341348c 100644 --- a/webui/plugins/podman/javascript/terminal.js +++ b/webui/plugins/podman/javascript/terminal.js @@ -1,89 +1,97 @@ /** * javascript/terminal.js * - * Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that - * file's header comment for the full, honest explanation of why this is - * "type a command, see its output" rather than a true interactive PTY — - * the short version is that libpod's interactive exec needs a persistent - * bidirectional connection this PHP/AJAX stack doesn't have, and faking - * interactivity on top of that would break the moment a user ran anything - * that expects a real terminal (vim, an interactive prompt, etc). + * Terminal panel: opens a real, fully interactive terminal inline (as an + * '; + }, 200); }).catch(function (err) { - appendLine('' + P.escapeHtml(err.message) + ''); + resetFrame('Could not open terminal: ' + P.escapeHtml(err.message)); + }).finally(function () { + btn.disabled = false; }); } - function populateContainerSelect(containers) { - const select = P.el('term-container-select'); - select.innerHTML = containers - .filter(function (c) { return c.state === 'running'; }) - .map(function (c) { return ''; }) - .join(''); - containerId = select.value || null; + function disconnect() { + if (!openName) return; + const btn = P.el('term-disconnect-btn'); + btn.disabled = true; + closeCurrent().finally(function () { + resetFrame('Disconnected. Pick a container and click "Open Terminal" to start a new session.'); + }); } function init() { - const input = P.el('term-input'); - - P.el('term-container-select').addEventListener('change', function (e) { - containerId = e.target.value; - cwd = '/'; - P.el('term-output').innerHTML = ''; - }); - - input.addEventListener('keydown', function (e) { - if (e.key !== 'Enter') return; - const cmd = input.value; - input.value = ''; - if (!containerId) { - appendLine('No running container selected.'); - return; - } - if (cmd.trim() === '') return; - runCommand(cmd); - }); - - return P.get('containers', 'list').then(populateContainerSelect).catch(function (err) { - appendLine('' + P.escapeHtml(err.message) + ''); - }); + P.el('term-open-btn').addEventListener('click', openLiveTerminal); + P.el('term-disconnect-btn').addEventListener('click', disconnect); + return loadContainers(); } - P.registerPanel('terminal', { init: init }); + // refresh() only repopulates the container select — it must never touch + // #term-frame-wrap, or an already-open terminal would be torn down out + // from under the user just by switching tabs and back. + P.registerPanel('terminal', { init: init, refresh: loadContainers }); })(); diff --git a/webui/plugins/podman/javascript/volumes.js b/webui/plugins/podman/javascript/volumes.js index 1064b8e..3b5a2a0 100644 --- a/webui/plugins/podman/javascript/volumes.js +++ b/webui/plugins/podman/javascript/volumes.js @@ -24,7 +24,7 @@ '' + P.escapeHtml(v.driver) + '' + '' + pathCell + '' + '' + v.usedBy + '' + - '
' + ''; } diff --git a/webui/plugins/podman/styles/podman.css b/webui/plugins/podman/styles/podman.css index c932978..dbee1f0 100644 --- a/webui/plugins/podman/styles/podman.css +++ b/webui/plugins/podman/styles/podman.css @@ -20,7 +20,7 @@ --border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1; --accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3; --good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6; - --bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee; + --bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee; --shadow: 0 1px 2px rgba(20, 22, 26, .06), 0 4px 12px rgba(20, 22, 26, .05); --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace; @@ -35,7 +35,7 @@ --border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280; --accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002; --good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d; - --bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36; + --bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36; --shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35); } } @@ -44,7 +44,7 @@ --border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280; --accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002; --good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d; - --bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36; + --bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36; --shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35); } :root[data-theme="light"] .podman-plugin { @@ -52,7 +52,7 @@ --border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1; --accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3; --good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6; - --bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee; + --bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee; --shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05); } @@ -76,11 +76,20 @@ .podman-pagehead .meta .dot-good { color: var(--good); } .podman-pagehead .meta .dot-bad { color: var(--bad); } +/* + * margin: 0 — Unraid's own webGui theme applies a 10px top/bottom margin + * to plain