pim_protocol/control_frame.rs
1//! Control-plane messages carried inside transport frames.
2
3use bytes::{Buf, BufMut, Bytes, BytesMut};
4
5use pim_core::{FrameCodec, NodeId, PimError};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[repr(u8)]
9/// Discriminator for [`ControlFrame`] payloads.
10///
11/// Tag values `0x01` and `0x02` were previously assigned to
12/// `IpRequest` / `IpAssign`. They were removed when mesh addresses
13/// became deterministic from each node's `NodeId` (no more dynamic
14/// allocation handshake). The slots are kept reserved on the wire so
15/// any straggling old daemon's frames decode to a clean error rather
16/// than aliasing a future tag.
17pub enum ControlType {
18 /// Peer is leaving and wants its state cleaned up promptly.
19 Goodbye = 0x03,
20 /// Session keys should be renegotiated.
21 Rekey = 0x04,
22 /// RTT probe request.
23 Ping = 0x05,
24 /// RTT probe response.
25 Pong = 0x06,
26 /// One-shot exchange of node identity metadata after handshake.
27 PeerInfo = 0x07,
28 /// Generic plugin-defined payload — see [`ControlFrame::PluginPayload`].
29 PluginPayload = 0x08,
30}
31
32impl ControlType {
33 /// Decode a raw control-type tag from the wire.
34 pub fn from_u8(v: u8) -> Result<Self, PimError> {
35 match v {
36 0x03 => Ok(Self::Goodbye),
37 0x04 => Ok(Self::Rekey),
38 0x05 => Ok(Self::Ping),
39 0x06 => Ok(Self::Pong),
40 0x07 => Ok(Self::PeerInfo),
41 0x08 => Ok(Self::PluginPayload),
42 other => Err(PimError::Protocol(format!(
43 "unknown control type: 0x{other:02x}"
44 ))),
45 }
46 }
47}
48
49/// Multiplexed control message.
50///
51/// Layout: control_type(1) + body (variable, depends on type).
52///
53/// Mesh-essential variants (routing, liveness, identity) live here
54/// directly. Optional features such as user messaging are carried
55/// inside [`ControlFrame::PluginPayload`] so the daemon can be built
56/// without those plugins compiled in.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum ControlFrame {
59 /// Graceful disconnect notification.
60 Goodbye {
61 /// Node that is departing.
62 departing_id: NodeId,
63 /// Implementation-defined reason code.
64 reason: u8,
65 },
66 /// Request that the session be rekeyed.
67 Rekey,
68 /// Ping carrying an opaque nonce.
69 Ping {
70 /// Opaque value echoed by the corresponding pong.
71 nonce: u64,
72 },
73 /// Pong echoing a ping nonce.
74 Pong {
75 /// Opaque value copied from the ping request.
76 nonce: u64,
77 },
78 /// Identity metadata exchanged once after the session is established
79 /// so that peers can address each other by `NodeId` and end-to-end
80 /// encrypt to the recipient's static X25519 key.
81 PeerInfo {
82 /// X25519 public key derived from the sender's Ed25519 seed.
83 x25519_pub: [u8; 32],
84 /// Sender's friendly node name as configured locally (UTF-8,
85 /// length-prefixed by `u8` — capped at 255 bytes by the codec).
86 friendly_name: String,
87 },
88 /// Generic plugin-defined payload.
89 ///
90 /// `kind` is an ASCII identifier (≤ 255 bytes) registered by a
91 /// [`pim-plugin`](https://crates.io/crates/pim-plugin)-style
92 /// plugin (e.g. `"messaging.msg"`). `body` is plugin-private and
93 /// opaque to the daemon — typically further encrypted/serialized
94 /// according to the plugin's own scheme.
95 ///
96 /// Wire layout:
97 /// ```text
98 /// 0x08
99 /// kind_len (u8, 1..=255)
100 /// kind (kind_len bytes, ASCII)
101 /// body_len (u16 BE)
102 /// body (body_len bytes)
103 /// ```
104 PluginPayload {
105 /// Plugin-namespaced kind identifier.
106 kind: String,
107 /// Opaque payload bytes — interpretation is up to the plugin
108 /// claiming `kind`.
109 body: Bytes,
110 },
111}
112
113impl FrameCodec for ControlFrame {
114 fn encode(&self, buf: &mut BytesMut) {
115 match self {
116 ControlFrame::Goodbye {
117 departing_id,
118 reason,
119 } => {
120 buf.put_u8(ControlType::Goodbye as u8);
121 buf.put_slice(departing_id.as_bytes());
122 buf.put_u8(*reason);
123 }
124 ControlFrame::Rekey => {
125 buf.put_u8(ControlType::Rekey as u8);
126 }
127 ControlFrame::Ping { nonce } => {
128 buf.put_u8(ControlType::Ping as u8);
129 buf.put_u64(*nonce);
130 }
131 ControlFrame::Pong { nonce } => {
132 buf.put_u8(ControlType::Pong as u8);
133 buf.put_u64(*nonce);
134 }
135 ControlFrame::PeerInfo {
136 x25519_pub,
137 friendly_name,
138 } => {
139 buf.put_u8(ControlType::PeerInfo as u8);
140 buf.put_slice(x25519_pub);
141 let name_bytes = friendly_name.as_bytes();
142 let name_len = name_bytes.len().min(255) as u8;
143 buf.put_u8(name_len);
144 buf.put_slice(&name_bytes[..name_len as usize]);
145 }
146 ControlFrame::PluginPayload { kind, body } => {
147 buf.put_u8(ControlType::PluginPayload as u8);
148 let kind_bytes = kind.as_bytes();
149 let kind_len = kind_bytes.len().min(255) as u8;
150 buf.put_u8(kind_len);
151 buf.put_slice(&kind_bytes[..kind_len as usize]);
152 let body_len = body.len().min(u16::MAX as usize) as u16;
153 buf.put_u16(body_len);
154 buf.put_slice(&body[..body_len as usize]);
155 }
156 }
157 }
158
159 fn decode(buf: &mut BytesMut) -> Result<Self, PimError> {
160 if buf.is_empty() {
161 return Err(PimError::Protocol("control frame empty".into()));
162 }
163
164 let control_type = ControlType::from_u8(buf[0])?;
165
166 match control_type {
167 ControlType::Goodbye => {
168 if buf.len() < 18 {
169 // 1 + 16 + 1
170 return Err(PimError::Protocol("Goodbye too short".into()));
171 }
172 let mut id = [0u8; 16];
173 id.copy_from_slice(&buf[1..17]);
174 let reason = buf[17];
175 buf.advance(18);
176 Ok(ControlFrame::Goodbye {
177 departing_id: NodeId::from_bytes(id),
178 reason,
179 })
180 }
181 ControlType::Rekey => {
182 buf.advance(1);
183 Ok(ControlFrame::Rekey)
184 }
185 ControlType::Ping => {
186 if buf.len() < 9 {
187 return Err(PimError::Protocol("Ping too short".into()));
188 }
189 let nonce = (&buf[1..9]).get_u64();
190 buf.advance(9);
191 Ok(ControlFrame::Ping { nonce })
192 }
193 ControlType::Pong => {
194 if buf.len() < 9 {
195 return Err(PimError::Protocol("Pong too short".into()));
196 }
197 let nonce = (&buf[1..9]).get_u64();
198 buf.advance(9);
199 Ok(ControlFrame::Pong { nonce })
200 }
201 ControlType::PeerInfo => {
202 // 1 (tag) + 32 (x25519) + 1 (name_len) + N (name)
203 if buf.len() < 34 {
204 return Err(PimError::Protocol("PeerInfo too short".into()));
205 }
206 let mut x25519_pub = [0u8; 32];
207 x25519_pub.copy_from_slice(&buf[1..33]);
208 let name_len = buf[33] as usize;
209 let total = 34 + name_len;
210 if buf.len() < total {
211 return Err(PimError::Protocol(format!(
212 "PeerInfo truncated: need {total}, have {}",
213 buf.len()
214 )));
215 }
216 let friendly_name = match std::str::from_utf8(&buf[34..total]) {
217 Ok(s) => s.to_owned(),
218 Err(_) => {
219 return Err(PimError::Protocol(
220 "PeerInfo friendly_name not valid UTF-8".into(),
221 ))
222 }
223 };
224 buf.advance(total);
225 Ok(ControlFrame::PeerInfo {
226 x25519_pub,
227 friendly_name,
228 })
229 }
230 ControlType::PluginPayload => {
231 // 1 (tag) + 1 (kind_len) + N (kind) + 2 (body_len) + M (body)
232 if buf.len() < 4 {
233 return Err(PimError::Protocol("PluginPayload too short".into()));
234 }
235 let kind_len = buf[1] as usize;
236 let body_len_off = 2 + kind_len;
237 if buf.len() < body_len_off + 2 {
238 return Err(PimError::Protocol(format!(
239 "PluginPayload header truncated: need {}, have {}",
240 body_len_off + 2,
241 buf.len()
242 )));
243 }
244 let kind = match std::str::from_utf8(&buf[2..body_len_off]) {
245 Ok(s) => s.to_owned(),
246 Err(_) => {
247 return Err(PimError::Protocol(
248 "PluginPayload kind not valid UTF-8".into(),
249 ))
250 }
251 };
252 let body_len = (&buf[body_len_off..body_len_off + 2]).get_u16() as usize;
253 let total = body_len_off + 2 + body_len;
254 if buf.len() < total {
255 return Err(PimError::Protocol(format!(
256 "PluginPayload truncated: need {total}, have {}",
257 buf.len()
258 )));
259 }
260 let body = Bytes::copy_from_slice(&buf[body_len_off + 2..total]);
261 buf.advance(total);
262 Ok(ControlFrame::PluginPayload { kind, body })
263 }
264 }
265 }
266}
267
268#[cfg(test)]
269mod tests;