feat: add runtime sound control to device tray

The device tray now shows Sound Output and Microphone Input menus for
running VMs. Each direction can be toggled per-VM at runtime via the
vhost-device-sound control socket. Initial state comes from the existing
sound.playback/sound.capture NixOS options.

NixOS module passes --initial-streams and --control-socket to
vhost-device-sound. The vhost-device flake input is updated to include
the new control socket support.
This commit is contained in:
Davíð Steinn Geirsson 2026-03-27 21:56:39 +00:00
parent d467ee444e
commit 72d50a50ee
13 changed files with 371 additions and 42 deletions

View file

@ -159,6 +159,7 @@ See README.md for full usage details and options.
- **Per-VM runtime dirs**: all sockets under `/run/vmsilo/<vmname>/` subdirectories (not flat). virtiofs instances get per-instance dirs at `/run/vmsilo/<vmname>/virtiofs-<tag>/`.
- **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-<vm>-usb@<devpath>.service`. Works with both crosvm and cloud-hypervisor.
- **CID file**: `/run/vmsilo/<vmname>/cid` written by prep service, read by `vsock-connect` and dbus-proxy for autodetection.
- **Sound control socket**: `/run/vmsilo/<vmname>/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/<vmname>/virtiofs-<tag>/`. Gated by `virtiofs.disableSandbox`; seccomp controlled independently by `virtiofs.seccompPolicy`.

View file

@ -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/<name>/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

8
flake.lock generated
View file

@ -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"
},

View file

@ -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}"

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 0-2 2v4a2 2 0 0 0 4 0V3a2 2 0 0 0-2-2zM4 6.5a.5.5 0 0 0-1 0A5 5 0 0 0 7.5 11.45V13H6a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1H8.5v-1.55A5 5 0 0 0 13 6.5a.5.5 0 0 0-1 0 4 4 0 0 1-8 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M3 5h2l4-3v12l-4-3H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 134 B

View file

@ -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|<path|<path fill=\"$color\"|" "$src/speaker.svg" > /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|<path|<path fill=\"$color\"|" "$src/microphone.svg" > /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 {

View file

@ -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");

View file

@ -1,6 +1,7 @@
mod icons;
mod systemd;
mod tray;
mod sound;
mod usb;
use ksni::TrayMethods;

View file

@ -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<String, VmSoundState> {
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<VmSoundState> {
let response = send_command(path, "QUERY\n").ok()?;
parse_query_response(&response)
}
fn send_command(path: &PathBuf, command: &str) -> Result<String, std::io::Error> {
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<VmSoundState> {
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")
);
}
}

View file

@ -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<MenuItem<Self>> {
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<String> =
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<MenuItem<Self>> {
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<String, sound::VmSoundState>,
) -> Vec<MenuItem<Self>> {
let mut items: Vec<MenuItem<Self>> = 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<String, sound::VmSoundState>,
get_enabled: fn(&sound::VmSoundState) -> bool,
) -> MenuItem<Self> {
let mut submenu_items: Vec<MenuItem<Self>> = 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<UsbDevice> {
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<String, sound::VmSoundState> {
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"));
}
}