agentmux_srv/backend/remote/
address.rs1use std::collections::HashMap;
19use std::net::IpAddr;
20use std::sync::{Arc, Mutex, OnceLock};
21use std::time::{Duration, Instant};
22
23use super::ConnTarget;
24
25const TTL: Duration = Duration::from_secs(300);
27
28const STEP_TIMEOUT: Duration = Duration::from_secs(3);
30
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
33pub struct RemoteAddress {
34 pub host: Option<String>,
36 pub address: Option<String>,
38}
39
40pub async fn of(name: &str) -> Option<RemoteAddress> {
43 let target = ConnTarget::parse(name).ok()?;
44 if target == ConnTarget::Local {
45 return None;
46 }
47 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 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 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
102fn 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
116async 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
153fn linux_address(out: &str) -> Option<String> {
158 use std::net::Ipv4Addr;
159 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 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 assert_eq!(parse_ssh_g("hostname box\r\nport 22\r\n"), (Some("box".to_string()), Some(22)));
205 }
206
207 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}