Skip to main content

ctl_proto/
lib.rs

1//! Versioned metadata preceding the service protocol on identified SSH streams.
2pub mod maintenance;
3use serde::{Deserialize, Serialize};
4use std::io;
5use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
6
7#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
8pub struct TcpListener {
9  pub bind_address: String,
10  pub port: u16,
11}
12
13#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
14pub struct TcpListenerCatalog {
15  pub listeners: Vec<TcpListener>,
16  pub warnings: Vec<String>,
17}
18
19/// Confirmation-bound maintenance request, sent on stdin rather than through a shell.
20#[derive(Debug, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct RemoteCtmuxRestartRequest {
23  pub expected_remote_id: String,
24}
25
26#[derive(Debug, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct RemoteCtmuxRestartResult {
29  pub terminated_sessions: u32,
30}
31
32pub const IDENTITY_PROTOCOL_VERSION: u16 = 3;
33pub const IDENTITY_PREFACE: &[u8] = b"ctl-ssh-v3\n";
34const MAX_IDENTITY_BYTES: usize = 8192;
35
36/// Stable identity of a remote user's ctl environment, independent of its address.
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub struct RemoteIdentity {
39  pub remote_id: String,
40  pub agent_version: String,
41  #[serde(default, skip_serializing_if = "Option::is_none")]
42  pub build: Option<ctl_component_info::ComponentBuildInfo>,
43  #[serde(default)]
44  pub ctmux_restart_supported: bool,
45  #[serde(default, skip_serializing_if = "Option::is_none")]
46  pub bundle: Option<Box<BundleVersion>>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
50pub struct BundleVersion {
51  pub app_version: String,
52  pub bundle_id: String,
53  pub git_revision: String,
54  pub target_triple: String,
55}
56
57impl RemoteIdentity {
58  #[must_use]
59  pub fn is_valid(&self) -> bool {
60    let text =
61      |value: &str| !value.is_empty() && value.len() <= 256 && !value.chars().any(char::is_control);
62    uuid::Uuid::parse_str(&self.remote_id)
63      .is_ok_and(|id| !id.is_nil() && id.to_string() == self.remote_id)
64      && text(&self.agent_version)
65      && self
66        .build
67        .as_ref()
68        .is_none_or(|build| build.is_valid() && build.version == self.agent_version)
69      && self.bundle.as_ref().is_none_or(|bundle| {
70        [
71          &bundle.app_version,
72          &bundle.bundle_id,
73          &bundle.git_revision,
74          &bundle.target_triple,
75        ]
76        .into_iter()
77        .all(|value| text(value))
78      })
79  }
80}
81
82/// Reads exactly one bounded identity frame, leaving service bytes untouched.
83///
84/// # Errors
85/// Returns I/O or invalid-data errors for incomplete or invalid metadata.
86pub async fn read_identity(reader: &mut (impl AsyncRead + Unpin)) -> io::Result<RemoteIdentity> {
87  let size = reader.read_u32().await? as usize;
88  if size == 0 || size > MAX_IDENTITY_BYTES {
89    return Err(invalid_identity());
90  }
91  let mut bytes = vec![0; size];
92  reader.read_exact(&mut bytes).await?;
93  let identity: RemoteIdentity = serde_json::from_slice(&bytes).map_err(|_| invalid_identity())?;
94  if !identity.is_valid() {
95    return Err(invalid_identity());
96  }
97  Ok(identity)
98}
99
100/// Writes a bounded identity frame after the v3 preface.
101///
102/// # Errors
103/// Returns I/O or invalid-data errors for invalid metadata.
104pub async fn write_identity(
105  writer: &mut (impl AsyncWrite + Unpin),
106  identity: &RemoteIdentity,
107) -> io::Result<()> {
108  if !identity.is_valid() {
109    return Err(invalid_identity());
110  }
111  let bytes = serde_json::to_vec(identity).map_err(|_| invalid_identity())?;
112  if bytes.len() > MAX_IDENTITY_BYTES {
113    return Err(invalid_identity());
114  }
115  writer
116    .write_u32(u32::try_from(bytes.len()).map_err(|_| invalid_identity())?)
117    .await?;
118  writer.write_all(&bytes).await
119}
120
121fn invalid_identity() -> io::Error {
122  io::Error::new(
123    io::ErrorKind::InvalidData,
124    "invalid remote identity metadata",
125  )
126}
127
128#[cfg(test)]
129mod tests {
130  use super::*;
131
132  #[test]
133  fn older_agent_identity_has_unknown_build_metadata() {
134    let identity: RemoteIdentity = serde_json::from_value(serde_json::json!({
135      "remote_id": uuid::Uuid::new_v4().to_string(),
136      "agent_version": "0.1.0"
137    }))
138    .unwrap();
139    assert!(identity.is_valid());
140    assert!(identity.build.is_none());
141  }
142
143  #[test]
144  fn rejects_malformed_or_inconsistent_agent_build() {
145    let mut identity = RemoteIdentity {
146      remote_id: uuid::Uuid::new_v4().to_string(),
147      agent_version: env!("CARGO_PKG_VERSION").into(),
148      build: Some(ctl_component_info::build_info()),
149      ctmux_restart_supported: false,
150      bundle: None,
151    };
152    assert!(identity.is_valid());
153    identity.build.as_mut().unwrap().source_fingerprint = "invalid".into();
154    assert!(!identity.is_valid());
155    identity.build = Some(ctl_component_info::build_info());
156    identity.build.as_mut().unwrap().version = "0.0.0".into();
157    assert!(!identity.is_valid());
158  }
159
160  #[tokio::test]
161  async fn metadata_round_trip_preserves_service_bytes() {
162    let identity = RemoteIdentity {
163      remote_id: uuid::Uuid::new_v4().to_string(),
164      agent_version: "0.1.0".into(),
165      build: None,
166      ctmux_restart_supported: false,
167      bundle: None,
168    };
169    let mut bytes = Vec::new();
170    write_identity(&mut bytes, &identity).await.unwrap();
171    bytes.extend_from_slice(&[0, 255, 27]);
172    let mut reader = bytes.as_slice();
173    assert_eq!(read_identity(&mut reader).await.unwrap(), identity);
174    assert_eq!(reader, &[0, 255, 27]);
175  }
176
177  #[tokio::test]
178  async fn rejects_oversized_truncated_and_invalid_metadata() {
179    for bytes in [
180      u32::MAX.to_be_bytes().to_vec(),
181      vec![0, 0, 0, 10, b'{'],
182      vec![0, 0, 0, 2, b'{', b'}'],
183    ] {
184      assert!(read_identity(&mut bytes.as_slice()).await.is_err());
185    }
186  }
187}