diff --git a/vmm/src/api/mod.rs b/vmm/src/api/mod.rs index 5cefbbddc..edaa85435 100644 --- a/vmm/src/api/mod.rs +++ b/vmm/src/api/mod.rs @@ -53,7 +53,9 @@ pub use self::http::{start_http_fd_thread, start_http_path_thread}; use crate::Error as VmmError; use crate::config::RestoreConfig; use crate::device_tree::DeviceTree; -use crate::migration_transport::MAX_MIGRATION_CONNECTIONS; +use crate::migration_transport::{ + MAX_MIGRATION_CONNECTIONS, TcpAddressParseError, tcp_address_to_server_name, +}; use crate::vm::{Error as VmError, VmState}; use crate::vm_config::{ DeviceConfig, DiskConfig, FsConfig, GenericVhostUserConfig, NetConfig, PmemConfig, @@ -301,6 +303,11 @@ pub enum VmSendMigrationConfigError { #[error("Error parsing send migration parameters")] ParseError(#[source] OptionParserError), + #[error( + "Error validating send migration parameters: destination_url must use tcp:: or unix:." + )] + InvalidDestinationUrl(#[source] TcpAddressParseError), + #[error("Error validating send migration parameters")] ValidationError(String), } @@ -441,26 +448,24 @@ impl VmSendMigrationData { } pub fn validate(&self) -> Result<(), VmSendMigrationConfigError> { - match self.destination_url.as_str() { - url if url - .strip_prefix("tcp:") - .is_some_and(|addr| !addr.is_empty()) => {} - url if url - .strip_prefix("unix:") - .is_some_and(|path| !path.is_empty()) => - { - if self.connections.get() > 1 { - return Err(VmSendMigrationConfigError::ValidationError( - "UNIX sockets and connections option cannot be used at the same time." - .to_string(), - )); - } - } - _ => { + if let Some(addr) = self.destination_url.strip_prefix("tcp:") { + tcp_address_to_server_name(addr) + .map_err(VmSendMigrationConfigError::InvalidDestinationUrl)?; + } else if self + .destination_url + .strip_prefix("unix:") + .is_some_and(|path| !path.is_empty()) + { + if self.connections.get() > 1 { return Err(VmSendMigrationConfigError::ValidationError( - "destination_url must use tcp:: or unix:.".to_string(), + "UNIX sockets and connections option cannot be used at the same time." + .to_string(), )); } + } else { + return Err(VmSendMigrationConfigError::ValidationError( + "destination_url must use tcp:: or unix:.".to_string(), + )); } if self.connections.get() > MAX_MIGRATION_CONNECTIONS { @@ -1782,6 +1787,14 @@ mod unit_tests { assert_eq!(data.timeout_strategy, TimeoutStrategy::default()); assert_eq!(data.connections, VmSendMigrationData::default_connections()); + let data = VmSendMigrationData::parse("destination_url=tcp:[2001:db8::1]:8080") + .expect("IPv6 migration string should parse"); + assert_eq!(data.destination_url, "tcp:[2001:db8::1]:8080"); + + let data = VmSendMigrationData::parse("destination_url=tcp:destination.example:8080") + .expect("hostname migration string should parse"); + assert_eq!(data.destination_url, "tcp:destination.example:8080"); + // Missing destination_url is an error VmSendMigrationData::parse("local=on,downtime_ms=200").unwrap_err(); @@ -1818,6 +1831,16 @@ mod unit_tests { // Invalid destination URL scheme is rejected VmSendMigrationData::parse("destination_url=file:///tmp/migration").unwrap_err(); + assert!(matches!( + VmSendMigrationData::parse("destination_url=tcp:192.168.1.1").unwrap_err(), + VmSendMigrationConfigError::InvalidDestinationUrl(TcpAddressParseError::MissingPort) + )); + assert!(matches!( + VmSendMigrationData::parse("destination_url=tcp:[2001:db8::1]").unwrap_err(), + VmSendMigrationConfigError::InvalidDestinationUrl( + TcpAddressParseError::MissingPortSeparatorAfterBracketedHost + ) + )); // Local migration requires a UNIX socket destination VmSendMigrationData::parse("destination_url=tcp:192.168.1.1:8080,local=yes").unwrap_err(); diff --git a/vmm/src/migration_transport.rs b/vmm/src/migration_transport.rs index a7df0c976..7c7007ea6 100644 --- a/vmm/src/migration_transport.rs +++ b/vmm/src/migration_transport.rs @@ -5,7 +5,7 @@ use std::io::{self, ErrorKind, Read, Write}; use std::net::{TcpListener, TcpStream}; -use std::num::NonZeroU32; +use std::num::{NonZeroU32, ParseIntError}; use std::os::fd::{AsFd, BorrowedFd}; use std::os::unix::io::AsRawFd; use std::os::unix::net::{UnixListener, UnixStream}; @@ -20,6 +20,7 @@ use std::time::Duration; use anyhow::{Context, anyhow}; use log::{debug, error, info, warn}; use serde_json; +use thiserror::Error; use vm_memory::bitmap::BitmapSlice; use vm_memory::{ Bytes, GuestAddress, GuestAddressSpace, GuestMemoryAtomic, ReadVolatile, VolatileMemoryError, @@ -782,6 +783,72 @@ fn socket_url_to_path(url: &str) -> Result { .map(|s| s.into()) } +/// Errors that can occur when parsing a TCP address. +#[derive(Debug, Error)] +pub enum TcpAddressParseError { + #[error("Missing closing ']' for bracketed IPv6 address")] + MissingIpv6ClosingBracket, + + #[error("Missing port separator after bracketed host")] + MissingPortSeparatorAfterBracketedHost, + + #[error("Missing TCP port")] + MissingPort, + + #[error("Host must not be empty")] + EmptyHost, + + #[error("Port must not be empty")] + EmptyPort, + + #[error("Invalid TCP port: {port}")] + InvalidPort { + port: String, + #[source] + source: ParseIntError, + }, +} + +/// Extract the server name from a TCP address. This function assumes that +/// `tcp:` has already been stripped. +/// +/// The expected format is `:` for hostnames and IPv4 addresses, or +/// `[]:` for IPv6 addresses. The host and port must both be +/// present, and the port must parse as a `u16`. +pub fn tcp_address_to_server_name(address: &str) -> Result<&str, TcpAddressParseError> { + let (host, port) = if let Some(rest) = address.strip_prefix('[') { + let (host, rest) = rest + .split_once(']') + .ok_or(TcpAddressParseError::MissingIpv6ClosingBracket)?; + + let port = rest + .strip_prefix(':') + .ok_or(TcpAddressParseError::MissingPortSeparatorAfterBracketedHost)?; + + (host, port) + } else { + address + .rsplit_once(':') + .ok_or(TcpAddressParseError::MissingPort)? + }; + + if host.is_empty() { + return Err(TcpAddressParseError::EmptyHost); + } + + if port.is_empty() { + return Err(TcpAddressParseError::EmptyPort); + } + + port.parse::() + .map_err(|source| TcpAddressParseError::InvalidPort { + port: port.to_owned(), + source, + })?; + + Ok(host) +} + /// Connect to a migration endpoint and return the established stream. pub(crate) fn send_migration_socket( destination_url: &str, @@ -975,3 +1042,78 @@ pub(crate) fn receive_memory_ranges( Ok(()) } + +#[cfg(test)] +mod tests { + use super::tcp_address_to_server_name; + + #[test] + fn test_tcp_address_to_server_name() { + assert_eq!( + tcp_address_to_server_name("example.com:1234").unwrap(), + "example.com" + ); + assert_eq!( + tcp_address_to_server_name("192.0.2.1:1234").unwrap(), + "192.0.2.1" + ); + assert_eq!( + tcp_address_to_server_name("[2001:db8::1]:1234").unwrap(), + "2001:db8::1" + ); + } + + #[test] + fn test_tcp_address_to_server_name_rejects_invalid_addresses() { + assert_eq!( + tcp_address_to_server_name("192.168.1.1") + .unwrap_err() + .to_string(), + "Missing TCP port" + ); + assert_eq!( + tcp_address_to_server_name(":8080").unwrap_err().to_string(), + "Host must not be empty" + ); + assert_eq!( + tcp_address_to_server_name("host:").unwrap_err().to_string(), + "Port must not be empty" + ); + assert_eq!( + tcp_address_to_server_name("host:not-a-port") + .unwrap_err() + .to_string(), + "Invalid TCP port: not-a-port" + ); + assert_eq!( + tcp_address_to_server_name("[2001:db8::1") + .unwrap_err() + .to_string(), + "Missing closing ']' for bracketed IPv6 address" + ); + assert_eq!( + tcp_address_to_server_name("[]:8080") + .unwrap_err() + .to_string(), + "Host must not be empty" + ); + assert_eq!( + tcp_address_to_server_name("[2001:db8::1]") + .unwrap_err() + .to_string(), + "Missing port separator after bracketed host" + ); + assert_eq!( + tcp_address_to_server_name("[2001:db8::1]:") + .unwrap_err() + .to_string(), + "Port must not be empty" + ); + assert_eq!( + tcp_address_to_server_name("[2001:db8::1]:99999") + .unwrap_err() + .to_string(), + "Invalid TCP port: 99999" + ); + } +}