Skip to main content

a3s_box_runtime/local_execution/
port.rs

1use 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            // The lifecycle may have advanced while the blocking connect was in
80            // flight. Re-read the canonical record before publishing the stream.
81            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;