Skip to main content

agentmux_srv/backend/remote/
conn.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! A pane's connection, parsed from its `connection` block meta.
5//!
6//! - `local` (or empty): this machine.
7//! - `wsl://<distro>`: a WSL distribution on this Windows machine.
8//! - anything else: an SSH destination as the user writes it for `ssh`:
9//!   `host`, `user@host`, `user@host:port`, or a `Host` alias from
10//!   `~/.ssh/config`. The system `ssh` resolves it (spec ยง3.1).
11//!
12//! The SSH destination ends up in an `ssh` argv, so it is validated here: one
13//! starting with `-` would be read by `ssh` as an option (`-oProxyCommand=...`
14//! runs a local command), and whitespace or control characters have no place in
15//! a destination. The launch also puts `--` before it; this is the first line.
16
17/// Where a pane's shell runs.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ConnTarget {
20    Local,
21    /// A WSL distribution, by name.
22    Wsl(String),
23    /// An SSH destination, passed to the system `ssh` as written.
24    Ssh(SshDest),
25}
26
27/// A validated SSH destination: `host`, `user@host` or `user@host:port`.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct SshDest {
30    /// The destination as `ssh` takes it, without the port: `user@host` or `host`.
31    pub destination: String,
32    /// From a trailing `:port`, when given; otherwise ssh config decides.
33    pub port: Option<u16>,
34}
35
36impl SshDest {
37    /// The connection name: `destination`, with `:port` when one was given.
38    pub fn name(&self) -> String {
39        match self.port {
40            Some(p) => format!("{}:{p}", self.destination),
41            None => self.destination.clone(),
42        }
43    }
44}
45
46/// Prefix of a WSL connection name.
47pub const WSL_PREFIX: &str = "wsl://";
48
49/// Characters that only mean something to a shell, refused in an SSH
50/// destination (see `parse_ssh`).
51const SHELL_CHARS: &[char] = &[
52    '`', '$', ';', '|', '&', '<', '>', '(', ')', '\'', '"', '\\', '*', '?', '!', '{', '}', '#', '~',
53];
54
55/// Longest connection name accepted: a hostname is at most 253 characters, plus
56/// a user and a port.
57const MAX_CONN_NAME_LEN: usize = 300;
58
59impl ConnTarget {
60    /// Parse a block's `connection` meta. `Err` names what is wrong, in words a
61    /// pane can show.
62    pub fn parse(name: &str) -> Result<ConnTarget, String> {
63        let name = name.trim();
64        if name.is_empty() || name == "local" {
65            return Ok(ConnTarget::Local);
66        }
67        if name.len() > MAX_CONN_NAME_LEN {
68            return Err("connection name is too long".to_string());
69        }
70        if name.chars().any(|c| c.is_whitespace() || c.is_control()) {
71            return Err(
72                "a connection name cannot contain spaces or control characters".to_string(),
73            );
74        }
75        if let Some(distro) = name.strip_prefix(WSL_PREFIX) {
76            return parse_wsl(distro).map(ConnTarget::Wsl);
77        }
78        parse_ssh(name).map(ConnTarget::Ssh)
79    }
80
81    /// The canonical connection name, as stored in block meta and used as the
82    /// status key.
83    pub fn name(&self) -> String {
84        match self {
85            ConnTarget::Local => "local".to_string(),
86            ConnTarget::Wsl(d) => format!("{WSL_PREFIX}{d}"),
87            ConnTarget::Ssh(s) => s.name(),
88        }
89    }
90
91    pub fn is_local(&self) -> bool {
92        matches!(self, ConnTarget::Local)
93    }
94}
95
96/// Whether two connection names mean the same place: `""` and `local` do, and
97/// WSL distro names compare without case, as Windows treats them.
98pub fn same_connection(a: &str, b: &str) -> bool {
99    match (ConnTarget::parse(a), ConnTarget::parse(b)) {
100        (Ok(ConnTarget::Wsl(a)), Ok(ConnTarget::Wsl(b))) => a.eq_ignore_ascii_case(&b),
101        (Ok(a), Ok(b)) => a == b,
102        _ => a == b,
103    }
104}
105
106fn parse_wsl(distro: &str) -> Result<String, String> {
107    // WSL distribution names are letters, digits, '.', '_' and '-' (wsl.exe
108    // --install --name rules); anything else cannot be one.
109    if distro.is_empty() {
110        return Err("a WSL connection needs a distribution name, e.g. wsl://Ubuntu".to_string());
111    }
112    if !distro
113        .chars()
114        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
115    {
116        return Err(format!("'{distro}' is not a WSL distribution name"));
117    }
118    if distro.starts_with('-') {
119        return Err(format!("'{distro}' is not a WSL distribution name"));
120    }
121    Ok(distro.to_string())
122}
123
124fn parse_ssh(name: &str) -> Result<SshDest, String> {
125    if name.starts_with('-') {
126        return Err("a connection name cannot start with '-'".to_string());
127    }
128    // A trailing :port, unless the host part is a bracketed IPv6 literal without
129    // a port, or a bare IPv6 address (several colons).
130    let (destination, port) = split_port(name)?;
131    let host = destination
132        .rsplit_once('@')
133        .map(|(_, h)| h)
134        .unwrap_or(destination);
135    if host.is_empty() {
136        return Err("a connection needs a host".to_string());
137    }
138    if let Some((user, _)) = destination.rsplit_once('@') {
139        if user.is_empty() {
140            return Err("the user before '@' is empty".to_string());
141        }
142    }
143    // Not a DNS-name whitelist: an ssh config `Host` alias may legitimately
144    // contain '/' or '+' ("prod/web"), and OpenSSH resolves it (#4248). What is
145    // refused is shell syntax. The destination is passed as one
146    // argv entry after `--`, so it never meets a local shell directly, but a
147    // user's `ProxyCommand` expands it through `%h` into a shell command, and no
148    // real host name or alias needs these characters.
149    if destination.chars().any(|c| SHELL_CHARS.contains(&c)) {
150        return Err(format!(
151            "'{name}' is not a host name, user@host, or ssh config alias"
152        ));
153    }
154    Ok(SshDest {
155        destination: destination.to_string(),
156        port,
157    })
158}
159
160fn split_port(name: &str) -> Result<(&str, Option<u16>), String> {
161    // [v6] with no port, or [v6]:port.
162    if name.ends_with(']') {
163        return Ok((name, None));
164    }
165    if let Some(idx) = name.rfind("]:") {
166        let port = parse_port(&name[idx + 2..])?;
167        return Ok((&name[..idx + 1], Some(port)));
168    }
169    // Bare IPv6 (two or more colons, no brackets): no port can be given.
170    if name.matches(':').count() >= 2 {
171        return Ok((name, None));
172    }
173    match name.rsplit_once(':') {
174        Some((dest, port)) => Ok((dest, Some(parse_port(port)?))),
175        None => Ok((name, None)),
176    }
177}
178
179fn parse_port(s: &str) -> Result<u16, String> {
180    match s.parse::<u16>() {
181        Ok(p) if p > 0 => Ok(p),
182        _ => Err(format!("'{s}' is not a port number")),
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn ssh(dest: &str, port: Option<u16>) -> ConnTarget {
191        ConnTarget::Ssh(SshDest {
192            destination: dest.to_string(),
193            port,
194        })
195    }
196
197    #[test]
198    fn local_is_the_default() {
199        for n in ["", "local", "  local  "] {
200            assert_eq!(ConnTarget::parse(n), Ok(ConnTarget::Local), "{n:?}");
201        }
202    }
203
204    #[test]
205    fn wsl_names_parse() {
206        assert_eq!(
207            ConnTarget::parse("wsl://Ubuntu"),
208            Ok(ConnTarget::Wsl("Ubuntu".into()))
209        );
210        assert_eq!(
211            ConnTarget::parse("wsl://Ubuntu-24.04"),
212            Ok(ConnTarget::Wsl("Ubuntu-24.04".into()))
213        );
214        assert!(ConnTarget::parse("wsl://").is_err());
215        assert!(ConnTarget::parse("wsl://a/b").is_err());
216        assert!(ConnTarget::parse("wsl://-d").is_err());
217    }
218
219    #[test]
220    fn ssh_destinations_parse_as_written() {
221        assert_eq!(ConnTarget::parse("area54"), Ok(ssh("area54", None)));
222        assert_eq!(
223            ConnTarget::parse("asaf@area54"),
224            Ok(ssh("asaf@area54", None))
225        );
226        assert_eq!(
227            ConnTarget::parse("asaf@area54:2222"),
228            Ok(ssh("asaf@area54", Some(2222)))
229        );
230        assert_eq!(
231            ConnTarget::parse("deploy@10.0.0.5"),
232            Ok(ssh("deploy@10.0.0.5", None))
233        );
234        assert_eq!(
235            ConnTarget::parse("[fe80::1]:22"),
236            Ok(ssh("[fe80::1]", Some(22)))
237        );
238        assert_eq!(ConnTarget::parse("fe80::1"), Ok(ssh("fe80::1", None)));
239        assert_eq!(
240            ConnTarget::parse("user@fe80::1%eth0"),
241            Ok(ssh("user@fe80::1%eth0", None))
242        );
243    }
244
245    /// An ssh config alias need not look like a DNS name (#4248).
246    #[test]
247    fn ssh_config_aliases_with_slashes_or_plus_are_accepted() {
248        assert_eq!(ConnTarget::parse("prod/web"), Ok(ssh("prod/web", None)));
249        assert_eq!(
250            ConnTarget::parse("deploy@db+replica"),
251            Ok(ssh("deploy@db+replica", None))
252        );
253    }
254
255    /// The destination goes into an `ssh` argv: nothing that ssh could read as an
256    /// option, and no shell syntax (a `ProxyCommand` expands it through `%h`).
257    #[test]
258    fn unsafe_or_malformed_names_are_refused() {
259        for bad in [
260            "-oProxyCommand=calc",
261            "-p 22 host",
262            "host;rm -rf ~",
263            "host name",
264            "host\nname",
265            "user@",
266            "@host",
267            "host:0",
268            "host:99999",
269            "host:abc",
270            "$(whoami)@host",
271            "host`id`",
272        ] {
273            assert!(ConnTarget::parse(bad).is_err(), "{bad:?} should be refused");
274        }
275        assert!(ConnTarget::parse(&"a".repeat(MAX_CONN_NAME_LEN + 1)).is_err());
276    }
277
278    #[test]
279    fn same_connection_ignores_local_spelling_and_distro_case() {
280        assert!(same_connection("local", ""));
281        assert!(same_connection("wsl://Ubuntu", " wsl://Ubuntu "));
282        assert!(same_connection("wsl://Ubuntu", "wsl://ubuntu"));
283        assert!(!same_connection("local", "wsl://Ubuntu"));
284        assert!(!same_connection("wsl://Ubuntu", "wsl://Debian"));
285        assert!(
286            !same_connection("Area54", "area54"),
287            "ssh aliases keep their case"
288        );
289    }
290
291    #[test]
292    fn names_round_trip() {
293        for n in [
294            "local",
295            "wsl://Ubuntu",
296            "area54",
297            "asaf@area54:2222",
298            "[fe80::1]:22",
299        ] {
300            assert_eq!(ConnTarget::parse(n).unwrap().name(), n);
301        }
302        assert_eq!(ConnTarget::parse("").unwrap().name(), "local");
303    }
304}