orch/cluster/
discovery.rs1use std::collections::HashMap;
4use std::iter;
5use std::time::Duration;
6
7use serde::Deserialize;
8use sha2::{Digest, Sha256};
9use tokio::process::Command;
10use tokio::time;
11
12use super::NodeId;
13
14#[derive(Deserialize)]
15struct Status {
16 #[serde(rename = "Self")]
17 me: Peer,
18 #[serde(rename = "Peer", default)]
19 peers: HashMap<String, Peer>,
20}
21
22#[derive(Deserialize)]
23struct Peer {
24 #[serde(rename = "DNSName", default)]
25 dns_name: String,
26 #[serde(rename = "TailscaleIPs", default)]
27 ips: Vec<String>,
28 #[serde(rename = "Online", default)]
29 online: bool,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Host {
35 pub name: String,
36 pub ip: String,
37 pub online: bool,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Tailnet {
42 pub me: Host,
43 pub peers: Vec<Host>,
45}
46
47impl Tailnet {
48 pub fn ip_of(&self, name: &str) -> Option<&str> {
50 self.all_named(name).first().map(|h| h.ip.as_str())
51 }
52
53 pub fn all_named(&self, name: &str) -> Vec<&Host> {
55 iter::once(&self.me)
56 .chain(&self.peers)
57 .filter(|h| h.name == name)
58 .collect()
59 }
60}
61
62fn node_name(dns_name: &str) -> String {
64 dns_name
65 .split('.')
66 .next()
67 .unwrap_or_default()
68 .to_ascii_lowercase()
69}
70
71fn host(peer: &Peer) -> Option<Host> {
72 let name = node_name(&peer.dns_name);
73 let ip = peer.ips.iter().find(|ip| ip.contains('.'))?.clone();
74 (!name.is_empty()).then_some(Host {
75 name,
76 ip,
77 online: peer.online,
78 })
79}
80
81fn tailnet_suffix(dns_name: &str) -> &str {
83 dns_name
84 .split_once('.')
85 .map(|(_, rest)| rest)
86 .unwrap_or_default()
87}
88
89pub fn parse(json: &str) -> anyhow::Result<Tailnet> {
91 let status: Status = serde_json::from_str(json)?;
92 let me = host(&status.me)
93 .ok_or_else(|| anyhow::anyhow!("tailscale reports no name or IPv4 for this machine"))?;
94 let suffix = tailnet_suffix(&status.me.dns_name).to_ascii_lowercase();
95 let mut peers: Vec<Host> = status
96 .peers
97 .values()
98 .filter(|p| tailnet_suffix(&p.dns_name).eq_ignore_ascii_case(&suffix))
99 .filter_map(host)
100 .collect();
101 peers.sort_by(|a, b| a.name.cmp(&b.name));
102 Ok(Tailnet { me, peers })
103}
104
105pub async fn status() -> anyhow::Result<Tailnet> {
106 let out = time::timeout(
107 Duration::from_secs(5),
108 Command::new("tailscale")
109 .args(["status", "--json"])
110 .kill_on_drop(true)
111 .output(),
112 )
113 .await??;
114 anyhow::ensure!(
115 out.status.success(),
116 "tailscale status failed: {}",
117 String::from_utf8_lossy(&out.stderr).trim()
118 );
119 parse(&String::from_utf8_lossy(&out.stdout))
120}
121
122pub fn node_id(name: &str) -> NodeId {
124 let digest = Sha256::digest(name.as_bytes());
125 let mut bytes = [0u8; 8];
126 bytes.copy_from_slice(&digest[..8]);
127 u64::from_be_bytes(bytes)
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 const STATUS: &str = r#"{
135 "Self": {"HostName": "m4", "DNSName": "m4.tail0.ts.net.", "TailscaleIPs": ["100.1.1.4", "fd7a::4"], "Online": true},
136 "Peer": {
137 "a": {"HostName": "m1 max", "DNSName": "m1-max.tail0.ts.net.", "TailscaleIPs": ["100.1.1.1"], "Online": true},
138 "b": {"HostName": "legion", "DNSName": "Legion.tail0.ts.net.", "TailscaleIPs": ["fd7a::2", "100.1.1.2"], "Online": true},
139 "c": {"HostName": "localhost", "DNSName": "phone.tail0.ts.net.", "TailscaleIPs": ["100.1.1.9"], "Online": false},
140 "d": {"HostName": "odd", "DNSName": "", "TailscaleIPs": ["100.1.1.8"], "Online": true},
141 "e": {"HostName": "legion", "DNSName": "legion.other.ts.net.", "TailscaleIPs": ["100.9.9.9"], "Online": true}
142 }
143 }"#;
144
145 #[test]
146 fn parse_names_hosts_by_dns_label_and_keeps_ipv4_peers_with_their_state() {
147 let t = parse(STATUS).unwrap();
148 assert_eq!(
149 t.me,
150 Host {
151 name: "m4".into(),
152 ip: "100.1.1.4".into(),
153 online: true,
154 }
155 );
156 let names: Vec<(&str, &str, bool)> = t
157 .peers
158 .iter()
159 .map(|h| (h.name.as_str(), h.ip.as_str(), h.online))
160 .collect();
161 assert_eq!(
162 names,
163 [
164 ("legion", "100.1.1.2", true),
165 ("m1-max", "100.1.1.1", true),
166 ("phone", "100.1.1.9", false)
167 ]
168 );
169 assert_eq!(t.ip_of("m4"), Some("100.1.1.4"));
170 assert_eq!(t.ip_of("phone"), Some("100.1.1.9"));
171 assert_eq!(t.ip_of("nowhere"), None);
172 }
173
174 #[test]
175 fn node_id_is_stable_and_distinct() {
176 assert_eq!(node_id("legion"), node_id("legion"));
177 assert_ne!(node_id("legion"), node_id("m1-max"));
178 assert_eq!(
180 node_id("m4"),
181 u64::from_be_bytes(Sha256::digest(b"m4")[..8].try_into().unwrap())
182 );
183 }
184
185 #[test]
186 fn parse_rejects_a_machine_without_ipv4() {
187 let json = r#"{"Self": {"DNSName": "m4.t.ts.net.", "TailscaleIPs": ["fd7a::4"]}}"#;
188 assert!(parse(json).is_err());
189 }
190}