snp-daemon/src/state.rs

567 lines
19 KiB
Rust

#![allow(dead_code)]
use crate::config::Config;
use crate::constants::*;
use anyhow::anyhow;
use anyhow::Result;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::fs::remove_file;
use std::fs::File;
use std::io::Read;
use std::io::Write;
use std::path::Path;
use std::process::Command;
pub struct Resources {
// QEMU does not support MHz limiation
reserved_vcpus: usize,
reserved_memory: usize,
reserved_ports: HashSet<u16>,
storage_pools: Vec<StoragePool>,
reserved_ips: HashSet<String>,
reserved_if_names: HashSet<String>,
// sha256sum -> absolute path
boot_files: HashMap<String, String>,
}
impl Resources {
fn reserve_ports(&mut self, nr: u16, config: &Config) -> Vec<u16> {
use rand::Rng;
if config.public_port_range.len() < self.reserved_ports.len() + nr as usize {
return Vec::new();
}
if nr > config.max_ports_per_vm {
return Vec::new();
}
let mut published_ports = Vec::new();
for _ in 0..nr {
for _ in 0..5 {
let port = rand::thread_rng().gen_range(config.public_port_range.clone());
if self.reserved_ports.insert(port) {
published_ports.push(port);
}
break;
}
}
published_ports
}
fn reserve_vm_if(&mut self) -> String {
use rand::{distributions::Alphanumeric, Rng};
loop {
let mut interface_name: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(9)
.map(char::from)
.collect();
interface_name = "detee".to_string() + &interface_name;
if !self.reserved_if_names.contains(&interface_name) {
return interface_name;
}
}
}
fn reserve_public_ipv4(&mut self, config: &Config) -> Option<VMNIC> {
for nic in config.network_interfaces.iter() {
for range in nic.ipv4.iter() {
for ip in range.subnet.iter() {
if !range.reserved_addrs.contains(&ip.address())
&& !self.reserved_ips.contains(&ip.to_string())
{
let if_config = match nic.driver {
crate::config::InterfaceType::MACVTAP => InterfaceConfig::MACVTAP {
name: self.reserve_vm_if(),
device: nic.device.clone(),
},
crate::config::InterfaceType::IPVTAP => InterfaceConfig::IPVTAP {
name: self.reserve_vm_if(),
device: nic.device.clone(),
},
crate::config::InterfaceType::Bridge => InterfaceConfig::Bridge {
device: nic.device.clone(),
},
};
let mut ips = Vec::new();
let mask = ip
.network()
.to_string()
.split('/')
.next()
.unwrap()
.to_string();
ips.push(IPConfig {
address: ip.address().to_string(),
subnet: mask,
gateway: range.gateway.to_string(),
});
return Some(VMNIC { if_config, ips });
}
}
}
}
None
}
// TODO: refactor this garbage cause it's only one char different from the previous one
fn reserve_public_ipv6(&mut self, config: &Config) -> Option<VMNIC> {
for nic in config.network_interfaces.iter() {
for range in nic.ipv6.iter() {
for ip in range.subnet.iter() {
if !range.reserved_addrs.contains(&ip.address())
&& !self.reserved_ips.contains(&ip.to_string())
{
let if_config = match nic.driver {
crate::config::InterfaceType::MACVTAP => InterfaceConfig::MACVTAP {
name: self.reserve_vm_if(),
device: nic.device.clone(),
},
crate::config::InterfaceType::IPVTAP => InterfaceConfig::IPVTAP {
name: self.reserve_vm_if(),
device: nic.device.clone(),
},
crate::config::InterfaceType::Bridge => InterfaceConfig::Bridge {
device: nic.device.clone(),
},
};
let mut ips = Vec::new();
let mask = ip
.network()
.to_string()
.split('/')
.next()
.unwrap()
.to_string();
ips.push(IPConfig {
address: ip.address().to_string(),
subnet: mask,
gateway: range.gateway.to_string(),
});
return Some(VMNIC { if_config, ips });
}
}
}
}
None
}
fn scan_boot_files(&mut self) -> Result<()> {
for entry in fs::read_dir(VM_BOOT_DIR)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
match compute_sha256(&path) {
Ok(hash) => {
self.boot_files
.insert(hash, path.to_string_lossy().to_string());
}
Err(e) => return Err(anyhow!("Error computing hash for {:?}: {}", path, e)),
}
}
}
Ok(())
}
fn download_boot_file(&mut self, url: String, sha: String) -> Result<()> {
if !self.boot_files.contains_key(&sha) {
download_and_check_sha(&url, &sha)?;
}
self.boot_files.insert(sha, url);
Ok(())
}
}
pub struct StoragePool {
path: String,
current_reservation: u64,
// add mechanic to detect storage tier
// tier: StorageTier,
}
pub enum InterfaceConfig {
// TODO: instead of QEMU userspace NAT, use iptables kernelspace NAT
// in case of QEMU-base NAT, device name is not needed
NAT { device: String },
// TODO: figure how to calculate IF_NAME based on index
MACVTAP { name: String, device: String },
IPVTAP { name: String, device: String },
Bridge { device: String },
}
impl InterfaceConfig {
fn if_type(&self) -> String {
match self {
InterfaceConfig::IPVTAP { .. } => format!("ipvtap"),
InterfaceConfig::MACVTAP { .. } => format!("macvtap"),
InterfaceConfig::NAT { .. } => format!("nat"),
InterfaceConfig::Bridge { .. } => format!("bridge"),
}
}
fn device_name(&self) -> String {
match self {
InterfaceConfig::IPVTAP { device, .. } => device.clone(),
InterfaceConfig::MACVTAP { device, .. } => device.clone(),
InterfaceConfig::NAT { device, .. } => device.clone(),
InterfaceConfig::Bridge { device, .. } => device.clone(),
}
}
fn vtap_name(&self) -> Option<String> {
match self {
InterfaceConfig::IPVTAP { name, .. } => Some(name.clone()),
InterfaceConfig::MACVTAP { name, .. } => Some(name.clone()),
InterfaceConfig::NAT { .. } | InterfaceConfig::Bridge { .. } => None,
}
}
fn is_vtap(&self) -> bool {
match self {
InterfaceConfig::IPVTAP { .. } | InterfaceConfig::MACVTAP { .. } => true,
InterfaceConfig::NAT { .. } | InterfaceConfig::Bridge { .. } => false,
}
}
}
struct IPConfig {
address: String,
// requires short format (example: 24)
subnet: String,
gateway: String,
}
pub struct VMNIC {
if_config: InterfaceConfig,
ips: Vec<IPConfig>,
}
impl VMNIC {
fn new() -> VMNIC {
todo!("implement this here to improve code elegance of resource reservation");
}
}
pub struct VM {
uuid: String,
hostname: String,
admin_key: String,
fw_ports: Vec<u16>,
nics: Vec<VMNIC>,
// currently hardcoded to EPYC-v4
// cpu_type: String,
vcpus: usize,
// memory in MB
memory: usize,
// disk size in GB
disk_size: usize,
kernel_sha: String,
dtrfs_sha: String,
}
pub struct NewVMRequest {
uuid: String,
hostname: String,
admin_key: String,
nr_of_fw_ports: u16,
public_ipv4: bool,
public_ipv6: bool,
disk_size: usize,
vcpus: usize,
memory: usize,
kernel_url: String,
kernel_sha: String,
dtrfs_url: String,
dtrfs_sha: String,
}
pub enum VMCreationErrors {
NATandIPv4Conflict,
TooManyCores,
NotEnoughPorts,
NotEnoughCPU,
NotEnoughMemory,
NotEnoughStorage,
IPv4NotAvailable,
IPv6NotAvailable,
BootFileError(String),
}
impl VM {
pub fn new(
req: NewVMRequest,
config: &Config,
res: &mut Resources,
) -> Result<Self, VMCreationErrors> {
if req.nr_of_fw_ports > 0 && req.public_ipv4 {
return Err(VMCreationErrors::NATandIPv4Conflict);
}
if config.max_cores_per_vm < req.vcpus {
return Err(VMCreationErrors::TooManyCores);
}
if config.max_vcpu_reservation < res.reserved_vcpus.saturating_add(req.vcpus) {
return Err(VMCreationErrors::NotEnoughCPU);
}
if config.max_mem_reservation < res.reserved_memory.saturating_add(req.memory) {
return Err(VMCreationErrors::NotEnoughMemory);
}
if let Err(kernel_file_error) =
res.download_boot_file(req.kernel_url, req.kernel_sha.clone())
{
return Err(VMCreationErrors::BootFileError(format!(
"Could not get kernel: {kernel_file_error:?}"
)));
};
if let Err(dtrfs_file_error) = res.download_boot_file(req.dtrfs_url, req.dtrfs_sha.clone())
{
return Err(VMCreationErrors::BootFileError(format!(
"Could not get kernel: {dtrfs_file_error:?}"
)));
};
let mut vm_nics = Vec::new();
if req.public_ipv4 {
match res.reserve_public_ipv4(config) {
Some(vmnic) => vm_nics.push(vmnic),
None => return Err(VMCreationErrors::IPv4NotAvailable),
}
}
if req.public_ipv6 {
match res.reserve_public_ipv4(config) {
Some(mut vmnic) => {
if let Some(mut existing_vmnic) = vm_nics.pop() {
if vmnic.if_config.device_name() == existing_vmnic.if_config.device_name() {
vmnic.ips.append(&mut existing_vmnic.ips);
vm_nics.push(vmnic);
} else {
vm_nics.push(existing_vmnic);
vm_nics.push(vmnic);
}
} else {
vm_nics.push(vmnic);
}
}
None => {
if let Some(existing_vmnic) = vm_nics.pop() {
for ip in existing_vmnic.ips {
res.reserved_ips.remove(&ip.address);
}
}
return Err(VMCreationErrors::IPv4NotAvailable);
}
}
}
let fw_ports = res.reserve_ports(req.nr_of_fw_ports, &config);
if fw_ports.len() == 0 {
for nic in vm_nics {
for ip in nic.ips {
res.reserved_ips.remove(&ip.address);
}
}
return Err(VMCreationErrors::NotEnoughPorts);
}
Ok(VM {
uuid: req.uuid,
hostname: req.hostname,
admin_key: req.admin_key,
nics: vm_nics,
vcpus: req.vcpus,
memory: req.memory,
disk_size: req.disk_size,
kernel_sha: req.kernel_sha,
dtrfs_sha: req.dtrfs_sha,
fw_ports,
})
}
pub fn deploy(&self) -> Result<()> {
self.create_disk()?;
self.write_vm_config()?;
self.write_systemd_unit_file()?;
// TODO: daemon-reload, systemctl enable, systemctl start
Ok(())
}
pub fn delete(&self) -> Result<()> {
// TODO: systemctl disable, systemctl stop
self.delete_disk()?;
self.delete_vtap_interfaces()?;
self.delete_systemd_unit_file()?;
// TODO: systemctl daemon-reload
Ok(())
}
// For the MVP, the T-Contract offers VM+IP+Disk as a bundle.
// This means we can enforce the path to the disk.
// This may change in the future as the VM is allowed to have multiple disks.
pub fn disk_path(&self) -> String {
VM_DISK_DIR.to_string() + "/" + &self.uuid + ".qcow2"
}
pub fn kernel_params(&self) -> String {
let mut ip_string = String::new();
let mut i = 0;
for nic in self.nics.iter() {
for ip in nic.ips.iter() {
ip_string += &format!(
"detee_net_eth{}={}_{}_{}",
i, ip.address, ip.subnet, ip.gateway
);
}
i += 1;
}
let admin_key = format!("detee_admin={}", self.admin_key);
let hostname = format!("detee_name={}", self.hostname);
format!("{} {} {}", ip_string, admin_key, hostname)
}
pub fn write_vm_config(&self) -> Result<()> {
let mut vars = String::new();
let mut i = 0;
for nic in self.nics.iter() {
let mut interface = String::new();
interface += &format!("NETWORK_INTERFACE_{}={}", i, nic.if_config.if_type());
// device is currently ignored in case of NAT cause we assume QEMU userspace NAT
if let Some(vtap_name) = nic.if_config.vtap_name() {
interface += &format!("_{}_{}", nic.if_config.device_name(), vtap_name);
}
vars += &format!("{}\n", interface);
i += 1;
}
vars += &format!("KERNEL={}\n", VM_BOOT_DIR.to_string() + "/" + &self.kernel_sha);
vars += &format!("INITRD={}\n", VM_BOOT_DIR.to_string() + "/" + &self.dtrfs_sha);
vars += &format!("PARAMS={}\n", self.kernel_params());
vars += &format!("CPU_TYPE={}\n", QEMU_VM_CPU_TYPE);
vars += &format!("VCPUS={}\n", self.vcpus);
vars += &format!("MEMORY={}MB\n", self.memory);
vars += &format!("MAX_MEMORY={}MB\n", self.memory + 256);
vars += &format!("DISK={}\n", self.disk_path());
vars += &format!("DISK_SIZE={}GB\n", self.disk_size);
let mut file = File::create(VM_CONFIG_DIR.to_string() + "/" + &self.uuid)?;
file.write_all(vars.as_bytes())?;
Ok(())
}
pub fn delete_vm_config(&self) -> Result<()> {
remove_file(VM_CONFIG_DIR.to_string() + "/" + &self.uuid)?;
Ok(())
}
pub fn delete_vtap_interfaces(&self) -> Result<()> {
for nic in self.nics.iter() {
if let Some(name) = nic.if_config.vtap_name() {
let result = Command::new("ip")
.arg("link")
.arg("del")
.arg(&name)
.output()?;
if !result.status.success() {
return Err(anyhow!(
"Could not delete vtap interface {:?}:\n{:?}\n{:?}",
name,
result.stdout,
result.stderr
));
}
}
}
Ok(())
}
pub fn write_systemd_unit_file(&self) -> Result<()> {
let mut contents = String::new();
contents += &format!("[Unit]");
contents += &format!("Description=DeTEE {}", self.uuid);
contents += &format!("After=network.target");
contents += &format!("");
contents += &format!("[Service]");
contents += &format!("Type=simple");
contents += &format!("Environment=VM_UUID={}", self.uuid);
contents += &format!("ExecStart={}", START_VM_SCRIPT);
contents += &format!("ExecStop=/bin/kill -s SIGINT $MAINPID");
contents += &format!("Restart=always");
contents += &format!("");
contents += &format!("[Install]");
contents += &format!("WantedBy=multi-user.target");
let mut file = File::create("/etc/systemd/system/".to_string() + &self.uuid + ".service")?;
file.write_all(contents.as_bytes())?;
Ok(())
}
pub fn delete_systemd_unit_file(&self) -> Result<()> {
remove_file("/etc/systemd/system/".to_string() + &self.uuid + ".service")?;
Ok(())
}
pub fn create_disk(&self) -> Result<()> {
let result = Command::new("qemu-img")
.arg("create")
.arg("-f")
.arg(self.disk_path())
.arg(self.disk_size.to_string() + "G")
.output()?;
if !result.status.success() {
return Err(anyhow!(
"Could not create VM Disk:\n{:?}\n{:?}",
result.stdout,
result.stderr
));
}
Ok(())
}
pub fn delete_disk(&self) -> Result<()> {
remove_file(self.disk_path())?;
Ok(())
}
}
fn download_and_check_sha(url: &str, sha: &str) -> Result<()> {
use reqwest::blocking::get;
use std::fs::File;
use std::io::copy;
use std::path::Path;
let save_path = VM_BOOT_DIR.to_string() + "/" + sha;
let response = get(url)?;
if !response.status().is_success() {
return Err(anyhow!("Failed to download file: {}", response.status()));
}
let mut file = File::create(Path::new(&save_path))?;
copy(&mut response.bytes()?.as_ref(), &mut file)?;
match compute_sha256(&save_path) {
Ok(hash) => {
if hash != sha {
return Err(anyhow!(
"Sha of the downloaded file did not match supplised sha: {} vs {}",
hash,
sha
));
}
}
Err(e) => return Err(anyhow!("Error computing hash for {:?}: {}", save_path, e)),
}
Ok(())
}
fn compute_sha256<P: AsRef<Path>>(path: P) -> Result<String> {
let mut file = fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = file.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let result = hasher.finalize();
Ok(format!("{:x}", result))
}