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}