From a57c85fa2cb48dfa46d3089f362b855c9f00a77d Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 26 Mar 2025 15:48:15 +0000 Subject: [PATCH] feat: ovh virtual mac router --- README.md | 38 +- .../20250325151434_vlan_to_range_config.sql | 2 + lnvps_db/src/model.rs | 5 + lnvps_db/src/mysql.rs | 9 +- src/fiat/revolut.rs | 43 ++- src/host/mod.rs | 4 + src/host/proxmox.rs | 41 +- src/json_api.rs | 131 +++++-- src/mocks.rs | 5 + src/provisioner/lnvps.rs | 83 ++-- src/router/mikrotik.rs | 7 +- src/router/mod.rs | 8 +- src/router/ovh.rs | 355 ++++++++++++++++++ src/settings.rs | 3 - 14 files changed, 599 insertions(+), 135 deletions(-) create mode 100644 lnvps_db/migrations/20250325151434_vlan_to_range_config.sql create mode 100644 src/router/ovh.rs diff --git a/README.md b/README.md index a93d378..c12a474 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ A bitcoin powered VPS system. - [RevolutPay](https://www.revolut.com/business/revolut-pay/) - VM Backend: - Proxmox +- Router: + - Mikrotik + - OVH dedicated server virtual mac ## Required Config @@ -47,15 +50,7 @@ provisioner: os-type: "l26" bridge: "vmbr0" cpu: "kvm64" - vlan: 100 kvm: false - -# Networking policy -network-policy: - # Configure network equipment on provisioning IP resources - access: "auto" - # Use SLAAC to auto-configure VM ipv6 addresses - ip6-slaac: true ``` ### Email notifications @@ -93,30 +88,7 @@ nostr: ### Network Setup (Advanced) -When ARP is disabled (reply-only) on your router you may need to create static ARP entries when allocating -IPs, we support managing ARP entries on routers directly as part of the provisioning process. - -```yaml -# (Optional) -# When allocating IPs for VM's it may be necessary to create static ARP entries on -# your router, at least one router can be configured -# -# Currently supports: Mikrotik -router: - mikrotik: - # !! MAKE SURE TO USE HTTPS !! - url: "https://my-router.net" - username: "admin" - password: "admin" -network-policy: - # How packets get to the VM - # (default "auto", nothing to do, packets will always arrive) - access: - # Static ARP entries are added to the router for each provisioned IP - static-arp: - # Interface where the static ARP entry is added - interface: "bridge1" -``` +**TODO:** AccessPolicy is now managed in the database ### DNS (PTR/A/AAAA) @@ -124,8 +96,6 @@ To create PTR records automatically use the following config: ```yaml dns: cloudflare: - # The zone containing the reverse domain (eg. X.Y.Z.in-addr.arpa) - reverse-zone-id: "my-reverse-zone-id" # The zone where forward (A/AAAA) entries are added (eg. lnvps.cloud zone) # We create forward entries with the format vm-.lnvps.cloud forward-zone-id: "my-forward-zone-id" diff --git a/lnvps_db/migrations/20250325151434_vlan_to_range_config.sql b/lnvps_db/migrations/20250325151434_vlan_to_range_config.sql new file mode 100644 index 0000000..12b0af2 --- /dev/null +++ b/lnvps_db/migrations/20250325151434_vlan_to_range_config.sql @@ -0,0 +1,2 @@ +alter table vm_host + add column vlan_id integer unsigned; \ No newline at end of file diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 2880a4a..02d6e2c 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -86,6 +86,8 @@ pub struct VmHost { pub load_memory: f32, /// Disk load factor pub load_disk: f32, + /// VLAN id assigned to all vms on the host + pub vlan_id: Option, } #[derive(FromRow, Clone, Debug, Default)] @@ -220,7 +222,10 @@ pub struct Router { #[derive(Debug, Clone, sqlx::Type)] #[repr(u16)] pub enum RouterKind { + /// Mikrotik router (JSON-Api) Mikrotik = 0, + /// A pseudo-router which allows adding virtual mac addresses to a dedicated server + OvhAdditionalIp = 1, } #[derive(FromRow, Clone, Debug)] diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index f47c934..e7a9915 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,4 +1,8 @@ -use crate::{AccessPolicy, IpRange, LNVpsDb, Router, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate}; +use crate::{ + AccessPolicy, IpRange, LNVpsDb, Router, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing, + VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, + VmOsImage, VmPayment, VmTemplate, +}; use anyhow::{bail, Error, Result}; use async_trait::async_trait; use sqlx::{Executor, MySqlPool, Row}; @@ -318,13 +322,14 @@ impl LNVpsDb for LNVpsDbMysql { async fn update_vm(&self, vm: &Vm) -> Result<()> { sqlx::query( - "update vm set image_id=?,template_id=?,ssh_key_id=?,expires=?,disk_id=? where id=?", + "update vm set image_id=?,template_id=?,ssh_key_id=?,expires=?,disk_id=?,mac_address=? where id=?", ) .bind(vm.image_id) .bind(vm.template_id) .bind(vm.ssh_key_id) .bind(vm.expires) .bind(vm.disk_id) + .bind(&vm.mac_address) .bind(vm.id) .execute(&self.db) .await diff --git a/src/fiat/revolut.rs b/src/fiat/revolut.rs index be76e3a..11d1361 100644 --- a/src/fiat/revolut.rs +++ b/src/fiat/revolut.rs @@ -1,11 +1,12 @@ use crate::exchange::{Currency, CurrencyAmount}; use crate::fiat::{FiatPaymentInfo, FiatPaymentService}; -use crate::json_api::JsonApi; +use crate::json_api::{JsonApi, TokenGen}; use crate::settings::RevolutConfig; use anyhow::{bail, Result}; use chrono::{DateTime, Utc}; +use nostr::Url; use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION}; -use reqwest::{Client, Method}; +use reqwest::{Client, Method, RequestBuilder}; use serde::{Deserialize, Serialize}; use std::future::Future; use std::pin::Pin; @@ -15,22 +16,36 @@ pub struct RevolutApi { api: JsonApi, } +#[derive(Clone)] +struct RevolutTokenGen { + pub token: String, + pub api_version: String, +} + +impl TokenGen for RevolutTokenGen { + fn generate_token( + &self, + _method: Method, + _url: &Url, + _body: Option<&str>, + req: RequestBuilder, + ) -> Result { + Ok(req + .header(AUTHORIZATION, format!("Bearer {}", &self.token)) + .header("Revolut-Api-Version", &self.api_version)) + } +} + impl RevolutApi { pub fn new(config: RevolutConfig) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, format!("Bearer {}", config.token).parse()?); - headers.insert(ACCEPT, "application/json".parse()?); - headers.insert("Revolut-Api-Version", config.api_version.parse()?); + let gen = RevolutTokenGen { + token: config.token, + api_version: config.api_version, + }; + const DEFAULT_URL: &str = "https://merchant.revolut.com"; - let client = Client::builder().default_headers(headers).build()?; Ok(Self { - api: JsonApi { - client, - base: config - .url - .unwrap_or("https://merchant.revolut.com".to_string()) - .parse()?, - }, + api: JsonApi::token_gen(&config.url.unwrap_or(DEFAULT_URL.to_string()), false, gen)?, }) } diff --git a/src/host/mod.rs b/src/host/mod.rs index 8b04df2..4806172 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -96,6 +96,8 @@ pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result) -> Result { let vm = db.get_vm(vm_id).await?; + let host = db.get_host(vm.host_id).await?; let image = db.get_os_image(vm.image_id).await?; let disk = db.get_host_disk(vm.disk_id).await?; let ssh_key = db.get_user_ssh_key(vm.ssh_key_id).await?; @@ -141,6 +144,7 @@ impl FullVmInfo { // create VM Ok(FullVmInfo { vm, + host, template, custom_template, image, diff --git a/src/host/proxmox.rs b/src/host/proxmox.rs index d1c7fb6..0174c26 100644 --- a/src/host/proxmox.rs +++ b/src/host/proxmox.rs @@ -39,19 +39,8 @@ impl ProxmoxClient { config: QemuConfig, ssh: Option, ) -> Self { - let mut headers = HeaderMap::new(); - headers.insert( - AUTHORIZATION, - format!("PVEAPIToken={}", token).parse().unwrap(), - ); - let client = ClientBuilder::new() - .danger_accept_invalid_certs(true) - .default_headers(headers) - .build() - .expect("Failed to build client"); - Self { - api: JsonApi { base, client }, + api: JsonApi::token(base.as_str(), &format!("PVEAPIToken={}", token), true).unwrap(), config, ssh, node: node.to_string(), @@ -251,7 +240,7 @@ impl ProxmoxClient { if let Some(ssh_config) = &self.ssh { let mut ses = SshClient::new()?; ses.connect( - (self.api.base.host().unwrap().to_string(), 22), + (self.api.base().host().unwrap().to_string(), 22), &ssh_config.user, &ssh_config.key, ) @@ -429,7 +418,7 @@ impl ProxmoxClient { format!("virtio={}", value.vm.mac_address), format!("bridge={}", self.config.bridge), ]; - if let Some(t) = self.config.vlan { + if let Some(t) = value.host.vlan_id { net.push(format!("tag={}", t)); } @@ -864,6 +853,7 @@ pub enum StorageContent { ISO, VZTmpL, Import, + Snippets, } impl FromStr for StorageContent { @@ -877,6 +867,7 @@ impl FromStr for StorageContent { "iso" => Ok(StorageContent::ISO), "vztmpl" => Ok(StorageContent::VZTmpL), "import" => Ok(StorageContent::Import), + "snippets" => Ok(StorageContent::Snippets), _ => Err(()), } } @@ -896,7 +887,7 @@ impl NodeStorage { pub fn contents(&self) -> Vec { self.content .split(",") - .map_while(|s| s.parse().ok()) + .map_while(|s| StorageContent::from_str(&s).ok()) .collect() } } @@ -1076,7 +1067,8 @@ mod tests { use super::*; use crate::{GB, MB, TB}; use lnvps_db::{ - DiskInterface, IpRange, OsDistribution, UserSshKey, VmHostDisk, VmIpAssignment, VmTemplate, + DiskInterface, IpRange, OsDistribution, UserSshKey, VmHost, VmHostDisk, VmIpAssignment, + VmTemplate, }; #[test] @@ -1111,6 +1103,21 @@ mod tests { deleted: false, ref_code: None, }, + host: VmHost { + id: 1, + kind: Default::default(), + region_id: 1, + name: "mock".to_string(), + ip: "https://localhost:8006".to_string(), + cpu: 20, + memory: 128 * GB, + enabled: true, + api_token: "mock".to_string(), + load_cpu: 1.0, + load_memory: 1.0, + load_disk: 1.0, + vlan_id: Some(100), + }, disk: VmHostDisk { id: 1, host_id: 1, @@ -1191,7 +1198,6 @@ mod tests { os_type: "l26".to_string(), bridge: "vmbr1".to_string(), cpu: "kvm64".to_string(), - vlan: Some(100), kvm: true, }; @@ -1209,6 +1215,7 @@ mod tests { assert_eq!(vm.cores, Some(template.cpu as i32)); assert_eq!(vm.memory, Some((template.memory / MB).to_string())); assert_eq!(vm.on_boot, Some(true)); + assert!(vm.net.unwrap().contains("tag=100")); assert_eq!( vm.ip_config, Some( diff --git a/src/json_api.rs b/src/json_api.rs index 856088d..3dcadaf 100644 --- a/src/json_api.rs +++ b/src/json_api.rs @@ -1,21 +1,49 @@ -use anyhow::bail; +use anyhow::{bail, Result}; use log::debug; -use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION}; -use reqwest::{Client, Method, Url}; +use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; +use reqwest::{Client, Method, RequestBuilder, Url}; use serde::de::DeserializeOwned; use serde::Serialize; +use std::sync::Arc; + +pub trait TokenGen: Send + Sync { + fn generate_token( + &self, + method: Method, + url: &Url, + body: Option<&str>, + req: RequestBuilder, + ) -> Result; +} #[derive(Clone)] pub struct JsonApi { - pub client: Client, - pub base: Url, + client: Client, + base: Url, + /// Custom token generator per request + token_gen: Option>, } impl JsonApi { - pub fn token(base: &str, token: &str, allow_invalid_certs: bool) -> anyhow::Result { + pub fn new(base: &str) -> Result { let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, "lnvps/1.0".parse()?); + headers.insert(ACCEPT, "application/json; charset=utf-8".parse()?); + + let client = Client::builder().default_headers(headers).build()?; + + Ok(Self { + client, + base: base.parse()?, + token_gen: None, + }) + } + + pub fn token(base: &str, token: &str, allow_invalid_certs: bool) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, "lnvps/1.0".parse()?); headers.insert(AUTHORIZATION, token.parse()?); - headers.insert(ACCEPT, "application/json".parse()?); + headers.insert(ACCEPT, "application/json; charset=utf-8".parse()?); let client = Client::builder() .danger_accept_invalid_certs(allow_invalid_certs) @@ -24,28 +52,61 @@ impl JsonApi { Ok(Self { client, base: base.parse()?, + token_gen: None, }) } - pub async fn get(&self, path: &str) -> anyhow::Result { + pub fn token_gen( + base: &str, + allow_invalid_certs: bool, + tg: impl TokenGen + 'static, + ) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, "lnvps/1.0".parse()?); + headers.insert(ACCEPT, "application/json; charset=utf-8".parse()?); + + let client = Client::builder() + .danger_accept_invalid_certs(allow_invalid_certs) + .default_headers(headers) + .build()?; + Ok(Self { + client, + base: base.parse()?, + token_gen: Some(Arc::new(tg)), + }) + } + + pub fn base(&self) -> &Url { + &self.base + } + + pub async fn get(&self, path: &str) -> Result { + let text = self.get_raw(path).await?; + Ok(serde_json::from_str::(&text)?) + } + + /// Get raw string response + pub async fn get_raw(&self, path: &str) -> Result { debug!(">> GET {}", path); - let rsp = self.client.get(self.base.join(path)?).send().await?; + let url = self.base.join(path)?; + let mut req = self.client.request(Method::GET, url.clone()); + if let Some(gen) = &self.token_gen { + req = gen.generate_token(Method::GET, &url, None, req)?; + } + let req = req.build()?; + debug!(">> HEADERS {:?}", req.headers()); + let rsp = self.client.execute(req).await?; let status = rsp.status(); let text = rsp.text().await?; - #[cfg(debug_assertions)] debug!("<< {}", text); if status.is_success() { - Ok(serde_json::from_str(&text)?) + Ok(text) } else { bail!("{}", status); } } - pub async fn post( - &self, - path: &str, - body: R, - ) -> anyhow::Result { + pub async fn post(&self, path: &str, body: R) -> Result { self.req(Method::POST, path, body).await } @@ -54,16 +115,20 @@ impl JsonApi { method: Method, path: &str, body: R, - ) -> anyhow::Result { + ) -> Result { let body = serde_json::to_string(&body)?; debug!(">> {} {}: {}", method.clone(), path, &body); - let rsp = self + let url = self.base.join(path)?; + let mut req = self .client - .request(method.clone(), self.base.join(path)?) - .header("Content-Type", "application/json") - .body(body) - .send() - .await?; + .request(method.clone(), url.clone()) + .header(CONTENT_TYPE, "application/json; charset=utf-8"); + if let Some(gen) = self.token_gen.as_ref() { + req = gen.generate_token(method.clone(), &url, Some(&body), req)?; + } + let req = req.body(body).build()?; + debug!(">> HEADERS {:?}", req.headers()); + let rsp = self.client.execute(req).await?; let status = rsp.status(); let text = rsp.text().await?; #[cfg(debug_assertions)] @@ -71,7 +136,7 @@ impl JsonApi { if status.is_success() { Ok(serde_json::from_str(&text)?) } else { - bail!("{} {}: {}: {}", method, path, status, &text); + bail!("{} {}: {}: {}", method, url, status, &text); } } @@ -81,16 +146,18 @@ impl JsonApi { method: Method, path: &str, body: R, - ) -> anyhow::Result { + ) -> Result { let body = serde_json::to_string(&body)?; debug!(">> {} {}: {}", method.clone(), path, &body); - let rsp = self + let url = self.base.join(path)?; + let mut req = self .client - .request(method.clone(), self.base.join(path)?) - .header("Content-Type", "application/json") - .body(body) - .send() - .await?; + .request(method.clone(), url.clone()) + .header(CONTENT_TYPE, "application/json; charset=utf-8"); + if let Some(gen) = &self.token_gen { + req = gen.generate_token(method.clone(), &url, Some(&body), req)?; + } + let rsp = req.body(body).send().await?; let status = rsp.status(); let text = rsp.text().await?; #[cfg(debug_assertions)] @@ -98,7 +165,7 @@ impl JsonApi { if status.is_success() { Ok(status.as_u16()) } else { - bail!("{} {}: {}: {}", method, path, status, &text); + bail!("{} {}: {}: {}", method, url, status, &text); } } } diff --git a/src/mocks.rs b/src/mocks.rs index 787cf22..fa89b3c 100644 --- a/src/mocks.rs +++ b/src/mocks.rs @@ -137,6 +137,7 @@ impl Default for MockDb { load_cpu: 1.5, load_memory: 2.0, load_disk: 3.0, + vlan_id: Some(100) }, ); let mut host_disks = HashMap::new(); @@ -639,6 +640,10 @@ impl MockRouter { } #[async_trait] impl Router for MockRouter { + async fn generate_mac(&self, ip: &str, comment: &str) -> anyhow::Result> { + Ok(None) + } + async fn list_arp_entry(&self) -> anyhow::Result> { let arp = self.arp.lock().await; Ok(arp.values().cloned().collect()) diff --git a/src/provisioner/lnvps.rs b/src/provisioner/lnvps.rs index 4c14a8b..0aa65f4 100644 --- a/src/provisioner/lnvps.rs +++ b/src/provisioner/lnvps.rs @@ -6,14 +6,14 @@ use crate::lightning::{AddInvoiceRequest, LightningNode}; use crate::provisioner::{ CostResult, HostCapacityService, NetworkProvisioner, PricingEngine, ProvisionerMethod, }; -use crate::router::{ArpEntry, MikrotikRouter, Router}; +use crate::router::{ArpEntry, MikrotikRouter, OvhDedicatedServerVMacRouter, Router}; use crate::settings::{ProvisionerConfig, Settings}; use anyhow::{bail, ensure, Context, Result}; use chrono::Utc; use isocountry::CountryCode; use lnvps_db::{ AccessPolicy, LNVpsDb, NetworkAccessPolicy, PaymentMethod, RouterKind, Vm, VmCustomTemplate, - VmIpAssignment, VmPayment, + VmHost, VmIpAssignment, VmPayment, }; use log::{info, warn}; use nostr::util::hex; @@ -72,6 +72,9 @@ impl LNVpsProvisioner { ); Ok(Arc::new(MikrotikRouter::new(&cfg.url, username, password))) } + RouterKind::OvhAdditionalIp => Ok(Arc::new( + OvhDedicatedServerVMacRouter::new(&cfg.url, &cfg.name, &cfg.token).await?, + )), } } @@ -91,17 +94,7 @@ impl LNVpsProvisioner { ) .await?; let vm = self.db.get_vm(assignment.vm_id).await?; - let entry = ArpEntry::new( - &vm, - assignment, - Some( - policy - .interface - .as_ref() - .context("Cannot apply static arp entry without an interface name")? - .clone(), - ), - )?; + let entry = ArpEntry::new(&vm, assignment, policy.interface.clone())?; let arp = if let Some(_id) = &assignment.arp_ref { router.update_arp_entry(&entry).await? } else { @@ -247,8 +240,43 @@ impl LNVpsProvisioner { Ok(()) } + async fn get_mac_for_assignment( + &self, + host: &VmHost, + vm: &Vm, + assignment: &VmIpAssignment, + ) -> Result { + let range = self.db.get_ip_range(assignment.ip_range_id).await?; + + // ask router first if it wants to set the MAC + if let Some(ap) = range.access_policy_id { + let ap = self.db.get_access_policy(ap).await?; + if let Some(rid) = ap.router_id { + let router = self.get_router(rid).await?; + + if let Some(mac) = router + .generate_mac(&assignment.ip, &format!("VM{}", assignment.vm_id)) + .await? + { + return Ok(mac); + } + } + } + + // ask the host next to generate the mac + let client = get_host_client(host, &self.provisioner_config)?; + let mac = client.generate_mac(vm).await?; + Ok(ArpEntry { + id: None, + address: assignment.ip.clone(), + mac_address: mac, + interface: None, + comment: None, + }) + } + async fn allocate_ips(&self, vm_id: u64) -> Result> { - let vm = self.db.get_vm(vm_id).await?; + let mut vm = self.db.get_vm(vm_id).await?; let existing_ips = self.db.list_vm_ip_assignments(vm_id).await?; if !existing_ips.is_empty() { return Ok(existing_ips); @@ -260,19 +288,20 @@ impl LNVpsProvisioner { 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 { - id: 0, vm_id, ip_range_id: ip.range_id, ip: ip.ip.to_string(), - deleted: false, - arp_ref: None, - dns_forward: None, - dns_forward_ref: None, - dns_reverse: None, - dns_reverse_ref: None, + ..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?; + self.save_ip_assignment(&mut assignment).await?; + Ok(vec![assignment]) } @@ -329,7 +358,6 @@ impl LNVpsProvisioner { bail!("No host disk found") }; - let client = get_host_client(&host.host, &self.provisioner_config)?; let mut new_vm = Vm { id: 0, host_id: host.host.id, @@ -341,14 +369,11 @@ impl LNVpsProvisioner { created: Utc::now(), expires: Utc::now(), disk_id: pick_disk.disk.id, - mac_address: "NOT FILLED YET".to_string(), + mac_address: "ff:ff:ff:ff:ff:ff".to_string(), deleted: false, ref_code, }; - // ask host client to generate the mac address - new_vm.mac_address = client.generate_mac(&new_vm).await?; - let new_id = self.db.insert_vm(&new_vm).await?; new_vm.id = new_id; Ok(new_vm) @@ -387,7 +412,6 @@ impl LNVpsProvisioner { // insert custom templates let template_id = self.db.insert_custom_vm_template(&template).await?; - let client = get_host_client(&host.host, &self.provisioner_config)?; let mut new_vm = Vm { id: 0, host_id: host.host.id, @@ -399,14 +423,11 @@ impl LNVpsProvisioner { created: Utc::now(), expires: Utc::now(), disk_id: pick_disk.disk.id, - mac_address: "NOT FILLED YET".to_string(), + mac_address: "ff:ff:ff:ff:ff:ff".to_string(), deleted: false, ref_code, }; - // ask host client to generate the mac address - new_vm.mac_address = client.generate_mac(&new_vm).await?; - let new_id = self.db.insert_vm(&new_vm).await?; new_vm.id = new_id; Ok(new_vm) diff --git a/src/router/mikrotik.rs b/src/router/mikrotik.rs index fc7fe12..3295a67 100644 --- a/src/router/mikrotik.rs +++ b/src/router/mikrotik.rs @@ -26,6 +26,11 @@ impl MikrotikRouter { #[async_trait] impl Router for MikrotikRouter { + async fn generate_mac(&self, _ip: &str, _comment: &str) -> Result> { + // Mikrotik router doesn't care what MAC address you use + Ok(None) + } + async fn list_arp_entry(&self) -> Result> { let rsp: Vec = self.api.req(Method::GET, "/rest/ip/arp", ()).await?; Ok(rsp.into_iter().map(|e| e.into()).collect()) @@ -64,7 +69,7 @@ impl Router for MikrotikRouter { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MikrotikArpEntry { +struct MikrotikArpEntry { #[serde(rename = ".id")] #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, diff --git a/src/router/mod.rs b/src/router/mod.rs index c69b526..18b3836 100644 --- a/src/router/mod.rs +++ b/src/router/mod.rs @@ -11,6 +11,8 @@ use rocket::async_trait; /// It also prevents people from re-assigning their IP to another in the range, #[async_trait] pub trait Router: Send + Sync { + /// Generate mac address for a given IP address + async fn generate_mac(&self, ip: &str, comment: &str) -> Result>; async fn list_arp_entry(&self) -> Result>; async fn add_arp_entry(&self, entry: &ArpEntry) -> Result; async fn remove_arp_entry(&self, id: &str) -> Result<()>; @@ -40,5 +42,9 @@ impl ArpEntry { #[cfg(feature = "mikrotik")] mod mikrotik; +mod ovh; + #[cfg(feature = "mikrotik")] -pub use mikrotik::*; +pub use mikrotik::MikrotikRouter; +pub use ovh::OvhDedicatedServerVMacRouter; + diff --git a/src/router/ovh.rs b/src/router/ovh.rs new file mode 100644 index 0000000..8955c4b --- /dev/null +++ b/src/router/ovh.rs @@ -0,0 +1,355 @@ +use crate::json_api::{JsonApi, TokenGen}; +use crate::router::{ArpEntry, Router}; +use anyhow::{anyhow, bail, Context, Result}; +use chrono::{DateTime, Utc}; +use lnvps_db::async_trait; +use log::{info, warn}; +use nostr::hashes::{sha1, Hash}; +use nostr::Url; +use reqwest::header::{HeaderName, HeaderValue, ACCEPT}; +use reqwest::{Method, RequestBuilder}; +use rocket::form::validate::Contains; +use rocket::serde::Deserialize; +use serde::Serialize; +use std::ops::Sub; +use std::str::FromStr; +use std::sync::atomic::AtomicI64; +use std::sync::Arc; + +/// This router is not really a router, but it allows +/// managing the virtual mac's for additional IPs on OVH dedicated servers +pub struct OvhDedicatedServerVMacRouter { + name: String, + api: JsonApi, +} + +#[derive(Clone)] +struct OvhTokenGen { + time_delta: i64, + application_key: String, + application_secret: String, + consumer_key: String, +} + +impl OvhTokenGen { + pub fn new(time_delta: i64, token: &str) -> Result { + let mut t_split = token.split(":"); + Ok(Self { + time_delta, + application_key: t_split + .next() + .context("Missing application_key")? + .to_string(), + application_secret: t_split + .next() + .context("Missing application_secret")? + .to_string(), + consumer_key: t_split.next().context("Missing consumer_key")?.to_string(), + }) + } + + /// Compute signature for OVH. + fn build_sig( + method: &str, + query: &str, + body: &str, + timestamp: &str, + aas: &str, + ck: &str, + ) -> String { + let sep = "+"; + let prefix = "$1$".to_string(); + + let capacity = 1 + + aas.len() + + sep.len() + + ck.len() + + method.len() + + sep.len() + + query.len() + + sep.len() + + body.len() + + sep.len() + + timestamp.len(); + let mut signature = String::with_capacity(capacity); + signature.push_str(aas); + signature.push_str(sep); + signature.push_str(ck); + signature.push_str(sep); + signature.push_str(method); + signature.push_str(sep); + signature.push_str(query); + signature.push_str(sep); + signature.push_str(body); + signature.push_str(sep); + signature.push_str(timestamp); + + // debug!("Signature: {}", &signature); + let sha1: sha1::Hash = Hash::hash(signature.as_bytes()); + let sig = hex::encode(sha1); + prefix + &sig + } +} + +impl TokenGen for OvhTokenGen { + fn generate_token( + &self, + method: Method, + url: &Url, + body: Option<&str>, + req: RequestBuilder, + ) -> Result { + let now = Utc::now().timestamp().sub(self.time_delta); + let now_string = now.to_string(); + let sig = Self::build_sig( + method.as_str(), + url.as_str(), + body.unwrap_or(""), + now_string.as_str(), + &self.application_secret, + &self.consumer_key, + ); + Ok(req + .header("X-Ovh-Application", &self.application_key) + .header("X-Ovh-Consumer", &self.consumer_key) + .header("X-Ovh-Timestamp", now_string) + .header("X-Ovh-Signature", sig)) + } +} + +impl OvhDedicatedServerVMacRouter { + pub async fn new(url: &str, name: &str, token: &str) -> Result { + // load API time delta + let time_api = JsonApi::new(url)?; + let time = time_api.get_raw("v1/auth/time").await?; + let delta: i64 = Utc::now().timestamp().sub(time.parse::()?); + + Ok(Self { + name: name.to_string(), + api: JsonApi::token_gen(url, false, OvhTokenGen::new(delta, token)?)?, + }) + } + + async fn get_task(&self, task_id: i64) -> Result { + self.api + .get(&format!( + "v1/dedicated/server/{}/task/{}", + self.name, task_id + )) + .await + } + + /// Poll a task until it completes + async fn wait_for_task_result(&self, task_id: i64) -> Result { + loop { + let status = self.get_task(task_id).await?; + match status.status { + OvhTaskStatus::Cancelled => { + return Err(anyhow!( + "Task was cancelled: {}", + status.comment.unwrap_or_default() + )) + } + OvhTaskStatus::CustomerError => { + return Err(anyhow!( + "Task failed: {}", + status.comment.unwrap_or_default() + )) + } + OvhTaskStatus::Done => return Ok(status), + OvhTaskStatus::OvhError => { + return Err(anyhow!( + "Task failed: {}", + status.comment.unwrap_or_default() + )) + } + _ => {} + } + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + } +} + +#[async_trait] +impl Router for OvhDedicatedServerVMacRouter { + async fn generate_mac(&self, ip: &str, comment: &str) -> Result> { + info!("[OVH] Generating mac: {}={}", ip, comment); + let rsp: OvhTaskResponse = self + .api + .post( + &format!("v1/dedicated/server/{}/virtualMac", &self.name), + OvhVMacRequest { + ip_address: ip.to_string(), + kind: OvhVMacType::Ovh, + name: comment.to_string(), + }, + ) + .await?; + + self.wait_for_task_result(rsp.task_id).await?; + + // api is shit, lookup ip address in list of arp entries + let e = self.list_arp_entry().await?; + Ok(e.into_iter().find(|e| e.address == ip)) + } + + async fn list_arp_entry(&self) -> Result> { + let rsp: Vec = self + .api + .get(&format!("v1/dedicated/server/{}/virtualMac", &self.name)) + .await?; + + let mut ret = vec![]; + for mac in rsp { + let rsp2: Vec = self + .api + .get(&format!( + "v1/dedicated/server/{}/virtualMac/{}/virtualAddress", + &self.name, mac + )) + .await?; + + for addr in rsp2 { + ret.push(ArpEntry { + id: Some(format!("{}={}", mac, &addr)), + address: addr, + mac_address: mac.clone(), + interface: None, + comment: None, + }) + } + } + + Ok(ret) + } + + async fn add_arp_entry(&self, entry: &ArpEntry) -> Result { + info!( + "[OVH] Adding mac ip: {} {}", + entry.mac_address, entry.address + ); + #[derive(Serialize)] + struct AddVMacAddressRequest { + #[serde(rename = "ipAddress")] + pub ip_address: String, + #[serde(rename = "virtualMachineName")] + pub comment: String, + } + let id = format!("{}={}", &entry.mac_address, &entry.address); + let task: OvhTaskResponse = self + .api + .post( + &format!( + "v1/dedicated/server/{}/virtualMac/{}/virtualAddress", + &self.name, &entry.mac_address + ), + AddVMacAddressRequest { + ip_address: entry.address.clone(), + comment: entry.comment.clone().unwrap_or(String::new()), + }, + ) + .await?; + self.wait_for_task_result(task.task_id).await?; + + Ok(ArpEntry { + id: Some(id), + address: entry.address.clone(), + mac_address: entry.mac_address.clone(), + interface: None, + comment: None, + }) + } + + async fn remove_arp_entry(&self, id: &str) -> Result<()> { + let entries = self.list_arp_entry().await?; + if let Some(this_entry) = entries.into_iter().find(|e| e.id == Some(id.to_string())) { + info!( + "[OVH] Deleting mac ip: {} {}", + this_entry.mac_address, this_entry.address + ); + let task: OvhTaskResponse = self + .api + .req( + Method::DELETE, + &format!( + "v1/dedicated/server/{}/virtualMac/{}/virtualAddress/{}", + self.name, this_entry.mac_address, this_entry.address + ), + (), + ) + .await?; + self.wait_for_task_result(task.task_id).await?; + Ok(()) + } else { + bail!("Cannot remove arp entry, not found") + } + } + + async fn update_arp_entry(&self, entry: &ArpEntry) -> Result { + // cant patch just return the entry + warn!("[OVH] Updating virtual mac is not supported"); + Ok(entry.clone()) + } +} + +#[derive(Debug, Serialize)] +struct OvhVMacRequest { + #[serde(rename = "ipAddress")] + pub ip_address: String, + #[serde(rename = "type")] + pub kind: OvhVMacType, + #[serde(rename = "virtualMachineName")] + pub name: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +enum OvhVMacType { + Ovh, + VMWare, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OvhTaskResponse { + pub comment: Option, + pub done_date: Option>, + pub function: OvhTaskFunction, + pub last_update: Option>, + pub need_schedule: bool, + pub note: Option, + pub planned_intervention_id: Option, + pub start_date: DateTime, + pub status: OvhTaskStatus, + pub tags: Option>, + pub task_id: i64, + pub ticket_reference: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct KVSimple { + pub key: Option, + pub value: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum OvhTaskStatus { + Cancelled, + CustomerError, + Doing, + Done, + Init, + OvhError, + Todo, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum OvhTaskFunction { + AddVirtualMac, + MoveVirtualMac, + VirtualMacAdd, + VirtualMacDelete, + RemoveVirtualMac +} diff --git a/src/settings.rs b/src/settings.rs index 9bc98f6..496843d 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -136,8 +136,6 @@ pub struct QemuConfig { pub bridge: String, /// CPU type pub cpu: String, - /// VLAN tag all spawned VM's - pub vlan: Option, /// Enable virtualization inside VM pub kvm: bool, } @@ -211,7 +209,6 @@ pub fn mock_settings() -> Settings { os_type: "l26".to_string(), bridge: "vmbr1".to_string(), cpu: "kvm64".to_string(), - vlan: None, kvm: false, }, ssh: None,