diff --git a/CLAUDE.md b/CLAUDE.md index 6e21bc8..ea956c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,7 @@ See README.md for full usage details and options. - **Per-VM runtime dirs**: all sockets under `/run/vmsilo//` subdirectories (not flat). virtiofs instances get per-instance dirs at `/run/vmsilo//virtiofs-/`. - **USB passthrough**: usbip-over-vsock on port 5002. Guest runs `usbip-rs client listen`, host runs one `usbip-rs host connect` per device as `vmsilo--usb@.service`. Works with both crosvm and cloud-hypervisor. - **CID file**: `/run/vmsilo//cid` written by prep service, read by `vsock-connect` and dbus-proxy for autodetection. +- **Sound control socket**: `/run/vmsilo//sound/control.socket` — written by vhost-device-sound when sound is enabled; used by the device tray to query and toggle playback/capture state at runtime. Initial state comes from `sound.playback`/`sound.capture` NixOS options (passed as `--initial-streams`). - **CH sandboxing**: CH VMs use NixOS confinement (chroot), PrivateUsers=identity, PrivateNetwork, PrivatePIDs, PrivateIPC, empty CapabilityBoundingSet. TAP FDs passed via `vmsilo-tap-open` + `ch-remote add-net`. All privileged operations in ExecStartPre=+/ExecStartPost=+/ExecStopPost=+. Gated by `cloud-hypervisor.disableSandbox`. - **virtiofsd sandboxing**: virtiofsd has built-in sandboxing (`--sandbox=namespace`): creates mount/PID/network namespaces, does pivot_root, drops capabilities, and applies its own seccomp filter. The systemd unit adds non-overlapping hardening: IPC/UTS namespace isolation, seccomp-based protections (clock/modules/logs/personality), capability bounding set (as defense-in-depth), and `LimitNOFILE=1048576`. Per-instance runtime dirs at `/run/vmsilo//virtiofs-/`. Gated by `virtiofs.disableSandbox`; seccomp controlled independently by `virtiofs.seccompPolicy`. diff --git a/README.md b/README.md index 955b871..7e280af 100644 --- a/README.md +++ b/README.md @@ -431,6 +431,12 @@ sound.logLevel = "debug"; # Set RUST_LOG for the sound service sound.seccompPolicy = "log"; # Log blocked syscalls instead of enforcing ``` +#### Runtime Sound Control + +The device tray shows **Sound Output** and **Microphone Input** top-level menu items for running VMs that have sound enabled. Each submenu lists running VMs; a checkmark indicates that direction is currently enabled. Clicking a VM name toggles it on or off. + +The `sound.playback` and `sound.capture` options set the initial enabled state when the VM starts (`playback` defaults to `true`, `capture` to `false`). The tray communicates with vhost-device-sound via a control socket at `/run/vmsilo//sound/control.socket`. VMs without an active control socket (e.g., sound not enabled, or service not yet started) are omitted from the sound menus. + ### PCI Passthrough Configuration ```nix diff --git a/flake.lock b/flake.lock index 9f29421..256ba07 100644 --- a/flake.lock +++ b/flake.lock @@ -252,11 +252,11 @@ ] }, "locked": { - "lastModified": 1774221602, - "narHash": "sha256-gK8iH1/foADfROW5kYXWo4fQgMJKYcO2oJtZW5WkYJ4=", + "lastModified": 1774650636, + "narHash": "sha256-OmeRy1jlilcoddZDnzccY1giYtfsjJi8Nijuompje9g=", "ref": "refs/heads/main", - "rev": "1460f1be1b80dac79856e5403b8f36e6a9cbeca1", - "revCount": 1389, + "rev": "dac004f86b9113e0699d8aadf2942ee10cce3466", + "revCount": 1391, "type": "git", "url": "https://git.dsg.is/dsg/vhost-device.git" }, diff --git a/modules/services.nix b/modules/services.nix index bb66f39..2699ed5 100644 --- a/modules/services.nix +++ b/modules/services.nix @@ -389,13 +389,15 @@ let vm: let soundEnabled = vm.sound.playback || vm.sound.capture; - streams = + initialStreams = if vm.sound.playback && vm.sound.capture then - "input,output" + "output,input" else if vm.sound.playback then "output" + else if vm.sound.capture then + "input" else - "input"; + ""; in lib.optional soundEnabled ( lib.nameValuePair "vmsilo-${vm.name}-sound" { @@ -411,13 +413,16 @@ let }; serviceConfig = { Type = "simple"; - ExecStart = lib.concatStringsSep " " [ - "${cfg._internal.vhost-device-sound}/bin/vhost-device-sound" - "--socket /run/vmsilo/${vm.name}/sound/sound.socket" - "--backend pipewire" - "--streams ${streams}" - ]; - ExecStopPost = "-${pkgs.coreutils}/bin/rm -f /run/vmsilo/${vm.name}/sound/sound.socket"; + ExecStart = lib.concatStringsSep " " ( + [ + "${cfg._internal.vhost-device-sound}/bin/vhost-device-sound" + "--socket /run/vmsilo/${vm.name}/sound/sound.socket" + "--backend pipewire" + "--control-socket /run/vmsilo/${vm.name}/sound/control.socket" + ] + ++ lib.optional (initialStreams != "") "--initial-streams ${initialStreams}" + ); + ExecStopPost = "-${pkgs.coreutils}/bin/rm -f /run/vmsilo/${vm.name}/sound/sound.socket /run/vmsilo/${vm.name}/sound/control.socket"; User = cfg.user; Environment = [ "XDG_RUNTIME_DIR=/run/user/${toString userUid}" diff --git a/vmsilo-device-tray/icons/microphone.svg b/vmsilo-device-tray/icons/microphone.svg new file mode 100644 index 0000000..70b5a3b --- /dev/null +++ b/vmsilo-device-tray/icons/microphone.svg @@ -0,0 +1,3 @@ + + + diff --git a/vmsilo-device-tray/icons/rendered/microphone.png b/vmsilo-device-tray/icons/rendered/microphone.png new file mode 100644 index 0000000..a7116ba Binary files /dev/null and b/vmsilo-device-tray/icons/rendered/microphone.png differ diff --git a/vmsilo-device-tray/icons/rendered/speaker.png b/vmsilo-device-tray/icons/rendered/speaker.png new file mode 100644 index 0000000..bdbe69b Binary files /dev/null and b/vmsilo-device-tray/icons/rendered/speaker.png differ diff --git a/vmsilo-device-tray/icons/speaker.svg b/vmsilo-device-tray/icons/speaker.svg new file mode 100644 index 0000000..9dec534 --- /dev/null +++ b/vmsilo-device-tray/icons/speaker.svg @@ -0,0 +1,3 @@ + + + diff --git a/vmsilo-device-tray/package.nix b/vmsilo-device-tray/package.nix index 3d4bedf..420ca17 100644 --- a/vmsilo-device-tray/package.nix +++ b/vmsilo-device-tray/package.nix @@ -29,6 +29,18 @@ let resvg --width "$size" --height "$size" /tmp/cable_colored.svg "$output" } + render_speaker() { + local color="$1" size="$2" output="$3" + sed "s| /tmp/speaker_colored.svg + resvg --width "$size" --height "$size" /tmp/speaker_colored.svg "$output" + } + + render_microphone() { + local color="$1" size="$2" output="$3" + sed "s| /tmp/mic_colored.svg + resvg --width "$size" --height "$size" /tmp/mic_colored.svg "$output" + } + render_usb "$GREEN" 16 "$out/rendered/usb-attached.png" render_usb "$BREEZE_DARK" 16 "$out/rendered/usb-unattached.png" @@ -38,6 +50,9 @@ let render_cable "$BREEZE_DARK" "$size" "$out/rendered/tray-icon-dark-$size.png" render_cable "$BREEZE_LIGHT" "$size" "$out/rendered/tray-icon-light-$size.png" done + + render_speaker "$BREEZE_DARK" 16 "$out/rendered/speaker.png" + render_microphone "$BREEZE_DARK" 16 "$out/rendered/microphone.png" ''; in pkgs.rustPlatform.buildRustPackage { diff --git a/vmsilo-device-tray/src/icons.rs b/vmsilo-device-tray/src/icons.rs index 218b7c3..0c8a195 100644 --- a/vmsilo-device-tray/src/icons.rs +++ b/vmsilo-device-tray/src/icons.rs @@ -7,6 +7,12 @@ pub const USB_ATTACHED_PNG: &[u8] = include_bytes!("../icons/rendered/usb-attach /// PNG bytes for the gray USB icon (unattached device), used in menu item icon_data. pub const USB_UNATTACHED_PNG: &[u8] = include_bytes!("../icons/rendered/usb-unattached.png"); +/// PNG bytes for the speaker icon (sound output menu item). +pub const SPEAKER_PNG: &[u8] = include_bytes!("../icons/rendered/speaker.png"); + +/// PNG bytes for the microphone icon (microphone input menu item). +pub const MICROPHONE_PNG: &[u8] = include_bytes!("../icons/rendered/microphone.png"); + // Tray icon pixmaps: light icons (for dark panels) and dark icons (for light panels) const TRAY_DARK_16: &[u8] = include_bytes!("../icons/rendered/tray-icon-dark-16.png"); const TRAY_DARK_22: &[u8] = include_bytes!("../icons/rendered/tray-icon-dark-22.png"); diff --git a/vmsilo-device-tray/src/main.rs b/vmsilo-device-tray/src/main.rs index 547342c..313aa5c 100644 --- a/vmsilo-device-tray/src/main.rs +++ b/vmsilo-device-tray/src/main.rs @@ -1,6 +1,7 @@ mod icons; mod systemd; mod tray; +mod sound; mod usb; use ksni::TrayMethods; diff --git a/vmsilo-device-tray/src/sound.rs b/vmsilo-device-tray/src/sound.rs new file mode 100644 index 0000000..d8aa280 --- /dev/null +++ b/vmsilo-device-tray/src/sound.rs @@ -0,0 +1,110 @@ +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::Duration; + +/// Sound state for a single VM. +#[derive(Debug, Default)] +pub struct VmSoundState { + pub output_enabled: bool, + pub input_enabled: bool, +} + +/// Query the sound state of all running VMs. +/// Returns a map from VM name to its sound state. +/// VMs whose control socket is missing or unreachable are silently skipped. +pub fn query_all_vms(vm_names: &[String]) -> HashMap { + let mut result = HashMap::new(); + for name in vm_names { + let path = control_socket_path(name); + if let Some(state) = query_vm(&path) { + result.insert(name.clone(), state); + } + } + result +} + +/// Set a direction for a single VM. Returns true on success. +pub fn set_direction(vm_name: &str, direction: &str, enabled: bool) -> bool { + let path = control_socket_path(vm_name); + let value = if enabled { "on" } else { "off" }; + let command = format!("SET {}={}\n", direction, value); + send_command(&path, &command) + .map(|r| r.trim() == "OK") + .unwrap_or(false) +} + +fn control_socket_path(vm_name: &str) -> PathBuf { + PathBuf::from(format!("/run/vmsilo/{}/sound/control.socket", vm_name)) +} + +fn query_vm(path: &PathBuf) -> Option { + let response = send_command(path, "QUERY\n").ok()?; + parse_query_response(&response) +} + +fn send_command(path: &PathBuf, command: &str) -> Result { + let mut stream = UnixStream::connect(path)?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.set_write_timeout(Some(Duration::from_secs(2)))?; + stream.write_all(command.as_bytes())?; + stream.shutdown(std::net::Shutdown::Write)?; + let mut response = String::new(); + BufReader::new(&mut stream).read_line(&mut response)?; + Ok(response) +} + +fn parse_query_response(response: &str) -> Option { + let mut state = VmSoundState::default(); + for pair in response.trim().split(',') { + if let Some(value) = pair.strip_prefix("OUTPUT=") { + state.output_enabled = value == "on"; + } else if let Some(value) = pair.strip_prefix("INPUT=") { + state.input_enabled = value == "on"; + } else { + return None; + } + } + Some(state) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_query_both_on() { + let state = parse_query_response("OUTPUT=on,INPUT=on\n").unwrap(); + assert!(state.output_enabled); + assert!(state.input_enabled); + } + + #[test] + fn test_parse_query_mixed() { + let state = parse_query_response("OUTPUT=on,INPUT=off\n").unwrap(); + assert!(state.output_enabled); + assert!(!state.input_enabled); + } + + #[test] + fn test_parse_query_both_off() { + let state = parse_query_response("OUTPUT=off,INPUT=off").unwrap(); + assert!(!state.output_enabled); + assert!(!state.input_enabled); + } + + #[test] + fn test_parse_query_invalid() { + assert!(parse_query_response("GARBAGE").is_none()); + } + + #[test] + fn test_control_socket_path() { + let path = control_socket_path("banking"); + assert_eq!( + path, + PathBuf::from("/run/vmsilo/banking/sound/control.socket") + ); + } +} diff --git a/vmsilo-device-tray/src/tray.rs b/vmsilo-device-tray/src/tray.rs index 3ec250c..61eef31 100644 --- a/vmsilo-device-tray/src/tray.rs +++ b/vmsilo-device-tray/src/tray.rs @@ -1,4 +1,5 @@ use crate::icons; +use crate::sound; use crate::systemd::{self, SystemdState}; use crate::usb::{self, UsbDevice}; use ksni::menu::*; @@ -20,29 +21,63 @@ impl DeviceTray { fn build_menu(&self) -> Vec> { let connection = self.connection.clone(); - let (devices, state) = tokio::task::block_in_place(|| { + let (devices, state, sound_states) = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { let devices = usb::enumerate_usb_devices(); let state = systemd::query_state(&connection).await.unwrap_or_default(); - (devices, state) + let vm_names: Vec = + state.running_vms.iter().map(|vm| vm.name.clone()).collect(); + let sound_states = sound::query_all_vms(&vm_names); + (devices, state, sound_states) }) }); - self.menu_from_state(&devices, &state) + self.menu_from_state(&devices, &state, &sound_states) } - fn menu_from_state(&self, devices: &[UsbDevice], state: &SystemdState) -> Vec> { - if devices.is_empty() { - return vec![StandardItem { - label: "No USB devices".to_string(), - enabled: false, - ..Default::default() - } - .into()]; + fn menu_from_state( + &self, + devices: &[UsbDevice], + state: &SystemdState, + sound_states: &std::collections::HashMap, + ) -> Vec> { + let mut items: Vec> = Vec::new(); + + // Sound Output submenu + if !sound_states.is_empty() { + items.push(self.build_sound_submenu( + "Sound Output", + icons::SPEAKER_PNG, + "OUTPUT", + state, + sound_states, + |s| s.output_enabled, + )); + + // Microphone Input submenu + items.push(self.build_sound_submenu( + "Microphone Input", + icons::MICROPHONE_PNG, + "INPUT", + state, + sound_states, + |s| s.input_enabled, + )); + + items.push(MenuItem::Separator); } - devices - .iter() - .map(|dev| { + // USB devices + if devices.is_empty() { + items.push( + StandardItem { + label: "No USB devices".to_string(), + enabled: false, + ..Default::default() + } + .into(), + ); + } else { + for dev in devices { let attached_vm = state.usb_attachments.get(&dev.sysfs_name).cloned(); let is_attached = attached_vm.is_some(); let icon_data = if is_attached { @@ -50,17 +85,22 @@ impl DeviceTray { } else { icons::USB_UNATTACHED_PNG.to_vec() }; - let submenu = self.build_device_submenu(dev, &attached_vm, &state.running_vms); + let submenu = + self.build_device_submenu(dev, &attached_vm, &state.running_vms); - SubMenu { - label: dev.display_name.clone(), - icon_data, - submenu, - ..Default::default() - } - .into() - }) - .collect() + items.push( + SubMenu { + label: dev.display_name.clone(), + icon_data, + submenu, + ..Default::default() + } + .into(), + ); + } + } + + items } fn build_device_submenu( @@ -149,6 +189,75 @@ impl DeviceTray { items } + + fn build_sound_submenu( + &self, + label: &str, + icon_png: &[u8], + direction: &str, + state: &SystemdState, + sound_states: &std::collections::HashMap, + get_enabled: fn(&sound::VmSoundState) -> bool, + ) -> MenuItem { + let mut submenu_items: Vec> = Vec::new(); + + for vm in &state.running_vms { + let Some(sound_state) = sound_states.get(&vm.name) else { + continue; + }; + let is_enabled = get_enabled(sound_state); + let vm_name = vm.name.clone(); + let dir = direction.to_string(); + let new_state = !is_enabled; + + let label_text = if is_enabled { + format!("{} \u{2713}", vm.name) + } else { + vm.name.clone() + }; + + submenu_items.push( + StandardItem { + label: label_text, + activate: Box::new(move |_tray: &mut Self| { + let vm = vm_name.clone(); + let direction = dir.clone(); + tokio::spawn(async move { + if !sound::set_direction(&vm, &direction, new_state) { + log::error!( + "Failed to set {} {} for VM {}", + direction, + if new_state { "on" } else { "off" }, + vm + ); + } + }); + }), + ..Default::default() + } + .into(), + ); + } + + if submenu_items.is_empty() { + submenu_items.push( + StandardItem { + label: "No running VMs".to_string(), + enabled: false, + ..Default::default() + } + .into(), + ); + } + + SubMenu { + label: label.to_string(), + icon_data: icon_png.to_vec(), + submenu: submenu_items, + ..Default::default() + } + .into() + } } impl KsniTray for DeviceTray { @@ -174,6 +283,8 @@ impl KsniTray for DeviceTray { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + use crate::sound; fn make_devices() -> Vec { vec![ @@ -305,7 +416,7 @@ mod tests { Err(_) => return, // skip if no D-Bus session available }; let tray = DeviceTray::new(conn); - let menu = tray.menu_from_state(&[], &make_state_empty()); + let menu = tray.menu_from_state(&[], &make_state_empty(), &HashMap::new()); assert_eq!(count_items(&menu), 1); assert_eq!(standard_label(&menu, 0), Some("No USB devices")); assert_eq!(standard_enabled(&menu, 0), Some(false)); @@ -320,7 +431,7 @@ mod tests { let tray = DeviceTray::new(conn); let devices = make_devices(); let state = make_state_empty(); - let menu = tray.menu_from_state(&devices, &state); + let menu = tray.menu_from_state(&devices, &state, &HashMap::new()); assert_eq!(count_items(&menu), 2); assert_eq!(submenu_label(&menu, 0), Some("YubiKey")); @@ -342,7 +453,7 @@ mod tests { let tray = DeviceTray::new(conn); let devices = make_devices(); let state = make_state_with_vms(); - let menu = tray.menu_from_state(&devices, &state); + let menu = tray.menu_from_state(&devices, &state, &HashMap::new()); assert_eq!(count_items(&menu), 2); @@ -366,7 +477,7 @@ mod tests { let tray = DeviceTray::new(conn); let devices = make_devices(); let state = make_state_attached(); - let menu = tray.menu_from_state(&devices, &state); + let menu = tray.menu_from_state(&devices, &state, &HashMap::new()); // First device (1-2) is attached to banking let sub0 = submenu_items(&menu, 0).unwrap(); @@ -383,4 +494,72 @@ mod tests { assert_eq!(standard_label(sub1, 0), Some("banking")); assert_eq!(standard_label(sub1, 1), Some("shopping")); } + + fn make_sound_states() -> HashMap { + let mut states = HashMap::new(); + states.insert( + "banking".to_string(), + sound::VmSoundState { + output_enabled: true, + input_enabled: false, + }, + ); + states.insert( + "shopping".to_string(), + sound::VmSoundState { + output_enabled: true, + input_enabled: true, + }, + ); + states + } + + #[tokio::test] + async fn test_sound_menus() { + let conn = match zbus::Connection::session().await { + Ok(c) => c, + Err(_) => return, + }; + let tray = DeviceTray::new(conn); + let devices = make_devices(); + let state = make_state_with_vms(); + let sound_states = make_sound_states(); + let menu = tray.menu_from_state(&devices, &state, &sound_states); + + // Sound Output, Microphone Input, Separator, then 2 USB devices + assert_eq!(count_items(&menu), 5); + assert_eq!(submenu_label(&menu, 0), Some("Sound Output")); + assert_eq!(submenu_label(&menu, 1), Some("Microphone Input")); + assert!(is_separator(&menu, 2)); + assert_eq!(submenu_label(&menu, 3), Some("YubiKey")); + assert_eq!(submenu_label(&menu, 4), Some("USB Receiver")); + + // Sound Output submenu: banking checked, shopping checked + let output_sub = submenu_items(&menu, 0).unwrap(); + assert_eq!(count_items(output_sub), 2); + assert_eq!(standard_label(output_sub, 0), Some("banking \u{2713}")); + assert_eq!(standard_label(output_sub, 1), Some("shopping \u{2713}")); + + // Microphone Input submenu: banking unchecked, shopping checked + let input_sub = submenu_items(&menu, 1).unwrap(); + assert_eq!(count_items(input_sub), 2); + assert_eq!(standard_label(input_sub, 0), Some("banking")); + assert_eq!(standard_label(input_sub, 1), Some("shopping \u{2713}")); + } + + #[tokio::test] + async fn test_no_sound_no_separator() { + let conn = match zbus::Connection::session().await { + Ok(c) => c, + Err(_) => return, + }; + let tray = DeviceTray::new(conn); + let devices = make_devices(); + let state = make_state_with_vms(); + let menu = tray.menu_from_state(&devices, &state, &HashMap::new()); + + // No sound menus, just USB devices + assert_eq!(count_items(&menu), 2); + assert_eq!(submenu_label(&menu, 0), Some("YubiKey")); + } }