A device paired over the LAN only learns the host's Tailscale address once it connects to a host that reports it, so away from home it had nothing to fall back on. When every saved route fails, the client now reads the local `tailscale status --json`, finds the peer named like the host (the name the user gave it, or the one the host gives itself, matched against the peer's machine name and MagicDNS name), and tries its Tailscale addresses. A route found this way is saved like one the host reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 5 changed files with 195 additions and 12 deletions.
| @@ -20,7 +20,7 @@ use std::{ | ||
| 20 | 20 | |
| 21 | 21 | use tungstenite::{Message, WebSocket}; |
| 22 | 22 | |
| 23 | - use super::{ClientMessage, PROTOCOL, Route, RouteKind, ServerMessage}; | |
| 23 | + use super::{ClientMessage, DEFAULT_PORT, PROTOCOL, Route, RouteKind, ServerMessage, tailnet_peers, tailnet_routes}; | |
| 24 | 24 | |
| 25 | 25 | /// How long to wait for a route that goes over the internet or a VPN. |
| 26 | 26 | const CONNECT_TIMEOUT: Duration = Duration::from_secs(6); |
| @@ -85,12 +85,13 @@ impl RemoteClient { | ||
| 85 | 85 | } |
| 86 | 86 | |
| 87 | 87 | /// Connects over `routes`, tried in order, and keeps reconnecting until |
| 88 | - /// dropped. | |
| 89 | - pub fn connect(routes: Vec<Route>, key: String, name: String) -> Self { | |
| 90 | - Self::connect_timed(routes, key, name, TIMING) | |
| 88 | + /// dropped. When none of them answers, it looks for a device called one | |
| 89 | + /// of `names` on this device's tailnet. | |
| 90 | + pub fn connect(routes: Vec<Route>, key: String, name: String, names: Vec<String>) -> Self { | |
| 91 | + Self::connect_timed(routes, key, name, names, TIMING) | |
| 91 | 92 | } |
| 92 | 93 | |
| 93 | - fn connect_timed(routes: Vec<Route>, key: String, name: String, timing: Timing) -> Self { | |
| 94 | + fn connect_timed(routes: Vec<Route>, key: String, name: String, names: Vec<String>, timing: Timing) -> Self { | |
| 94 | 95 | let stop = Arc::new(AtomicBool::new(false)); |
| 95 | 96 | let routes = Arc::new(Mutex::new(routes)); |
| 96 | 97 | let (outbox, inbox) = mpsc::channel::<String>(); |
| @@ -99,11 +100,15 @@ impl RemoteClient { | ||
| 99 | 100 | let stop = stop.clone(); |
| 100 | 101 | let routes = routes.clone(); |
| 101 | 102 | let _ = thread::Builder::new().name("remote-client".into()).spawn(move || { |
| 102 | - let hello = Hello { key, name, routes }; | |
| 103 | + let hello = Hello { key, name, names, routes }; | |
| 103 | 104 | let mut backoff = Duration::from_secs(2); |
| 104 | 105 | while !stop.load(Ordering::Relaxed) { |
| 105 | 106 | let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Connecting)); |
| 106 | - match open_any(&hello.routes(), &hello) { | |
| 107 | + let attempt = match open_any(&hello.routes(), &hello) { | |
| 108 | + Err(Failure::Other(reason)) => discover(&hello).ok_or(Failure::Other(reason)), | |
| 109 | + attempt => attempt, | |
| 110 | + }; | |
| 111 | + match attempt { | |
| 107 | 112 | Ok(link) => { |
| 108 | 113 | backoff = Duration::from_secs(2); |
| 109 | 114 | // Whatever was asked while offline is stale now. |
| @@ -165,6 +170,8 @@ enum Failure { | ||
| 165 | 170 | struct Hello { |
| 166 | 171 | key: String, |
| 167 | 172 | name: String, |
| 173 | + /// What the host is called, to look it up on the tailnet by. | |
| 174 | + names: Vec<String>, | |
| 168 | 175 | routes: Arc<Mutex<Vec<Route>>>, |
| 169 | 176 | } |
| 170 | 177 | |
| @@ -211,6 +218,32 @@ fn open_any(routes: &[Route], hello: &Hello) -> Result<Link, Failure> { | ||
| 211 | 218 | } |
| 212 | 219 | } |
| 213 | 220 | |
| 221 | + /// Looks for the host on this device's tailnet by name when no saved route | |
| 222 | + /// answers, as when it was only ever reached over the LAN and couldn't | |
| 223 | + /// report its Tailscale address yet. A route found this way is kept like | |
| 224 | + /// one the host reported. | |
| 225 | + fn discover(hello: &Hello) -> Option<Link> { | |
| 226 | + if hello.names.is_empty() { | |
| 227 | + return None; | |
| 228 | + } | |
| 229 | + let routes = hello.routes(); | |
| 230 | + let port = routes.first().and_then(|route| route.address.rsplit_once(':')?.1.parse().ok()).unwrap_or(DEFAULT_PORT); | |
| 231 | + let found: Vec<Route> = tailnet_routes(&tailnet_peers(), &hello.names, port) | |
| 232 | + .into_iter() | |
| 233 | + .filter(|route| !routes.iter().any(|known| known.address == route.address)) | |
| 234 | + .collect(); | |
| 235 | + if found.is_empty() { | |
| 236 | + return None; | |
| 237 | + } | |
| 238 | + let mut link = open_any(&found, hello).ok()?; | |
| 239 | + // An older host reports no routes; the workspace saves what it hears. | |
| 240 | + if !link.routes.iter().any(|route| route.address == link.route.address) { | |
| 241 | + link.routes.push(link.route.clone()); | |
| 242 | + } | |
| 243 | + hello.routes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).push(link.route.clone()); | |
| 244 | + Some(link) | |
| 245 | + } | |
| 246 | + | |
| 214 | 247 | /// Connects, says hello, and waits for the welcome. |
| 215 | 248 | fn open(route: &Route, hello: &Hello) -> Result<Link, Failure> { |
| 216 | 249 | let other = |err: &dyn std::fmt::Display| Failure::Other(err.to_string()); |
| @@ -367,7 +400,7 @@ mod tests { | ||
| 367 | 400 | let gone = Route { address: format!("127.0.0.1:{}", port + 100), kind: RouteKind::Lan, learned: true }; |
| 368 | 401 | let tailnet = Route { address: format!("127.0.0.1:{port}"), kind: RouteKind::Tailnet, learned: true }; |
| 369 | 402 | let timing = Timing { ping: Duration::from_millis(200), silence: Duration::from_secs(5), probe: Duration::from_millis(300) }; |
| 370 | - let client = RemoteClient::connect_timed(vec![gone, tailnet.clone()], key.clone(), "test-client".into(), timing); | |
| 403 | + let client = RemoteClient::connect_timed(vec![gone, tailnet.clone()], key.clone(), "test-client".into(), Vec::new(), timing); | |
| 371 | 404 | assert_eq!(connected_route(&client), tailnet.address); |
| 372 | 405 | |
| 373 | 406 | // Back home: a faster route answers, and the client moves to it. |
| @@ -379,7 +412,7 @@ mod tests { | ||
| 379 | 412 | let other_port = port + 200; |
| 380 | 413 | let _other = RemoteServer::start(other_port, new_key(), "someone-else".into()).unwrap(); |
| 381 | 414 | let theirs = Route { address: format!("127.0.0.1:{other_port}"), kind: RouteKind::Lan, learned: true }; |
| 382 | - let client = RemoteClient::connect_timed(vec![theirs, tailnet.clone()], key, "test-client".into(), timing); | |
| 415 | + let client = RemoteClient::connect_timed(vec![theirs, tailnet.clone()], key, "test-client".into(), Vec::new(), timing); | |
| 383 | 416 | assert_eq!(connected_route(&client), tailnet.address); |
| 384 | 417 | } |
| 385 | 418 | } |
| @@ -704,6 +704,105 @@ fn interface_addresses() -> Vec<(String, std::net::IpAddr)> { | ||
| 704 | 704 | Vec::new() |
| 705 | 705 | } |
| 706 | 706 | |
| 707 | + /// Another device on this one's tailnet, as `tailscale status` lists it. | |
| 708 | + #[derive(Debug, PartialEq)] | |
| 709 | + pub struct TailnetPeer { | |
| 710 | + /// Its machine name and its MagicDNS name's first label, which differ | |
| 711 | + /// once it's renamed in Tailscale's admin console. | |
| 712 | + pub names: Vec<String>, | |
| 713 | + pub addresses: Vec<std::net::Ipv4Addr>, | |
| 714 | + /// What Tailscale's coordination server last heard, which lags. | |
| 715 | + pub online: bool, | |
| 716 | + } | |
| 717 | + | |
| 718 | + /// The other devices on this device's tailnet; none when Tailscale isn't | |
| 719 | + /// installed, running or logged in. | |
| 720 | + pub fn tailnet_peers() -> Vec<TailnetPeer> { | |
| 721 | + tailscale_status().map(|json| parse_tailnet_peers(&json)).unwrap_or_default() | |
| 722 | + } | |
| 723 | + | |
| 724 | + /// Routes over Tailscale to the peers called one of `names`, as a host's | |
| 725 | + /// own name or the one the user gave it, online ones first. This finds a | |
| 726 | + /// host that was only ever reached over the LAN, before it could report its | |
| 727 | + /// Tailscale address. | |
| 728 | + pub fn tailnet_routes(peers: &[TailnetPeer], names: &[String], port: u16) -> Vec<Route> { | |
| 729 | + let wanted: Vec<String> = names.iter().map(|name| machine_name(name)).filter(|name| !name.is_empty()).collect(); | |
| 730 | + let mut found: Vec<&TailnetPeer> = | |
| 731 | + peers.iter().filter(|peer| peer.names.iter().any(|name| wanted.contains(&machine_name(name)))).collect(); | |
| 732 | + found.sort_by_key(|peer| !peer.online); | |
| 733 | + found | |
| 734 | + .into_iter() | |
| 735 | + .flat_map(|peer| &peer.addresses) | |
| 736 | + .map(|ip| Route { address: format!("{ip}:{port}"), kind: RouteKind::Tailnet, learned: true }) | |
| 737 | + .collect() | |
| 738 | + } | |
| 739 | + | |
| 740 | + /// A name as Tailscale makes a DNS label of it: `My Phone` is `my-phone`. | |
| 741 | + fn machine_name(name: &str) -> String { | |
| 742 | + name.trim().to_lowercase().chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '-' }).collect() | |
| 743 | + } | |
| 744 | + | |
| 745 | + fn parse_tailnet_peers(json: &str) -> Vec<TailnetPeer> { | |
| 746 | + let Ok(status) = serde_json::from_str::<serde_json::Value>(json) else { return Vec::new() }; | |
| 747 | + let Some(peers) = status.get("Peer").and_then(|peers| peers.as_object()) else { return Vec::new() }; | |
| 748 | + peers | |
| 749 | + .values() | |
| 750 | + .map(|peer| { | |
| 751 | + let text = |key: &str| peer.get(key).and_then(|value| value.as_str()).unwrap_or_default(); | |
| 752 | + let dns_label = text("DNSName").split('.').next().unwrap_or_default(); | |
| 753 | + TailnetPeer { | |
| 754 | + names: [text("HostName"), dns_label].into_iter().filter(|name| !name.is_empty()).map(str::to_string).collect(), | |
| 755 | + addresses: peer | |
| 756 | + .get("TailscaleIPs") | |
| 757 | + .and_then(|ips| ips.as_array()) | |
| 758 | + .into_iter() | |
| 759 | + .flatten() | |
| 760 | + .filter_map(|ip| ip.as_str()?.parse().ok()) | |
| 761 | + .collect(), | |
| 762 | + online: peer.get("Online").and_then(|online| online.as_bool()).unwrap_or(false), | |
| 763 | + } | |
| 764 | + }) | |
| 765 | + .filter(|peer| !peer.names.is_empty() && !peer.addresses.is_empty()) | |
| 766 | + .collect() | |
| 767 | + } | |
| 768 | + | |
| 769 | + /// `tailscale status --json`, or `None` when it's missing, fails, or takes | |
| 770 | + /// longer than a moment. | |
| 771 | + fn tailscale_status() -> Option<String> { | |
| 772 | + use std::{io::Read, process::{Command, Stdio}, time::{Duration, Instant}}; | |
| 773 | + // macOS's app doesn't put its CLI on the PATH. | |
| 774 | + const PROGRAMS: [&str; 3] = ["tailscale", "tailscale.exe", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; | |
| 775 | + for program in PROGRAMS { | |
| 776 | + let Ok(mut child) = Command::new(program) | |
| 777 | + .args(["status", "--json"]) | |
| 778 | + .stdin(Stdio::null()) | |
| 779 | + .stdout(Stdio::piped()) | |
| 780 | + .stderr(Stdio::null()) | |
| 781 | + .spawn() | |
| 782 | + else { | |
| 783 | + continue; | |
| 784 | + }; | |
| 785 | + let mut stdout = child.stdout.take()?; | |
| 786 | + let reader = std::thread::spawn(move || { | |
| 787 | + let mut text = String::new(); | |
| 788 | + stdout.read_to_string(&mut text).ok().map(|_| text) | |
| 789 | + }); | |
| 790 | + let deadline = Instant::now() + Duration::from_millis(1_500); | |
| 791 | + loop { | |
| 792 | + match child.try_wait() { | |
| 793 | + Ok(Some(status)) => return reader.join().ok().flatten().filter(|_| status.success()), | |
| 794 | + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), | |
| 795 | + _ => { | |
| 796 | + let _ = child.kill(); | |
| 797 | + let _ = child.wait(); | |
| 798 | + return None; | |
| 799 | + } | |
| 800 | + } | |
| 801 | + } | |
| 802 | + } | |
| 803 | + None | |
| 804 | + } | |
| 805 | + | |
| 707 | 806 | #[cfg(test)] |
| 708 | 807 | mod tests { |
| 709 | 808 | use super::*; |
| @@ -720,6 +819,34 @@ mod tests { | ||
| 720 | 819 | assert_eq!(with_port("[::1]:9000"), "[::1]:9000"); |
| 721 | 820 | } |
| 722 | 821 | |
| 822 | + #[::core::prelude::v1::test] | |
| 823 | + fn tailnet_lookup() { | |
| 824 | + let status = r#"{ | |
| 825 | + "Self": { "HostName": "laptop", "DNSName": "laptop.example.ts.net.", "TailscaleIPs": ["100.100.0.1"] }, | |
| 826 | + "Peer": { | |
| 827 | + "a": { "HostName": "Desktop", "DNSName": "desktop.example.ts.net.", "TailscaleIPs": ["100.100.0.2", "fd7a:115c:a1e0::2"], "Online": false }, | |
| 828 | + "b": { "HostName": "raspberrypi", "DNSName": "nas.example.ts.net.", "TailscaleIPs": ["100.100.0.3"], "Online": true }, | |
| 829 | + "c": { "HostName": "My Phone", "DNSName": "my-phone.example.ts.net.", "TailscaleIPs": ["100.100.0.4"], "Online": true } | |
| 830 | + } | |
| 831 | + }"#; | |
| 832 | + let peers = parse_tailnet_peers(status); | |
| 833 | + assert_eq!(peers.len(), 3); | |
| 834 | + let lookup = |names: &[&str]| -> Vec<String> { | |
| 835 | + let names: Vec<String> = names.iter().map(|name| name.to_string()).collect(); | |
| 836 | + tailnet_routes(&peers, &names, 47823).into_iter().map(|route| route.describe()).collect() | |
| 837 | + }; | |
| 838 | + // The name the user gave it, in another case; offline as far as the coordination server knows. | |
| 839 | + assert_eq!(lookup(&["DESKTOP"]), ["Tailscale · 100.100.0.2:47823"]); | |
| 840 | + // Renamed in the admin console: its DNS name still finds it. | |
| 841 | + assert_eq!(lookup(&["nas"]), ["Tailscale · 100.100.0.3:47823"]); | |
| 842 | + assert_eq!(lookup(&["My Phone"]), ["Tailscale · 100.100.0.4:47823"]); | |
| 843 | + // Online ones first when several match. | |
| 844 | + assert_eq!(lookup(&["desktop", "raspberrypi"]), ["Tailscale · 100.100.0.3:47823", "Tailscale · 100.100.0.2:47823"]); | |
| 845 | + // This device itself, and names that aren't there, find nothing. | |
| 846 | + assert!(lookup(&["laptop", "", "printer"]).is_empty()); | |
| 847 | + assert!(parse_tailnet_peers("not json").is_empty()); | |
| 848 | + } | |
| 849 | + | |
| 723 | 850 | #[::core::prelude::v1::test] |
| 724 | 851 | fn routes() { |
| 725 | 852 | use std::net::IpAddr; |
| @@ -921,7 +1048,7 @@ mod tests { | ||
| 921 | 1048 | }) |
| 922 | 1049 | } |
| 923 | 1050 | |
| 924 | - let client = RemoteClient::connect(vec![Route::guess(&format!("127.0.0.1:{port}"))], key, "test-client".into()); | |
| 1051 | + let client = RemoteClient::connect(vec![Route::guess(&format!("127.0.0.1:{port}"))], key, "test-client".into(), Vec::new()); | |
| 925 | 1052 | assert!(matches!(wait(&client.events), Some(ClientEvent::Status(ConnectionStatus::Connecting)))); |
| 926 | 1053 | match wait(&client.events) { |
| 927 | 1054 | Some(ClientEvent::Route(route)) => assert_eq!(route.address, format!("127.0.0.1:{port}")), |
| @@ -953,7 +1080,7 @@ mod tests { | ||
| 953 | 1080 | _ => panic!("client didn't get the reply"), |
| 954 | 1081 | } |
| 955 | 1082 | |
| 956 | - let intruder = RemoteClient::connect(vec![Route::guess(&format!("127.0.0.1:{port}"))], "wrong".into(), "intruder".into()); | |
| 1083 | + let intruder = RemoteClient::connect(vec![Route::guess(&format!("127.0.0.1:{port}"))], "wrong".into(), "intruder".into(), Vec::new()); | |
| 957 | 1084 | assert!(matches!(wait(&intruder.events), Some(ClientEvent::Status(ConnectionStatus::Connecting)))); |
| 958 | 1085 | assert!(matches!(wait(&intruder.events), Some(ClientEvent::Status(ConnectionStatus::Denied(_))))); |
| 959 | 1086 | } |
| @@ -335,6 +335,10 @@ impl ProviderInstance { | ||
| 335 | 335 | pub struct RemoteHost { |
| 336 | 336 | pub id: String, |
| 337 | 337 | pub name: String, |
| 338 | + /// The name it gives itself, as it last said, to find it on the tailnet | |
| 339 | + /// by when the user named it something else. | |
| 340 | + #[serde(skip_serializing_if = "String::is_empty")] | |
| 341 | + pub hostname: String, | |
| 338 | 342 | /// `host` or `host:port`. |
| 339 | 343 | pub address: String, |
| 340 | 344 | pub key: String, |
| @@ -353,6 +357,12 @@ impl RemoteHost { | ||
| 353 | 357 | if self.name.trim().is_empty() { self.address.clone() } else { self.name.trim().to_string() } |
| 354 | 358 | } |
| 355 | 359 | |
| 360 | + /// What to look it up on the tailnet by: the name the user gave it and | |
| 361 | + /// the one it gives itself. | |
| 362 | + pub fn lookup_names(&self) -> Vec<String> { | |
| 363 | + [&self.name, &self.hostname].into_iter().filter(|name| !name.trim().is_empty()).cloned().collect() | |
| 364 | + } | |
| 365 | + | |
| 356 | 366 | /// Every way to reach it, fastest first. |
| 357 | 367 | pub fn ordered_routes(&self) -> Vec<crate::remote::Route> { |
| 358 | 368 | crate::remote::ordered_routes(&self.address, &self.routes) |
| @@ -102,6 +102,7 @@ impl SettingsView { | ||
| 102 | 102 | enabled: true, |
| 103 | 103 | settings_offered: false, |
| 104 | 104 | routes: learned, |
| 105 | + ..Default::default() | |
| 105 | 106 | }) |
| 106 | 107 | }); |
| 107 | 108 | } |
| @@ -162,7 +162,7 @@ impl Workspace { | ||
| 162 | 162 | } |
| 163 | 163 | |
| 164 | 164 | fn connect_environment(&mut self, host: RemoteHost, window: &mut Window, cx: &mut Context<Self>) { |
| 165 | - let client = Rc::new(RemoteClient::connect(host.ordered_routes(), host.key.clone(), hostname())); | |
| 165 | + let client = Rc::new(RemoteClient::connect(host.ordered_routes(), host.key.clone(), hostname(), host.lookup_names())); | |
| 166 | 166 | let events = client.events.clone(); |
| 167 | 167 | let id = host.id.clone(); |
| 168 | 168 | let task = cx.spawn_in(window, async move |this, cx| { |
| @@ -760,6 +760,18 @@ impl Workspace { | ||
| 760 | 760 | self.stop_remote_terminals(env, "The connection to the other device dropped; this terminal's shell ended.", cx); |
| 761 | 761 | } |
| 762 | 762 | if let Some(environment) = self.remote.environments.iter_mut().find(|candidate| candidate.host.id == env) { |
| 763 | + if let ConnectionStatus::Connected(name) = &status | |
| 764 | + && environment.host.hostname != *name | |
| 765 | + { | |
| 766 | + // Kept in step with the settings, so the change doesn't reconnect. | |
| 767 | + environment.host.hostname = name.clone(); | |
| 768 | + let name = name.clone(); | |
| 769 | + settings::update(|settings| { | |
| 770 | + if let Some(host) = settings.remotes.iter_mut().find(|host| host.id == env) { | |
| 771 | + host.hostname = name; | |
| 772 | + } | |
| 773 | + }); | |
| 774 | + } | |
| 763 | 775 | environment.status = status; |
| 764 | 776 | } |
| 765 | 777 | if connected { |