From e8fd8d04a94feb8ed82c68c67cc9c2180dd0d0cd Mon Sep 17 00:00:00 2001 From: Giray Pultar Date: Tue, 15 Sep 2026 16:54:57 +0300 Subject: [PATCH 1/3] Add CLI commands to create and delete saved SSH servers Headless installs can now register servers with `vibeshell servers add user@host[:port]` and remove them with `vibeshell servers delete`, without opening the desktop UI. Optional flags cover the Add Server dialog fields (jump host, agent forwarding, post-login command, group, tags). Passwords come from SSH_PASSWORD or VIBESHELL_PASSWORD; keys from --identity. --- .claude/skills/vibeshell/SKILL.md | 15 +- .codex/skills/vibeshell/SKILL.md | 15 +- README.md | 3 +- cli/README.md | 3 + cli/src/commands/server.rs | 165 +++++++++++++++- cli/src/main.rs | 139 +++++++++++++- cli/src/ssh_target.rs | 113 +++++++++++ skills/vibeshell/SKILL.md | 15 +- src-tauri/src/commands/server.rs | 310 +++++++++++++++++++++++++++--- src-tauri/src/ipc/socket.rs | 82 ++++++++ 10 files changed, 822 insertions(+), 38 deletions(-) create mode 100644 cli/src/ssh_target.rs diff --git a/.claude/skills/vibeshell/SKILL.md b/.claude/skills/vibeshell/SKILL.md index 84d32d3..8e7810c 100644 --- a/.claude/skills/vibeshell/SKILL.md +++ b/.claude/skills/vibeshell/SKILL.md @@ -12,12 +12,23 @@ VibeShell automatically starts its local headless daemon when an SSH, SFTP, or s ## Before operating 1. Verify the CLI is available with `vibeshell version`. -2. Inspect configured targets with `vibeshell servers`. -3. Prefer saved server names over raw hosts. Credentials remain local to VibeShell and must never be printed, copied into prompts, or passed on the command line. +2. Inspect configured targets with `vibeshell servers`. Add or remove them with `vibeshell servers add` and `vibeshell servers delete` — the desktop UI is not required. +3. Prefer saved server names over raw hosts. Credentials remain local to VibeShell and must never be printed, copied into prompts, or passed on the command line. Passwords for `servers add` come from `SSH_PASSWORD` or `VIBESHELL_PASSWORD`; keys from `--identity`. 4. Reuse an existing session unless the user explicitly needs an independent parallel shell. Resolve the native executable in this order: `vibeshell` from `PATH`, `$HOME/.local/bin/vibeshell`, then `/Applications/VibeShell.app/Contents/MacOS/vibeshell` on macOS. Use the resolved absolute path for the rest of the workflow when necessary. If none exists, tell the user which lookup failed. Do not silently replace VibeShell with `ssh`, `scp`, or another client because that bypasses the saved VibeShell configuration and session model. +## Add or delete saved servers + +```bash +vibeshell servers add root@prod.example.com --name prod-web +SSH_PASSWORD=... vibeshell servers add root@prod.example.com --name prod-web +vibeshell servers add ubuntu@10.0.0.8:2222 --identity ~/.ssh/id_ed25519 --jump bastion --agent-forwarding +vibeshell servers delete prod-web +``` + +Display name defaults to the host. Secrets must not appear on the command line. + ## Import existing SSH configurations Preview everything VibeShell can discover: diff --git a/.codex/skills/vibeshell/SKILL.md b/.codex/skills/vibeshell/SKILL.md index 84d32d3..8e7810c 100644 --- a/.codex/skills/vibeshell/SKILL.md +++ b/.codex/skills/vibeshell/SKILL.md @@ -12,12 +12,23 @@ VibeShell automatically starts its local headless daemon when an SSH, SFTP, or s ## Before operating 1. Verify the CLI is available with `vibeshell version`. -2. Inspect configured targets with `vibeshell servers`. -3. Prefer saved server names over raw hosts. Credentials remain local to VibeShell and must never be printed, copied into prompts, or passed on the command line. +2. Inspect configured targets with `vibeshell servers`. Add or remove them with `vibeshell servers add` and `vibeshell servers delete` — the desktop UI is not required. +3. Prefer saved server names over raw hosts. Credentials remain local to VibeShell and must never be printed, copied into prompts, or passed on the command line. Passwords for `servers add` come from `SSH_PASSWORD` or `VIBESHELL_PASSWORD`; keys from `--identity`. 4. Reuse an existing session unless the user explicitly needs an independent parallel shell. Resolve the native executable in this order: `vibeshell` from `PATH`, `$HOME/.local/bin/vibeshell`, then `/Applications/VibeShell.app/Contents/MacOS/vibeshell` on macOS. Use the resolved absolute path for the rest of the workflow when necessary. If none exists, tell the user which lookup failed. Do not silently replace VibeShell with `ssh`, `scp`, or another client because that bypasses the saved VibeShell configuration and session model. +## Add or delete saved servers + +```bash +vibeshell servers add root@prod.example.com --name prod-web +SSH_PASSWORD=... vibeshell servers add root@prod.example.com --name prod-web +vibeshell servers add ubuntu@10.0.0.8:2222 --identity ~/.ssh/id_ed25519 --jump bastion --agent-forwarding +vibeshell servers delete prod-web +``` + +Display name defaults to the host. Secrets must not appear on the command line. + ## Import existing SSH configurations Preview everything VibeShell can discover: diff --git a/README.md b/README.md index ea05eb0..0f046f8 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Import the discovered OpenSSH, PuTTY, and Tabby profiles: ```bash vibeshell import auto +vibeshell servers add root@prod.example.com --name prod-web vibeshell servers ``` @@ -224,7 +225,7 @@ The standalone `vibeshell` binary is a real Rust client and daemon, not a JavaSc | Area | Commands | | --- | --- | | Version and diagnostics | `vibeshell version`, `vibeshell daemon start`, `vibeshell daemon status` | -| Inventory | `vibeshell servers` | +| Inventory | `vibeshell servers`, `vibeshell servers add user@host`, `vibeshell servers delete ` | | Import | `vibeshell import auto|openssh|putty|tabby [--path ...] [--dry-run] [--json]` | | Connect | `vibeshell ssh [--new] [--wait]` | | Remote command | `vibeshell ssh -- `, `--command-file`, or `--command-stdin` | diff --git a/cli/README.md b/cli/README.md index e45fca8..2d58df0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -26,11 +26,14 @@ Both installers verify the native binary and trigger its built-in, idempotent Sk vibeshell version vibeshell import auto --dry-run vibeshell import auto +vibeshell servers add root@prod.example.com --name prod-web vibeshell servers vibeshell ssh vibeshell sftp ``` +Add a server without the GUI using `user@host[:port]`. Passwords are read from `SSH_PASSWORD` or `VIBESHELL_PASSWORD` (never argv). Use `--identity` for a private key. Delete with `vibeshell servers delete `. + Commands that need an SSH/SFTP session automatically start the native local daemon. The daemon stores its IPC endpoint and state under the current user's VibeShell data directory and can be inspected directly: ```bash diff --git a/cli/src/commands/server.rs b/cli/src/commands/server.rs index 1faff71..23fd776 100644 --- a/cli/src/commands/server.rs +++ b/cli/src/commands/server.rs @@ -1,12 +1,26 @@ //! Server management commands for the CLI. -//! -//! These commands allow listing configured servers by communicating -//! with the VibeShell GUI over IPC. -use anyhow::{bail, Result}; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use vibeshell_core::commands::server::AddServerSpec; use vibeshell_core::ipc::IpcMessage; use crate::ipc_support; +use crate::ssh_target; + +pub struct AddServerArgs { + pub target: String, + pub name: Option, + pub user: Option, + pub port: Option, + pub identity: Option, + pub jump: Option, + pub agent_forwarding: bool, + pub post_login: Option, + pub group: Option, + pub tags: Vec, +} /// List all configured servers known to VibeShell. pub fn list() -> Result<()> { @@ -36,3 +50,146 @@ pub fn list() -> Result<()> { } } } + +/// Add a server from `user@host[:port]` shorthand. Secrets come from env vars, +/// never from argv: `SSH_PASSWORD` or `VIBESHELL_PASSWORD`, and +/// `VIBESHELL_KEY_PASSPHRASE` when `--identity` points at an encrypted key. +pub fn add(args: AddServerArgs) -> Result<()> { + let target = ssh_target::parse_ssh_target(&args.target)?; + let username = args + .user + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or(target.username) + .ok_or_else(|| anyhow::anyhow!("Username is required (use user@host or pass --user)"))?; + let host = target.host; + let port = args.port.or(target.port).unwrap_or(22); + let name = args + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| host.clone()); + + let identity_path = args.identity.as_deref(); + let (auth_type, credential, passphrase, key_path, saved_credentials) = + resolve_credentials(identity_path)?; + + let spec = AddServerSpec { + name: name.clone(), + host: host.clone(), + port, + username: username.clone(), + auth_type: auth_type.to_string(), + group_id: None, + group_name: args.group, + tags: args.tags, + jump_host_id: None, + jump_host: args.jump, + post_login_command: args.post_login, + agent_forwarding: args.agent_forwarding, + credential, + passphrase, + key_path, + }; + + match ipc_support::send(&IpcMessage::AddServer { spec })? { + IpcMessage::ServerAdded { server } => { + println!( + "Added server '{}' ({}@{}:{})", + server.name, server.username, server.host, server.port + ); + if !saved_credentials { + if identity_path.is_some() { + eprintln!( + "Warning: --identity was set but the key file could not be stored. This should not happen." + ); + } else { + eprintln!( + "Credentials were not saved. Set SSH_PASSWORD or VIBESHELL_PASSWORD to store a password, or pass --identity KEYFILE." + ); + } + } + Ok(()) + } + IpcMessage::Error { message } => { + bail!("Error adding server: {}", message); + } + _ => bail!("Unexpected response from background service"), + } +} + +pub fn delete(name: &str) -> Result<()> { + let name = name.trim(); + if name.is_empty() { + bail!("Server name is required"); + } + + match ipc_support::send(&IpcMessage::DeleteServer { + name: name.to_string(), + })? { + IpcMessage::Ok => { + println!("Deleted server '{name}'"); + Ok(()) + } + IpcMessage::Error { message } => { + bail!("Error deleting server: {}", message); + } + _ => bail!("Unexpected response from background service"), + } +} + +fn resolve_credentials( + identity: Option<&Path>, +) -> Result<( + &'static str, + Option, + Option, + Option, + bool, +)> { + if let Some(path) = identity { + let key = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read identity file {}", path.display()))?; + if key.trim().is_empty() { + bail!("Identity file {} is empty", path.display()); + } + let passphrase = + env_nonempty("VIBESHELL_KEY_PASSPHRASE").or_else(|| env_nonempty("SSH_KEY_PASSPHRASE")); + Ok(( + "key_with_passphrase", + Some(key), + passphrase, + Some(path.display().to_string()), + true, + )) + } else if let Some(password) = + env_nonempty("SSH_PASSWORD").or_else(|| env_nonempty("VIBESHELL_PASSWORD")) + { + Ok(("password", Some(password), None, None, true)) + } else { + Ok(("password", None, None, None, false)) + } +} + +fn env_nonempty(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::env_nonempty; + + #[test] + fn env_nonempty_treats_blank_as_unset() { + std::env::set_var("VIBESHELL_TEST_EMPTY_SECRET", " "); + assert!(env_nonempty("VIBESHELL_TEST_EMPTY_SECRET").is_none()); + std::env::remove_var("VIBESHELL_TEST_EMPTY_SECRET"); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 8234309..792bd5e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -9,6 +9,7 @@ mod commands; mod daemon; mod ipc_support; mod session_alias; +mod ssh_target; mod terminal; use std::path::PathBuf; @@ -42,7 +43,9 @@ use commands::file_tools::ContentInputArgs; vibeshell sftp prod-web ls /var/www\n\ vibeshell sftp prod-web get /etc/nginx/nginx.conf .\\nginx.conf\n\ vibeshell sftp prod-web\n\ - vibeshell sessions\n\ + vibeshell servers\n\ + vibeshell servers add root@prod.example.com:22 --name prod-web --identity ~/.ssh/id_ed25519\n\ + vibeshell servers delete prod-web\n\ vibeshell daemon status" )] struct Cli { @@ -64,7 +67,8 @@ enum Commands { Pass `--new` to force creation of a fresh session.\n\ For heavily quoted commands, use `--command-file ` or pipe the command into\n\ `--command-stdin` to bypass local shell parsing.\n\ - The target server must already exist in the VibeShell database.\n\n\ + The target server must already exist in the VibeShell database.\n\ + Add one with `vibeshell servers add user@host` if it is not listed by `vibeshell servers`.\n\n\ Examples:\n\ vibeshell ssh prod-web\n\ vibeshell ssh prod-web --new\n\ @@ -161,9 +165,9 @@ enum Commands { )] EditFile(EditFileArgs), - /// List all configured servers + /// List, add, or delete configured servers #[command(alias = "server-list")] - Servers, + Servers(ServersArgs), /// Import OpenSSH, PuTTY, and Tabby connection profiles #[command( @@ -215,6 +219,82 @@ enum Commands { Uninstall(UninstallArgs), } +#[derive(Args)] +struct ServersArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum ServersCommand { + /// Add a server from user@host[:port] shorthand + #[command( + long_about = "Add a saved SSH server without using the desktop UI.\n\n\ + The first argument is `user@host` or `user@host:port`. Optional flags match the Add Server dialog.\n\ + Passwords are read from SSH_PASSWORD or VIBESHELL_PASSWORD (never from argv).\n\ + Key files are passed with --identity; encrypted-key passphrases use VIBESHELL_KEY_PASSPHRASE.\n\ + If no password env var and no --identity are provided, the server is stored without credentials.\n\n\ + Examples:\n\ + vibeshell servers add root@prod.example.com\n\ + SSH_PASSWORD=... vibeshell servers add root@prod.example.com --name prod-web\n\ + vibeshell servers add ubuntu@10.0.0.8:2222 --identity ~/.ssh/id_ed25519 --jump bastion" + )] + Add(ServerAddCliArgs), + /// Delete a saved server by name or ID + #[command(alias = "rm", alias = "remove")] + Delete(ServerDeleteCliArgs), + /// List configured servers + List, +} + +#[derive(Args)] +struct ServerAddCliArgs { + /// Connection target: user@host, user@host:port, or host (with --user) + target: String, + + /// Display name (defaults to the host) + #[arg(long)] + name: Option, + + /// Username (overrides the user@ portion of the target) + #[arg(long, short = 'u')] + user: Option, + + /// SSH port (overrides :port in the target; default 22) + #[arg(long, short = 'p')] + port: Option, + + /// Path to an OpenSSH private key (enables key auth) + #[arg(long, short = 'i', value_name = "KEYFILE")] + identity: Option, + + /// Jump host display name or ID + #[arg(long)] + jump: Option, + + /// Enable SSH agent forwarding + #[arg(long)] + agent_forwarding: bool, + + /// Command to run after login + #[arg(long = "post-login")] + post_login: Option, + + /// Group name (created if missing) + #[arg(long)] + group: Option, + + /// Tags (repeatable) + #[arg(long = "tag")] + tags: Vec, +} + +#[derive(Args)] +struct ServerDeleteCliArgs { + /// Server display name or ID + name: String, +} + #[derive(Args)] struct SshArgs { /// Name of the configured server to connect to @@ -524,7 +604,24 @@ fn main() -> Result<()> { args.with_text.as_deref(), args.all, ), - Some(Commands::Servers) => commands::server::list(), + Some(Commands::Servers(args)) => match args.command { + None | Some(ServersCommand::List) => commands::server::list(), + Some(ServersCommand::Add(add)) => { + commands::server::add(commands::server::AddServerArgs { + target: add.target, + name: add.name, + user: add.user, + port: add.port, + identity: add.identity, + jump: add.jump, + agent_forwarding: add.agent_forwarding, + post_login: add.post_login, + group: add.group, + tags: add.tags, + }) + } + Some(ServersCommand::Delete(delete)) => commands::server::delete(&delete.name), + }, Some(Commands::Import(args)) => { commands::import::run(args.source.into(), args.path, args.dry_run, args.json) } @@ -595,6 +692,38 @@ mod tests { assert!(parsed.is_ok(), "vibeshell servers should parse"); } + #[test] + fn parses_servers_add_shorthand() { + let parsed = Cli::try_parse_from([ + "vibeshell", + "servers", + "add", + "root@prod.example.com:2222", + "--name", + "prod-web", + "--identity", + "/tmp/id_ed25519", + "--jump", + "bastion", + "--agent-forwarding", + "--post-login", + "uptime", + "--group", + "production", + "--tag", + "web", + ]); + assert!(parsed.is_ok(), "vibeshell servers add should parse"); + } + + #[test] + fn parses_servers_delete() { + let parsed = Cli::try_parse_from(["vibeshell", "servers", "delete", "prod-web"]); + assert!(parsed.is_ok(), "vibeshell servers delete should parse"); + let parsed = Cli::try_parse_from(["vibeshell", "servers", "rm", "prod-web"]); + assert!(parsed.is_ok(), "vibeshell servers rm should parse"); + } + #[test] fn parses_import_auto_dry_run() { let parsed = Cli::try_parse_from(["vibeshell", "import", "auto", "--dry-run", "--json"]); diff --git a/cli/src/ssh_target.rs b/cli/src/ssh_target.rs new file mode 100644 index 0000000..20063a8 --- /dev/null +++ b/cli/src/ssh_target.rs @@ -0,0 +1,113 @@ +//! Parse `user@host[:port]` shorthand used by `vibeshell servers add`. + +use anyhow::{bail, Result}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SshTarget { + pub username: Option, + pub host: String, + pub port: Option, +} + +pub fn parse_ssh_target(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + bail!("Target is required (user@host[:port])"); + } + + let (username, hostport) = match input.split_once('@') { + Some((user, rest)) => { + if user.is_empty() { + bail!("Username is empty in '{input}'"); + } + if rest.is_empty() { + bail!("Host is empty in '{input}'"); + } + (Some(user.to_string()), rest) + } + None => (None, input), + }; + + let (host, port) = split_host_port(hostport)?; + if host.is_empty() { + bail!("Host is empty in '{input}'"); + } + + Ok(SshTarget { + username, + host, + port, + }) +} + +fn split_host_port(hostport: &str) -> Result<(String, Option)> { + if let Some(rest) = hostport.strip_prefix('[') { + let Some((host, after)) = rest.split_once(']') else { + bail!("Invalid IPv6 target '{hostport}' (missing ']')"); + }; + if after.is_empty() { + return Ok((host.to_string(), None)); + } + let Some(port_str) = after.strip_prefix(':') else { + bail!("Invalid IPv6 target '{hostport}'"); + }; + return Ok((host.to_string(), Some(parse_port(port_str)?))); + } + + if let Some((host, port_str)) = hostport.rsplit_once(':') { + if !host.contains(':') + && !port_str.is_empty() + && port_str.chars().all(|c| c.is_ascii_digit()) + { + return Ok((host.to_string(), Some(parse_port(port_str)?))); + } + } + + Ok((hostport.to_string(), None)) +} + +fn parse_port(value: &str) -> Result { + let port: u16 = value + .parse() + .map_err(|_| anyhow::anyhow!("Invalid port '{value}'"))?; + if port == 0 { + bail!("Port must be between 1 and 65535"); + } + Ok(port) +} + +#[cfg(test)] +mod tests { + use super::parse_ssh_target; + + #[test] + fn parses_user_host_port() { + let target = parse_ssh_target("root@prod.example.com:2222").unwrap(); + assert_eq!(target.username.as_deref(), Some("root")); + assert_eq!(target.host, "prod.example.com"); + assert_eq!(target.port, Some(2222)); + } + + #[test] + fn parses_user_host_default_port() { + let target = parse_ssh_target("ubuntu@10.0.0.8").unwrap(); + assert_eq!(target.username.as_deref(), Some("ubuntu")); + assert_eq!(target.host, "10.0.0.8"); + assert_eq!(target.port, None); + } + + #[test] + fn parses_host_only() { + let target = parse_ssh_target("db.internal").unwrap(); + assert!(target.username.is_none()); + assert_eq!(target.host, "db.internal"); + assert_eq!(target.port, None); + } + + #[test] + fn parses_bracket_ipv6() { + let target = parse_ssh_target("root@[2001:db8::1]:22").unwrap(); + assert_eq!(target.host, "2001:db8::1"); + assert_eq!(target.port, Some(22)); + } +} diff --git a/skills/vibeshell/SKILL.md b/skills/vibeshell/SKILL.md index 84d32d3..8e7810c 100644 --- a/skills/vibeshell/SKILL.md +++ b/skills/vibeshell/SKILL.md @@ -12,12 +12,23 @@ VibeShell automatically starts its local headless daemon when an SSH, SFTP, or s ## Before operating 1. Verify the CLI is available with `vibeshell version`. -2. Inspect configured targets with `vibeshell servers`. -3. Prefer saved server names over raw hosts. Credentials remain local to VibeShell and must never be printed, copied into prompts, or passed on the command line. +2. Inspect configured targets with `vibeshell servers`. Add or remove them with `vibeshell servers add` and `vibeshell servers delete` — the desktop UI is not required. +3. Prefer saved server names over raw hosts. Credentials remain local to VibeShell and must never be printed, copied into prompts, or passed on the command line. Passwords for `servers add` come from `SSH_PASSWORD` or `VIBESHELL_PASSWORD`; keys from `--identity`. 4. Reuse an existing session unless the user explicitly needs an independent parallel shell. Resolve the native executable in this order: `vibeshell` from `PATH`, `$HOME/.local/bin/vibeshell`, then `/Applications/VibeShell.app/Contents/MacOS/vibeshell` on macOS. Use the resolved absolute path for the rest of the workflow when necessary. If none exists, tell the user which lookup failed. Do not silently replace VibeShell with `ssh`, `scp`, or another client because that bypasses the saved VibeShell configuration and session model. +## Add or delete saved servers + +```bash +vibeshell servers add root@prod.example.com --name prod-web +SSH_PASSWORD=... vibeshell servers add root@prod.example.com --name prod-web +vibeshell servers add ubuntu@10.0.0.8:2222 --identity ~/.ssh/id_ed25519 --jump bastion --agent-forwarding +vibeshell servers delete prod-web +``` + +Display name defaults to the host. Secrets must not appear on the command line. + ## Import existing SSH configurations Preview everything VibeShell can discover: diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs index 5a81d2e..a505e74 100644 --- a/src-tauri/src/commands/server.rs +++ b/src-tauri/src/commands/server.rs @@ -1,10 +1,188 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use crate::storage::database::Group; use crate::storage::{AuthType, Database, Server}; +/// Shared add-server payload used by the GUI command and the CLI IPC path. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddServerSpec { + pub name: String, + pub host: String, + pub port: u16, + pub username: String, + pub auth_type: String, + pub group_id: Option, + #[serde(default)] + pub group_name: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub jump_host_id: Option, + #[serde(default)] + pub jump_host: Option, + #[serde(default)] + pub post_login_command: Option, + #[serde(default)] + pub agent_forwarding: bool, + #[serde(default)] + pub credential: Option, + #[serde(default)] + pub passphrase: Option, + #[serde(default)] + pub key_path: Option, +} + +/// Insert a server row and optionally save device-local credentials. +/// +/// `group_name` / `jump_host` are resolved by unique name when the corresponding +/// ID is not provided (CLI convenience). +pub fn add_server_spec(db: &Database, spec: AddServerSpec) -> Result { + let name = spec.name.trim().to_string(); + let host = spec.host.trim().to_string(); + let username = spec.username.trim().to_string(); + if name.is_empty() { + return Err("Server name is required".to_string()); + } + if host.is_empty() { + return Err("Host is required".to_string()); + } + if username.is_empty() { + return Err("Username is required".to_string()); + } + if spec.port == 0 { + return Err("Port must be between 1 and 65535".to_string()); + } + + if db + .server_get_by_name(&name) + .map_err(|e| format!("Failed to check existing servers: {e}"))? + .is_some() + { + return Err(format!("A server named '{name}' already exists")); + } + + let group_id = if spec.group_id.as_deref().is_some_and(|id| !id.is_empty()) { + spec.group_id + } else if let Some(group_name) = spec + .group_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(resolve_or_create_group(db, group_name)?) + } else { + None + }; + + let jump_host_id = if spec + .jump_host_id + .as_deref() + .is_some_and(|id| !id.is_empty()) + { + spec.jump_host_id + } else if let Some(jump_name) = spec + .jump_host + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(resolve_server_id(db, jump_name)?) + } else { + None + }; + + let mut new_server = Server { + id: String::new(), + name: name.clone(), + host, + port: spec.port, + username, + auth_type: string_to_auth_type(&spec.auth_type), + credential_id: None, + group_id, + tags: spec.tags, + created_at: 0, + updated_at: 0, + jump_host_id, + post_login_command: spec + .post_login_command + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + agent_forwarding: spec.agent_forwarding, + }; + + db.server_add(&mut new_server) + .map_err(|e| format!("Failed to add server: {e}"))?; + + if let Some(credential) = spec + .credential + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let auth_type = match new_server.auth_type { + AuthType::Password => "password", + AuthType::Key | AuthType::KeyWithPassphrase => "key_with_passphrase", + }; + let credential_id = db + .credential_save( + &new_server.name, + auth_type, + credential, + spec.passphrase + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + spec.key_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + ) + .map_err(|e| format!("Failed to save credentials: {e}"))?; + new_server.credential_id = Some(credential_id); + db.server_update(&new_server) + .map_err(|e| format!("Failed to attach credentials: {e}"))?; + } + + Ok(new_server) +} + +fn resolve_or_create_group(db: &Database, name: &str) -> Result { + if let Some(existing) = db + .group_list() + .map_err(|e| format!("Failed to list groups: {e}"))? + .into_iter() + .find(|group| group.name == name) + { + return Ok(existing.id); + } + + let mut group = Group { + id: String::new(), + name: name.to_string(), + parent_id: None, + color: "#808080".to_string(), + }; + db.group_add(&mut group) + .map_err(|e| format!("Failed to create group '{name}': {e}"))?; + Ok(group.id) +} + +fn resolve_server_id(db: &Database, name_or_id: &str) -> Result { + if let Some(server) = db + .server_get(name_or_id) + .map_err(|e| format!("Failed to look up server: {e}"))? + { + return Ok(server.id); + } + db.server_get_by_name(name_or_id) + .map_err(|e| format!("Failed to look up server '{name_or_id}': {e}"))? + .map(|server| server.id) + .ok_or_else(|| format!("Server '{name_or_id}' not found")) +} + /// Server input from frontend (without auto-generated fields) /// Frontend sends snake_case field names (auth_type, credential_id, etc.) #[derive(Debug, Deserialize)] @@ -60,27 +238,26 @@ pub fn get_servers(db: State<'_, Arc>) -> Result, String> /// Add a new server #[tauri::command] pub fn add_server(db: State<'_, Arc>, server: ServerInput) -> Result { - let mut new_server = Server { - id: String::new(), - name: server.name, - host: server.host, - port: server.port, - username: server.username, - auth_type: string_to_auth_type(&server.auth_type), - credential_id: server.credential_id, - group_id: server.group_id, - tags: server.tags, - created_at: 0, - updated_at: 0, - jump_host_id: server.jump_host_id, - post_login_command: server.post_login_command, - agent_forwarding: server.agent_forwarding, - }; - - db.server_add(&mut new_server) - .map_err(|e| format!("Failed to add server: {}", e))?; - - Ok(new_server) + add_server_spec( + db.inner(), + AddServerSpec { + name: server.name, + host: server.host, + port: server.port, + username: server.username, + auth_type: server.auth_type, + group_id: server.group_id, + group_name: None, + tags: server.tags, + jump_host_id: server.jump_host_id, + jump_host: None, + post_login_command: server.post_login_command, + agent_forwarding: server.agent_forwarding, + credential: None, + passphrase: None, + key_path: None, + }, + ) } /// Partial server update input — all fields optional except name/host/port/username @@ -379,4 +556,93 @@ mod tests { assert!(explicit_null.group_id.is_some()); assert!(explicit_null.jump_host_id.is_some()); } + + #[test] + fn add_server_spec_saves_optional_credentials_and_jump_host() { + let dir = tempfile::tempdir().unwrap(); + let db = crate::storage::Database::new_at(dir.path().join("vibeshell.db")).unwrap(); + + let jump = super::add_server_spec( + &db, + super::AddServerSpec { + name: "bastion".to_string(), + host: "bastion.example.com".to_string(), + port: 22, + username: "jump".to_string(), + auth_type: "password".to_string(), + group_id: None, + group_name: None, + tags: vec![], + jump_host_id: None, + jump_host: None, + post_login_command: None, + agent_forwarding: false, + credential: Some("jump-secret".to_string()), + passphrase: None, + key_path: None, + }, + ) + .unwrap(); + + let added = super::add_server_spec( + &db, + super::AddServerSpec { + name: "prod-web".to_string(), + host: "prod.example.com".to_string(), + port: 2222, + username: "root".to_string(), + auth_type: "key".to_string(), + group_id: None, + group_name: Some("production".to_string()), + tags: vec!["web".to_string()], + jump_host_id: None, + jump_host: Some("bastion".to_string()), + post_login_command: Some("uptime".to_string()), + agent_forwarding: true, + credential: Some( + "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----" + .to_string(), + ), + passphrase: Some("phrase".to_string()), + key_path: Some("/tmp/id_ed25519".to_string()), + }, + ) + .unwrap(); + + assert_eq!(added.host, "prod.example.com"); + assert_eq!(added.port, 2222); + assert_eq!(added.jump_host_id.as_deref(), Some(jump.id.as_str())); + assert!(added.agent_forwarding); + assert_eq!(added.post_login_command.as_deref(), Some("uptime")); + assert_eq!(added.tags, vec!["web".to_string()]); + assert!(added.credential_id.is_some()); + assert!(added.group_id.is_some()); + + let cred = db.credential_get("prod-web").unwrap().unwrap(); + assert_eq!(cred.auth_type, "key_with_passphrase"); + assert_eq!(cred.passphrase.as_deref(), Some("phrase")); + assert_eq!(cred.key_path.as_deref(), Some("/tmp/id_ed25519")); + + let duplicate = super::add_server_spec( + &db, + super::AddServerSpec { + name: "prod-web".to_string(), + host: "other.example.com".to_string(), + port: 22, + username: "root".to_string(), + auth_type: "password".to_string(), + group_id: None, + group_name: None, + tags: vec![], + jump_host_id: None, + jump_host: None, + post_login_command: None, + agent_forwarding: false, + credential: None, + passphrase: None, + key_path: None, + }, + ); + assert!(duplicate.unwrap_err().contains("already exists")); + } } diff --git a/src-tauri/src/ipc/socket.rs b/src-tauri/src/ipc/socket.rs index 01517b9..f7febed 100644 --- a/src-tauri/src/ipc/socket.rs +++ b/src-tauri/src/ipc/socket.rs @@ -115,6 +115,12 @@ pub enum IpcMessage { // Requests from CLI to GUI /// List all configured servers ListServers, + /// Add a server to the shared database (used by `vibeshell servers add`) + AddServer { + spec: crate::commands::server::AddServerSpec, + }, + /// Delete a server by unique name or ID (used by `vibeshell servers delete`) + DeleteServer { name: String }, /// List all active sessions ListSessions, /// Create a new session connecting to the specified server @@ -228,6 +234,8 @@ pub enum IpcMessage { // Responses from GUI to CLI /// List of configured servers ServerList { servers: Vec }, + /// A server was added + ServerAdded { server: IpcServerInfo }, /// List of active session IDs SessionList { sessions: Vec }, /// A new session was created @@ -788,6 +796,73 @@ impl IpcServer { rt: &tokio::runtime::Handle, ) -> IpcMessage { match message { + IpcMessage::AddServer { spec } => { + match crate::commands::server::add_server_spec(&database, spec) { + std::result::Result::Ok(server) => IpcMessage::ServerAdded { + server: IpcServerInfo { + id: server.id, + name: server.name, + host: server.host, + port: server.port, + username: server.username, + auth_type: auth_type_to_string(&server.auth_type).to_string(), + group_id: server.group_id, + jump_host_id: server.jump_host_id, + tags: server.tags, + }, + }, + Err(message) => IpcMessage::Error { message }, + } + } + IpcMessage::DeleteServer { name } => { + let server = match database.server_get(&name) { + std::result::Result::Ok(Some(server)) => server, + std::result::Result::Ok(None) => match database.server_get_by_name(&name) { + std::result::Result::Ok(Some(server)) => server, + std::result::Result::Ok(None) => { + return IpcMessage::Error { + message: format!("Server '{name}' not found"), + }; + } + Err(e) => { + return IpcMessage::Error { + message: format!("Failed to look up server: {e}"), + }; + } + }, + Err(e) => { + return IpcMessage::Error { + message: format!("Failed to look up server: {e}"), + }; + } + }; + + let killed = + rt.block_on(async { session_manager.kill_by_server_id(&server.id).await }); + match killed { + std::result::Result::Ok(session_ids) => { + for session_id in session_ids { + Self::clear_sftp_context(&sftp_contexts, &session_id); + } + } + Err(e) => { + return IpcMessage::Error { + message: format!("Failed to close sessions for server: {e}"), + }; + } + } + + if let Err(e) = database.credential_delete(&server.name) { + log::warn!("Failed to delete credentials for '{}': {}", server.name, e); + } + + match database.server_delete(&server.id) { + std::result::Result::Ok(()) => IpcMessage::Ok, + Err(e) => IpcMessage::Error { + message: format!("Failed to delete server: {e}"), + }, + } + } IpcMessage::ListServers => match database.server_list(None, None) { std::result::Result::Ok(servers) => { let servers = servers @@ -1850,6 +1925,13 @@ mod tests { let json = serde_json::to_string(&msg).unwrap(); assert!(json.contains("ListServers")); + let msg = IpcMessage::DeleteServer { + name: "prod-web".to_string(), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("DeleteServer")); + assert!(json.contains("prod-web")); + let msg = IpcMessage::ListSessions; let json = serde_json::to_string(&msg).unwrap(); assert!(json.contains("ListSessions")); From 3d5e0466b9abb62704ea3eb92b65d4b4f845e8b1 Mon Sep 17 00:00:00 2001 From: Giray Pultar Date: Tue, 15 Sep 2026 17:59:45 +0300 Subject: [PATCH 2/3] Add Teleport servers that connect through tsh Saved servers can be SSH or Teleport. Teleport sessions spawn `tsh ssh` as the PTY, file/exec operations wrap `tsh scp`/`tsh ssh`, and import reads `tsh status` plus `tsh ls`. CLI add/import and the GUI Add/Edit dialogs accept a required proxy; login/MFA stays with `tsh login`. --- .claude/skills/vibeshell/SKILL.md | 4 +- .codex/skills/vibeshell/SKILL.md | 4 +- README.md | 2 +- cli/README.md | 2 +- cli/src/commands/server.rs | 44 +- cli/src/main.rs | 29 +- skills/vibeshell/SKILL.md | 4 +- src-tauri/src/cloud_sync/mod.rs | 6 +- src-tauri/src/cloud_sync/portable_file.rs | 6 +- src-tauri/src/commands/server.rs | 64 +- src-tauri/src/commands/session.rs | 37 +- src-tauri/src/commands/sftp.rs | 197 +++++- src-tauri/src/ipc/socket.rs | 269 +++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/mcp/server.rs | 4 +- src-tauri/src/session/manager.rs | 127 ++++ src-tauri/src/session/session.rs | 73 +++ src-tauri/src/ssh_import/mod.rs | 116 +++- src-tauri/src/ssh_import/openssh.rs | 2 + src-tauri/src/ssh_import/putty.rs | 2 + src-tauri/src/ssh_import/tabby.rs | 2 + src-tauri/src/storage/database.rs | 45 +- src-tauri/src/storage/mod.rs | 4 +- src-tauri/src/storage/models.rs | 20 + src-tauri/src/storage/sync.rs | 84 ++- src-tauri/src/teleport/mod.rs | 589 ++++++++++++++++++ .../cloud_sync_webdav_integration_test.rs | 4 +- src/App.tsx | 18 +- .../AddServerDialog/AddServerDialog.test.tsx | 23 + .../AddServerDialog/AddServerDialog.tsx | 100 ++- .../EditServerDialog/EditServerDialog.tsx | 64 +- src/stores/serverStore.ts | 8 + 32 files changed, 1833 insertions(+), 121 deletions(-) create mode 100644 src-tauri/src/teleport/mod.rs diff --git a/.claude/skills/vibeshell/SKILL.md b/.claude/skills/vibeshell/SKILL.md index 8e7810c..8408ad4 100644 --- a/.claude/skills/vibeshell/SKILL.md +++ b/.claude/skills/vibeshell/SKILL.md @@ -24,10 +24,11 @@ Resolve the native executable in this order: `vibeshell` from `PATH`, `$HOME/.lo vibeshell servers add root@prod.example.com --name prod-web SSH_PASSWORD=... vibeshell servers add root@prod.example.com --name prod-web vibeshell servers add ubuntu@10.0.0.8:2222 --identity ~/.ssh/id_ed25519 --jump bastion --agent-forwarding +vibeshell servers add alice@web-1 --type teleport --proxy teleport.example.com:443 vibeshell servers delete prod-web ``` -Display name defaults to the host. Secrets must not appear on the command line. +Display name defaults to the host. Secrets must not appear on the command line. Teleport nodes use `tsh` from PATH after `tsh login --proxy=...`. ## Import existing SSH configurations @@ -50,6 +51,7 @@ vibeshell import openssh vibeshell import openssh --path ~/.ssh/config vibeshell import tabby --path ~/.config/tabby/config.yaml vibeshell import putty --path ~/putty-sessions.reg +vibeshell import teleport ``` Use `--json` when structured output is more useful. Never import or expose plaintext passwords from third-party profiles. VibeShell may reference an existing private-key path and reads that local key only when establishing a connection. diff --git a/.codex/skills/vibeshell/SKILL.md b/.codex/skills/vibeshell/SKILL.md index 8e7810c..8408ad4 100644 --- a/.codex/skills/vibeshell/SKILL.md +++ b/.codex/skills/vibeshell/SKILL.md @@ -24,10 +24,11 @@ Resolve the native executable in this order: `vibeshell` from `PATH`, `$HOME/.lo vibeshell servers add root@prod.example.com --name prod-web SSH_PASSWORD=... vibeshell servers add root@prod.example.com --name prod-web vibeshell servers add ubuntu@10.0.0.8:2222 --identity ~/.ssh/id_ed25519 --jump bastion --agent-forwarding +vibeshell servers add alice@web-1 --type teleport --proxy teleport.example.com:443 vibeshell servers delete prod-web ``` -Display name defaults to the host. Secrets must not appear on the command line. +Display name defaults to the host. Secrets must not appear on the command line. Teleport nodes use `tsh` from PATH after `tsh login --proxy=...`. ## Import existing SSH configurations @@ -50,6 +51,7 @@ vibeshell import openssh vibeshell import openssh --path ~/.ssh/config vibeshell import tabby --path ~/.config/tabby/config.yaml vibeshell import putty --path ~/putty-sessions.reg +vibeshell import teleport ``` Use `--json` when structured output is more useful. Never import or expose plaintext passwords from third-party profiles. VibeShell may reference an existing private-key path and reads that local key only when establishing a connection. diff --git a/README.md b/README.md index 0f046f8..d59cd4d 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ The standalone `vibeshell` binary is a real Rust client and daemon, not a JavaSc | --- | --- | | Version and diagnostics | `vibeshell version`, `vibeshell daemon start`, `vibeshell daemon status` | | Inventory | `vibeshell servers`, `vibeshell servers add user@host`, `vibeshell servers delete ` | -| Import | `vibeshell import auto|openssh|putty|tabby [--path ...] [--dry-run] [--json]` | +| Import | `vibeshell import auto|openssh|putty|tabby|teleport [--path ...] [--dry-run] [--json]` | | Connect | `vibeshell ssh [--new] [--wait]` | | Remote command | `vibeshell ssh -- `, `--command-file`, or `--command-stdin` | | Sessions | `vibeshell sessions`, `vibeshell attach`, `vibeshell ssh-session`, `vibeshell exec`, `vibeshell send-key`, `vibeshell kill` | diff --git a/cli/README.md b/cli/README.md index 2d58df0..6a0307c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -32,7 +32,7 @@ vibeshell ssh vibeshell sftp ``` -Add a server without the GUI using `user@host[:port]`. Passwords are read from `SSH_PASSWORD` or `VIBESHELL_PASSWORD` (never argv). Use `--identity` for a private key. Delete with `vibeshell servers delete `. +Add a server without the GUI using `user@host[:port]`. Passwords are read from `SSH_PASSWORD` or `VIBESHELL_PASSWORD` (never argv). Use `--identity` for a private key. Teleport: `vibeshell servers add user@node --type teleport --proxy teleport.example.com:443`. Delete with `vibeshell servers delete `. Commands that need an SSH/SFTP session automatically start the native local daemon. The daemon stores its IPC endpoint and state under the current user's VibeShell data directory and can be inspected directly: diff --git a/cli/src/commands/server.rs b/cli/src/commands/server.rs index 23fd776..296997a 100644 --- a/cli/src/commands/server.rs +++ b/cli/src/commands/server.rs @@ -20,6 +20,8 @@ pub struct AddServerArgs { pub post_login: Option, pub group: Option, pub tags: Vec, + pub connection_kind: String, + pub teleport_proxy: Option, } /// List all configured servers known to VibeShell. @@ -35,10 +37,21 @@ pub fn list() -> Result<()> { println!("Configured servers:"); for server in servers { - println!( - " {} {}@{}:{} auth={}", - server.name, server.username, server.host, server.port, server.auth_type - ); + let kind = server.connection_kind.as_deref().unwrap_or("ssh"); + if kind == "teleport" { + println!( + " {} {}@{} teleport proxy={}", + server.name, + server.username, + server.host, + server.teleport_proxy.as_deref().unwrap_or("-") + ); + } else { + println!( + " {} {}@{}:{} auth={}", + server.name, server.username, server.host, server.port, server.auth_type + ); + } } Ok(()) } @@ -75,8 +88,25 @@ pub fn add(args: AddServerArgs) -> Result<()> { .unwrap_or_else(|| host.clone()); let identity_path = args.identity.as_deref(); - let (auth_type, credential, passphrase, key_path, saved_credentials) = - resolve_credentials(identity_path)?; + let is_teleport = args.connection_kind.eq_ignore_ascii_case("teleport") + || args.connection_kind.eq_ignore_ascii_case("tsh"); + if is_teleport { + let proxy_ok = args + .teleport_proxy + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some(); + if !proxy_ok { + bail!("Teleport servers require --proxy (for example teleport.example.com:443)"); + } + } + + let (auth_type, credential, passphrase, key_path, saved_credentials) = if is_teleport { + ("password", None, None, None, true) + } else { + resolve_credentials(identity_path)? + }; let spec = AddServerSpec { name: name.clone(), @@ -91,6 +121,8 @@ pub fn add(args: AddServerArgs) -> Result<()> { jump_host: args.jump, post_login_command: args.post_login, agent_forwarding: args.agent_forwarding, + connection_kind: Some(args.connection_kind), + teleport_proxy: args.teleport_proxy, credential, passphrase, key_path, diff --git a/cli/src/main.rs b/cli/src/main.rs index 792bd5e..ff7534f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -287,6 +287,14 @@ struct ServerAddCliArgs { /// Tags (repeatable) #[arg(long = "tag")] tags: Vec, + + /// Connection type: ssh (default) or teleport + #[arg(long = "type", default_value = "ssh")] + connection_type: String, + + /// Teleport proxy, e.g. teleport.example.com:443 (required with --type teleport) + #[arg(long)] + proxy: Option, } #[derive(Args)] @@ -465,6 +473,7 @@ enum ImportSourceArg { OpenSsh, Putty, Tabby, + Teleport, } impl From for vibeshell_core::ssh_import::ImportSourceKind { @@ -474,13 +483,14 @@ impl From for vibeshell_core::ssh_import::ImportSourceKind { ImportSourceArg::OpenSsh => Self::OpenSsh, ImportSourceArg::Putty => Self::Putty, ImportSourceArg::Tabby => Self::Tabby, + ImportSourceArg::Teleport => Self::Teleport, } } } #[derive(Args)] struct ImportArgs { - /// Source to import: auto, openssh, putty, or tabby + /// Source to import: auto, openssh, putty, tabby, or teleport #[arg(value_enum, default_value = "auto")] source: ImportSourceArg, @@ -618,6 +628,8 @@ fn main() -> Result<()> { post_login: add.post_login, group: add.group, tags: add.tags, + connection_kind: add.connection_type, + teleport_proxy: add.proxy, }) } Some(ServersCommand::Delete(delete)) => commands::server::delete(&delete.name), @@ -716,6 +728,21 @@ mod tests { assert!(parsed.is_ok(), "vibeshell servers add should parse"); } + #[test] + fn parses_servers_add_teleport() { + let parsed = Cli::try_parse_from([ + "vibeshell", + "servers", + "add", + "alice@web-1", + "--type", + "teleport", + "--proxy", + "teleport.example.com:443", + ]); + assert!(parsed.is_ok(), "teleport servers add should parse"); + } + #[test] fn parses_servers_delete() { let parsed = Cli::try_parse_from(["vibeshell", "servers", "delete", "prod-web"]); diff --git a/skills/vibeshell/SKILL.md b/skills/vibeshell/SKILL.md index 8e7810c..8408ad4 100644 --- a/skills/vibeshell/SKILL.md +++ b/skills/vibeshell/SKILL.md @@ -24,10 +24,11 @@ Resolve the native executable in this order: `vibeshell` from `PATH`, `$HOME/.lo vibeshell servers add root@prod.example.com --name prod-web SSH_PASSWORD=... vibeshell servers add root@prod.example.com --name prod-web vibeshell servers add ubuntu@10.0.0.8:2222 --identity ~/.ssh/id_ed25519 --jump bastion --agent-forwarding +vibeshell servers add alice@web-1 --type teleport --proxy teleport.example.com:443 vibeshell servers delete prod-web ``` -Display name defaults to the host. Secrets must not appear on the command line. +Display name defaults to the host. Secrets must not appear on the command line. Teleport nodes use `tsh` from PATH after `tsh login --proxy=...`. ## Import existing SSH configurations @@ -50,6 +51,7 @@ vibeshell import openssh vibeshell import openssh --path ~/.ssh/config vibeshell import tabby --path ~/.config/tabby/config.yaml vibeshell import putty --path ~/putty-sessions.reg +vibeshell import teleport ``` Use `--json` when structured output is more useful. Never import or expose plaintext passwords from third-party profiles. VibeShell may reference an existing private-key path and reads that local key only when establishing a connection. diff --git a/src-tauri/src/cloud_sync/mod.rs b/src-tauri/src/cloud_sync/mod.rs index 2a1944e..37f8800 100644 --- a/src-tauri/src/cloud_sync/mod.rs +++ b/src-tauri/src/cloud_sync/mod.rs @@ -533,7 +533,7 @@ mod tests { sync::atomic::{AtomicBool, Ordering}, }; - use crate::storage::{AuthType, CommandSnippet, Server}; + use crate::storage::{AuthType, CommandSnippet, ConnectionKind, Server}; use super::*; @@ -674,6 +674,8 @@ mod tests { jump_host_id: None, post_login_command: Some("secret-command".to_string()), agent_forwarding: false, + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, }; source.server_add(&mut server).unwrap(); @@ -775,6 +777,8 @@ mod tests { jump_host_id: None, post_login_command: None, agent_forwarding: false, + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, }; database.server_add(&mut server).unwrap(); diff --git a/src-tauri/src/cloud_sync/portable_file.rs b/src-tauri/src/cloud_sync/portable_file.rs index dc29887..228be73 100644 --- a/src-tauri/src/cloud_sync/portable_file.rs +++ b/src-tauri/src/cloud_sync/portable_file.rs @@ -102,7 +102,7 @@ pub fn import_from_path(database: &Database, path: &Path) -> Result, + #[serde(default)] + pub teleport_proxy: Option, + #[serde(default)] pub credential: Option, #[serde(default)] pub passphrase: Option, @@ -93,6 +97,23 @@ pub fn add_server_spec(db: &Database, spec: AddServerSpec) -> Result Result, + #[serde(default)] + #[serde(alias = "teleportProxy")] + pub teleport_proxy: Option, } /// Group input from frontend @@ -228,6 +261,13 @@ fn string_to_auth_type(s: &str) -> AuthType { } } +fn parse_connection_kind(value: Option<&str>) -> ConnectionKind { + match value.map(str::trim).unwrap_or("ssh") { + "teleport" | "tsh" => ConnectionKind::Teleport, + _ => ConnectionKind::Ssh, + } +} + /// Get all servers #[tauri::command] pub fn get_servers(db: State<'_, Arc>) -> Result, String> { @@ -253,6 +293,8 @@ pub fn add_server(db: State<'_, Arc>, server: ServerInput) -> Result>, #[serde(alias = "agentForwarding")] pub agent_forwarding: Option, + #[serde(default)] + #[serde(alias = "connectionKind")] + pub connection_kind: Option, + #[serde( + default, + alias = "teleportProxy", + deserialize_with = "deserialize_present_option" + )] + pub teleport_proxy: Option>, } fn deserialize_present_option<'de, D, T>(deserializer: D) -> Result>, D::Error> @@ -348,6 +399,11 @@ pub fn update_server( agent_forwarding: updates .agent_forwarding .unwrap_or(existing.agent_forwarding), + connection_kind: updates + .connection_kind + .map(|kind| parse_connection_kind(Some(&kind))) + .unwrap_or(existing.connection_kind), + teleport_proxy: updates.teleport_proxy.unwrap_or(existing.teleport_proxy), }; db.server_update(&updated_server) @@ -577,6 +633,8 @@ mod tests { jump_host: None, post_login_command: None, agent_forwarding: false, + connection_kind: None, + teleport_proxy: None, credential: Some("jump-secret".to_string()), passphrase: None, key_path: None, @@ -599,6 +657,8 @@ mod tests { jump_host: Some("bastion".to_string()), post_login_command: Some("uptime".to_string()), agent_forwarding: true, + connection_kind: None, + teleport_proxy: None, credential: Some( "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----" .to_string(), @@ -638,6 +698,8 @@ mod tests { jump_host: None, post_login_command: None, agent_forwarding: false, + connection_kind: None, + teleport_proxy: None, credential: None, passphrase: None, key_path: None, diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index c667fa1..60f09c9 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -127,7 +127,14 @@ fn emit_session_output_event(app: &AppHandle, session_id: &str, data: Vec) { async fn emit_replay_output(webview: &tauri::WebviewWindow, session: &Arc) { for data in session.replay_output().await { - let _ = webview.emit_to(webview.label(), "session-output", SessionOutputEvent { session_id: session.id.clone(), data }); + let _ = webview.emit_to( + webview.label(), + "session-output", + SessionOutputEvent { + session_id: session.id.clone(), + data, + }, + ); } } @@ -392,18 +399,22 @@ pub async fn session_connect( } } - // Parse credentials based on auth type - let ssh_credential = match request.auth_type.as_str() { - "password" => SshCredential::Password(request.credential), - "key" => SshCredential::PrivateKey { - key: request.credential, - // Treat an empty passphrase as "no passphrase" so unencrypted - // keys authenticate instead of failing to decode with Some(""). - passphrase: request - .passphrase - .filter(|passphrase| !passphrase.is_empty()), - }, - _ => return Err(format!("Unknown auth type: {}", request.auth_type)), + let ssh_credential = if manager + .is_teleport_server(&request.server_name) + .map_err(|e| e.to_string())? + { + SshCredential::Password(String::new()) + } else { + match request.auth_type.as_str() { + "password" => SshCredential::Password(request.credential), + "key" => SshCredential::PrivateKey { + key: request.credential, + passphrase: request + .passphrase + .filter(|passphrase| !passphrase.is_empty()), + }, + _ => return Err(format!("Unknown auth type: {}", request.auth_type)), + } }; // Configure PTY diff --git a/src-tauri/src/commands/sftp.rs b/src-tauri/src/commands/sftp.rs index 7897358..cc5420d 100644 --- a/src-tauri/src/commands/sftp.rs +++ b/src-tauri/src/commands/sftp.rs @@ -172,8 +172,9 @@ pub struct SftpExtractRequest { /// Data for an active SFTP session pub struct SftpSessionData { - /// The real SFTP session (only for SSH sessions, None for local) + /// The real SFTP session (only for SSH sessions, None for local/Teleport) pub sftp: Option, + pub teleport: Option, /// The user's home directory on the remote server (resolved on init) pub home_dir: String, /// Current working directory on the remote server @@ -234,12 +235,25 @@ fn ensure_native_path_transfer_supported() -> Result<(), String> { pub struct SftpEntry { pub name: String, pub path: String, + #[serde(alias = "is_directory")] pub is_directory: bool, pub size: u64, + #[serde(alias = "modified_at")] pub modified_at: i64, pub permissions: String, } +async fn teleport_blocking(f: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> anyhow::Result + Send + 'static, +{ + tokio::task::spawn_blocking(f) + .await + .map_err(|e| format!("Teleport task failed: {e}"))? + .map_err(|e| e.to_string()) +} + // ==================== Helper Functions ==================== /// Get an existing SFTP session data Arc from state @@ -638,6 +652,7 @@ pub async fn sftp_init( let initial_path = local_home_dir().to_string_lossy().to_string(); let data = Arc::new(TokioMutex::new(SftpSessionData { sftp: None, + teleport: None, home_dir: initial_path.clone(), current_path: initial_path, connected: true, @@ -666,6 +681,26 @@ pub async fn sftp_init( .await .ok_or_else(|| format!("Session not found: {}", request.session_id))?; + if let Some(target) = session.teleport_target().await { + let home_target = target.clone(); + let home_dir = + teleport_blocking(move || crate::teleport::teleport_home(&home_target)).await?; + let data = Arc::new(TokioMutex::new(SftpSessionData { + sftp: None, + teleport: Some(target), + home_dir: home_dir.clone(), + current_path: home_dir, + connected: true, + })); + let mut sessions = sftp_state.sessions.write().await; + sessions.insert(request.session_id.clone(), data); + info!( + "[SFTP] Teleport session initialized: {}", + request.session_id + ); + return Ok(true); + } + let sftp = session .open_sftp_session() .await @@ -684,6 +719,7 @@ pub async fn sftp_init( let data = Arc::new(TokioMutex::new(SftpSessionData { sftp: Some(sftp), + teleport: None, home_dir: home_dir.clone(), current_path: home_dir, connected: true, @@ -787,6 +823,26 @@ pub async fn sftp_list_dir( // SSH session - use real SFTP protocol let sftp_data = get_sftp_data(sftp_state.inner(), &request.session_id).await?; + { + let guard = sftp_data.lock().await; + if let Some(target) = guard.teleport.clone() { + let path = if request.path.is_empty() { + if guard.current_path.is_empty() { + guard.home_dir.clone() + } else { + guard.current_path.clone() + } + } else { + resolve_remote_path(&request.path, &guard.home_dir, &guard.current_path) + }; + drop(guard); + let list_path = path.clone(); + let entries = + teleport_blocking(move || crate::teleport::list_dir(&target, &list_path)).await?; + sftp_data.lock().await.current_path = path; + return Ok(entries); + } + } let guard = sftp_data.lock().await; let sftp = guard .sftp @@ -933,6 +989,27 @@ pub async fn sftp_download_file( // SSH session - use real SFTP protocol for binary-safe download let sftp_data = get_sftp_data(sftp_state.inner(), &request.session_id).await?; + { + let guard = sftp_data.lock().await; + if let Some(target) = guard.teleport.clone() { + let remote_path = + resolve_remote_path(&request.remote_path, &guard.home_dir, &guard.current_path); + let local_path = request.local_path.clone(); + drop(guard); + teleport_blocking(move || { + crate::teleport::download_file(&target, &remote_path, &local_path) + }) + .await?; + let filename = std::path::Path::new(&request.remote_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + let mut progress = TransferProgress::new(filename, 0); + progress.status = TransferStatus::Completed; + return Ok(progress); + } + } let guard = sftp_data.lock().await; let sftp = guard .sftp @@ -1063,6 +1140,27 @@ pub async fn sftp_upload_file( // SSH session - use real SFTP protocol for binary-safe upload let sftp_data = get_sftp_data(sftp_state.inner(), &request.session_id).await?; + { + let guard = sftp_data.lock().await; + if let Some(target) = guard.teleport.clone() { + let remote_path = + resolve_remote_path(&request.remote_path, &guard.home_dir, &guard.current_path); + let local_path = request.local_path.clone(); + drop(guard); + teleport_blocking(move || { + crate::teleport::upload_file(&target, &local_path, &remote_path) + }) + .await?; + let filename = std::path::Path::new(&request.local_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + let mut progress = TransferProgress::new(filename, 0); + progress.status = TransferStatus::Completed; + return Ok(progress); + } + } let guard = sftp_data.lock().await; let sftp = guard .sftp @@ -1152,6 +1250,31 @@ pub async fn sftp_upload_directory( } let sftp_data = get_sftp_data(sftp_state.inner(), &request.session_id).await?; + { + let guard = sftp_data.lock().await; + if let Some(target) = guard.teleport.clone() { + let remote_path = + resolve_remote_path(&request.remote_path, &guard.home_dir, &guard.current_path); + let local_path = request.local_path.clone(); + drop(guard); + teleport_blocking(move || { + crate::teleport::upload_directory(&target, &local_path, &remote_path) + }) + .await?; + return Ok(DirectoryTransferSummary { + mode: mode.as_str().to_string(), + local_root: request.local_path, + remote_root: request.remote_path, + directories_total: 1, + files_total: 1, + created_directories: 0, + uploaded_files: 1, + skipped_files: 0, + deleted_entries: 0, + transferred_bytes: 0, + }); + } + } let guard = sftp_data.lock().await; let sftp = guard .sftp @@ -1218,6 +1341,14 @@ pub async fn sftp_mkdir( } let sftp_data = get_sftp_data(sftp_state.inner(), &request.session_id).await?; + { + let guard = sftp_data.lock().await; + if let Some(target) = guard.teleport.clone() { + let path = resolve_remote_path(&request.path, &guard.home_dir, &guard.current_path); + drop(guard); + return teleport_blocking(move || crate::teleport::mkdir(&target, &path)).await; + } + } let guard = sftp_data.lock().await; let sftp = guard .sftp @@ -1287,6 +1418,18 @@ pub async fn sftp_delete( } let sftp_data = get_sftp_data(sftp_state.inner(), &request.session_id).await?; + { + let guard = sftp_data.lock().await; + if let Some(target) = guard.teleport.clone() { + let path = resolve_remote_path(&request.path, &guard.home_dir, &guard.current_path); + let recursive = request.recursive.unwrap_or(false); + drop(guard); + return teleport_blocking(move || { + crate::teleport::delete_path(&target, &path, recursive) + }) + .await; + } + } let guard = sftp_data.lock().await; let sftp = guard .sftp @@ -1350,6 +1493,20 @@ pub async fn sftp_rename( } let sftp_data = get_sftp_data(sftp_state.inner(), &request.session_id).await?; + { + let guard = sftp_data.lock().await; + if let Some(target) = guard.teleport.clone() { + let old_path = + resolve_remote_path(&request.old_path, &guard.home_dir, &guard.current_path); + let new_path = + resolve_remote_path(&request.new_path, &guard.home_dir, &guard.current_path); + drop(guard); + return teleport_blocking(move || { + crate::teleport::rename(&target, &old_path, &new_path) + }) + .await; + } + } let guard = sftp_data.lock().await; let sftp = guard .sftp @@ -1459,6 +1616,14 @@ pub async fn sftp_stat( } let sftp_data = get_sftp_data(sftp_state.inner(), &request.session_id).await?; + { + let guard = sftp_data.lock().await; + if let Some(target) = guard.teleport.clone() { + let path = resolve_remote_path(&request.path, &guard.home_dir, &guard.current_path); + drop(guard); + return teleport_blocking(move || crate::teleport::stat_path(&target, &path)).await; + } + } let guard = sftp_data.lock().await; let sftp = guard .sftp @@ -1587,6 +1752,24 @@ pub async fn sftp_read_file( // SSH session - use real SFTP protocol (binary-safe) let sftp_data = get_sftp_data(sftp_state.inner(), &request.session_id).await?; + { + let guard = sftp_data.lock().await; + if let Some(target) = guard.teleport.clone() { + let path = resolve_remote_path(&request.path, &guard.home_dir, &guard.current_path); + let limit = Some(max_size); + drop(guard); + let content = + teleport_blocking(move || crate::teleport::read_file(&target, &path, limit)) + .await?; + return Ok(SftpFileContent { + content, + is_binary: as_binary, + size: 0, + truncated: false, + mime_type: get_mime_type(&request.path), + }); + } + } let guard = sftp_data.lock().await; let sftp = guard .sftp @@ -1678,6 +1861,18 @@ pub async fn sftp_write_file( } let sftp_data = get_sftp_data(sftp_state.inner(), &request.session_id).await?; + { + let guard = sftp_data.lock().await; + if let Some(target) = guard.teleport.clone() { + let path = resolve_remote_path(&request.path, &guard.home_dir, &guard.current_path); + let content = request.content.clone(); + drop(guard); + return teleport_blocking(move || { + crate::teleport::write_file(&target, &path, &content) + }) + .await; + } + } let guard = sftp_data.lock().await; let sftp = guard .sftp diff --git a/src-tauri/src/ipc/socket.rs b/src-tauri/src/ipc/socket.rs index f7febed..b1d35c4 100644 --- a/src-tauri/src/ipc/socket.rs +++ b/src-tauri/src/ipc/socket.rs @@ -32,7 +32,7 @@ use crate::sftp::{ effective_directory_transfer_options, transfer_directory_to_sftp, DirectoryTransferMode, DirectoryTransferSummary, TransferProgress, }; -use crate::storage::{AuthType, Database}; +use crate::storage::{AuthType, ConnectionKind, Database}; const DEFAULT_SOCKET_NAME: &str = "vibeshell.sock"; const SOCKET_NAME_ENV: &str = "VIBESHELL_IPC_NAME"; @@ -51,6 +51,10 @@ pub struct IpcServerInfo { pub group_id: Option, pub jump_host_id: Option, pub tags: Vec, + #[serde(default)] + pub connection_kind: Option, + #[serde(default)] + pub teleport_proxy: Option, } /// Session metadata returned to CLI for `vibeshell sessions`. @@ -72,6 +76,25 @@ fn auth_type_to_string(auth_type: &AuthType) -> &'static str { } } +fn ipc_server_info(server: crate::storage::Server) -> IpcServerInfo { + IpcServerInfo { + id: server.id, + name: server.name, + host: server.host, + port: server.port, + username: server.username, + auth_type: auth_type_to_string(&server.auth_type).to_string(), + group_id: server.group_id, + jump_host_id: server.jump_host_id, + tags: server.tags, + connection_kind: Some(match server.connection_kind { + ConnectionKind::Ssh => "ssh".to_string(), + ConnectionKind::Teleport => "teleport".to_string(), + }), + teleport_proxy: server.teleport_proxy, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IpcEndpointStatus { Reachable, @@ -448,6 +471,20 @@ pub struct IpcServer { struct SftpContext { home_dir: String, current_path: String, + teleport: Option, +} + +fn teleport_blocking(rt: &tokio::runtime::Handle, f: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> anyhow::Result + Send + 'static, +{ + rt.block_on(async { + tokio::task::spawn_blocking(f) + .await + .map_err(|e| format!("Teleport task failed: {e}"))? + .map_err(|e| e.to_string()) + }) } impl IpcServer { @@ -799,17 +836,7 @@ impl IpcServer { IpcMessage::AddServer { spec } => { match crate::commands::server::add_server_spec(&database, spec) { std::result::Result::Ok(server) => IpcMessage::ServerAdded { - server: IpcServerInfo { - id: server.id, - name: server.name, - host: server.host, - port: server.port, - username: server.username, - auth_type: auth_type_to_string(&server.auth_type).to_string(), - group_id: server.group_id, - jump_host_id: server.jump_host_id, - tags: server.tags, - }, + server: ipc_server_info(server), }, Err(message) => IpcMessage::Error { message }, } @@ -865,20 +892,7 @@ impl IpcServer { } IpcMessage::ListServers => match database.server_list(None, None) { std::result::Result::Ok(servers) => { - let servers = servers - .into_iter() - .map(|server| IpcServerInfo { - id: server.id, - name: server.name, - host: server.host, - port: server.port, - username: server.username, - auth_type: auth_type_to_string(&server.auth_type).to_string(), - group_id: server.group_id, - jump_host_id: server.jump_host_id, - tags: server.tags, - }) - .collect(); + let servers = servers.into_iter().map(ipc_server_info).collect(); IpcMessage::ServerList { servers } } @@ -905,6 +919,33 @@ impl IpcServer { IpcMessage::SessionList { sessions } } IpcMessage::CreateSession { server_name } => { + let pty_config = Some(crate::ssh::PtyConfig { + term: "xterm-256color".to_string(), + cols: 80, + rows: 24, + pix_width: 0, + pix_height: 0, + }); + + let teleport = match database.server_get_by_name(&server_name) { + std::result::Result::Ok(Some(server)) => server.is_teleport(), + _ => false, + }; + if teleport { + return match rt.block_on(session_manager.create_with_credentials( + &server_name, + crate::session::SshCredential::Password(String::new()), + pty_config, + )) { + std::result::Result::Ok(session) => IpcMessage::SessionCreated { + session_id: session.id.clone(), + }, + Err(e) => IpcMessage::Error { + message: format!("Failed to connect to '{}': {}", server_name, e), + }, + }; + } + // Look up saved credentials for the server and connect match database.credential_get(&server_name) { std::result::Result::Ok(Some(cred)) => { @@ -1090,6 +1131,16 @@ impl IpcServer { .get(&session_id) .await .ok_or_else(|| format!("Session not found: {}", session_id))?; + if let Some(target) = session.teleport_target().await { + let home_target = target.clone(); + let home_dir = tokio::task::spawn_blocking(move || { + crate::teleport::teleport_home(&home_target) + }) + .await + .map_err(|e| format!("Teleport home lookup failed: {e}"))? + .map_err(|e| e.to_string())?; + return Ok((home_dir, Some(target))); + } let sftp = session .open_sftp_session() .await @@ -1098,15 +1149,18 @@ impl IpcServer { .canonicalize(".") .await .map_err(|e| format!("Failed to resolve home directory: {}", e))?; - Ok::(home_dir) + Ok::<(String, Option), String>(( + home_dir, None, + )) }) { - std::result::Result::Ok(home_dir) => { + std::result::Result::Ok((home_dir, teleport)) => { Self::set_sftp_context( &sftp_contexts, &session_id, SftpContext { home_dir: home_dir.clone(), current_path: home_dir.clone(), + teleport, }, ); info!("[IPC] Initialized SFTP context for {}", session_id); @@ -1126,6 +1180,29 @@ impl IpcServer { }; let resolved = resolve_remote_path(&path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + let list_path = resolved.clone(); + match teleport_blocking(rt, move || { + crate::teleport::list_dir(&target, &list_path) + }) { + std::result::Result::Ok(entries) => { + if !preserve_cwd { + Self::set_sftp_context( + &sftp_contexts, + &session_id, + SftpContext { + home_dir: context.home_dir, + current_path: resolved, + teleport: context.teleport, + }, + ); + } + return IpcMessage::SftpEntries { entries }; + } + Err(message) => return IpcMessage::Error { message }, + } + } + match rt.block_on(async { let session = session_manager .get(&session_id) @@ -1195,6 +1272,7 @@ impl IpcServer { SftpContext { home_dir: context.home_dir, current_path: resolved, + teleport: context.teleport, }, ); IpcMessage::SftpEntries { entries } @@ -1216,6 +1294,15 @@ impl IpcServer { Err(message) => return IpcMessage::Error { message }, }; let resolved = resolve_remote_path(&path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + let stat_path = resolved.clone(); + return match teleport_blocking(rt, move || { + crate::teleport::stat_path(&target, &stat_path) + }) { + std::result::Result::Ok(entry) => IpcMessage::SftpStatResult { entry }, + Err(message) => IpcMessage::Error { message }, + }; + } match rt.block_on(async { let session = session_manager .get(&session_id) @@ -1269,6 +1356,24 @@ impl IpcServer { Err(message) => return IpcMessage::Error { message }, }; let resolved = resolve_remote_path(&path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + let read_path = resolved.clone(); + let limit = max_size; + return match teleport_blocking(rt, move || { + crate::teleport::read_file(&target, &read_path, limit) + }) { + std::result::Result::Ok(content) => IpcMessage::SftpFileContent { + content: SftpFileContent { + content, + is_binary: as_binary.unwrap_or(false), + size: 0, + truncated: false, + mime_type: mime_type(&resolved), + }, + }, + Err(message) => IpcMessage::Error { message }, + }; + } match rt.block_on(async { let session = session_manager .get(&session_id) @@ -1348,6 +1453,15 @@ impl IpcServer { Err(message) => return IpcMessage::Error { message }, }; let resolved = resolve_remote_path(&path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + let write_path = resolved.clone(); + return match teleport_blocking(rt, move || { + crate::teleport::write_file(&target, &write_path, &content) + }) { + std::result::Result::Ok(()) => IpcMessage::Ok, + Err(message) => IpcMessage::Error { message }, + }; + } match rt.block_on(async { let session = session_manager .get(&session_id) @@ -1375,6 +1489,20 @@ impl IpcServer { Err(message) => return IpcMessage::Error { message }, }; let resolved = resolve_remote_path(&path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + let write_path = resolved.clone(); + return match teleport_blocking(rt, move || { + if parents { + if let Some(parent) = Path::new(&write_path).parent() { + crate::teleport::mkdir(&target, &parent.to_string_lossy())?; + } + } + crate::teleport::write_file(&target, &write_path, &content) + }) { + std::result::Result::Ok(()) => IpcMessage::Ok, + Err(message) => IpcMessage::Error { message }, + }; + } match rt.block_on(async { let session = session_manager .get(&session_id) @@ -1411,6 +1539,25 @@ impl IpcServer { }; let resolved = resolve_remote_path(&remote_path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + let source = resolved.clone(); + let dest = local_path.clone(); + return match teleport_blocking(rt, move || { + crate::teleport::download_file(&target, &source, &dest) + }) { + std::result::Result::Ok(()) => { + let filename = Path::new(&resolved) + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or("unknown") + .to_string(); + let mut progress = TransferProgress::new(filename, 0); + progress.status = crate::sftp::TransferStatus::Completed; + IpcMessage::SftpTransfer { progress } + } + Err(message) => IpcMessage::Error { message }, + }; + } match rt.block_on(async { let session = session_manager .get(&session_id) @@ -1463,6 +1610,25 @@ impl IpcServer { }; let resolved = resolve_remote_path(&remote_path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + let dest = resolved.clone(); + let source = local_path.clone(); + return match teleport_blocking(rt, move || { + crate::teleport::upload_file(&target, &source, &dest) + }) { + std::result::Result::Ok(()) => { + let filename = Path::new(&local_path) + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or("unknown") + .to_string(); + let mut progress = TransferProgress::new(filename, 0); + progress.status = crate::sftp::TransferStatus::Completed; + IpcMessage::SftpTransfer { progress } + } + Err(message) => IpcMessage::Error { message }, + }; + } match rt.block_on(async { let session = session_manager .get(&session_id) @@ -1506,6 +1672,29 @@ impl IpcServer { }; let resolved = resolve_remote_path(&remote_path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + let dest = resolved.clone(); + let source = local_path.clone(); + return match teleport_blocking(rt, move || { + crate::teleport::upload_directory(&target, &source, &dest) + }) { + std::result::Result::Ok(()) => IpcMessage::SftpDirectoryTransfer { + summary: DirectoryTransferSummary { + mode: mode.as_str().to_string(), + local_root: local_path, + remote_root: resolved, + directories_total: 1, + files_total: 1, + created_directories: 0, + uploaded_files: 1, + skipped_files: 0, + deleted_entries: 0, + transferred_bytes: 0, + }, + }, + Err(message) => IpcMessage::Error { message }, + }; + } let options = effective_directory_transfer_options( Some(excluded_paths), respect_gitignore, @@ -1542,6 +1731,14 @@ impl IpcServer { Err(message) => return IpcMessage::Error { message }, }; let resolved = resolve_remote_path(&path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + return match teleport_blocking(rt, move || { + crate::teleport::mkdir(&target, &resolved) + }) { + std::result::Result::Ok(()) => IpcMessage::Ok, + Err(message) => IpcMessage::Error { message }, + }; + } match rt.block_on(async { let session = session_manager .get(&session_id) @@ -1567,6 +1764,14 @@ impl IpcServer { Err(message) => return IpcMessage::Error { message }, }; let resolved = resolve_remote_path(&path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + return match teleport_blocking(rt, move || { + crate::teleport::delete_path(&target, &resolved, recursive) + }) { + std::result::Result::Ok(()) => IpcMessage::Ok, + Err(message) => IpcMessage::Error { message }, + }; + } match rt.block_on(async { let session = session_manager .get(&session_id) @@ -1595,6 +1800,14 @@ impl IpcServer { resolve_remote_path(&old_path, &context.home_dir, &context.current_path); let new_resolved = resolve_remote_path(&new_path, &context.home_dir, &context.current_path); + if let Some(target) = context.teleport.clone() { + return match teleport_blocking(rt, move || { + crate::teleport::rename(&target, &old_resolved, &new_resolved) + }) { + std::result::Result::Ok(()) => IpcMessage::Ok, + Err(message) => IpcMessage::Error { message }, + }; + } match rt.block_on(async { let session = session_manager .get(&session_id) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db13509..b229501 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub mod sftp; pub mod ssh; pub mod ssh_import; pub mod storage; +pub mod teleport; pub mod tunnel; use std::sync::Arc; diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index f5d1dc9..a4836e7 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -28,7 +28,7 @@ use crate::sftp::helpers::{ use crate::sftp::{ effective_directory_transfer_options, transfer_directory_to_sftp, DirectoryTransferMode, }; -use crate::storage::models::{AuthType, Server}; +use crate::storage::models::{AuthType, ConnectionKind, Server}; use crate::storage::Database; use super::approval::{AgentApprovalManager, ApprovalOutcome, ApprovalRequest}; @@ -733,6 +733,8 @@ async fn tool_server_add(state: &McpState, args: &Value) -> Result Result { + Ok(self + .database + .server_get_by_name(server_name)? + .map(|server| server.is_teleport()) + .unwrap_or(false)) + } + pub async fn list(&self) -> Vec { let sessions = self.sessions.read().await; let mut infos = Vec::new(); @@ -256,6 +265,10 @@ impl SessionManager { server.username, server.host, server.port ); + if server.is_teleport() { + return self.create_teleport_session(server, pty_config).await; + } + // Create channels for input/output let (input_tx, mut input_rx) = tokio::sync::mpsc::channel::>(256); let (output_tx, _) = tokio::sync::broadcast::channel::>(256); @@ -588,6 +601,110 @@ impl SessionManager { Ok(session) } + async fn create_teleport_session( + &self, + server: Server, + pty_config: Option, + ) -> Result> { + let (input_tx, mut input_rx) = tokio::sync::mpsc::channel::>(256); + let (output_tx, _) = tokio::sync::broadcast::channel::>(256); + let session = Arc::new(Session::new( + server.id.clone(), + server.name.clone(), + input_tx, + output_tx, + )); + + self.attach_teleport(&session, &server, pty_config).await?; + + let session_clone = session.clone(); + let session_id_for_input = session.id.clone(); + tokio::spawn(async move { + while let Some(data) = input_rx.recv().await { + if let Err(e) = session_clone.write_to_ssh(&data).await { + error!( + "[SessionManager] Error writing to Teleport PTY for session {}: {}", + session_id_for_input, e + ); + break; + } + } + }); + + self.send_post_login_command(&session, &server).await; + + let mut sessions = self.sessions.write().await; + sessions.insert(session.id.clone(), session.clone()); + info!( + "[SessionManager] Teleport session {} ready ({})", + session.id, server.name + ); + Ok(session) + } + + async fn attach_teleport( + &self, + session: &Arc, + server: &Server, + pty_config: Option, + ) -> Result<()> { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let _ = (session, server, pty_config); + anyhow::bail!("Teleport sessions are not supported on mobile"); + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let target = TeleportTarget::from_server(server)?; + let cols = pty_config.as_ref().map(|config| config.cols).unwrap_or(80) as u16; + let rows = pty_config.as_ref().map(|config| config.rows).unwrap_or(24) as u16; + let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::>(256); + let spawn_target = target.clone(); + let pty = tokio::task::spawn_blocking(move || { + crate::teleport::spawn_tsh_ssh(&spawn_target, cols, rows, output_tx) + }) + .await + .map_err(|e| anyhow!("Failed to spawn tsh ssh: {e}"))??; + + session.set_teleport_target(target).await; + session.set_teleport_pty(pty).await; + session.set_state(SessionState::Connected).await; + + let session_for_output = session.clone(); + tokio::spawn(async move { + while let Some(data) = output_rx.recv().await { + session_for_output.publish_output(data).await; + } + }); + Ok(()) + } + } + + async fn send_post_login_command(&self, session: &Arc, server: &Server) { + if let Some(ref cmd) = server.post_login_command { + if cmd.trim().is_empty() { + return; + } + let session_for_cmd = session.clone(); + let cmd_str = cmd.clone(); + let sid = session.id.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(800)).await; + let cmd_with_newline = format!("{}\n", cmd_str); + if let Err(e) = session_for_cmd + .write_to_ssh(cmd_with_newline.as_bytes()) + .await + { + warn!( + "[SessionManager] Failed to send post-login command for session {}: {}", + sid, e + ); + } + }); + } + } + /// Connect an existing session with credentials pub async fn connect_session( &self, @@ -625,6 +742,16 @@ impl SessionManager { server.username, server.host, server.port ); + if server.is_teleport() { + self.attach_teleport(&session, &server, pty_config).await?; + self.send_post_login_command(&session, &server).await; + info!( + "[SessionManager] Teleport session {} connected successfully", + session_id + ); + return Ok(()); + } + // Create channel for SSH output let (ssh_output_tx, mut ssh_output_rx) = tokio::sync::mpsc::channel::>(256); diff --git a/src-tauri/src/session/session.rs b/src-tauri/src/session/session.rs index c6f2a93..c187376 100644 --- a/src-tauri/src/session/session.rs +++ b/src-tauri/src/session/session.rs @@ -8,6 +8,7 @@ use uuid::Uuid; use crate::replay::OutputReplayBuffer; use crate::ssh::{ClientHandler, SshClient}; +use crate::teleport::TeleportTarget; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] @@ -55,6 +56,10 @@ pub struct Session { // Last observed activity (attach/input/output/resize/exec). last_activity: Arc>, + + teleport_target: Arc>>, + #[cfg(not(any(target_os = "android", target_os = "ios")))] + teleport_pty: Arc>>, } impl Session { @@ -77,6 +82,9 @@ impl Session { output_forwarder_started: Arc::new(Mutex::new(false)), client_count: Arc::new(RwLock::new(0)), last_activity: Arc::new(RwLock::new(Instant::now())), + teleport_target: Arc::new(Mutex::new(None)), + #[cfg(not(any(target_os = "android", target_os = "ios")))] + teleport_pty: Arc::new(Mutex::new(None)), } } @@ -100,6 +108,23 @@ impl Session { *ssh_guard = Some(client); } + pub async fn set_teleport_target(&self, target: TeleportTarget) { + *self.teleport_target.lock().await = Some(target); + } + + pub async fn teleport_target(&self) -> Option { + self.teleport_target.lock().await.clone() + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + pub async fn set_teleport_pty(&self, pty: crate::teleport::TeleportPty) { + *self.teleport_pty.lock().await = Some(pty); + } + + pub async fn is_teleport(&self) -> bool { + self.teleport_target.lock().await.is_some() + } + /// Get a reference to the output broadcast sender pub fn output_sender(&self) -> broadcast::Sender> { self.output_tx.clone() @@ -186,6 +211,17 @@ impl Session { /// Send data to the SSH shell pub async fn write_to_ssh(&self, data: &[u8]) -> Result<()> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let pty_guard = self.teleport_pty.lock().await; + if let Some(ref pty) = *pty_guard { + pty.write(data)?; + drop(pty_guard); + self.mark_activity().await; + return Ok(()); + } + } + let ssh_guard = self.ssh_client.lock().await; if let Some(ref client) = *ssh_guard { client.send_data(data).await?; @@ -199,6 +235,17 @@ impl Session { /// Resize the PTY pub async fn resize_pty(&self, cols: u32, rows: u32) -> Result<()> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let pty_guard = self.teleport_pty.lock().await; + if let Some(ref pty) = *pty_guard { + pty.resize(cols, rows)?; + drop(pty_guard); + self.mark_activity().await; + return Ok(()); + } + } + let ssh_guard = self.ssh_client.lock().await; if let Some(ref client) = *ssh_guard { client.resize_pty(cols, rows).await?; @@ -212,6 +259,15 @@ impl Session { /// Disconnect the SSH session pub async fn disconnect(&self) -> Result<()> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let mut pty_guard = self.teleport_pty.lock().await; + if let Some(pty) = pty_guard.take() { + pty.kill(); + } + } + *self.teleport_target.lock().await = None; + let mut ssh_guard = self.ssh_client.lock().await; if let Some(ref mut client) = *ssh_guard { client.disconnect().await?; @@ -242,6 +298,18 @@ impl Session { command: &str, stdin: Option<&str>, ) -> Result { + if let Some(target) = self.teleport_target().await { + let command = command.to_string(); + let stdin = stdin.map(ToOwned::to_owned); + let output = tokio::task::spawn_blocking(move || { + crate::teleport::tsh_ssh_exec_with_stdin(&target, &command, stdin.as_deref()) + }) + .await + .map_err(|e| anyhow::anyhow!("Teleport exec join error: {e}"))??; + self.mark_activity().await; + return Ok(output); + } + let client = { let ssh_guard = self.ssh_client.lock().await; ssh_guard @@ -267,6 +335,11 @@ impl Session { /// Open an SFTP subsystem session on a new SSH channel. /// Returns an SftpSession for performing file operations via the SFTP protocol. pub async fn open_sftp_session(&self) -> Result { + if self.is_teleport().await { + return Err(anyhow::anyhow!( + "Teleport sessions use tsh scp/sftp rather than the SSH SFTP subsystem" + )); + } let client = { let ssh_guard = self.ssh_client.lock().await; ssh_guard diff --git a/src-tauri/src/ssh_import/mod.rs b/src-tauri/src/ssh_import/mod.rs index 74f089f..012745e 100644 --- a/src-tauri/src/ssh_import/mod.rs +++ b/src-tauri/src/ssh_import/mod.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; -use crate::storage::{AuthType, Database, Server}; +use crate::storage::{AuthType, ConnectionKind, Database, Server}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ImportSourceKind { @@ -27,6 +27,8 @@ pub enum ImportSourceKind { Putty, #[serde(rename = "tabby")] Tabby, + #[serde(rename = "teleport")] + Teleport, } impl ImportSourceKind { @@ -36,6 +38,7 @@ impl ImportSourceKind { Self::OpenSsh => "OpenSSH", Self::Putty => "PuTTY", Self::Tabby => "Tabby", + Self::Teleport => "Teleport", } } } @@ -71,6 +74,10 @@ pub struct ImportCandidate { pub post_login_command: Option, pub agent_forwarding: bool, pub tags: Vec, + #[serde(default)] + pub connection_kind: ConnectionKind, + #[serde(default)] + pub teleport_proxy: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -141,6 +148,22 @@ pub fn detect_import_sources() -> Vec { path: tabby_path.as_ref().map(|path| display_path(path)), detail: "Tabby config.yaml SSH profiles".to_string(), }, + DetectedImportSource { + kind: ImportSourceKind::Teleport, + label: "Teleport (tsh)".to_string(), + available: crate::teleport::tsh_output(["status"]) + .ok() + .map(|output| { + crate::teleport::logged_in_from_status(&format!( + "{}\n{}", + output.stdout, output.stderr + )) + }) + .unwrap_or(false), + path: None, + detail: "SSH nodes from `tsh status` and `tsh ls`. Run `tsh login --proxy=...` first." + .to_string(), + }, ] } @@ -149,7 +172,7 @@ pub fn preview_import( explicit_path: Option, ) -> Result { if source == ImportSourceKind::Auto && explicit_path.is_some() { - bail!("--path must be used with openssh, putty, or tabby, not auto"); + bail!("--path must be used with openssh, putty, tabby, or teleport, not auto"); } let sources = detect_import_sources(); @@ -168,7 +191,10 @@ pub fn preview_import( } kind => { let path = explicit_path.or_else(|| default_path_for(kind)); - if path.is_none() && kind != ImportSourceKind::Putty { + if path.is_none() + && kind != ImportSourceKind::Putty + && kind != ImportSourceKind::Teleport + { bail!( "Could not determine the default {} configuration path", kind @@ -272,6 +298,8 @@ pub fn import_preview(database: &Database, preview: &ImportPreview) -> Result Result { - server.credential_id = Some(credential_id); - database.server_update(&server)?; + if candidate.connection_kind != ConnectionKind::Teleport { + if let Some(key_path) = candidate.key_path.as_deref() { + match database.credential_save( + &server.name, + "key_with_passphrase", + "", + None, + Some(key_path), + ) { + Ok(credential_id) => { + server.credential_id = Some(credential_id); + database.server_update(&server)?; + } + Err(error) => report.warnings.push(format!( + "Imported '{}' but could not save its private-key path: {}", + server.name, error + )), } - Err(error) => report.warnings.push(format!( - "Imported '{}' but could not save its private-key path: {}", - server.name, error - )), } } @@ -368,6 +398,7 @@ fn append_source( ImportSourceKind::Tabby => { tabby::parse(path.context("Tabby config path is unavailable")?, warnings)? } + ImportSourceKind::Teleport => teleport_candidates(path, warnings)?, }; servers.append(&mut imported); Ok(()) @@ -379,9 +410,50 @@ fn default_path_for(kind: ImportSourceKind) -> Option { ImportSourceKind::OpenSsh => openssh::default_path(), ImportSourceKind::Putty => putty::default_path(), ImportSourceKind::Tabby => tabby::default_path(), + ImportSourceKind::Teleport => None, } } +fn teleport_candidates( + path: Option<&Path>, + warnings: &mut Vec, +) -> Result> { + let proxy = path + .and_then(|value| value.to_str()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let preview = crate::teleport::preview_import(proxy)?; + warnings.extend(preview.warnings); + let username = preview + .login + .as_deref() + .map(|value| value.split('@').next().unwrap_or(value).trim()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(local_username); + + Ok(preview + .nodes + .into_iter() + .map(|node| ImportCandidate { + source: ImportSourceKind::Teleport, + source_name: node.clone(), + name: node.clone(), + host: node, + port: 22, + username: username.clone(), + auth_type: AuthType::Password, + key_path: None, + jump_host: None, + post_login_command: None, + agent_forwarding: false, + tags: vec!["import:teleport".to_string()], + connection_kind: ConnectionKind::Teleport, + teleport_proxy: Some(preview.proxy.clone()), + }) + .collect()) +} + fn remember_aliases( aliases: &mut HashMap<(ImportSourceKind, String), String>, candidate: &ImportCandidate, @@ -401,6 +473,8 @@ fn same_endpoint(server: &Server, candidate: &ImportCandidate) -> bool { server.host.eq_ignore_ascii_case(&candidate.host) && server.port == candidate.port && server.username.eq_ignore_ascii_case(&candidate.username) + && server.connection_kind == candidate.connection_kind + && server.teleport_proxy.as_deref() == candidate.teleport_proxy.as_deref() } fn unique_server_name( @@ -500,6 +574,8 @@ mod tests { jump_host_id: None, post_login_command: None, agent_forwarding: false, + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, }; database.server_add(&mut existing).unwrap(); @@ -520,6 +596,8 @@ mod tests { post_login_command: None, agent_forwarding: false, tags: vec!["import:openssh".to_string()], + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, }, ImportCandidate { source: ImportSourceKind::OpenSsh, @@ -534,6 +612,8 @@ mod tests { post_login_command: None, agent_forwarding: false, tags: vec!["import:openssh".to_string()], + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, }, ], }; diff --git a/src-tauri/src/ssh_import/openssh.rs b/src-tauri/src/ssh_import/openssh.rs index 1633db0..bfac898 100644 --- a/src-tauri/src/ssh_import/openssh.rs +++ b/src-tauri/src/ssh_import/openssh.rs @@ -154,6 +154,8 @@ pub(super) fn parse(path: &Path, warnings: &mut Vec) -> Result) -> Result(14) + .unwrap_or_else(|_| "ssh".to_string()), + ), + teleport_proxy: row.get(15).unwrap_or(None), }) } } @@ -420,6 +432,20 @@ fn string_to_auth_type(s: &str) -> AuthType { } } +fn connection_kind_to_string(kind: &ConnectionKind) -> &'static str { + match kind { + ConnectionKind::Ssh => "ssh", + ConnectionKind::Teleport => "teleport", + } +} + +fn string_to_connection_kind(value: &str) -> ConnectionKind { + match value { + "teleport" => ConnectionKind::Teleport, + _ => ConnectionKind::Ssh, + } +} + /// Group model #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Group { @@ -1169,7 +1195,6 @@ impl Database { // === Plugin Operations === - // ----------------------------------------------------------------------- // Database connections // ----------------------------------------------------------------------- @@ -1410,6 +1435,8 @@ mod tests { jump_host_id: None, post_login_command: None, agent_forwarding: false, + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, }; db.server_add(&mut server).unwrap(); @@ -1481,6 +1508,8 @@ mod tests { jump_host_id: None, post_login_command: None, agent_forwarding: false, + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, }; let mut server_b = Server { name: "history-b".to_string(), @@ -1549,6 +1578,8 @@ mod tests { jump_host_id: None, post_login_command: None, agent_forwarding: false, + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, }; let mut server2 = Server { @@ -1566,6 +1597,8 @@ mod tests { jump_host_id: None, post_login_command: None, agent_forwarding: false, + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, }; db.server_add(&mut server1).unwrap(); diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 5bb92a0..7f3603b 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -18,6 +18,6 @@ pub use sync_crypto::{ // Re-export new model types pub use models::{ - CommandHistoryEntry, CommandSnippet, Recording, TunnelConfig, TunnelInfo, TunnelStatus, - TunnelType, + AuthType, CommandHistoryEntry, CommandSnippet, ConnectionKind, DatabaseConnection, + PluginInstallation, Recording, Server, TunnelConfig, TunnelInfo, TunnelStatus, TunnelType, }; diff --git a/src-tauri/src/storage/models.rs b/src-tauri/src/storage/models.rs index 6bbc4c3..78a0675 100644 --- a/src-tauri/src/storage/models.rs +++ b/src-tauri/src/storage/models.rs @@ -22,6 +22,26 @@ pub struct Server { /// Whether to enable SSH agent forwarding #[serde(default)] pub agent_forwarding: bool, + /// Direct SSH versus Teleport (`tsh`) connections + #[serde(default)] + pub connection_kind: ConnectionKind, + /// Teleport proxy address, e.g. `teleport.example.com:443` + #[serde(default)] + pub teleport_proxy: Option, +} + +impl Server { + pub fn is_teleport(&self) -> bool { + self.connection_kind == ConnectionKind::Teleport + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionKind { + #[default] + Ssh, + Teleport, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src-tauri/src/storage/sync.rs b/src-tauri/src/storage/sync.rs index 8a534f2..5490a8b 100644 --- a/src-tauri/src/storage/sync.rs +++ b/src-tauri/src/storage/sync.rs @@ -10,7 +10,7 @@ use sha2::{Digest, Sha256}; use uuid::Uuid; use super::database::{Database, Group}; -use super::models::{AuthType, CommandSnippet, PluginInstallation, Server}; +use super::models::{AuthType, CommandSnippet, ConnectionKind, PluginInstallation, Server}; pub const SYNC_CHANGE_SCHEMA_VERSION: u32 = 1; @@ -658,6 +658,8 @@ pub(super) fn record_server_upsert( jump_host_id: server.jump_host_id.clone(), post_login_command: server.post_login_command.clone(), agent_forwarding: server.agent_forwarding, + connection_kind: server.connection_kind, + teleport_proxy: server.teleport_proxy.clone(), }; record_local_upsert( conn, @@ -1536,8 +1538,9 @@ fn apply_remote_server(conn: &Connection, id: &str, mut server: ServerSyncPayloa conn.execute( r#"INSERT INTO servers (id, name, host, port, username, auth_type, credential_id, group_id, tags, - created_at, updated_at, jump_host_id, post_login_command, agent_forwarding) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + created_at, updated_at, jump_host_id, post_login_command, agent_forwarding, + connection_kind, teleport_proxy) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) ON CONFLICT(id) DO UPDATE SET name = excluded.name, host = excluded.host, @@ -1550,7 +1553,9 @@ fn apply_remote_server(conn: &Connection, id: &str, mut server: ServerSyncPayloa updated_at = excluded.updated_at, jump_host_id = excluded.jump_host_id, post_login_command = excluded.post_login_command, - agent_forwarding = excluded.agent_forwarding"#, + agent_forwarding = excluded.agent_forwarding, + connection_kind = excluded.connection_kind, + teleport_proxy = excluded.teleport_proxy"#, params![ id, server.name, @@ -1565,6 +1570,8 @@ fn apply_remote_server(conn: &Connection, id: &str, mut server: ServerSyncPayloa server.jump_host_id, server.post_login_command, server.agent_forwarding as i32, + connection_kind_to_string(&server.connection_kind), + server.teleport_proxy, ], )?; Ok(()) @@ -1607,17 +1614,19 @@ fn synced_grant_permissions(installation: &PluginInstallationSyncPayload) -> Res .map_err(|error| anyhow!("Synced external plugin manifest is invalid: {error}"))?; manifest.permissions } - _ => crate::plugins::builtin_catalog() - .map_err(|error| anyhow!("Built-in plugin catalog is invalid: {error}"))? - .into_iter() - .find(|manifest| manifest.id == installation.plugin_id) - .ok_or_else(|| { - anyhow!( - "Synced built-in plugin {} is unknown on this device", - installation.plugin_id - ) - })? - .permissions, + _ => { + crate::plugins::builtin_catalog() + .map_err(|error| anyhow!("Built-in plugin catalog is invalid: {error}"))? + .into_iter() + .find(|manifest| manifest.id == installation.plugin_id) + .ok_or_else(|| { + anyhow!( + "Synced built-in plugin {} is unknown on this device", + installation.plugin_id + ) + })? + .permissions + } }; serde_json::to_string(&permissions).map_err(|error| anyhow!("Failed to encode grants: {error}")) @@ -1683,7 +1692,8 @@ fn reference_is_tombstoned( conn: &Connection, entity_kind: SyncEntityKind, entity_id: Option<&str>, -) -> Result { let Some(entity_id) = entity_id else { +) -> Result { + let Some(entity_id) = entity_id else { return Ok(false); }; Ok(entity_state(conn, entity_kind, entity_id)? @@ -1807,7 +1817,7 @@ fn current_domain_payload( .query_row( r#"SELECT name, host, port, username, auth_type, group_id, tags, created_at, updated_at, jump_host_id, post_login_command, - agent_forwarding + agent_forwarding, connection_kind, teleport_proxy FROM servers WHERE id = ?1"#, [entity_id], |row| { @@ -1826,6 +1836,11 @@ fn current_domain_payload( jump_host_id: row.get(9).unwrap_or(None), post_login_command: row.get(10).unwrap_or(None), agent_forwarding: row.get::<_, i32>(11).unwrap_or(0) != 0, + connection_kind: string_to_connection_kind( + &row.get::<_, String>(12) + .unwrap_or_else(|_| "ssh".to_string()), + ), + teleport_proxy: row.get(13).unwrap_or(None), }) }, ) @@ -1941,7 +1956,8 @@ fn bootstrap_servers(conn: &Connection) -> Result<()> { let servers = { let mut stmt = conn.prepare( r#"SELECT id, name, host, port, username, auth_type, group_id, tags, - created_at, updated_at, jump_host_id, post_login_command, agent_forwarding + created_at, updated_at, jump_host_id, post_login_command, agent_forwarding, + connection_kind, teleport_proxy FROM servers"#, )?; let rows = stmt.query_map([], |row| { @@ -1962,6 +1978,11 @@ fn bootstrap_servers(conn: &Connection) -> Result<()> { jump_host_id: row.get(10).unwrap_or(None), post_login_command: row.get(11).unwrap_or(None), agent_forwarding: row.get::<_, i32>(12).unwrap_or(0) != 0, + connection_kind: string_to_connection_kind( + &row.get::<_, String>(13) + .unwrap_or_else(|_| "ssh".to_string()), + ), + teleport_proxy: row.get(14).unwrap_or(None), }, )) })?; @@ -2069,6 +2090,10 @@ struct ServerSyncPayload { jump_host_id: Option, post_login_command: Option, agent_forwarding: bool, + #[serde(default)] + connection_kind: ConnectionKind, + #[serde(default)] + teleport_proxy: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -2127,6 +2152,20 @@ fn string_to_auth_type(value: &str) -> AuthType { } } +fn connection_kind_to_string(kind: &ConnectionKind) -> &'static str { + match kind { + ConnectionKind::Ssh => "ssh", + ConnectionKind::Teleport => "teleport", + } +} + +fn string_to_connection_kind(value: &str) -> ConnectionKind { + match value { + "teleport" => ConnectionKind::Teleport, + _ => ConnectionKind::Ssh, + } +} + #[cfg(test)] mod tests { use std::collections::HashSet; @@ -2161,6 +2200,8 @@ mod tests { jump_host_id: None, post_login_command: Some("uptime".to_string()), agent_forwarding: false, + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, } } @@ -2358,7 +2399,9 @@ mod tests { ); // Deleting emits a tombstone like every other entity. - database.plugin_installation_delete("docker-containers").unwrap(); + database + .plugin_installation_delete("docker-containers") + .unwrap(); pending(&database) .into_iter() .find(|change| { @@ -3564,8 +3607,7 @@ mod tests { let (_dir, database) = test_database(); // Leave a real scheduling margin: validation samples `now` after this line, // so a +1 ms boundary makes the test race the wall clock on slower CI hosts. - let too_far_future = - Utc::now().timestamp_millis() + MAX_REMOTE_CLOCK_SKEW_MILLIS + 60_000; + let too_far_future = Utc::now().timestamp_millis() + MAX_REMOTE_CLOCK_SKEW_MILLIS + 60_000; let future = remote_upsert( SyncEntityKind::Group, "future-group", diff --git a/src-tauri/src/teleport/mod.rs b/src-tauri/src/teleport/mod.rs new file mode 100644 index 0000000..bf1df09 --- /dev/null +++ b/src-tauri/src/teleport/mod.rs @@ -0,0 +1,589 @@ +//! Teleport (`tsh`) integration. +//! +//! Interactive sessions spawn `tsh ssh` in a local PTY. File and exec +//! operations call `tsh scp` / `tsh ssh -- ` so russh is not used. + +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::sync::Arc; + +use anyhow::{anyhow, bail, Context, Result}; +use log::{info, warn}; +use serde::Deserialize; + +use crate::commands::sftp::SftpEntry; +use crate::storage::Server; + +#[derive(Debug, Clone)] +pub struct TeleportTarget { + pub proxy: String, + pub login: String, + pub node: String, +} + +impl TeleportTarget { + pub fn from_server(server: &Server) -> Result { + let proxy = server + .teleport_proxy + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("Teleport server '{}' is missing a proxy", server.name))?; + Ok(Self { + proxy: proxy.to_string(), + login: server.username.clone(), + node: server.host.clone(), + }) + } + + pub fn ssh_destination(&self) -> String { + if self.login.is_empty() { + self.node.clone() + } else { + format!("{}@{}", self.login, self.node) + } + } +} + +pub fn tsh_binary() -> Result { + Ok("tsh".to_string()) +} + +pub fn ensure_logged_in(proxy: &str) -> Result<()> { + let output = tsh_output(["status", "--proxy", proxy])?; + let combined = format!("{}\n{}", output.stdout, output.stderr); + if output.status.success() && logged_in_from_status(&combined) { + return Ok(()); + } + bail!( + "tsh is not logged in to proxy '{proxy}'. Run: tsh login --proxy={proxy}\n{}", + combined.trim() + ); +} + +pub fn logged_in_from_status(status: &str) -> bool { + let lower = status.to_ascii_lowercase(); + if lower.contains("not logged in") || lower.contains("no active profile") { + return false; + } + status.lines().any(|line| { + let trimmed = line.trim(); + trimmed.to_ascii_lowercase().starts_with("logged in as") + || trimmed.to_ascii_lowercase().contains("logged in as:") + }) +} + +pub fn parse_proxy_from_status(status: &str) -> Option { + for line in status.lines() { + let trimmed = line.trim().trim_start_matches('>').trim(); + let Some((label, value)) = trimmed.split_once(':') else { + continue; + }; + let label = label.trim().to_ascii_lowercase(); + if label == "profile url" || label == "proxy" { + let host = strip_url_scheme(value.trim()); + if !host.is_empty() { + return Some(host.trim_end_matches('/').to_string()); + } + } + } + None +} + +fn strip_url_scheme(value: &str) -> String { + value + .trim() + .trim_start_matches("https://") + .trim_start_matches("http://") + .to_string() +} + +pub struct CommandOutput { + pub status: std::process::ExitStatus, + pub stdout: String, + pub stderr: String, +} + +pub fn tsh_output(args: [&str; N]) -> Result { + tsh_output_slice(&args) +} + +pub fn tsh_output_slice(args: &[&str]) -> Result { + let binary = tsh_binary()?; + let output = Command::new(&binary) + .args(args) + .output() + .with_context(|| format!("Failed to run `{binary} {}`", args.join(" ")))?; + Ok(CommandOutput { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +pub fn tsh_ssh_exec(target: &TeleportTarget, command: &str) -> Result { + tsh_ssh_exec_with_stdin(target, command, None) +} + +pub fn tsh_ssh_exec_with_stdin( + target: &TeleportTarget, + command: &str, + stdin: Option<&str>, +) -> Result { + ensure_logged_in(&target.proxy)?; + let dest = target.ssh_destination(); + let mut child = Command::new(tsh_binary()?) + .args(["--proxy", &target.proxy, "ssh", &dest, "--", command]) + .stdin(if stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to start tsh ssh {dest}"))?; + if let Some(payload) = stdin { + let mut handle = child + .stdin + .take() + .ok_or_else(|| anyhow!("tsh stdin closed"))?; + handle.write_all(payload.as_bytes())?; + } + let output = child.wait_with_output()?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if !output.status.success() { + let detail = stderr.trim(); + bail!( + "tsh ssh failed on {} (proxy {}): {}", + dest, + target.proxy, + if detail.is_empty() { + stdout.trim() + } else { + detail + } + ); + } + Ok(stdout) +} + +pub fn teleport_home(target: &TeleportTarget) -> Result { + let pwd = tsh_ssh_exec(target, "pwd")?; + let home = pwd.lines().last().unwrap_or(pwd.trim()).trim(); + if home.is_empty() { + bail!("Could not determine home directory via tsh ssh"); + } + Ok(home.to_string()) +} + +pub fn list_dir(target: &TeleportTarget, path: &str) -> Result> { + match tsh_ssh_exec(target, &python_stat_command("listdir", path)) { + Ok(stdout) => { + if let Ok(entries) = serde_json::from_str::>(stdout.trim()) { + return Ok(entries); + } + } + Err(error) => warn!("[Teleport] python listdir failed: {error}"), + } + + let listing = tsh_ssh_exec(target, &format!("ls -1A {escaped}"))?; + Ok(listing + .lines() + .map(|name| name.trim()) + .filter(|name| !name.is_empty()) + .map(|name| { + let entry_path = if path.ends_with('/') { + format!("{path}{name}") + } else { + format!("{path}/{name}") + }; + SftpEntry { + name: name.to_string(), + path: entry_path, + is_directory: false, + size: 0, + modified_at: 0, + permissions: "-".to_string(), + } + }) + .collect()) +} + +pub fn stat_path(target: &TeleportTarget, path: &str) -> Result { + let stdout = tsh_ssh_exec(target, &python_stat_command("stat", path))?; + serde_json::from_str(stdout.trim()).context("Failed to parse Teleport stat JSON") +} + +pub fn read_file(target: &TeleportTarget, path: &str, max_size: Option) -> Result { + let escaped = shell_single_quote(path); + let command = match max_size { + Some(limit) => format!("head -c {limit} -- {escaped}"), + None => format!("cat -- {escaped}"), + }; + tsh_ssh_exec(target, &command) +} + +pub fn write_file(target: &TeleportTarget, path: &str, content: &str) -> Result<()> { + tsh_ssh_exec_with_stdin( + target, + &format!("cat > {}", shell_single_quote(path)), + Some(content), + ) + .map(|_| ()) +} + +pub fn mkdir(target: &TeleportTarget, path: &str) -> Result<()> { + tsh_ssh_exec(target, &format!("mkdir -p {}", shell_single_quote(path))).map(|_| ()) +} + +pub fn delete_path(target: &TeleportTarget, path: &str, recursive: bool) -> Result<()> { + let flag = if recursive { "-rf" } else { "-f" }; + tsh_ssh_exec( + target, + &format!("rm {flag} -- {}", shell_single_quote(path)), + ) + .map(|_| ()) +} + +pub fn rename(target: &TeleportTarget, old_path: &str, new_path: &str) -> Result<()> { + tsh_ssh_exec( + target, + &format!( + "mv -- {} {}", + shell_single_quote(old_path), + shell_single_quote(new_path) + ), + ) + .map(|_| ()) +} + +pub fn download_file(target: &TeleportTarget, remote_path: &str, local_path: &str) -> Result<()> { + ensure_logged_in(&target.proxy)?; + let source = format!("{}:{}", target.ssh_destination(), remote_path); + let output = tsh_output_slice(["--proxy", &target.proxy, "scp", &source, local_path])?; + if !output.status.success() { + bail!("tsh scp download failed: {}", output.stderr.trim()); + } + Ok(()) +} + +pub fn upload_file(target: &TeleportTarget, local_path: &str, remote_path: &str) -> Result<()> { + ensure_logged_in(&target.proxy)?; + let dest = format!("{}:{}", target.ssh_destination(), remote_path); + let output = tsh_output_slice(["--proxy", &target.proxy, "scp", local_path, &dest])?; + if !output.status.success() { + bail!("tsh scp upload failed: {}", output.stderr.trim()); + } + Ok(()) +} + +pub fn upload_directory( + target: &TeleportTarget, + local_path: &str, + remote_path: &str, +) -> Result<()> { + ensure_logged_in(&target.proxy)?; + let dest = format!("{}:{}", target.ssh_destination(), remote_path); + let output = tsh_output_slice(["--proxy", &target.proxy, "scp", "-r", local_path, &dest])?; + if !output.status.success() { + bail!("tsh scp directory upload failed: {}", output.stderr.trim()); + } + Ok(()) +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn python_stat_command(mode: &str, path: &str) -> String { + let script = r#"import json,os,stat,sys +path=sys.argv[2] +mode=sys.argv[1] +def entry(p): + st=os.lstat(p) + return {'name':os.path.basename(p) or p,'path':p,'isDirectory':stat.S_ISDIR(st.st_mode),'size':st.st_size,'modifiedAt':int(st.st_mtime),'permissions':oct(st.st_mode & 0o777)} +if mode=='stat': + print(json.dumps(entry(path))) +else: + entries=[] + for name in os.listdir(path): + p=os.path.join(path,name) + try: + entries.append(entry(p)) + except OSError: + continue + print(json.dumps(entries))"#; + format!( + "python3 -c {} {} {}", + shell_single_quote(script), + shell_single_quote(mode), + shell_single_quote(path) + ) +} + +#[derive(Debug, Deserialize)] +struct TeleportNode { + #[serde(default)] + kind: Option, + #[serde(default)] + name: Option, + #[serde(default)] + hostname: Option, + #[serde(default)] + metadata: Option, + #[serde(default)] + spec: Option, +} + +#[derive(Debug, Deserialize)] +struct TeleportMetadata { + #[serde(default)] + name: Option, +} + +#[derive(Debug, Deserialize)] +struct TeleportSpec { + #[serde(default)] + hostname: Option, +} + +impl TeleportNode { + fn is_ssh_node(&self) -> bool { + match self.kind.as_deref() { + None => true, + Some(kind) => kind.eq_ignore_ascii_case("node"), + } + } + + fn hostname(&self) -> Option { + self.hostname + .clone() + .or_else(|| self.spec.as_ref().and_then(|spec| spec.hostname.clone())) + .or_else(|| self.metadata.as_ref().and_then(|meta| meta.name.clone())) + .or_else(|| self.name.clone()) + .filter(|value| !value.is_empty()) + } +} + +pub struct TeleportImportPreview { + pub proxy: String, + pub login: Option, + pub nodes: Vec, + pub warnings: Vec, +} + +pub fn preview_import(explicit_proxy: Option<&str>) -> Result { + let status_args = match explicit_proxy { + Some(proxy) => vec!["status", "--proxy", proxy], + None => vec!["status"], + }; + let status = tsh_output_slice(&status_args)?; + let combined = format!("{}\n{}", status.stdout, status.stderr); + if !logged_in_from_status(&combined) { + bail!( + "tsh is not logged in. Run `tsh login --proxy=` first.\n{}", + combined.trim() + ); + } + let proxy = explicit_proxy + .map(ToOwned::to_owned) + .or_else(|| parse_proxy_from_status(&combined)) + .ok_or_else(|| anyhow!("Could not determine Teleport proxy from `tsh status`"))?; + + let login = combined.lines().find_map(|line| { + let lower = line.to_ascii_lowercase(); + lower + .split_once("logged in as:") + .map(|(_, rest)| rest.trim().to_string()) + .filter(|value| !value.is_empty()) + }); + + let ls = tsh_output_slice(["--proxy", &proxy, "ls", "--format=json"])?; + let mut warnings = Vec::new(); + if !ls.status.success() { + warnings.push(format!("tsh ls failed: {}", ls.stderr.trim())); + } + let nodes = parse_tsh_ls_json(&ls.stdout).unwrap_or_else(|error| { + warnings.push(format!("Could not parse tsh ls JSON: {error}")); + Vec::new() + }); + + Ok(TeleportImportPreview { + proxy, + login, + nodes, + warnings, + }) +} + +pub fn parse_tsh_ls_json(json: &str) -> Result> { + let trimmed = json.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + if let Ok(nodes) = serde_json::from_str::>(trimmed) { + return Ok(nodes + .into_iter() + .filter(TeleportNode::is_ssh_node) + .filter_map(|node| node.hostname()) + .collect()); + } + if let Ok(node) = serde_json::from_str::(trimmed) { + return Ok(node.hostname().into_iter().collect()); + } + bail!("Unrecognized tsh ls JSON"); +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub struct TeleportPty { + pub writer: Arc>>>, + pub master: Arc>>>, + child: Arc>>>, +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn spawn_tsh_ssh( + target: &TeleportTarget, + cols: u16, + rows: u16, + output_tx: tokio::sync::mpsc::Sender>, +) -> Result { + ensure_logged_in(&target.proxy)?; + let binary = tsh_binary()?; + let pty_system = portable_pty::native_pty_system(); + let pair = pty_system.openpty(portable_pty::PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + + let mut cmd = portable_pty::CommandBuilder::new(&binary); + cmd.arg("--proxy"); + cmd.arg(&target.proxy); + cmd.arg("ssh"); + cmd.arg(target.ssh_destination()); + #[cfg(not(target_os = "windows"))] + { + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + cmd.env("TERM_PROGRAM", "VibeShell"); + } + + let child = pair.slave.spawn_command(cmd)?; + info!( + "[Teleport] spawned tsh ssh {} via proxy {}", + target.ssh_destination(), + target.proxy + ); + + let writer = pair.master.take_writer()?; + let mut reader = pair.master.try_clone_reader()?; + let writer = Arc::new(std::sync::Mutex::new(Some(writer))); + let master = Arc::new(std::sync::Mutex::new(Some(pair.master))); + let child = Arc::new(std::sync::Mutex::new(Some(child))); + + let child_for_wait = child.clone(); + std::thread::spawn(move || { + let mut buf = vec![0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if output_tx.blocking_send(buf[..n].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + if let Ok(mut guard) = child_for_wait.lock() { + if let Some(mut child) = guard.take() { + let _ = child.wait(); + } + } + }); + + Ok(TeleportPty { + writer, + master, + child, + }) +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +impl TeleportPty { + pub fn write(&self, data: &[u8]) -> Result<()> { + let mut guard = self + .writer + .lock() + .map_err(|_| anyhow!("Teleport PTY writer lock poisoned"))?; + let writer = guard + .as_mut() + .ok_or_else(|| anyhow!("Teleport PTY is closed"))?; + writer.write_all(data)?; + writer.flush()?; + Ok(()) + } + + pub fn resize(&self, cols: u32, rows: u32) -> Result<()> { + let guard = self + .master + .lock() + .map_err(|_| anyhow!("Teleport PTY master lock poisoned"))?; + let master = guard + .as_ref() + .ok_or_else(|| anyhow!("Teleport PTY is closed"))?; + master.resize(portable_pty::PtySize { + rows: rows as u16, + cols: cols as u16, + pixel_width: 0, + pixel_height: 0, + })?; + Ok(()) + } + + pub fn kill(&self) { + if let Ok(mut guard) = self.child.lock() { + if let Some(mut child) = guard.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + if let Ok(mut writer) = self.writer.lock() { + *writer = None; + } + if let Ok(mut master) = self.master.lock() { + *master = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::{logged_in_from_status, parse_proxy_from_status, parse_tsh_ls_json}; + + #[test] + fn detects_logged_in_status() { + let status = ">\n Profile URL: https://teleport.example.com:443\n Logged in as: alice\n Cluster: example\n"; + assert!(logged_in_from_status(status)); + assert_eq!( + parse_proxy_from_status(status).as_deref(), + Some("teleport.example.com:443") + ); + } + + #[test] + fn detects_logged_out_status() { + assert!(!logged_in_from_status("ERROR: not logged in")); + assert!(!logged_in_from_status("No active profile")); + } + + #[test] + fn parses_node_list_json() { + let json = r#"[{"kind":"node","metadata":{"name":"web-1"},"spec":{"hostname":"web-1"}}]"#; + assert_eq!(parse_tsh_ls_json(json).unwrap(), vec!["web-1".to_string()]); + } +} diff --git a/src-tauri/tests/cloud_sync_webdav_integration_test.rs b/src-tauri/tests/cloud_sync_webdav_integration_test.rs index 16424fd..1d5f8cd 100644 --- a/src-tauri/tests/cloud_sync_webdav_integration_test.rs +++ b/src-tauri/tests/cloud_sync_webdav_integration_test.rs @@ -11,7 +11,7 @@ use axum::{ use base64::{engine::general_purpose::STANDARD, Engine as _}; use vibeshell_core::{ cloud_sync::CloudSyncManager, - storage::{AuthType, Database, Server}, + storage::{AuthType, ConnectionKind, Database, Server}, }; #[derive(Clone, Default)] @@ -113,6 +113,8 @@ async fn two_devices_sync_encrypted_records_through_webdav() { jump_host_id: None, post_login_command: None, agent_forwarding: false, + connection_kind: ConnectionKind::Ssh, + teleport_proxy: None, }; source.server_add(&mut source_server).unwrap(); diff --git a/src/App.tsx b/src/App.tsx index d0fddd2..441828d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,7 +58,7 @@ import { WorkspaceToolbar } from './components/WorkspaceToolbar'; import { FingerprintVerificationDialog, FingerprintManagerDialog } from './components/FingerprintDialog'; import { SnippetManagerDialog } from './components/SnippetManager/SnippetManagerDialog'; import { TunnelPanelDialog } from './components/TunnelPanel/TunnelPanelDialog'; -import { useServerStore, type Server } from './stores/serverStore'; +import { useServerStore, type Server, isTeleportServer } from './stores/serverStore'; import { useRuntimeCapabilitiesStore } from './stores/runtimeCapabilitiesStore'; import { useMediaQuery } from './lib/useMediaQuery'; import { usePluginStore } from './stores/pluginStore'; @@ -546,6 +546,22 @@ function App() { console.log('[App] handleConnect called for server:', server.name); const forceNew = options?.forceNew ?? false; + if (isTeleportServer(server)) { + const session = await connectWithCredentials( + server.name, + 'password', + '', + undefined, + 80, + 24, + forceNew + ); + if (session) { + handleConnected(session.id); + } + return; + } + const credResult = await safeInvoke<{ id: string; server_name: string; diff --git a/src/components/AddServerDialog/AddServerDialog.test.tsx b/src/components/AddServerDialog/AddServerDialog.test.tsx index 60ae379..673365f 100644 --- a/src/components/AddServerDialog/AddServerDialog.test.tsx +++ b/src/components/AddServerDialog/AddServerDialog.test.tsx @@ -115,4 +115,27 @@ describe('AddServerDialog credential storage', () => { }), }); }); + + it('creates a Teleport server without saving SSH credentials', async () => { + render( {}} />); + fireEvent.click(screen.getByRole('button', { name: 'Teleport' })); + fireEvent.change(screen.getByPlaceholderText('My Server'), { + target: { value: 'web-1' }, + }); + fireEvent.change(screen.getByPlaceholderText('node hostname'), { + target: { value: 'web-1' }, + }); + fireEvent.change(screen.getByPlaceholderText('teleport.example.com:443'), { + target: { value: 'teleport.example.com:443' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Add Server' })); + + await waitFor(() => expect(addServer).toHaveBeenCalled()); + expect(addServer).toHaveBeenCalledWith(expect.objectContaining({ + connection_kind: 'teleport', + teleport_proxy: 'teleport.example.com:443', + host: 'web-1', + })); + expect(safeInvokeMock).not.toHaveBeenCalledWith('save_credential', expect.anything()); + }); }); diff --git a/src/components/AddServerDialog/AddServerDialog.tsx b/src/components/AddServerDialog/AddServerDialog.tsx index 1e300e7..a543d21 100644 --- a/src/components/AddServerDialog/AddServerDialog.tsx +++ b/src/components/AddServerDialog/AddServerDialog.tsx @@ -16,6 +16,8 @@ const INITIAL_FORM_DATA = { host: '', port: 22, username: 'root', + connectionKind: 'ssh' as 'ssh' | 'teleport', + teleportProxy: '', // Standalone 'key' auth was removed: key-based servers always use // 'key_with_passphrase' with an optional (possibly empty) passphrase. authType: 'password' as 'password' | 'key_with_passphrase', @@ -102,17 +104,25 @@ export function AddServerDialog({ isOpen, onClose }: AddServerDialogProps) { return; } + const isTeleport = formData.connectionKind === 'teleport'; + if (isTeleport && !formData.teleportProxy.trim()) { + setLocalError('Teleport proxy is required'); + return; + } + // Validate auth-specific fields const isKeyAuth = formData.authType === 'key_with_passphrase'; - if (isKeyAuth && !keyContent) { - setLocalError(isMobile ? 'Please paste an SSH private key' : 'Please select an SSH private key file'); - return; - } + if (!isTeleport) { + if (isKeyAuth && !keyContent) { + setLocalError(isMobile ? 'Please paste an SSH private key' : 'Please select an SSH private key file'); + return; + } - if (!isKeyAuth && !formData.password) { - setLocalError('Password is required'); - return; + if (!isKeyAuth && !formData.password) { + setLocalError('Password is required'); + return; + } } try { @@ -126,14 +136,16 @@ export function AddServerDialog({ isOpen, onClose }: AddServerDialogProps) { credential_id: undefined, group_id: undefined, tags: [], - jump_host_id: formData.jumpHostId || undefined, - agent_forwarding: formData.agentForwarding, + jump_host_id: isTeleport ? undefined : (formData.jumpHostId || undefined), + agent_forwarding: isTeleport ? false : formData.agentForwarding, post_login_command: formData.postLoginCommand.trim() || undefined, + connection_kind: formData.connectionKind, + teleport_proxy: isTeleport ? formData.teleportProxy.trim() : undefined, }); // Credentials entered during server creation are saved automatically on // desktop so the next connection does not ask for them again. - if (!isMobile) { + if (!isMobile && !isTeleport) { const credentialResult = await safeInvoke('save_credential', { request: { serverName: createdServer.name, @@ -187,6 +199,7 @@ export function AddServerDialog({ isOpen, onClose }: AddServerDialogProps) { const displayError = localError || error; const isKeyAuth = formData.authType === 'key_with_passphrase'; + const isTeleport = formData.connectionKind === 'teleport'; return (
@@ -225,6 +238,38 @@ export function AddServerDialog({ isOpen, onClose }: AddServerDialogProps) {

Connection

+
+ +
+ + +
+
+ {/* Name */}
+ {!isTeleport && (
+ )}
+ {isTeleport && ( +
+ + handleChange('teleportProxy', e.target.value)} + placeholder="teleport.example.com:443" + className={cn( + 'w-full px-3 py-2 rounded-md', + 'bg-tokyo-bg border border-tokyo-bg-hl', + 'text-tokyo-fg placeholder-tokyo-comment', + 'focus:outline-none focus:ring-1 focus:ring-tokyo-blue focus:border-tokyo-blue' + )} + /> +

+ Uses `tsh` from PATH. Log in first with `tsh login --proxy=...`. +

+
+ )} + {/* Username */}
{/* Authentication Section */} + {!isTeleport && (

Authentication

@@ -480,11 +551,14 @@ export function AddServerDialog({ isOpen, onClose }: AddServerDialogProps) {

)}
+ )} {/* Advanced Section */}

Advanced

+ {!isTeleport && ( + <> {/* Jump Host */}
+ + )} {/* Post-login Command */}
diff --git a/src/components/EditServerDialog/EditServerDialog.tsx b/src/components/EditServerDialog/EditServerDialog.tsx index d64b8f0..8eafb4d 100644 --- a/src/components/EditServerDialog/EditServerDialog.tsx +++ b/src/components/EditServerDialog/EditServerDialog.tsx @@ -25,6 +25,8 @@ export function EditServerDialog({ isOpen, server, onClose }: EditServerDialogPr host: '', port: 22, username: 'root', + connectionKind: 'ssh' as 'ssh' | 'teleport', + teleportProxy: '', authType: 'password' as AuthType, privateKeyPath: '', jumpHostId: '', @@ -44,6 +46,8 @@ export function EditServerDialog({ isOpen, server, onClose }: EditServerDialogPr host: server.host, port: server.port, username: server.username, + connectionKind: server.connection_kind === 'teleport' ? 'teleport' : 'ssh', + teleportProxy: server.teleport_proxy || '', // Standalone 'key' auth was removed; legacy rows fall back to the // unified key+passphrase mode (empty passphrase = unencrypted key). authType: server.auth_type === 'key' ? 'key_with_passphrase' : server.auth_type, @@ -82,6 +86,10 @@ export function EditServerDialog({ isOpen, server, onClose }: EditServerDialogPr setLocalError('Username is required'); return; } + if (formData.connectionKind === 'teleport' && !formData.teleportProxy.trim()) { + setLocalError('Teleport proxy is required'); + return; + } try { await updateServer(server.id, { @@ -90,9 +98,11 @@ export function EditServerDialog({ isOpen, server, onClose }: EditServerDialogPr port: formData.port, username: formData.username.trim(), auth_type: formData.authType, - jump_host_id: formData.jumpHostId || null, - agent_forwarding: formData.agentForwarding, + jump_host_id: formData.connectionKind === 'teleport' ? null : (formData.jumpHostId || null), + agent_forwarding: formData.connectionKind === 'teleport' ? false : formData.agentForwarding, post_login_command: formData.postLoginCommand.trim() || null, + connection_kind: formData.connectionKind, + teleport_proxy: formData.connectionKind === 'teleport' ? formData.teleportProxy.trim() : null, }); notifySuccess('Server Updated', `${formData.name} has been updated successfully.`); @@ -122,6 +132,7 @@ export function EditServerDialog({ isOpen, server, onClose }: EditServerDialogPr if (!isOpen || !server) return null; const displayError = localError || error; + const isTeleport = formData.connectionKind === 'teleport'; return (
@@ -172,6 +183,25 @@ export function EditServerDialog({ isOpen, server, onClose }: EditServerDialogPr />
+
+ + +
+ {/* Host */}
+ {isTeleport && ( +
+ + handleChange('teleportProxy', e.target.value)} + placeholder="teleport.example.com:443" + className={cn( + 'w-full px-3 py-2 rounded-md', + 'bg-tokyo-bg border border-tokyo-bg-hl', + 'text-tokyo-fg placeholder-tokyo-comment', + 'focus:outline-none focus:ring-1 focus:ring-tokyo-blue focus:border-tokyo-blue' + )} + /> +
+ )} + {/* Port & Username */}
+ {!isTeleport && (
+ )}
+ {!isTeleport && ( + <> {/* Auth Type */}
)} + + )} {/* Advanced Section */}

Advanced

+ {!isTeleport && ( + <> {/* Jump Host */}
@@ -326,6 +384,8 @@ export function EditServerDialog({ isOpen, server, onClose }: EditServerDialogPr SSH Agent Forwarding
+ + )} {/* Post-login Command */}
diff --git a/src/stores/serverStore.ts b/src/stores/serverStore.ts index adc0642..bc44b47 100644 --- a/src/stores/serverStore.ts +++ b/src/stores/serverStore.ts @@ -34,6 +34,10 @@ export function isKeyAuthType(authType: AuthType): boolean { return authType === 'key' || authType === 'key_with_passphrase'; } +export function isTeleportServer(server: Pick): boolean { + return server.connection_kind === 'teleport'; +} + /** * Server configuration for SSH connections * Matches backend Server model @@ -56,6 +60,10 @@ export interface Server { post_login_command?: string | null; /** Whether to enable SSH agent forwarding */ agent_forwarding?: boolean; + /** Direct SSH versus Teleport (`tsh`) */ + connection_kind?: 'ssh' | 'teleport'; + /** Teleport proxy, e.g. teleport.example.com:443 */ + teleport_proxy?: string | null; } /** From b81e609d8342d2637819f9380396e94e9642c12d Mon Sep 17 00:00:00 2001 From: Giray Pultar Date: Tue, 15 Sep 2026 19:31:48 +0300 Subject: [PATCH 3/3] Fix Teleport compile errors blocking the CLI release build Quote remote ls paths and pass tsh argument slices by reference so vibeshell-desktop links. --- src-tauri/src/teleport/mod.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/teleport/mod.rs b/src-tauri/src/teleport/mod.rs index bf1df09..5c86efb 100644 --- a/src-tauri/src/teleport/mod.rs +++ b/src-tauri/src/teleport/mod.rs @@ -188,7 +188,10 @@ pub fn list_dir(target: &TeleportTarget, path: &str) -> Result> { Err(error) => warn!("[Teleport] python listdir failed: {error}"), } - let listing = tsh_ssh_exec(target, &format!("ls -1A {escaped}"))?; + let listing = tsh_ssh_exec( + target, + &format!("ls -1A {}", shell_single_quote(path)), + )?; Ok(listing .lines() .map(|name| name.trim()) @@ -262,7 +265,7 @@ pub fn rename(target: &TeleportTarget, old_path: &str, new_path: &str) -> Result pub fn download_file(target: &TeleportTarget, remote_path: &str, local_path: &str) -> Result<()> { ensure_logged_in(&target.proxy)?; let source = format!("{}:{}", target.ssh_destination(), remote_path); - let output = tsh_output_slice(["--proxy", &target.proxy, "scp", &source, local_path])?; + let output = tsh_output_slice(&["--proxy", &target.proxy, "scp", &source, local_path])?; if !output.status.success() { bail!("tsh scp download failed: {}", output.stderr.trim()); } @@ -272,7 +275,7 @@ pub fn download_file(target: &TeleportTarget, remote_path: &str, local_path: &st pub fn upload_file(target: &TeleportTarget, local_path: &str, remote_path: &str) -> Result<()> { ensure_logged_in(&target.proxy)?; let dest = format!("{}:{}", target.ssh_destination(), remote_path); - let output = tsh_output_slice(["--proxy", &target.proxy, "scp", local_path, &dest])?; + let output = tsh_output_slice(&["--proxy", &target.proxy, "scp", local_path, &dest])?; if !output.status.success() { bail!("tsh scp upload failed: {}", output.stderr.trim()); } @@ -286,7 +289,7 @@ pub fn upload_directory( ) -> Result<()> { ensure_logged_in(&target.proxy)?; let dest = format!("{}:{}", target.ssh_destination(), remote_path); - let output = tsh_output_slice(["--proxy", &target.proxy, "scp", "-r", local_path, &dest])?; + let output = tsh_output_slice(&["--proxy", &target.proxy, "scp", "-r", local_path, &dest])?; if !output.status.success() { bail!("tsh scp directory upload failed: {}", output.stderr.trim()); } @@ -400,7 +403,7 @@ pub fn preview_import(explicit_proxy: Option<&str>) -> Result