diff --git a/Cargo.lock b/Cargo.lock index 54ab8c81c7d5feff90383af0b1f8e181397c0a16..151a0035cd6a84d92aa7752b15109b2e9534b78f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10886,6 +10886,7 @@ dependencies = [ "prost 0.9.0", "release_channel", "rpc", + "schemars", "serde", "serde_json", "shlex", diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 5b4e3f083a9bad6fb480c8c683d1c6165c73f657..b9d95ce9b3c5188f759304034742308ac969630e 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -878,6 +878,7 @@ impl RemoteServerProjects { nickname: None, args: connection_options.args.unwrap_or_default(), upload_binary_over_ssh: None, + port_forwards: connection_options.port_forwards, }) }); } diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index 983e0aa33a7690babef4548aaf93e36fc6e1b731..249f9db116b60c22dfae99ea39f20840066cfce6 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -15,7 +15,7 @@ use gpui::{ use language::CursorShape; use markdown::{Markdown, MarkdownStyle}; use release_channel::ReleaseChannel; -use remote::ssh_session::ConnectionIdentifier; +use remote::ssh_session::{ConnectionIdentifier, SshPortForwardOption}; use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -52,6 +52,7 @@ impl SshSettings { host, port, username, + port_forwards: conn.port_forwards, password: None, }; } @@ -86,6 +87,9 @@ pub struct SshConnection { // limited outbound internet access. #[serde(skip_serializing_if = "Option::is_none")] pub upload_binary_over_ssh: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub port_forwards: Option>, } impl From for SshConnectionOptions { @@ -98,6 +102,7 @@ impl From for SshConnectionOptions { args: Some(val.args), nickname: val.nickname, upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(), + port_forwards: val.port_forwards, } } } diff --git a/crates/remote/Cargo.toml b/crates/remote/Cargo.toml index 46102fac9a8663f974ee8840b8ff2aae9b6c6107..18b5a98faccaadeb66d7f08ab8f3a71f0f6dbebf 100644 --- a/crates/remote/Cargo.toml +++ b/crates/remote/Cargo.toml @@ -30,6 +30,7 @@ paths.workspace = true parking_lot.workspace = true prost.workspace = true rpc = { workspace = true, features = ["gpui"] } +schemars.workspace = true serde.workspace = true serde_json.workspace = true shlex.workspace = true diff --git a/crates/remote/src/ssh_session.rs b/crates/remote/src/ssh_session.rs index d05c89a4402a9bf41be94bb9f14f9521b46703af..2d83b492a991227df8a3c5c7a2939744441c7770 100644 --- a/crates/remote/src/ssh_session.rs +++ b/crates/remote/src/ssh_session.rs @@ -29,6 +29,8 @@ use rpc::{ AnyProtoClient, EntityMessageSubscriber, ErrorExt, ProtoClient, ProtoMessageHandlerSet, RpcError, }; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use smol::{ fs, process::{self, Child, Stdio}, @@ -59,6 +61,16 @@ pub struct SshSocket { socket_path: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)] +pub struct SshPortForwardOption { + #[serde(skip_serializing_if = "Option::is_none")] + pub local_host: Option, + pub local_port: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_host: Option, + pub remote_port: u16, +} + #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] pub struct SshConnectionOptions { pub host: String, @@ -66,6 +78,7 @@ pub struct SshConnectionOptions { pub port: Option, pub password: Option, pub args: Option>, + pub port_forwards: Option>, pub nickname: Option, pub upload_binary_over_ssh: bool, @@ -83,6 +96,42 @@ macro_rules! shell_script { }}; } +fn parse_port_number(port_str: &str) -> Result { + port_str + .parse() + .map_err(|e| anyhow!("Invalid port number: {}: {}", port_str, e)) +} + +fn parse_port_forward_spec(spec: &str) -> Result { + let parts: Vec<&str> = spec.split(':').collect(); + + match parts.len() { + 4 => { + let local_port = parse_port_number(parts[1])?; + let remote_port = parse_port_number(parts[3])?; + + Ok(SshPortForwardOption { + local_host: Some(parts[0].to_string()), + local_port, + remote_host: Some(parts[2].to_string()), + remote_port, + }) + } + 3 => { + let local_port = parse_port_number(parts[0])?; + let remote_port = parse_port_number(parts[2])?; + + Ok(SshPortForwardOption { + local_host: None, + local_port, + remote_host: Some(parts[1].to_string()), + remote_port, + }) + } + _ => anyhow::bail!("Invalid port forward format"), + } +} + impl SshConnectionOptions { pub fn parse_command_line(input: &str) -> Result { let input = input.trim_start_matches("ssh "); @@ -90,14 +139,14 @@ impl SshConnectionOptions { let mut username: Option = None; let mut port: Option = None; let mut args = Vec::new(); + let mut port_forwards: Vec = Vec::new(); // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W const ALLOWED_OPTS: &[&str] = &[ "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y", ]; const ALLOWED_ARGS: &[&str] = &[ - "-B", "-b", "-c", "-D", "-I", "-i", "-J", "-L", "-l", "-m", "-o", "-P", "-p", "-R", - "-w", + "-B", "-b", "-c", "-D", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R", "-w", ]; let mut tokens = shlex::split(input) @@ -123,6 +172,20 @@ impl SshConnectionOptions { username = Some(l.to_string()); continue; } + if arg == "-L" || arg.starts_with("-L") { + let forward_spec = if arg == "-L" { + tokens.next() + } else { + Some(arg.strip_prefix("-L").unwrap().to_string()) + }; + + if let Some(spec) = forward_spec { + port_forwards.push(parse_port_forward_spec(&spec)?); + } else { + anyhow::bail!("Missing port forward format"); + } + } + for a in ALLOWED_ARGS { if arg == *a { args.push(arg); @@ -154,10 +217,16 @@ impl SshConnectionOptions { anyhow::bail!("missing hostname"); }; + let port_forwards = match port_forwards.len() { + 0 => None, + _ => Some(port_forwards), + }; + Ok(Self { host: hostname.to_string(), username: username.clone(), port, + port_forwards, args: Some(args), password: None, nickname: None, @@ -179,8 +248,28 @@ impl SshConnectionOptions { result } - pub fn additional_args(&self) -> Option<&Vec> { - self.args.as_ref() + pub fn additional_args(&self) -> Vec { + let mut args = self.args.iter().flatten().cloned().collect::>(); + + if let Some(forwards) = &self.port_forwards { + args.extend(forwards.iter().map(|pf| { + let local_host = match &pf.local_host { + Some(host) => host, + None => "localhost", + }; + let remote_host = match &pf.remote_host { + Some(host) => host, + None => "localhost", + }; + + format!( + "-L{}:{}:{}:{}", + local_host, pf.local_port, remote_host, pf.remote_port + ) + })); + } + + args } fn scp_url(&self) -> String { @@ -1454,7 +1543,7 @@ impl SshRemoteConnection { .stderr(Stdio::piped()) .env("SSH_ASKPASS_REQUIRE", "force") .env("SSH_ASKPASS", &askpass_script_path) - .args(connection_options.additional_args().unwrap_or(&Vec::new())) + .args(connection_options.additional_args()) .args([ "-N", "-o", diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md index a206de4e0a5c72073c5f764aab4cbc7b2dc11bb3..b07d59e901ddd807e39d7e118405748941837c83 100644 --- a/docs/src/remote-development.md +++ b/docs/src/remote-development.md @@ -89,6 +89,61 @@ If you use the command line to open a connection to a host by doing `zed ssh://1 Additionally it's worth noting that while you can pass a password on the command line `zed ssh://user:password@host/~`, we do not support writing a password to your settings file. If you're connecting repeatedly to the same host, you should configure key-based authentication. +## Port forwarding + +If you'd like to be able to connect to ports on your remote server from your local machine, you can configure port forwarding in your settings file. This is particularly useful for developing websites so you can load the site in your browser while working. + +```json +{ + "ssh_connections": [ + { + "host": "192.168.1.10", + "port_forwards": [{ "local_port": 8080, "remote_port": 80 }] + } + ] +} +``` + +This will cause requests from your local machine to `localhost:8080` to be forwarded to the remote machine's port 80. Under the hood this uses the `-L` argument to ssh. + +By default these ports are bound to localhost, so other computers in the same network as your development machine cannot access them. You can set the local_host to bind to a different interface, for example, 0.0.0.0 will bind to all local interfaces. + +```json +{ + "ssh_connections": [ + { + "host": "192.168.1.10", + "port_forwards": [ + { + "local_port": 8080, + "remote_port": 80, + "local_host": "0.0.0.0" + } + ] + } + ] +} +``` + +These ports also default to the `localhost` interface on the remote host. If you need to change this, you can also set the remote host: + +```json +{ + "ssh_connections": [ + { + "host": "192.168.1.10", + "port_forwards": [ + { + "local_port": 8080, + "remote_port": 80, + "remote_host": "docker-host" + } + ] + } + ] +} +``` + ## Zed settings When opening a remote project there are three relevant settings locations: