1use std::path::PathBuf;
24use std::sync::Mutex;
25use std::time::Duration;
26
27use serde::{Deserialize, Serialize};
28
29use crate::backend::rpc_types::{TowerPeerInfo, TowerSnapshot};
30use crate::backend::viewer::client::pinned_client;
31
32pub const PEER_PREFIX: &str = "peer:";
34
35const FILE: &str = "tower-peers.json";
36const TIMEOUT: Duration = Duration::from_secs(8);
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Peer {
41 pub id: String,
42 pub hostname: String,
43 pub host: String,
44 pub port: u16,
45 pub fingerprint: String,
46 pub device_id: String,
48 pub paired_ms: i64,
49}
50
51impl Peer {
52 fn info(&self) -> TowerPeerInfo {
53 TowerPeerInfo {
54 connection: format!("{PEER_PREFIX}{}", self.id),
55 hostname: self.hostname.clone(),
56 address: format!("{}:{}", self.host, self.port),
57 paired_ms: self.paired_ms,
58 }
59 }
60
61 fn secret_key(&self) -> String {
62 format!("tower-peer:{}", self.id)
63 }
64
65 fn url(&self, host: &str, path: &str) -> String {
66 url_at(host, self.port, path)
67 }
68}
69
70fn url_at(host: &str, port: u16, path: &str) -> String {
71 let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_string() };
73 format!("https://{host}:{port}{path}")
74}
75
76async fn probe_viewer_ports(host: &str) -> Vec<u16> {
80 use crate::backend::lan_discovery::{UDP_DISCOVERY_PORT, UDP_PROBE_TYPE, UDP_PROTOCOL_VERSION};
81 let Ok(ip) = host.parse::<std::net::IpAddr>() else { return Vec::new() };
82 let bind = if ip.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" };
83 let Ok(sock) = tokio::net::UdpSocket::bind(bind).await else { return Vec::new() };
84 let probe = serde_json::json!({ "type": UDP_PROBE_TYPE, "v": UDP_PROTOCOL_VERSION }).to_string();
85 if sock.send_to(probe.as_bytes(), (ip, UDP_DISCOVERY_PORT)).await.is_err() {
86 return Vec::new();
87 }
88 let mut buf = vec![0u8; 8192];
89 match tokio::time::timeout(Duration::from_millis(1500), sock.recv_from(&mut buf)).await {
90 Ok(Ok((n, _))) => viewer_ports_of(&buf[..n]),
91 _ => Vec::new(),
92 }
93}
94
95fn viewer_ports_of(reply: &[u8]) -> Vec<u16> {
97 let Ok(v) = serde_json::from_slice::<serde_json::Value>(reply) else { return Vec::new() };
98 let port = |x: &serde_json::Value| x.get("viewer_port").and_then(|p| p.as_u64()).and_then(|p| u16::try_from(p).ok());
99 let mut ports: Vec<u16> = port(&v).into_iter().collect();
100 for s in v.get("siblings").and_then(|s| s.as_array()).into_iter().flatten() {
101 if let Some(p) = port(s).filter(|p| !ports.contains(p)) {
102 ports.push(p);
103 }
104 }
105 ports
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Link {
111 pub host: String,
112 pub port: u16,
113 pub fingerprint: String,
114 pub code: String,
115 pub hostname: String,
116}
117
118pub fn parse_link(link: &str) -> Result<Link, String> {
120 let bad = |why: &str| format!("That isn't a pairing link ({why}). Copy it from the other computer's \u{201c}Pair a device\u{201d} panel.");
121 let url = url::Url::parse(link.trim()).map_err(|_| bad("not a link"))?;
122 if url.scheme() != "agentmux" || url.host_str() != Some("pair") {
123 return Err(bad("not an agentmux://pair link"));
124 }
125 let get = |k: &str| url.query_pairs().find(|(key, _)| key == k).map(|(_, v)| v.into_owned());
126 if get("v").as_deref() != Some("1") {
127 return Err(bad("an unknown version"));
128 }
129 let fingerprint = get("fp").filter(|f| f.len() == 64 && f.bytes().all(|b| b.is_ascii_hexdigit())).ok_or_else(|| bad("no fingerprint"))?;
130 let host = get("host").filter(|h| h.parse::<std::net::IpAddr>().is_ok()).ok_or_else(|| bad("no address"))?;
131 Ok(Link {
132 host,
133 port: get("port").and_then(|p| p.parse().ok()).filter(|&p| p > 0).ok_or_else(|| bad("no port"))?,
134 fingerprint: fingerprint.to_ascii_lowercase(),
135 code: get("code").filter(|c| !c.is_empty()).ok_or_else(|| bad("no code"))?,
136 hostname: get("hostname").unwrap_or_default(),
137 })
138}
139
140pub trait Tokens: Send + Sync {
143 fn put(&self, key: &str, token: &str) -> Result<(), String>;
144 fn get(&self, key: &str) -> Result<String, String>;
145 fn delete(&self, key: &str) -> Result<(), String>;
146}
147
148struct SecretStore;
149
150impl Tokens for SecretStore {
151 fn put(&self, key: &str, token: &str) -> Result<(), String> {
152 crate::identity::secret_store::put(key, token)
153 }
154 fn get(&self, key: &str) -> Result<String, String> {
155 crate::identity::secret_store::get(key).map(|t| t.as_str().to_string())
156 }
157 fn delete(&self, key: &str) -> Result<(), String> {
158 crate::identity::secret_store::delete(key)
159 }
160}
161
162pub struct Peers {
164 dir: Option<PathBuf>,
165 lock: Mutex<()>,
166 tokens: Box<dyn Tokens>,
167}
168
169impl Peers {
170 pub fn new(dir: Option<PathBuf>) -> Self {
171 Self::with_tokens(dir, Box::new(SecretStore))
172 }
173
174 pub fn with_tokens(dir: Option<PathBuf>, tokens: Box<dyn Tokens>) -> Self {
175 Self { dir, lock: Mutex::new(()), tokens }
176 }
177
178 pub fn global() -> &'static Peers {
179 static GLOBAL: std::sync::OnceLock<Peers> = std::sync::OnceLock::new();
180 GLOBAL.get_or_init(|| Peers::new(Some(crate::backend::base::get_mux_data_dir())))
181 }
182
183 fn path(&self) -> Option<PathBuf> {
184 self.dir.as_ref().map(|d| d.join(FILE))
185 }
186
187 fn load(&self) -> Vec<Peer> {
188 self.path()
189 .and_then(|p| std::fs::read(p).ok())
190 .and_then(|b| serde_json::from_slice(&b).ok())
191 .unwrap_or_default()
192 }
193
194 fn save(&self, peers: &[Peer]) -> Result<(), String> {
195 let path = self.path().ok_or("no data directory")?;
196 let tmp = path.with_extension("json.tmp");
197 std::fs::write(&tmp, serde_json::to_vec_pretty(peers).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
198 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
199 }
200
201 pub fn list(&self) -> Vec<TowerPeerInfo> {
202 let _g = self.lock.lock().unwrap();
203 self.load().iter().map(Peer::info).collect()
204 }
205
206 pub fn get(&self, id: &str) -> Option<Peer> {
207 let _g = self.lock.lock().unwrap();
208 self.load().into_iter().find(|p| p.id == id)
209 }
210
211 fn put(&self, peer: Peer) -> Result<(), String> {
212 let _g = self.lock.lock().unwrap();
213 let mut all = self.load();
214 all.retain(|p| p.id != peer.id);
215 all.push(peer);
216 self.save(&all)
217 }
218
219 pub fn forget(&self, id: &str) -> Result<(), String> {
222 let Some(peer) = self.get(id) else { return Ok(()) };
223 self.tokens
228 .delete(&peer.secret_key())
229 .map_err(|e| format!("Couldn't remove the pairing with {} from the keychain: {e}", peer.hostname))?;
230 let _g = self.lock.lock().unwrap();
231 let mut all = self.load();
232 all.retain(|p| p.id != id);
233 self.save(&all)
234 }
235
236 fn moved_to(&self, id: &str, host: &str, port: u16) {
240 let _g = self.lock.lock().unwrap();
241 let mut all = self.load();
242 let Some(peer) = all.iter_mut().find(|p| p.id == id) else { return };
243 (peer.host, peer.port) = (host.to_string(), port);
244 let _ = self.save(&all);
245 }
246}
247
248#[derive(Deserialize)]
249struct PairReply {
250 token: String,
251 device_id: String,
252 #[serde(default)]
253 hostname: String,
254}
255
256pub async fn pair(peers: &'static Peers, link: &str, device_name: &str) -> Result<TowerPeerInfo, String> {
258 let link = parse_link(link)?;
259 let client = pinned_client(&link.fingerprint, TIMEOUT)?;
260 let probe = Peer {
261 id: uuid::Uuid::new_v4().to_string(),
262 hostname: link.hostname.clone(),
263 host: link.host.clone(),
264 port: link.port,
265 fingerprint: link.fingerprint.clone(),
266 device_id: String::new(),
267 paired_ms: agentmux_common::time::now_ms(),
268 };
269 let resp = client
270 .post(probe.url(&link.host, "/agentmux/viewer/pair"))
271 .json(&serde_json::json!({ "code": link.code, "device_name": device_name }))
272 .send()
273 .await
274 .map_err(|e| unreachable(&probe, &e))?;
275 let status = resp.status();
276 if status == reqwest::StatusCode::UNAUTHORIZED {
277 return Err("The code was refused: it works once, for two minutes. Show a new one on the other computer.".into());
278 }
279 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
280 return Err("Too many wrong codes: wait a minute and try again.".into());
281 }
282 if !status.is_success() {
283 return Err(format!("The other computer refused to pair ({status})."));
284 }
285 let reply: PairReply = resp.json().await.map_err(|e| format!("The other computer answered oddly: {e}"))?;
286 let peer = Peer {
287 hostname: if reply.hostname.is_empty() { link.hostname } else { reply.hostname },
288 device_id: reply.device_id,
289 ..probe
290 };
291 let info = peer.info();
294 let token = reply.token;
295 tokio::task::spawn_blocking(move || {
296 peers.tokens.put(&peer.secret_key(), &token)?;
297 if let Err(e) = peers.put(peer.clone()) {
298 if let Err(d) = peers.tokens.delete(&peer.secret_key()) {
299 tracing::warn!(error = %d, "tower: could not remove a token after a failed pairing");
300 }
301 return Err(e);
302 }
303 Ok(())
304 })
305 .await
306 .map_err(|e| e.to_string())??;
307 Ok(info)
308}
309
310fn unreachable(peer: &Peer, e: &reqwest::Error) -> String {
311 let why = if e.is_timeout() {
312 "it didn't answer".to_string()
313 } else if format!("{e:?}").contains("fingerprint") {
314 "its certificate isn't the one it was paired with".to_string()
315 } else {
316 "the connection failed".to_string()
317 };
318 format!("Can't reach {} at {}:{} ({why}).", if peer.hostname.is_empty() { "the other computer" } else { &peer.hostname }, peer.host, peer.port)
319}
320
321pub async fn sample(peers: &'static Peers, id: &str, filter: &str, moved: Vec<String>) -> Result<TowerSnapshot, String> {
324 let mut peer = peers.get(id).ok_or("That computer is no longer paired.")?;
325 let key = peer.secret_key();
327 let token = tokio::task::spawn_blocking(move || peers.tokens.get(&key))
328 .await
329 .map_err(|e| e.to_string())?
330 .map_err(|e| format!("Couldn't read the pairing with {}: {e}", peer.hostname))?;
331 let client = pinned_client(&peer.fingerprint, TIMEOUT)?;
332 let mut hosts = vec![peer.host.clone()];
333 hosts.extend(moved.into_iter().filter(|h| *h != peer.host));
334 let mut candidates: Vec<(String, u16)> = hosts.iter().map(|h| (h.clone(), peer.port)).collect();
338 let mut probed = false;
339 let mut next = 0;
340 let mut last_err = String::new();
341 loop {
342 if next == candidates.len() {
343 if probed {
344 break;
345 }
346 probed = true;
347 for h in &hosts {
348 for p in probe_viewer_ports(h).await {
349 if !candidates.contains(&(h.clone(), p)) {
350 candidates.push((h.clone(), p));
351 }
352 }
353 }
354 if next == candidates.len() {
355 break;
356 }
357 }
358 let (host, port) = candidates[next].clone();
359 next += 1;
360 let resp = match client
361 .get(url_at(&host, port, "/agentmux/viewer/procs"))
362 .query(&[("filter", filter)])
363 .bearer_auth(&token)
364 .send()
365 .await
366 {
367 Ok(r) => r,
368 Err(e) => {
369 last_err = unreachable(&Peer { host: host.clone(), port, ..peer.clone() }, &e);
370 continue;
371 }
372 };
373 match resp.status() {
374 s if s.is_success() => {
375 if host != peer.host || port != peer.port {
376 (peer.host, peer.port) = (host.clone(), port);
377 let id = peer.id.clone();
378 let _ = tokio::task::spawn_blocking(move || peers.moved_to(&id, &host, port)).await;
379 }
380 let mut snap: TowerSnapshot = resp.json().await.map_err(|e| format!("{} answered oddly: {e}", peer.hostname))?;
381 snap.remote = true;
382 return Ok(snap);
383 }
384 reqwest::StatusCode::UNAUTHORIZED => {
385 return Err(format!("{} no longer accepts this computer (it was unpaired there). Forget it and pair again.", peer.hostname))
386 }
387 reqwest::StatusCode::FORBIDDEN => {
388 return Err(format!("{} doesn't share its processes. Turn on \u{201c}Share this computer's processes with paired devices\u{201d} in its Tower.", peer.hostname))
389 }
390 s => return Err(format!("{} refused ({s}).", peer.hostname)),
391 }
392 }
393 Err(last_err)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 const FP: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
401
402 #[test]
403 fn a_pairing_link_is_read_and_anything_else_refused() {
404 let link = format!("agentmux://pair?v=1&host=198.51.100.20&port=47900&fp={FP}&code=ABCDEFGHJK&hostname=studio&channel=stable");
405 assert_eq!(
406 parse_link(&link).unwrap(),
407 Link { host: "198.51.100.20".into(), port: 47900, fingerprint: FP.into(), code: "ABCDEFGHJK".into(), hostname: "studio".into() }
408 );
409 assert!(parse_link("https://example.com/pair?v=1").is_err());
410 assert!(parse_link(&link.replace("v=1", "v=9")).is_err(), "unknown version");
411 assert!(parse_link(&link.replace(FP, "abc")).is_err(), "short fingerprint");
412 assert!(parse_link(&link.replace("198.51.100.20", "evil.example")).is_err(), "an address, not a name");
413 assert!(parse_link(&link.replace("&code=ABCDEFGHJK", "")).is_err());
414 }
415
416 #[derive(Default)]
418 struct MemTokens(std::sync::Mutex<std::collections::HashMap<String, String>>);
419
420 impl Tokens for std::sync::Arc<MemTokens> {
421 fn put(&self, key: &str, token: &str) -> Result<(), String> {
422 self.0.lock().unwrap().insert(key.into(), token.into());
423 Ok(())
424 }
425 fn get(&self, key: &str) -> Result<String, String> {
426 self.0.lock().unwrap().get(key).cloned().ok_or_else(|| "no token".into())
427 }
428 fn delete(&self, key: &str) -> Result<(), String> {
429 self.0.lock().unwrap().remove(key);
430 Ok(())
431 }
432 }
433
434 fn peers_in(dir: &std::path::Path) -> (&'static Peers, std::sync::Arc<MemTokens>) {
437 let tokens = std::sync::Arc::new(MemTokens::default());
438 (Box::leak(Box::new(Peers::with_tokens(Some(dir.to_path_buf()), Box::new(tokens.clone())))), tokens)
439 }
440
441 #[test]
442 fn a_discovery_reply_gives_its_viewer_port_then_its_siblings() {
443 let reply = br#"{"type":"agentmux_discover_response","v":1,"viewer_port":29702,
444 "siblings":[{"channel":"dev","viewer_port":29703},{"channel":"x"},{"viewer_port":29702}]}"#;
445 assert_eq!(viewer_ports_of(reply), vec![29702, 29703]);
446 assert!(viewer_ports_of(b"not json").is_empty());
447 assert!(viewer_ports_of(br#"{"type":"agentmux_discover_response"}"#).is_empty(), "no viewer listener up");
448 }
449
450 #[test]
451 fn peers_are_kept_without_their_token_and_forgotten() {
452 let dir = tempfile::tempdir().unwrap();
453 let (peers, _) = peers_in(dir.path());
454 let peer = Peer {
455 id: "p1".into(),
456 hostname: "studio".into(),
457 host: "2001:db8::1".into(),
458 port: 47900,
459 fingerprint: FP.into(),
460 device_id: "d1".into(),
461 paired_ms: 5,
462 };
463 peers.put(peer.clone()).unwrap();
464 let list = peers.list();
465 assert_eq!(list.len(), 1);
466 assert_eq!(list[0].connection, "peer:p1");
467 assert_eq!(peer.url("2001:db8::1", "/x"), "https://[2001:db8::1]:47900/x");
468 let on_disk = std::fs::read_to_string(dir.path().join(FILE)).unwrap();
469 assert!(!on_disk.contains("amxv_"), "no token on disk");
470 peers.forget("p1").unwrap();
471 assert!(peers.list().is_empty());
472 peers.moved_to("p1", "198.51.100.30", 47901);
475 assert!(peers.list().is_empty());
476 }
477
478 #[tokio::test]
480 async fn pairing_with_nothing_listening_fails_closed() {
481 let dir = tempfile::tempdir().unwrap();
482 let (peers, _) = peers_in(dir.path());
483 let link = format!("agentmux://pair?v=1&host=127.0.0.1&port=9&fp={FP}&code=ABCDEFGHJK&hostname=studio");
485 let err = pair(peers, &link, "me").await.unwrap_err();
486 assert!(err.contains("Can't reach studio"), "{err}");
487 assert!(peers.list().is_empty(), "nothing kept");
488 }
489
490 #[tokio::test(flavor = "multi_thread")]
495 async fn a_peer_pairs_over_pinned_tls_and_reads_once_sharing_is_on() {
496 let viewer_dir = tempfile::tempdir().unwrap();
497 let mut state = crate::server::tests::test_state();
498 let broker = crate::backend::mps::Broker::new();
499 state.viewer = crate::backend::viewer::ViewerService::new(Some(viewer_dir.path().to_path_buf()), &broker);
500 let tls = state.viewer.tls().unwrap();
501 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
502 listener.set_nonblocking(true).unwrap();
503 let port = listener.local_addr().unwrap().port();
504 let token = tokio_util::sync::CancellationToken::new();
505 crate::backend::lan_listeners::LanListenerSupervisor::serve_tls(
506 crate::server::build_viewer_router(state.clone()),
507 listener,
508 tokio_rustls::TlsAcceptor::from(tls.server_config().unwrap()),
509 token.clone(),
510 );
511 let link = |fp: &str| {
512 let (code, _) = state.viewer.pairing.start();
513 format!("agentmux://pair?v=1&host=127.0.0.1&port={port}&fp={fp}&code={code}&hostname=studio")
514 };
515
516 let dir = tempfile::tempdir().unwrap();
517 let (peers, tokens) = peers_in(dir.path());
518 let err = pair(peers, &link(FP), "laptop (Tower)").await.unwrap_err();
520 assert!(err.contains("Can't reach studio"), "{err}");
521 assert!(peers.list().is_empty());
522
523 let info = pair(peers, &link(&tls.fingerprint), "laptop (Tower)").await.unwrap();
524 let id = info.connection.strip_prefix(PEER_PREFIX).unwrap().to_string();
525 assert_eq!(tokens.0.lock().unwrap().len(), 1, "the token went to the token store");
526 assert!(!std::fs::read_to_string(dir.path().join(FILE)).unwrap().contains("amxv_"));
527
528 let err = sample(peers, &id, "", vec![]).await.unwrap_err();
529 assert!(err.contains("doesn't share"), "{err}");
530
531 let mut settings = state.config_watcher.get_settings();
532 settings.extra.insert("tower:sharewithpaired".to_string(), serde_json::json!(true));
533 state.config_watcher.update_settings(settings);
534 let snap = sample(peers, &id, "", vec![]).await.unwrap();
535 assert!(snap.remote);
536 assert!(snap.host.unwrap().total > 0);
537
538 let device = peers.get(&id).unwrap().device_id;
540 state.mstore.viewer_device_delete(&device).unwrap();
541 let err = sample(peers, &id, "", vec![]).await.unwrap_err();
542 assert!(err.contains("no longer accepts"), "{err}");
543 token.cancel();
544 }
545}