Skip to main content

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;