Skip to main content

orch/cluster/
discovery.rs

1//! Finds the other orch nodes on the tailnet from `tailscale status --json`
2
3use 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/// A machine on the tailnet, by node name and Tailscale IPv4
30#[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    /// Every peer, online or not, sorted by name. A voter that is down still has an address
41    pub peers: Vec<Host>,
42}
43
44impl Tailnet {
45    /// The address of a machine by node name, this one included
46    pub fn ip_of(&self, name: &str) -> Option<&str> {
47        self.all_named(name).first().map(|h| h.ip.as_str())
48    }
49
50    /// Every machine with this node name, this one included
51    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
59/// The first label of the MagicDNS name. `HostName` can hold spaces, like "m1 max"
60fn 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
78/// The MagicDNS suffix after the first label, which names the tailnet
79fn tailnet_suffix(dns_name: &str) -> &str {
80    dns_name
81        .split_once('.')
82        .map(|(_, rest)| rest)
83        .unwrap_or_default()
84}
85
86/// Only machines of this tailnet. A device shared in from another tailnet has another suffix
87pub 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
119/// A stable id from the node name, so a reinstall of orch keeps its Raft id
120pub 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        // Pinned, so a change of hash would show here before it renumbers a live cluster
176        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}