diff --git a/src/cli.rs b/src/cli.rs index 146d531..3acb338 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,7 +16,10 @@ pub struct App { #[derive(Subcommand, Clone, Debug)] pub enum Commands { /// Start a headless VM in the background. - Start, + Start { + #[arg(long, default_value = "1G")] + memory: String, + }, /// Stop a headless VM. Stop, @@ -61,17 +64,17 @@ pub enum Commands { }, /// Test synchronization by repeatedly running commands. - Test {} + Test {}, } /// An attachable USB device identifier. #[derive(Clone, Debug)] pub struct DeviceIdentifier { /// The Vendor ID. - pub vendorid: String, + pub vendor_id: u16, /// The Device ID. - pub deviceid: String, + pub device_id: u16, } /// An error encountered while parsing a USB device identifier @@ -80,8 +83,11 @@ pub enum DeviceIdentifierFromStrError { #[error("could not split input by colon; expected output similar to `lsusb`")] CouldNotSplitByColon, - #[error("found non-hex {0} at position {1}")] - BadChar(char, usize), + #[error("could not parse hex from vendor or device ID")] + Hex(#[from] hex::FromHexError), + + #[error("could not decode u64 from bytes: {0:?}")] + BadBytes(Vec), } impl FromStr for DeviceIdentifier { @@ -91,23 +97,22 @@ impl FromStr for DeviceIdentifier { let Some((first, last)) = s.split_once(':') else { return Err(DeviceIdentifierFromStrError::CouldNotSplitByColon); }; - if let Some((position, ch)) = first - .chars() - .enumerate() - .find(|(_, ch)| !ch.is_ascii_hexdigit()) - { - return Err(DeviceIdentifierFromStrError::BadChar(ch, position)); - } - if let Some((position, ch)) = last - .chars() - .enumerate() - .find(|(_, ch)| !ch.is_ascii_hexdigit()) - { - return Err(DeviceIdentifierFromStrError::BadChar(ch, position)); - } + + let vendor_id = u16::from_be_bytes( + hex::decode(first)? + .try_into() + .map_err(DeviceIdentifierFromStrError::BadBytes)?, + ); + + let device_id = u16::from_be_bytes( + hex::decode(last)? + .try_into() + .map_err(DeviceIdentifierFromStrError::BadBytes)?, + ); + Ok(Self { - vendorid: first.to_owned(), - deviceid: last.to_owned(), + vendor_id, + device_id, }) } } diff --git a/src/main.rs b/src/main.rs index 4c7d3d3..0d61b0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,9 +21,12 @@ fn main() -> eyre::Result<()> { let opts = cli::App::parse_from(args); match opts.subcommand { - cli::Commands::Start => { - let spawn_args = SpawnArguments::default(); - let mut vm = VirtualMachine::start(spawn_args)?; + cli::Commands::Start { memory } => { + let spawn_args = SpawnArguments { + memory: memory.clone(), + ..Default::default() + }; + let vm = VirtualMachine::start(spawn_args)?; let pid = vm.pid(); std::fs::write(&opts.lockfile, pid.to_string()).with_context(|| { format!( @@ -31,9 +34,6 @@ fn main() -> eyre::Result<()> { lockfile = opts.lockfile.display(), ) })?; - - // temp - vm.run_command("uptime", [])?; } cli::Commands::Stop => { let spawn_arguments = SpawnArguments::default(); @@ -41,8 +41,7 @@ fn main() -> eyre::Result<()> { vm.kill()?; } cli::Commands::Shell => { - // TODO: qemu inline, is it possible to pass through stdin/stdout w/o buffering? - todo!() + todo!("custom args to starting a VM and piping stdin/stdout are not yet implemented"); } cli::Commands::Status => { let spawn_arguments = SpawnArguments::default(); @@ -57,7 +56,17 @@ fn main() -> eyre::Result<()> { eprintln!("hostname: {hostname}"); 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 { local_path, remote_path, diff --git a/src/vm.rs b/src/vm.rs index 104b4dc..5f649e9 100644 --- a/src/vm.rs +++ b/src/vm.rs @@ -30,7 +30,7 @@ fn spinner(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! { if #[cfg(feature = "unicode")] { let style = ProgressStyle::with_template(template).unwrap(); @@ -119,16 +119,20 @@ fn find_pci_device_by_class(class: u16) -> Result> { #[derive(Debug)] pub struct VirtualMachine { pid: u32, - writer: UnixStream, - reader: BufReader, + // qemu guest agent (proxied to guest) + guest_writer: UnixStream, + guest_reader: BufReader, + // qemu machine protocol (host) + host_writer: UnixStream, + host_reader: BufReader, args: SpawnArguments, } /// The configuration to use when starting a VM. #[derive(Clone, Debug)] pub struct SpawnArguments { - /// The PCI device to use for connecting to a network. - pub network_pci_device: Option, + /// The amount of memory to allocate to a VM. + pub memory: String, /// The image file to use when booting the machine. /// @@ -148,7 +152,7 @@ pub struct SpawnArguments { impl Default for SpawnArguments { fn default() -> Self { Self { - network_pci_device: None, + memory: String::from("1G"), guest_image: PathBuf::from("/guest.img"), guest_agent_socket_path: PathBuf::from("/var/run/netvm_qga.sock"), qmp_socket_path: PathBuf::from("/var/run/netvm_qmp.sock"), @@ -220,7 +224,7 @@ impl VirtualMachine { .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .args(["-m", "4G"]) + .args(["-m", &args.memory]) .args(["-machine", "q35"]) .arg("-nographic") .args(["-serial", "none"]) @@ -255,7 +259,9 @@ impl VirtualMachine { 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; } std::thread::sleep(Duration::from_millis(100)); @@ -277,14 +283,11 @@ impl VirtualMachine { } }; - 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")?; - let reader = BufReader::new( - writer - .try_clone() - .context("couldn't clone socket to make buffered reader")?, - ); + let host = UnixStream::connect(&args.qmp_socket_path) + .context("could not open socket to qemu management socket")?; bar.println(format!( "Connected to VM with PID {} and socket {}", @@ -293,21 +296,38 @@ impl VirtualMachine { )); bar.finish_and_clear(); - let vm = Self::from_parts(pid, writer, reader, args)?; + let vm = Self::from_parts(pid, guest, host, args)?; Ok(vm) } fn from_parts( pid: u32, - writer: UnixStream, - reader: BufReader, + guest_socket: UnixStream, + host_socket: UnixStream, args: SpawnArguments, ) -> Result { + 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 { pid, - writer, - reader, + guest_writer: guest_socket, + guest_reader, + host_writer: host_socket, + host_reader, args, }; @@ -347,21 +367,21 @@ impl VirtualMachine { // * read a line from the parser to reset the input let bar = spinner("Re-establishing connection..."); - self.writer + self.guest_writer .set_nonblocking(true) .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 { return Err(e).context("flush: can't read nonblocked data"); } } - self.writer + self.guest_writer .set_nonblocking(false) .context("flush: can't set blocking")?; - self.writer + self.guest_writer .write_all(&[0x1b]) .context("flush: can't send reset byte")?; - self.reader + self.guest_reader .read_line(&mut String::new()) .context("flush: can't read error")?; bar.finish_and_clear(); @@ -599,12 +619,12 @@ impl VirtualMachine { "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")?; - 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(); - self.reader + self.guest_reader .read_line(&mut line) .context("can't read line from socket")?; @@ -631,6 +651,40 @@ impl VirtualMachine { result } + pub fn execute_host( + &mut self, + command: &'static str, + args: S, + ) -> Result { + 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 // 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