From 489eb966d5c197891383f9b13b44ab4f1d55867d Mon Sep 17 00:00:00 2001 From: falkoro <39274208+falkoro@users.noreply.github.com> Date: Sun, 31 May 2026 14:25:52 +0200 Subject: [PATCH] feat: per-container Restart + Pull-latest buttons (unlock-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each container row (local + remote host cards) gets Restart and Pull-latest buttons. Pull is compose-aware: it pulls and recreates the service via the container's compose labels, falling back to pull + restart for plain `docker run` containers. Also adds a compact uptime/age badge per container. Security: new POST /api/container-action is login + shell-unlock + action-header gated (same as the tmux session controls), and the UI hides the buttons until shells are unlocked + confirms each action. Container names and engines are strictly validated (no shell metacharacters) before they touch a shell; remote commands run over SSH stdin, not as command-line args. Verified against logan-gl502vs: unauthenticated action → 403; unlocked restart of glances bounced it (StartedAt advanced); cargo test (31) green. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/events.ts | 5 ++ frontend/metrics.ts | 45 +++++++++- public/app.css | 7 ++ public/events.js | 5 ++ public/metrics.js | 52 ++++++++++- src/container_actions.rs | 185 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/routes.rs | 45 +++++++++- 8 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 src/container_actions.rs diff --git a/frontend/events.ts b/frontend/events.ts index 70f2fbf..0a2ed8b 100644 --- a/frontend/events.ts +++ b/frontend/events.ts @@ -15,6 +15,7 @@ document.addEventListener('click', async (event: MouseEvent) => { const renameShellButton = target.closest('[data-rename-shell]'); const resetShellLabelButton = target.closest('[data-reset-shell-label]'); const renameSensorButton = target.closest('[data-rename-sensor]'); + const containerActionButton = target.closest('[data-container-action]'); const shellinButton = target.closest('[data-shellin]'); const resumeButton = target.closest('[data-resume]'); const removeImageButton = target.closest('[data-remove-image]'); @@ -49,6 +50,10 @@ document.addEventListener('click', async (event: MouseEvent) => { return; } if (renameSensorButton) return renameSensorLabel(renameSensorButton.dataset.renameSensor || ''); + if (containerActionButton) { + const d = containerActionButton.dataset; + return containerAction(d.chost || '', d.cengine || '', d.cname || '', d.containerAction || ''); + } if (shellinButton) { openTerminal(shellinButton.dataset.shellin || ''); return; diff --git a/frontend/metrics.ts b/frontend/metrics.ts index c21cc97..7efa452 100644 --- a/frontend/metrics.ts +++ b/frontend/metrics.ts @@ -93,11 +93,32 @@ function containerStatsText(c: ContainerInfo): string { return bits.join(' · '); } +// Compact age from a docker status string, e.g. "Up 7 days (healthy)" → "7d", "Up About an hour" +// → "~1h", "Exited (0) 3 minutes ago" → "" (stopped, no age badge). +function containerUptime(status: string): string { + if (!/^up\b/i.test(status)) return ''; + if (/about an hour/i.test(status)) return '~1h'; + if (/less than a (second|minute)/i.test(status)) return '<1m'; + const m = /(\d+)\s*(second|minute|hour|day|week|month|year)/i.exec(status); + if (!m) return ''; + const unit = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', year: 'y' }[m[2].toLowerCase()] || ''; + return `${m[1]}${unit}`; +} + +// Restart + Pull-latest buttons. Hidden via CSS unless shells are unlocked; the click handler +// confirms and the server re-checks login + unlock + action header. host = remote host id ('' local). +function containerActionsHtml(c: ContainerInfo, host: string): string { + const attrs = `data-cname="${escapeHtml(c.name)}" data-cengine="${escapeHtml(c.engine)}" data-chost="${escapeHtml(host)}"`; + return `
`; +} + // Shared row for local + remote container lists. Stopped/unhealthy get a state class for greying. -function containerRowHtml(c: ContainerInfo, extraClass = ''): string { +function containerRowHtml(c: ContainerInfo, extraClass = '', host = ''): string { const stats = containerStatsText(c); const statsHtml = stats ? `${escapeHtml(stats)}` : ''; - return `
${escapeHtml(c.name)}${escapeHtml(c.image)}
${escapeHtml(c.engine)}${escapeHtml(c.status)}${statsHtml}
`; + const age = containerUptime(c.status); + const ageHtml = age ? `${escapeHtml(age)}` : ''; + return `
${escapeHtml(c.name)}${escapeHtml(c.image)}
${escapeHtml(c.engine)}${escapeHtml(c.status)}${ageHtml}${statsHtml}${containerActionsHtml(c, host)}
`; } const SENSOR_LABEL_ALIASES_KEY = 'sdSensorLabelAliases'; @@ -272,7 +293,7 @@ function renderRemoteHosts(hosts: RemoteHostStatus[]): void { const total = typeof host.container_total === 'number' ? host.container_total : containers.length; const shownNote = total > containers.length ? ` · showing ${containers.length} of ${total}` : ''; const containerHtml = containers.length - ? `
${escapeHtml(containerHealth(containers))}${escapeHtml(shownNote)}
${containers.map((c) => containerRowHtml(c, 'remote-container')).join('')}
` + ? `
${escapeHtml(containerHealth(containers))}${escapeHtml(shownNote)}
${containers.map((c) => containerRowHtml(c, 'remote-container', host.id)).join('')}
` : `
${host.online ? 'No containers' : escapeHtml(host.error || 'Remote host is offline')}
`; const error = host.error && host.online ? `
${escapeHtml(host.error)}
` : ''; const metricsHtml = host.metrics ? remoteMetricsHtml(host.metrics) : ''; @@ -304,6 +325,24 @@ async function loadRemoteHosts(): Promise { renderRemoteHosts(payload.hosts || []); } +// Restart / pull-latest a container. Confirms first; server re-checks login + unlock + action header. +async function containerAction(host: string, engine: string, name: string, action: string): Promise { + if (!shellUnlocked) { toast('Unlock shells first to manage containers'); return; } + if (!name || !engine) return; + const verb = action === 'pull' ? 'Pull latest image for' : 'Restart'; + const where = host ? ` on ${host}` : ''; + if (!window.confirm(`${verb} "${name}"${where}?`)) return; + toast(`${action === 'pull' ? 'Pulling' : 'Restarting'} ${name}…`); + try { + const payload = await postJson('/api/container-action', { host, engine, name, action }) as { message?: string }; + toast(payload.message || `${name}: done`); + } catch (error) { + toast((error as Error).message || 'Action failed'); + } + await Promise.allSettled([loadContainers(), loadRemoteHosts()]); +} + +(window as any).containerAction = containerAction; (window as any).loadMetrics = loadMetrics; (window as any).loadContainers = loadContainers; (window as any).loadRemoteHosts = loadRemoteHosts; diff --git a/public/app.css b/public/app.css index a317b2f..eab840e 100644 --- a/public/app.css +++ b/public/app.css @@ -434,6 +434,13 @@ body.preview-fullscreen-open{overflow:hidden} .container-item.state-unhealthy{border-color:rgba(255,106,122,.45)} .container-item.state-unhealthy em{color:#ffb3bd} .container-health{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;margin-bottom:2px} +.container-item .container-age{align-self:start;justify-self:end;font-size:10px;color:#9fe9dd;font-variant-numeric:tabular-nums;border:1px solid rgba(139,246,255,.18);border-radius:999px;padding:1px 6px} +.container-actions{grid-column:1 / -1;display:flex;gap:6px;justify-content:flex-end;margin-top:2px} +.container-action{font-size:10.5px;padding:2px 9px;border-radius:6px;border:1px solid rgba(139,246,255,.22);background:rgba(139,246,255,.06);color:#c9fff3;cursor:pointer} +.container-action:hover{background:rgba(139,246,255,.16)} +.container-action[data-container-action="restart"]{border-color:rgba(255,191,73,.3);color:var(--amber)} +/* Container actions are mutating — only available once shells are unlocked. */ +body.shells-locked .container-actions{display:none} /* Remote host monitor widget */ .remote-list{display:flex;flex-direction:column;gap:8px} .remote-empty{font-size:12px} diff --git a/public/events.js b/public/events.js index 7ff145c..03d577f 100644 --- a/public/events.js +++ b/public/events.js @@ -17,6 +17,7 @@ document.addEventListener('click', async (event) => { const renameShellButton = target.closest('[data-rename-shell]'); const resetShellLabelButton = target.closest('[data-reset-shell-label]'); const renameSensorButton = target.closest('[data-rename-sensor]'); + const containerActionButton = target.closest('[data-container-action]'); const shellinButton = target.closest('[data-shellin]'); const resumeButton = target.closest('[data-resume]'); const removeImageButton = target.closest('[data-remove-image]'); @@ -58,6 +59,10 @@ document.addEventListener('click', async (event) => { } if (renameSensorButton) return renameSensorLabel(renameSensorButton.dataset.renameSensor || ''); + if (containerActionButton) { + const d = containerActionButton.dataset; + return containerAction(d.chost || '', d.cengine || '', d.cname || '', d.containerAction || ''); + } if (shellinButton) { openTerminal(shellinButton.dataset.shellin || ''); return; diff --git a/public/metrics.js b/public/metrics.js index c67d18b..1f1362a 100644 --- a/public/metrics.js +++ b/public/metrics.js @@ -43,11 +43,34 @@ function containerStatsText(c) { bits.push(c.mem); return bits.join(' · '); } +// Compact age from a docker status string, e.g. "Up 7 days (healthy)" → "7d", "Up About an hour" +// → "~1h", "Exited (0) 3 minutes ago" → "" (stopped, no age badge). +function containerUptime(status) { + if (!/^up\b/i.test(status)) + return ''; + if (/about an hour/i.test(status)) + return '~1h'; + if (/less than a (second|minute)/i.test(status)) + return '<1m'; + const m = /(\d+)\s*(second|minute|hour|day|week|month|year)/i.exec(status); + if (!m) + return ''; + const unit = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', year: 'y' }[m[2].toLowerCase()] || ''; + return `${m[1]}${unit}`; +} +// Restart + Pull-latest buttons. Hidden via CSS unless shells are unlocked; the click handler +// confirms and the server re-checks login + unlock + action header. host = remote host id ('' local). +function containerActionsHtml(c, host) { + const attrs = `data-cname="${escapeHtml(c.name)}" data-cengine="${escapeHtml(c.engine)}" data-chost="${escapeHtml(host)}"`; + return `
`; +} // Shared row for local + remote container lists. Stopped/unhealthy get a state class for greying. -function containerRowHtml(c, extraClass = '') { +function containerRowHtml(c, extraClass = '', host = '') { const stats = containerStatsText(c); const statsHtml = stats ? `${escapeHtml(stats)}` : ''; - return `
${escapeHtml(c.name)}${escapeHtml(c.image)}
${escapeHtml(c.engine)}${escapeHtml(c.status)}${statsHtml}
`; + const age = containerUptime(c.status); + const ageHtml = age ? `${escapeHtml(age)}` : ''; + return `
${escapeHtml(c.name)}${escapeHtml(c.image)}
${escapeHtml(c.engine)}${escapeHtml(c.status)}${ageHtml}${statsHtml}${containerActionsHtml(c, host)}
`; } const SENSOR_LABEL_ALIASES_KEY = 'sdSensorLabelAliases'; let latestMachineMetrics = null; @@ -226,7 +249,7 @@ function renderRemoteHosts(hosts) { const total = typeof host.container_total === 'number' ? host.container_total : containers.length; const shownNote = total > containers.length ? ` · showing ${containers.length} of ${total}` : ''; const containerHtml = containers.length - ? `
${escapeHtml(containerHealth(containers))}${escapeHtml(shownNote)}
${containers.map((c) => containerRowHtml(c, 'remote-container')).join('')}
` + ? `
${escapeHtml(containerHealth(containers))}${escapeHtml(shownNote)}
${containers.map((c) => containerRowHtml(c, 'remote-container', host.id)).join('')}
` : `
${host.online ? 'No containers' : escapeHtml(host.error || 'Remote host is offline')}
`; const error = host.error && host.online ? `
${escapeHtml(host.error)}
` : ''; const metricsHtml = host.metrics ? remoteMetricsHtml(host.metrics) : ''; @@ -260,6 +283,29 @@ async function loadRemoteHosts() { const payload = await response.json(); renderRemoteHosts(payload.hosts || []); } +// Restart / pull-latest a container. Confirms first; server re-checks login + unlock + action header. +async function containerAction(host, engine, name, action) { + if (!shellUnlocked) { + toast('Unlock shells first to manage containers'); + return; + } + if (!name || !engine) + return; + const verb = action === 'pull' ? 'Pull latest image for' : 'Restart'; + const where = host ? ` on ${host}` : ''; + if (!window.confirm(`${verb} "${name}"${where}?`)) + return; + toast(`${action === 'pull' ? 'Pulling' : 'Restarting'} ${name}…`); + try { + const payload = await postJson('/api/container-action', { host, engine, name, action }); + toast(payload.message || `${name}: done`); + } + catch (error) { + toast(error.message || 'Action failed'); + } + await Promise.allSettled([loadContainers(), loadRemoteHosts()]); +} +window.containerAction = containerAction; window.loadMetrics = loadMetrics; window.loadContainers = loadContainers; window.loadRemoteHosts = loadRemoteHosts; diff --git a/src/container_actions.rs b/src/container_actions.rs new file mode 100644 index 0000000..c28af8d --- /dev/null +++ b/src/container_actions.rs @@ -0,0 +1,185 @@ +// Mutating container actions (restart / pull-latest) for the dashboard, run locally via `sh -c` +// or on a remote host via SSH stdin (`sh -s`). Names and engines are strictly validated before +// they ever reach a shell, so there is nothing to inject. These endpoints are login + shell-unlock +// + action-header gated in routes.rs. + +use std::time::Duration; +use tokio::{io::AsyncWriteExt, process::Command, time::timeout}; + +// docker/podman names: first char alphanumeric, then [A-Za-z0-9_.-]. This charset has no shell +// metacharacters, so a validated name is safe to splice into a shell command. +pub fn clean_name(raw: &str) -> Option { + let name: String = raw + .trim() + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) + .take(64) + .collect(); + let starts_ok = name + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric()); + (starts_ok && name.len() == raw.trim().len()).then_some(name) +} + +pub fn engine_ok(engine: &str) -> bool { + matches!(engine, "docker" | "podman") +} + +// Pull-latest: if the container is compose-managed (has project labels) pull + recreate that +// service; otherwise pull the image and restart (a plain `docker run` container can't be recreated +// without its original args, so we at least fetch the new image and bounce it). +const PULL_TEMPLATE: &str = r#"set -e +ENG=__ENGINE__ +NAME=__NAME__ +proj=$($ENG inspect -f '{{ index .Config.Labels "com.docker.compose.project.config_files" }}' "$NAME" 2>/dev/null || true) +svc=$($ENG inspect -f '{{ index .Config.Labels "com.docker.compose.service" }}' "$NAME" 2>/dev/null || true) +dir=$($ENG inspect -f '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' "$NAME" 2>/dev/null || true) +img=$($ENG inspect -f '{{ .Config.Image }}' "$NAME" 2>/dev/null || true) +if [ -n "$proj" ] && [ -n "$svc" ]; then + [ -n "$dir" ] && cd "$dir" 2>/dev/null || true + $ENG compose -f "$proj" pull "$svc" && $ENG compose -f "$proj" up -d "$svc" && echo "pulled + recreated $NAME ($svc)" +else + $ENG pull "$img" && $ENG restart "$NAME" && echo "pulled $img + restarted $NAME (not compose-managed)" +fi +"#; + +// Returns (script, timeout_ms) for the action, or None for an unknown action. +fn action_script(engine: &str, name: &str, action: &str) -> Option<(String, u64)> { + match action { + "restart" => Some((format!("{engine} restart {name} && echo \"restarted {name}\"\n"), 30_000)), + "pull" => Some(( + PULL_TEMPLATE + .replace("__ENGINE__", engine) + .replace("__NAME__", name), + // image pulls can be slow. + 240_000, + )), + _ => None, + } +} + +fn validate(engine: &str, name: &str, action: &str) -> Result<(String, u64), String> { + if !engine_ok(engine) { + return Err("Unknown container engine".to_string()); + } + let name = clean_name(name).ok_or_else(|| "Invalid container name".to_string())?; + action_script(engine, &name, action).ok_or_else(|| "Unknown action".to_string()) +} + +fn finish(stdout: &[u8], stderr: &[u8], ok: bool, fallback: &str) -> Result { + let pick = |bytes: &[u8]| { + String::from_utf8_lossy(bytes) + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .chars() + .filter(|c| !c.is_control()) + .take(200) + .collect::() + }; + if ok { + let msg = pick(stdout); + Ok(if msg.is_empty() { + fallback.to_string() + } else { + msg + }) + } else { + let err = pick(stderr); + Err(if err.is_empty() { + "Command failed".to_string() + } else { + err + }) + } +} + +// Run an action against a container on the ShellDeck host itself. +pub async fn local(engine: &str, name: &str, action: &str) -> Result { + let (script, budget) = validate(engine, name, action)?; + let mut command = Command::new("sh"); + command.args(["-c", script.as_str()]).kill_on_drop(true); + let output = timeout(Duration::from_millis(budget), command.output()) + .await + .map_err(|_| "Action timed out".to_string())? + .map_err(|_| "Could not run the action".to_string())?; + finish( + &output.stdout, + &output.stderr, + output.status.success(), + "done", + ) +} + +// Run an action against a container on a remote host, feeding the script over SSH stdin. +pub async fn remote( + target: &str, + engine: &str, + name: &str, + action: &str, +) -> Result { + let (script, budget) = validate(engine, name, action)?; + let mut command = Command::new("ssh"); + command + .args([ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=3", + "-o", + "NumberOfPasswordPrompts=0", + target, + "sh", + "-s", + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let mut child = command + .spawn() + .map_err(|_| "Could not start SSH".to_string())?; + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(script.as_bytes()).await; + } + let output = timeout(Duration::from_millis(budget), child.wait_with_output()) + .await + .map_err(|_| "Action timed out".to_string())? + .map_err(|_| "SSH failed".to_string())?; + finish( + &output.stdout, + &output.stderr, + output.status.success(), + "done", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_bad_names_and_engines() { + assert!(clean_name("memoh-server").is_some()); + assert!(clean_name("ig-yente-api").is_some()); + assert!(clean_name("a b").is_none()); // space + assert!(clean_name("$(rm -rf /)").is_none()); // injection + assert!(clean_name("-bad").is_none()); // leading dash + assert!(clean_name("").is_none()); + assert!(engine_ok("docker")); + assert!(!engine_ok("sh")); + } + + #[test] + fn builds_scripts_and_rejects_unknown_action() { + let (restart, _) = validate("docker", "glances", "restart").unwrap(); + assert!(restart.contains("docker restart glances")); + let (pull, ms) = validate("docker", "glances", "pull").unwrap(); + assert!(pull.contains("compose -f") && pull.contains("glances")); + assert!(ms > 60_000); + assert!(validate("docker", "glances", "delete").is_err()); + assert!(validate("evil", "glances", "restart").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 5bd5616..033e267 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod auth; mod config; +mod container_actions; mod containers; mod links; mod metrics; diff --git a/src/routes.rs b/src/routes.rs index 2719356..bcf24ea 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,6 +1,6 @@ use crate::{ - auth, config, containers, links, metrics, pages, ratelimit, remote, remote_hosts, settings, - share, stream, stt, summary, term, tmux, uploads, webutil, AppState, + auth, config, container_actions, containers, links, metrics, pages, ratelimit, remote, + remote_hosts, settings, share, stream, stt, summary, term, tmux, uploads, webutil, AppState, }; use axum::{ extract::{ConnectInfo, DefaultBodyLimit, Path, Query, State}, @@ -35,6 +35,15 @@ struct NameBody { name: String, } +#[derive(Deserialize)] +struct ContainerActionBody { + // Remote host id (from remote-hosts config) for a remote container; omitted/empty = local. + host: Option, + engine: String, + name: String, + action: String, +} + #[derive(Deserialize)] struct LinesQuery { lines: Option, @@ -107,6 +116,7 @@ pub fn router(state: AppState) -> Router { ) .route("/api/start", post(api_start)) .route("/api/restart", post(api_restart)) + .route("/api/container-action", post(api_container_action)) .with_state(state) } @@ -586,6 +596,37 @@ async fn api_restart( session_result(tmux::restart_session(state.config.clone(), &body.name).await) } +// Restart / pull-latest a Docker/Podman container, locally or on a configured remote host. +// Mutating: login + shell-unlock + action-header gated, same as the tmux session controls. +async fn api_container_action( + State(state): State, + headers: HeaderMap, + connect: ConnectInfo, + axum::Json(body): axum::Json, +) -> Response { + if let Some(response) = guard(&state, &headers, &connect) { + return response; + } + if let Err(response) = require_unlock(&state, &headers).and_then(|_| require_action(&headers)) { + return response; + } + let host_id = body.host.as_deref().unwrap_or("").trim(); + let result = if host_id.is_empty() { + container_actions::local(&body.engine, &body.name, &body.action).await + } else { + // Resolve the remote host's SSH target from the runtime config by id. + let hosts = remote_hosts::load(state.config.clone()).await; + match hosts.into_iter().find(|h| h.id == host_id) { + Some(host) => { + container_actions::remote(&host.target, &body.engine, &body.name, &body.action) + .await + } + None => Err("Unknown remote host".to_string()), + } + }; + session_result(result) +} + // Live machine stats (CPU / RAM / temps) for the host ShellDeck runs on. Login-gated // like the rest of the dashboard, but not behind the shell unlock — they are not sensitive. async fn api_metrics(