Initial commit

This commit is contained in:
snow flurry 2022-12-29 22:06:14 -08:00
commit 4c02261015
35 changed files with 7969 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
.DS_Store
.vscode

2332
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

6
Cargo.toml Normal file
View file

@ -0,0 +1,6 @@
[workspace]
members = [
"nzrd",
"api",
"client",
]

14
api/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "nzr-api"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
figment = { version = "0.10.8", features = ["json", "toml", "env"] }
serde = { version = "1", features = ["derive"] }
tarpc = { version = "0.31", features = ["tokio1", "unix"] }
tokio = { version = "1.0", features = ["macros"] }
uuid = "1.2.2"
trust-dns-proto = { version = "0.22.0", features = ["serde-config"] }

27
api/src/args.rs Normal file
View file

@ -0,0 +1,27 @@
use serde::{Deserialize, Serialize};
use crate::net::cidr::CidrV4;
use std::net::Ipv4Addr;
#[derive(Debug, Serialize, Deserialize)]
pub struct NewInstance {
pub name: String,
pub title: Option<String>,
pub description: Option<String>,
pub interface: String,
pub base_image: String,
pub cores: u8,
pub memory: u32,
pub disk_sizes: (u32, Option<u32>),
pub ssh_keys: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NewSubnet {
pub if_name: String,
pub network: CidrV4,
pub start_addr: Option<Ipv4Addr>,
pub end_addr: Option<Ipv4Addr>,
pub gateway: Option<Ipv4Addr>,
pub dns: Vec<Ipv4Addr>,
}

112
api/src/config.rs Normal file
View file

@ -0,0 +1,112 @@
use std::path::PathBuf;
use figment::{
providers::{Env, Format, Json, Toml},
Figment, Metadata, Provider,
};
use serde::{Deserialize, Serialize};
use trust_dns_proto::rr::Name;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StorageConfig {
pub primary_pool: String,
pub secondary_pool: String,
pub ci_image_pool: String,
pub base_image_pool: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SOAConfig {
pub nzr_domain: Name,
pub contact: Name,
pub refresh: i32,
pub retry: i32,
pub expire: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DNSConfig {
pub listen_addr: String,
pub default_zone: Name,
pub soa: SOAConfig,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RPCConfig {
pub socket_path: PathBuf,
pub admin_group: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub rpc: RPCConfig,
pub log_level: String,
pub db_path: PathBuf,
pub libvirt_uri: String,
pub storage: StorageConfig,
pub dns: DNSConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
log_level: "WARN".to_owned(),
rpc: RPCConfig {
socket_path: PathBuf::from("/var/run/nazrin/nzrd.sock"),
admin_group: None,
},
db_path: PathBuf::from("/var/run/nazrin/nzr.db"),
libvirt_uri: match std::env::var("LIBVIRT_URI") {
Ok(v) => v,
Err(_) => String::from("qemu:///system"),
},
storage: StorageConfig {
primary_pool: "pri".to_owned(),
secondary_pool: "data".to_owned(),
ci_image_pool: "cidata".to_owned(),
base_image_pool: "images".to_owned(),
},
dns: DNSConfig {
listen_addr: "127.0.0.1:5353".to_owned(),
default_zone: Name::from_utf8("servers.local").unwrap(),
soa: SOAConfig {
nzr_domain: Name::from_utf8("nzr.local").unwrap(),
contact: Name::from_utf8("admin.nzr.local").unwrap(),
refresh: 86400,
retry: 7200,
expire: 3_600_000,
},
},
}
}
}
impl Provider for Config {
fn metadata(&self) -> figment::Metadata {
Metadata::named("Nazrin config")
}
fn data(
&self,
) -> Result<figment::value::Map<figment::Profile, figment::value::Dict>, figment::Error> {
figment::providers::Serialized::defaults(Config::default()).data()
}
fn profile(&self) -> Option<figment::Profile> {
None
}
}
impl Config {
pub fn figment() -> Figment {
let mut fig = Figment::from(Config::default()).merge(Toml::file("/etc/nazrin.conf"));
#[allow(deprecated)]
if let Some(mut home) = std::env::home_dir() {
home.push(".nazrin.conf");
fig = fig.merge(Json::file(home));
}
fig.merge(Env::prefixed("NZR_"))
}
}

33
api/src/lib.rs Normal file
View file

@ -0,0 +1,33 @@
use model::{Instance, Subnet};
pub mod args;
pub mod config;
pub mod model;
pub mod net;
pub use trust_dns_proto;
#[tarpc::service]
pub trait Nazrin {
/// Creates a new instance.
async fn new_instance(build_args: args::NewInstance) -> Result<Instance, String>;
/// Deletes an existing instance.
///
/// This should involve deleting all related disks and clearing
/// the lease information from the subnet data, if any.
async fn delete_instance(name: String) -> Result<(), String>;
/// Gets a list of existing instances.
async fn get_instances() -> Result<Vec<Instance>, String>;
/// Cleans up unusable entries in the database.
async fn garbage_collect() -> Result<(), String>;
/// Creates a new subnet.
///
/// Unlike instances, subnets shouldn't perform any changes to the
/// interfaces they reference. This should be used primarily for
/// ease-of-use and bookkeeping (e.g., assigning dynamic leases).
async fn new_subnet(build_args: Subnet) -> Result<Subnet, String>;
/// Gets a list of existing subnets.
async fn get_subnets() -> Result<Vec<Subnet>, String>;
/// Deletes an existing subnet.
async fn delete_subnet(interface: String) -> Result<(), String>;
}

162
api/src/model.rs Normal file
View file

@ -0,0 +1,162 @@
use serde::{Deserialize, Serialize};
use std::{fmt, net::Ipv4Addr, str::FromStr};
use trust_dns_proto::rr::Name;
use crate::net::{cidr::CidrV4, mac::MacAddr};
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[repr(u32)]
pub enum DomainState {
NoState = 0u32,
Running,
Blocked,
Paused,
ShuttingDown,
ShutOff,
Crashed,
Suspended,
Unknown(u32),
}
impl Default for DomainState {
fn default() -> Self {
Self::NoState
}
}
impl From<u32> for DomainState {
fn from(value: u32) -> Self {
match value {
0 => Self::NoState,
1 => Self::Running,
2 => Self::Blocked,
3 => Self::Paused,
4 => Self::ShuttingDown,
5 => Self::ShutOff,
6 => Self::Crashed,
7 => Self::Suspended,
other => Self::Unknown(other),
}
}
}
impl fmt::Display for DomainState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
Self::NoState => "no state".to_owned(),
Self::Running => "running".to_owned(),
Self::Blocked => "blocked".to_owned(),
Self::Paused => "paused".to_owned(),
Self::ShuttingDown => "shutting down".to_owned(),
Self::ShutOff => "shut off".to_owned(),
Self::Crashed => "crashed".to_owned(),
Self::Suspended => "suspended".to_owned(),
Self::Unknown(code) => format!("unknown ({})", code),
}
)
}
}
/// Struct representing a VM instance.
#[derive(Debug, Serialize, Deserialize)]
pub struct Instance {
pub name: String,
pub uuid: uuid::Uuid,
pub lease: Option<Lease>,
pub state: DomainState,
}
/// Struct representing a logical "lease" held by a VM.
#[derive(Debug, Serialize, Deserialize)]
pub struct Lease {
/// The IPv4 address held by the Lease
pub addr: CidrV4,
/// The MAC address associated by the Lease
pub mac_addr: MacAddr,
}
/// Struct representing a subnet used by the host for virtual
/// networking.
#[derive(Debug, Serialize, Deserialize)]
pub struct Subnet {
/// The name of the interface the subnet is accessible via.
pub ifname: IfaceStr,
pub data: SubnetData,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SubnetData {
/// The network information for the subnet.
pub network: CidrV4,
/// The first host address that can be assigned dynamically
/// on the subnet.
pub start_host: Ipv4Addr,
/// The last host address that can be assigned dynamically
/// on the subnet.
pub end_host: Ipv4Addr,
/// The default gateway for the subnet.
pub gateway4: Option<Ipv4Addr>,
/// The primary DNS server for the subnet.
pub dns: Vec<Ipv4Addr>,
/// The base domain used for DNS lookup.
pub domain_name: Option<Name>,
}
/// A wrapper struct for [u8; 16], representing the maximum length
/// for an interface's name.
#[derive(Debug, Serialize, Deserialize)]
pub struct IfaceStr {
name: [u8; 16],
}
impl AsRef<[u8]> for IfaceStr {
fn as_ref(&self) -> &[u8] {
&self.name
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum ParseError {
BadSize(String),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Interface name must be at most 15 characters")
}
}
impl std::error::Error for ParseError {}
impl FromStr for IfaceStr {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.bytes().len() > 15 {
Err(Self::Err::BadSize(s.to_owned()))
} else {
Ok(IfaceStr {
name: {
let mut ifstr = [0u8; 16];
let bytes = s.as_bytes();
ifstr[..bytes.len()].copy_from_slice(&bytes[..bytes.len()]);
ifstr
},
})
}
}
}
impl fmt::Display for IfaceStr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
std::str::from_utf8(&self.name)
.map(|a| a.trim_end_matches(char::from(0)))
.map_err(|_| fmt::Error)?
)
}
}

257
api/src/net/cidr.rs Normal file
View file

@ -0,0 +1,257 @@
use std::fmt;
use std::net::{AddrParseError, Ipv4Addr};
use std::num::ParseIntError;
use std::str::FromStr;
use serde::{de, Deserialize, Serialize};
#[derive(Debug)]
pub enum Error {
Malformed,
AddrParse(AddrParseError),
SuffixParse(ParseIntError),
InvalidSize,
HostBitsTooLarge,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed => write!(f, "Malformed address/cidr combination"),
Self::AddrParse(er) => write!(f, "Couldn't parse address: {}", er),
Self::SuffixParse(er) => write!(f, "Couldn't parse CIDR suffix: {}", er),
Self::InvalidSize => write!(f, "Byte array needs to be at least 5 bytes long"),
Self::HostBitsTooLarge => write!(
f,
"Provided host does not match bits allowed by subnet mask"
),
}
}
}
impl std::error::Error for Error {}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct CidrV4 {
pub addr: Ipv4Addr,
cidr: u8,
netmask: u32,
}
impl fmt::Display for CidrV4 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.addr, self.cidr)
}
}
impl FromStr for CidrV4 {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts = s.split('/').collect::<Vec<&str>>();
match parts.len() {
2 => {
let addr = Ipv4Addr::from_str(parts[0]).map_err(Error::AddrParse)?;
let cidr = u8::from_str(parts[1]).map_err(Error::SuffixParse)?;
Ok(CidrV4::new(addr, cidr))
}
_ => Err(Error::Malformed),
}
}
}
impl Serialize for CidrV4 {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for CidrV4 {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
CidrV4::from_str(s.as_str()).map_err(de::Error::custom)
}
}
impl From<[u8; 5]> for CidrV4 {
fn from(octets: [u8; 5]) -> Self {
let addr = Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
let cidr = octets[4];
Self::new(addr, cidr)
}
}
impl TryFrom<&[u8]> for CidrV4 {
type Error = Error;
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
if value.len() < 5 {
Err(Error::InvalidSize)
} else {
// unwrap should be fine here, since we already validate for size?
let arr: [u8; 5] = value[0..5].try_into().unwrap();
Ok(Self::from(arr))
}
}
}
impl CidrV4 {
pub fn new(addr: Ipv4Addr, cidr: u8) -> Self {
let netmask = match cidr {
0 => 0,
32.. => u32::MAX,
c => ((1u32 << c) - 1) << (32 - c),
};
CidrV4 {
addr,
cidr,
netmask,
}
}
/// Get the subnet mask as an Ipv4Addr.
pub fn netmask(&self) -> Ipv4Addr {
Ipv4Addr::from(self.netmask)
}
/// Get the network address.
pub fn network(&self) -> CidrV4 {
CidrV4::new(
Ipv4Addr::from(u32::from(self.addr) & self.netmask),
self.cidr,
)
}
/// Get the network bit length in CIDR notation.
pub fn cidr(&self) -> u8 {
self.cidr
}
/// Get the broadcast address for the network.
pub fn broadcast(&self) -> Ipv4Addr {
Ipv4Addr::from(u32::from(self.network().addr) | !self.netmask)
}
/// Determine if a network contains a given address.
///
/// This method is not affected by the object's host address.
pub fn contains(&self, addr: &Ipv4Addr) -> bool {
if self.cidr == 32 {
&self.addr == addr
} else {
&self.broadcast() > addr && addr > &self.network().addr
}
}
/// Gets only the host bits for a given Ipv4 address.
///
/// This method is not affected by the object's host address.
pub fn host_bits(&self, addr: &Ipv4Addr) -> u32 {
let addr_bits = u32::from(*addr);
addr_bits & !self.netmask
}
/// Create an IP from a given u32 of host bits.
///
/// This method is not affected by the object's host address.
pub fn make_ip(&self, host_bits: u32) -> Result<Ipv4Addr, Error> {
if host_bits > !self.netmask {
Err(Error::HostBitsTooLarge)
} else {
Ok((host_bits | u32::from(self.network().addr)).into())
}
}
/// Get the address and CIDR bit length as an array of bytes.
///
/// This is in big endian format, so e.g., 192.168.0.5/24 would
/// be returned as `[192, 168, 0, 5, 24]`.
pub fn octets(&self) -> [u8; 5] {
[self.network().addr.octets().as_slice(), &[self.cidr]]
.concat()
.try_into()
.unwrap()
}
}
#[cfg(test)]
mod test {
use crate::net::cidr::CidrV4;
use std::{net::Ipv4Addr, str::FromStr};
const ADDR_BYTES: u32 = 0xc0_a8_02_a9; // 192.168.2.169
#[allow(clippy::unusual_byte_groupings)]
const NETWORK_TRUTH_MAP: &[(u8, u32, u32, [u8; 5])] = &[
(
8,
0b11111111_00000000_00000000_00000000,
0xc0_00_00_00,
[0xc0, 0, 0, 0, 8],
),
(
16,
0b11111111_11111111_00000000_00000000,
0xc0_a8_00_00,
[0xc0, 0xa8, 0, 0, 16],
),
(
25,
0b11111111_11111111_11111111_10000000,
0xc0_a8_02_80,
[0xc0, 0xa8, 0x02, 0x80, 25],
),
(0, 0, 0, [0u8; 5]),
(
32,
0b11111111_11111111_11111111_11111111,
0xc0_a8_02_a9,
[0xc0, 0xa8, 0x02, 0xa9, 32],
),
];
#[test]
fn v4_constructor() {
for (cidr, netmask, net_addr, _) in NETWORK_TRUTH_MAP {
let test = CidrV4::new(Ipv4Addr::from(ADDR_BYTES), *cidr);
assert_eq!(u32::from(test.netmask()), *netmask);
assert_eq!(test.cidr(), *cidr);
assert_eq!(u32::from(test.network().addr), *net_addr);
}
}
#[test]
fn v4_fromstr() {
let addr = Ipv4Addr::from(ADDR_BYTES);
for (cidr, _netmask, net_addr, _) in NETWORK_TRUTH_MAP {
let addr_str = format!("{}/{}", addr, cidr);
println!(">> {}", addr_str);
let cidr = CidrV4::from_str(&addr_str).unwrap();
assert_eq!(cidr.network().addr, Ipv4Addr::from(*net_addr));
assert!(cidr.contains(&addr));
}
}
#[test]
fn v4_contains() {
let yes = Ipv4Addr::from(ADDR_BYTES);
let no = Ipv4Addr::from(0x08_08_08_08);
let cidr = CidrV4::new(yes, 8);
assert!(cidr.contains(&yes));
assert!(!cidr.contains(&no));
}
#[test]
fn v4_octets() {
for (cidr, _, _, netbytes) in NETWORK_TRUTH_MAP {
let test = CidrV4::new(Ipv4Addr::from(ADDR_BYTES), *cidr);
assert_eq!(&test.octets(), netbytes);
}
}
}

96
api/src/net/mac.rs Normal file
View file

@ -0,0 +1,96 @@
use std::{fmt, str::FromStr};
use serde::{de, Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MacAddr {
octets: [u8; 6],
}
impl Serialize for MacAddr {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for MacAddr {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
MacAddr::from_str(s.as_str()).map_err(de::Error::custom)
}
}
#[derive(Debug)]
pub enum Error {
ParseError(std::num::ParseIntError),
SizeError(usize),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ParseError(er) => write!(f, "Couldn't parse octets: {}", er),
Self::SizeError(sz) => write!(f, "Too many octets; expected 6, string had {}", sz),
}
}
}
impl std::error::Error for Error {}
impl fmt::Display for MacAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
self.octets.map(|oct| format!("{:02x}", oct)).join(":")
)
}
}
impl FromStr for MacAddr {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let octets = s
.split(':')
.map(|s| u8::from_str_radix(s, 16))
.collect::<Result<Vec<u8>, std::num::ParseIntError>>()
.map_err(Error::ParseError)?;
Ok(MacAddr {
octets: octets
.try_into()
.map_err(|v: Vec<u8>| Error::SizeError(v.len()))?,
})
}
}
impl MacAddr {
pub fn new(a: u8, b: u8, c: u8, d: u8, e: u8, f: u8) -> MacAddr {
MacAddr {
octets: [a, b, c, d, e, f],
}
}
pub fn from_bytes<T>(value: T) -> Result<MacAddr, Error>
where
T: AsRef<[u8]>,
{
let slice = value.as_ref();
if slice.len() < 6 {
Err(Error::SizeError(slice.len()))
} else {
Ok(MacAddr {
octets: slice[0..6].try_into().unwrap(),
})
}
}
pub fn octets(&self) -> [u8; 6] {
self.octets
}
}

2
api/src/net/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod cidr;
pub mod mac;

15
client/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "nzr"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
nzr-api = { path = "../api" }
clap = { version = "4.0.26", features = ["derive"] }
home = "0.5.4"
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
tokio-serde = { version = "0.8.0", features = ["bincode"] }
tarpc = { version = "0.31", features = ["tokio1", "unix", "serde-transport", "serde-transport-bincode"] }
tabled = "0.10.0"

290
client/src/main.rs Normal file
View file

@ -0,0 +1,290 @@
use clap::{Parser, Subcommand};
use nzr_api::model;
use nzr_api::net::cidr::CidrV4;
use nzr_api::trust_dns_proto::rr::Name;
use nzr_api::{config, NazrinClient};
use std::any::{Any, TypeId};
use std::path::PathBuf;
use std::str::FromStr;
use tarpc::tokio_serde::formats::Bincode;
use tarpc::tokio_util::codec::LengthDelimitedCodec;
use tokio::net::UnixStream;
mod table;
#[derive(Debug, clap::Args)]
pub struct NewInstanceArgs {
/// Name of the instance to be created
name: String,
/// Bridge the instance will initially run on
#[arg(short, long)]
interface: String,
/// Long description of the instance
#[arg(long)]
description: Option<String>,
/// Base image to use for the instance
#[arg(short, long)]
base: String,
///How many cores to assign to the instance
#[arg(short, long, default_value_t = 2)]
cores: u8,
/// Memory to assign, in MiB
#[arg(short, long, default_value_t = 1024)]
mem: u32,
/// Primary HDD size, in MiB
#[arg(short, long, default_value_t = 20)]
primary_size: u32,
/// Secndary HDD size, in MiB
#[arg(short, long)]
secondary_size: Option<u32>,
/// File containing a list of SSH keys to use
#[arg(long)]
sshkey_file: Option<PathBuf>,
}
#[derive(Debug, Subcommand)]
enum InstanceCmd {
/// Create a new instance
New(NewInstanceArgs),
/// Delete an existing instance
Delete { name: String },
/// List all instances
List,
/// Deletes all invalid instances from the database
Prune,
}
#[derive(Debug, clap::Args)]
pub struct AddNetArgs {
/// Name of the bridge interface VMs will attach to
pub interface: String,
/// Subnet associated with the bridge interface, in CIDR notation (x.x.x.x/y)
pub network: String,
/// Default gateway for the subnet
#[arg(long)]
pub gateway: Option<std::net::Ipv4Addr>,
/// Default DNS address for the subnet
#[arg(long)]
pub dns_server: Option<std::net::Ipv4Addr>,
/// Start address for IP assignment
#[arg(short, long)]
pub start_addr: Option<std::net::Ipv4Addr>,
/// End address for IP assignment
#[arg(short, long)]
pub end_addr: Option<std::net::Ipv4Addr>,
#[arg(short, long)]
pub domain_name: Option<Name>,
}
#[derive(Debug, Subcommand)]
enum NetCmd {
/// Add a new network to the database
Add(AddNetArgs),
/// List all networks in the database
List,
/// Delete a network from the database
Delete {
#[arg(short, long)]
interface: String,
},
}
#[derive(Debug, Subcommand)]
enum Commands {
/// Commands for managing instances
Instance {
#[command(subcommand)]
command: InstanceCmd,
},
/// Commands for managing network assignments
Net {
#[command(subcommand)]
command: NetCmd,
},
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = "")]
struct Args {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug)]
struct CommandError {
inner: Option<Box<dyn std::error::Error>>,
message: String,
}
impl std::fmt::Display for CommandError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", &self.message)?;
if let Some(inner) = &self.inner {
write!(f, "\n inner: {:?}", &inner)?;
}
Ok(())
}
}
impl std::error::Error for CommandError {}
impl From<String> for CommandError {
fn from(value: String) -> Self {
Self {
inner: None,
message: value,
}
}
}
impl From<&str> for CommandError {
fn from(value: &str) -> Self {
Self {
inner: None,
message: value.to_owned(),
}
}
}
impl CommandError {
fn new<S, E>(message: S, inner: E) -> Self
where
S: AsRef<str>,
E: std::error::Error + 'static,
{
Self {
message: message.as_ref().to_owned(),
inner: Some(Box::new(inner)),
}
}
}
async fn handle_command() -> Result<(), Box<dyn std::error::Error>> {
let cli = Args::parse();
let config: config::Config = nzr_api::config::Config::figment().extract()?;
let conn = UnixStream::connect(&config.rpc.socket_path).await?;
let codec_builder = LengthDelimitedCodec::builder();
let transport = tarpc::serde_transport::new(codec_builder.new_framed(conn), Bincode::default());
let client = NazrinClient::new(Default::default(), transport).spawn();
match cli.command {
Commands::Instance { command } => {
match command {
InstanceCmd::New(args) => {
let ssh_keys: Vec<String> = {
let key_file =
args.sshkey_file.map_or_else(
|| {
home::home_dir().map_or_else(|| {
Err(CommandError::from("SSH keyfile not defined, and couldn't find home directory"))
}, |hd| {
Ok(hd.join(".ssh/authorized_keys"))
})
},
Ok,
)?;
if !key_file.exists() {
Err("SSH keyfile doesn't exist".into())
} else {
match std::fs::read_to_string(&key_file) {
Ok(data) => {
let keys: Vec<String> =
data.split('\n').map(|s| s.trim().to_owned()).collect();
Ok(keys)
}
Err(err) => Err(CommandError::new(
format!("Couldn't read {} for SSH keys", &key_file.display()),
err,
)),
}
}
}?;
let build_args = nzr_api::args::NewInstance {
name: args.name,
title: None,
description: args.description,
interface: args.interface,
base_image: args.base,
cores: args.cores,
memory: args.mem,
disk_sizes: (args.primary_size, args.secondary_size),
ssh_keys,
};
let instance = (client
.new_instance(tarpc::context::current(), build_args)
.await?)?;
println!("Instance {} created!", &instance.name);
if let Some(lease) = instance.lease {
println!(
"You should be able to reach it at:\n\n ssh root@{}",
lease.addr.addr,
);
}
}
InstanceCmd::Delete { name } => {
(client
.delete_instance(tarpc::context::current(), name)
.await?)?;
}
InstanceCmd::List => {
let instances = client.get_instances(tarpc::context::current()).await?;
let tabular: Vec<table::Instance> =
instances?.iter().map(table::Instance::from).collect();
let mut table = tabled::Table::new(&tabular);
println!("{}", table.with(tabled::Style::psql()));
}
InstanceCmd::Prune => (client.garbage_collect(tarpc::context::current()).await?)?,
}
}
Commands::Net { command } => match command {
NetCmd::Add(args) => {
let net_arg = CidrV4::from_str(&args.network)?;
let build_args = model::Subnet {
ifname: model::IfaceStr::from_str(&args.interface)?,
data: model::SubnetData {
network: net_arg.clone(),
start_host: args.start_addr.unwrap_or(net_arg.make_ip(10)?),
end_host: args
.end_addr
.unwrap_or((u32::from(net_arg.broadcast()) - 1u32).into()),
gateway4: args.gateway,
dns: args.dns_server.map_or(Vec::new(), |d| vec![d]),
domain_name: args.domain_name,
},
};
(client
.new_subnet(tarpc::context::current(), build_args)
.await?)?;
}
NetCmd::Delete { interface } => {
(client
.delete_subnet(tarpc::context::current(), interface)
.await?)?;
}
NetCmd::List => {
let subnets = client.get_subnets(tarpc::context::current()).await?;
let tabular: Vec<table::Subnet> =
subnets?.iter().map(table::Subnet::from).collect();
let mut table = tabled::Table::new(&tabular);
println!("{}", table.with(tabled::Style::psql()));
}
},
};
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Err(err) = handle_command().await {
if std::any::Any::type_id(&*err).type_id() == TypeId::of::<tarpc::client::RpcError>() {
eprintln!("[err] Error communicating with server: {}", err);
} else {
eprintln!("[err] {}", err);
}
}
Ok(())
}

42
client/src/table.rs Normal file
View file

@ -0,0 +1,42 @@
use nzr_api::model;
use tabled::Tabled;
#[derive(Tabled)]
pub struct Instance {
#[tabled(rename = "Hostname")]
hostname: String,
#[tabled(rename = "IP Address")]
ip_addr: String,
#[tabled(rename = "State")]
state: model::DomainState,
}
impl From<&model::Instance> for Instance {
fn from(value: &model::Instance) -> Self {
Self {
hostname: value.name.to_owned(),
ip_addr: value
.lease
.as_ref()
.map_or("(none)".to_owned(), |lease| lease.addr.to_string()),
state: value.state,
}
}
}
#[derive(Tabled)]
pub struct Subnet {
#[tabled(rename = "Interface")]
interface: String,
#[tabled(rename = "Network")]
network: String,
}
impl From<&model::Subnet> for Subnet {
fn from(value: &model::Subnet) -> Self {
Self {
interface: value.ifname.to_string(),
network: value.data.network.to_string(),
}
}
}

11
deploy.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
RHOST="$1"
if [ -z "$RHOST" ] ; then
echo "usage: $0 remote-host" >&2
exit 1
fi
cargo build --profile $PROFILE && \
scp "$PWD/target/${PROFILE:-debug}/nzr"{,d} "$RHOST:"

1353
nzrd/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

44
nzrd/Cargo.toml Normal file
View file

@ -0,0 +1,44 @@
[package]
name = "nzrd"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tarpc = { version = "0.31", features = ["tokio1", "unix", "serde-transport", "serde-transport-bincode"] }
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
tokio-serde = { version = "0.8.0", features = ["bincode"] }
sled = "0.34.7"
# virt = "0.2.11"
virt = { path = "../../libvirt-rust" }
fatfs = "0.3"
uuid = { version = "1.2.2", features = ["v4", "fast-rng", "serde", "macro-diagnostics"] }
clap = { version = "4.0.26", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
# for new @attr support, awaiting 0.27.0
quick-xml = { git = "https://github.com/tafia/quick-xml", rev = "fb079b6714d7238d5180aaa098c5f9b02dbcc7da", features = ["serialize"] }
serde_with = "2.1.0"
serde_yaml = "0.9.14"
rand = "0.8.5"
libc = "0.2.137"
home = "0.5.4"
stdext = "0.3.1"
zerocopy = "0.6.1"
nzr-api = { path = "../api" }
futures = "0.3"
async-trait = "0.1.60"
ciborium = "0.2.0"
ciborium-io = "0.2.0"
trust-dns-server = "0.22.0"
log = "0.4.17"
syslog = "6.0.1"
nix = "0.26.1"
[dev-dependencies]
tempdir = "0.3.7"
regex = "1"
[[bin]]
name = "nzrd"
path = "src/main.rs"

192
nzrd/src/cloud.rs Normal file
View file

@ -0,0 +1,192 @@
use std::net::Ipv4Addr;
use fatfs::FsOptions;
use serde::Serialize;
use serde_with::skip_serializing_none;
use std::collections::HashMap;
use std::io::{prelude::*, Cursor};
use trust_dns_server::proto::rr::Name;
use nzr_api::net::{cidr::CidrV4, mac::MacAddr};
#[derive(Debug, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Metadata<'a> {
instance_id: &'a str,
local_hostname: &'a str,
public_keys: Option<Vec<&'a String>>,
}
impl<'a> Metadata<'a> {
pub fn new(instance_id: &'a str) -> Self {
Self {
instance_id,
local_hostname: instance_id,
public_keys: None,
}
}
pub fn ssh_pubkeys(mut self, pubkeys: &'a [String]) -> Self {
self.public_keys = Some(pubkeys.iter().filter(|i| !i.is_empty()).collect());
self
}
}
#[derive(Debug, Serialize)]
pub struct NetworkMeta<'a> {
version: u32,
ethernets: HashMap<String, EtherNic<'a>>,
#[serde(skip)]
ethnum: u8,
}
impl<'a> NetworkMeta<'a> {
pub fn new() -> Self {
Self {
version: 2,
ethernets: HashMap::new(),
ethnum: 0,
}
}
/// Define a NIC with a static address.
pub fn static_nic(
mut self,
match_data: EtherMatch<'a>,
cidr: &'a CidrV4,
gateway: &'a Ipv4Addr,
dns: DNSMeta<'a>,
) -> Self {
self.ethernets.insert(
format!("eth{}", self.ethnum),
EtherNic {
r#match: match_data,
addresses: Some(vec![cidr]),
gateway4: Some(gateway),
dhcp4: false,
nameservers: Some(dns),
},
);
self.ethnum += 1;
self
}
#[allow(dead_code)]
pub fn dhcp_nic(mut self, match_data: EtherMatch<'a>) -> Self {
self.ethernets.insert(
format!("eth{}", self.ethnum),
EtherNic {
r#match: match_data,
addresses: None,
gateway4: None,
dhcp4: true,
nameservers: None,
},
);
self.ethnum += 1;
self
}
}
#[derive(Debug, Serialize)]
pub struct Ethernets<'a> {
nics: Vec<EtherNic<'a>>,
}
#[derive(Debug, Serialize)]
pub struct EtherNic<'a> {
r#match: EtherMatch<'a>,
addresses: Option<Vec<&'a CidrV4>>,
gateway4: Option<&'a Ipv4Addr>,
dhcp4: bool,
nameservers: Option<DNSMeta<'a>>,
}
#[skip_serializing_none]
#[derive(Default, Debug, Serialize)]
pub struct EtherMatch<'a> {
name: Option<&'a str>,
macaddress: Option<&'a MacAddr>,
driver: Option<&'a str>,
}
impl<'a> EtherMatch<'a> {
#[allow(dead_code)]
pub fn name(name: &'a str) -> Self {
Self {
name: Some(name),
..Default::default()
}
}
pub fn mac_addr(addr: &'a MacAddr) -> Self {
Self {
macaddress: Some(addr),
..Default::default()
}
}
#[allow(dead_code)]
pub fn driver(driver: &'a str) -> Self {
Self {
driver: Some(driver),
..Default::default()
}
}
}
#[derive(Debug, Serialize)]
pub struct DNSMeta<'a> {
search: Vec<Name>,
addresses: &'a Vec<Ipv4Addr>,
}
impl<'a> DNSMeta<'a> {
pub fn with_addrs(search: Option<Vec<Name>>, addrs: &'a Vec<Ipv4Addr>) -> Self {
Self {
addresses: addrs,
search: search.unwrap_or_default(),
}
}
}
pub fn create_image<B>(
metadata: &Metadata,
netconfig: &NetworkMeta,
user_data: Option<&B>,
) -> Result<Cursor<Vec<u8>>, Box<dyn std::error::Error>>
where
B: AsRef<[u8]>,
{
let mut image: Cursor<Vec<u8>> = Cursor::new(Vec::new());
// format a:
fatfs::format_volume(
&mut image,
fatfs::FormatVolumeOptions::new()
.volume_label(*b"cidata ")
.fat_type(fatfs::FatType::Fat12)
.total_sectors(2880),
)?;
{
let fs = fatfs::FileSystem::new(&mut image, FsOptions::new())?;
let rootdir = fs.root_dir();
let md_data = serde_yaml::to_string(&metadata)?;
let mut md_fd = rootdir.create_file("meta-data")?;
md_fd.write_all(md_data.as_bytes())?;
let net_data = serde_yaml::to_string(&netconfig)?;
let mut net_fd = rootdir.create_file("network-config")?;
net_fd.write_all(net_data.as_bytes())?;
// user-data MUST exist, even if there is no user-data
let mut user_fd = rootdir.create_file("user-data")?;
if let Some(user_data) = user_data {
user_fd.write_all(user_data.as_ref())?;
}
}
Ok(image)
}

23
nzrd/src/cmd/mod.rs Normal file
View file

@ -0,0 +1,23 @@
pub mod net;
pub mod vm;
use std::fmt;
#[derive(Debug)]
pub struct CommandError(String);
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for CommandError {}
macro_rules! cmd_error {
($($arg:tt)*) => {
Box::new(CommandError(format!($($arg)*)))
};
}
pub(crate) use cmd_error;

49
nzrd/src/cmd/net.rs Normal file
View file

@ -0,0 +1,49 @@
use super::*;
use crate::ctrl::net::Subnet;
use crate::ctrl::Storable;
use crate::ctx::Context;
use nzr_api::model;
pub async fn add_subnet(
ctx: &Context,
args: model::Subnet,
) -> Result<Subnet, Box<dyn std::error::Error>> {
let subnet = Subnet::new(
&args.ifname.to_string(),
&args.data.network,
&args.data.start_host,
&args.data.end_host,
args.data.gateway4.as_ref(),
&args.data.dns,
args.data.domain_name,
)
.map_err(|er| cmd_error!("Couldn't generate subnet: {}", er))?;
let mut ent = Subnet::insert(
ctx.db.clone(),
subnet.clone(),
args.ifname.to_string().as_bytes(),
)?;
ent.transient = true;
if let Err(err) = ctx.zones.new_zone(&subnet).await {
Err(cmd_error!("Failed to create new DNS zone: {}", err))
} else {
ent.transient = false;
Ok(subnet)
}
}
pub fn delete_subnet(ctx: &Context, interface: &str) -> Result<(), Box<dyn std::error::Error>> {
match Subnet::get_by_key(ctx.db.clone(), interface.as_bytes())
.map_err(|er| cmd_error!("Couldn't find subnet: {}", er))?
{
Some(subnet) => subnet
.delete()
.map_err(|er| cmd_error!("Couldn't fully delete subnet entry: {}", er)),
None => Err(cmd_error!("No subnet object found for {}", interface)),
}?;
Ok(())
}

220
nzrd/src/cmd/vm.rs Normal file
View file

@ -0,0 +1,220 @@
use virt::stream::Stream;
use super::*;
use crate::cloud::{DNSMeta, EtherMatch, Metadata, NetworkMeta};
use crate::ctrl::net::Subnet;
use crate::ctrl::virtxml::build::DomainBuilder;
use crate::ctrl::virtxml::{DiskDeviceType, SerialType, VolType, Volume};
use crate::ctrl::vm::{InstDb, Instance, InstanceError};
use crate::ctrl::Storable;
use crate::ctx::Context;
use crate::prelude::*;
use crate::virt::VirtVolume;
use log::*;
use nzr_api::args;
use nzr_api::net::mac::MacAddr;
use trust_dns_server::proto::rr::Name;
const VIRT_MAC_OUI: &[u8] = &[0x02, 0xf1, 0x0f];
/// Creates a new instance
pub async fn new_instance(
ctx: Context,
args: &args::NewInstance,
) -> Result<Instance, Box<dyn std::error::Error>> {
// find the subnet corresponding to the interface
let subnet = Subnet::get_by_key(ctx.db.clone(), args.interface.as_bytes())
.map_err(|er| cmd_error!("Unable to get interface: {}", er))?
.map_or(
Err(cmd_error!(
"Interface {} wasn't found in database",
&args.interface
)),
Ok,
)?;
// bail if a domain already exists
if let Ok(dom) = virt::domain::Domain::lookup_by_name(&ctx.virt.conn, &args.name) {
Err(cmd_error!(
"Domain with name already exists (uuid {})",
dom.get_uuid_string().unwrap_or("unknown".to_owned())
))
} else {
// make sure the base image exists
let mut base_image = VirtVolume::lookup_by_name(&ctx.virt.pools.baseimg, &args.base_image)
.map_err(|er| cmd_error!("Couldn't find base image: {}", er))?;
// generate a new lease with a new MAC addr
let mac_addr = {
let bytes = [VIRT_MAC_OUI, rand::random::<[u8; 3]>().as_ref()].concat();
MacAddr::from_bytes(bytes)
}
.map_err(|er| cmd_error!("Unable to create a new MAC address: {}", er))?;
let lease = subnet
.new_lease(&mac_addr, &args.name)
.map_err(|er| cmd_error!("Failed to generate a new lease: {}", er))?;
// generate cloud-init data
let meta = Metadata::new(&args.name).ssh_pubkeys(&args.ssh_keys);
let netconfig = NetworkMeta::new().static_nic(
EtherMatch::mac_addr(&mac_addr),
&lease.ipv4_addr,
&subnet.gateway4,
DNSMeta::with_addrs(
{
let mut search: Vec<Name> = vec![ctx.config.dns.default_zone.clone()];
if let Some(zone) = &subnet.domain_name {
search.push(zone.clone());
}
Some(search)
},
&subnet.dns,
),
);
let ci_data = crate::cloud::create_image(&meta, &netconfig, None as Option<&Vec<u8>>)
.map_err(|er| cmd_error!("Unable to create initial cloud-init image: {}", er))?
.into_inner();
// and upload it to a vol
let vol_data = Volume::new(&args.name, VolType::Raw, datasize!(1440 KiB));
let mut cidata_vol = VirtVolume::create_xml(&ctx.virt.pools.cidata, vol_data, 0)?;
let cistream = Stream::new(&cidata_vol.get_connect()?, 0)?;
if let Err(er) = cidata_vol.upload(&cistream, 0, datasize!(1440 KiB).into(), 0) {
cistream.abort().ok();
cidata_vol.delete(0)?;
Err(cmd_error!("Failed to create cloud-init volume: {}", er))
} else {
let mut idx: usize = 0;
while idx < ci_data.len() {
match cistream.send(&ci_data[idx..ci_data.len()]) {
Ok(sz) => idx += sz,
Err(er) => {
cistream.abort().ok();
cidata_vol.delete(0)?;
return Err(cmd_error!("Failed uploading to cloud-init image: {}", er));
}
}
}
// mark the stream as finished
cistream.finish()?;
// create primary volume from base image
let mut pri_vol = base_image
.clone_vol(
&ctx.virt.pools.primary,
&args.name,
datasize!((args.disk_sizes.0) GiB),
)
.map_err(|er| cmd_error!("Failed to clone base image: {}", er))?;
// and, if it exists: the second volume
let sec_vol = match args.disk_sizes.1 {
Some(sec_size) => {
let voldata = Volume::new(
&args.name,
ctx.virt.pools.secondary.xml.vol_type(),
datasize!(sec_size GiB),
);
Some(VirtVolume::create_xml(
&ctx.virt.pools.secondary,
voldata,
0,
)?)
}
None => None,
};
// build domain xml
let (mut inst, conn) = Instance::new(ctx.clone(), subnet, lease, {
let pri_name = &ctx.virt.pools.primary.xml.name;
let sec_name = &ctx.virt.pools.secondary.xml.name;
let cidata_name = &ctx.virt.pools.cidata.xml.name;
let mut instdata = DomainBuilder::default()
.name(&args.name)
.memory(datasize!((args.memory) MiB))
.cpu_topology(1, 1, args.cores, 1)
.net_device(|nd| nd.mac_addr(&mac_addr).with_bridge(&args.interface))
.disk_device(|dsk| {
dsk.volume_source(pri_name, &pri_vol.name)
.target("vda", "virtio")
.boot_order(1)
})
.disk_device(|fda| {
fda.volume_source(cidata_name, &cidata_vol.name)
.device_type(DiskDeviceType::Disk)
.target("hda", "ide")
})
.serial_device(SerialType::Pty);
// add desription, if provided
instdata = match &args.description {
Some(desc) => instdata.description(desc),
None => instdata,
};
// add second volume, if provided
match &sec_vol {
Some(vol) => instdata.disk_device(|dsk| {
dsk.volume_source(sec_name, &vol.name)
.target("vdb", "virtio")
}),
None => instdata,
}
})
.await?;
// not a fatal error, we can set autostart afterward
if let Err(er) = conn.set_autostart(true) {
warn!("Couldn't set autostart for domain: {}", er);
}
if let Err(er) = conn.create() {
warn!("Domain defined, but couldn't be started! Error: {}", er);
}
// set all volumes to persistent to avoid deletion
pri_vol.persist = true;
if let Some(mut sec_vol) = sec_vol {
sec_vol.persist = true;
}
cidata_vol.persist = true;
inst.persist();
debug!("Domain {} created!", inst.xml().name.as_str());
Ok(inst)
}
}
}
pub async fn delete_instance(ctx: Context, name: String) -> Result<(), Box<dyn std::error::Error>> {
let mut inst = Instance::lookup_by_name(ctx.clone(), &name)
.await?
.ok_or(cmd_error!("No such domain!"))?;
let conn = inst.virt()?;
if conn.is_active()? {
conn.destroy()
.map_err(|er| cmd_error!("Failed to destroy domain: {}", er))?;
}
inst.undefine().await?;
Ok(())
}
pub fn prune_instances(ctx: &Context) -> Result<(), Box<dyn std::error::Error>> {
for entity in InstDb::all(ctx.db.clone())? {
let entity = entity?;
if let Err(InstanceError::DomainNotFound(name)) =
Instance::from_entity(ctx.clone(), entity.clone())
{
info!("Instance {} was invalid, deleting", name);
if let Err(err) = entity.delete() {
warn!("Couldn't delete {}: {}", name, err);
}
}
}
Ok(())
}

287
nzrd/src/ctrl/mod.rs Normal file
View file

@ -0,0 +1,287 @@
use std::{
marker::PhantomData,
ops::{Deref, DerefMut},
};
use serde::{Deserialize, Serialize};
use log::*;
use std::fmt;
pub mod net;
pub mod virtxml;
pub mod vm;
#[derive(Clone)]
pub struct Entity<T>
where
T: Storable + Serialize,
{
inner: T,
key: Vec<u8>,
tree: sled::Tree,
db: sled::Db,
pub transient: bool,
}
impl<T> Deref for Entity<T>
where
T: Storable,
{
type Target = T;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<T> DerefMut for Entity<T>
where
T: Storable,
{
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
impl<T> Drop for Entity<T>
where
T: Storable,
{
fn drop(&mut self) {
if self.transient {
let key_str = String::from_utf8_lossy(&self.key);
debug!("Transient flag enabled for {}, dropping!", &key_str);
if let Err(err) = self.delete() {
warn!("Couldn't delete {} from database: {}", &key_str, err);
}
}
}
}
impl<T> Entity<T>
where
T: Storable,
{
pub fn transient<V>(inner: T, key: V, tree: sled::Tree, db: sled::Db) -> Self
where
V: AsRef<[u8]>,
{
Entity {
inner,
key: key.as_ref().to_owned(),
tree,
db,
transient: true,
}
}
pub fn key(&self) -> &[u8] {
&self.key
}
}
impl<T> Entity<T>
where
T: Storable + Serialize,
{
pub fn update(&self) -> Result<(), StorableError> {
let mut bytes: Vec<u8> = Vec::new();
ciborium::ser::into_writer(&self.inner, &mut bytes)
.map_err(|e| StorableError::new(ErrType::SerializeFailed, e))?;
self.tree
.insert(&self.key, bytes.as_slice())
.map_err(|e| StorableError::new(ErrType::DbError, e))?;
Ok(())
}
pub fn delete(&self) -> Result<(), StorableError> {
self.on_delete(&self.db)?;
self.tree
.remove(&self.key)
.map_err(|e| StorableError::new(ErrType::DbError, e))?;
Ok(())
}
}
#[derive(Debug)]
pub enum ErrType {
DbError,
DeserializeFailed,
SerializeFailed,
}
#[derive(Debug)]
pub struct StorableError {
err_type: ErrType,
inner: Option<Box<dyn std::error::Error>>,
}
impl StorableError {
fn new<E>(err_type: ErrType, inner: E) -> Self
where
E: std::error::Error + 'static,
{
Self {
err_type,
inner: Some(Box::new(inner)),
}
}
}
impl fmt::Display for ErrType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DbError => write!(f, "Database error"),
Self::DeserializeFailed => write!(f, "Deserialize failed"),
Self::SerializeFailed => write!(f, "Serialize failed"),
}
}
}
impl fmt::Display for StorableError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.err_type.fmt(f)?;
if let Some(inner) = &self.inner {
write!(f, ": {}", inner)?;
}
Ok(())
}
}
impl std::error::Error for StorableError {}
pub trait Storable
where
for<'de> Self: Deserialize<'de> + Serialize,
{
fn tree_name() -> Option<&'static [u8]>;
fn get_by_key(db: sled::Db, key: &[u8]) -> Result<Option<Entity<Self>>, StorableError> {
let tree_name = match Self::tree_name() {
Some(tn) => tn,
None => unimplemented!(),
};
let tree = db
.open_tree(tree_name)
.map_err(|e| StorableError::new(ErrType::DbError, e))?;
match tree
.get(key)
.map_err(|e| StorableError::new(ErrType::DbError, e))?
{
Some(vec) => {
let deserialized: Self = ciborium::de::from_reader(&*vec)
.map_err(|e| StorableError::new(ErrType::DeserializeFailed, e))?;
Ok(Some(Entity {
inner: deserialized,
key: key.to_owned(),
tree,
db,
transient: false,
}))
}
None => Ok(None),
}
}
fn insert(db: sled::Db, item: Self, key: &[u8]) -> Result<Entity<Self>, StorableError> {
let tree_name = match Self::tree_name() {
Some(tn) => tn,
None => unimplemented!(),
};
let tree = db
.open_tree(tree_name)
.map_err(|e| StorableError::new(ErrType::DbError, e))?;
let ent = Entity {
inner: item,
key: key.to_owned(),
tree,
db,
transient: false,
};
ent.update()?;
Ok(ent)
}
/// Requests all items from the database, as a [`StorIter`].
fn all(db: sled::Db) -> Result<StorIter<Self>, StorableError> {
let tree_name = match Self::tree_name() {
Some(tn) => tn,
None => unimplemented!(),
};
let tree = db
.open_tree(tree_name)
.map_err(|e| StorableError::new(ErrType::DbError, e))?;
Ok(StorIter::new(db, tree))
}
/// Function to allow storable objects to perform actions on deletion.
fn on_delete(&self, _db: &sled::Db) -> Result<(), StorableError> {
// No-op
debug!("deleting; Storable no-op!");
Ok(())
}
}
/// Iterator of [`Storable`]s in the running database.
pub struct StorIter<T>
where
T: Storable,
{
db: sled::Db,
tree: sled::Tree,
iter: sled::Iter,
phantom: PhantomData<T>,
}
impl<T> StorIter<T>
where
T: Storable,
{
/// Creates a new iterator of [`Storable`]s using a [`sled::Db`] and
/// [`sled::Tree`].
fn new(db: sled::Db, tree: sled::Tree) -> Self {
Self {
db,
tree: tree.clone(),
iter: tree.iter(),
phantom: PhantomData,
}
}
}
impl<T> Iterator for StorIter<T>
where
T: Storable,
{
type Item = Result<Entity<T>, StorableError>;
fn next(&mut self) -> Option<Self::Item> {
if let Some(next) = self.iter.next() {
match next {
Ok((key, val)) => {
let inner = {
let vec = val.to_vec();
let inner = ciborium::de::from_reader(vec.as_slice())
.map_err(|e| StorableError::new(ErrType::DeserializeFailed, e));
match inner {
Ok(inner) => inner,
Err(err) => {
return Some(Err(err));
}
}
};
Some(Ok(Entity {
inner,
key: key.to_vec(),
tree: self.tree.clone(),
db: self.db.clone(),
transient: false,
}))
}
Err(err) => Some(Err(StorableError::new(ErrType::DbError, err))),
}
} else {
None
}
}
}

198
nzrd/src/ctrl/net.rs Normal file
View file

@ -0,0 +1,198 @@
use super::{Entity, StorIter};
use nzr_api::model::IfaceStr;
use nzr_api::net::cidr::CidrV4;
use nzr_api::net::mac::MacAddr;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::net::Ipv4Addr;
use std::str::FromStr;
use trust_dns_server::proto::rr::Name;
use super::Storable;
#[derive(Clone, Serialize, Deserialize)]
pub struct Subnet {
pub interface: String,
pub network: CidrV4,
pub gateway4: Ipv4Addr,
pub dns: Vec<Ipv4Addr>,
pub domain_name: Option<Name>,
pub start_host: u32,
pub end_host: u32,
}
impl From<&Subnet> for nzr_api::model::Subnet {
fn from(value: &Subnet) -> Self {
let start_host = value.network.make_ip(value.start_host).unwrap();
let end_host = value.network.make_ip(value.end_host).unwrap();
Self {
ifname: IfaceStr::from_str(&value.interface).unwrap(),
data: nzr_api::model::SubnetData {
network: value.network.clone(),
start_host,
end_host,
gateway4: Some(value.gateway4),
dns: value.dns.clone(),
domain_name: value.domain_name.to_owned(),
},
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Lease {
pub ipv4_addr: CidrV4,
pub mac_addr: MacAddr,
pub inst_name: String,
}
#[derive(Debug)]
pub enum SubnetError {
DbError(sled::Error),
SubnetExists,
BadNetwork(nzr_api::net::cidr::Error),
BadData,
BadStartHost,
BadEndHost,
BadRange,
HostOutsideRange,
BadHost(nzr_api::net::cidr::Error),
CantDelete(sled::Error),
SubnetFull,
BadDomainName,
}
impl fmt::Display for SubnetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DbError(er) => write!(f, "Database error: {}", er),
Self::SubnetExists => write!(f, "Subnet already exists"),
Self::BadNetwork(er) => write!(f, "Error deserializing network from database: {}", er),
Self::BadData => write!(f, "Malformed data in database"),
Self::BadStartHost => write!(f, "Starting host is not in provided subnet"),
Self::BadEndHost => write!(f, "Ending host is not in provided subnet"),
Self::BadRange => write!(f, "Ending host is before starting host"),
Self::HostOutsideRange => write!(f, "Available host is outside defined host range"),
Self::BadHost(er) => write!(
f,
"Host is within range but couldn't be converted to IP: {}",
er
),
Self::CantDelete(de) => write!(f, "Error when trying to delete: {}", de),
Self::SubnetFull => write!(f, "No addresses are left to assign in subnet"),
Self::BadDomainName => {
write!(f, "Invalid domain name. Must be in the format xx.yy.tld")
}
}
}
}
impl std::error::Error for SubnetError {}
impl Storable for Subnet {
fn tree_name() -> Option<&'static [u8]> {
Some(b"nets")
}
fn on_delete(&self, db: &sled::Db) -> Result<(), super::StorableError> {
db.drop_tree(self.lease_tree())
.map_err(|e| super::StorableError::new(super::ErrType::DbError, e))?;
Ok(())
}
}
impl Subnet {
pub fn new(
iface: &str,
network: &CidrV4,
start_addr: &Ipv4Addr,
end_addr: &Ipv4Addr,
gateway4: Option<&Ipv4Addr>,
dns: &[Ipv4Addr],
domain_name: Option<Name>,
) -> Result<Self, SubnetError> {
// validate start and end addresses
if end_addr < start_addr {
Err(SubnetError::BadRange)
} else if !network.contains(start_addr) {
Err(SubnetError::BadStartHost)
} else if !network.contains(end_addr) {
Err(SubnetError::BadEndHost)
} else {
let gateway4 = gateway4.cloned().unwrap_or(
network
.make_ip(1)
.map_err(|_| SubnetError::HostOutsideRange)?,
);
let mut dns = dns.to_owned();
if dns.is_empty() {
// default DNS: quad9
dns.push(Ipv4Addr::new(9, 9, 9, 9));
}
let start_host = network.host_bits(start_addr);
let end_host = network.host_bits(end_addr);
let subnet = Subnet {
interface: iface.to_owned(),
network: network.clone(),
start_host,
end_host,
gateway4,
dns,
domain_name,
};
Ok(subnet)
}
}
/// Gets the lease tree from sled.
pub fn lease_tree(&self) -> Vec<u8> {
let mut lt_name: Vec<u8> = vec![b'L'];
lt_name.extend_from_slice(&self.network.octets());
lt_name
}
}
impl Storable for Lease {
fn tree_name() -> Option<&'static [u8]> {
None
}
}
impl Entity<Subnet> {
/// Create a new lease associated with the subnet.
pub fn new_lease(
&self,
mac_addr: &MacAddr,
inst_name: &str,
) -> Result<Entity<Lease>, Box<dyn std::error::Error>> {
let tree = self.db.open_tree(self.lease_tree())?;
let max_lease = match tree.last()? {
Some(lease) => u32::from_be_bytes(lease.0[..4].try_into().unwrap()),
None => self.start_host,
};
let new_ip = self
.network
.make_ip(max_lease + 1)
.map_err(|_| SubnetError::SubnetFull)?;
let lease_data = Lease {
ipv4_addr: CidrV4::new(new_ip, self.network.cidr()),
mac_addr: mac_addr.clone(),
inst_name: inst_name.to_owned(),
};
let lease_tree = self
.db
.open_tree(self.lease_tree())
.map_err(SubnetError::DbError)?;
let octets = lease_data.ipv4_addr.addr.octets();
let ent = Entity::transient(lease_data, octets, lease_tree, self.db.clone());
ent.update()?;
Ok(ent)
}
/// Get an iterator over all leases in the subnet.
pub fn leases(&self) -> Result<StorIter<Lease>, sled::Error> {
let lease_tree = self.db.open_tree(self.lease_tree())?;
Ok(StorIter::new(self.db.clone(), lease_tree))
}
}

View file

@ -0,0 +1,239 @@
use log::*;
use super::*;
#[derive(Default)]
pub struct DomainBuilder {
domain: Domain,
}
impl DomainBuilder {
/// Sets the name attribute of the domain.
///
/// From libvirt documentation:
///
/// > The content of the name element provides a short
/// > name for the virtual machine. This name should consist
/// > only of alphanumeric characters and is required to be
/// > unique within the scope of a single host. It is often
/// > used to form the filename for storing the persistent
/// > configuration file.
pub fn name(mut self, name: &str) -> Self {
self.domain.name = name.to_owned();
self
}
#[cfg(test)]
pub fn uuid(mut self, uuid: uuid::Uuid) -> Self {
self.domain.uuid = uuid;
self
}
/// Sets the title attribute of the domain.
///
/// From libvirt documentation:
///
/// > The optional element title provides space for a short
/// > description of the domain. The title should not
/// > contain any newlines.
pub fn title(mut self, title: &str) -> Self {
self.domain.title = Some(title.to_owned());
self
}
/// Sets the description attribute of the domain.
///
/// From libvirt documentation:
///
/// > The content of the description element provides a
/// > human readable description of the virtual machine.
/// > This data is not used by libvirt in any way, it can
/// > contain any information the user wants.
pub fn description(mut self, desc: &str) -> Self {
self.domain.description = Some(desc.to_owned());
self
}
/// Sets what action is performed on the domain when
/// powered off.
pub fn poweroff_action(mut self, action: PowerAction) -> Self {
self.domain.on_poweroff = Some(action);
self
}
/// Sets what action is performed on the domain when
/// rebooted.
pub fn reboot_action(mut self, action: PowerAction) -> Self {
self.domain.on_reboot = Some(action);
self
}
/// Sets what action is performed on the domain when
/// it crashes.
pub fn crash_action(mut self, action: PowerAction) -> Self {
self.domain.on_crash = Some(action);
self
}
/// Adds a network interface to the domain.
pub fn net_device<P>(mut self, net_func: P) -> Self
where
P: Fn(IfaceBuilder) -> IfaceBuilder,
{
let netdev = net_func(IfaceBuilder::new());
self.domain.devices.push(Device::Interface {
interface: netdev.build(),
});
self
}
/// Adds a disk device to the domain.
pub fn disk_device<P>(mut self, disk_func: P) -> Self
where
P: Fn(DiskBuilder) -> DiskBuilder,
{
let diskdev = disk_func(DiskBuilder::new());
self.domain.devices.push(Device::Disk {
disk: diskdev.build(),
});
self
}
pub fn memory(mut self, size: SizeInfo) -> Self {
self.domain.memory = size;
self
}
pub fn serial_device(mut self, serial_type: SerialType) -> Self {
self.domain.devices.push(Device::Console {
console: SerialDevice {
r#type: serial_type,
},
});
self
}
pub fn cpu_topology(mut self, sockets: u8, dies: u8, cores: u8, threads: u8) -> Self {
self.domain.cpu.topology = CpuTopology {
sockets,
dies,
cores,
threads,
};
self.domain.vcpu.value = (sockets * dies * cores * threads).into();
self
}
pub fn build(mut self) -> Domain {
if self.domain.devices.disk.iter().any(|d| d.boot.is_some()) {
debug!("Disk has boot order, removing <os/> style boot...");
self.domain.os.boot = None;
}
self.domain
}
}
pub struct IfaceBuilder {
iface: NetDevice,
}
impl IfaceBuilder {
fn new() -> Self {
Self {
iface: NetDevice::default(),
}
}
/// Uses a bridge as the source device.
pub fn with_bridge(mut self, bridge: &str) -> Self {
self.iface.r#type = IfaceType::Bridge;
self.iface.source.bridge = Some(bridge.to_owned());
self
}
/// Uses a libvirt-defined network as the source device.
pub fn with_network(mut self, network: &str) -> Self {
self.iface.r#type = IfaceType::Network;
self.iface.source.network = Some(network.to_owned());
self
}
/// Defines the MAC address the interface should use.
pub fn mac_addr(mut self, addr: &MacAddr) -> Self {
self.iface.mac = Some(NetMac {
address: addr.clone(),
});
self
}
/// Defines the model type to emulate.
///
/// By default, interfaces will be created as virtio. To
/// see what models you can use on your hypervisor, run:
///
/// ```
/// qemu-system-x86_64 -net nic,model=? /dev/null
/// ```
pub fn model(mut self, model: &str) -> Self {
self.iface.model.r#type = model.to_owned();
self
}
fn build(self) -> NetDevice {
self.iface
}
}
#[derive(Default)]
pub struct DiskBuilder {
disk: DiskDevice,
}
impl DiskBuilder {
pub fn new() -> Self {
Self::default()
}
/// Set the source for a file
pub fn file_source(mut self, filename: &str) -> Self {
self.disk.r#type = DiskType::File;
self.disk.source.file = Some(filename.to_owned());
self
}
/// Set the source for a block device
pub fn block_source(mut self, dev: &str) -> Self {
self.disk.r#type = DiskType::Block;
self.disk.source.dev = Some(dev.to_owned());
self
}
/// Set the source for a volume drive (e.g., zfs)
pub fn volume_source(mut self, pool: &str, volume: &str) -> Self {
self.disk.r#type = DiskType::Volume;
self.disk.source.pool = Some(pool.to_owned());
self.disk.source.volume = Some(volume.to_owned());
self
}
/// Set the target for the disk to attach to.
pub fn target(mut self, dev: &str, bus: &str) -> Self {
self.disk.target.bus = bus.to_owned();
self.disk.target.dev = dev.to_owned();
self
}
pub fn boot_order(mut self, order: u32) -> Self {
self.disk.boot = Some(DiskBoot { order });
self
}
pub fn device_type(mut self, devtype: DiskDeviceType) -> Self {
self.disk.device = devtype;
self
}
pub fn build(self) -> DiskDevice {
self.disk
}
}

View file

@ -0,0 +1,549 @@
pub mod build;
#[cfg(test)]
mod test;
use nzr_api::net::mac::MacAddr;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use uuid::Uuid;
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename = "domain")]
#[serde(rename_all = "camelCase")]
pub struct Domain {
#[serde(rename = "@type")]
pub r#type: String,
pub name: String,
pub uuid: uuid::Uuid,
pub title: Option<String>,
pub description: Option<String>,
pub vcpu: Vcpu,
pub memory: SizeInfo,
pub current_memory: Option<SizeInfo>,
pub features: Option<FeatureList>,
pub cpu: Cpu,
pub devices: DeviceList,
pub os: OsData,
pub on_poweroff: Option<PowerAction>,
pub on_reboot: Option<PowerAction>,
pub on_crash: Option<PowerAction>,
}
impl Default for Domain {
fn default() -> Self {
Domain {
r#type: "kvm".to_owned(),
name: String::default(),
memory: SizeInfo {
unit: SizeUnit::MiB,
amount: 512,
},
current_memory: None,
features: Some(FeatureList {
features: vec![Feature::APIC, Feature::ACPI],
}),
cpu: Cpu {
mode: "host-passthrough".to_owned(),
topology: CpuTopology {
sockets: 1,
dies: 1,
cores: 2,
threads: 1,
},
},
uuid: Uuid::new_v4(),
title: None,
description: None,
vcpu: Vcpu {
placement: "static".to_owned(),
value: 2,
},
os: OsData {
boot: Some(BootNode {
dev: BootDevice::HardDrive,
}),
r#type: OsType::default(),
bios: BiosData {
useserial: "yes".to_owned(),
reboot_timeout: 0,
},
},
on_poweroff: None,
on_reboot: None,
on_crash: None,
devices: DeviceList::default(),
}
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct FeatureList {
#[serde(rename = "$value")]
features: Vec<Feature>,
}
impl std::ops::Deref for FeatureList {
type Target = Vec<Feature>;
fn deref(&self) -> &Self::Target {
&self.features
}
}
impl std::ops::DerefMut for FeatureList {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.features
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[allow(clippy::upper_case_acronyms)]
pub enum Feature {
ACPI,
APIC,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum PowerAction {
Destroy,
Restart,
Preserve,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Vcpu {
#[serde(rename = "@placement")]
placement: String,
#[serde(rename = "$value")]
value: u16,
}
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
#[skip_serializing_none]
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiskSource {
#[serde(rename = "@file")]
file: Option<String>,
#[serde(rename = "@dev")]
dev: Option<String>,
#[serde(rename = "@dir")]
dir: Option<String>,
// XXX: not supporting network type
#[serde(rename = "@pool")]
pub pool: Option<String>,
#[serde(rename = "@volume")]
pub volume: Option<String>,
}
#[skip_serializing_none]
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiskDriver {
#[serde(rename = "@name")]
name: String,
#[serde(rename = "@type")]
r#type: Option<String>,
}
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiskTarget {
#[serde(rename = "@dev")]
dev: String,
#[serde(rename = "@bus")]
bus: String,
}
#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DiskType {
File,
Block,
Volume,
#[default]
None,
}
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DiskDeviceType {
Floppy,
#[default]
Disk,
Cdrom,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiskBoot {
#[serde(rename = "@order")]
order: u32,
}
#[skip_serializing_none]
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiskDevice {
#[serde(rename = "@type")]
pub r#type: DiskType,
boot: Option<DiskBoot>,
#[serde(rename = "@device")]
device: DiskDeviceType,
pub source: DiskSource,
target: DiskTarget,
driver: Option<DiskDriver>,
}
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct NetMac {
#[serde(rename = "@address")]
address: MacAddr,
}
#[skip_serializing_none]
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct NetSource {
#[serde(rename = "@bridge")]
bridge: Option<String>,
#[serde(rename = "@network")]
network: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct NetModel {
#[serde(rename = "@type")]
r#type: String,
}
impl Default for NetModel {
fn default() -> Self {
Self {
r#type: "virtio".to_owned(),
}
}
}
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum IfaceType {
Network,
Bridge,
#[default]
None,
}
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct NetDevice {
#[serde(rename = "@type")]
r#type: IfaceType,
mac: Option<NetMac>,
source: NetSource,
model: NetModel,
}
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SerialType {
#[default]
Pty,
Unix,
SpiceVMC,
StdIO,
}
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct SerialDevice {
#[serde(rename = "@type")]
r#type: SerialType,
}
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum Device {
#[serde(rename = "disk")]
Disk { disk: DiskDevice },
#[serde(rename = "interface")]
Interface { interface: NetDevice },
#[serde(rename = "console")]
Console { console: SerialDevice },
}
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeviceList {
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
disk: Vec<DiskDevice>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
interface: Vec<NetDevice>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
console: Vec<SerialDevice>,
}
impl DeviceList {
pub fn push(&mut self, dev: Device) {
match dev {
Device::Disk { disk } => self.disk.push(disk),
Device::Interface { interface } => self.interface.push(interface),
Device::Console { console } => self.console.push(console),
}
}
pub fn disks(&self) -> &Vec<DiskDevice> {
&self.disk
}
pub fn interfaces(&mut self) -> &Vec<NetDevice> {
&self.interface
}
}
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BootDevice {
Floppy,
Cdrom,
#[serde(rename = "hd")]
HardDrive,
Network,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct BootNode {
#[serde(rename = "@dev")]
dev: BootDevice,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct BiosData {
#[serde(rename = "@useserial")]
useserial: String,
#[serde(rename = "@rebootTimeout")]
reboot_timeout: u32,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct OsType {
#[serde(rename = "@arch")]
arch: String,
#[serde(rename = "@machine")]
machine: String,
#[serde(rename = "$value")]
value: String,
}
impl Default for OsType {
fn default() -> Self {
Self {
arch: "x86_64".to_owned(),
machine: "pc-i440fx-5.2".to_owned(),
value: "hvm".to_owned(),
}
}
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct OsData {
boot: Option<BootNode>,
r#type: OsType,
// we will not be doing PV, no <bootloader>/<kernel>/<initrd>/etc
bios: BiosData,
}
impl Default for OsData {
fn default() -> Self {
Self {
boot: Some(BootNode {
dev: BootDevice::HardDrive,
}),
r#type: OsType::default(),
bios: BiosData {
useserial: "yes".to_owned(),
reboot_timeout: 0,
},
}
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum SizeUnit {
#[serde(rename = "bytes")]
Bytes,
KiB,
MiB,
GiB,
TiB,
PiB,
}
impl std::fmt::Display for SizeUnit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Self::Bytes => "bytes",
Self::KiB => "KiB",
Self::MiB => "MiB",
Self::GiB => "GiB",
Self::TiB => "TiB",
Self::PiB => "PiB",
}
)
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct SizeInfo {
#[serde(rename = "@unit")]
pub unit: SizeUnit,
#[serde(rename = "$value")]
pub amount: u64,
}
impl From<SizeInfo> for u64 {
fn from(value: SizeInfo) -> Self {
let pow = match value.unit {
SizeUnit::Bytes => 0,
SizeUnit::KiB => 1,
SizeUnit::MiB => 2,
SizeUnit::GiB => 3,
SizeUnit::TiB => 4,
SizeUnit::PiB => 5,
};
value.amount * 1024u64.pow(pow)
}
}
impl std::fmt::Display for SizeInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.amount, self.unit)
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CpuTopology {
#[serde(rename = "@sockets")]
sockets: u8,
#[serde(rename = "@dies")]
dies: u8,
#[serde(rename = "@cores")]
cores: u8,
#[serde(rename = "@threads")]
threads: u8,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Cpu {
#[serde(rename = "@mode")]
mode: String,
topology: CpuTopology,
}
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
#[skip_serializing_none]
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename = "volume")]
#[serde(rename_all = "camelCase")]
pub struct Volume {
#[serde(rename = "@type")]
r#type: String,
pub name: String,
pub key: Option<String>,
allocation: Option<SizeInfo>,
pub capacity: Option<SizeInfo>,
pub target: Option<VolTarget>,
}
pub enum VolType {
Qcow2,
Block,
Raw,
Invalid,
}
impl Volume {
pub fn new(name: &str, voltype: VolType, size: SizeInfo) -> Self {
Self {
name: name.to_owned(),
r#type: match voltype {
VolType::Qcow2 => "file",
VolType::Block => "block",
VolType::Raw => "file",
VolType::Invalid => "invalid",
}
.to_owned(),
capacity: Some(size),
target: match voltype {
VolType::Qcow2 => Some(VolTarget {
path: None,
format: Some(TargetFormat {
r#type: "qcow2".to_owned(),
}),
}),
VolType::Raw => Some(VolTarget {
path: None,
format: Some(TargetFormat {
r#type: "raw".to_owned(),
}),
}),
// block doesn't require a target aiui, libvirt is
// able to infer
VolType::Block => None,
VolType::Invalid => None,
},
allocation: Some(size),
key: None,
}
}
}
#[skip_serializing_none]
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct VolTarget {
path: Option<String>,
format: Option<TargetFormat>,
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TargetFormat {
#[serde(rename = "@type")]
r#type: String,
}
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename = "pool")]
pub struct Pool {
#[serde(rename = "@type")]
r#type: String,
pub name: String,
uuid: Option<uuid::Uuid>,
capacity: Option<SizeInfo>,
allocation: Option<SizeInfo>,
available: Option<SizeInfo>,
target: Option<VolTarget>,
}
impl Pool {
pub fn vol_type(&self) -> VolType {
match self.r#type.as_str() {
"fs" => VolType::Qcow2,
"dir" => VolType::Qcow2,
"logical" => VolType::Block,
"zfs" => VolType::Block,
_ => VolType::Invalid,
}
}
}

View file

@ -0,0 +1,125 @@
use uuid::uuid;
use super::*;
use crate::ctrl::virtxml::build::DomainBuilder;
use crate::prelude::*;
trait Unprettify {
fn unprettify(&self) -> String;
}
impl Unprettify for &str {
fn unprettify(&self) -> String {
self.split('\n')
.map(|s| s.trim())
.collect::<Vec<&str>>()
.concat()
}
}
#[test]
fn domain_serde() {
let dom_str = r#"<domain type="kvm">
<name>test-vm</name>
<uuid>9a8f2611-a976-4d06-ac91-2750ac3462b3</uuid>
<description>This is a test</description>
<vcpu placement="static">2</vcpu>
<memory unit="MiB">512</memory>
<features>
<apic/>
<acpi/>
</features>
<cpu mode="host-passthrough">
<topology sockets="1" dies="1" cores="2" threads="1"/>
</cpu>
<devices>
<disk type="volume" device="disk">
<source pool="tank" volume="test-vm-root"/>
<target dev="sda" bus="virtio"/>
</disk>
<interface type="bridge">
<mac address="02:0b:ee:ca:fe:42"/>
<source bridge="virbr0"/>
<model type="virtio"/>
</interface>
</devices>
<os>
<boot dev="hd"/>
<type arch="x86_64" machine="pc-i440fx-5.2">hvm</type>
<bios useserial="yes" rebootTimeout="0"/>
</os>
</domain>"#
.unprettify();
println!("Serializing domain...");
let mac = MacAddr::new(0x02, 0x0b, 0xee, 0xca, 0xfe, 0x42);
let uuid = uuid!("9a8f2611-a976-4d06-ac91-2750ac3462b3");
let domain = DomainBuilder::default()
.name("test-vm")
.uuid(uuid)
.description("This is a test")
.disk_device(|dsk| {
dsk.volume_source("tank", "test-vm-root")
.target("sda", "virtio")
})
.net_device(|net| net.with_bridge("virbr0").mac_addr(&mac))
.build();
let dom_xml = quick_xml::se::to_string(&domain).unwrap();
println!("{}", dom_xml);
assert_eq!(&dom_xml, &dom_str);
println!("Deserializing domain...");
let _new_dom: Domain = quick_xml::de::from_str(&dom_str).unwrap();
}
#[test]
fn pool_serde() {
let pool_str = r#"<pool type="dir">
<name>default</name>
<uuid>4ad34b59-9418-483d-9533-2e8b51e7317e</uuid>
<capacity unit="GiB">200</capacity>
<allocation unit="GiB">120</allocation>
<available unit="GiB">80</available>
<target>
<path>/var/lib/libvirt/images</path>
</target>
</pool>"#
.unprettify();
println!("Serializing pool...");
let pool: Pool = Pool {
r#type: "dir".to_owned(),
name: "default".to_owned(),
uuid: Some(uuid!("4ad34b59-9418-483d-9533-2e8b51e7317e")),
capacity: Some(datasize!(200 GiB)),
allocation: Some(datasize!(120 GiB)),
available: Some(datasize!(80 GiB)),
target: Some(VolTarget {
path: Some("/var/lib/libvirt/images".to_owned()),
format: None,
}),
};
let pool_xml = quick_xml::se::to_string(&pool).unwrap();
assert_eq!(&pool_xml, &pool_str);
println!("Deserializing pool...");
let pool: Pool = quick_xml::de::from_str(&pool_str).unwrap();
assert_eq!(pool.name, "default".to_owned());
assert_eq!(pool.r#type, "dir".to_owned());
}
#[test]
fn vol_serde() {
let vol_str = r#"<volume type="file">
<name>test</name>
<allocation unit="GiB">20</allocation>
<capacity unit="GiB">20</capacity>
<target>
<format type="qcow2"/>
</target>
</volume>"#
.unprettify();
println!("Serializing volume...");
let vol: Volume = Volume::new("test", VolType::Qcow2, datasize!(20 GiB));
let vol_xml = quick_xml::se::to_string(&vol).unwrap();
assert_eq!(&vol_xml, &vol_str);
println!("Deserializing volume...");
let vol_obj: Volume = quick_xml::de::from_str(&vol_str).unwrap();
assert_eq!(vol_obj, vol);
}

307
nzrd/src/ctrl/vm.rs Normal file
View file

@ -0,0 +1,307 @@
use crate::ctrl::net::Lease;
use crate::ctx::Context;
use log::*;
use nzr_api::net::cidr::CidrV4;
use std::net::Ipv4Addr;
use std::str::{self, Utf8Error};
use super::virtxml::{build::DomainBuilder, Domain};
use super::Storable;
use super::{net::Subnet, Entity};
use crate::virt::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub struct InstDb {
uuid: uuid::Uuid,
lease_if: String,
lease_addr: CidrV4,
}
impl InstDb {
pub fn addr(&self) -> Ipv4Addr {
self.lease_addr.addr
}
}
impl Storable for InstDb {
fn tree_name() -> Option<&'static [u8]> {
Some(b"instances")
}
}
pub struct Instance {
db_data: Entity<InstDb>,
lease: Option<Entity<Lease>>,
ctx: Context,
domain_xml: Domain,
}
impl Instance {
pub async fn new(
ctx: Context,
subnet: Entity<Subnet>,
lease: Entity<Lease>,
builder: DomainBuilder,
) -> Result<(Self, virt::domain::Domain), InstanceError> {
let domain_xml = builder.build();
let virt_domain = {
let inst_xml =
quick_xml::se::to_string(&domain_xml).map_err(InstanceError::CantSerialize)?;
virt::domain::Domain::define_xml(&ctx.virt.conn, &inst_xml)
.map_err(InstanceError::CreationFailed)?
};
// Get the final XML data back from libvirt; this will contain the UUID and
// other auto-filled stuff
let real_xml = match virt_domain.get_xml_desc(0) {
Ok(xml_data) => match quick_xml::de::from_str::<Domain>(&xml_data) {
Ok(xml_obj) => xml_obj,
Err(err) => {
error!("Failed to deserialize XML from libvirt: {}", err);
if let Err(err) = virt_domain.undefine() {
warn!("Couldn't undefine domain after failure: {}", err);
}
return Err(InstanceError::CantDeserialize(err));
}
},
Err(err) => {
error!("Failed to get XML data from libvirt: {}", err);
if let Err(err) = virt_domain.undefine() {
warn!("Couldn't undefine domain after failure: {}", err);
}
return Err(InstanceError::VirtError(err));
}
};
debug!(
"Adding {} (interface: {}) to the instance tree...",
&lease.ipv4_addr, &subnet.interface,
);
let db_data = InstDb {
uuid: real_xml.uuid,
lease_if: subnet.interface.clone(),
lease_addr: lease.ipv4_addr.clone(),
};
let db_data = InstDb::insert(ctx.db.clone(), db_data, real_xml.name.as_bytes())
.map_err(InstanceError::other)?;
let inst_obj = Instance {
db_data,
lease: Some(lease),
ctx,
domain_xml,
};
Ok((inst_obj, virt_domain))
}
pub fn uuid(&self) -> uuid::Uuid {
self.db_data.uuid
}
pub fn persist(&mut self) {
if let Some(lease) = &mut self.lease {
lease.transient = false;
}
self.db_data.transient = false;
}
pub async fn undefine(&mut self) -> Result<(), InstanceError> {
let virt_domain = self.virt()?;
let connect = virt_domain
.get_connect()
.map_err(InstanceError::VirtError)?;
// delete volumes
for disk in self.domain_xml.devices.disks() {
if let (Some(pool), Some(vol)) = (&disk.source.pool, &disk.source.volume) {
if let Ok(vpool) = VirtPool::lookup_by_name(&connect, pool) {
match VirtVolume::lookup_by_name(vpool, vol) {
Ok(virt_vol) => {
if let Err(er) = virt_vol.delete(0) {
warn!("Can't delete {}/{}: {}", pool, vol, er);
}
}
Err(er) => {
warn!("Can't acquire handle to {}/{}: {}", pool, vol, er);
}
}
}
}
}
// undefine IP lease
if let Some(lease) = &mut self.lease {
lease.delete().map_err(InstanceError::other)?;
}
// delete instance
virt_domain
.undefine()
.map_err(InstanceError::DomainDelete)?;
self.db_data.delete().map_err(InstanceError::other)?;
Ok(())
}
pub fn from_entity(ctx: Context, db_data: Entity<InstDb>) -> Result<Self, InstanceError> {
let name = String::from_utf8_lossy(&db_data.key).into_owned();
let virt_domain = match virt::domain::Domain::lookup_by_name(&ctx.virt.conn, &name) {
Ok(inst) => Ok(inst),
Err(err) => {
if err.code() == virt::error::ErrorNumber::NoDomain {
// domain not found
Err(InstanceError::DomainNotFound(name.to_owned()))
} else {
Err(InstanceError::VirtError(err))
}
}
}?;
let domain_xml: Domain = {
let xml_str = virt_domain
.get_xml_desc(0)
.map_err(InstanceError::VirtError)?;
quick_xml::de::from_str(&xml_str).map_err(InstanceError::CantDeserialize)?
};
let lease = match Subnet::get_by_key(ctx.db.clone(), db_data.lease_if.as_bytes())
.map_err(InstanceError::other)?
{
Some(subnet) => subnet
.leases()
.map_err(InstanceError::other)?
.find(|l| {
if let Ok(lease) = l {
lease.ipv4_addr == db_data.lease_addr
} else {
false
}
})
.map(|o| o.unwrap()),
None => None,
};
Ok(Self {
ctx,
domain_xml,
db_data,
lease,
})
}
pub async fn lookup_by_name(ctx: Context, name: &str) -> Result<Option<Self>, InstanceError> {
let db_data = match InstDb::get_by_key(ctx.db.clone(), name.as_bytes())
.map_err(InstanceError::other)?
{
Some(data) => data,
None => {
return Ok(None);
}
};
// TODO: handle from_instdb having None?
Self::from_entity(ctx, db_data).map(Some)
}
pub fn virt(&self) -> Result<virt::domain::Domain, InstanceError> {
let name = self.domain_xml.name.as_str();
match virt::domain::Domain::lookup_by_name(&self.ctx.virt.conn, name) {
Ok(inst) => Ok(inst),
Err(err) => {
if err.code() == virt::error::ErrorNumber::NoDomain {
// domain not found
Err(InstanceError::DomainNotFound(name.to_owned()))
} else {
Err(InstanceError::VirtError(err))
}
}
}
}
pub fn xml(&self) -> &Domain {
&self.domain_xml
}
pub fn ip_lease(&self) -> Option<&Lease> {
self.lease.as_deref()
}
}
impl From<&Instance> for nzr_api::model::Instance {
fn from(value: &Instance) -> Self {
nzr_api::model::Instance {
name: value.domain_xml.name.clone(),
uuid: value.domain_xml.uuid,
lease: value.lease.as_ref().map(|l| nzr_api::model::Lease {
addr: l.ipv4_addr.clone(),
mac_addr: l.mac_addr.clone(),
}),
state: value.virt().map_or(Default::default(), |domain| {
domain
.get_state()
.map_or(Default::default(), |(code, _reason)| code.into())
}),
}
}
}
#[derive(Debug)]
pub enum InstanceError {
VirtError(virt::error::Error),
NotInDb,
CantDeserialize(quick_xml::de::DeError),
CantSerialize(quick_xml::de::DeError),
DbError(sled::Error),
MalformedData,
DomainNotFound(String),
CreationFailed(virt::error::Error),
BadInterface(Utf8Error),
NoSubnetForInterface,
Other(Box<dyn std::error::Error>),
LeaseNotInDb,
DomainDelete(virt::error::Error),
LeaseUndefined,
}
impl std::fmt::Display for InstanceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::VirtError(er) => er.fmt(f),
Self::NotInDb => write!(f, "Domain exists in libvirt but is not in database"),
Self::CantDeserialize(er) => write!(f, "Deserializing domain XML failed: {}", er),
Self::CantSerialize(er) => write!(f, "Serializing domain XML failed: {}", er),
Self::DbError(er) => write!(f, "Database error: {}", er),
Self::DomainNotFound(name) => write!(f, "No domain {} found in libvirt", name),
Self::MalformedData => write!(f, "Entry has malformed data in database"),
Self::CreationFailed(er) => write!(f, "Error while creating domain: {}", er),
Self::BadInterface(er) => {
write!(f, "Couldn't get interface name from database: {}", er)
}
Self::NoSubnetForInterface => {
write!(f, "Interface associated with instance isn't in database!")
}
Self::LeaseNotInDb => write!(
f,
"Found IP address, but it doesn't correspond to a lease in the database"
),
Self::DomainDelete(ve) => write!(f, "Couldn't delete libvirt domain: {}", ve),
Self::LeaseUndefined => write!(f, "Lease has been undefined by another function"),
Self::Other(er) => er.fmt(f),
}
}
}
impl InstanceError {
fn other<E>(err: E) -> Self
where
E: std::error::Error + 'static,
{
Self::Other(Box::new(err))
}
}
impl std::error::Error for InstanceError {}

100
nzrd/src/ctx.rs Normal file
View file

@ -0,0 +1,100 @@
use std::{fmt, ops::Deref};
use virt::connect::Connect;
use crate::{dns::ZoneData, virt::VirtPool};
use nzr_api::config::Config;
use std::sync::Arc;
pub struct PoolRefs {
pub primary: VirtPool,
pub secondary: VirtPool,
pub cidata: VirtPool,
pub baseimg: VirtPool,
}
impl PoolRefs {
pub fn find_pool(&self, name: &str) -> Option<&VirtPool> {
for pool in [&self.primary, &self.secondary, &self.baseimg, &self.cidata] {
if let Ok(pool_name) = pool.get_name() {
if pool_name == name {
return Some(pool);
}
}
}
None
}
}
pub struct VirtCtx {
pub conn: virt::connect::Connect,
pub pools: PoolRefs,
}
#[derive(Clone)]
pub struct Context(Arc<InnerCtx>);
impl Deref for Context {
type Target = InnerCtx;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub struct InnerCtx {
pub db: sled::Db,
pub config: Config,
pub zones: crate::dns::ZoneData,
pub virt: VirtCtx,
}
#[derive(Debug)]
pub enum ContextError {
Virt(virt::error::Error),
Db(sled::Error),
Pool(crate::virt::PoolError),
}
impl fmt::Display for ContextError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Virt(ve) => write!(f, "Error connecting to libvirt: {}", ve),
Self::Db(de) => write!(f, "Error opening database: {}", de),
Self::Pool(pe) => write!(f, "Error opening pool: {}", pe),
}
}
}
impl std::error::Error for ContextError {}
impl InnerCtx {
fn new(config: Config) -> Result<Self, ContextError> {
let zones = ZoneData::new(&config.dns);
let conn = Connect::open(&config.libvirt_uri).map_err(ContextError::Virt)?;
virt::error::clear_error_callback();
let pools = PoolRefs {
primary: VirtPool::lookup_by_name(&conn, &config.storage.primary_pool)
.map_err(ContextError::Pool)?,
secondary: VirtPool::lookup_by_name(&conn, &config.storage.secondary_pool)
.map_err(ContextError::Pool)?,
cidata: VirtPool::lookup_by_name(&conn, &config.storage.ci_image_pool)
.map_err(ContextError::Pool)?,
baseimg: VirtPool::lookup_by_name(&conn, &config.storage.base_image_pool)
.map_err(ContextError::Pool)?,
};
Ok(Self {
db: sled::open(&config.db_path).map_err(ContextError::Db)?,
config,
zones,
virt: VirtCtx { conn, pools },
})
}
}
impl Context {
pub fn new(config: Config) -> Result<Self, ContextError> {
let inner = InnerCtx::new(config)?;
Ok(Self(Arc::new(inner)))
}
}

196
nzrd/src/dns.rs Normal file
View file

@ -0,0 +1,196 @@
use crate::ctrl::net::Subnet;
use log::*;
use nzr_api::config::DNSConfig;
use std::borrow::Borrow;
use std::collections::{BTreeMap, HashMap};
use std::net::Ipv4Addr;
use std::ops::Deref;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use trust_dns_server::authority::{AuthorityObject, Catalog};
use trust_dns_server::client::rr::{LowerName, RrKey};
use trust_dns_server::proto::rr::{rdata::soa, RData, RecordSet};
use trust_dns_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo};
use trust_dns_server::{
proto::rr::{Name, Record},
store::in_memory::InMemoryAuthority,
};
#[derive(Clone)]
pub struct CatalogRef(Arc<RwLock<Catalog>>);
macro_rules! make_serial {
() => {{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("You know what? Fuck you. *unepochs your time*")
.as_secs() as u32
}};
}
#[async_trait::async_trait]
impl RequestHandler for CatalogRef {
async fn handle_request<R>(&self, request: &Request, response_handle: R) -> ResponseInfo
where
R: ResponseHandler,
{
self.0
.read()
.await
.handle_request(request, response_handle)
.await
}
}
#[derive(Clone)]
pub struct ZoneData(Arc<InnerZD>);
impl Deref for ZoneData {
type Target = InnerZD;
fn deref(&self) -> &Self::Target {
self.0.as_ref()
}
}
impl ZoneData {
pub fn new(dns_config: &DNSConfig) -> Self {
ZoneData(Arc::new(InnerZD::new(dns_config)))
}
}
pub struct InnerZD {
default_zone: Arc<InMemoryAuthority>,
map: Mutex<HashMap<String, Arc<InMemoryAuthority>>>,
catalog: CatalogRef,
config: DNSConfig,
}
pub fn make_rectree_with_soa(name: &Name, config: &DNSConfig) -> BTreeMap<RrKey, RecordSet> {
debug!("Creating initial SOA for {}", &name);
let mut records: BTreeMap<RrKey, RecordSet> = BTreeMap::new();
let soa_key = RrKey::new(
LowerName::from(name),
trust_dns_server::proto::rr::RecordType::SOA,
);
let soa_rec = Record::from_rdata(
name.clone(),
3600,
RData::SOA(soa::SOA::new(
name.clone(),
config.soa.contact.clone(),
make_serial!(),
config.soa.refresh,
config.soa.retry,
config.soa.expire,
3600,
)),
);
records.insert(soa_key, RecordSet::from(soa_rec));
records
}
impl InnerZD {
pub fn new(dns_config: &DNSConfig) -> Self {
let default_zone = Arc::new({
let records = make_rectree_with_soa(&dns_config.default_zone, dns_config);
InMemoryAuthority::new(
dns_config.default_zone.clone(),
records,
trust_dns_server::authority::ZoneType::Primary,
false,
)
.unwrap()
});
let mut catalog = Catalog::new();
catalog.upsert(
dns_config.default_zone.borrow().into(),
Box::new(default_zone.clone()),
);
Self {
default_zone,
map: Mutex::new(HashMap::new()),
catalog: CatalogRef(Arc::new(RwLock::new(catalog))),
config: dns_config.clone(),
}
}
pub async fn new_zone(&self, subnet: &Subnet) -> Result<(), Box<dyn std::error::Error>> {
if let Some(name) = &subnet.domain_name {
let auth = InMemoryAuthority::new(
name.clone(),
make_rectree_with_soa(name, &self.config),
trust_dns_server::authority::ZoneType::Primary,
false,
)?;
self.import(&subnet.interface, auth).await;
}
Ok(())
}
pub async fn import(&self, name: &str, auth: InMemoryAuthority) {
let auth_arc = Arc::new(auth);
self.map
.lock()
.await
.insert(name.to_owned(), auth_arc.clone());
self.catalog
.0
.write()
.await
.upsert(auth_arc.origin().clone(), Box::new(auth_arc.clone()));
}
pub async fn delete_zone(&self, interface: &str) -> bool {
self.map.lock().await.remove(interface).is_some()
}
pub async fn new_record(
&self,
interface: &str,
name: &str,
addr: Ipv4Addr,
) -> Result<(), Box<dyn std::error::Error>> {
let hostname = Name::from_str(name)?;
let zones = self.map.lock().await;
let zone = zones.get(interface).unwrap_or(&self.default_zone);
let fqdn = {
let origin: Name = zone.origin().into();
hostname.append_domain(&origin)?
};
let record = Record::from_rdata(fqdn, 3600, RData::A(addr));
zone.upsert(record, 0).await;
self.catalog()
.0
.write()
.await
.upsert(zone.origin().clone(), Box::new(zone.clone()));
Ok(())
}
pub async fn delete_record(
&self,
interface: &str,
name: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let hostname = Name::from_str(name)?;
let mut zones = self.map.lock().await;
if let Some(zone) = zones.get_mut(interface) {
let hostname: LowerName = hostname.into();
self.catalog.0.write().await.remove(&hostname);
let key = RrKey::new(hostname, trust_dns_server::proto::rr::RecordType::A);
Ok(zone.records_mut().await.remove(&key).is_some())
} else {
Ok(false)
}
}
pub fn catalog(&self) -> CatalogRef {
self.catalog.clone()
}
}

99
nzrd/src/main.rs Normal file
View file

@ -0,0 +1,99 @@
mod cloud;
mod cmd;
mod ctrl;
mod ctx;
mod dns;
mod prelude;
mod rpc;
#[cfg(test)]
mod test;
mod virt;
use crate::ctrl::{net::Subnet, Storable};
use log::LevelFilter;
use log::*;
use nzr_api::config;
use std::str::FromStr;
use tokio::net::UdpSocket;
use trust_dns_server::ServerFuture;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg: config::Config = config::Config::figment().extract()?;
let ctx = ctx::Context::new(cfg)?;
syslog::init_unix(
syslog::Facility::LOG_DAEMON,
LevelFilter::from_str(ctx.config.log_level.as_str())?,
)?;
info!("Hydrating initial zones...");
for subnet in Subnet::all(ctx.db.clone())? {
match subnet {
Ok(subnet) => {
// A records
if let Err(err) = ctx.zones.new_zone(&subnet).await {
error!("Couldn't create zone for {}: {}", &subnet.interface, err);
continue;
}
match subnet.leases() {
Ok(leases) => {
for lease in leases {
match lease {
Ok(lease) => {
if let Err(err) = ctx
.zones
.new_record(
&subnet.interface,
&lease.inst_name,
lease.ipv4_addr.addr,
)
.await
{
error!(
"Failed to set up lease for {} in {}: {}",
&lease.inst_name, &subnet.interface, err
);
}
}
Err(err) => {
warn!(
"Lease iterator error while hydrating {}: {}",
&subnet.interface, err
);
}
}
}
}
Err(err) => {
error!("Couldn't get leases for {}: {}", &subnet.interface, err);
continue;
}
}
}
Err(err) => {
warn!("Error while iterating subnets: {}", err);
}
}
}
// DNS init
let mut dns_listener = ServerFuture::new(ctx.zones.catalog());
let dns_socket = UdpSocket::bind(ctx.config.dns.listen_addr.as_str()).await?;
dns_listener.register_socket(dns_socket);
tokio::select! {
res = rpc::serve(ctx.clone(), ctx.zones.clone()) => {
if let Err(err) = res {
error!("Error from RPC: {}", err);
}
},
res = dns_listener.block_until_done() => {
if let Err(err) = res {
error!("Error from DNS: {}", err);
}
}
}
Ok(())
}

10
nzrd/src/prelude.rs Normal file
View file

@ -0,0 +1,10 @@
macro_rules! datasize {
($amt:tt $unit:tt) => {
$crate::ctrl::virtxml::SizeInfo {
amount: $amt as u64,
unit: $crate::ctrl::virtxml::SizeUnit::$unit,
}
};
}
pub(crate) use datasize;

167
nzrd/src/rpc.rs Normal file
View file

@ -0,0 +1,167 @@
use nzr_api::{args, model, Nazrin};
use tarpc::server::{BaseChannel, Channel};
use tarpc::tokio_serde::formats::Bincode;
use tarpc::tokio_util::codec::LengthDelimitedCodec;
use tokio::net::UnixListener;
use crate::ctrl::vm::InstDb;
use crate::ctrl::{net::Subnet, Storable};
use crate::ctx::Context;
use crate::dns::ZoneData;
use crate::{cmd, ctrl::vm::Instance};
use log::*;
use std::ops::Deref;
#[derive(Clone)]
pub struct NzrServer {
ctx: Context,
zones: ZoneData,
}
impl NzrServer {
pub fn new(ctx: Context, zones: ZoneData) -> Self {
Self { ctx, zones }
}
}
#[tarpc::server]
impl Nazrin for NzrServer {
async fn new_instance(
self,
_: tarpc::context::Context,
build_args: args::NewInstance,
) -> Result<model::Instance, String> {
let inst = cmd::vm::new_instance(self.ctx.clone(), &build_args)
.await
.map_err(|e| format!("Instance creation failed: {}", e))?;
let addr = inst.ip_lease().map(|l| l.ipv4_addr.addr);
if let Some(addr) = addr {
if let Err(err) = self
.zones
.new_record(&build_args.interface, &build_args.name, addr)
.await
{
warn!("Instance created, but no DNS record was made: {}", err);
}
}
Ok((&inst).into())
}
async fn delete_instance(self, _: tarpc::context::Context, name: String) -> Result<(), String> {
cmd::vm::delete_instance(self.ctx.clone(), name)
.await
.map_err(|e| format!("Couldn't delete instance: {}", e))?;
Ok(())
}
async fn get_instances(
self,
_: tarpc::context::Context,
) -> Result<Vec<model::Instance>, String> {
let insts: Vec<model::Instance> = InstDb::all(self.ctx.db.clone())
.map_err(|e| e.to_string())?
.filter_map(|i| match i {
Ok(entity) => match Instance::from_entity(self.ctx.clone(), entity.clone()) {
Ok(instance) => Some(<&Instance as Into<model::Instance>>::into(&instance)),
Err(err) => {
let ent_name = {
let key = entity.key();
String::from_utf8_lossy(key).to_string()
};
warn!("Couldn't get instance for {}: {}", err, ent_name);
None
}
},
Err(err) => {
warn!("Iterator error: {}", err);
None
}
})
.collect();
Ok(insts)
}
async fn new_subnet(
self,
_: tarpc::context::Context,
build_args: model::Subnet,
) -> Result<model::Subnet, String> {
let subnet = cmd::net::add_subnet(&self.ctx, build_args)
.await
.map_err(|e| e.to_string())?;
self.zones
.new_zone(&subnet)
.await
.map_err(|e| e.to_string())?;
Ok(<&Subnet as Into<model::Subnet>>::into(&subnet))
}
async fn get_subnets(self, _: tarpc::context::Context) -> Result<Vec<model::Subnet>, String> {
let subnets: Vec<model::Subnet> = Subnet::all(self.ctx.db.clone())
.map_err(|e| e.to_string())?
.filter_map(|s| match s {
Ok(s) => Some(<&Subnet as Into<model::Subnet>>::into(s.deref())),
Err(err) => {
warn!("Iterator error: {}", err);
None
}
})
.collect();
Ok(subnets)
}
async fn delete_subnet(
self,
_: tarpc::context::Context,
interface: String,
) -> Result<(), String> {
cmd::net::delete_subnet(&self.ctx, &interface).map_err(|e| e.to_string())?;
self.zones.delete_zone(&interface).await;
Ok(())
}
async fn garbage_collect(self, _: tarpc::context::Context) -> Result<(), String> {
cmd::vm::prune_instances(&self.ctx).map_err(|e| e.to_string())?;
Ok(())
}
}
#[derive(Debug)]
pub struct GroupError(String);
impl std::fmt::Display for GroupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Group {} does not exist", &self.0)
}
}
impl std::error::Error for GroupError {}
pub async fn serve(ctx: Context, zones: ZoneData) -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
if ctx.config.rpc.socket_path.exists() {
std::fs::remove_file(&ctx.config.rpc.socket_path)?;
}
let listener = UnixListener::bind(&ctx.config.rpc.socket_path)?;
// setup permissions so admins can actually connect to it
std::fs::set_permissions(
&ctx.config.rpc.socket_path,
std::fs::Permissions::from_mode(0o770),
)?;
if let Some(group) = &ctx.config.rpc.admin_group {
let group = nix::unistd::Group::from_name(group)?.ok_or(GroupError(group.clone()))?;
nix::unistd::chown(&ctx.config.rpc.socket_path, None, Some(group.gid))?;
}
let codec_builder = LengthDelimitedCodec::builder();
loop {
let (conn, _addr) = listener.accept().await?;
let framed = codec_builder.new_framed(conn);
let transport = tarpc::serde_transport::new(framed, Bincode::default());
BaseChannel::with_defaults(transport)
.execute(NzrServer::new(ctx.clone(), zones.clone()).serve())
.await;
}
}

126
nzrd/src/schema.rs Normal file
View file

@ -0,0 +1,126 @@
// @generated automatically by Diesel CLI.
diesel::table! {
comments (id) {
id -> Int4,
domain_id -> Int4,
name -> Varchar,
#[sql_name = "type"]
type_ -> Varchar,
modified_at -> Int4,
account -> Nullable<Varchar>,
comment -> Varchar,
}
}
diesel::table! {
cryptokeys (id) {
id -> Int4,
domain_id -> Nullable<Int4>,
flags -> Int4,
active -> Nullable<Bool>,
published -> Nullable<Bool>,
content -> Nullable<Text>,
}
}
diesel::table! {
domainmetadata (id) {
id -> Int4,
domain_id -> Nullable<Int4>,
kind -> Nullable<Varchar>,
content -> Nullable<Text>,
}
}
diesel::table! {
domains (id) {
id -> Int4,
name -> Varchar,
master -> Nullable<Varchar>,
last_check -> Nullable<Int4>,
#[sql_name = "type"]
type_ -> Text,
notified_serial -> Nullable<Int8>,
account -> Nullable<Varchar>,
options -> Nullable<Text>,
catalog -> Nullable<Text>,
}
}
diesel::table! {
instances (id) {
id -> Int4,
name -> Varchar,
uuid -> Uuid,
arec_id -> Int8,
ptr_id -> Int8,
lease -> Int8,
}
}
diesel::table! {
leases (id) {
id -> Int8,
subnet_id -> Int4,
host -> Int4,
}
}
diesel::table! {
records (id) {
id -> Int8,
domain_id -> Nullable<Int4>,
name -> Nullable<Varchar>,
#[sql_name = "type"]
type_ -> Nullable<Varchar>,
content -> Nullable<Varchar>,
ttl -> Nullable<Int4>,
prio -> Nullable<Int4>,
disabled -> Nullable<Bool>,
ordername -> Nullable<Varchar>,
auth -> Nullable<Bool>,
}
}
diesel::table! {
subnets (id) {
id -> Int4,
ifname -> Varchar,
network -> Cidr,
}
}
diesel::table! {
supermasters (ip, nameserver) {
ip -> Inet,
nameserver -> Varchar,
account -> Varchar,
}
}
diesel::table! {
tsigkeys (id) {
id -> Int4,
name -> Nullable<Varchar>,
algorithm -> Nullable<Varchar>,
secret -> Nullable<Varchar>,
}
}
diesel::joinable!(cryptokeys -> domains (domain_id));
diesel::joinable!(domainmetadata -> domains (domain_id));
diesel::joinable!(leases -> subnets (subnet_id));
diesel::allow_tables_to_appear_in_same_query!(
comments,
cryptokeys,
domainmetadata,
domains,
instances,
leases,
records,
subnets,
supermasters,
tsigkeys,
);

54
nzrd/src/test.rs Normal file
View file

@ -0,0 +1,54 @@
use std::{net::Ipv4Addr, str::FromStr};
use crate::cloud::*;
use nzr_api::net::{cidr::CidrV4, mac::MacAddr};
#[test]
fn cloud_metadata() {
let expected = r#"
instance-id: my-instance
local-hostname: my-instance
public-keys:
- ssh-key 123456 admin@laptop
"#
.trim_start();
let pubkeys = vec!["ssh-key 123456 admin@laptop".to_owned(), "".to_owned()];
let meta = Metadata::new("my-instance").ssh_pubkeys(&pubkeys);
let meta_xml = serde_yaml::to_string(&meta).unwrap();
assert_eq!(meta_xml, expected);
}
#[test]
fn cloud_netdata() {
let expected = r#"
version: 2
ethernets:
eth0:
match:
macaddress: 02:15:42:0b:ee:01
addresses:
- 192.0.2.69/24
gateway4: 192.0.2.1
dhcp4: false
nameservers:
search: []
addresses:
- 192.0.2.1
"#
.trim_start();
let mac_addr = MacAddr::new(0x02, 0x15, 0x42, 0x0b, 0xee, 0x01);
let cidr = CidrV4::from_str("192.0.2.69/24").unwrap();
let gateway = Ipv4Addr::from_str("192.0.2.1").unwrap();
let dns = vec![gateway];
let netconfig = NetworkMeta::new().static_nic(
EtherMatch::mac_addr(&mac_addr),
&cidr,
&gateway,
DNSMeta::with_addrs(None, &dns),
);
let net_xml = serde_yaml::to_string(&netconfig).unwrap();
assert_eq!(net_xml, expected);
}

229
nzrd/src/virt.rs Normal file
View file

@ -0,0 +1,229 @@
use std::io::{prelude::*, BufReader};
use std::{fmt::Display, ops::Deref};
use virt::{storage_pool::StoragePool, storage_vol::StorageVol, stream::Stream};
use crate::{
ctrl::virtxml::{Pool, SizeInfo, Volume},
prelude::*,
};
use log::*;
pub struct VirtVolume {
inner: StorageVol,
pub persist: bool,
pub name: String,
}
impl VirtVolume {
pub fn create_xml(
pool: &StoragePool,
xmldata: Volume,
flags: u32,
) -> Result<Self, Box<dyn std::error::Error>> {
let xml = quick_xml::se::to_string(&xmldata)?;
Ok(Self {
inner: StorageVol::create_xml(pool, &xml, flags)?,
persist: false,
name: xmldata.name,
})
}
pub fn lookup_by_name<P>(pool: P, name: &str) -> Result<Self, virt::error::Error>
where
P: AsRef<StoragePool>,
{
Ok(Self {
inner: StorageVol::lookup_by_name(pool.as_ref(), name)?,
// default to persisting when looking up by name
persist: true,
name: name.to_owned(),
})
}
pub fn clone_vol(
&mut self,
pool: &VirtPool,
vol_name: &str,
size: SizeInfo,
) -> Result<Self, PoolError> {
debug!("Cloning volume to {} ({})", vol_name, &size);
let src_path = self.get_path().map_err(PoolError::NoPath)?;
let src_fd = std::fs::File::open(src_path).map_err(PoolError::FileError)?;
let newvol = Volume::new(vol_name, pool.xml.vol_type(), size);
let newxml_str = quick_xml::se::to_string(&newvol).map_err(PoolError::SerdeError)?;
debug!("Creating new vol...");
let cloned = StorageVol::create_xml(pool, &newxml_str, 0).map_err(PoolError::VirtError)?;
match cloned.get_info() {
Ok(info) => {
if info.capacity != u64::from(size) {
debug!(
"libvirt set wrong size {}, trying this again...",
info.capacity
);
if let Err(er) = cloned.resize(size.into(), 0) {
if let Err(er) = cloned.delete(0) {
warn!("Resizing disk failed, and couldn't clean up: {}", er);
}
return Err(PoolError::VirtError(er));
}
} else {
debug!(
"capacity is correct ({} bytes), allocation = {} bytes",
info.capacity, info.allocation,
);
}
}
Err(er) => {
if let Err(er) = cloned.delete(0) {
warn!("Couldn't clean up destination volume: {}", er);
}
return Err(PoolError::VirtError(er));
}
}
let stream = match Stream::new(&cloned.get_connect().map_err(PoolError::VirtError)?, 0) {
Ok(s) => s,
Err(er) => {
cloned.delete(0).ok();
return Err(PoolError::VirtError(er));
}
};
let img_size = src_fd.metadata().unwrap().len();
if let Err(er) = cloned.upload(&stream, 0, img_size, 0) {
cloned.delete(0).ok();
return Err(PoolError::CantUpload(er));
}
let buf_cap: u64 = datasize!(4 MiB).into();
let mut reader = BufReader::with_capacity(buf_cap as usize, src_fd);
loop {
let read_bytes = {
// read from the source file...
let data = match reader.fill_buf() {
Ok(buf) => buf,
Err(er) => {
if let Err(er) = stream.abort() {
warn!("Stream abort failed: {}", er);
}
if let Err(er) = cloned.delete(0) {
warn!("Couldn't delete destination volume: {}", er);
}
return Err(PoolError::FileError(er));
}
};
if data.is_empty() {
break;
}
// ... and then send upstream
let mut send_idx = 0;
while send_idx < data.len() {
match stream.send(&data[send_idx..]) {
Ok(sz) => {
send_idx += sz;
}
Err(er) => {
if let Err(er) = stream.abort() {
warn!("Stream abort failed: {}", er);
}
if let Err(er) = cloned.delete(0) {
warn!("Couldn't delete destination volume: {}", er);
}
return Err(PoolError::UploadError(er));
}
}
}
data.len()
};
reader.consume(read_bytes);
}
Ok(Self {
inner: cloned,
persist: false,
name: vol_name.to_owned(),
})
}
}
impl Deref for VirtVolume {
type Target = StorageVol;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl Drop for VirtVolume {
fn drop(&mut self) {
if !self.persist {
debug!("Deleting volume {}", &self.name);
self.inner.delete(0).ok();
}
}
}
#[derive(Debug)]
pub enum PoolError {
VirtError(virt::error::Error),
SerdeError(quick_xml::de::DeError),
NoPath(virt::error::Error),
FileError(std::io::Error),
CantUpload(virt::error::Error),
UploadError(virt::error::Error),
}
impl Display for PoolError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::VirtError(er) => er.fmt(f),
Self::SerdeError(er) => er.fmt(f),
Self::NoPath(er) => write!(f, "Couldn't get source image path: {}", er),
Self::FileError(er) => er.fmt(f),
Self::CantUpload(er) => write!(f, "Unable to start upload to image: {}", er),
Self::UploadError(er) => write!(f, "Failed to upload image: {}", er),
}
}
}
impl std::error::Error for PoolError {}
pub struct VirtPool {
inner: StoragePool,
pub xml: Pool,
}
impl Deref for VirtPool {
type Target = StoragePool;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl AsRef<StoragePool> for VirtPool {
fn as_ref(&self) -> &StoragePool {
&self.inner
}
}
impl VirtPool {
pub fn lookup_by_name(conn: &virt::connect::Connect, id: &str) -> Result<Self, PoolError> {
let inner = StoragePool::lookup_by_name(conn, id).map_err(PoolError::VirtError)?;
if !inner.is_active().map_err(PoolError::VirtError)? {
inner.create(0).map_err(PoolError::VirtError)?;
}
let xml_str = inner.get_xml_desc(0).map_err(PoolError::VirtError)?;
let xml = quick_xml::de::from_str(&xml_str).map_err(PoolError::SerdeError)?;
Ok(Self { inner, xml })
}
}