mirror of
https://github.com/cloud-hypervisor/cloud-hypervisor.git
synced 2026-08-05 02:19:16 +00:00
Import the std modules used in the crate instead of spelling the full paths at every use site, and drop the now-unnecessary crate-level #![expect(clippy::absolute_paths)]. Signed-off-by: Henry Hrvoje Tonkovac <htonkovac@gmail.com> Assisted-by: Claude:Opus-4.8
565 lines
18 KiB
Rust
565 lines
18 KiB
Rust
// Copyright © 2026 Cyberus Technology GmbH
|
|
//
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
//
|
|
|
|
//! TLS support for migration streams over TCP.
|
|
//!
|
|
//! This module wraps `rustls` to provide a blocking [`TlsStream`] for migration
|
|
//! traffic. [`TlsStream::new_client`] authenticates the server against
|
|
//! `ca-cert.pem` and the expected hostname, and presents `client-cert.pem` and
|
|
//! `client-key.pem` for mutual TLS (mTLS) authentication. [`TlsServerConfig`] loads
|
|
//! `server-cert.pem` and `server-key.pem`, trusts client certificates issued by
|
|
//! the CA in `ca-cert.pem`, and [`TlsStream::new_server`] uses that
|
|
//! configuration to establish the server side of the connection.
|
|
//!
|
|
//! [`TlsStream`] implements [`Read`], [`Write`], [`ReadVolatile`],
|
|
//! [`WriteVolatile`], and [`AsFd`] so it can be used by the transport layer like
|
|
//! other migration streams. All data must pass through rustls; direct I/O on the
|
|
//! underlying socket would bypass TLS processing and break the connection.
|
|
|
|
use std::fs::{self, File};
|
|
use std::io::{self, BufRead, Read, Write};
|
|
use std::net::TcpStream;
|
|
use std::os::fd::{AsFd, BorrowedFd};
|
|
use std::path::{Path, PathBuf};
|
|
use std::result;
|
|
use std::sync::Arc;
|
|
|
|
use log::warn;
|
|
use rustls::pki_types::pem::{self, PemObject};
|
|
use rustls::pki_types::{CertificateDer, InvalidDnsNameError, PrivateKeyDer, ServerName};
|
|
use rustls::server::{VerifierBuilderError, WebPkiClientVerifier};
|
|
use rustls::{
|
|
ClientConfig, ClientConnection, RootCertStore, ServerConfig, ServerConnection, StreamOwned,
|
|
};
|
|
use thiserror::Error;
|
|
use vm_memory::bitmap::BitmapSlice;
|
|
use vm_memory::{ReadVolatile, VolatileMemoryError, VolatileSlice, WriteVolatile};
|
|
|
|
use crate::MigratableError;
|
|
|
|
const CA_CERT_FILE: &str = "ca-cert.pem";
|
|
const CLIENT_CERT_FILE: &str = "client-cert.pem";
|
|
const CLIENT_KEY_FILE: &str = "client-key.pem";
|
|
const SERVER_CERT_FILE: &str = "server-cert.pem";
|
|
const SERVER_KEY_FILE: &str = "server-key.pem";
|
|
|
|
/// Identifies which side of live migration uses a TLS certificate directory.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum TlsEndpoint {
|
|
Client,
|
|
Server,
|
|
}
|
|
|
|
impl TlsEndpoint {
|
|
fn required_files(self) -> [&'static str; 3] {
|
|
match self {
|
|
Self::Client => [CA_CERT_FILE, CLIENT_CERT_FILE, CLIENT_KEY_FILE],
|
|
Self::Server => [CA_CERT_FILE, SERVER_CERT_FILE, SERVER_KEY_FILE],
|
|
}
|
|
}
|
|
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Client => "migration client",
|
|
Self::Server => "migration server",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validation errors for a migration TLS certificate directory.
|
|
#[derive(Error, Debug)]
|
|
pub enum TlsConfigError {
|
|
#[error("TLS directory does not exist or is inaccessible: {path}: {source}")]
|
|
DirectoryMetadata {
|
|
path: PathBuf,
|
|
#[source]
|
|
source: io::Error,
|
|
},
|
|
|
|
#[error("TLS directory must point to a directory: {path}")]
|
|
NotADirectory { path: PathBuf },
|
|
|
|
#[error("Missing required TLS file for {endpoint}: {path}")]
|
|
MissingFile {
|
|
endpoint: &'static str,
|
|
path: PathBuf,
|
|
},
|
|
|
|
#[error("Required TLS path for {endpoint} must be a regular file: {path}")]
|
|
NotAFile {
|
|
endpoint: &'static str,
|
|
path: PathBuf,
|
|
},
|
|
|
|
#[error("Required TLS file for {endpoint} is not readable: {path}: {source}")]
|
|
FileRead {
|
|
endpoint: &'static str,
|
|
path: PathBuf,
|
|
#[source]
|
|
source: io::Error,
|
|
},
|
|
|
|
#[error("Failed to inspect required TLS file for {endpoint}: {path}: {source}")]
|
|
FileMetadata {
|
|
endpoint: &'static str,
|
|
path: PathBuf,
|
|
#[source]
|
|
source: io::Error,
|
|
},
|
|
}
|
|
|
|
/// Validates that a TLS directory contains all files required by the endpoint.
|
|
///
|
|
/// Each required file must exist, be a regular file, and be readable by the
|
|
/// current Cloud Hypervisor process.
|
|
pub fn validate_tls_dir(
|
|
cert_dir: &Path,
|
|
endpoint: TlsEndpoint,
|
|
) -> result::Result<(), TlsConfigError> {
|
|
let metadata = fs::metadata(cert_dir).map_err(|source| TlsConfigError::DirectoryMetadata {
|
|
path: cert_dir.to_path_buf(),
|
|
source,
|
|
})?;
|
|
|
|
if !metadata.is_dir() {
|
|
return Err(TlsConfigError::NotADirectory {
|
|
path: cert_dir.to_path_buf(),
|
|
});
|
|
}
|
|
|
|
for file_name in endpoint.required_files() {
|
|
let path = cert_dir.join(file_name);
|
|
let endpoint_name = endpoint.as_str();
|
|
|
|
let metadata = match fs::metadata(&path) {
|
|
Ok(metadata) => metadata,
|
|
Err(source) if source.kind() == io::ErrorKind::NotFound => {
|
|
return Err(TlsConfigError::MissingFile {
|
|
endpoint: endpoint_name,
|
|
path,
|
|
});
|
|
}
|
|
Err(source) => {
|
|
return Err(TlsConfigError::FileMetadata {
|
|
endpoint: endpoint_name,
|
|
path,
|
|
source,
|
|
});
|
|
}
|
|
};
|
|
|
|
if !metadata.is_file() {
|
|
return Err(TlsConfigError::NotAFile {
|
|
endpoint: endpoint_name,
|
|
path,
|
|
});
|
|
}
|
|
|
|
if let Err(source) = File::open(&path) {
|
|
return Err(TlsConfigError::FileRead {
|
|
endpoint: endpoint_name,
|
|
path,
|
|
source,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Errors that can occur when establishing a TLS-encrypted migration channel.
|
|
#[derive(Error, Debug)]
|
|
pub enum TlsError {
|
|
#[error("The provided hostname could not be parsed")]
|
|
InvalidDnsName(#[source] InvalidDnsNameError),
|
|
|
|
#[error("Rustls protocol error")]
|
|
RustlsError(#[from] rustls::Error),
|
|
|
|
#[error("Rustls verifier configuration error")]
|
|
RustlsVerifierBuilderError(#[source] VerifierBuilderError),
|
|
|
|
#[error("Rustls protocol IO error")]
|
|
RustlsIoError(#[from] io::Error),
|
|
|
|
#[error("TLS handshake stalled: no read/write progress while handshake is still in progress")]
|
|
HandshakeError,
|
|
|
|
#[error("Error handling PEM file")]
|
|
RustlsPemError(#[from] pem::Error),
|
|
}
|
|
|
|
/// Wraps the concrete rustls stream for either side (server or client) of the
|
|
/// TLS connection.
|
|
///
|
|
/// [`TlsStream`] uses this enum to store a [`StreamOwned`] with either a
|
|
/// [`ClientConnection`] or [`ServerConnection`] while exposing a single
|
|
/// transport-agnostic API.
|
|
#[derive(Debug)]
|
|
enum TlsStreamParticipant {
|
|
Client(StreamOwned<ClientConnection, TcpStream>),
|
|
Server(StreamOwned<ServerConnection, TcpStream>),
|
|
}
|
|
|
|
/// Server/Client-agnostic TLS stream.
|
|
pub struct TlsStream {
|
|
stream: TlsStreamParticipant,
|
|
// `rustls` only accepts plaintext writes as regular byte slices, so
|
|
// `WriteVolatile` needs a staging buffer to copy out of guest memory.
|
|
write_buf: Vec<u8>,
|
|
}
|
|
|
|
impl TlsStream {
|
|
/// The maximum size of [`TlsStream::write_buf`]. This keeps the reusable buffer
|
|
/// from growing without bound.
|
|
const BUF_SIZE: usize = 64 /* KiB */ << 10;
|
|
|
|
/// Creates a client [`TlsStream`].
|
|
///
|
|
/// The client verifies the server certificate against `ca-cert.pem` and the
|
|
/// provided `hostname`, and presents the certificate chain in
|
|
/// `client-cert.pem` together with the private key in `client-key.pem` for
|
|
/// mutual TLS authentication.
|
|
pub fn new_client(
|
|
socket: TcpStream,
|
|
cert_dir: &Path,
|
|
hostname: &str,
|
|
) -> result::Result<Self, MigratableError> {
|
|
let root_store = load_root_store(&cert_dir.join(CA_CERT_FILE))?;
|
|
let client_certs = load_cert_chain(&cert_dir.join(CLIENT_CERT_FILE))?;
|
|
let client_key = load_private_key(&cert_dir.join(CLIENT_KEY_FILE))?;
|
|
|
|
let config = ClientConfig::builder()
|
|
.with_root_certificates(root_store)
|
|
.with_client_auth_cert(client_certs, client_key)
|
|
.map_err(TlsError::RustlsError)
|
|
.map_err(MigratableError::Tls)?;
|
|
let config = Arc::new(config);
|
|
|
|
let server_name = ServerName::try_from(hostname.to_string())
|
|
.map_err(TlsError::InvalidDnsName)
|
|
.map_err(MigratableError::Tls)?;
|
|
let conn = ClientConnection::new(config, server_name)
|
|
.map_err(TlsError::RustlsError)
|
|
.map_err(MigratableError::Tls)?;
|
|
|
|
let mut tls = StreamOwned::new(conn, socket);
|
|
while tls.conn.is_handshaking() {
|
|
let (rd, wr) = tls
|
|
.conn
|
|
.complete_io(&mut tls.sock)
|
|
.map_err(TlsError::RustlsIoError)
|
|
.map_err(MigratableError::Tls)?;
|
|
// No handshake progress on a connection that should be handshaking, we treat
|
|
// that as a failure.
|
|
if rd == 0 && wr == 0 {
|
|
return Err(MigratableError::Tls(TlsError::HandshakeError));
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
stream: TlsStreamParticipant::Client(tls),
|
|
write_buf: Vec::new(),
|
|
})
|
|
}
|
|
|
|
/// Creates a server [`TlsStream`]. Encrypts and decrypts data sent through
|
|
/// this stream using the certificates and key from the provided
|
|
/// [`TlsServerConfig`].
|
|
pub fn new_server(
|
|
socket: TcpStream,
|
|
config: &TlsServerConfig,
|
|
) -> result::Result<Self, MigratableError> {
|
|
let conn = ServerConnection::new(config.config.clone())
|
|
.map_err(TlsError::RustlsError)
|
|
.map_err(MigratableError::Tls)?;
|
|
|
|
let mut tls = StreamOwned::new(conn, socket);
|
|
while tls.conn.is_handshaking() {
|
|
let (rd, wr) = tls
|
|
.conn
|
|
.complete_io(&mut tls.sock)
|
|
.map_err(TlsError::RustlsIoError)
|
|
.map_err(MigratableError::Tls)?;
|
|
// No handshake progress on a connection that should be handshaking, we treat
|
|
// that as a failure.
|
|
if rd == 0 && wr == 0 {
|
|
return Err(MigratableError::Tls(TlsError::HandshakeError));
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
stream: TlsStreamParticipant::Server(tls),
|
|
write_buf: Vec::new(),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Read for TlsStream {
|
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
|
match &mut self.stream {
|
|
TlsStreamParticipant::Client(s) => Read::read(s, buf),
|
|
TlsStreamParticipant::Server(s) => Read::read(s, buf),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Write for TlsStream {
|
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
match &mut self.stream {
|
|
TlsStreamParticipant::Client(s) => Write::write(s, buf),
|
|
TlsStreamParticipant::Server(s) => Write::write(s, buf),
|
|
}
|
|
}
|
|
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
match &mut self.stream {
|
|
TlsStreamParticipant::Client(s) => Write::flush(s),
|
|
TlsStreamParticipant::Server(s) => Write::flush(s),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reading from or writing to these FDs would break the connection, because
|
|
// those reads or writes wouldn't go through rustls. But the FD is necessary to
|
|
// listen for incoming connections.
|
|
impl AsFd for TlsStream {
|
|
fn as_fd(&self) -> BorrowedFd<'_> {
|
|
match &self.stream {
|
|
TlsStreamParticipant::Client(s) => s.get_ref().as_fd(),
|
|
TlsStreamParticipant::Server(s) => s.get_ref().as_fd(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ReadVolatile for TlsStream {
|
|
fn read_volatile<B: BitmapSlice>(
|
|
&mut self,
|
|
vs: &mut VolatileSlice<B>,
|
|
) -> result::Result<usize, VolatileMemoryError> {
|
|
if vs.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
|
|
let chunk = match &mut self.stream {
|
|
TlsStreamParticipant::Client(s) => BufRead::fill_buf(s),
|
|
TlsStreamParticipant::Server(s) => BufRead::fill_buf(s),
|
|
}
|
|
.map_err(VolatileMemoryError::IOError)?;
|
|
|
|
let n = chunk.len().min(vs.len());
|
|
if n == 0 {
|
|
return Ok(0);
|
|
}
|
|
|
|
vs.copy_from(&chunk[..n]);
|
|
|
|
match &mut self.stream {
|
|
TlsStreamParticipant::Client(s) => BufRead::consume(s, n),
|
|
TlsStreamParticipant::Server(s) => BufRead::consume(s, n),
|
|
}
|
|
Ok(n)
|
|
}
|
|
}
|
|
|
|
impl WriteVolatile for TlsStream {
|
|
fn write_volatile<B: BitmapSlice>(
|
|
&mut self,
|
|
vs: &VolatileSlice<B>,
|
|
) -> Result<usize, VolatileMemoryError> {
|
|
let len = vs.len().min(Self::BUF_SIZE);
|
|
|
|
if len == 0 {
|
|
return Ok(0);
|
|
}
|
|
|
|
if self.write_buf.len() < len {
|
|
self.write_buf.resize(len, 0);
|
|
}
|
|
|
|
let buf = &mut self.write_buf[..len];
|
|
let n = vs.copy_to(buf);
|
|
|
|
if n == 0 {
|
|
return Ok(0);
|
|
}
|
|
|
|
let n = match &mut self.stream {
|
|
TlsStreamParticipant::Client(s) => Write::write(s, &buf[..n]),
|
|
TlsStreamParticipant::Server(s) => Write::write(s, &buf[..n]),
|
|
}
|
|
.map_err(VolatileMemoryError::IOError)?;
|
|
|
|
Ok(n)
|
|
}
|
|
}
|
|
|
|
/// Carries a TLS server configuration. Intended to be turned into a [`TlsStream`]
|
|
/// when paired with a [`TcpStream`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct TlsServerConfig {
|
|
/// This config is shared between all server connections.
|
|
config: Arc<ServerConfig>,
|
|
}
|
|
|
|
impl TlsServerConfig {
|
|
/// Creates a [`TlsServerConfig`] from the certificate chain in
|
|
/// `server-cert.pem`, the private key in `server-key.pem`, and the client
|
|
/// trust anchors in `ca-cert.pem`.
|
|
///
|
|
/// Client certificates presented during the TLS handshake must chain to a CA in
|
|
/// `ca-cert.pem`.
|
|
pub fn new(cert_dir: &Path) -> result::Result<Self, MigratableError> {
|
|
let server_certs = load_cert_chain(&cert_dir.join(SERVER_CERT_FILE))?;
|
|
let server_key = load_private_key(&cert_dir.join(SERVER_KEY_FILE))?;
|
|
// Trust anchors used to verify client certificates for mTLS.
|
|
let client_roots = Arc::new(load_root_store(&cert_dir.join(CA_CERT_FILE))?);
|
|
|
|
let client_verifier = WebPkiClientVerifier::builder(client_roots)
|
|
.build()
|
|
.map_err(TlsError::RustlsVerifierBuilderError)
|
|
.map_err(MigratableError::Tls)?;
|
|
|
|
let config = ServerConfig::builder()
|
|
.with_client_cert_verifier(client_verifier)
|
|
.with_single_cert(server_certs, server_key)
|
|
.map_err(TlsError::RustlsError)
|
|
.map_err(MigratableError::Tls)?;
|
|
let config = Arc::new(config);
|
|
Ok(Self { config })
|
|
}
|
|
}
|
|
|
|
/// Loads trusted CA certificates into a root store, i.e. the set of trust anchors
|
|
/// used to verify the peer's certificate chain.
|
|
fn load_root_store(cert_path: &Path) -> result::Result<RootCertStore, MigratableError> {
|
|
let mut root_store = RootCertStore::empty();
|
|
let (_, ignored) = root_store.add_parsable_certificates(
|
|
CertificateDer::pem_file_iter(cert_path)
|
|
.map_err(TlsError::RustlsPemError)
|
|
.map_err(MigratableError::Tls)?
|
|
.map(|cert| cert.map_err(TlsError::RustlsPemError))
|
|
.collect::<Result<Vec<CertificateDer<'static>>, TlsError>>()
|
|
.map_err(MigratableError::Tls)?,
|
|
);
|
|
|
|
if ignored > 0 {
|
|
warn!(
|
|
"Ignored {ignored} certificate(s) while loading TLS CA file {}",
|
|
cert_path.display()
|
|
);
|
|
}
|
|
|
|
Ok(root_store)
|
|
}
|
|
|
|
/// Loads a certificate chain to present during the TLS handshake.
|
|
fn load_cert_chain(
|
|
cert_path: &Path,
|
|
) -> result::Result<Vec<CertificateDer<'static>>, MigratableError> {
|
|
CertificateDer::pem_file_iter(cert_path)
|
|
.map_err(TlsError::RustlsPemError)
|
|
.map_err(MigratableError::Tls)?
|
|
.map(|cert| cert.map_err(TlsError::RustlsPemError))
|
|
.collect::<Result<Vec<CertificateDer<'static>>, TlsError>>()
|
|
.map_err(MigratableError::Tls)
|
|
}
|
|
|
|
/// Loads the private key that proves ownership of the presented certificate chain.
|
|
fn load_private_key(key_path: &Path) -> result::Result<PrivateKeyDer<'static>, MigratableError> {
|
|
PrivateKeyDer::from_pem_file(key_path)
|
|
.map_err(TlsError::RustlsPemError)
|
|
.map_err(MigratableError::Tls)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::path::PathBuf;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use std::{env, fs, process};
|
|
|
|
use super::*;
|
|
|
|
struct TestDir {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl TestDir {
|
|
fn new(name: &str) -> Self {
|
|
let unique = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let path = env::temp_dir().join(format!(
|
|
"cloud-hypervisor-{name}-{}-{unique}",
|
|
process::id()
|
|
));
|
|
fs::create_dir(&path).unwrap();
|
|
Self { path }
|
|
}
|
|
|
|
fn add_file(&self, file_name: &str) {
|
|
fs::write(self.path.join(file_name), b"test").unwrap();
|
|
}
|
|
|
|
fn add_client_files(&self) {
|
|
self.add_file(CA_CERT_FILE);
|
|
self.add_file(CLIENT_CERT_FILE);
|
|
self.add_file(CLIENT_KEY_FILE);
|
|
}
|
|
|
|
fn add_server_files(&self) {
|
|
self.add_file(CA_CERT_FILE);
|
|
self.add_file(SERVER_CERT_FILE);
|
|
self.add_file(SERVER_KEY_FILE);
|
|
}
|
|
}
|
|
|
|
impl Drop for TestDir {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.path);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validate_tls_dir_accepts_complete_client_directory() {
|
|
let dir = TestDir::new("tls-client");
|
|
dir.add_client_files();
|
|
|
|
validate_tls_dir(&dir.path, TlsEndpoint::Client).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn validate_tls_dir_accepts_complete_server_directory() {
|
|
let dir = TestDir::new("tls-server");
|
|
dir.add_server_files();
|
|
|
|
validate_tls_dir(&dir.path, TlsEndpoint::Server).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn validate_tls_dir_rejects_missing_role_specific_file() {
|
|
let dir = TestDir::new("tls-missing-client-key");
|
|
dir.add_file(CA_CERT_FILE);
|
|
dir.add_file(CLIENT_CERT_FILE);
|
|
|
|
let err = validate_tls_dir(&dir.path, TlsEndpoint::Client).unwrap_err();
|
|
let err = err.to_string();
|
|
assert!(err.contains(CLIENT_KEY_FILE), "unexpected error: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn validate_tls_dir_rejects_non_file_entry() {
|
|
let dir = TestDir::new("tls-non-file");
|
|
dir.add_file(CA_CERT_FILE);
|
|
dir.add_file(CLIENT_KEY_FILE);
|
|
fs::create_dir(dir.path.join(CLIENT_CERT_FILE)).unwrap();
|
|
|
|
let err = validate_tls_dir(&dir.path, TlsEndpoint::Client).unwrap_err();
|
|
let err = err.to_string();
|
|
assert!(err.contains(CLIENT_CERT_FILE), "unexpected error: {err}");
|
|
}
|
|
}
|