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