Skip to main content

agentmux_srv/backend/remote/
address.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which machine a remote pane is connected to, by name and address, for its
5//! stats badge tooltip (`blockstats.detail`).
6//!
7//! - SSH: `ssh -G` says which host a destination really names (the user's
8//!   ssh config applies: an alias's `HostName`, `Include`, `Match`), and a
9//!   DNS lookup gives its address. Through a `ProxyJump` the address is
10//!   what this computer resolves the name to, which may be none.
11//! - WSL: the distribution's address on the interface its default route
12//!   uses, from `/proc/net/route` and `/proc/net/fib_trie`, which every
13//!   distribution has (BusyBox's `hostname` has no `-I`).
14//!
15//! Answers are kept for [`TTL`], failures too, so a tooltip that asks every
16//! few seconds runs `ssh` or `wsl.exe` at most once in that time.
17
18use std::collections::HashMap;
19use std::net::IpAddr;
20use std::sync::{Arc, Mutex, OnceLock};
21use std::time::{Duration, Instant};
22
23use super::ConnTarget;
24
25/// How long an answer is kept.
26const TTL: Duration = Duration::from_secs(300);
27
28/// How long `ssh -G`, `wsl.exe` or a DNS lookup may take.
29const STEP_TIMEOUT: Duration = Duration::from_secs(3);
30
31/// A remote pane's machine. Either part is `None` when it couldn't be found.
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
33pub struct RemoteAddress {
34    /// SSH: the host name `ssh` connects to. WSL: the distribution.
35    pub host: Option<String>,
36    /// Its IP address.
37    pub address: Option<String>,
38}
39
40/// [`RemoteAddress`] for a remote connection, from the cache when it is
41/// fresh; `None` for a local one.
42pub async fn of(name: &str) -> Option<RemoteAddress> {
43    let target = ConnTarget::parse(name).ok()?;
44    if target == ConnTarget::Local {
45        return None;
46    }
47    // One slot per connection, its lock held across the lookup: a second
48    // tooltip asking for the same connection meanwhile waits for this answer
49    // instead of starting another, and one slow host holds up no other.
50    static SLOTS: OnceLock<Mutex<HashMap<String, Slot>>> = OnceLock::new();
51    let slot = SLOTS.get_or_init(Default::default).lock().unwrap().entry(name.to_string()).or_default().clone();
52    let mut cached = slot.lock().await;
53    if let Some((at, found)) = cached.as_ref() {
54        if at.elapsed() < TTL {
55            return Some(found.clone());
56        }
57    }
58    let found = match &target {
59        ConnTarget::Ssh(dest) => ssh(dest).await,
60        ConnTarget::Wsl(distro) => RemoteAddress {
61            host: Some(distro.clone()),
62            address: wsl_address(distro).await,
63        },
64        ConnTarget::Local => unreachable!(),
65    };
66    *cached = Some((Instant::now(), found.clone()));
67    Some(found)
68}
69
70type Slot = Arc<tokio::sync::Mutex<Option<(Instant, RemoteAddress)>>>;
71
72async fn ssh(dest: &super::conn::SshDest) -> RemoteAddress {
73    let Some(binary) = super::ssh::binary_for_user() else {
74        return RemoteAddress::default();
75    };
76    let mut cmd = tokio::process::Command::new(binary);
77    // Not ours to configure, and it needs nothing of AgentMux's environment.
78    crate::backend::pane_env::sanitize_external_command(&mut cmd);
79    cmd.arg("-G");
80    if let Some(port) = dest.port {
81        cmd.args(["-p", &port.to_string()]);
82    }
83    // `--` so a destination can never be read as an option.
84    // Killed if it outlives the timeout below, rather than left running.
85    cmd.args(["--", &dest.destination]).stdin(std::process::Stdio::null()).kill_on_drop(true);
86    #[cfg(windows)]
87    {
88        use agentmux_common::win32::NoWindow;
89        cmd.no_window();
90    }
91    let (host, port) = match tokio::time::timeout(STEP_TIMEOUT, cmd.output()).await {
92        Ok(Ok(out)) if out.status.success() => parse_ssh_g(&String::from_utf8_lossy(&out.stdout)),
93        _ => (None, None),
94    };
95    let Some(host) = host else {
96        return RemoteAddress::default();
97    };
98    let address = resolve(&host, port.or(dest.port).unwrap_or(22)).await;
99    RemoteAddress { host: Some(host), address }
100}
101
102/// `hostname` and `port` from `ssh -G`'s output.
103fn parse_ssh_g(out: &str) -> (Option<String>, Option<u16>) {
104    let mut host = None;
105    let mut port = None;
106    for line in out.lines() {
107        match line.split_once(' ') {
108            Some(("hostname", v)) if !v.trim().is_empty() => host = Some(v.trim().to_string()),
109            Some(("port", v)) => port = v.trim().parse().ok(),
110            _ => {}
111        }
112    }
113    (host, port)
114}
115
116/// `host`'s address: itself when it is one, else the first a lookup gives,
117/// IPv4 first.
118async fn resolve(host: &str, port: u16) -> Option<String> {
119    if let Ok(ip) = host.trim_matches(['[', ']']).parse::<IpAddr>() {
120        return Some(ip.to_string());
121    }
122    let addrs: Vec<IpAddr> = tokio::time::timeout(STEP_TIMEOUT, tokio::net::lookup_host((host, port)))
123        .await
124        .ok()?
125        .ok()?
126        .map(|a| a.ip())
127        .collect();
128    addrs.iter().find(|ip| ip.is_ipv4()).or(addrs.first()).map(IpAddr::to_string)
129}
130
131async fn wsl_address(distro: &str) -> Option<String> {
132    #[cfg(windows)]
133    {
134        use agentmux_common::win32::NoWindow;
135        let mut cmd = tokio::process::Command::new("wsl.exe");
136        crate::backend::pane_env::sanitize_external_command(&mut cmd);
137        cmd.args(["-d", distro, "--exec", "cat", "/proc/net/route", "/proc/net/fib_trie"])
138            .stdin(std::process::Stdio::null())
139            .kill_on_drop(true)
140            .no_window();
141        match tokio::time::timeout(STEP_TIMEOUT, cmd.output()).await {
142            Ok(Ok(out)) if out.status.success() => linux_address(&String::from_utf8_lossy(&out.stdout)),
143            _ => None,
144        }
145    }
146    #[cfg(not(windows))]
147    {
148        let _ = distro;
149        None
150    }
151}
152
153/// A Linux machine's own IPv4 address, from `/proc/net/route` followed by
154/// `/proc/net/fib_trie`: of its local addresses, the one in a network on the
155/// interface the default route uses, else the first that isn't loopback.
156/// WSL also puts its DNS proxy's address on `lo`, which this skips.
157fn linux_address(out: &str) -> Option<String> {
158    use std::net::Ipv4Addr;
159    // /proc/net/route: tab-separated, addresses in hex, in memory order.
160    let hex = |s: &str| u32::from_str_radix(s, 16).ok().map(|v| Ipv4Addr::from(v.to_le_bytes()));
161    let routes: Vec<(&str, Ipv4Addr, Ipv4Addr)> = out
162        .lines()
163        .filter_map(|l| {
164            let f: Vec<&str> = l.split('\t').map(str::trim).collect();
165            (f.len() >= 8 && f[0] != "Iface").then(|| Some((f[0], hex(f[1])?, hex(f[7])?))).flatten()
166        })
167        .collect();
168    let default_iface = routes.iter().find(|r| r.1.is_unspecified()).map(|r| r.0);
169    let nets: Vec<(u32, u32)> = routes
170        .iter()
171        .filter(|r| Some(r.0) == default_iface && !r.1.is_unspecified())
172        .map(|r| (u32::from(r.1), u32::from(r.2)))
173        .collect();
174    // /proc/net/fib_trie: "|-- <address>" then "/32 host LOCAL" for each of
175    // the machine's own addresses.
176    let lines: Vec<&str> = out.lines().collect();
177    let mut local: Vec<Ipv4Addr> = Vec::new();
178    for w in lines.windows(2) {
179        if let (Some(addr), true) = (w[0].trim().strip_prefix("|-- "), w[1].contains("/32 host LOCAL")) {
180            if let Ok(ip) = addr.trim().parse::<Ipv4Addr>() {
181                if !ip.is_loopback() && !local.contains(&ip) {
182                    local.push(ip);
183                }
184            }
185        }
186    }
187    local
188        .iter()
189        .find(|ip| nets.iter().any(|&(net, mask)| u32::from(**ip) & mask == net))
190        .or(local.first())
191        .map(Ipv4Addr::to_string)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn ssh_g_gives_the_real_host_and_port() {
200        let out = "user me\nhostname build.example.net\nport 2222\nhostkeyalias none\n";
201        assert_eq!(parse_ssh_g(out), (Some("build.example.net".to_string()), Some(2222)));
202        assert_eq!(parse_ssh_g("user me\n"), (None, None));
203        // Windows' ssh ends lines with CRLF.
204        assert_eq!(parse_ssh_g("hostname box\r\nport 22\r\n"), (Some("box".to_string()), Some(22)));
205    }
206
207    /// What a WSL distribution's `cat /proc/net/route /proc/net/fib_trie`
208    /// prints (docker-desktop's, abridged, with documentation addresses).
209    const WSL_NET: &str = concat!(
210        "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n",
211        "eth0\t00000000\t017100CB\t0003\t0\t0\t0\t00000000\t0\t0\t0\n",
212        "eth0\t007100CB\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0\n",
213        "Main:\n",
214        "  +-- 0.0.0.0/0 3 0 5\n",
215        "     |-- 0.0.0.0\n",
216        "        /0 universe UNICAST\n",
217        "     +-- 198.51.100.254/32 host LOCAL\n",
218        "     |-- 198.51.100.254\n",
219        "        /32 host LOCAL\n",
220        "     +-- 127.0.0.0/8 2 0 2\n",
221        "        |-- 127.0.0.1\n",
222        "           /32 host LOCAL\n",
223        "     +-- 203.0.113.0/24 2 0 2\n",
224        "        |-- 203.0.113.121\n",
225        "           /32 host LOCAL\n",
226    );
227
228    #[test]
229    fn a_wsl_distro_is_its_address_on_the_default_route() {
230        assert_eq!(linux_address(WSL_NET), Some("203.0.113.121".to_string()));
231    }
232
233    #[test]
234    fn without_routes_the_first_non_loopback_address() {
235        assert_eq!(linux_address("Main:\n  |-- 127.0.0.1\n     /32 host LOCAL\n  |-- 192.0.2.20\n     /32 host LOCAL\n"), Some("192.0.2.20".to_string()));
236        assert_eq!(linux_address("Main:\n  |-- 127.0.0.1\n     /32 host LOCAL\n"), None);
237        assert_eq!(linux_address(""), None);
238    }
239
240    #[tokio::test]
241    async fn an_address_resolves_to_itself_without_a_lookup() {
242        assert_eq!(resolve("192.0.2.7", 22).await, Some("192.0.2.7".to_string()));
243        assert_eq!(resolve("[2001:db8::1]", 22).await, Some("2001:db8::1".to_string()));
244    }
245
246    #[tokio::test]
247    async fn localhost_resolves() {
248        let ip = resolve("localhost", 22).await.expect("localhost resolves");
249        assert!(ip == "127.0.0.1" || ip == "::1", "{ip}");
250    }
251
252    #[tokio::test]
253    async fn a_local_pane_has_no_remote_address() {
254        assert_eq!(of("").await, None);
255        assert_eq!(of("local").await, None);
256    }
257}