1use std::io;
10
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
14
15use crate::forwarding::ForwardingDirective;
16use crate::ssh::SessionPolicy;
17use crate::ssh::tokio_client::AddressFamily;
18
19use super::ControlCommand;
20
21pub const CONTROL_PROTOCOL_VERSION: u32 = 1;
23pub const MAX_CONTROL_FRAME_BYTES: usize = 1024 * 1024;
25pub const MAX_CONTROL_DATA_BYTES: usize = 64 * 1024;
27const MAX_CONTROL_ENVIRONMENT_ENTRIES: usize = 4_096;
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[non_exhaustive]
32#[serde(tag = "type", content = "body", rename_all = "snake_case")]
33pub enum ControlMessage {
34 Request(ControlRequest),
35 Response(ControlResponse),
36 Data(ControlData),
37}
38
39impl ControlMessage {
40 fn validate(&self) -> Result<(), ControlProtocolError> {
41 match self {
42 Self::Request(request) => request.validate(),
43 Self::Response(_) => Ok(()),
44 Self::Data(data) => data.validate(),
45 }
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct ControlRequest {
52 pub request_id: u64,
53 pub operation: ControlOperation,
54}
55
56impl ControlRequest {
57 pub fn validate(&self) -> Result<(), ControlProtocolError> {
60 match &self.operation {
61 ControlOperation::Hello { version } if *version != CONTROL_PROTOCOL_VERSION => {
62 Err(ControlProtocolError::UnsupportedVersion {
63 received: *version,
64 supported: CONTROL_PROTOCOL_VERSION,
65 })
66 }
67 ControlOperation::Hello { .. } => Ok(()),
68 ControlOperation::OpenSession(session) => session.validate(),
69 ControlOperation::Command {
70 command, forwards, ..
71 } => match command {
72 ControlCommand::Forward | ControlCommand::Cancel if forwards.is_empty() => {
73 Err(ControlProtocolError::MissingForwarding(*command))
74 }
75 ControlCommand::Check | ControlCommand::Exit | ControlCommand::Stop
76 if !forwards.is_empty() =>
77 {
78 Err(ControlProtocolError::UnexpectedForwarding(*command))
79 }
80 _ => Ok(()),
81 },
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[non_exhaustive]
89#[serde(tag = "operation", rename_all = "snake_case")]
90pub enum ControlOperation {
91 Hello {
92 version: u32,
93 },
94 OpenSession(SessionOpenRequest),
95 Command {
96 command: ControlCommand,
97 forwards: Vec<ForwardingDirective>,
99 address_family: AddressFamily,
101 },
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct SessionOpenRequest {
107 pub policy: SessionPolicy,
108 pub terminal: Option<String>,
110}
111
112impl SessionOpenRequest {
113 pub fn new(
116 policy: SessionPolicy,
117 terminal: Option<String>,
118 ) -> Result<Self, ControlProtocolError> {
119 let request = Self { policy, terminal };
120 request.validate()?;
121 Ok(request)
122 }
123
124 pub fn validate(&self) -> Result<(), ControlProtocolError> {
126 if self.policy.local_command.is_some() {
127 return Err(ControlProtocolError::LocalCommandNotAllowed);
128 }
129 if self.policy.environment.len() > MAX_CONTROL_ENVIRONMENT_ENTRIES {
130 return Err(ControlProtocolError::TooManyEnvironmentEntries {
131 count: self.policy.environment.len(),
132 maximum: MAX_CONTROL_ENVIRONMENT_ENTRIES,
133 });
134 }
135 Ok(())
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct ControlResponse {
142 pub request_id: u64,
143 pub kind: ControlResponseKind,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[non_exhaustive]
149#[serde(tag = "result", rename_all = "snake_case")]
150pub enum ControlResponseKind {
151 Ok,
152 Alive { pid: u32 },
153 SessionOpened { session_id: u64 },
154 ExitStatus { session_id: u64, status: u32 },
155 Error { code: String, message: String },
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
160#[non_exhaustive]
161#[serde(rename_all = "snake_case")]
162pub enum ControlDataStream {
163 Stdin,
164 Stdout,
165 Stderr,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct ControlData {
171 pub session_id: u64,
172 pub stream: ControlDataStream,
173 pub sequence: u64,
174 pub payload: Vec<u8>,
175 pub eof: bool,
176}
177
178impl ControlData {
179 pub fn validate(&self) -> Result<(), ControlProtocolError> {
181 if self.payload.len() > MAX_CONTROL_DATA_BYTES {
182 return Err(ControlProtocolError::DataTooLarge {
183 length: self.payload.len(),
184 maximum: MAX_CONTROL_DATA_BYTES,
185 });
186 }
187 Ok(())
188 }
189}
190
191pub async fn write_control_message<W>(
193 writer: &mut W,
194 message: &ControlMessage,
195) -> Result<(), ControlProtocolError>
196where
197 W: AsyncWrite + Unpin,
198{
199 message.validate()?;
200 let encoded = serde_json::to_vec(message)?;
201 if encoded.len() > MAX_CONTROL_FRAME_BYTES {
202 return Err(ControlProtocolError::FrameTooLarge {
203 length: encoded.len(),
204 maximum: MAX_CONTROL_FRAME_BYTES,
205 });
206 }
207 let length = u32::try_from(encoded.len()).map_err(|_| ControlProtocolError::FrameTooLarge {
208 length: encoded.len(),
209 maximum: MAX_CONTROL_FRAME_BYTES,
210 })?;
211 writer.write_all(&length.to_be_bytes()).await?;
212 writer.write_all(&encoded).await?;
213 writer.flush().await?;
214 Ok(())
215}
216
217pub async fn read_control_message<R>(reader: &mut R) -> Result<ControlMessage, ControlProtocolError>
220where
221 R: AsyncRead + Unpin,
222{
223 let mut header = [0u8; 4];
224 reader.read_exact(&mut header).await?;
225 let length = u32::from_be_bytes(header) as usize;
226 if length == 0 {
227 return Err(ControlProtocolError::EmptyFrame);
228 }
229 if length > MAX_CONTROL_FRAME_BYTES {
230 return Err(ControlProtocolError::FrameTooLarge {
231 length,
232 maximum: MAX_CONTROL_FRAME_BYTES,
233 });
234 }
235
236 let mut encoded = vec![0u8; length];
237 reader.read_exact(&mut encoded).await?;
238 let message = serde_json::from_slice::<ControlMessage>(&encoded)?;
239 message.validate()?;
240 Ok(message)
241}
242
243#[derive(Debug, Error)]
245#[non_exhaustive]
246pub enum ControlProtocolError {
247 #[error("control socket I/O failed: {0}")]
248 Io(#[from] io::Error),
249 #[error("control message JSON is invalid: {0}")]
250 Json(#[from] serde_json::Error),
251 #[error("control protocol frame must not be empty")]
252 EmptyFrame,
253 #[error("control protocol frame is {length} bytes, exceeding the {maximum}-byte limit")]
254 FrameTooLarge { length: usize, maximum: usize },
255 #[error("control data chunk is {length} bytes, exceeding the {maximum}-byte limit")]
256 DataTooLarge { length: usize, maximum: usize },
257 #[error("control protocol version {received} is unsupported; this build supports {supported}")]
258 UnsupportedVersion { received: u32, supported: u32 },
259 #[error(
260 "LocalCommand must execute in the invoking process and cannot be sent to a control master"
261 )]
262 LocalCommandNotAllowed,
263 #[error("session request contains {count} environment entries; maximum is {maximum}")]
264 TooManyEnvironmentEntries { count: usize, maximum: usize },
265 #[error("control command '{0}' requires at least one forwarding directive")]
266 MissingForwarding(ControlCommand),
267 #[error("control command '{0}' does not accept forwarding directives")]
268 UnexpectedForwarding(ControlCommand),
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::ssh::SessionRequest;
275
276 fn session_policy() -> SessionPolicy {
277 SessionPolicy {
278 environment: vec![("LANG".into(), "C.UTF-8".into())],
279 local_command: None,
280 forward_agent: true,
281 request_pty: false,
282 stdin_null: false,
283 request: SessionRequest::Exec("printf test".into()),
284 }
285 }
286
287 #[tokio::test]
288 async fn request_round_trip_preserves_session_policy() {
289 let request = ControlMessage::Request(ControlRequest {
290 request_id: 7,
291 operation: ControlOperation::OpenSession(
292 SessionOpenRequest::new(session_policy(), Some("xterm-256color".into()))
293 .expect("wire-safe session"),
294 ),
295 });
296 let (mut client, mut server) = tokio::io::duplex(16 * 1024);
297 let write = write_control_message(&mut client, &request);
298 let read = read_control_message(&mut server);
299 let (written, decoded) = tokio::join!(write, read);
300 written.expect("write succeeds");
301 assert_eq!(decoded.expect("read succeeds"), request);
302 }
303
304 #[tokio::test]
305 async fn data_round_trip_is_binary_safe() {
306 let message = ControlMessage::Data(ControlData {
307 session_id: 9,
308 stream: ControlDataStream::Stdout,
309 sequence: 3,
310 payload: vec![0, 255, b'\n', 0],
311 eof: true,
312 });
313 let (mut client, mut server) = tokio::io::duplex(16 * 1024);
314 let write = write_control_message(&mut client, &message);
315 let read = read_control_message(&mut server);
316 let (written, decoded) = tokio::join!(write, read);
317 written.expect("write succeeds");
318 assert_eq!(decoded.expect("read succeeds"), message);
319 }
320
321 #[tokio::test]
322 async fn oversized_declared_frame_is_rejected_before_payload_read() {
323 let (mut client, mut server) = tokio::io::duplex(16);
324 client
325 .write_all(&((MAX_CONTROL_FRAME_BYTES as u32) + 1).to_be_bytes())
326 .await
327 .expect("header write");
328 let error = read_control_message(&mut server)
329 .await
330 .expect_err("oversized frame");
331 assert!(matches!(error, ControlProtocolError::FrameTooLarge { .. }));
332 }
333
334 #[tokio::test]
335 async fn oversized_data_is_rejected_before_serialization() {
336 let message = ControlMessage::Data(ControlData {
337 session_id: 1,
338 stream: ControlDataStream::Stdin,
339 sequence: 0,
340 payload: vec![0; MAX_CONTROL_DATA_BYTES + 1],
341 eof: false,
342 });
343 let mut sink = tokio::io::sink();
344 let error = write_control_message(&mut sink, &message)
345 .await
346 .expect_err("oversized data");
347 assert!(matches!(error, ControlProtocolError::DataTooLarge { .. }));
348 }
349
350 #[test]
351 fn local_command_cannot_cross_the_master_boundary() {
352 let mut policy = session_policy();
353 policy.local_command = Some("touch /tmp/client-only".into());
354 assert!(matches!(
355 SessionOpenRequest::new(policy, None),
356 Err(ControlProtocolError::LocalCommandNotAllowed)
357 ));
358 }
359
360 #[test]
361 fn command_forwarding_shape_is_validated() {
362 let missing = ControlRequest {
363 request_id: 1,
364 operation: ControlOperation::Command {
365 command: ControlCommand::Forward,
366 forwards: Vec::new(),
367 address_family: AddressFamily::Any,
368 },
369 };
370 assert!(matches!(
371 missing.validate(),
372 Err(ControlProtocolError::MissingForwarding(
373 ControlCommand::Forward
374 ))
375 ));
376
377 let unexpected = ControlRequest {
378 request_id: 2,
379 operation: ControlOperation::Command {
380 command: ControlCommand::Check,
381 forwards: vec![ForwardingDirective::Dynamic("1080".into())],
382 address_family: AddressFamily::Any,
383 },
384 };
385 assert!(matches!(
386 unexpected.validate(),
387 Err(ControlProtocolError::UnexpectedForwarding(
388 ControlCommand::Check
389 ))
390 ));
391 }
392}