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::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/// A machine on the tailnet, by node name and Tailscale IPv4
33#[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    /// Every peer, online or not, sorted by name. A voter that is down still has an address
44    pub peers: Vec<Host>,
45}
46
47impl Tailnet {
48    /// The address of a machine by node name, this one included
49    pub fn ip_of(&self, name: &str) -> Option<&str> {
50        self.all_named(name).first().map(|h| h.ip.as_str())
51    }
52
53    /// Every machine with this node name, this one included
54    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
62/// The first label of the MagicDNS name. `HostName` can hold spaces, like "m1 max"
63fn 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
81/// The MagicDNS suffix after the first label, which names the tailnet
82fn tailnet_suffix(dns_name: &str) -> &str {
83    dns_name
84        .split_once('.')
85        .map(|(_, rest)| rest)
86        .unwrap_or_default()
87}
88
89/// Only machines of this tailnet. A device shared in from another tailnet has another suffix
90pub 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
122/// A stable id from the node name, so a reinstall of orch keeps its Raft id
123pub 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        // Pinned, so a change of hash would show here before it renumbers a live cluster
179        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}