Skip to main content

ts_control_serde/
dial_plan.rs

1use alloc::vec::Vec;
2use core::{net::IpAddr, time::Duration};
3
4use serde::Deserialize;
5
6/// Instructions from the control server to a Tailscale node on how to connect to the control
7/// server. Used to maintain connection if the node's network state changes after the initial
8/// connection, or if the control server pushes other changes to the node (such as DNS config
9/// updates) that break connectivity.
10#[serde_with::apply(
11    Vec => #[serde(default, deserialize_with = "crate::util::null_to_default")],
12)]
13#[derive(Default, Debug, Clone, Deserialize)]
14#[serde(rename_all = "PascalCase", default)]
15pub struct ControlDialPlan<'a> {
16    /// The list of candidate IP addresses this Tailscale node should use to reach the control
17    /// server. An empty list means the node should use any DNS resolver available to discover the
18    /// control server's IP address.
19    #[serde(borrow)]
20    pub candidates: Vec<ControlIpCandidate<'a>>,
21}
22
23/// Represents a single candidate IP address to attempt a connection to the control server.
24#[serde_with::serde_as]
25#[derive(Default, Debug, Clone, Deserialize)]
26#[serde(rename = "ControlIPCandidate", rename_all = "PascalCase", default)]
27pub struct ControlIpCandidate<'a> {
28    /// If populated, the IP address of the control server to attempt a connection to.
29    #[serde(rename = "IP")]
30    pub ip: Option<IpAddr>,
31
32    /// If populated, indicates this Tailscale node should connect to the control plane using an
33    /// HTTPS CONNECT request to the given hostname. If [`ControlIpCandidate::ip`] is also
34    /// populated, [`ControlIpCandidate::ip`] is the IP address of the
35    /// [`ControlIpCandidate::ace_host`] (not the control server) and DNS should NOT be used to
36    /// look up the IP address of the ACE host.
37    ///
38    /// ACE requires the hostname even if an IP address is provided because the hostname is a
39    /// required part of an HTTPS CONNECT request to the control plane.
40    #[serde(rename = "ACEHost", borrow)]
41    pub ace_host: Option<&'a str>,
42
43    /// Number of seconds this Tailscale node should wait between starting the overall control
44    /// plane connection process, and attempting to connect to this candidate control server.
45    ///
46    /// This value allows the control plane to spread individual connection attempts from the
47    /// same node out over time.
48    #[serde_as(as = "serde_with::DurationSeconds<f64>")]
49    pub dial_start_delay_sec: Duration,
50
51    /// Number of seconds this Tailscale node should wait for a response from this candidate
52    /// control server before considering it unreachable (timing out).
53    ///
54    /// The node should start this timer when it starts attempting to connect to this particular
55    /// candidate control server.
56    #[serde_as(as = "serde_with::DurationSeconds<f64>")]
57    pub dial_timeout_sec: Duration,
58
59    /// The relative priority of this candidate control server compared to other candidates.
60    /// Candidates with a numerically higher priority are preferred over candidates with a lower
61    /// priority; in other words, a candidate with a priority of `256` is preferred over a
62    /// candidate with a priority of `1`.
63    pub priority: i64,
64}
65
66#[cfg(test)]
67mod test {
68    use super::*;
69
70    const TEST_SAMPLE: &str = r#"{
71      "Candidates": [
72        {
73          "IP": "2606:b740:49::114",
74          "DialTimeoutSec": 10,
75          "Priority": 5
76        },
77        {
78          "IP": "192.200.0.114",
79          "DialStartDelaySec": 0.3,
80          "DialTimeoutSec": 10,
81          "Priority": 5
82        },
83        {
84          "IP": "2606:b740:49::101",
85          "DialStartDelaySec": 0.55,
86          "DialTimeoutSec": 10,
87          "Priority": 4
88        },
89        {
90          "IP": "192.200.0.101",
91          "DialStartDelaySec": 0.8,
92          "DialTimeoutSec": 10,
93          "Priority": 4
94        },
95        {
96          "IP": "2606:b740:49::103",
97          "DialStartDelaySec": 1.05,
98          "DialTimeoutSec": 10,
99          "Priority": 3
100        },
101        {
102          "IP": "192.200.0.103",
103          "DialStartDelaySec": 1.3,
104          "DialTimeoutSec": 10,
105          "Priority": 3
106        },
107        {
108          "IP": "2606:b740:49::113",
109          "DialStartDelaySec": 1.55,
110          "DialTimeoutSec": 10,
111          "Priority": 2
112        },
113        {
114          "IP": "192.200.0.113",
115          "DialStartDelaySec": 1.8,
116          "DialTimeoutSec": 10,
117          "Priority": 2
118        },
119        {
120          "IP": "192.200.0.113",
121          "ACEHost": "abc.def.com",
122          "DialStartDelaySec": 1.8,
123          "DialTimeoutSec": 10,
124          "Priority": 2
125        },
126        {
127          "ACEHost": "abc.def.com",
128          "DialStartDelaySec": 1.8,
129          "DialTimeoutSec": 10,
130          "Priority": 2
131        }
132      ]
133    }"#;
134
135    #[test]
136    fn dial_plan() {
137        let dial_plan = serde_json::from_str::<ControlDialPlan>(TEST_SAMPLE).unwrap();
138
139        assert_eq!(10, dial_plan.candidates.len());
140        for candidate in dial_plan.candidates {
141            assert!(candidate.ip.is_some() || candidate.ace_host.is_some());
142        }
143    }
144
145    /// Go marshals an empty `omitempty` slice as `null`, so a control plane can send
146    /// `"Candidates": null` — which previously failed the decode with `invalid type: null,
147    /// expected a sequence`.
148    #[test]
149    fn null_candidates_decode_as_empty() {
150        let dial_plan = serde_json::from_str::<ControlDialPlan>(r#"{ "Candidates": null }"#)
151            .expect("ControlDialPlan with null Candidates must decode");
152        assert!(dial_plan.candidates.is_empty());
153    }
154}