feat(sandbox): port-in-use consent gate + uniform sudo capture
GUI launch (docker/podman) now pre-flights the noVNC port (6080): if it's already bound, a modal names the holding pid and asks before killing it and proceeding — never a blind `bind: address already in use` collision. Sudo capture is now uniform across every backend installer. Previously only Docker fed the masked-modal password through `--stdin-pass`/`sudo -S`; the captured password was silently dropped for Podman/Multipass and never wired for VirtualBox, and those scripts used bare `sudo` (which hangs/corrupts a raw-mode tty). Now: - shared `run_ensure()` feeds the password to any ensure-*.sh via stdin - podman/multipass/vbox scripts gain the docker sudo ladder (interactive `sudo` / `--yes` `sudo -n` / `--stdin-pass` `sudo -S -p ''`) - podman's apt path wrapped in `sh -c` so one sudo covers update+install - vbox GUI path preflights `sudo_ready()` with actionable guidance, falling back to fail-fast `sudo -n` instead of a tty hang Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,21 +14,35 @@
|
|||||||
# ./ensure-multipass.sh --yes # install without prompting
|
# ./ensure-multipass.sh --yes # install without prompting
|
||||||
# ./ensure-multipass.sh --check # test only; exit 0 if present, 1 if missing
|
# ./ensure-multipass.sh --check # test only; exit 0 if present, 1 if missing
|
||||||
# ./ensure-multipass.sh --plan # show the install plan; change nothing
|
# ./ensure-multipass.sh --plan # show the install plan; change nothing
|
||||||
|
# ./ensure-multipass.sh --stdin-pass # read a sudo password from stdin (sudo -S)
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
ASSUME_YES=0
|
ASSUME_YES=0
|
||||||
CHECK_ONLY=0
|
CHECK_ONLY=0
|
||||||
PLAN_ONLY=0
|
PLAN_ONLY=0
|
||||||
|
STDIN_PASS=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
-y|--yes) ASSUME_YES=1 ;;
|
-y|--yes) ASSUME_YES=1 ;;
|
||||||
--check) CHECK_ONLY=1 ;;
|
--check) CHECK_ONLY=1 ;;
|
||||||
--plan|--dry-run) PLAN_ONLY=1 ;;
|
--plan|--dry-run) PLAN_ONLY=1 ;;
|
||||||
|
# A sudo password is waiting on stdin (the hack-house TUI feeds it). Use
|
||||||
|
# `sudo -S` so escalation reads that, never the controlling tty.
|
||||||
|
--stdin-pass) STDIN_PASS=1 ;;
|
||||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# How to escalate (mirrors ensure-docker.sh):
|
||||||
|
# * --stdin-pass: a password is on stdin → `sudo -S -p ''` (reads stdin, never
|
||||||
|
# the tty; a raw-mode TUI would corrupt a tty prompt). First sudo caches it.
|
||||||
|
# * --yes alone: `sudo -n` — fails fast if creds aren't cached, never hangs.
|
||||||
|
# * interactive shell: plain `sudo` (a real terminal can prompt normally).
|
||||||
|
SUDO="sudo"
|
||||||
|
[[ $ASSUME_YES -eq 1 ]] && SUDO="sudo -n"
|
||||||
|
[[ $STDIN_PASS -eq 1 ]] && SUDO="sudo -S -p ''"
|
||||||
|
|
||||||
installed() { command -v multipass >/dev/null 2>&1 && multipass version >/dev/null 2>&1; }
|
installed() { command -v multipass >/dev/null 2>&1 && multipass version >/dev/null 2>&1; }
|
||||||
mp_version() { multipass version 2>/dev/null | head -1; }
|
mp_version() { multipass version 2>/dev/null | head -1; }
|
||||||
|
|
||||||
@@ -78,7 +92,7 @@ if [[ -z "$install_cmd" ]]; then
|
|||||||
echo "✖ don't know how to install Multipass here — get it from https://multipass.run/install" >&2
|
echo "✖ don't know how to install Multipass here — get it from https://multipass.run/install" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
[[ $need_sudo -eq 1 ]] && install_cmd="sudo $install_cmd"
|
[[ $need_sudo -eq 1 ]] && install_cmd="$SUDO $install_cmd"
|
||||||
[[ -n "$manual_note" ]] && echo "ⓘ $manual_note" >&2
|
[[ -n "$manual_note" ]] && echo "ⓘ $manual_note" >&2
|
||||||
|
|
||||||
# --plan: show the real plan and change nothing.
|
# --plan: show the real plan and change nothing.
|
||||||
|
|||||||
@@ -17,21 +17,35 @@
|
|||||||
# ./ensure-podman.sh --yes # install without prompting
|
# ./ensure-podman.sh --yes # install without prompting
|
||||||
# ./ensure-podman.sh --check # test only; exit 0 if present, 1 if missing
|
# ./ensure-podman.sh --check # test only; exit 0 if present, 1 if missing
|
||||||
# ./ensure-podman.sh --plan # show the install plan; change nothing
|
# ./ensure-podman.sh --plan # show the install plan; change nothing
|
||||||
|
# ./ensure-podman.sh --stdin-pass # read a sudo password from stdin (sudo -S)
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
ASSUME_YES=0
|
ASSUME_YES=0
|
||||||
CHECK_ONLY=0
|
CHECK_ONLY=0
|
||||||
PLAN_ONLY=0
|
PLAN_ONLY=0
|
||||||
|
STDIN_PASS=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
-y|--yes) ASSUME_YES=1 ;;
|
-y|--yes) ASSUME_YES=1 ;;
|
||||||
--check) CHECK_ONLY=1 ;;
|
--check) CHECK_ONLY=1 ;;
|
||||||
--plan|--dry-run) PLAN_ONLY=1 ;;
|
--plan|--dry-run) PLAN_ONLY=1 ;;
|
||||||
|
# A sudo password is waiting on stdin (the hack-house TUI feeds it). Use
|
||||||
|
# `sudo -S` so escalation reads that, never the controlling tty.
|
||||||
|
--stdin-pass) STDIN_PASS=1 ;;
|
||||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# How to escalate (mirrors ensure-docker.sh):
|
||||||
|
# * --stdin-pass: a password is on stdin → `sudo -S -p ''` (reads stdin, never
|
||||||
|
# the tty; a raw-mode TUI would corrupt a tty prompt). First sudo caches it.
|
||||||
|
# * --yes alone: `sudo -n` — fails fast if creds aren't cached, never hangs.
|
||||||
|
# * interactive shell: plain `sudo` (a real terminal can prompt normally).
|
||||||
|
SUDO="sudo"
|
||||||
|
[[ $ASSUME_YES -eq 1 ]] && SUDO="sudo -n"
|
||||||
|
[[ $STDIN_PASS -eq 1 ]] && SUDO="sudo -S -p ''"
|
||||||
|
|
||||||
installed() { command -v podman >/dev/null 2>&1 && podman --version >/dev/null 2>&1; }
|
installed() { command -v podman >/dev/null 2>&1 && podman --version >/dev/null 2>&1; }
|
||||||
pm_version() { podman --version 2>/dev/null | head -1; }
|
pm_version() { podman --version 2>/dev/null | head -1; }
|
||||||
|
|
||||||
@@ -67,7 +81,9 @@ manual_note=""
|
|||||||
case "$(uname -s)" in
|
case "$(uname -s)" in
|
||||||
Linux)
|
Linux)
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
install_cmd="apt-get update && apt-get install -y podman"
|
# Wrapped in `sh -c` so a single `$SUDO …` escalation covers BOTH the
|
||||||
|
# update and the install (a bare `$SUDO a && b` would only sudo `a`).
|
||||||
|
install_cmd="sh -c 'apt-get update && apt-get install -y podman'"
|
||||||
plan_cmd="apt-cache policy podman"
|
plan_cmd="apt-cache policy podman"
|
||||||
need_sudo=1
|
need_sudo=1
|
||||||
elif command -v dnf >/dev/null 2>&1; then
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
@@ -99,7 +115,7 @@ if [[ -z "$install_cmd" ]]; then
|
|||||||
echo "✖ don't know how to install Podman here — get it from https://podman.io/docs/installation" >&2
|
echo "✖ don't know how to install Podman here — get it from https://podman.io/docs/installation" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
[[ $need_sudo -eq 1 ]] && install_cmd="sudo $install_cmd"
|
[[ $need_sudo -eq 1 ]] && install_cmd="$SUDO $install_cmd"
|
||||||
[[ -n "$manual_note" ]] && echo "ⓘ $manual_note" >&2
|
[[ -n "$manual_note" ]] && echo "ⓘ $manual_note" >&2
|
||||||
|
|
||||||
# --plan: show the real plan and change nothing.
|
# --plan: show the real plan and change nothing.
|
||||||
|
|||||||
@@ -12,21 +12,35 @@
|
|||||||
# ./ensure-vbox.sh --yes # install without prompting (used by --install)
|
# ./ensure-vbox.sh --yes # install without prompting (used by --install)
|
||||||
# ./ensure-vbox.sh --check # test only; exit 0 if present, 1 if missing
|
# ./ensure-vbox.sh --check # test only; exit 0 if present, 1 if missing
|
||||||
# ./ensure-vbox.sh --plan # show the install/download plan; change nothing
|
# ./ensure-vbox.sh --plan # show the install/download plan; change nothing
|
||||||
|
# ./ensure-vbox.sh --stdin-pass # read a sudo password from stdin (sudo -S)
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
ASSUME_YES=0
|
ASSUME_YES=0
|
||||||
CHECK_ONLY=0
|
CHECK_ONLY=0
|
||||||
PLAN_ONLY=0
|
PLAN_ONLY=0
|
||||||
|
STDIN_PASS=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
-y|--yes) ASSUME_YES=1 ;;
|
-y|--yes) ASSUME_YES=1 ;;
|
||||||
--check) CHECK_ONLY=1 ;;
|
--check) CHECK_ONLY=1 ;;
|
||||||
--plan|--dry-run) PLAN_ONLY=1 ;;
|
--plan|--dry-run) PLAN_ONLY=1 ;;
|
||||||
|
# A sudo password is waiting on stdin (the hack-house TUI feeds it). Use
|
||||||
|
# `sudo -S` so escalation reads that, never the controlling tty.
|
||||||
|
--stdin-pass) STDIN_PASS=1 ;;
|
||||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# How to escalate (mirrors ensure-docker.sh):
|
||||||
|
# * --stdin-pass: a password is on stdin → `sudo -S -p ''` (reads stdin, never
|
||||||
|
# the tty; a raw-mode TUI would corrupt a tty prompt). First sudo caches it.
|
||||||
|
# * --yes alone: `sudo -n` — fails fast if creds aren't cached, never hangs.
|
||||||
|
# * interactive shell: plain `sudo` (a real terminal can prompt normally).
|
||||||
|
SUDO="sudo"
|
||||||
|
[[ $ASSUME_YES -eq 1 ]] && SUDO="sudo -n"
|
||||||
|
[[ $STDIN_PASS -eq 1 ]] && SUDO="sudo -S -p ''"
|
||||||
|
|
||||||
# HH_VBOX_FORCE_MISSING=1 lets a demo exercise the missing→install path without
|
# HH_VBOX_FORCE_MISSING=1 lets a demo exercise the missing→install path without
|
||||||
# actually uninstalling anything (the probe is the single source of truth).
|
# actually uninstalling anything (the probe is the single source of truth).
|
||||||
installed() {
|
installed() {
|
||||||
@@ -88,7 +102,9 @@ if [[ -z "$install_cmd" ]]; then
|
|||||||
echo "✖ don't know how to install VirtualBox here — get it from https://www.virtualbox.org/wiki/Downloads" >&2
|
echo "✖ don't know how to install VirtualBox here — get it from https://www.virtualbox.org/wiki/Downloads" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
[[ $need_sudo -eq 1 ]] && install_cmd="sudo $install_cmd"
|
[[ $need_sudo -eq 1 ]] && install_cmd="$SUDO $install_cmd"
|
||||||
|
# The plan path is only ever run interactively (the TUI never asks for --plan), so
|
||||||
|
# a plain `sudo` tty prompt is fine there.
|
||||||
[[ $plan_sudo -eq 1 ]] && plan_cmd="sudo $plan_cmd"
|
[[ $plan_sudo -eq 1 ]] && plan_cmd="sudo $plan_cmd"
|
||||||
|
|
||||||
# Secure Boot needs the vboxdrv kernel module signed/enrolled (MOK) or it won't
|
# Secure Boot needs the vboxdrv kernel module signed/enrolled (MOK) or it won't
|
||||||
|
|||||||
+141
-3
@@ -197,6 +197,31 @@ pub struct SudoPrompt {
|
|||||||
pub pending: PendingSudoLaunch,
|
pub pending: PendingSudoLaunch,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Launch parameters captured when a GUI sandbox's noVNC port is already bound
|
||||||
|
/// by another process. Held aside while the port-consent modal asks whether to
|
||||||
|
/// kill the holder; the launch fires once the owner confirms. Carries the
|
||||||
|
/// (already-collected) sudo password, if any, so the gate works on both the
|
||||||
|
/// no-sudo and sudo-submit launch paths.
|
||||||
|
pub struct PendingGuiLaunch {
|
||||||
|
pub backend: sbx::Backend,
|
||||||
|
pub image: String,
|
||||||
|
pub members: Vec<String>,
|
||||||
|
pub rows: u16,
|
||||||
|
pub cols: u16,
|
||||||
|
pub start_daemon: bool,
|
||||||
|
pub install_first: bool,
|
||||||
|
pub gui: bool,
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active "port in use — kill it?" consent prompt for a GUI sandbox launch.
|
||||||
|
/// While `Some`, y/n keystrokes resolve this instead of feeding chat.
|
||||||
|
pub struct PortPrompt {
|
||||||
|
pub pid: i32,
|
||||||
|
pub holder: String,
|
||||||
|
pub pending: PendingGuiLaunch,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub me: String,
|
pub me: String,
|
||||||
pub lines: Vec<ChatLine>,
|
pub lines: Vec<ChatLine>,
|
||||||
@@ -260,6 +285,10 @@ pub struct App {
|
|||||||
/// and creds aren't cached). While `Some`, keystrokes feed this masked
|
/// and creds aren't cached). While `Some`, keystrokes feed this masked
|
||||||
/// buffer instead of chat — the secret never leaves the client.
|
/// buffer instead of chat — the secret never leaves the client.
|
||||||
pub sudo_prompt: Option<SudoPrompt>,
|
pub sudo_prompt: Option<SudoPrompt>,
|
||||||
|
/// Active "noVNC port already in use — kill the holder?" consent prompt for a
|
||||||
|
/// GUI sandbox launch (None unless the port is busy). While `Some`, y/n keys
|
||||||
|
/// resolve it instead of feeding chat.
|
||||||
|
pub port_prompt: Option<PortPrompt>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -296,6 +325,7 @@ impl App {
|
|||||||
layout: Layout::default(),
|
layout: Layout::default(),
|
||||||
focused_pane: None,
|
focused_pane: None,
|
||||||
sudo_prompt: None,
|
sudo_prompt: None,
|
||||||
|
port_prompt: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,6 +1055,32 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
app.sudo_prompt.take().unwrap();
|
app.sudo_prompt.take().unwrap();
|
||||||
if password.is_empty() {
|
if password.is_empty() {
|
||||||
app.sys("⛧ cancelled — empty password");
|
app.sys("⛧ cancelled — empty password");
|
||||||
|
} else if let Some(h) =
|
||||||
|
if pending.gui { sbx::port_holder(sbx::GUI_PORT) } else { None }
|
||||||
|
{
|
||||||
|
// Root's now authenticated, but the noVNC port
|
||||||
|
// is taken — carry the password into the
|
||||||
|
// port-consent gate so the launch can still fire
|
||||||
|
// (with sudo) once the holder is cleared.
|
||||||
|
app.sys(format!(
|
||||||
|
"⚠ port {} (noVNC) in use by pid {} ({}) — kill it and proceed? [y/N] · Esc cancels",
|
||||||
|
sbx::GUI_PORT, h.pid, h.name
|
||||||
|
));
|
||||||
|
app.port_prompt = Some(PortPrompt {
|
||||||
|
pid: h.pid,
|
||||||
|
holder: h.name,
|
||||||
|
pending: PendingGuiLaunch {
|
||||||
|
backend: pending.backend,
|
||||||
|
image: pending.image,
|
||||||
|
members: pending.members,
|
||||||
|
rows: pending.rows,
|
||||||
|
cols: pending.cols,
|
||||||
|
start_daemon: pending.start_daemon,
|
||||||
|
install_first: pending.install_first,
|
||||||
|
gui: pending.gui,
|
||||||
|
password: Some(password),
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
launching = true;
|
launching = true;
|
||||||
app.sys("🔒 authenticating + launching…");
|
app.sys("🔒 authenticating + launching…");
|
||||||
@@ -1061,6 +1117,50 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
} else if app.port_prompt.is_some() {
|
||||||
|
// GUI-launch port-consent modal: the noVNC port is held
|
||||||
|
// by another process. y/Y kills the named pid then fires
|
||||||
|
// the held launch; n/N/Esc aborts. Swallows keys so the
|
||||||
|
// y/n never lands in chat.
|
||||||
|
match k.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||||
|
let PortPrompt { pid, holder, pending } =
|
||||||
|
app.port_prompt.take().unwrap();
|
||||||
|
app.sys(format!(
|
||||||
|
"⛧ killing {holder} (pid {pid}) to free port {}…",
|
||||||
|
sbx::GUI_PORT
|
||||||
|
));
|
||||||
|
if sbx::kill_port_holder(sbx::GUI_PORT, pid) {
|
||||||
|
launching = true;
|
||||||
|
app.sys("🖥 port freed — launching…");
|
||||||
|
spawn_launch(
|
||||||
|
pending.backend,
|
||||||
|
pending.image,
|
||||||
|
app.me.clone(),
|
||||||
|
pending.members,
|
||||||
|
pending.rows,
|
||||||
|
pending.cols,
|
||||||
|
pending.start_daemon,
|
||||||
|
pending.install_first,
|
||||||
|
pending.gui,
|
||||||
|
pending.password,
|
||||||
|
pty_tx.clone(),
|
||||||
|
broker_tx.clone(),
|
||||||
|
app_tx.clone(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
app.sys(format!(
|
||||||
|
"⚠ couldn't free port {} — launch aborted",
|
||||||
|
sbx::GUI_PORT
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||||
|
app.port_prompt = None;
|
||||||
|
app.sys("⛧ launch aborted — port left untouched");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
} else if k.modifiers.contains(KeyModifiers::CONTROL)
|
} else if k.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
&& matches!(k.code, KeyCode::Char('x'))
|
&& matches!(k.code, KeyCode::Char('x'))
|
||||||
&& !app.driving
|
&& !app.driving
|
||||||
@@ -2055,6 +2155,29 @@ fn handle_command(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
app.sys("🔒 sudo password needed — type it here (hidden), Enter to launch · Esc cancels. Local only: never sent to the room.");
|
app.sys("🔒 sudo password needed — type it here (hidden), Enter to launch · Esc cancels. Local only: never sent to the room.");
|
||||||
|
} else if let Some(h) = if gui { sbx::port_holder(sbx::GUI_PORT) } else { None } {
|
||||||
|
// The noVNC port is already bound (a stale sandbox, a
|
||||||
|
// leftover websockify, etc.). Don't blindly collide —
|
||||||
|
// ask the owner whether to kill the holder, then launch.
|
||||||
|
app.sys(format!(
|
||||||
|
"⚠ port {} (noVNC) in use by pid {} ({}) — kill it and proceed? [y/N] · Esc cancels",
|
||||||
|
sbx::GUI_PORT, h.pid, h.name
|
||||||
|
));
|
||||||
|
app.port_prompt = Some(PortPrompt {
|
||||||
|
pid: h.pid,
|
||||||
|
holder: h.name,
|
||||||
|
pending: PendingGuiLaunch {
|
||||||
|
backend,
|
||||||
|
image,
|
||||||
|
members,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
start_daemon,
|
||||||
|
install_first,
|
||||||
|
gui,
|
||||||
|
password: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
*launching = true;
|
*launching = true;
|
||||||
if install_first {
|
if install_first {
|
||||||
@@ -2867,6 +2990,16 @@ fn launch_vbox_gui(
|
|||||||
));
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Installing VirtualBox needs root. This GUI path runs off-thread with no
|
||||||
|
// in-TUI password modal, so it relies on `sudo -n` (cached creds). If they
|
||||||
|
// aren't cached the apt step would fail fast mid-launch — preempt that with
|
||||||
|
// a clear, actionable message rather than a cryptic install error.
|
||||||
|
if needs_install && !sbx::sudo_ready() {
|
||||||
|
app.err(format!(
|
||||||
|
"installing VirtualBox needs sudo, but it isn't cached. Run `!sudo -v` here (caches your password ~15 min), then retry `/sbx vbox gui {vm} yes`."
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
spawn_vm_execute(vm, conflicts, needs_install, needs_import, app, app_tx, out_tx, room);
|
spawn_vm_execute(vm, conflicts, needs_install, needs_import, app, app_tx, out_tx, room);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2914,7 +3047,12 @@ fn spawn_vm_execute(
|
|||||||
sbx::stop_vtx_holder(h).map_err(|e| format!("freeing VT-x: {e}"))?;
|
sbx::stop_vtx_holder(h).map_err(|e| format!("freeing VT-x: {e}"))?;
|
||||||
}
|
}
|
||||||
if needs_install && !sbx::vbox_installed() {
|
if needs_install && !sbx::vbox_installed() {
|
||||||
sbx::ensure_vbox_install().map_err(|e| format!("install failed: {e}"))?;
|
// VirtualBox install needs root. No in-TUI password was captured
|
||||||
|
// on this path, so `ensure_vbox_install(None)` uses `sudo -n`:
|
||||||
|
// it succeeds if creds are cached (the confirm step asks the user
|
||||||
|
// to run `sudo -v` first) and fails fast with a clear message
|
||||||
|
// otherwise — never hanging on a tty prompt the TUI can't host.
|
||||||
|
sbx::ensure_vbox_install(None).map_err(|e| format!("install failed: {e}"))?;
|
||||||
}
|
}
|
||||||
// Pull the shared appliance in if the VM still isn't registered.
|
// Pull the shared appliance in if the VM still isn't registered.
|
||||||
if needs_import && !sbx::vm_registered(&vm) {
|
if needs_import && !sbx::vm_registered(&vm) {
|
||||||
@@ -2968,8 +3106,8 @@ fn spawn_launch(
|
|||||||
let pw = password.clone();
|
let pw = password.clone();
|
||||||
let res = tokio::task::spawn_blocking(move || match backend {
|
let res = tokio::task::spawn_blocking(move || match backend {
|
||||||
sbx::Backend::Docker => sbx::ensure_docker_install(pw),
|
sbx::Backend::Docker => sbx::ensure_docker_install(pw),
|
||||||
sbx::Backend::Podman => sbx::ensure_podman_install(),
|
sbx::Backend::Podman => sbx::ensure_podman_install(pw),
|
||||||
sbx::Backend::Multipass => sbx::ensure_multipass_install(),
|
sbx::Backend::Multipass => sbx::ensure_multipass_install(pw),
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
+127
-40
@@ -32,6 +32,94 @@ const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh
|
|||||||
/// server (:1 / 5901) to this port via websockify; the owner opens it in a browser.
|
/// server (:1 / 5901) to this port via websockify; the owner opens it in a browser.
|
||||||
pub const GUI_PORT: u16 = 6080;
|
pub const GUI_PORT: u16 = 6080;
|
||||||
|
|
||||||
|
/// A process currently bound to a TCP port we want to use.
|
||||||
|
pub struct PortHolder {
|
||||||
|
pub pid: i32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the process listening on `port` (host loopback/any), if any.
|
||||||
|
///
|
||||||
|
/// Parses `ss -H -ltnp` — one listening socket per line, e.g.
|
||||||
|
/// `LISTEN 0 4096 127.0.0.1:6080 0.0.0.0:* users:(("websockify",pid=3395944,fd=3))`.
|
||||||
|
/// We match the local-address column (index 3) ending in `:port` and pull the
|
||||||
|
/// first `("name",pid=N` out of the `users:(...)` column. Returns `None` if the
|
||||||
|
/// port is free or `ss` can't report the owner (no `-p` perms → no name/pid).
|
||||||
|
pub fn port_holder(port: u16) -> Option<PortHolder> {
|
||||||
|
let out = Command::new("ss")
|
||||||
|
.args(["-H", "-ltnp"])
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let suffix = format!(":{port}");
|
||||||
|
for line in text.lines() {
|
||||||
|
let cols: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if cols.len() < 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Local address is the 4th column (Recv-Q is dropped by some ss builds,
|
||||||
|
// so guard by scanning for the column that ends in `:<port>`).
|
||||||
|
let local_match = cols.iter().any(|c| c.ends_with(&suffix));
|
||||||
|
if !local_match {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// users:(("websockify",pid=3395944,fd=3),...)
|
||||||
|
let users = match line.split_once("users:(") {
|
||||||
|
Some((_, rest)) => rest,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let name = users
|
||||||
|
.split_once("(\"")
|
||||||
|
.and_then(|(_, r)| r.split_once('"'))
|
||||||
|
.map(|(n, _)| n.to_string())
|
||||||
|
.unwrap_or_else(|| "?".to_string());
|
||||||
|
let pid = users
|
||||||
|
.split_once("pid=")
|
||||||
|
.and_then(|(_, r)| {
|
||||||
|
let end = r.find(|c: char| !c.is_ascii_digit()).unwrap_or(r.len());
|
||||||
|
r[..end].parse::<i32>().ok()
|
||||||
|
});
|
||||||
|
if let Some(pid) = pid {
|
||||||
|
return Some(PortHolder { pid, name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill the process holding `port` and wait for the port to free up.
|
||||||
|
///
|
||||||
|
/// Sends SIGTERM first (graceful), then SIGKILL if it lingers, polling
|
||||||
|
/// `port_holder` until the port is free or a short timeout elapses. Returns
|
||||||
|
/// `true` once the port is free. Used by the GUI-launch consent gate after the
|
||||||
|
/// owner confirms killing the named pid.
|
||||||
|
pub fn kill_port_holder(port: u16, pid: i32) -> bool {
|
||||||
|
let _ = Command::new("kill")
|
||||||
|
.arg(pid.to_string())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
for _ in 0..15 {
|
||||||
|
if port_holder(port).is_none() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
// Still bound after SIGTERM — escalate to SIGKILL.
|
||||||
|
let _ = Command::new("kill")
|
||||||
|
.args(["-9", &pid.to_string()])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
for _ in 0..15 {
|
||||||
|
if port_holder(port).is_none() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
port_holder(port).is_none()
|
||||||
|
}
|
||||||
|
|
||||||
/// Is the `docker` binary installed? (`docker --version` succeeds.) This is a
|
/// Is the `docker` binary installed? (`docker --version` succeeds.) This is a
|
||||||
/// weaker check than `docker_daemon_up`: the CLI can be present while the daemon
|
/// weaker check than `docker_daemon_up`: the CLI can be present while the daemon
|
||||||
/// is down. We need both before a Docker sandbox can launch.
|
/// is down. We need both before a Docker sandbox can launch.
|
||||||
@@ -98,18 +186,25 @@ pub fn sudo_ready() -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `ensure-docker.sh --yes` with the given extra args, optionally feeding a
|
/// Run an `ensure-*.sh` installer `--yes` with the given extra args, optionally
|
||||||
/// sudo password to the script's `sudo -S` via stdin.
|
/// feeding a sudo password to the script's `sudo -S` via stdin. Shared by every
|
||||||
|
/// backend installer (docker/podman/multipass/vbox) so sudo capture is uniform.
|
||||||
///
|
///
|
||||||
/// Secret handling: the password (if any) is written to the child's stdin and
|
/// Sudo ladder (the scripts honour all three):
|
||||||
/// then the local buffer is wiped. It only ever travels parent→child stdin; it
|
/// * password present ⇒ `--stdin-pass` ⇒ the script uses `sudo -S -p ''`,
|
||||||
/// is NEVER echoed, logged, or surfaced — sudo never prints the password, so the
|
/// reading the secret from stdin — never the controlling tty (a raw-mode TUI
|
||||||
/// captured stderr (used for error messages) can't contain it. With no password
|
/// would corrupt a tty prompt). The first sudo caches the credential.
|
||||||
/// we close stdin and the script uses `sudo -n` (fails fast if creds aren't
|
/// * no password ⇒ stdin closed; the script's `--yes` selects `sudo -n`, which
|
||||||
/// cached) so it can never block on an interactive tty prompt.
|
/// fails fast (clear error) if creds aren't cached rather than hanging on a
|
||||||
fn run_ensure_docker(extra: &[&str], password: Option<String>) -> Result<(bool, String)> {
|
/// tty prompt the TUI can't host.
|
||||||
|
///
|
||||||
|
/// Secret handling: the password (if any) only ever travels parent→child stdin;
|
||||||
|
/// the local buffer is wiped immediately after. It is NEVER echoed, logged, or
|
||||||
|
/// surfaced — sudo never prints the password, so the captured stderr (used for
|
||||||
|
/// error messages) can't contain it.
|
||||||
|
fn run_ensure(script: &str, extra: &[&str], password: Option<String>) -> Result<(bool, String)> {
|
||||||
let mut cmd = Command::new("bash");
|
let mut cmd = Command::new("bash");
|
||||||
cmd.arg(ENSURE_DOCKER).arg("--yes");
|
cmd.arg(script).arg("--yes");
|
||||||
for a in extra {
|
for a in extra {
|
||||||
cmd.arg(a);
|
cmd.arg(a);
|
||||||
}
|
}
|
||||||
@@ -119,7 +214,9 @@ fn run_ensure_docker(extra: &[&str], password: Option<String>) -> Result<(bool,
|
|||||||
cmd.stdin(Stdio::null());
|
cmd.stdin(Stdio::null());
|
||||||
}
|
}
|
||||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
let mut child = cmd.spawn().context("running ensure-docker.sh")?;
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("running {script}"))?;
|
||||||
if let Some(mut pw) = password {
|
if let Some(mut pw) = password {
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
// Feed the password to the first `sudo -S`; it caches the credential
|
// Feed the password to the first `sudo -S`; it caches the credential
|
||||||
@@ -134,7 +231,9 @@ fn run_ensure_docker(extra: &[&str], password: Option<String>) -> Result<(bool,
|
|||||||
}
|
}
|
||||||
pw.clear();
|
pw.clear();
|
||||||
}
|
}
|
||||||
let out = child.wait_with_output().context("ensure-docker.sh")?;
|
let out = child
|
||||||
|
.wait_with_output()
|
||||||
|
.with_context(|| format!("{script}"))?;
|
||||||
Ok((out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned()))
|
Ok((out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +241,7 @@ fn run_ensure_docker(extra: &[&str], password: Option<String>) -> Result<(bool,
|
|||||||
/// ready. With `password`, escalation goes through `sudo -S` (read from stdin);
|
/// ready. With `password`, escalation goes through `sudo -S` (read from stdin);
|
||||||
/// without it the script uses `sudo -n` and fails fast if creds aren't cached.
|
/// without it the script uses `sudo -n` and fails fast if creds aren't cached.
|
||||||
fn start_docker_daemon(password: Option<String>) -> Result<()> {
|
fn start_docker_daemon(password: Option<String>) -> Result<()> {
|
||||||
let (ok, err) = run_ensure_docker(&[], password)?;
|
let (ok, err) = run_ensure(ENSURE_DOCKER, &[], password)?;
|
||||||
if !ok {
|
if !ok {
|
||||||
let last = err
|
let last = err
|
||||||
.lines()
|
.lines()
|
||||||
@@ -158,7 +257,7 @@ fn start_docker_daemon(password: Option<String>) -> Result<()> {
|
|||||||
/// job (they passed `install`); the script is idempotent if Docker is present.
|
/// job (they passed `install`); the script is idempotent if Docker is present.
|
||||||
/// `password` feeds `sudo -S` as above.
|
/// `password` feeds `sudo -S` as above.
|
||||||
pub fn ensure_docker_install(password: Option<String>) -> Result<()> {
|
pub fn ensure_docker_install(password: Option<String>) -> Result<()> {
|
||||||
let (ok, err) = run_ensure_docker(&["--install"], password)?;
|
let (ok, err) = run_ensure(ENSURE_DOCKER, &["--install"], password)?;
|
||||||
if !ok {
|
if !ok {
|
||||||
let last = err.lines().last().unwrap_or("could not install Docker");
|
let last = err.lines().last().unwrap_or("could not install Docker");
|
||||||
anyhow::bail!("{last}");
|
anyhow::bail!("{last}");
|
||||||
@@ -169,15 +268,11 @@ pub fn ensure_docker_install(password: Option<String>) -> Result<()> {
|
|||||||
/// Install Podman via `ensure-podman.sh --yes` (apt + a rootless subuid/subgid
|
/// Install Podman via `ensure-podman.sh --yes` (apt + a rootless subuid/subgid
|
||||||
/// preflight). Consent is the caller's job (they passed `install`); the script is
|
/// preflight). Consent is the caller's job (they passed `install`); the script is
|
||||||
/// idempotent if Podman is already present. Daemonless — nothing to start after.
|
/// idempotent if Podman is already present. Daemonless — nothing to start after.
|
||||||
/// Returns the script's last error line on failure.
|
/// `password` feeds the script's `sudo -S` (apt needs root); without it the
|
||||||
pub fn ensure_podman_install() -> Result<()> {
|
/// script's `sudo -n` fails fast rather than hanging. Returns the last error line.
|
||||||
let out = Command::new("bash")
|
pub fn ensure_podman_install(password: Option<String>) -> Result<()> {
|
||||||
.arg(ENSURE_PODMAN)
|
let (ok, err) = run_ensure(ENSURE_PODMAN, &[], password)?;
|
||||||
.arg("--yes")
|
if !ok {
|
||||||
.output()
|
|
||||||
.context("running ensure-podman.sh")?;
|
|
||||||
if !out.status.success() {
|
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
|
||||||
let last = err.lines().last().unwrap_or("could not install Podman");
|
let last = err.lines().last().unwrap_or("could not install Podman");
|
||||||
anyhow::bail!("{last}");
|
anyhow::bail!("{last}");
|
||||||
}
|
}
|
||||||
@@ -199,14 +294,9 @@ pub fn multipass_installed() -> bool {
|
|||||||
/// install (the only supported channel). Consent is the caller's job; the
|
/// install (the only supported channel). Consent is the caller's job; the
|
||||||
/// script is idempotent if Multipass is already present. Returns the script's
|
/// script is idempotent if Multipass is already present. Returns the script's
|
||||||
/// last error line on failure (e.g. snapd missing, or needs sudo).
|
/// last error line on failure (e.g. snapd missing, or needs sudo).
|
||||||
pub fn ensure_multipass_install() -> Result<()> {
|
pub fn ensure_multipass_install(password: Option<String>) -> Result<()> {
|
||||||
let out = Command::new("bash")
|
let (ok, err) = run_ensure(ENSURE_MULTIPASS, &[], password)?;
|
||||||
.arg(ENSURE_MULTIPASS)
|
if !ok {
|
||||||
.arg("--yes")
|
|
||||||
.output()
|
|
||||||
.context("running ensure-multipass.sh")?;
|
|
||||||
if !out.status.success() {
|
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
|
||||||
let last = err.lines().last().unwrap_or("could not install Multipass");
|
let last = err.lines().last().unwrap_or("could not install Multipass");
|
||||||
anyhow::bail!("{last}");
|
anyhow::bail!("{last}");
|
||||||
}
|
}
|
||||||
@@ -241,15 +331,12 @@ pub fn vbox_version() -> Option<String> {
|
|||||||
|
|
||||||
/// Install VirtualBox via `ensure-vbox.sh --yes`. Consent is the caller's job
|
/// Install VirtualBox via `ensure-vbox.sh --yes`. Consent is the caller's job
|
||||||
/// (they passed `--install`); detection is the script's (idempotent if present).
|
/// (they passed `--install`); detection is the script's (idempotent if present).
|
||||||
/// Returns the script's last error line on failure (e.g. needs sudo).
|
/// `password` feeds the script's `sudo -S` (apt needs root); without it the
|
||||||
pub fn ensure_vbox_install() -> Result<()> {
|
/// script's `sudo -n` fails fast rather than hanging on a tty prompt the TUI
|
||||||
let out = Command::new("bash")
|
/// can't host. Returns the script's last error line on failure.
|
||||||
.arg(ENSURE_VBOX)
|
pub fn ensure_vbox_install(password: Option<String>) -> Result<()> {
|
||||||
.arg("--yes")
|
let (ok, err) = run_ensure(ENSURE_VBOX, &[], password)?;
|
||||||
.output()
|
if !ok {
|
||||||
.context("running ensure-vbox.sh")?;
|
|
||||||
if !out.status.success() {
|
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
|
||||||
let last = err.lines().last().unwrap_or("could not install VirtualBox");
|
let last = err.lines().last().unwrap_or("could not install VirtualBox");
|
||||||
anyhow::bail!("{last}");
|
anyhow::bail!("{last}");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user