a3s_box_runtime/local_execution/
port.rs1use std::num::NonZeroU16;
2#[cfg(target_os = "linux")]
3use std::path::Path;
4use std::time::Duration;
5
6#[cfg(target_os = "linux")]
7use a3s_box_core::ExecutionBackend;
8use a3s_box_core::{
9 ExecutionGeneration, ExecutionId, ExecutionManagerError, ExecutionManagerResult,
10 ExecutionPortConnector, ExecutionPortStream,
11};
12use async_trait::async_trait;
13#[cfg(target_os = "linux")]
14use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
15#[cfg(target_os = "linux")]
16use tokio::net::UnixStream;
17
18use super::LocalExecutionManager;
19#[cfg(target_os = "linux")]
20use crate::BoxRecord;
21
22#[cfg(target_os = "linux")]
23const PORT_FORWARD_STREAM_ID: u32 = 1;
24#[cfg(target_os = "linux")]
25const PORT_FORWARD_FRAME_OPEN: u8 = 1;
26#[cfg(target_os = "linux")]
27const PORT_FORWARD_FRAME_OPEN_ACK: u8 = 2;
28#[cfg(target_os = "linux")]
29const PORT_FORWARD_FRAME_DATA: u8 = 3;
30#[cfg(target_os = "linux")]
31const PORT_FORWARD_FRAME_CLOSE: u8 = 4;
32#[cfg(target_os = "linux")]
33const PORT_FORWARD_BUFFER_BYTES: usize = 16 * 1024;
34#[cfg(target_os = "linux")]
35const PORT_FORWARD_MAX_FRAME_BYTES: usize = 64 * 1024;
36
37#[async_trait]
38impl ExecutionPortConnector for LocalExecutionManager {
39 async fn connect_port(
40 &self,
41 execution_id: &ExecutionId,
42 generation: ExecutionGeneration,
43 port: NonZeroU16,
44 timeout: Duration,
45 ) -> ExecutionManagerResult<ExecutionPortStream> {
46 if timeout.is_zero() {
47 return Err(ExecutionManagerError::InvalidRequest(
48 "port connection timeout must be non-zero".to_string(),
49 ));
50 }
51
52 #[cfg(target_os = "linux")]
53 {
54 let (record, backend) = self.require_connectable(execution_id, generation).await?;
55 let pid = record
56 .pid
57 .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?;
58 let pid_start_time = record.pid_start_time;
59 if !crate::process::is_process_alive_with_identity(pid, pid_start_time) {
60 return Err(ExecutionManagerError::NotFound(execution_id.clone()));
61 }
62
63 let stream: ExecutionPortStream = if backend.is_sandbox() {
64 Box::pin(
65 connect_in_network_namespace(
66 execution_id.clone(),
67 pid,
68 pid_start_time,
69 port,
70 timeout,
71 )
72 .await?,
73 )
74 } else {
75 let socket_path = record.exec_socket_path.with_file_name("portfwd.sock");
76 connect_microvm_port(execution_id, &socket_path, port, timeout).await?
77 };
78
79 let (current, current_backend) =
82 self.require_connectable(execution_id, generation).await?;
83 if current.pid != Some(pid)
84 || current.pid_start_time != pid_start_time
85 || current_backend != backend
86 || current.exec_socket_path != record.exec_socket_path
87 || !crate::process::is_process_alive_with_identity(pid, pid_start_time)
88 {
89 return Err(ExecutionManagerError::Conflict {
90 execution_id: execution_id.clone(),
91 message: "runtime generation changed while connecting its data plane"
92 .to_string(),
93 });
94 }
95 return Ok(stream);
96 }
97
98 #[cfg(not(target_os = "linux"))]
99 {
100 let _ = (execution_id, generation, port, timeout);
101 Err(ExecutionManagerError::Unavailable(
102 "Sandbox port connections require Linux network namespaces".to_string(),
103 ))
104 }
105 }
106}
107
108#[cfg(target_os = "linux")]
109impl LocalExecutionManager {
110 async fn require_connectable(
111 &self,
112 execution_id: &ExecutionId,
113 generation: ExecutionGeneration,
114 ) -> ExecutionManagerResult<(BoxRecord, ExecutionBackend)> {
115 let record = self
116 .require_running_record(execution_id, generation)
117 .await?;
118 let backend = record
119 .managed_execution
120 .as_ref()
121 .map(|metadata| metadata.plan.backend)
122 .ok_or_else(|| {
123 ExecutionManagerError::Internal(format!(
124 "execution {execution_id} has no managed execution plan"
125 ))
126 })?;
127 Ok((record, backend))
128 }
129}
130
131#[cfg(target_os = "linux")]
132#[derive(Debug)]
133struct PortForwardFrame {
134 kind: u8,
135 stream_id: u32,
136 payload: Vec<u8>,
137}
138
139#[cfg(target_os = "linux")]
140async fn connect_microvm_port(
141 execution_id: &ExecutionId,
142 socket_path: &Path,
143 port: NonZeroU16,
144 timeout: Duration,
145) -> ExecutionManagerResult<ExecutionPortStream> {
146 let connect = async {
147 let mut control = UnixStream::connect(socket_path).await.map_err(|error| {
148 ExecutionManagerError::Unavailable(format!(
149 "failed to connect to MicroVM port channel for {execution_id}: {error}"
150 ))
151 })?;
152 write_port_forward_frame(
153 &mut control,
154 PORT_FORWARD_FRAME_OPEN,
155 PORT_FORWARD_STREAM_ID,
156 &port.get().to_be_bytes(),
157 )
158 .await
159 .map_err(|error| {
160 ExecutionManagerError::Unavailable(format!(
161 "failed to request MicroVM port {} for {execution_id}: {error}",
162 port.get()
163 ))
164 })?;
165 let acknowledgement = read_port_forward_frame(&mut control)
166 .await
167 .map_err(|error| {
168 ExecutionManagerError::Unavailable(format!(
169 "failed to open MicroVM port {} for {execution_id}: {error}",
170 port.get()
171 ))
172 })?;
173 let accepted = acknowledgement.as_ref().is_some_and(|frame| {
174 frame.kind == PORT_FORWARD_FRAME_OPEN_ACK
175 && frame.stream_id == PORT_FORWARD_STREAM_ID
176 && frame.payload.as_slice() == [0]
177 });
178 if !accepted {
179 return Err(ExecutionManagerError::Unavailable(format!(
180 "MicroVM port {} rejected the connection for {execution_id}",
181 port.get()
182 )));
183 }
184
185 let (application, relay) = tokio::io::duplex(PORT_FORWARD_MAX_FRAME_BYTES);
186 let relay_execution_id = execution_id.clone();
187 tokio::spawn(async move {
188 if let Err(error) = relay_microvm_port(control, relay).await {
189 tracing::warn!(
190 execution_id = %relay_execution_id,
191 guest_port = port.get(),
192 error = %error,
193 "MicroVM port relay failed"
194 );
195 }
196 });
197 Ok(Box::pin(application) as ExecutionPortStream)
198 };
199
200 tokio::time::timeout(timeout, connect).await.map_err(|_| {
201 ExecutionManagerError::Unavailable(format!(
202 "timed out connecting MicroVM port {} for {execution_id}",
203 port.get()
204 ))
205 })?
206}
207
208#[cfg(target_os = "linux")]
209async fn relay_microvm_port(
210 control: UnixStream,
211 relay: tokio::io::DuplexStream,
212) -> std::io::Result<()> {
213 let (mut control_read, mut control_write) = control.into_split();
214 let (mut relay_read, mut relay_write) = tokio::io::split(relay);
215 let mut buffer = [0_u8; PORT_FORWARD_BUFFER_BYTES];
216
217 loop {
218 tokio::select! {
219 read = relay_read.read(&mut buffer) => match read? {
220 0 => {
221 write_port_forward_frame(
222 &mut control_write,
223 PORT_FORWARD_FRAME_CLOSE,
224 PORT_FORWARD_STREAM_ID,
225 &[],
226 )
227 .await?;
228 return Ok(());
229 }
230 count => {
231 write_port_forward_frame(
232 &mut control_write,
233 PORT_FORWARD_FRAME_DATA,
234 PORT_FORWARD_STREAM_ID,
235 &buffer[..count],
236 )
237 .await?;
238 }
239 },
240 frame = read_port_forward_frame(&mut control_read) => {
241 let Some(frame) = frame? else {
242 relay_write.shutdown().await?;
243 return Ok(());
244 };
245 if frame.stream_id != PORT_FORWARD_STREAM_ID {
246 return Err(std::io::Error::new(
247 std::io::ErrorKind::InvalidData,
248 "MicroVM port channel returned an unexpected stream ID",
249 ));
250 }
251 match frame.kind {
252 PORT_FORWARD_FRAME_DATA => relay_write.write_all(&frame.payload).await?,
253 PORT_FORWARD_FRAME_CLOSE => {
254 relay_write.shutdown().await?;
255 return Ok(());
256 }
257 _ => {
258 return Err(std::io::Error::new(
259 std::io::ErrorKind::InvalidData,
260 "MicroVM port channel returned an unexpected frame",
261 ));
262 }
263 }
264 }
265 }
266 }
267}
268
269#[cfg(target_os = "linux")]
270async fn write_port_forward_frame<W>(
271 stream: &mut W,
272 kind: u8,
273 stream_id: u32,
274 payload: &[u8],
275) -> std::io::Result<()>
276where
277 W: AsyncWrite + Unpin,
278{
279 let payload_len = u32::try_from(payload.len()).map_err(|_| {
280 std::io::Error::new(
281 std::io::ErrorKind::InvalidInput,
282 "MicroVM port frame payload exceeds u32",
283 )
284 })?;
285 stream.write_all(&[kind]).await?;
286 stream.write_all(&stream_id.to_be_bytes()).await?;
287 stream.write_all(&payload_len.to_be_bytes()).await?;
288 stream.write_all(payload).await?;
289 stream.flush().await
290}
291
292#[cfg(target_os = "linux")]
293async fn read_port_forward_frame<R>(stream: &mut R) -> std::io::Result<Option<PortForwardFrame>>
294where
295 R: AsyncRead + Unpin,
296{
297 let mut header = [0_u8; 9];
298 match stream.read_exact(&mut header).await {
299 Ok(_) => {}
300 Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
301 Err(error) => return Err(error),
302 }
303 let payload_len = u32::from_be_bytes([header[5], header[6], header[7], header[8]]) as usize;
304 if payload_len > PORT_FORWARD_MAX_FRAME_BYTES {
305 return Err(std::io::Error::new(
306 std::io::ErrorKind::InvalidData,
307 "MicroVM port frame exceeds the bounded payload limit",
308 ));
309 }
310 let mut payload = vec![0_u8; payload_len];
311 stream.read_exact(&mut payload).await?;
312 Ok(Some(PortForwardFrame {
313 kind: header[0],
314 stream_id: u32::from_be_bytes([header[1], header[2], header[3], header[4]]),
315 payload,
316 }))
317}
318
319#[cfg(target_os = "linux")]
320async fn connect_in_network_namespace(
321 execution_id: ExecutionId,
322 pid: u32,
323 pid_start_time: Option<u64>,
324 port: NonZeroU16,
325 timeout: Duration,
326) -> ExecutionManagerResult<tokio::net::TcpStream> {
327 let (sender, receiver) = tokio::sync::oneshot::channel();
328 std::thread::Builder::new()
329 .name(format!("a3s-port-{pid}-{}", port.get()))
330 .spawn(move || {
331 let result = connect_in_network_namespace_blocking(
332 &execution_id,
333 pid,
334 pid_start_time,
335 port,
336 timeout,
337 );
338 let _ = sender.send(result);
339 })
340 .map_err(|error| {
341 ExecutionManagerError::Unavailable(format!(
342 "failed to start Sandbox port connector: {error}"
343 ))
344 })?;
345
346 let stream = receiver.await.map_err(|_| {
347 ExecutionManagerError::Internal(
348 "Sandbox port connector exited without a result".to_string(),
349 )
350 })??;
351 tokio::net::TcpStream::from_std(stream).map_err(|error| {
352 ExecutionManagerError::Unavailable(format!(
353 "failed to register Sandbox port stream with Tokio: {error}"
354 ))
355 })
356}
357
358#[cfg(target_os = "linux")]
359fn connect_in_network_namespace_blocking(
360 execution_id: &ExecutionId,
361 pid: u32,
362 pid_start_time: Option<u64>,
363 port: NonZeroU16,
364 timeout: Duration,
365) -> ExecutionManagerResult<std::net::TcpStream> {
366 use std::fs::File;
367 use std::os::fd::AsRawFd;
368
369 if !crate::process::is_process_alive_with_identity(pid, pid_start_time) {
370 return Err(ExecutionManagerError::NotFound(execution_id.clone()));
371 }
372 let namespace_path = format!("/proc/{pid}/ns/net");
373 let namespace = File::open(&namespace_path).map_err(|error| {
374 ExecutionManagerError::Unavailable(format!(
375 "failed to open Sandbox network namespace {namespace_path}: {error}"
376 ))
377 })?;
378 let result = unsafe { libc::setns(namespace.as_raw_fd(), libc::CLONE_NEWNET) };
379 if result != 0 {
380 return Err(ExecutionManagerError::Unavailable(format!(
381 "failed to enter Sandbox network namespace for PID {pid}: {}",
382 std::io::Error::last_os_error()
383 )));
384 }
385 if !crate::process::is_process_alive_with_identity(pid, pid_start_time) {
386 return Err(ExecutionManagerError::Unavailable(
387 "Sandbox runtime exited while entering its network namespace".to_string(),
388 ));
389 }
390
391 let address = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port.get()));
392 let stream = std::net::TcpStream::connect_timeout(&address, timeout).map_err(|error| {
393 ExecutionManagerError::Unavailable(format!(
394 "failed to connect to Sandbox loopback port {}: {error}",
395 port.get()
396 ))
397 })?;
398 stream.set_nonblocking(true).map_err(|error| {
399 ExecutionManagerError::Unavailable(format!(
400 "failed to configure Sandbox port stream: {error}"
401 ))
402 })?;
403 Ok(stream)
404}
405
406#[cfg(all(test, target_os = "linux"))]
407#[path = "port_tests.rs"]
408mod tests;