ts_control_serde/tka_sync.rs
1//! Wire types for the Tailnet-Lock (TKA) chain-sync RPCs (`GET /machine/tka/sync/offer` and
2//! `GET /machine/tka/sync/send`, both Noise-tunnelled).
3//!
4//! A node catches its local TKA chain up to control's via a two-step handshake: it POSTs — actually
5//! a `GET` carrying a JSON body, matching Go — its [`TkaSyncOfferRequest`] (its head + a sparse
6//! ancestor sample), control replies with the AUMs the node is missing plus control's own offer
7//! ([`TkaSyncOfferResponse`]); the node then sends control the AUMs *it* is missing in a
8//! [`TkaSyncSendRequest`].
9//!
10//! Mirrors Go `tailcfg.TKASyncOfferRequest`/`Response` + `TKASyncSendRequest`/`Response`
11//! (`tailcfg/tka.go`, v1.100.0). Wire encodings to get byte-exact:
12//! - `Head` / `Ancestors[]` are **base32** (RFC4648 standard alphabet, no padding) text forms of the
13//! 32-byte AUM hashes (Go `AUMHash.MarshalText`). Carried here as `String` — the RPC client
14//! converts to/from `ts_tka::AumHash`.
15//! - `MissingAUMs` is Go `[]tkatype.MarshaledAUM` = `[][]byte`, which Go's `encoding/json`
16//! **base64-encodes** (standard) per element. So on the wire it is a JSON array of base64 strings,
17//! each decoding to a raw CBOR-serialized AUM. The [`marshaled_aums`] serde module does exactly
18//! that (no intermediate type): `Vec<Vec<u8>>` ⇄ JSON array of base64 strings.
19
20use alloc::{
21 string::{String, ToString},
22 vec::Vec,
23};
24
25use base64::{Engine, engine::general_purpose::STANDARD};
26use serde::{Deserialize, Serialize};
27use ts_capabilityversion::CapabilityVersion;
28use ts_keys::NodePublicKey;
29
30/// Request body for `GET /machine/tka/sync/offer` (Go `tailcfg.TKASyncOfferRequest`): the node's
31/// current chain head + a sparse ancestor sample, so control can compute what to send back.
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "PascalCase")]
34pub struct TkaSyncOfferRequest {
35 /// Client capability version (serializes as `Version`).
36 pub version: CapabilityVersion,
37 /// This node's public key (serializes as `NodeKey` → `nodekey:`+hex).
38 pub node_key: NodePublicKey,
39 /// The node's current chain head, base32 (no-pad) text form of the 32-byte AUM hash.
40 pub head: String,
41 /// An exponentially-spaced sample of ancestors, newest-first, ending with the oldest-known AUM;
42 /// each is a base32 (no-pad) AUM-hash text form.
43 pub ancestors: Vec<String>,
44}
45
46/// Response to `GET /machine/tka/sync/offer` (Go `tailcfg.TKASyncOfferResponse`): control's own
47/// offer (its head + ancestors) plus the AUMs it computed the node is missing.
48#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "PascalCase")]
50pub struct TkaSyncOfferResponse {
51 /// Control's current chain head (base32 no-pad).
52 pub head: String,
53 /// Control's ancestor sample (base32 no-pad each).
54 pub ancestors: Vec<String>,
55 /// The AUMs the node is missing — each raw CBOR bytes, base64 on the wire (Go
56 /// `[]tkatype.MarshaledAUM`). Empty/absent when the node is already up to date.
57 #[serde(rename = "MissingAUMs", with = "marshaled_aums", default)]
58 pub missing_aums: Vec<Vec<u8>>,
59}
60
61/// Request body for `GET /machine/tka/sync/send` (Go `tailcfg.TKASyncSendRequest`): the node's
62/// (post-`Inform`) head plus the AUMs control is missing.
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "PascalCase")]
65pub struct TkaSyncSendRequest {
66 /// Client capability version.
67 pub version: CapabilityVersion,
68 /// This node's public key (`nodekey:`+hex).
69 pub node_key: NodePublicKey,
70 /// The node's head after applying the AUMs from the offer response (base32 no-pad).
71 pub head: String,
72 /// The AUMs control is missing — raw CBOR bytes, base64 on the wire.
73 #[serde(rename = "MissingAUMs", with = "marshaled_aums", default)]
74 pub missing_aums: Vec<Vec<u8>>,
75 /// Whether this sync is interactive (Go `Interactive`). Always `false` for a background catch-up.
76 pub interactive: bool,
77}
78
79/// Response to `GET /machine/tka/sync/send` (Go `tailcfg.TKASyncSendResponse`): control's resulting
80/// head after applying the node's AUMs.
81#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "PascalCase")]
83pub struct TkaSyncSendResponse {
84 /// Control's chain head after the send (base32 no-pad).
85 pub head: String,
86}
87
88/// Serde for Go `[]tkatype.MarshaledAUM` (= `[][]byte`): a JSON array of **standard-base64** strings,
89/// each the raw CBOR bytes of one AUM. Go's `encoding/json` base64-encodes a `[]byte`; serde's
90/// default would emit an array-of-ints, so this module bridges the two without an intermediate type.
91mod marshaled_aums {
92 use super::*;
93
94 pub fn serialize<S>(aums: &[Vec<u8>], serializer: S) -> Result<S::Ok, S::Error>
95 where
96 S: serde::Serializer,
97 {
98 use serde::ser::SerializeSeq;
99 let mut seq = serializer.serialize_seq(Some(aums.len()))?;
100 for aum in aums {
101 seq.serialize_element(&STANDARD.encode(aum))?;
102 }
103 seq.end()
104 }
105
106 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Vec<u8>>, D::Error>
107 where
108 D: serde::Deserializer<'de>,
109 {
110 // A `null` (Go nil slice) deserializes to the empty vec; otherwise a list of base64 strings.
111 let strs: Option<Vec<String>> = Option::deserialize(deserializer)?;
112 let Some(strs) = strs else {
113 return Ok(Vec::new());
114 };
115 strs.into_iter()
116 .map(|s| {
117 STANDARD
118 .decode(s.as_bytes())
119 .map_err(|e| serde::de::Error::custom(e.to_string()))
120 })
121 .collect()
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn offer_request_serializes_pascalcase_with_nodekey_and_base32_head() {
131 let req = TkaSyncOfferRequest {
132 version: CapabilityVersion::CURRENT,
133 node_key: NodePublicKey::from([7u8; 32]),
134 head: "AEBAGBAF".to_string(),
135 ancestors: alloc::vec!["AEBAGBAF".to_string(), "MFRGGZDF".to_string()],
136 };
137 let json = serde_json::to_value(&req).unwrap();
138 assert!(json.get("Version").is_some());
139 assert!(json.get("Head").is_some());
140 assert!(json.get("Ancestors").is_some());
141 let nk = json.get("NodeKey").and_then(|v| v.as_str()).unwrap();
142 assert!(
143 nk.starts_with("nodekey:"),
144 "NodeKey is nodekey:+hex, got {nk}"
145 );
146 }
147
148 #[test]
149 fn offer_response_missing_aums_are_base64() {
150 // A response carrying two AUMs (raw bytes {1,2,3} and {0xff}) must encode them as base64
151 // strings, and round-trip back to the same bytes.
152 let resp = TkaSyncOfferResponse {
153 head: "AEBAGBAF".to_string(),
154 ancestors: Vec::new(),
155 missing_aums: alloc::vec![alloc::vec![1u8, 2, 3], alloc::vec![0xffu8]],
156 };
157 let json = serde_json::to_value(&resp).unwrap();
158 let arr = json.get("MissingAUMs").and_then(|v| v.as_array()).unwrap();
159 assert_eq!(arr[0].as_str().unwrap(), "AQID"); // base64(0x01 0x02 0x03)
160 assert_eq!(arr[1].as_str().unwrap(), "/w=="); // base64(0xff)
161 let back: TkaSyncOfferResponse = serde_json::from_value(json).unwrap();
162 assert_eq!(back.missing_aums, resp.missing_aums);
163 }
164
165 #[test]
166 fn offer_response_missing_aums_null_is_empty() {
167 // Go sends `null` (or omits) MissingAUMs when nothing is missing → empty vec, not an error.
168 let resp: TkaSyncOfferResponse =
169 serde_json::from_str(r#"{"Head":"AEBAGBAF","Ancestors":[],"MissingAUMs":null}"#)
170 .unwrap();
171 assert!(resp.missing_aums.is_empty());
172 // Absent entirely (default) also works.
173 let resp2: TkaSyncOfferResponse =
174 serde_json::from_str(r#"{"Head":"AEBAGBAF","Ancestors":[]}"#).unwrap();
175 assert!(resp2.missing_aums.is_empty());
176 }
177
178 #[test]
179 fn send_request_roundtrips() {
180 let req = TkaSyncSendRequest {
181 version: CapabilityVersion::CURRENT,
182 node_key: NodePublicKey::from([3u8; 32]),
183 head: "MFRGGZDF".to_string(),
184 missing_aums: alloc::vec![alloc::vec![9u8, 9]],
185 interactive: false,
186 };
187 let json = serde_json::to_string(&req).unwrap();
188 let back: TkaSyncSendRequest = serde_json::from_str(&json).unwrap();
189 assert_eq!(back, req);
190 assert!(json.contains("\"Interactive\":false"));
191 }
192
193 #[test]
194 fn send_response_deserializes() {
195 let resp: TkaSyncSendResponse = serde_json::from_str(r#"{"Head":"MFRGGZDF"}"#).unwrap();
196 assert_eq!(resp.head, "MFRGGZDF");
197 }
198}