diff --git a/Cargo.toml b/Cargo.toml index ce9d908..0b567f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,9 @@ edition = "2021" [[bin]] name = "api" +[features] +default = ["mikrotik"] +mikrotik = [] [dependencies] lnvps_db = { path = "lnvps_db" } @@ -20,7 +23,7 @@ serde_json = "1.0.132" rocket = { version = "0.5.1", features = ["json"] } chrono = { version = "0.4.38", features = ["serde"] } nostr = { version = "0.37.0", default-features = false, features = ["std"] } -base64 = "0.22.1" +base64 = { version = "0.22.1", features = ["alloc"] } urlencoding = "2.1.3" fedimint-tonic-lnd = { version = "0.2.0", default-features = false, features = ["invoicesrpc"] } ipnetwork = "0.20.0" diff --git a/src/bin/api.rs b/src/bin/api.rs index b5815ab..274cdd6 100644 --- a/src/bin/api.rs +++ b/src/bin/api.rs @@ -50,11 +50,12 @@ async fn main() -> Result<(), Error> { db.execute(setup_script).await?; } + let router = settings.router.as_ref().map(|r| r.get_router()); let status = VmStateCache::new(); let worker_provisioner = settings .provisioner - .get_provisioner(db.clone(), lnd.clone(), exchange.clone()); + .get_provisioner(db.clone(), router, lnd.clone(), exchange.clone()); worker_provisioner.init().await?; let mut worker = Worker::new(db.clone(), worker_provisioner, &settings, status.clone()); @@ -110,10 +111,11 @@ async fn main() -> Result<(), Error> { } }); + let router = settings.router.as_ref().map(|r| r.get_router()); let provisioner = settings .provisioner - .get_provisioner(db.clone(), lnd.clone(), exchange.clone()); + .get_provisioner(db.clone(), router, lnd.clone(), exchange.clone()); let db: Box = Box::new(db.clone()); let pv: Box = Box::new(provisioner); diff --git a/src/host/proxmox.rs b/src/host/proxmox.rs index ad9cc24..4af07eb 100644 --- a/src/host/proxmox.rs +++ b/src/host/proxmox.rs @@ -282,10 +282,10 @@ impl ProxmoxClient { body: R, ) -> Result { let body = serde_json::to_string(&body)?; - debug!("{}", &body); + debug!(">> {} {}: {}", method.clone(), path, &body); let rsp = self .client - .request(method, self.base.join(path)?) + .request(method.clone(), self.base.join(path)?) .header("Authorization", format!("PVEAPIToken={}", self.token)) .header("Content-Type", "application/json") .header("Accept", "application/json") @@ -299,7 +299,7 @@ impl ProxmoxClient { if status.is_success() { Ok(serde_json::from_str(&text)?) } else { - bail!("{}", status); + bail!("{} {}: {}", method, path, status); } } } @@ -579,5 +579,5 @@ pub struct VmConfig { pub kvm: Option, #[serde(rename = "serial0")] #[serde(skip_serializing_if = "Option::is_none")] - pub serial_0: Option + pub serial_0: Option, } diff --git a/src/nip98.rs b/src/nip98.rs index 2e57bf5..94d04e7 100644 --- a/src/nip98.rs +++ b/src/nip98.rs @@ -1,6 +1,6 @@ use base64::prelude::BASE64_STANDARD; use base64::Engine; -use log::{debug, info}; +use log::debug; use nostr::{Event, JsonUtil, Kind, Timestamp}; use rocket::http::uri::{Absolute, Uri}; use rocket::http::Status; diff --git a/src/provisioner/lnvps.rs b/src/provisioner/lnvps.rs index e1aecdd..5753027 100644 --- a/src/provisioner/lnvps.rs +++ b/src/provisioner/lnvps.rs @@ -5,6 +5,7 @@ use crate::host::proxmox::{ VmConfig, }; use crate::provisioner::Provisioner; +use crate::router::Router; use crate::settings::{QemuConfig, SshConfig}; use crate::ssh_client::SshClient; use anyhow::{bail, Result}; @@ -15,7 +16,7 @@ use fedimint_tonic_lnd::Client; use ipnetwork::IpNetwork; use lnvps_db::hydrate::Hydrate; use lnvps_db::{IpRange, LNVpsDb, Vm, VmCostPlanIntervalType, VmIpAssignment, VmPayment}; -use log::info; +use log::{info, warn}; use nostr::util::hex; use rand::random; use rand::seq::IteratorRandom; @@ -27,6 +28,7 @@ use std::time::Duration; pub struct LNVpsProvisioner { db: Box, + router: Option>, lnd: Client, rates: ExchangeRateCache, read_only: bool, @@ -40,11 +42,13 @@ impl LNVpsProvisioner { config: QemuConfig, ssh: Option, db: impl LNVpsDb + 'static, + router: Option, lnd: Client, rates: ExchangeRateCache, ) -> Self { Self { db: Box::new(db), + router: router.map(|r| Box::new(r) as Box), lnd, rates, config, @@ -265,6 +269,12 @@ impl Provisioner for LNVpsProvisioner { ip: ip_net.to_string(), ..Default::default() }; + + // add arp entry for router + if let Some(r) = self.router.as_ref() { + r.add_arp_entry(ip, &vm.mac_address, Some(&format!("VM{}", vm.id))) + .await?; + } let id = self.db.insert_vm_ip_assignment(&assignment).await?; assignment.id = id; @@ -274,6 +284,10 @@ impl Provisioner for LNVpsProvisioner { } } + if ret.is_empty() { + bail!("No ip ranges found in this region"); + } + Ok(ret) } @@ -451,6 +465,21 @@ impl Provisioner for LNVpsProvisioner { let j_stop = client.stop_vm(&host.name, vm.id + 100).await?; client.wait_for_task(&j_stop).await?; + if let Some(r) = self.router.as_ref() { + let ent = r.list_arp_entry().await?; + if let Some(ent) = ent.iter().find(|e| { + e.mac_address + .as_ref() + .map(|m| m.eq_ignore_ascii_case(&vm.mac_address)) + .unwrap_or(false) + }) { + r.remove_arp_entry(ent.id.as_ref().unwrap().as_str()) + .await?; + } else { + warn!("ARP entry not found, skipping") + } + } + self.db.delete_vm_ip_assignment(vm.id).await?; self.db.delete_vm(vm.id).await?; diff --git a/src/router/mikrotik.rs b/src/router/mikrotik.rs index b49509a..7104d40 100644 --- a/src/router/mikrotik.rs +++ b/src/router/mikrotik.rs @@ -1,29 +1,101 @@ -use crate::router::Router; +use crate::router::{ArpEntry, Router}; +use anyhow::{bail, Result}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use log::debug; +use reqwest::{Client, Method, Url}; use rocket::async_trait; +use serde::de::DeserializeOwned; +use serde::Serialize; use std::net::IpAddr; pub struct MikrotikRouter { - url: String, - token: String, + url: Url, + username: String, + password: String, + client: Client, + arp_interface: String, } impl MikrotikRouter { - pub fn new(url: &str, token: &str) -> Self { + pub fn new(url: &str, username: &str, password: &str, arp_interface: &str) -> Self { Self { - url: url.to_string(), - token: token.to_string(), + url: url.parse().unwrap(), + username: username.to_string(), + password: password.to_string(), + client: Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap(), + arp_interface: arp_interface.to_string(), + } + } + + async fn req( + &self, + method: Method, + path: &str, + body: R, + ) -> Result { + let body = serde_json::to_string(&body)?; + debug!(">> {} {}: {}", method.clone(), path, &body); + let rsp = self + .client + .request(method.clone(), self.url.join(path)?) + .header( + "Authorization", + format!( + "Basic {}", + STANDARD.encode(format!("{}:{}", self.username, self.password)) + ), + ) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .body(body) + .send() + .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)?) + } else { + bail!("{} {}: {}", method, path, status); } } } #[async_trait] impl Router for MikrotikRouter { - async fn add_arp_entry( - &self, - ip: IpAddr, - mac: &[u8; 6], - comment: Option<&str>, - ) -> anyhow::Result<()> { - todo!() + async fn list_arp_entry(&self) -> Result> { + let rsp: Vec = self.req(Method::GET, "/rest/ip/arp", ()).await?; + Ok(rsp) + } + + async fn add_arp_entry(&self, ip: IpAddr, mac: &str, comment: Option<&str>) -> Result<()> { + let _rsp: ArpEntry = self + .req( + Method::PUT, + "/rest/ip/arp", + ArpEntry { + address: ip.to_string(), + mac_address: Some(mac.to_string()), + interface: self.arp_interface.to_string(), + comment: comment.map(|c| c.to_string()), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } + + async fn remove_arp_entry(&self, id: &str) -> Result<()> { + let _rsp: ArpEntry = self + .req(Method::DELETE, &format!("/rest/ip/arp/{id}"), ()) + .await?; + + Ok(()) } } diff --git a/src/router/mod.rs b/src/router/mod.rs index 15a8468..8583e2d 100644 --- a/src/router/mod.rs +++ b/src/router/mod.rs @@ -1,5 +1,6 @@ use anyhow::Result; use rocket::async_trait; +use rocket::serde::{Deserialize, Serialize}; use std::net::IpAddr; /// Router defines a network device used to access the hosts @@ -10,9 +11,27 @@ use std::net::IpAddr; /// /// It also prevents people from re-assigning their IP to another in the range, #[async_trait] -pub trait Router { - async fn add_arp_entry(&self, ip: IpAddr, mac: &[u8; 6], comment: Option<&str>) -> Result<()>; +pub trait Router: Send + Sync { + async fn list_arp_entry(&self) -> Result>; + async fn add_arp_entry(&self, ip: IpAddr, mac: &str, comment: Option<&str>) -> Result<()>; + async fn remove_arp_entry(&self, id: &str) -> Result<()>; } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ArpEntry { + #[serde(rename = ".id")] + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub address: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "mac-address")] + pub mac_address: Option, + pub interface: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, +} + +#[cfg(feature = "mikrotik")] mod mikrotik; +#[cfg(feature = "mikrotik")] pub use mikrotik::*; diff --git a/src/settings.rs b/src/settings.rs index 88cd1ed..9799afb 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,6 +1,7 @@ use crate::exchange::ExchangeRateCache; use crate::provisioner::lnvps::LNVpsProvisioner; use crate::provisioner::Provisioner; +use crate::router::{MikrotikRouter, Router}; use fedimint_tonic_lnd::Client; use lnvps_db::LNVpsDb; use serde::{Deserialize, Serialize}; @@ -20,6 +21,9 @@ pub struct Settings { /// SMTP settings for sending emails pub smtp: Option, + + /// Network router config + pub router: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -29,6 +33,18 @@ pub struct LndConfig { pub macaroon: PathBuf, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum RouterConfig { + Mikrotik { + url: String, + username: String, + password: String, + + /// Interface used to add arp entries + arp_interface: String, + }, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SmtpConfig { /// Admin user id, for sending system notifications @@ -92,6 +108,7 @@ impl ProvisionerConfig { pub fn get_provisioner( &self, db: impl LNVpsDb + 'static, + router: Option, lnd: Client, exchange: ExchangeRateCache, ) -> impl Provisioner + 'static { @@ -100,7 +117,28 @@ impl ProvisionerConfig { qemu, ssh, read_only, - } => LNVpsProvisioner::new(*read_only, qemu.clone(), ssh.clone(), db, lnd, exchange), + } => LNVpsProvisioner::new( + *read_only, + qemu.clone(), + ssh.clone(), + db, + router, + lnd, + exchange, + ), + } + } +} + +impl RouterConfig { + pub fn get_router(&self) -> impl Router + 'static { + match self { + RouterConfig::Mikrotik { + url, + username, + password, + arp_interface, + } => MikrotikRouter::new(&url, &username, &password, &arp_interface), } } } diff --git a/src/worker.rs b/src/worker.rs index a6a26fa..947c693 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -4,12 +4,11 @@ use crate::provisioner::Provisioner; use crate::settings::{Settings, SmtpConfig}; use crate::status::{VmRunningState, VmState, VmStateCache}; use anyhow::Result; -use chrono::{DateTime, Days, Utc}; +use chrono::{Days, Utc}; use lettre::message::MessageBuilder; use lettre::transport::smtp::authentication::Credentials; -use lettre::transport::smtp::SmtpTransportBuilder; use lettre::AsyncTransport; -use lettre::{AsyncSmtpTransport, SmtpTransport, Tokio1Executor, Transport}; +use lettre::{AsyncSmtpTransport, Tokio1Executor, Transport}; use lnvps_db::LNVpsDb; use log::{debug, error, info}; use rocket::futures::SinkExt; @@ -118,11 +117,16 @@ impl Worker { { info!("Deleting expired VM {}", db_vm.id); self.provisioner.delete_vm(db_vm.id).await?; + let title = Some(format!("[VM{}] Deleted", db_vm.id)); self.tx.send(WorkJob::SendNotification { user_id: db_vm.user_id, - title: Some(format!("[VM{}] Deleted", db_vm.id)), + title: title.clone(), message: format!("Your VM #{} has been deleted!", db_vm.id), })?; + self.queue_admin_notification( + format!("VM{} is ready for deletion", db_vm.id), + title, + )?; } } @@ -222,7 +226,7 @@ impl Worker { } let msg = b.body(message)?; - let mut sender = AsyncSmtpTransport::::relay(&smtp.server)? + let sender = AsyncSmtpTransport::::relay(&smtp.server)? .credentials(Credentials::new( smtp.username.to_string(), smtp.password.to_string(),