Skip to main content

arcbox_vm_proto/
exec.rs

1//! Exec channel: the guest vsock port the agent serves exec/run sessions
2//! and control commands on, plus the readiness dial-out port.
3//!
4//! ## Frame format
5//!
6//! Every message (in both directions) is:
7//!
8//! ```text
9//! [u8: msg_type][u32 LE: payload_len][payload_len bytes: payload]
10//! ```
11//!
12//! | Type | Direction   | Payload                                    |
13//! |------|-------------|--------------------------------------------|
14//! | 0x01 | Host→Agent  | JSON-encoded [`StartCommand`]              |
15//! | 0x02 | Host→Agent  | raw stdin bytes                            |
16//! | 0x03 | Host→Agent  | `[u16 LE width][u16 LE height]`            |
17//! | 0x04 | Host→Agent  | empty — signals stdin EOF                  |
18//! | 0x05 | Host→Agent  | `[i64 LE secs][u32 LE nanos]` — clock sync |
19//! | 0x06 | Host→Agent  | JSON `NetReconfigCommand` — re-address eth0 |
20//! | 0x07 | Host→Agent  | `[i32 LE signal]` — deliver to workload    |
21//! | 0x08 | Host→Agent  | JSON [`WaitPortReq`] — wait for a listener |
22//! | 0x10 | Agent→Host  | raw stdout bytes                           |
23//! | 0x11 | Agent→Host  | raw stderr bytes                           |
24//! | 0x12 | Agent→Host  | `[i32 LE code][i32 LE signal]` (signal 0 = normal exit; old agents send only the 4-byte code). Net-reconfig replies append six `u32 LE` micros. Readers key on payload length. |
25//!
26//! Agents ignore frame types they do not know, so a new host→agent opcode
27//! is a silent no-op against an agent from before it.
28
29use std::collections::HashMap;
30
31use serde::{Deserialize, Serialize};
32
33/// Host-side port the guest agent dials once it is fully serving.
34///
35/// Firecracker hybrid vsock forwards a guest-initiated connect to host port
36/// `P` onto the host Unix socket at `{uds_path}_{P}`, so a pre-bound
37/// listener's `accept()` IS the "vm-agent is up" event — no connect
38/// polling involved.
39pub const READY_PORT: u32 = 51;
40
41/// Guest-side vsock port the agent listens on (exec channel).
42pub const AGENT_PORT: u32 = 52;
43
44/// Start a session. Payload: JSON [`StartCommand`].
45pub const MSG_START: u8 = 0x01;
46/// Raw stdin bytes for the session's process.
47pub const MSG_STDIN: u8 = 0x02;
48/// Resize the session's pseudo-TTY. Payload: `[u16 LE width][u16 LE height]`.
49pub const MSG_RESIZE: u8 = 0x03;
50/// Signal EOF on the process's stdin. Empty payload.
51pub const MSG_EOF: u8 = 0x04;
52/// Synchronise the guest clock to the host (after snapshot restore, and as
53/// the cold-boot agent-readiness gate).
54/// Payload: `[i64 LE unix_seconds][u32 LE nanos]` (12 bytes).
55pub const MSG_CLOCK_SYNC: u8 = 0x05;
56/// Re-address the guest network after a fresh-network snapshot restore.
57/// Payload: JSON [`NetReconfigCommand`](crate::boot::NetReconfigCommand).
58pub const MSG_NET_RECONFIG: u8 = 0x06;
59/// Deliver a POSIX signal to the workload's process group.
60/// Payload: `[i32 LE signal]` (4 bytes).
61pub const MSG_SIGNAL: u8 = 0x07;
62/// Wait until the guest's TCP listen table has a listener on a port.
63/// Payload: JSON [`WaitPortReq`]; answered with [`MSG_EXIT`] carrying `0`
64/// (listening) or `1` (deadline elapsed).
65pub const MSG_WAIT_PORT: u8 = 0x08;
66
67/// Raw stdout bytes from the session's process (the merged PTY stream for
68/// `tty` sessions).
69pub const MSG_STDOUT: u8 = 0x10;
70/// Raw stderr bytes from the session's process (never sent for `tty`
71/// sessions).
72pub const MSG_STDERR: u8 = 0x11;
73/// The process terminated, or a control command completed.
74/// Payload: `[i32 LE code][i32 LE signal]`; see the module table for the
75/// legacy 4-byte form and the net-reconfig timing suffix.
76pub const MSG_EXIT: u8 = 0x12;
77
78/// Maximum allowed frame payload size (16 MiB) on every channel.
79pub const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
80
81/// Parameters forwarded to the guest agent as the session-start frame.
82///
83/// The host always serializes every field; the defaults exist for the
84/// decoder, so an agent reads a start frame from any host generation.
85#[derive(Debug, Serialize, Deserialize)]
86pub struct StartCommand {
87    /// Program and arguments (`argv`), never empty.
88    pub cmd: Vec<String>,
89    /// Environment for the process, replacing the agent's own.
90    #[serde(default)]
91    pub env: HashMap<String, String>,
92    /// Working directory; empty leaves the agent's own cwd in place.
93    #[serde(default)]
94    pub working_dir: String,
95    /// Docker-style user spec (`uid`, `uid:gid`, `name`, `name:group`)
96    /// resolved against the sandbox rootfs; empty runs as the agent's own
97    /// user (root inside the microVM).
98    #[serde(default)]
99    pub user: String,
100    /// Allocate a pseudo-TTY and merge stdout/stderr into it.
101    #[serde(default)]
102    pub tty: bool,
103    /// Initial PTY width in columns.
104    #[serde(default = "default_tty_width")]
105    pub tty_width: u16,
106    /// Initial PTY height in rows.
107    #[serde(default = "default_tty_height")]
108    pub tty_height: u16,
109    /// Kill the process after this many seconds; `0` means no timeout.
110    #[serde(default)]
111    pub timeout_seconds: u32,
112}
113
114const fn default_tty_width() -> u16 {
115    80
116}
117
118const fn default_tty_height() -> u16 {
119    24
120}
121
122/// [`MSG_WAIT_PORT`] payload.
123#[derive(Debug, Serialize, Deserialize)]
124pub struct WaitPortReq {
125    /// TCP port a workload is expected to listen on.
126    pub port: u16,
127    /// Give up after this long (0 = check once and answer immediately).
128    pub timeout_ms: u64,
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn start_command_decodes_with_only_cmd() {
137        // Old hosts (inside restored snapshots the roles invert: a new host
138        // talks to an old agent, but the decoder tolerance is the same
139        // contract) may omit every field but `cmd`.
140        let cmd: StartCommand = serde_json::from_str(r#"{"cmd":["sh"]}"#).unwrap();
141        assert_eq!(cmd.cmd, ["sh"]);
142        assert!(cmd.env.is_empty());
143        assert_eq!(cmd.working_dir, "");
144        assert_eq!(cmd.user, "");
145        assert!(!cmd.tty);
146        assert_eq!((cmd.tty_width, cmd.tty_height), (80, 24));
147        assert_eq!(cmd.timeout_seconds, 0);
148    }
149
150    #[test]
151    fn start_command_round_trips_every_field() {
152        let cmd = StartCommand {
153            cmd: vec!["env".into()],
154            env: HashMap::from([("K".to_owned(), "v".to_owned())]),
155            working_dir: "/w".into(),
156            user: "1000:1000".into(),
157            tty: true,
158            tty_width: 120,
159            tty_height: 40,
160            timeout_seconds: 7,
161        };
162        let json = serde_json::to_string(&cmd).unwrap();
163        let back: StartCommand = serde_json::from_str(&json).unwrap();
164        assert_eq!(back.cmd, cmd.cmd);
165        assert_eq!(back.env, cmd.env);
166        assert_eq!(back.working_dir, cmd.working_dir);
167        assert_eq!(back.user, cmd.user);
168        assert_eq!(back.tty, cmd.tty);
169        assert_eq!((back.tty_width, back.tty_height), (120, 40));
170        assert_eq!(back.timeout_seconds, 7);
171    }
172}