#[cfg(unix)]
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub const CONTROL_SOCKET_EXTENSION: &str = "control.sock";
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum ControlRequest {
Capabilities,
MemoryTarget {
total_mib: u64,
},
MemoryState,
CpuTarget {
online: u32,
},
CpuState,
SecretsUpdate {
changes: Vec<SecretLiveChange>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "change", rename_all = "snake_case")]
pub enum SecretLiveChange {
Rotate {
name: String,
value: SecretValue,
},
Remove {
name: String,
},
SetAllowedHosts {
name: String,
hosts: Vec<String>,
},
}
#[derive(Clone, Serialize, Deserialize, zeroize::ZeroizeOnDrop)]
#[serde(transparent)]
pub struct SecretValue(pub String);
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ControlResponse {
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<MemoryControlState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu: Option<CpuControlState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<ControlCapabilities>,
}
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
pub struct ControlCapabilities {
pub cpu_resize: bool,
pub memory_resize: bool,
pub secrets_update: bool,
}
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
pub struct MemoryControlState {
pub boot_mib: u64,
pub target_mib: u64,
pub current_mib: u64,
pub max_mib: u64,
}
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
pub struct CpuControlState {
pub possible: u32,
pub requested_online: u32,
pub actual_online: u32,
pub enforced: u32,
}
pub struct ControlContext {
pub vm: msb_krun::VmControl,
#[cfg(feature = "net")]
pub secrets: Option<microsandbox_network::secrets::handle::SecretsHandle>,
}
impl ControlContext {
#[cfg(unix)]
fn secrets_update_supported(&self) -> bool {
#[cfg(feature = "net")]
{
self.secrets.is_some()
}
#[cfg(not(feature = "net"))]
{
false
}
}
}
impl std::fmt::Debug for SecretValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[redacted]")
}
}
pub fn control_socket_path_for(agent_sock: &std::path::Path) -> PathBuf {
agent_sock.with_extension(CONTROL_SOCKET_EXTENSION)
}
#[cfg(unix)]
pub fn spawn_control_listener(
socket_path: PathBuf,
context: ControlContext,
) -> std::io::Result<()> {
let _ = std::fs::remove_file(&socket_path);
let listener = std::os::unix::net::UnixListener::bind(&socket_path)?;
std::thread::Builder::new()
.name("msb-control".to_string())
.spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
if let Err(e) = serve_connection(&mut stream, &context) {
tracing::debug!("control: connection error: {e}");
}
}
Err(e) => {
tracing::warn!("control: accept failed, stopping listener: {e}");
break;
}
}
}
})?;
Ok(())
}
#[cfg(unix)]
fn serve_connection(
stream: &mut std::os::unix::net::UnixStream,
context: &ControlContext,
) -> std::io::Result<()> {
let mut line = String::new();
BufReader::new(&mut *stream).read_line(&mut line)?;
let response = match serde_json::from_str::<ControlRequest>(line.trim()) {
Ok(request) => handle_request(request, context),
Err(e) => ControlResponse {
ok: false,
error: Some(format!("invalid control request: {e}")),
..Default::default()
},
};
let mut payload = serde_json::to_vec(&response).unwrap_or_default();
payload.push(b'\n');
stream.write_all(&payload)
}
#[cfg(unix)]
fn handle_request(request: ControlRequest, context: &ControlContext) -> ControlResponse {
let control = &context.vm;
let memory = |state: Option<msb_krun::VmMemoryState>| match state {
Some(state) => ControlResponse {
ok: true,
memory: Some(MemoryControlState {
boot_mib: state.boot_mib,
target_mib: state.target_mib,
current_mib: state.current_mib,
max_mib: state.max_mib,
}),
..Default::default()
},
None => ControlResponse {
ok: false,
error: Some("this VM booted without memory hotplug capacity".to_string()),
..Default::default()
},
};
let cpu = |state: Option<msb_krun::VmCpuState>| match state {
Some(state) => ControlResponse {
ok: true,
cpu: Some(CpuControlState {
possible: state.possible,
requested_online: state.requested_online,
actual_online: state.actual_online,
enforced: state.enforced,
}),
..Default::default()
},
None => ControlResponse {
ok: false,
error: Some("this VM booted without CPU capacity".to_string()),
..Default::default()
},
};
match request {
ControlRequest::Capabilities => ControlResponse {
ok: true,
capabilities: Some(ControlCapabilities {
cpu_resize: control.cpu_resize_supported(),
memory_resize: control.memory_resize_supported(),
secrets_update: context.secrets_update_supported(),
}),
..Default::default()
},
ControlRequest::MemoryTarget { total_mib } => {
if control.set_memory_target_mib(total_mib).is_none() {
return memory(None);
}
memory(control.memory_state())
}
ControlRequest::MemoryState => memory(control.memory_state()),
ControlRequest::CpuTarget { online } => {
if control.set_cpu_target(online).is_none() {
return cpu(None);
}
cpu(control.cpu_state())
}
ControlRequest::CpuState => cpu(control.cpu_state()),
ControlRequest::SecretsUpdate { changes } => handle_secrets_update(context, changes),
}
}
#[cfg(all(unix, feature = "net"))]
fn handle_secrets_update(
context: &ControlContext,
changes: Vec<SecretLiveChange>,
) -> ControlResponse {
let Some(secrets) = &context.secrets else {
return ControlResponse {
ok: false,
error: Some(
"live secret reconfiguration is not available for this sandbox".to_string(),
),
..Default::default()
};
};
for change in changes {
let result = match change {
SecretLiveChange::Rotate { name, value } => {
secrets.rotate_value(&name, value.0.clone())
}
SecretLiveChange::Remove { name } => {
secrets.remove(&name);
Ok(())
}
SecretLiveChange::SetAllowedHosts { name, hosts } => {
secrets.set_allowed_hosts(&name, &hosts)
}
};
if let Err(e) = result {
return ControlResponse {
ok: false,
error: Some(e.to_string()),
..Default::default()
};
}
}
ControlResponse {
ok: true,
..Default::default()
}
}
#[cfg(all(unix, not(feature = "net")))]
fn handle_secrets_update(
_context: &ControlContext,
_changes: Vec<SecretLiveChange>,
) -> ControlResponse {
ControlResponse {
ok: false,
error: Some("this runtime was built without network support".to_string()),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_value_debug_is_redacted() {
let request = ControlRequest::SecretsUpdate {
changes: vec![SecretLiveChange::Rotate {
name: "API_KEY".into(),
value: SecretValue("sentinel-secret-value".into()),
}],
};
let debug = format!("{request:?}");
assert!(!debug.contains("sentinel-secret-value"));
assert!(debug.contains("[redacted]"));
assert!(debug.contains("API_KEY"));
}
#[test]
fn secrets_update_round_trips_through_json() {
let request = ControlRequest::SecretsUpdate {
changes: vec![
SecretLiveChange::Rotate {
name: "API_KEY".into(),
value: SecretValue("new-material".into()),
},
SecretLiveChange::Remove {
name: "OLD_KEY".into(),
},
SecretLiveChange::SetAllowedHosts {
name: "API_KEY".into(),
hosts: vec!["api.example.com".into(), "*".into()],
},
],
};
let json = serde_json::to_string(&request).unwrap();
let parsed: ControlRequest = serde_json::from_str(&json).unwrap();
let ControlRequest::SecretsUpdate { changes } = parsed else {
panic!("expected secrets_update");
};
assert_eq!(changes.len(), 3);
let SecretLiveChange::Rotate { name, value } = &changes[0] else {
panic!("expected rotate");
};
assert_eq!(name, "API_KEY");
assert_eq!(value.0, "new-material");
}
#[test]
fn capabilities_response_serializes_flags() {
let response = ControlResponse {
ok: true,
capabilities: Some(ControlCapabilities {
cpu_resize: true,
memory_resize: false,
secrets_update: true,
}),
..Default::default()
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"secrets_update\":true"));
assert!(json.contains("\"memory_resize\":false"));
let parsed: ControlResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.capabilities.unwrap().secrets_update);
}
#[test]
fn legacy_responses_without_capabilities_still_parse() {
let parsed: ControlResponse = serde_json::from_str(r#"{"ok":true}"#).unwrap();
assert!(parsed.ok);
assert!(parsed.capabilities.is_none());
}
}