From d18f32e897c0a72fb8971d6139385c8edab34a7c Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 28 Mar 2025 10:46:16 +0000 Subject: [PATCH] feat: ipv6 allocation closes #9 --- .../20250327124211_ip_allocation_mode.sql | 3 + lnvps_db/src/model.rs | 18 +- lnvps_db/src/mysql.rs | 20 +- src/bin/api.rs | 3 +- src/fiat/revolut.rs | 2 +- src/host/proxmox.rs | 51 +++-- src/mocks.rs | 42 +++- src/provisioner/capacity.rs | 3 +- src/provisioner/lnvps.rs | 127 +++++++----- src/provisioner/network.rs | 196 +++++++++++++----- 10 files changed, 322 insertions(+), 143 deletions(-) create mode 100644 lnvps_db/migrations/20250327124211_ip_allocation_mode.sql diff --git a/lnvps_db/migrations/20250327124211_ip_allocation_mode.sql b/lnvps_db/migrations/20250327124211_ip_allocation_mode.sql new file mode 100644 index 0000000..4d1fd66 --- /dev/null +++ b/lnvps_db/migrations/20250327124211_ip_allocation_mode.sql @@ -0,0 +1,3 @@ +alter table ip_range + add column allocation_mode smallint unsigned not null default 0, + add column use_full_range bit(1) not null; \ No newline at end of file diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index ffe275b..a633ce1 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -247,7 +247,7 @@ pub enum RouterKind { OvhAdditionalIp = 1, } -#[derive(FromRow, Clone, Debug)] +#[derive(FromRow, Clone, Debug, Default)] pub struct IpRange { pub id: u64, pub cidr: String, @@ -256,6 +256,22 @@ pub struct IpRange { pub region_id: u64, pub reverse_zone_id: Option, pub access_policy_id: Option, + pub allocation_mode: IpRangeAllocationMode, + /// Use all IPs in the range, including first and last + pub use_full_range: bool, +} + +#[derive(Debug, Clone, sqlx::Type, Default)] +#[repr(u16)] +/// How ips are allocated from this range +pub enum IpRangeAllocationMode { + /// IPs are assigned in a random order + Random = 0, + #[default] + /// IPs are assigned in sequential order + Sequential = 1, + /// IP(v6) assignment uses SLAAC EUI-64 + SlaacEui64 = 2, } #[derive(FromRow, Clone, Debug)] diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index bc3f644..4de2ecf 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -80,13 +80,13 @@ impl LNVpsDb for LNVpsDbMysql { Ok(sqlx::query( "insert into user_ssh_key(name,user_id,key_data) values(?, ?, ?) returning id", ) - .bind(&new_key.name) - .bind(new_key.user_id) - .bind(&new_key.key_data) - .fetch_one(&self.db) - .await - .map_err(Error::new)? - .try_get(0)?) + .bind(&new_key.name) + .bind(new_key.user_id) + .bind(&new_key.key_data) + .fetch_one(&self.db) + .await + .map_err(Error::new)? + .try_get(0)?) } async fn get_user_ssh_key(&self, id: u64) -> Result { @@ -489,9 +489,9 @@ impl LNVpsDb for LNVpsDbMysql { sqlx::query_as( "select * from vm_payment where is_paid = true order by created desc limit 1", ) - .fetch_optional(&self.db) - .await - .map_err(Error::new) + .fetch_optional(&self.db) + .await + .map_err(Error::new) } async fn list_custom_pricing(&self, region_id: u64) -> Result> { diff --git a/src/bin/api.rs b/src/bin/api.rs index 5a77f89..03e0ebf 100644 --- a/src/bin/api.rs +++ b/src/bin/api.rs @@ -1,5 +1,4 @@ use anyhow::Error; -use chrono::Utc; use clap::Parser; use config::{Config, File}; use lnvps::api; @@ -13,7 +12,7 @@ use lnvps::settings::Settings; use lnvps::status::VmStateCache; use lnvps::worker::{WorkJob, Worker}; use lnvps_db::{LNVpsDb, LNVpsDbMysql}; -use log::{error, LevelFilter}; +use log::error; use nostr::Keys; use nostr_sdk::Client; use rocket::http::Method; diff --git a/src/fiat/revolut.rs b/src/fiat/revolut.rs index 11d1361..09d736d 100644 --- a/src/fiat/revolut.rs +++ b/src/fiat/revolut.rs @@ -5,7 +5,7 @@ use crate::settings::RevolutConfig; use anyhow::{bail, Result}; use chrono::{DateTime, Utc}; use nostr::Url; -use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION}; +use reqwest::header::AUTHORIZATION; use reqwest::{Client, Method, RequestBuilder}; use serde::{Deserialize, Serialize}; use std::future::Future; diff --git a/src/host/proxmox.rs b/src/host/proxmox.rs index cae8c38..3bca847 100644 --- a/src/host/proxmox.rs +++ b/src/host/proxmox.rs @@ -10,7 +10,7 @@ use anyhow::{anyhow, bail, ensure, Result}; use chrono::Utc; use futures::StreamExt; use ipnetwork::IpNetwork; -use lnvps_db::{async_trait, DiskType, Vm, VmOsImage}; +use lnvps_db::{async_trait, DiskType, IpRangeAllocationMode, Vm, VmOsImage}; use log::{info, warn}; use rand::random; use reqwest::header::{HeaderMap, AUTHORIZATION}; @@ -414,7 +414,17 @@ impl ProxmoxClient { range_gw.ip() ) } - IpAddr::V6(addr) => format!("ip6={}", addr), + IpAddr::V6(addr) => { + let ip_range = value.ranges.iter().find(|r| r.id == ip.ip_range_id)?; + if matches!(ip_range.allocation_mode, IpRangeAllocationMode::SlaacEui64) + { + // just ignore what's in the db and use whatever the host wants + // what's in the db is purely informational + "ip6=auto".to_string() + } else { + format!("ip6={}", addr) + } + } }) } else { None @@ -422,9 +432,6 @@ impl ProxmoxClient { }) .collect::>(); - // TODO: make this configurable - ip_config.push("ip6=auto".to_string()); - let mut net = vec![ format!("virtio={}", value.vm.mac_address), format!("bridge={}", self.config.bridge), @@ -932,8 +939,7 @@ impl NodeStorage { } #[derive(Debug, Deserialize)] -pub struct NodeDisk { -} +pub struct NodeDisk {} #[derive(Debug, Serialize)] pub struct DownloadUrlRequest { @@ -1111,8 +1117,8 @@ mod tests { use super::*; use crate::{GB, MB, TB}; use lnvps_db::{ - DiskInterface, IpRange, OsDistribution, UserSshKey, VmHost, VmHostDisk, VmIpAssignment, - VmTemplate, + DiskInterface, IpRange, IpRangeAllocationMode, OsDistribution, UserSshKey, VmHost, + VmHostDisk, VmIpAssignment, VmTemplate, }; #[test] @@ -1207,6 +1213,18 @@ mod tests { dns_reverse: None, dns_reverse_ref: None, }, + VmIpAssignment { + id: 3, + vm_id: 1, + ip_range_id: 3, + ip: "fd00::ff:ff:ff:ff:ff".to_string(), + deleted: false, + arp_ref: None, + dns_forward: None, + dns_forward_ref: None, + dns_reverse: None, + dns_reverse_ref: None, + }, ], ranges: vec![ IpRange { @@ -1215,8 +1233,7 @@ mod tests { gateway: "192.168.1.1/16".to_string(), enabled: true, region_id: 1, - reverse_zone_id: None, - access_policy_id: None, + ..Default::default() }, IpRange { id: 2, @@ -1224,8 +1241,16 @@ mod tests { gateway: "10.10.10.10".to_string(), enabled: true, region_id: 2, - reverse_zone_id: None, - access_policy_id: None, + ..Default::default() + }, + IpRange { + id: 3, + cidr: "fd00::/64".to_string(), + gateway: "fd00::1".to_string(), + enabled: true, + region_id: 1, + allocation_mode: IpRangeAllocationMode::SlaacEui64, + ..Default::default() }, ], ssh_key: UserSshKey { diff --git a/src/mocks.rs b/src/mocks.rs index 1d06b13..48715e4 100644 --- a/src/mocks.rs +++ b/src/mocks.rs @@ -1,19 +1,14 @@ #![allow(unused)] use crate::dns::{BasicRecord, DnsServer, RecordType}; use crate::exchange::{ExchangeRateService, Ticker, TickerRate}; -use crate::host::{FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient}; +use crate::host::{FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostInfo}; use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode}; use crate::router::{ArpEntry, Router}; use crate::status::{VmRunningState, VmState}; use anyhow::{anyhow, bail, ensure, Context}; use chrono::{DateTime, TimeDelta, Utc}; use fedimint_tonic_lnd::tonic::codegen::tokio_stream::Stream; -use lnvps_db::{ - async_trait, AccessPolicy, DiskInterface, DiskType, IpRange, LNVpsDb, OsDistribution, User, - UserSshKey, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, - VmCustomTemplate, VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, - VmPayment, VmTemplate, -}; +use lnvps_db::{async_trait, AccessPolicy, DiskInterface, DiskType, IpRange, IpRangeAllocationMode, LNVpsDb, OsDistribution, User, UserSshKey, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate}; use std::collections::HashMap; use std::ops::Add; use std::pin::Pin; @@ -117,8 +112,19 @@ impl Default for MockDb { gateway: "10.0.0.1/8".to_string(), enabled: true, region_id: 1, - reverse_zone_id: None, - access_policy_id: None, + ..Default::default() + }, + ); + ip_ranges.insert( + 2, + IpRange { + id: 2, + cidr: "fd00::/64".to_string(), + gateway: "fd00::1".to_string(), + enabled: true, + region_id: 1, + allocation_mode: IpRangeAllocationMode::SlaacEui64, + ..Default::default() }, ); let mut hosts = HashMap::new(); @@ -324,6 +330,16 @@ impl LNVpsDb for MockDb { Ok(disks.get(&disk_id).ok_or(anyhow!("no disk"))?.clone()) } + async fn update_host_disk(&self, disk: &VmHostDisk) -> anyhow::Result<()> { + let mut disks = self.host_disks.lock().await; + if let Some(d) = disks.get_mut(&disk.id) { + d.size = disk.size; + d.kind = disk.kind; + d.interface = disk.interface; + } + Ok(()) + } + async fn get_os_image(&self, id: u64) -> anyhow::Result { let os_images = self.os_images.lock().await; Ok(os_images.get(&id).ok_or(anyhow!("no image"))?.clone()) @@ -478,7 +494,7 @@ impl LNVpsDb for MockDb { async fn update_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> anyhow::Result<()> { let mut ip_assignments = self.ip_assignments.lock().await; - if let Some(i) = ip_assignments.get_mut(&ip_assignment.vm_id) { + if let Some(i) = ip_assignments.get_mut(&ip_assignment.id) { i.arp_ref = ip_assignment.arp_ref.clone(); i.dns_forward = ip_assignment.dns_forward.clone(); i.dns_reverse = ip_assignment.dns_reverse.clone(); @@ -757,6 +773,10 @@ impl MockVmHost { #[async_trait] impl VmHostClient for MockVmHost { + async fn get_info(&self) -> anyhow::Result { + todo!() + } + async fn download_os_image(&self, image: &VmOsImage) -> anyhow::Result<()> { Ok(()) } @@ -876,7 +896,7 @@ impl DnsServer for MockDnsServer { zones.get_mut(zone_id).unwrap() }; - if table.values().any(|v| v.name == record.name) { + if table.values().any(|v| v.name == record.name && v.kind == record.kind.to_string()) { bail!("Duplicate record with name {}", record.name); } diff --git a/src/provisioner/capacity.rs b/src/provisioner/capacity.rs index 9d1b851..d45a57e 100644 --- a/src/provisioner/capacity.rs +++ b/src/provisioner/capacity.rs @@ -354,8 +354,7 @@ mod tests { gateway: "10.0.0.1".to_string(), enabled: true, region_id: 1, - reverse_zone_id: None, - access_policy_id: None, + ..Default::default() }, usage: 69, }], diff --git a/src/provisioner/lnvps.rs b/src/provisioner/lnvps.rs index 92a6aaf..529cb5e 100644 --- a/src/provisioner/lnvps.rs +++ b/src/provisioner/lnvps.rs @@ -3,22 +3,22 @@ use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService}; use crate::fiat::FiatPaymentService; use crate::host::{get_host_client, FullVmInfo}; use crate::lightning::{AddInvoiceRequest, LightningNode}; -use crate::provisioner::{ - CostResult, HostCapacityService, NetworkProvisioner, PricingEngine, ProvisionerMethod, -}; +use crate::provisioner::{CostResult, HostCapacityService, NetworkProvisioner, PricingEngine}; use crate::router::{ArpEntry, MikrotikRouter, OvhDedicatedServerVMacRouter, Router}; use crate::settings::{ProvisionerConfig, Settings}; use anyhow::{bail, ensure, Context, Result}; use chrono::Utc; +use ipnetwork::IpNetwork; use isocountry::CountryCode; use lnvps_db::{ - AccessPolicy, LNVpsDb, NetworkAccessPolicy, PaymentMethod, RouterKind, Vm, VmCustomTemplate, - VmHost, VmIpAssignment, VmPayment, + AccessPolicy, IpRangeAllocationMode, LNVpsDb, NetworkAccessPolicy, PaymentMethod, RouterKind, + Vm, VmCustomTemplate, VmHost, VmIpAssignment, VmPayment, }; use log::{info, warn}; use nostr::util::hex; use std::collections::HashMap; use std::ops::Add; +use std::str::FromStr; use std::sync::Arc; use std::time::Duration; @@ -88,8 +88,8 @@ impl LNVpsProvisioner { assignment: &mut VmIpAssignment, policy: &AccessPolicy, ) -> Result<()> { - // apply network policy - if let NetworkAccessPolicy::StaticArp = policy.kind { + let ip = IpNetwork::from_str(&assignment.ip)?; + if matches!(policy.kind, NetworkAccessPolicy::StaticArp) && ip.is_ipv4() { let router = self .get_router( policy @@ -116,8 +116,8 @@ impl LNVpsProvisioner { assignment: &mut VmIpAssignment, policy: &AccessPolicy, ) -> Result<()> { - // Delete access policy - if let NetworkAccessPolicy::StaticArp = &policy.kind { + let ip = IpNetwork::from_str(&assignment.ip)?; + if matches!(policy.kind, NetworkAccessPolicy::StaticArp) && ip.is_ipv4() { let router = self .get_router( policy @@ -292,26 +292,56 @@ impl LNVpsProvisioner { } // Use random network provisioner - let network = NetworkProvisioner::new(ProvisionerMethod::Random, self.db.clone()); + let network = NetworkProvisioner::new(self.db.clone()); let host = self.db.get_host(vm.host_id).await?; let ip = network.pick_ip_for_region(host.region_id).await?; - let mut assignment = VmIpAssignment { - vm_id, - ip_range_id: ip.range_id, - ip: ip.ip.to_string(), - ..Default::default() - }; + let mut assignments = vec![]; + match ip.ip4 { + Some(v4) => { + let mut assignment = VmIpAssignment { + vm_id, + ip_range_id: v4.range_id, + ip: v4.ip.ip().to_string(), + ..Default::default() + }; - //generate mac address from ip assignment - let mac = self.get_mac_for_assignment(&host, &vm, &assignment).await?; - vm.mac_address = mac.mac_address; - assignment.arp_ref = mac.id; // store ref if we got one - self.db.update_vm(&vm).await?; + //generate mac address from ip assignment + let mac = self.get_mac_for_assignment(&host, &vm, &assignment).await?; + vm.mac_address = mac.mac_address; + assignment.arp_ref = mac.id; // store ref if we got one + self.db.update_vm(&vm).await?; - self.save_ip_assignment(&mut assignment).await?; + self.save_ip_assignment(&mut assignment).await?; + assignments.push(assignment); + } + None => bail!("Cannot provision VM without an IPv4 address"), + } + match ip.ip6 { + Some(mut v6) => { + match v6.mode { + // it's a bit awkward but we need to update the IP AFTER its been picked + // simply because sometimes we dont know the MAC of the NIC yet + IpRangeAllocationMode::SlaacEui64 => { + let mac = NetworkProvisioner::parse_mac(&vm.mac_address)?; + let addr = NetworkProvisioner::calculate_eui64(&mac, &v6.ip)?; + v6.ip = IpNetwork::new(addr, v6.ip.prefix())?; + } + _ => {} + } + let mut assignment = VmIpAssignment { + vm_id, + ip_range_id: v6.range_id, + ip: v6.ip.ip().to_string(), + ..Default::default() + }; + self.save_ip_assignment(&mut assignment).await?; + assignments.push(assignment); + } + None => {} + } - Ok(vec![assignment]) + Ok(assignments) } /// Do any necessary initialization @@ -684,28 +714,30 @@ mod tests { println!("{:?}", arp); let ips = db.list_vm_ip_assignments(vm.id).await?; - assert_eq!(1, ips.len()); - let ip = ips.first().unwrap(); - println!("{:?}", ip); - assert_eq!(ip.ip, arp.address); - assert_eq!(ip.ip_range_id, 1); - assert_eq!(ip.vm_id, vm.id); - assert!(ip.dns_forward.is_some()); - assert!(ip.dns_reverse.is_some()); - assert!(ip.dns_reverse_ref.is_some()); - assert!(ip.dns_forward_ref.is_some()); - assert_eq!(ip.dns_reverse, ip.dns_forward); + assert_eq!(2, ips.len()); + + // lookup v4 ip + let v4 = ips.iter().find(|r| r.ip_range_id == 1).unwrap(); + println!("{:?}", v4); + assert_eq!(v4.ip, arp.address); + assert_eq!(v4.ip_range_id, 1); + assert_eq!(v4.vm_id, vm.id); + assert!(v4.dns_forward.is_some()); + assert!(v4.dns_reverse.is_some()); + assert!(v4.dns_reverse_ref.is_some()); + assert!(v4.dns_forward_ref.is_some()); + assert_eq!(v4.dns_reverse, v4.dns_forward); // assert IP address is not CIDR - assert!(IpAddr::from_str(&ip.ip).is_ok()); - assert!(!ip.ip.ends_with("/8")); - assert!(!ip.ip.ends_with("/24")); + assert!(IpAddr::from_str(&v4.ip).is_ok()); + assert!(!v4.ip.ends_with("/8")); + assert!(!v4.ip.ends_with("/24")); // test zones have dns entries { let zones = dns.zones.lock().await; assert_eq!(zones.get("mock-rev-zone-id").unwrap().len(), 1); - assert_eq!(zones.get("mock-forward-zone-id").unwrap().len(), 1); + assert_eq!(zones.get("mock-forward-zone-id").unwrap().len(), 2); } // now expire @@ -723,15 +755,16 @@ mod tests { } // ensure IPS are deleted - let ips = db.ip_assignments.lock().await; - let ip = ips.values().next().unwrap(); - assert!(ip.arp_ref.is_none()); - assert!(ip.dns_forward.is_none()); - assert!(ip.dns_reverse.is_none()); - assert!(ip.dns_reverse_ref.is_none()); - assert!(ip.dns_forward_ref.is_none()); - assert!(ip.deleted); - println!("{:?}", ip); + let ips = db.list_vm_ip_assignments(vm.id).await?; + for ip in ips { + println!("{:?}", ip); + assert!(ip.arp_ref.is_none()); + assert!(ip.dns_forward.is_none()); + assert!(ip.dns_reverse.is_none()); + assert!(ip.dns_reverse_ref.is_none()); + assert!(ip.dns_forward_ref.is_none()); + assert!(ip.deleted); + } Ok(()) } diff --git a/src/provisioner/network.rs b/src/provisioner/network.rs index d05c5fb..24ddc04 100644 --- a/src/provisioner/network.rs +++ b/src/provisioner/network.rs @@ -1,89 +1,167 @@ -use anyhow::{bail, Result}; -use ipnetwork::IpNetwork; -use lnvps_db::LNVpsDb; +use anyhow::{bail, Context, Result}; +use clap::builder::TypedValueParser; +use ipnetwork::{IpNetwork, Ipv6Network}; +use lnvps_db::{IpRange, IpRangeAllocationMode, LNVpsDb}; +use log::warn; use rand::prelude::IteratorRandom; +use rocket::form::validate::Contains; +use rocket::http::ext::IntoCollection; use std::collections::HashSet; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv6Addr}; use std::sync::Arc; -#[derive(Debug, Clone, Copy)] -pub enum ProvisionerMethod { - Sequential, - Random, +#[derive(Debug, Clone)] +pub struct AvailableIps { + pub ip4: Option, + pub ip6: Option, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct AvailableIp { - pub ip: IpAddr, + pub ip: IpNetwork, pub gateway: IpNetwork, pub range_id: u64, pub region_id: u64, + pub mode: IpRangeAllocationMode, } /// Handles picking available IPs #[derive(Clone)] pub struct NetworkProvisioner { - method: ProvisionerMethod, db: Arc, } impl NetworkProvisioner { - pub fn new(method: ProvisionerMethod, db: Arc) -> Self { - Self { method, db } + pub fn new(db: Arc) -> Self { + Self { db } } /// Pick an IP from one of the available ip ranges /// This method MUST return a free IP which can be used - pub async fn pick_ip_for_region(&self, region_id: u64) -> Result { + pub async fn pick_ip_for_region(&self, region_id: u64) -> Result { let ip_ranges = self.db.list_ip_range_in_region(region_id).await?; if ip_ranges.is_empty() { bail!("No ip range found in this region"); } + let mut ret = AvailableIps { + ip4: None, + ip6: None, + }; for range in ip_ranges { let range_cidr: IpNetwork = range.cidr.parse()?; - let ips = self.db.list_vm_ip_assignments_in_range(range.id).await?; - let mut ips: HashSet = ips.iter().map_while(|i| i.ip.parse().ok()).collect(); + if ret.ip4.is_none() && range_cidr.is_ipv4() { + ret.ip4 = match self.pick_ip_from_range(&range).await { + Ok(i) => Some(i), + Err(e) => { + warn!("Failed to pick ip range: {} {}", range.cidr, e); + None + } + } + } + if ret.ip6.is_none() && range_cidr.is_ipv6() { + ret.ip6 = match self.pick_ip_from_range(&range).await { + Ok(i) => Some(i), + Err(e) => { + warn!("Failed to pick ip range: {} {}", range.cidr, e); + None + } + } + } + } + if ret.ip4.is_none() && ret.ip6.is_none() { + bail!("No IPs available in this region"); + } else { + Ok(ret) + } + } - let gateway: IpNetwork = range.gateway.parse()?; + pub async fn pick_ip_from_range(&self, range: &IpRange) -> Result { + let range_cidr: IpNetwork = range.cidr.parse()?; + let ips = self.db.list_vm_ip_assignments_in_range(range.id).await?; + let mut ips: HashSet = ips.iter().map_while(|i| i.ip.parse().ok()).collect(); - // mark some IPS as always used - // Namely: - // .0 & .255 of /24 (first and last) - // gateway ip of the range + let gateway: IpNetwork = range.gateway.parse()?; + + // mark some IPS as always used + // Namely: + // .0 & .255 of /24 (first and last) + // gateway ip of the range + if !range.use_full_range && range_cidr.is_ipv4() { ips.insert(range_cidr.iter().next().unwrap()); ips.insert(range_cidr.iter().last().unwrap()); - ips.insert(gateway.ip()); + } + ips.insert(gateway.ip()); - // pick an IP at random - let ip_pick = { - match self.method { - ProvisionerMethod::Sequential => range_cidr.iter().find(|i| !ips.contains(i)), - ProvisionerMethod::Random => { - let mut rng = rand::rng(); - loop { - if let Some(i) = range_cidr.iter().choose(&mut rng) { - if !ips.contains(&i) { - break Some(i); - } - } else { - break None; + // pick an IP from the range + let ip_pick = { + match &range.allocation_mode { + IpRangeAllocationMode::Sequential => range_cidr + .iter() + .find(|i| !ips.contains(i)) + .and_then(|i| IpNetwork::new(i, range_cidr.prefix()).ok()), + IpRangeAllocationMode::Random => { + let mut rng = rand::rng(); + loop { + if let Some(i) = range_cidr.iter().choose(&mut rng) { + if !ips.contains(&i) { + break IpNetwork::new(i, range_cidr.prefix()).ok(); } + } else { + break None; } } } - }; - - if let Some(ip_pick) = ip_pick { - return Ok(AvailableIp { - range_id: range.id, - gateway, - ip: ip_pick, - region_id, - }); + IpRangeAllocationMode::SlaacEui64 => { + if range_cidr.network().is_ipv4() { + bail!("Cannot create EUI-64 from IPv4 address") + } else { + // basically always free ips here + Some(range_cidr) + } + } } } - bail!("No IPs available in this region"); + .context("No ips available in range")?; + + Ok(AvailableIp { + range_id: range.id, + gateway, + ip: ip_pick, + region_id: range.region_id, + mode: range.allocation_mode.clone(), + }) + } + + pub fn calculate_eui64(mac: &[u8; 6], prefix: &IpNetwork) -> Result { + if prefix.is_ipv4() { + bail!("Prefix must be IPv6".to_string()) + } + + let mut eui64 = [0u8; 8]; + eui64[0] = mac[0] ^ 0x02; + eui64[1] = mac[1]; + eui64[2] = mac[2]; + eui64[3] = 0xFF; + eui64[4] = 0xFE; + eui64[5] = mac[3]; + eui64[6] = mac[4]; + eui64[7] = mac[5]; + + // Combine prefix with EUI-64 interface identifier + let mut prefix_bytes = match prefix.network() { + IpAddr::V4(_) => bail!("Not supported"), + IpAddr::V6(v6) => v6.octets(), + }; + // copy EUI-64 into prefix + prefix_bytes[8..16].copy_from_slice(&eui64); + + let ipv6_addr = Ipv6Addr::from(prefix_bytes); + Ok(IpAddr::V6(ipv6_addr)) + } + + pub fn parse_mac(mac: &str) -> Result<[u8; 6]> { + Ok(hex::decode(mac.replace(":", ""))?.as_slice().try_into()?) } } @@ -98,38 +176,44 @@ mod tests { #[tokio::test] async fn pick_seq_ip_for_region_test() { let db: Arc = Arc::new(MockDb::default()); - let mgr = NetworkProvisioner::new(ProvisionerMethod::Sequential, db.clone()); + let mgr = NetworkProvisioner::new(db.clone()); + let mac: [u8; 6] = [0xff, 0xff, 0xff, 0xfa, 0xfb, 0xfc]; let gateway = IpNetwork::from_str("10.0.0.1/8").unwrap(); let first = IpAddr::from_str("10.0.0.2").unwrap(); let second = IpAddr::from_str("10.0.0.3").unwrap(); let ip = mgr.pick_ip_for_region(1).await.expect("No ip found in db"); - assert_eq!(1, ip.region_id); - assert_eq!(first, ip.ip); - assert_eq!(gateway, ip.gateway); + let v4 = ip.ip4.unwrap(); + assert_eq!(v4.region_id, 1); + assert_eq!(first, v4.ip.ip()); + assert_eq!(gateway, v4.gateway); let ip = mgr.pick_ip_for_region(1).await.expect("No ip found in db"); - assert_eq!(1, ip.region_id); - assert_eq!(first, ip.ip); + let v4 = ip.ip4.unwrap(); + assert_eq!(1, v4.region_id); + assert_eq!(first, v4.ip.ip()); db.insert_vm_ip_assignment(&VmIpAssignment { id: 0, vm_id: 0, - ip_range_id: ip.range_id, - ip: ip.ip.to_string(), + ip_range_id: v4.range_id, + ip: v4.ip.ip().to_string(), ..Default::default() }) .await .expect("Could not insert vm ip"); let ip = mgr.pick_ip_for_region(1).await.expect("No ip found in db"); - assert_eq!(second, ip.ip); + let v4 = ip.ip4.unwrap(); + assert_eq!(second, v4.ip.ip()); } #[tokio::test] async fn pick_rng_ip_for_region_test() { let db: Arc = Arc::new(MockDb::default()); - let mgr = NetworkProvisioner::new(ProvisionerMethod::Random, db); + let mgr = NetworkProvisioner::new(db); + let mac: [u8; 6] = [0xff, 0xff, 0xff, 0xfa, 0xfb, 0xfc]; let ip = mgr.pick_ip_for_region(1).await.expect("No ip found in db"); - assert_eq!(1, ip.region_id); + let v4 = ip.ip4.unwrap(); + assert_eq!(1, v4.region_id); } }