Skip to content

Commit

Permalink
Add SSH port forwards to settings (#24474)
Browse files Browse the repository at this point in the history
Closes #6920

Release Notes:

- Added ability to specify port forwarding settings for remote
connections
  • Loading branch information
Tebro authored Feb 14, 2025
1 parent 58b0a6c commit 5f63111
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 6 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/recent_projects/src/remote_servers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
});
}
Expand Down
7 changes: 6 additions & 1 deletion crates/recent_projects/src/ssh_connections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -52,6 +52,7 @@ impl SshSettings {
host,
port,
username,
port_forwards: conn.port_forwards,
password: None,
};
}
Expand Down Expand Up @@ -86,6 +87,9 @@ pub struct SshConnection {
// limited outbound internet access.
#[serde(skip_serializing_if = "Option::is_none")]
pub upload_binary_over_ssh: Option<bool>,

#[serde(skip_serializing_if = "Option::is_none")]
pub port_forwards: Option<Vec<SshPortForwardOption>>,
}

impl From<SshConnection> for SshConnectionOptions {
Expand All @@ -98,6 +102,7 @@ impl From<SshConnection> 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,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/remote/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 94 additions & 5 deletions crates/remote/src/ssh_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -59,13 +61,24 @@ 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<String>,
pub local_port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_host: Option<String>,
pub remote_port: u16,
}

#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
pub struct SshConnectionOptions {
pub host: String,
pub username: Option<String>,
pub port: Option<u16>,
pub password: Option<String>,
pub args: Option<Vec<String>>,
pub port_forwards: Option<Vec<SshPortForwardOption>>,

pub nickname: Option<String>,
pub upload_binary_over_ssh: bool,
Expand All @@ -83,21 +96,57 @@ macro_rules! shell_script {
}};
}

fn parse_port_number(port_str: &str) -> Result<u16> {
port_str
.parse()
.map_err(|e| anyhow!("Invalid port number: {}: {}", port_str, e))
}

fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
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<Self> {
let input = input.trim_start_matches("ssh ");
let mut hostname: Option<String> = None;
let mut username: Option<String> = None;
let mut port: Option<u16> = None;
let mut args = Vec::new();
let mut port_forwards: Vec<SshPortForwardOption> = 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)
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -179,8 +248,28 @@ impl SshConnectionOptions {
result
}

pub fn additional_args(&self) -> Option<&Vec<String>> {
self.args.as_ref()
pub fn additional_args(&self) -> Vec<String> {
let mut args = self.args.iter().flatten().cloned().collect::<Vec<String>>();

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 {
Expand Down Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions docs/src/remote-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down

0 comments on commit 5f63111

Please sign in to comment.