1pub 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#[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#[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
82pub 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
100pub 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}