Vectrum
Sign In

Find a host on the tailnet by name when no saved route answers

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>
JjohannSocommitted 1 parent eb8e51ecommitbd589f3

Showing 5 changed files with 195 additions and 12 deletions.

+42−9src/remote/client.rsView File
@@ -20,7 +20,7 @@ use std::{
2020
2121 use tungstenite::{Message, WebSocket};
2222
23- use super::{ClientMessage, PROTOCOL, Route, RouteKind, ServerMessage};
23+ use super::{ClientMessage, DEFAULT_PORT, PROTOCOL, Route, RouteKind, ServerMessage, tailnet_peers, tailnet_routes};
2424
2525 /// How long to wait for a route that goes over the internet or a VPN.
2626 const CONNECT_TIMEOUT: Duration = Duration::from_secs(6);
@@ -85,12 +85,13 @@ impl RemoteClient {
8585 }
8686
8787 /// 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)
9192 }
9293
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 {
9495 let stop = Arc::new(AtomicBool::new(false));
9596 let routes = Arc::new(Mutex::new(routes));
9697 let (outbox, inbox) = mpsc::channel::<String>();
@@ -99,11 +100,15 @@ impl RemoteClient {
99100 let stop = stop.clone();
100101 let routes = routes.clone();
101102 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 };
103104 let mut backoff = Duration::from_secs(2);
104105 while !stop.load(Ordering::Relaxed) {
105106 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 {
107112 Ok(link) => {
108113 backoff = Duration::from_secs(2);
109114 // Whatever was asked while offline is stale now.
@@ -165,6 +170,8 @@ enum Failure {
165170 struct Hello {
166171 key: String,
167172 name: String,
173+ /// What the host is called, to look it up on the tailnet by.
174+ names: Vec<String>,
168175 routes: Arc<Mutex<Vec<Route>>>,
169176 }
170177
@@ -211,6 +218,32 @@ fn open_any(routes: &[Route], hello: &Hello) -> Result<Link, Failure> {
211218 }
212219 }
213220
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+
214247 /// Connects, says hello, and waits for the welcome.
215248 fn open(route: &Route, hello: &Hello) -> Result<Link, Failure> {
216249 let other = |err: &dyn std::fmt::Display| Failure::Other(err.to_string());
@@ -367,7 +400,7 @@ mod tests {
367400 let gone = Route { address: format!("127.0.0.1:{}", port + 100), kind: RouteKind::Lan, learned: true };
368401 let tailnet = Route { address: format!("127.0.0.1:{port}"), kind: RouteKind::Tailnet, learned: true };
369402 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);
371404 assert_eq!(connected_route(&client), tailnet.address);
372405
373406 // Back home: a faster route answers, and the client moves to it.
@@ -379,7 +412,7 @@ mod tests {
379412 let other_port = port + 200;
380413 let _other = RemoteServer::start(other_port, new_key(), "someone-else".into()).unwrap();
381414 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);
383416 assert_eq!(connected_route(&client), tailnet.address);
384417 }
385418 }
+129−2src/remote/mod.rsView File
@@ -704,6 +704,105 @@ fn interface_addresses() -> Vec<(String, std::net::IpAddr)> {
704704 Vec::new()
705705 }
706706
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+
707806 #[cfg(test)]
708807 mod tests {
709808 use super::*;
@@ -720,6 +819,34 @@ mod tests {
720819 assert_eq!(with_port("[::1]:9000"), "[::1]:9000");
721820 }
722821
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+
723850 #[::core::prelude::v1::test]
724851 fn routes() {
725852 use std::net::IpAddr;
@@ -921,7 +1048,7 @@ mod tests {
9211048 })
9221049 }
9231050
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());
9251052 assert!(matches!(wait(&client.events), Some(ClientEvent::Status(ConnectionStatus::Connecting))));
9261053 match wait(&client.events) {
9271054 Some(ClientEvent::Route(route)) => assert_eq!(route.address, format!("127.0.0.1:{port}")),
@@ -953,7 +1080,7 @@ mod tests {
9531080 _ => panic!("client didn't get the reply"),
9541081 }
9551082
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());
9571084 assert!(matches!(wait(&intruder.events), Some(ClientEvent::Status(ConnectionStatus::Connecting))));
9581085 assert!(matches!(wait(&intruder.events), Some(ClientEvent::Status(ConnectionStatus::Denied(_)))));
9591086 }
+10−0src/settings.rsView File
@@ -335,6 +335,10 @@ impl ProviderInstance {
335335 pub struct RemoteHost {
336336 pub id: String,
337337 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,
338342 /// `host` or `host:port`.
339343 pub address: String,
340344 pub key: String,
@@ -353,6 +357,12 @@ impl RemoteHost {
353357 if self.name.trim().is_empty() { self.address.clone() } else { self.name.trim().to_string() }
354358 }
355359
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+
356366 /// Every way to reach it, fastest first.
357367 pub fn ordered_routes(&self) -> Vec<crate::remote::Route> {
358368 crate::remote::ordered_routes(&self.address, &self.routes)
+1−0src/ui/settings/connections.rsView File
@@ -102,6 +102,7 @@ impl SettingsView {
102102 enabled: true,
103103 settings_offered: false,
104104 routes: learned,
105+ ..Default::default()
105106 })
106107 });
107108 }
+13−1src/ui/workspace/remote.rsView File
@@ -162,7 +162,7 @@ impl Workspace {
162162 }
163163
164164 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()));
166166 let events = client.events.clone();
167167 let id = host.id.clone();
168168 let task = cx.spawn_in(window, async move |this, cx| {
@@ -760,6 +760,18 @@ impl Workspace {
760760 self.stop_remote_terminals(env, "The connection to the other device dropped; this terminal's shell ended.", cx);
761761 }
762762 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+ }
763775 environment.status = status;
764776 }
765777 if connected {