Compare commits

...

4 Commits

Author SHA1 Message Date
Ryan Heywood 4a8106660c
add more documentation 2025-05-09 14:19:31 -04:00
Ryan Heywood 665c39c05c
fix clippy pedantry warnings 2025-05-09 14:09:59 -04:00
Ryan Heywood 6c5c130e77
remove `vmctl test` 2025-05-09 14:01:31 -04:00
Ryan Heywood 723b663cb5
add `vmctl attach` 2025-05-09 14:00:36 -04:00
3 changed files with 172 additions and 91 deletions

View File

@ -1,7 +1,7 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::{path::PathBuf, str::FromStr}; use std::{path::PathBuf, str::FromStr};
/// VM controller for AirgapOS /// VM controller for `AirgapOS`
#[derive(Parser, Clone, Debug)] #[derive(Parser, Clone, Debug)]
pub struct App { pub struct App {
// global options go here // global options go here
@ -16,7 +16,10 @@ pub struct App {
#[derive(Subcommand, Clone, Debug)] #[derive(Subcommand, Clone, Debug)]
pub enum Commands { pub enum Commands {
/// Start a headless VM in the background. /// Start a headless VM in the background.
Start, Start {
#[arg(long, default_value = "1G")]
memory: String,
},
/// Stop a headless VM. /// Stop a headless VM.
Stop, Stop,
@ -29,7 +32,7 @@ pub enum Commands {
/// Attach a USB device to a running VM. /// Attach a USB device to a running VM.
Attach { Attach {
/// The device to attach. /// The device to attach, in the format of `vendorid:deviceid`.
device: DeviceIdentifier, device: DeviceIdentifier,
}, },
@ -59,19 +62,16 @@ pub enum Commands {
/// Arguments to pass to the running command. /// Arguments to pass to the running command.
args: Vec<String>, args: Vec<String>,
}, },
/// Test synchronization by repeatedly running commands.
Test {}
} }
/// An attachable USB device identifier. /// An attachable USB device identifier.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct DeviceIdentifier { pub struct DeviceIdentifier {
/// The Vendor ID. /// The Vendor ID.
pub vendorid: String, pub vendor_id: u16,
/// The Device ID. /// The Device ID.
pub deviceid: String, pub device_id: u16,
} }
/// An error encountered while parsing a USB device identifier /// An error encountered while parsing a USB device identifier
@ -80,8 +80,11 @@ pub enum DeviceIdentifierFromStrError {
#[error("could not split input by colon; expected output similar to `lsusb`")] #[error("could not split input by colon; expected output similar to `lsusb`")]
CouldNotSplitByColon, CouldNotSplitByColon,
#[error("found non-hex {0} at position {1}")] #[error("could not parse hex from vendor or device ID")]
BadChar(char, usize), Hex(#[from] hex::FromHexError),
#[error("could not decode u64 from bytes: {0:?}")]
BadBytes(Vec<u8>),
} }
impl FromStr for DeviceIdentifier { impl FromStr for DeviceIdentifier {
@ -91,23 +94,22 @@ impl FromStr for DeviceIdentifier {
let Some((first, last)) = s.split_once(':') else { let Some((first, last)) = s.split_once(':') else {
return Err(DeviceIdentifierFromStrError::CouldNotSplitByColon); return Err(DeviceIdentifierFromStrError::CouldNotSplitByColon);
}; };
if let Some((position, ch)) = first
.chars() let vendor_id = u16::from_be_bytes(
.enumerate() hex::decode(first)?
.find(|(_, ch)| !ch.is_ascii_hexdigit()) .try_into()
{ .map_err(DeviceIdentifierFromStrError::BadBytes)?,
return Err(DeviceIdentifierFromStrError::BadChar(ch, position)); );
}
if let Some((position, ch)) = last let device_id = u16::from_be_bytes(
.chars() hex::decode(last)?
.enumerate() .try_into()
.find(|(_, ch)| !ch.is_ascii_hexdigit()) .map_err(DeviceIdentifierFromStrError::BadBytes)?,
{ );
return Err(DeviceIdentifierFromStrError::BadChar(ch, position));
}
Ok(Self { Ok(Self {
vendorid: first.to_owned(), vendor_id,
deviceid: last.to_owned(), device_id,
}) })
} }
} }

View File

@ -1,3 +1,5 @@
#![allow(clippy::redundant_else)]
use clap::Parser; use clap::Parser;
use eyre::WrapErr; use eyre::WrapErr;
use std::io::Write; use std::io::Write;
@ -21,9 +23,12 @@ fn main() -> eyre::Result<()> {
let opts = cli::App::parse_from(args); let opts = cli::App::parse_from(args);
match opts.subcommand { match opts.subcommand {
cli::Commands::Start => { cli::Commands::Start { memory } => {
let spawn_args = SpawnArguments::default(); let spawn_args = SpawnArguments {
let mut vm = VirtualMachine::start(spawn_args)?; memory: memory.clone(),
..Default::default()
};
let vm = VirtualMachine::start(spawn_args)?;
let pid = vm.pid(); let pid = vm.pid();
std::fs::write(&opts.lockfile, pid.to_string()).with_context(|| { std::fs::write(&opts.lockfile, pid.to_string()).with_context(|| {
format!( format!(
@ -31,9 +36,6 @@ fn main() -> eyre::Result<()> {
lockfile = opts.lockfile.display(), lockfile = opts.lockfile.display(),
) )
})?; })?;
// temp
vm.run_command("uptime", [])?;
} }
cli::Commands::Stop => { cli::Commands::Stop => {
let spawn_arguments = SpawnArguments::default(); let spawn_arguments = SpawnArguments::default();
@ -41,8 +43,7 @@ fn main() -> eyre::Result<()> {
vm.kill()?; vm.kill()?;
} }
cli::Commands::Shell => { cli::Commands::Shell => {
// TODO: qemu inline, is it possible to pass through stdin/stdout w/o buffering? todo!("custom args to starting a VM and piping stdin/stdout are not yet implemented");
todo!()
} }
cli::Commands::Status => { cli::Commands::Status => {
let spawn_arguments = SpawnArguments::default(); let spawn_arguments = SpawnArguments::default();
@ -57,7 +58,20 @@ fn main() -> eyre::Result<()> {
eprintln!("hostname: {hostname}"); eprintln!("hostname: {hostname}");
eprint!("{}", String::from_utf8_lossy(&uptime.0)); eprint!("{}", String::from_utf8_lossy(&uptime.0));
} }
cli::Commands::Attach { device } => todo!(), cli::Commands::Attach { device } => {
let spawn_arguments = SpawnArguments::default();
let mut vm = VirtualMachine::load(spawn_arguments, None)?;
vm.execute_host("qmp_capabilities", serde_json::json!({}))?;
vm.execute_host(
"device_add",
serde_json::json!({
"driver": "usb-host",
"bus": "usb.0",
"vendorid": device.vendor_id,
"productid": device.device_id,
}),
)?;
}
cli::Commands::Push { cli::Commands::Push {
local_path, local_path,
remote_path, remote_path,
@ -79,21 +93,9 @@ fn main() -> eyre::Result<()> {
let mut vm = VirtualMachine::load(spawn_arguments, None)?; let mut vm = VirtualMachine::load(spawn_arguments, None)?;
let (response, exit_code) = vm.run_command(&command, args)?; let (response, exit_code) = vm.run_command(&command, args)?;
std::io::stdout().write_all(&response)?; std::io::stdout().write_all(&response)?;
std::process::exit(exit_code as i32); std::process::exit(
} i32::try_from(exit_code).context(eyre::eyre!("bad PID: pid < i32::MAX << 1"))?,
cli::Commands::Test {} => { );
let spawn_arguments = SpawnArguments::default();
let mut vm = VirtualMachine::load(spawn_arguments, None)?;
for i in 0..10 {
let sleep_command = format!("sleep 10; echo {i}");
let (response, exit_code) =
vm.run_command("sh", [String::from("-c"), sleep_command])?;
eprint!(
"exit code {}, output {}",
exit_code,
String::from_utf8_lossy(&response),
);
}
} }
} }

159
src/vm.rs
View File

@ -30,7 +30,7 @@ fn spinner(msg: impl Display) -> ProgressBar {
} }
fn bar(count: u64, msg: impl Display) -> ProgressBar { fn bar(count: u64, msg: impl Display) -> ProgressBar {
let template = "[{elapsed_precise}] {wide_bar} {percent}% {msg}"; let template = "{elapsed_precise} [{wide_bar}] {percent}% {msg}";
cfg_if::cfg_if! { cfg_if::cfg_if! {
if #[cfg(feature = "unicode")] { if #[cfg(feature = "unicode")] {
let style = ProgressStyle::with_template(template).unwrap(); let style = ProgressStyle::with_template(template).unwrap();
@ -66,6 +66,7 @@ fn to_lowercase_hexlike(s: impl AsRef<str>) -> String {
s.to_ascii_lowercase() s.to_ascii_lowercase()
} }
#[allow(clippy::struct_field_names)]
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct Device { struct Device {
vendor_id: u16, vendor_id: u16,
@ -100,7 +101,7 @@ fn find_pci_device_by_class(class: u16) -> Result<Vec<Device>> {
let bus_id = bus_address let bus_id = bus_address
.into_string() .into_string()
.map_err(|bad| eyre::eyre!("non-utf8 bus address: {bad:?}"))? .map_err(|bad| eyre::eyre!("non-utf8 bus address: {bad:?}"))?
.split_once(":") .split_once(':')
.ok_or(eyre::eyre!("bad path ID"))? .ok_or(eyre::eyre!("bad path ID"))?
.1 .1
.to_string(); .to_string();
@ -116,19 +117,25 @@ fn find_pci_device_by_class(class: u16) -> Result<Vec<Device>> {
} }
// NOTE: Do not implement `clone`, as there is side-effect state involved. // NOTE: Do not implement `clone`, as there is side-effect state involved.
/// A control handle for a virtual machine.
#[derive(Debug)] #[derive(Debug)]
pub struct VirtualMachine { pub struct VirtualMachine {
pid: u32, pid: u32,
writer: UnixStream, // qemu guest agent (proxied to guest)
reader: BufReader<UnixStream>, guest_writer: UnixStream,
guest_reader: BufReader<UnixStream>,
// qemu machine protocol (host)
host_writer: UnixStream,
host_reader: BufReader<UnixStream>,
args: SpawnArguments, args: SpawnArguments,
} }
/// The configuration to use when starting a VM. /// The configuration to use when starting a VM.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct SpawnArguments { pub struct SpawnArguments {
/// The PCI device to use for connecting to a network. /// The amount of memory to allocate to a VM.
pub network_pci_device: Option<String>, pub memory: String,
/// The image file to use when booting the machine. /// The image file to use when booting the machine.
/// ///
@ -148,7 +155,7 @@ pub struct SpawnArguments {
impl Default for SpawnArguments { impl Default for SpawnArguments {
fn default() -> Self { fn default() -> Self {
Self { Self {
network_pci_device: None, memory: String::from("1G"),
guest_image: PathBuf::from("/guest.img"), guest_image: PathBuf::from("/guest.img"),
guest_agent_socket_path: PathBuf::from("/var/run/netvm_qga.sock"), guest_agent_socket_path: PathBuf::from("/var/run/netvm_qga.sock"),
qmp_socket_path: PathBuf::from("/var/run/netvm_qmp.sock"), qmp_socket_path: PathBuf::from("/var/run/netvm_qmp.sock"),
@ -158,6 +165,7 @@ impl Default for SpawnArguments {
} }
impl VirtualMachine { impl VirtualMachine {
/// Start a virutal machine with the given parameters.
pub fn start(args: SpawnArguments) -> eyre::Result<Self> { pub fn start(args: SpawnArguments) -> eyre::Result<Self> {
let eth_devices = find_pci_device_by_class(0x0200)?; let eth_devices = find_pci_device_by_class(0x0200)?;
@ -165,8 +173,9 @@ impl VirtualMachine {
if std::fs::exists(&args.lockfile_path)? { if std::fs::exists(&args.lockfile_path)? {
// Check if VM is running // Check if VM is running
use nix::unistd::{getpgid, Pid}; use nix::unistd::{getpgid, Pid};
let pid = get_pid(&args.lockfile_path)?; let pid = i32::try_from(get_pid(&args.lockfile_path)?)
if getpgid(Some(Pid::from_raw(pid as i32))).is_ok() { .context(eyre::eyre!("bad PID: pid < i32::MAX << 1"))?;
if getpgid(Some(Pid::from_raw(pid))).is_ok() {
// process exists, exit // process exists, exit
return Err(eyre::eyre!( return Err(eyre::eyre!(
"VM with this configuration exists as PID {pid}" "VM with this configuration exists as PID {pid}"
@ -213,14 +222,14 @@ impl VirtualMachine {
), ),
)?; )?;
net_args.push("-device".to_string()); net_args.push("-device".to_string());
net_args.push(format!("vfio-pci,host={bus_id}")) net_args.push(format!("vfio-pci,host={bus_id}"));
} }
let mut child = Command::new("qemu-system-x86_64") let mut child = Command::new("qemu-system-x86_64")
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.args(["-m", "4G"]) .args(["-m", &args.memory])
.args(["-machine", "q35"]) .args(["-machine", "q35"])
.arg("-nographic") .arg("-nographic")
.args(["-serial", "none"]) .args(["-serial", "none"])
@ -255,7 +264,9 @@ impl VirtualMachine {
return Err(eyre::eyre!("child exited with code {:?}", status.code())); return Err(eyre::eyre!("child exited with code {:?}", status.code()));
} }
if std::fs::exists(&args.guest_agent_socket_path)? { if std::fs::exists(&args.guest_agent_socket_path)?
&& std::fs::exists(&args.qmp_socket_path)?
{
break; break;
} }
std::thread::sleep(Duration::from_millis(100)); std::thread::sleep(Duration::from_millis(100));
@ -266,25 +277,25 @@ impl VirtualMachine {
Self::load(args, Some(child.id())) Self::load(args, Some(child.id()))
} }
/// Load a virtual machine with the given parameters and optionally a custom PID.
///
/// The custom PID option may be relevant if the virtual machine sockets were loaded but the
/// PID of the virtual machine was not properly persisted.
pub fn load(args: SpawnArguments, pid: Option<u32>) -> Result<Self> { pub fn load(args: SpawnArguments, pid: Option<u32>) -> Result<Self> {
let bar = spinner("Connecting to VM"); let bar = spinner("Connecting to VM");
let pid = match pid { let pid = if let Some(pid) = pid {
Some(pid) => pid, pid
None => { } else {
let pid_str = std::fs::read_to_string(&args.lockfile_path) let pid_str = std::fs::read_to_string(&args.lockfile_path)
.context("error reading PID from lockfile")?; .context("error reading PID from lockfile")?;
pid_str.parse().context("could not parse PID")? pid_str.parse().context("could not parse PID")?
}
}; };
let writer = UnixStream::connect(&args.guest_agent_socket_path) let guest = UnixStream::connect(&args.guest_agent_socket_path)
.context("could not open socket to QVM guest agent")?; .context("could not open socket to QVM guest agent")?;
let reader = BufReader::new( let host = UnixStream::connect(&args.qmp_socket_path)
writer .context("could not open socket to qemu management socket")?;
.try_clone()
.context("couldn't clone socket to make buffered reader")?,
);
bar.println(format!( bar.println(format!(
"Connected to VM with PID {} and socket {}", "Connected to VM with PID {} and socket {}",
@ -293,21 +304,39 @@ impl VirtualMachine {
)); ));
bar.finish_and_clear(); bar.finish_and_clear();
let vm = Self::from_parts(pid, writer, reader, args)?; let vm = Self::from_parts(pid, guest, host, args)?;
Ok(vm) Ok(vm)
} }
fn from_parts( fn from_parts(
pid: u32, pid: u32,
writer: UnixStream, guest_socket: UnixStream,
reader: BufReader<UnixStream>, host_socket: UnixStream,
args: SpawnArguments, args: SpawnArguments,
) -> Result<Self> { ) -> Result<Self> {
let guest_reader = BufReader::new(
guest_socket
.try_clone()
.context("couldn't clone socket to make buffered reader")?,
);
let mut host_reader = BufReader::new(
host_socket
.try_clone()
.context("couldn't clone socket to make buffered reader")?,
);
let mut server_hello = String::new();
host_reader
.read_line(&mut server_hello)
.context("can't read line from socket (pre-load)")?;
let mut vm = Self { let mut vm = Self {
pid, pid,
writer, guest_writer: guest_socket,
reader, guest_reader,
host_writer: host_socket,
host_reader,
args, args,
}; };
@ -319,7 +348,7 @@ impl VirtualMachine {
// crashing if those circumstances happen to be met. // crashing if those circumstances happen to be met.
let time = SystemTime::now().duration_since(UNIX_EPOCH)?; let time = SystemTime::now().duration_since(UNIX_EPOCH)?;
let identifier = time.as_secs() % (u32::MAX as u64); let identifier = time.as_secs() % u64::from(u32::MAX);
let ping_response = vm let ping_response = vm
.execute_internal("guest-sync", serde_json::json!({"id": identifier})) .execute_internal("guest-sync", serde_json::json!({"id": identifier}))
@ -334,6 +363,7 @@ impl VirtualMachine {
Ok(vm) Ok(vm)
} }
/// The PID of the virtual machine.
pub fn pid(&self) -> u32 { pub fn pid(&self) -> u32 {
self.pid self.pid
} }
@ -347,21 +377,21 @@ impl VirtualMachine {
// * read a line from the parser to reset the input // * read a line from the parser to reset the input
let bar = spinner("Re-establishing connection..."); let bar = spinner("Re-establishing connection...");
self.writer self.guest_writer
.set_nonblocking(true) .set_nonblocking(true)
.context("flush: can't set nonblocking")?; .context("flush: can't set nonblocking")?;
if let Err(e) = self.reader.read_to_end(&mut vec![]) { if let Err(e) = self.guest_reader.read_to_end(&mut vec![]) {
if e.kind() != std::io::ErrorKind::WouldBlock { if e.kind() != std::io::ErrorKind::WouldBlock {
return Err(e).context("flush: can't read nonblocked data"); return Err(e).context("flush: can't read nonblocked data");
} }
} }
self.writer self.guest_writer
.set_nonblocking(false) .set_nonblocking(false)
.context("flush: can't set blocking")?; .context("flush: can't set blocking")?;
self.writer self.guest_writer
.write_all(&[0x1b]) .write_all(&[0x1b])
.context("flush: can't send reset byte")?; .context("flush: can't send reset byte")?;
self.reader self.guest_reader
.read_line(&mut String::new()) .read_line(&mut String::new())
.context("flush: can't read error")?; .context("flush: can't read error")?;
bar.finish_and_clear(); bar.finish_and_clear();
@ -369,6 +399,7 @@ impl VirtualMachine {
Ok(()) Ok(())
} }
/// Push a single file from the local machine to the VM.
pub fn push( pub fn push(
&mut self, &mut self,
local_path: impl AsRef<Path>, local_path: impl AsRef<Path>,
@ -421,7 +452,8 @@ impl VirtualMachine {
.ok_or(eyre::eyre!("not given 'count' of bytes written"))? .ok_or(eyre::eyre!("not given 'count' of bytes written"))?
.as_u64() .as_u64()
.ok_or(eyre::eyre!("'count' not u64"))?; .ok_or(eyre::eyre!("'count' not u64"))?;
written += response_written as usize; written +=
usize::try_from(response_written).expect("wrote more than u46::MAX bytes");
if written == size { if written == size {
break; break;
} }
@ -437,6 +469,8 @@ impl VirtualMachine {
Ok(()) Ok(())
} }
/// Pull a single file from the VM to the local machine. This operation is destructive and will
/// overwrite existing files.
pub fn pull( pub fn pull(
&mut self, &mut self,
remote_path: impl AsRef<Path>, remote_path: impl AsRef<Path>,
@ -534,6 +568,8 @@ impl VirtualMachine {
// TODO: make this return status, stdout, stderr // TODO: make this return status, stdout, stderr
// TODO: accept optional: env, input-data, disable capture-output // TODO: accept optional: env, input-data, disable capture-output
/// Run a command on the virtual machine. Standard input is not sent to the process, and only
/// standard output is received from the process.
pub fn run_command( pub fn run_command(
&mut self, &mut self,
command: &str, command: &str,
@ -599,12 +635,12 @@ impl VirtualMachine {
"arguments": args, "arguments": args,
}); });
serde_json::to_writer(&mut self.writer, &message) serde_json::to_writer(&mut self.guest_writer, &message)
.context("could not send message over socket")?; .context("could not send message over socket")?;
writeln!(&mut self.writer).context("could not send newline over socket")?; writeln!(&mut self.guest_writer).context("could not send newline over socket")?;
let mut line = String::new(); let mut line = String::new();
self.reader self.guest_reader
.read_line(&mut line) .read_line(&mut line)
.context("can't read line from socket")?; .context("can't read line from socket")?;
@ -620,6 +656,7 @@ impl VirtualMachine {
} }
} }
/// Execute an operation via QEMU Guest Agent. This modifies state inside the VM.
pub fn execute<S: serde::Serialize + Debug>( pub fn execute<S: serde::Serialize + Debug>(
&mut self, &mut self,
command: &'static str, command: &'static str,
@ -631,16 +668,56 @@ impl VirtualMachine {
result result
} }
/// Execute an operation via QEMU Machine Protocol. This modifies state on the host machine and
/// the VM.
pub fn execute_host<S: serde::Serialize + Debug>(
&mut self,
command: &'static str,
args: S,
) -> Result<serde_json::Value> {
let bar = spinner(format!("Executing: {command:?} with {args:?}"));
let message = serde_json::json!({
"execute": command,
"arguments": args,
});
serde_json::to_writer(&mut self.host_writer, &message)
.context("could not send message over socket")?;
writeln!(&mut self.host_writer).context("could not send newline over socket")?;
let mut line = String::new();
self.host_reader
.read_line(&mut line)
.context("can't read line from socket")?;
let response: serde_json::Value =
serde_json::from_str(&line).context("response from qemu is not json")?;
bar.finish_and_clear();
if let Some(response) = response.get("return") {
Ok(response.clone())
} else if let Some(error) = response.get("error") {
Err(eyre::eyre!("error response from qemu: {error:?}"))
} else {
Err(eyre::eyre!("invalid response from qemu: {response:?}"))
}
}
// NOTE: u32 is returned from Process::id(), i32 is the Linux internal version // NOTE: u32 is returned from Process::id(), i32 is the Linux internal version
// This should be safe; the kernel wouldn't give a value that, when converted // This should be safe; the kernel wouldn't give a value that, when converted
// to a u32, can't be made back into an i32 // to a u32, can't be made back into an i32
/// Terminate the VM and remove any stateful files.
pub fn kill(self) -> Result<()> { pub fn kill(self) -> Result<()> {
use nix::{ use nix::{
errno::Errno, errno::Errno,
sys::signal::{kill, SIGKILL}, sys::signal::{kill, SIGKILL},
unistd::{getpgid, Pid}, unistd::{getpgid, Pid},
}; };
let pid = Pid::from_raw(self.pid as i32); let pid = Pid::from_raw(
i32::try_from(self.pid).context(eyre::eyre!("bad PID: pid < i32::MAX << 1"))?,
);
if getpgid(Some(pid)).is_err() { if getpgid(Some(pid)).is_err() {
eprintln!("Process not found"); eprintln!("Process not found");
return Ok(()); return Ok(());