diff --git a/Cargo.toml b/Cargo.toml index 62c1644..d62d69f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,13 +7,14 @@ edition = "2021" name = "api" [features] -default = ["mikrotik", "nostr-dm", "proxmox", "lnd", "bitvora"] +default = ["mikrotik", "nostr-dm", "proxmox", "lnd", "bitvora", "cloudflare"] mikrotik = ["dep:reqwest"] nostr-dm = ["dep:nostr-sdk"] proxmox = ["dep:reqwest", "dep:ssh2", "dep:tokio-tungstenite"] libvirt = ["dep:virt"] lnd = ["dep:fedimint-tonic-lnd"] bitvora = ["dep:reqwest", "dep:tokio-stream"] +cloudflare = ["dep:reqwest"] [dependencies] lnvps_db = { path = "lnvps_db" } @@ -25,7 +26,7 @@ pretty_env_logger = "0.5.0" serde = { version = "1.0.213", features = ["derive"] } serde_json = "1.0.132" rocket = { version = "0.5.1", features = ["json"] } -rocket_okapi = { version = "0.9.0", features = ["swagger", "rapidoc"] } +rocket_okapi = { version = "0.9.0", features = ["swagger"] } schemars = { version = "0.8.22", features = ["chrono"] } chrono = { version = "0.4.38", features = ["serde"] } base64 = { version = "0.22.1", features = ["alloc"] } diff --git a/README.md b/README.md index 957c577..5504c98 100644 --- a/README.md +++ b/README.md @@ -112,4 +112,19 @@ network-policy: static-arp: # Interface where the static ARP entry is added interface: "bridge1" +``` + +### DNS (PTR/A/AAAA) + +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" + # API token to add/remove DNS records to this zone + token: "my-api-token" ``` \ No newline at end of file diff --git a/lnvps_db/migrations/20250303152800_ip_refs.sql b/lnvps_db/migrations/20250303152800_ip_refs.sql new file mode 100644 index 0000000..d3e8fa0 --- /dev/null +++ b/lnvps_db/migrations/20250303152800_ip_refs.sql @@ -0,0 +1,7 @@ +-- Add migration script here +alter table vm_ip_assignment + add column arp_ref varchar(50), + add column dns_reverse varchar(255), + add column dns_reverse_ref varchar(50), + add column dns_forward varchar(255), + add column dns_forward_ref varchar(50); \ No newline at end of file diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 322de7a..2a26f09 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -104,6 +104,9 @@ pub trait LNVpsDb: Sync + Send { /// List VM ip assignments async fn insert_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> Result; + /// Update VM ip assignments + async fn update_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> Result<()>; + /// List VM ip assignments async fn list_vm_ip_assignments(&self, vm_id: u64) -> Result>; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 3c89e06..a05d020 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -226,11 +226,26 @@ pub struct Vm { #[derive(FromRow, Clone, Debug, Default)] pub struct VmIpAssignment { + /// Unique id of this assignment pub id: u64, + /// VM id this IP is assigned to pub vm_id: u64, + /// IP range id pub ip_range_id: u64, + /// The IP address (v4/v6) pub ip: String, + /// If this record was freed pub deleted: bool, + /// External ID pointing to a static arp entry on the router + pub arp_ref: Option, + /// Forward DNS FQDN + pub dns_forward: Option, + /// External ID pointing to the forward DNS entry for this IP + pub dns_forward_ref: Option, + /// Reverse DNS FQDN + pub dns_reverse: Option, + /// External ID pointing to the reverse DNS entry for this IP + pub dns_reverse_ref: Option, } impl Display for VmIpAssignment { diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 98f5f2a..c7da376 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -290,17 +290,38 @@ impl LNVpsDb for LNVpsDbMysql { async fn insert_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> Result { Ok(sqlx::query( - "insert into vm_ip_assignment(vm_id,ip_range_id,ip) values(?, ?, ?) returning id", + "insert into vm_ip_assignment(vm_id,ip_range_id,ip,arp_ref,dns_forward,dns_forward_ref,dns_reverse,dns_reverse_ref) values(?,?,?,?,?,?,?,?) returning id", ) .bind(ip_assignment.vm_id) .bind(ip_assignment.ip_range_id) .bind(&ip_assignment.ip) + .bind(&ip_assignment.arp_ref) + .bind(&ip_assignment.dns_forward) + .bind(&ip_assignment.dns_forward_ref) + .bind(&ip_assignment.dns_reverse) + .bind(&ip_assignment.dns_reverse_ref) .fetch_one(&self.db) .await .map_err(Error::new)? .try_get(0)?) } + async fn update_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> Result<()> { + sqlx::query( + "update vm_ip_assignment set arp_ref = ?, dns_forward = ?, dns_forward_ref = ?, dns_reverse = ?, dns_reverse_ref = ? where id = ?", + ) + .bind(&ip_assignment.arp_ref) + .bind(&ip_assignment.dns_forward) + .bind(&ip_assignment.dns_forward_ref) + .bind(&ip_assignment.dns_reverse) + .bind(&ip_assignment.dns_reverse_ref) + .bind(&ip_assignment.id) + .execute(&self.db) + .await + .map_err(Error::new)?; + Ok(()) + } + async fn list_vm_ip_assignments(&self, vm_id: u64) -> Result> { sqlx::query_as("select * from vm_ip_assignment where vm_id = ? and deleted = 0") .bind(vm_id) diff --git a/src/api/model.rs b/src/api/model.rs index b0b47c3..033171c 100644 --- a/src/api/model.rs +++ b/src/api/model.rs @@ -51,6 +51,8 @@ pub struct ApiVmIpAssignment { pub id: u64, pub ip: String, pub gateway: String, + pub forward_dns: Option, + pub reverse_dns: Option, } impl ApiVmIpAssignment { @@ -64,6 +66,8 @@ impl ApiVmIpAssignment { .unwrap() .to_string(), gateway: range.gateway.to_string(), + forward_dns: ip.dns_forward.clone(), + reverse_dns: ip.dns_reverse.clone(), } } } diff --git a/src/bin/api.rs b/src/bin/api.rs index d73a820..ac3e97a 100644 --- a/src/bin/api.rs +++ b/src/bin/api.rs @@ -74,16 +74,6 @@ async fn main() -> Result<(), Error> { nostr_client.clone(), ); let sender = worker.sender(); - - // send a startup notification - if let Some(admin) = settings.smtp.as_ref().and_then(|s| s.admin) { - sender.send(WorkJob::SendNotification { - title: Some("Startup".to_string()), - message: "System is starting!".to_string(), - user_id: admin, - })?; - } - tokio::spawn(async move { loop { if let Err(e) = worker.handle().await { diff --git a/src/dns/cloudflare.rs b/src/dns/cloudflare.rs new file mode 100644 index 0000000..2eebc09 --- /dev/null +++ b/src/dns/cloudflare.rs @@ -0,0 +1,92 @@ +use crate::dns::{BasicRecord, DnsServer}; +use crate::json_api::JsonApi; +use lnvps_db::async_trait; +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; + +pub struct Cloudflare { + api: JsonApi, + reverse_zone_id: String, + forward_zone_id: String, +} + +impl Cloudflare { + pub fn new(token: &str, reverse_zone_id: &str, forward_zone_id: &str) -> Cloudflare { + Self { + api: JsonApi::token("https://api.cloudflare.com", &format!("Bearer {}", token)) + .unwrap(), + reverse_zone_id: reverse_zone_id.to_owned(), + forward_zone_id: forward_zone_id.to_owned(), + } + } +} + +#[async_trait] +impl DnsServer for Cloudflare { + async fn add_ptr_record(&self, key: &str, value: &str) -> anyhow::Result { + let id_response: CfResult = self + .api + .post( + &format!("/client/v4/zones/{}/dns_records", self.reverse_zone_id), + CfRecord { + content: value.to_string(), + name: key.to_string(), + r_type: "PTR".to_string(), + id: None, + }, + ) + .await?; + Ok(BasicRecord { + name: id_response.result.name, + value: value.to_string(), + id: id_response.result.id.unwrap(), + }) + } + + async fn delete_ptr_record(&self, key: &str) -> anyhow::Result<()> { + todo!() + } + + async fn add_a_record(&self, name: &str, ip: IpAddr) -> anyhow::Result { + let id_response: CfResult = self + .api + .post( + &format!("/client/v4/zones/{}/dns_records", self.forward_zone_id), + CfRecord { + content: ip.to_string(), + name: name.to_string(), + r_type: if ip.is_ipv4() { + "A".to_string() + } else { + "AAAA".to_string() + }, + id: None, + }, + ) + .await?; + Ok(BasicRecord { + name: id_response.result.name, + value: ip.to_string(), + id: id_response.result.id.unwrap(), + }) + } + + async fn delete_a_record(&self, name: &str) -> anyhow::Result<()> { + todo!() + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct CfRecord { + pub content: String, + pub name: String, + #[serde(rename = "type")] + pub r_type: String, + pub id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CfResult { + pub success: bool, + pub result: T, +} diff --git a/src/dns/mod.rs b/src/dns/mod.rs new file mode 100644 index 0000000..59b8252 --- /dev/null +++ b/src/dns/mod.rs @@ -0,0 +1,30 @@ +use anyhow::Result; +use lnvps_db::async_trait; +use std::net::IpAddr; + +#[cfg(feature = "cloudflare")] +mod cloudflare; +#[cfg(feature = "cloudflare")] +pub use cloudflare::*; + +#[async_trait] +pub trait DnsServer: Send + Sync { + /// Add PTR record to the reverse zone + async fn add_ptr_record(&self, key: &str, value: &str) -> Result; + + /// Delete PTR record from the reverse zone + async fn delete_ptr_record(&self, key: &str) -> Result<()>; + + /// Add A/AAAA record onto the forward zone + async fn add_a_record(&self, name: &str, ip: IpAddr) -> Result; + + /// Delete A/AAAA record from the forward zone + async fn delete_a_record(&self, name: &str) -> Result<()>; +} + +#[derive(Debug, Clone)] +pub struct BasicRecord { + pub name: String, + pub value: String, + pub id: String, +} \ No newline at end of file diff --git a/src/host/mod.rs b/src/host/mod.rs index 593db10..2a021a5 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -43,7 +43,7 @@ pub trait VmHostClient: Send + Sync { pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result> { #[cfg(test)] { - Ok(Arc::new(crate::mocks::MockVmHost::default())) + Ok(Arc::new(crate::mocks::MockVmHost::new())) } #[cfg(not(test))] { @@ -56,16 +56,14 @@ pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result Arc::new( - proxmox::ProxmoxClient::new( - host.ip.parse()?, - &host.name, - mac_prefix.clone(), - qemu.clone(), - ssh.clone(), - ) - .with_api_token(&host.api_token), - ), + ) => Arc::new(proxmox::ProxmoxClient::new( + host.ip.parse()?, + &host.name, + &host.api_token, + mac_prefix.clone(), + qemu.clone(), + ssh.clone(), + )), _ => bail!("Unknown host config: {}", host.kind), }) } diff --git a/src/host/proxmox.rs b/src/host/proxmox.rs index c052f99..956bd6f 100644 --- a/src/host/proxmox.rs +++ b/src/host/proxmox.rs @@ -1,4 +1,5 @@ use crate::host::{CreateVmRequest, VmHostClient}; +use crate::json_api::JsonApi; use crate::settings::{QemuConfig, SshConfig}; use crate::ssh_client::SshClient; use crate::status::{VmRunningState, VmState}; @@ -9,6 +10,7 @@ use ipnetwork::IpNetwork; use lnvps_db::{async_trait, DiskType, IpRange, LNVpsDb, Vm, VmIpAssignment, VmOsImage}; use log::{debug, info}; use rand::random; +use reqwest::header::{HeaderMap, AUTHORIZATION}; use reqwest::{ClientBuilder, Method, Url}; use serde::de::value::I32Deserializer; use serde::de::DeserializeOwned; @@ -25,9 +27,7 @@ use tokio_tungstenite::tungstenite::handshake::client::{generate_key, Request}; use tokio_tungstenite::{Connector, MaybeTlsStream, WebSocketStream}; pub struct ProxmoxClient { - base: Url, - token: String, - client: reqwest::Client, + api: JsonApi, config: QemuConfig, ssh: Option, mac_prefix: String, @@ -38,19 +38,24 @@ impl ProxmoxClient { pub fn new( base: Url, node: &str, + token: &str, mac_prefix: Option, 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 { - base, - token: String::new(), - client, + api: JsonApi { base, client }, config, ssh, node: node.to_string(), @@ -58,25 +63,21 @@ impl ProxmoxClient { } } - pub fn with_api_token(mut self, token: &str) -> Self { - self.token = token.to_string(); - self - } - /// Get version info pub async fn version(&self) -> Result { - let rsp: ResponseBase = self.get("/api2/json/version").await?; + let rsp: ResponseBase = self.api.get("/api2/json/version").await?; Ok(rsp.data) } /// List nodes pub async fn list_nodes(&self) -> Result> { - let rsp: ResponseBase> = self.get("/api2/json/nodes").await?; + let rsp: ResponseBase> = self.api.get("/api2/json/nodes").await?; Ok(rsp.data) } pub async fn get_vm_status(&self, node: &str, vm_id: ProxmoxVmId) -> Result { let rsp: ResponseBase = self + .api .get(&format!( "/api2/json/nodes/{node}/qemu/{vm_id}/status/current" )) @@ -85,13 +86,16 @@ impl ProxmoxClient { } pub async fn list_vms(&self, node: &str) -> Result> { - let rsp: ResponseBase> = - self.get(&format!("/api2/json/nodes/{node}/qemu")).await?; + let rsp: ResponseBase> = self + .api + .get(&format!("/api2/json/nodes/{node}/qemu")) + .await?; Ok(rsp.data) } pub async fn list_storage(&self, node: &str) -> Result> { let rsp: ResponseBase> = self + .api .get(&format!("/api2/json/nodes/{node}/storage")) .await?; Ok(rsp.data) @@ -104,6 +108,7 @@ impl ProxmoxClient { storage: &str, ) -> Result> { let rsp: ResponseBase> = self + .api .get(&format!( "/api2/json/nodes/{node}/storage/{storage}/content" )) @@ -116,6 +121,7 @@ impl ProxmoxClient { /// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/qemu pub async fn create_vm(&self, req: CreateVm) -> Result { let rsp: ResponseBase> = self + .api .post(&format!("/api2/json/nodes/{}/qemu", req.node), &req) .await?; if let Some(id) = rsp.data { @@ -130,6 +136,7 @@ impl ProxmoxClient { /// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/qemu/{vmid}/config pub async fn configure_vm(&self, req: ConfigureVm) -> Result { let rsp: ResponseBase> = self + .api .post( &format!("/api2/json/nodes/{}/qemu/{}/config", req.node, req.vm_id), &req, @@ -147,6 +154,7 @@ impl ProxmoxClient { /// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/qemu pub async fn delete_vm(&self, node: &str, vm: ProxmoxVmId) -> Result { let rsp: ResponseBase> = self + .api .req( Method::DELETE, &format!("/api2/json/nodes/{node}/qemu/{vm}"), @@ -168,6 +176,7 @@ impl ProxmoxClient { /// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/tasks/{upid}/status pub async fn get_task_status(&self, task: &TaskId) -> Result { let rsp: ResponseBase = self + .api .get(&format!( "/api2/json/nodes/{}/tasks/{}/status", task.node, task.id @@ -209,6 +218,7 @@ impl ProxmoxClient { /// Download an image to the host disk pub async fn download_image(&self, req: DownloadUrlRequest) -> Result { let rsp: ResponseBase = self + .api .post( &format!( "/api2/json/nodes/{}/storage/{}/download-url", @@ -229,7 +239,7 @@ impl ProxmoxClient { if let Some(ssh_config) = &self.ssh { let mut ses = SshClient::new()?; ses.connect( - (self.base.host().unwrap().to_string(), 22), + (self.api.base.host().unwrap().to_string(), 22), &ssh_config.user, &ssh_config.key, ) @@ -274,6 +284,7 @@ impl ProxmoxClient { /// Resize a disk on a VM pub async fn resize_disk(&self, req: ResizeDiskRequest) -> Result { let rsp: ResponseBase = self + .api .req( Method::PUT, &format!("/api2/json/nodes/{}/qemu/{}/resize", &req.node, &req.vm_id), @@ -289,6 +300,7 @@ impl ProxmoxClient { /// Start a VM pub async fn start_vm(&self, node: &str, vm: ProxmoxVmId) -> Result { let rsp: ResponseBase = self + .api .post( &format!("/api2/json/nodes/{}/qemu/{}/status/start", node, vm), (), @@ -303,6 +315,7 @@ impl ProxmoxClient { /// Stop a VM pub async fn stop_vm(&self, node: &str, vm: ProxmoxVmId) -> Result { let rsp: ResponseBase = self + .api .post( &format!("/api2/json/nodes/{}/qemu/{}/status/stop", node, vm), (), @@ -317,6 +330,7 @@ impl ProxmoxClient { /// Stop a VM pub async fn shutdown_vm(&self, node: &str, vm: ProxmoxVmId) -> Result { let rsp: ResponseBase = self + .api .post( &format!("/api2/json/nodes/{}/qemu/{}/status/shutdown", node, vm), (), @@ -331,6 +345,7 @@ impl ProxmoxClient { /// Stop a VM pub async fn reset_vm(&self, node: &str, vm: ProxmoxVmId) -> Result { let rsp: ResponseBase = self + .api .post( &format!("/api2/json/nodes/{}/qemu/{}/status/reset", node, vm), (), @@ -341,117 +356,6 @@ impl ProxmoxClient { node: node.to_string(), }) } - - /// Create terminal proxy session - pub async fn terminal_proxy(&self, node: &str, vm: ProxmoxVmId) -> Result { - let rsp: ResponseBase = self - .post( - &format!("/api2/json/nodes/{}/qemu/{}/termproxy", node, vm), - (), - ) - .await?; - Ok(rsp.data) - } - - /// Open websocket connection to terminal proxy - pub async fn open_terminal_proxy( - &self, - node: &str, - vm: ProxmoxVmId, - req: TerminalProxyTicket, - ) -> Result>> { - self.get_task_status(&TaskId { - id: req.upid, - node: node.to_string(), - }) - .await?; - - let mut url: Url = self.base.join(&format!( - "/api2/json/nodes/{}/qemu/{}/vncwebsocket", - node, vm - ))?; - url.set_scheme("wss").unwrap(); - url.query_pairs_mut().append_pair("port", &req.port); - url.query_pairs_mut().append_pair("vncticket", &req.ticket); - - let r = Request::builder() - .method("GET") - .header("Host", url.host().unwrap().to_string()) - .header("Connection", "Upgrade") - .header("Upgrade", "websocket") - .header("Sec-WebSocket-Version", "13") - .header("Sec-WebSocket-Key", generate_key()) - .header("Sec-WebSocket-Protocol", "binary") - .header("Authorization", format!("PVEAPIToken={}", self.token)) - .uri(url.as_str()) - .body(())?; - - debug!("Connecting terminal proxy: {:?}", &r); - let (ws, _rsp) = tokio_tungstenite::connect_async_tls_with_config( - r, - None, - false, - Some(Connector::NativeTls( - native_tls::TlsConnector::builder() - .danger_accept_invalid_certs(true) - .build()?, - )), - ) - .await?; - - Ok(ws) - } - - async fn get(&self, path: &str) -> Result { - debug!(">> GET {}", path); - let rsp = self - .client - .get(self.base.join(path)?) - .header("Authorization", format!("PVEAPIToken={}", self.token)) - .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!("{}", status); - } - } - - async fn post(&self, path: &str, body: R) -> Result { - self.req(Method::POST, path, body).await - } - - 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.base.join(path)?) - .header("Authorization", format!("PVEAPIToken={}", self.token)) - .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, &text); - } - } } impl ProxmoxClient { diff --git a/src/json_api.rs b/src/json_api.rs new file mode 100644 index 0000000..de4a1f9 --- /dev/null +++ b/src/json_api.rs @@ -0,0 +1,73 @@ +use anyhow::bail; +use log::debug; +use reqwest::header::{HeaderMap, AUTHORIZATION}; +use reqwest::{Client, Method, Url}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +pub struct JsonApi { + pub client: Client, + pub base: Url, +} + +impl JsonApi { + pub fn token(base: &str, token: &str) -> anyhow::Result { + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, token.parse()?); + + let client = Client::builder().default_headers(headers).build()?; + Ok(Self { + client, + base: base.parse()?, + }) + } + + pub async fn get(&self, path: &str) -> anyhow::Result { + debug!(">> GET {}", path); + let rsp = self.client.get(self.base.join(path)?).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!("{}", status); + } + } + + pub async fn post( + &self, + path: &str, + body: R, + ) -> anyhow::Result { + self.req(Method::POST, path, body).await + } + + pub async fn req( + &self, + method: Method, + path: &str, + body: R, + ) -> anyhow::Result { + let body = serde_json::to_string(&body)?; + debug!(">> {} {}: {}", method.clone(), path, &body); + let rsp = self + .client + .request(method.clone(), self.base.join(path)?) + .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, &text); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 8f7ab81..0a07ea0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,10 @@ pub mod api; pub mod cors; +pub mod dns; pub mod exchange; pub mod host; pub mod invoice; +pub mod json_api; pub mod lightning; pub mod nip98; pub mod provisioner; diff --git a/src/lightning/bitvora.rs b/src/lightning/bitvora.rs index 943cce7..a988c31 100644 --- a/src/lightning/bitvora.rs +++ b/src/lightning/bitvora.rs @@ -1,91 +1,26 @@ use crate::api::WEBHOOK_BRIDGE; +use crate::json_api::JsonApi; use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode}; use anyhow::bail; use futures::{Stream, StreamExt}; use lnvps_db::async_trait; -use log::debug; -use reqwest::header::HeaderMap; -use reqwest::{Method, Url}; -use rocket::http::ext::IntoCollection; -use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::pin::Pin; use tokio_stream::wrappers::BroadcastStream; pub struct BitvoraNode { - base: Url, - client: reqwest::Client, + api: JsonApi, webhook_secret: String, } impl BitvoraNode { pub fn new(api_token: &str, webhook_secret: &str) -> Self { - let mut headers = HeaderMap::new(); - headers.insert( - "Authorization", - format!("Bearer {}", api_token).parse().unwrap(), - ); - - let client = reqwest::Client::builder() - .default_headers(headers) - .build() - .unwrap(); - + let auth = format!("Bearer {}", api_token); Self { - base: Url::parse("https://api.bitvora.com/").unwrap(), - client, + api: JsonApi::token("https://api.bitvora.com/", &auth).unwrap(), webhook_secret: webhook_secret.to_string(), } } - - async fn get(&self, path: &str) -> anyhow::Result { - debug!(">> GET {}", path); - let rsp = self.client.get(self.base.join(path)?).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!("{}", status); - } - } - - async fn post( - &self, - path: &str, - body: R, - ) -> anyhow::Result { - self.req(Method::POST, path, body).await - } - - async fn req( - &self, - method: Method, - path: &str, - body: R, - ) -> anyhow::Result { - let body = serde_json::to_string(&body)?; - debug!(">> {} {}: {}", method.clone(), path, &body); - let rsp = self - .client - .request(method.clone(), self.base.join(path)?) - .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, &text); - } - } } #[async_trait] @@ -98,7 +33,8 @@ impl LightningNode for BitvoraNode { expiry_seconds: req.expire.unwrap_or(3600) as u64, }; let rsp: BitvoraResponse = self - .req(Method::POST, "/v1/bitcoin/deposit/lightning-invoice", req) + .api + .post("/v1/bitcoin/deposit/lightning-invoice", req) .await?; if rsp.status >= 400 { bail!( diff --git a/src/mocks.rs b/src/mocks.rs index a17b85d..97920ba 100644 --- a/src/mocks.rs +++ b/src/mocks.rs @@ -1,3 +1,4 @@ +use crate::dns::{BasicRecord, DnsServer}; use crate::host::{CreateVmRequest, VmHostClient}; use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode}; use crate::router::{ArpEntry, Router}; @@ -406,11 +407,28 @@ impl LNVpsDb for MockDb { ip_range_id: ip_assignment.ip_range_id, ip: ip_assignment.ip.clone(), deleted: false, + arp_ref: ip_assignment.arp_ref.clone(), + dns_forward: ip_assignment.dns_forward.clone(), + dns_forward_ref: ip_assignment.dns_forward_ref.clone(), + dns_reverse: ip_assignment.dns_reverse.clone(), + dns_reverse_ref: ip_assignment.dns_reverse_ref.clone(), }, ); Ok(max + 1) } + 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) { + i.arp_ref = ip_assignment.arp_ref.clone(); + i.dns_forward = ip_assignment.dns_forward.clone(); + i.dns_reverse = ip_assignment.dns_reverse.clone(); + i.dns_reverse_ref = ip_assignment.dns_reverse_ref.clone(); + i.dns_forward_ref = ip_assignment.dns_forward_ref.clone(); + } + Ok(()) + } + async fn list_vm_ip_assignments(&self, vm_id: u64) -> anyhow::Result> { let ip_assignments = self.ip_assignments.lock().await; Ok(ip_assignments @@ -475,12 +493,12 @@ pub struct MockRouter { impl MockRouter { pub fn new(policy: NetworkPolicy) -> Self { - static ARP: LazyLock>>> = + static LAZY_ARP: LazyLock>>> = LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); Self { policy, - arp: ARP.clone(), + arp: LAZY_ARP.clone(), } } } @@ -535,6 +553,16 @@ struct MockInvoice { settle_index: u64, } +impl MockNode { + pub fn new() -> Self { + static LAZY_INVOICES: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); + Self { + invoices: LAZY_INVOICES.clone(), + } + } +} + #[async_trait] impl LightningNode for MockNode { async fn add_invoice(&self, req: AddInvoiceRequest) -> anyhow::Result { @@ -549,7 +577,7 @@ impl LightningNode for MockNode { } } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct MockVmHost { vms: Arc>>, } @@ -559,6 +587,16 @@ struct MockVm { pub state: VmRunningState, } +impl MockVmHost { + pub fn new() -> Self { + static LAZY_VMS: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); + Self { + vms: LAZY_VMS.clone(), + } + } +} + #[async_trait] impl VmHostClient for MockVmHost { async fn download_os_image(&self, image: &VmOsImage) -> anyhow::Result<()> { @@ -633,3 +671,86 @@ impl VmHostClient for MockVmHost { Ok(()) } } + +pub struct MockDnsServer { + pub forward: Arc>>, + pub reverse: Arc>>, +} + +pub struct MockDnsEntry { + pub name: String, + pub value: String, + pub kind: String, +} + +impl MockDnsServer { + pub fn new() -> Self { + static LAZY_FWD: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); + static LAZY_REV: LazyLock>>> = + LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); + Self { + forward: LAZY_FWD.clone(), + reverse: LAZY_REV.clone(), + } + } +} +#[async_trait] +impl DnsServer for MockDnsServer { + async fn add_ptr_record(&self, key: &str, value: &str) -> anyhow::Result { + let mut rev = self.reverse.lock().await; + + if rev.values().any(|v| v.name == key) { + bail!("Duplicate record with name {}", key); + } + + let rnd_id: [u8; 12] = rand::random(); + let id = hex::encode(rnd_id); + rev.insert( + id.clone(), + MockDnsEntry { + name: key.to_string(), + value: value.to_string(), + kind: "PTR".to_string(), + }, + ); + Ok(BasicRecord { + name: format!("{}.X.Y.Z.in-addr.arpa", key), + value: value.to_string(), + id, + }) + } + + async fn delete_ptr_record(&self, key: &str) -> anyhow::Result<()> { + todo!() + } + + async fn add_a_record(&self, name: &str, ip: IpAddr) -> anyhow::Result { + let mut rev = self.forward.lock().await; + + if rev.values().any(|v| v.name == name) { + bail!("Duplicate record with name {}", name); + } + + let fqdn = format!("{}.lnvps.mock", name); + let rnd_id: [u8; 12] = rand::random(); + let id = hex::encode(rnd_id); + rev.insert( + id.clone(), + MockDnsEntry { + name: fqdn.clone(), + value: ip.to_string(), + kind: "A".to_string(), + }, + ); + Ok(BasicRecord { + name: fqdn, + value: ip.to_string(), + id, + }) + } + + async fn delete_a_record(&self, name: &str) -> anyhow::Result<()> { + todo!() + } +} diff --git a/src/provisioner/lnvps.rs b/src/provisioner/lnvps.rs index c645026..96683f1 100644 --- a/src/provisioner/lnvps.rs +++ b/src/provisioner/lnvps.rs @@ -1,3 +1,4 @@ +use crate::dns::DnsServer; use crate::exchange::{ExchangeRateService, Ticker}; use crate::host::{get_host_client, CreateVmRequest, VmHostClient}; use crate::lightning::{AddInvoiceRequest, LightningNode}; @@ -13,6 +14,7 @@ use nostr::util::hex; use rand::random; use rocket::futures::{SinkExt, StreamExt}; use std::collections::{HashMap, HashSet}; +use std::fmt::format; use std::net::IpAddr; use std::ops::Add; use std::str::FromStr; @@ -31,6 +33,8 @@ pub struct LNVpsProvisioner { rates: Arc, router: Option>, + dns: Option>, + network_policy: NetworkPolicy, provisioner_config: ProvisionerConfig, } @@ -47,6 +51,7 @@ impl LNVpsProvisioner { node, rates, router: settings.get_router().expect("router config"), + dns: settings.get_dns().expect("dns config"), network_policy: settings.network_policy, provisioner_config: settings.provisioner, read_only: settings.read_only, @@ -73,12 +78,14 @@ impl LNVpsProvisioner { Ok(()) } - async fn save_ip_assignment(&self, vm: &Vm, assignment: &VmIpAssignment) -> Result<()> { + async fn save_ip_assignment(&self, vm: &Vm, assignment: &mut VmIpAssignment) -> Result<()> { + let ip = IpAddr::from_str(&assignment.ip)?; + // apply network policy if let NetworkAccessPolicy::StaticArp { interface } = &self.network_policy.access { if let Some(r) = self.router.as_ref() { r.add_arp_entry( - IpAddr::from_str(&assignment.ip)?, + ip.clone(), &vm.mac_address, interface, Some(&format!("VM{}", vm.id)), @@ -89,8 +96,28 @@ impl LNVpsProvisioner { } } + // Add DNS records + if let Some(dns) = &self.dns { + let sub_name = format!("vm-{}", vm.id); + let fwd = dns.add_a_record(&sub_name, ip.clone()).await?; + assignment.dns_forward = Some(fwd.name.clone()); + assignment.dns_forward_ref = Some(fwd.id); + + match ip { + IpAddr::V4(ip) => { + let last_octet = ip.octets()[3].to_string(); + let rev = dns.add_ptr_record(&last_octet, &fwd.name).await?; + assignment.dns_reverse = Some(fwd.name.clone()); + assignment.dns_reverse_ref = Some(rev.id); + } + IpAddr::V6(_) => { + warn!("IPv6 forward DNS not supported yet") + } + } + } + // save to db - self.db.insert_vm_ip_assignment(assignment).await?; + self.db.insert_vm_ip_assignment(&assignment).await?; Ok(()) } @@ -106,15 +133,20 @@ impl LNVpsProvisioner { let template = self.db.get_vm_template(vm.template_id).await?; let ip = network.pick_ip_for_region(template.region_id).await?; - let assignment = VmIpAssignment { + 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, }; - self.save_ip_assignment(&vm, &assignment).await?; + self.save_ip_assignment(&vm, &mut assignment).await?; Ok(vec![assignment]) } @@ -326,9 +358,7 @@ mod tests { use super::*; use crate::exchange::DefaultRateCache; use crate::mocks::{MockDb, MockNode}; - use crate::settings::{ - ApiConfig, Credentials, LndConfig, ProvisionerConfig, QemuConfig, RouterConfig, - }; + use crate::settings::{DnsServerConfig, LightningConfig, QemuConfig, RouterConfig}; use lnvps_db::UserSshKey; #[tokio::test] @@ -338,7 +368,7 @@ mod tests { let settings = Settings { listen: None, db: "".to_string(), - lnd: LndConfig { + lightning: LightningConfig::LND { url: "".to_string(), cert: Default::default(), macaroon: Default::default(), @@ -364,21 +394,23 @@ mod tests { }, delete_after: 0, smtp: None, - router: Some(RouterConfig::Mikrotik(ApiConfig { - id: "mock-router".to_string(), + router: Some(RouterConfig::Mikrotik { url: "https://localhost".to_string(), - credentials: Credentials::UsernamePassword { - username: "admin".to_string(), - password: "password123".to_string(), - }, - })), - dns: None, + username: "admin".to_string(), + password: "password123".to_string(), + }), + dns: Some(DnsServerConfig::Cloudflare { + token: "abc".to_string(), + forward_zone_id: "123".to_string(), + reverse_zone_id: "456".to_string(), + }), nostr: None, }; let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); let rates = Arc::new(DefaultRateCache::default()); let router = settings.get_router().expect("router").unwrap(); + let dns = settings.get_dns().expect("dns").unwrap(); let provisioner = LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone()); let pubkey: [u8; 32] = random(); @@ -408,9 +440,15 @@ mod tests { 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.is_some()); + assert_eq!(ip.dns_reverse, ip.dns_forward); // assert IP address is not CIDR assert!(IpAddr::from_str(&ip.ip).is_ok()); diff --git a/src/provisioner/network.rs b/src/provisioner/network.rs index a8df24b..d05c5fb 100644 --- a/src/provisioner/network.rs +++ b/src/provisioner/network.rs @@ -116,7 +116,7 @@ mod tests { vm_id: 0, ip_range_id: ip.range_id, ip: ip.ip.to_string(), - deleted: false, + ..Default::default() }) .await .expect("Could not insert vm ip"); diff --git a/src/router/mikrotik.rs b/src/router/mikrotik.rs index 7548891..56f5d67 100644 --- a/src/router/mikrotik.rs +++ b/src/router/mikrotik.rs @@ -1,65 +1,24 @@ +use crate::json_api::JsonApi; use crate::router::{ArpEntry, Router}; -use anyhow::{bail, Result}; +use anyhow::Result; use base64::engine::general_purpose::STANDARD; use base64::Engine; -use log::debug; -use reqwest::{Client, Method, Url}; +use reqwest::Method; use rocket::async_trait; -use serde::de::DeserializeOwned; -use serde::Serialize; use std::net::IpAddr; pub struct MikrotikRouter { - url: Url, - username: String, - password: String, - client: Client, + api: JsonApi, } impl MikrotikRouter { pub fn new(url: &str, username: &str, password: &str) -> Self { + let auth = format!( + "Basic {}", + STANDARD.encode(format!("{}:{}", username, password)) + ); Self { - url: url.parse().unwrap(), - username: username.to_string(), - password: password.to_string(), - client: Client::builder() - .danger_accept_invalid_certs(true) - .build() - .unwrap(), - } - } - - 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); + api: JsonApi::token(url, &auth).unwrap(), } } } @@ -67,7 +26,7 @@ impl MikrotikRouter { #[async_trait] impl Router for MikrotikRouter { async fn list_arp_entry(&self) -> Result> { - let rsp: Vec = self.req(Method::GET, "/rest/ip/arp", ()).await?; + let rsp: Vec = self.api.req(Method::GET, "/rest/ip/arp", ()).await?; Ok(rsp) } @@ -79,6 +38,7 @@ impl Router for MikrotikRouter { comment: Option<&str>, ) -> Result<()> { let _rsp: ArpEntry = self + .api .req( Method::PUT, "/rest/ip/arp", @@ -97,6 +57,7 @@ impl Router for MikrotikRouter { async fn remove_arp_entry(&self, id: &str) -> Result<()> { let _rsp: ArpEntry = self + .api .req(Method::DELETE, &format!("/rest/ip/arp/{id}"), ()) .await?; diff --git a/src/settings.rs b/src/settings.rs index 9c7bd56..fec31f2 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,8 +1,9 @@ +use crate::dns::DnsServer; use crate::exchange::ExchangeRateService; use crate::lightning::LightningNode; use crate::provisioner::LNVpsProvisioner; -use crate::router::{MikrotikRouter, Router}; -use anyhow::{bail, Result}; +use crate::router::Router; +use anyhow::Result; use lnvps_db::LNVpsDb; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -56,7 +57,7 @@ pub enum LightningConfig { Bitvora { token: String, webhook_secret: String, - } + }, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -68,32 +69,22 @@ pub struct NostrConfig { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum RouterConfig { - Mikrotik(ApiConfig), + Mikrotik { + url: String, + username: String, + password: String, + }, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum DnsServerConfig { #[serde(rename_all = "kebab-case")] - Cloudflare { api: ApiConfig, zone_id: String }, -} - -/// Generic remote API credentials -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ApiConfig { - /// unique ID of this router, used in references - pub id: String, - /// http:// - pub url: String, - /// Login credentials used for this router - pub credentials: Credentials, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum Credentials { - UsernamePassword { username: String, password: String }, - ApiToken { token: String }, + Cloudflare { + token: String, + forward_zone_id: String, + reverse_zone_id: String, + }, } /// Policy that determines how packets arrive at the VM @@ -187,26 +178,52 @@ impl Settings { Arc::new(LNVpsProvisioner::new(self.clone(), db, node, exchange)) } - #[cfg(not(test))] pub fn get_router(&self) -> Result>> { - match &self.router { - Some(RouterConfig::Mikrotik(api)) => match &api.credentials { - Credentials::UsernamePassword { username, password } => Ok(Some(Arc::new( - MikrotikRouter::new(&api.url, username, password), - ))), - _ => bail!("Only username/password is supported for Mikrotik routers"), - }, - _ => Ok(None), + #[cfg(test)] + { + if let Some(router) = &self.router { + let router = crate::mocks::MockRouter::new(self.network_policy.clone()); + Ok(Some(Arc::new(router))) + } else { + Ok(None) + } + } + #[cfg(not(test))] + { + match &self.router { + #[cfg(feature = "mikrotik")] + Some(RouterConfig::Mikrotik { + url, + username, + password, + }) => Ok(Some(Arc::new(crate::router::MikrotikRouter::new( + url, username, password, + )))), + _ => Ok(None), + } } } - #[cfg(test)] - pub fn get_router(&self) -> Result>> { - if self.router.is_some() { - let router = crate::mocks::MockRouter::new(self.network_policy.clone()); - Ok(Some(Arc::new(router))) - } else { - Ok(None) + pub fn get_dns(&self) -> Result>> { + #[cfg(test)] + { + Ok(Some(Arc::new(crate::mocks::MockDnsServer::new()))) + } + #[cfg(not(test))] + { + match &self.dns { + None => Ok(None), + #[cfg(feature = "cloudflare")] + Some(DnsServerConfig::Cloudflare { + token, + forward_zone_id, + reverse_zone_id, + }) => Ok(Some(Arc::new(crate::dns::Cloudflare::new( + token, + reverse_zone_id, + forward_zone_id, + )))), + } } } }