Skip to main content

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}