Add SSH connections: saved hosts manager and hierarchical shell picker
This commit is contained in:
parent
4e5bc7e081
commit
872fb0e80e
14 changed files with 1324 additions and 171 deletions
|
|
@ -1,6 +1,6 @@
|
|||
//! PTY backend. Spawns `wsl.exe` (or any command) through portable-pty,
|
||||
//! reads its output on a background thread, and forwards chunks to the
|
||||
//! frontend as `pane://{id}/data` events.
|
||||
//! PTY backend. Spawns a shell (`wsl.exe`, `powershell.exe`, or `ssh.exe`)
|
||||
//! through portable-pty, reads its output on a background thread, and
|
||||
//! forwards chunks to the frontend as `pane://{id}/data` events.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
|
|
@ -9,16 +9,35 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use parking_lot::Mutex;
|
||||
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||
use serde::Serialize;
|
||||
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// Sentinel "distro" name used to spawn Windows PowerShell instead of WSL.
|
||||
/// Frontend appends this to the distro list it shows in the dropdown.
|
||||
pub const POWERSHELL_DISTRO: &str = "PowerShell";
|
||||
|
||||
pub type PaneId = u64;
|
||||
|
||||
/// Discriminated union describing what to spawn into a fresh PTY. Serialized
|
||||
/// as `{ kind: "wsl" | "powershell" | "ssh", ... }` from the frontend.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum SpawnSpec {
|
||||
Wsl {
|
||||
distro: Option<String>,
|
||||
cwd: Option<String>,
|
||||
},
|
||||
Powershell,
|
||||
Ssh {
|
||||
host: String,
|
||||
user: Option<String>,
|
||||
port: Option<u16>,
|
||||
#[serde(rename = "identityFile")]
|
||||
identity_file: Option<String>,
|
||||
#[serde(rename = "jumpHost")]
|
||||
jump_host: Option<String>,
|
||||
#[serde(rename = "extraArgs")]
|
||||
extra_args: Option<Vec<String>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What we keep alive for each spawned PTY.
|
||||
///
|
||||
/// `master` stays in scope to keep the PTY alive; we never write through it
|
||||
|
|
@ -45,14 +64,13 @@ impl PtyManager {
|
|||
}
|
||||
}
|
||||
|
||||
/// Spawn `wsl.exe` (optionally `-d <distro>`, optionally `--cd <cwd>`).
|
||||
/// Returns the new pane id. A background thread starts reading the PTY
|
||||
/// immediately and emits `pane://{id}/data` events.
|
||||
pub fn spawn_wsl(
|
||||
/// Spawn the shell described by `spec` into a fresh PTY. Returns the
|
||||
/// new pane id; a background thread immediately starts reading and
|
||||
/// emits `pane://{id}/data` events.
|
||||
pub fn spawn(
|
||||
&self,
|
||||
app: AppHandle,
|
||||
distro: Option<String>,
|
||||
cwd: Option<String>,
|
||||
spec: SpawnSpec,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> Result<PaneId> {
|
||||
|
|
@ -66,39 +84,7 @@ impl PtyManager {
|
|||
})
|
||||
.context("openpty failed")?;
|
||||
|
||||
let is_powershell = distro.as_deref() == Some(POWERSHELL_DISTRO);
|
||||
|
||||
let cmd = if is_powershell {
|
||||
// cwd from the leaf is ignored — leaves may carry Linux-style
|
||||
// paths (e.g. `~`, `/mnt/d/...`) from a previously-assigned WSL
|
||||
// distro that PowerShell wouldn't understand. PowerShell starts
|
||||
// in its own default cwd; user can `cd` if they want.
|
||||
let mut c = CommandBuilder::new("powershell.exe");
|
||||
c.arg("-NoLogo");
|
||||
c
|
||||
} else {
|
||||
let mut c = CommandBuilder::new("wsl.exe");
|
||||
if let Some(d) = distro.as_deref() {
|
||||
c.arg("-d");
|
||||
c.arg(d);
|
||||
}
|
||||
// Default new panes to the WSL user's home (~) rather than the
|
||||
// Windows-side cwd we inherit from the launcher (typically
|
||||
// C:\Users\<you>, which shows up as /mnt/c/Users/<you> inside WSL).
|
||||
// wsl.exe resolves `~` against the distro's default shell.
|
||||
let resolved_cwd = cwd.as_deref().unwrap_or("~");
|
||||
c.arg("--cd");
|
||||
c.arg(resolved_cwd);
|
||||
// wsl.exe without an explicit command launches the default shell
|
||||
// interactively, which is exactly what we want.
|
||||
c
|
||||
};
|
||||
|
||||
let spawn_err = if is_powershell {
|
||||
"failed to spawn powershell.exe"
|
||||
} else {
|
||||
"failed to spawn wsl.exe; is WSL installed?"
|
||||
};
|
||||
let (cmd, spawn_err) = build_command(&spec)?;
|
||||
let child = pair.slave.spawn_command(cmd).context(spawn_err)?;
|
||||
|
||||
// We need to keep the master alive (drop = close the PTY), but we
|
||||
|
|
@ -197,6 +183,102 @@ struct DataChunk {
|
|||
b64: String,
|
||||
}
|
||||
|
||||
// ---- command construction ---------------------------------------------------
|
||||
|
||||
/// Reject hostnames / usernames that would let an attacker smuggle in a
|
||||
/// flag (`-oProxyCommand=...`) or a shell metacharacter via OpenSSH's token
|
||||
/// expansion. We additionally pass `--` before the host on the command line,
|
||||
/// but rejecting up front gives a clearer error and avoids ever handing the
|
||||
/// bad value to ssh.exe.
|
||||
fn validate_ssh_token(label: &str, value: &str) -> Result<()> {
|
||||
if value.is_empty() {
|
||||
return Err(anyhow!("ssh: {label} must not be empty"));
|
||||
}
|
||||
if value.starts_with('-') {
|
||||
return Err(anyhow!("ssh: {label} must not start with '-' (got {value:?})"));
|
||||
}
|
||||
if value.chars().any(|c| c.is_control() || c == '\n' || c == '\r') {
|
||||
return Err(anyhow!("ssh: {label} must not contain control characters"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_command(spec: &SpawnSpec) -> Result<(CommandBuilder, &'static str)> {
|
||||
match spec {
|
||||
SpawnSpec::Wsl { distro, cwd } => {
|
||||
let mut c = CommandBuilder::new("wsl.exe");
|
||||
if let Some(d) = distro.as_deref() {
|
||||
c.arg("-d");
|
||||
c.arg(d);
|
||||
}
|
||||
// Default new panes to the WSL user's home (~) rather than the
|
||||
// Windows-side cwd we inherit from the launcher (typically
|
||||
// C:\Users\<you>, which shows up as /mnt/c/Users/<you> inside WSL).
|
||||
// wsl.exe resolves `~` against the distro's default shell.
|
||||
let resolved_cwd = cwd.as_deref().unwrap_or("~");
|
||||
c.arg("--cd");
|
||||
c.arg(resolved_cwd);
|
||||
Ok((c, "failed to spawn wsl.exe; is WSL installed?"))
|
||||
}
|
||||
SpawnSpec::Powershell => {
|
||||
// cwd intentionally ignored — see commit history.
|
||||
let mut c = CommandBuilder::new("powershell.exe");
|
||||
c.arg("-NoLogo");
|
||||
Ok((c, "failed to spawn powershell.exe"))
|
||||
}
|
||||
SpawnSpec::Ssh {
|
||||
host,
|
||||
user,
|
||||
port,
|
||||
identity_file,
|
||||
jump_host,
|
||||
extra_args,
|
||||
} => {
|
||||
validate_ssh_token("host", host)?;
|
||||
if let Some(u) = user.as_deref() {
|
||||
validate_ssh_token("user", u)?;
|
||||
}
|
||||
if let Some(jh) = jump_host.as_deref() {
|
||||
validate_ssh_token("jump host", jh)?;
|
||||
}
|
||||
|
||||
let mut c = CommandBuilder::new("ssh.exe");
|
||||
// ssh would auto-detect a tty here, but force it explicitly so
|
||||
// remote-side TUI apps don't accidentally see a non-tty stdin.
|
||||
c.arg("-t");
|
||||
if let Some(u) = user.as_deref() {
|
||||
c.arg("-l");
|
||||
c.arg(u);
|
||||
}
|
||||
if let Some(p) = port {
|
||||
c.arg("-p");
|
||||
c.arg(p.to_string());
|
||||
}
|
||||
if let Some(idf) = identity_file.as_deref() {
|
||||
c.arg("-i");
|
||||
c.arg(idf);
|
||||
}
|
||||
if let Some(jh) = jump_host.as_deref() {
|
||||
c.arg("-J");
|
||||
c.arg(jh);
|
||||
}
|
||||
if let Some(extra) = extra_args.as_deref() {
|
||||
for a in extra {
|
||||
c.arg(a);
|
||||
}
|
||||
}
|
||||
// `--` ends option parsing — a hostname starting with `-` can't
|
||||
// smuggle in flags via OpenSSH's option parser.
|
||||
c.arg("--");
|
||||
c.arg(host);
|
||||
// Some Windows OpenSSH builds otherwise advertise a TERM the
|
||||
// remote side doesn't recognise; xterm.js speaks xterm-256color.
|
||||
c.env("TERM", "xterm-256color");
|
||||
Ok((c, "failed to spawn ssh.exe; is OpenSSH installed?"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- distro enumeration -----------------------------------------------------
|
||||
|
||||
/// Run a process without flashing a console window on Windows.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue