feat: DNS A/PTR with Cloudflare

This commit is contained in:
2025-03-03 19:19:17 +00:00
parent 80ae12b33f
commit 4aa96020a6
20 changed files with 560 additions and 332 deletions

View File

@ -7,13 +7,14 @@ edition = "2021"
name = "api" name = "api"
[features] [features]
default = ["mikrotik", "nostr-dm", "proxmox", "lnd", "bitvora"] default = ["mikrotik", "nostr-dm", "proxmox", "lnd", "bitvora", "cloudflare"]
mikrotik = ["dep:reqwest"] mikrotik = ["dep:reqwest"]
nostr-dm = ["dep:nostr-sdk"] nostr-dm = ["dep:nostr-sdk"]
proxmox = ["dep:reqwest", "dep:ssh2", "dep:tokio-tungstenite"] proxmox = ["dep:reqwest", "dep:ssh2", "dep:tokio-tungstenite"]
libvirt = ["dep:virt"] libvirt = ["dep:virt"]
lnd = ["dep:fedimint-tonic-lnd"] lnd = ["dep:fedimint-tonic-lnd"]
bitvora = ["dep:reqwest", "dep:tokio-stream"] bitvora = ["dep:reqwest", "dep:tokio-stream"]
cloudflare = ["dep:reqwest"]
[dependencies] [dependencies]
lnvps_db = { path = "lnvps_db" } lnvps_db = { path = "lnvps_db" }
@ -25,7 +26,7 @@ pretty_env_logger = "0.5.0"
serde = { version = "1.0.213", features = ["derive"] } serde = { version = "1.0.213", features = ["derive"] }
serde_json = "1.0.132" serde_json = "1.0.132"
rocket = { version = "0.5.1", features = ["json"] } 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"] } schemars = { version = "0.8.22", features = ["chrono"] }
chrono = { version = "0.4.38", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] }
base64 = { version = "0.22.1", features = ["alloc"] } base64 = { version = "0.22.1", features = ["alloc"] }

View File

@ -112,4 +112,19 @@ network-policy:
static-arp: static-arp:
# Interface where the static ARP entry is added # Interface where the static ARP entry is added
interface: "bridge1" 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-<vmid>.lnvps.cloud
forward-zone-id: "my-forward-zone-id"
# API token to add/remove DNS records to this zone
token: "my-api-token"
``` ```

View File

@ -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);

View File

@ -104,6 +104,9 @@ pub trait LNVpsDb: Sync + Send {
/// List VM ip assignments /// List VM ip assignments
async fn insert_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> Result<u64>; async fn insert_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> Result<u64>;
/// Update VM ip assignments
async fn update_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> Result<()>;
/// List VM ip assignments /// List VM ip assignments
async fn list_vm_ip_assignments(&self, vm_id: u64) -> Result<Vec<VmIpAssignment>>; async fn list_vm_ip_assignments(&self, vm_id: u64) -> Result<Vec<VmIpAssignment>>;

View File

@ -226,11 +226,26 @@ pub struct Vm {
#[derive(FromRow, Clone, Debug, Default)] #[derive(FromRow, Clone, Debug, Default)]
pub struct VmIpAssignment { pub struct VmIpAssignment {
/// Unique id of this assignment
pub id: u64, pub id: u64,
/// VM id this IP is assigned to
pub vm_id: u64, pub vm_id: u64,
/// IP range id
pub ip_range_id: u64, pub ip_range_id: u64,
/// The IP address (v4/v6)
pub ip: String, pub ip: String,
/// If this record was freed
pub deleted: bool, pub deleted: bool,
/// External ID pointing to a static arp entry on the router
pub arp_ref: Option<String>,
/// Forward DNS FQDN
pub dns_forward: Option<String>,
/// External ID pointing to the forward DNS entry for this IP
pub dns_forward_ref: Option<String>,
/// Reverse DNS FQDN
pub dns_reverse: Option<String>,
/// External ID pointing to the reverse DNS entry for this IP
pub dns_reverse_ref: Option<String>,
} }
impl Display for VmIpAssignment { impl Display for VmIpAssignment {

View File

@ -290,17 +290,38 @@ impl LNVpsDb for LNVpsDbMysql {
async fn insert_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> Result<u64> { async fn insert_vm_ip_assignment(&self, ip_assignment: &VmIpAssignment) -> Result<u64> {
Ok(sqlx::query( 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.vm_id)
.bind(ip_assignment.ip_range_id) .bind(ip_assignment.ip_range_id)
.bind(&ip_assignment.ip) .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) .fetch_one(&self.db)
.await .await
.map_err(Error::new)? .map_err(Error::new)?
.try_get(0)?) .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<Vec<VmIpAssignment>> { async fn list_vm_ip_assignments(&self, vm_id: u64) -> Result<Vec<VmIpAssignment>> {
sqlx::query_as("select * from vm_ip_assignment where vm_id = ? and deleted = 0") sqlx::query_as("select * from vm_ip_assignment where vm_id = ? and deleted = 0")
.bind(vm_id) .bind(vm_id)

View File

@ -51,6 +51,8 @@ pub struct ApiVmIpAssignment {
pub id: u64, pub id: u64,
pub ip: String, pub ip: String,
pub gateway: String, pub gateway: String,
pub forward_dns: Option<String>,
pub reverse_dns: Option<String>,
} }
impl ApiVmIpAssignment { impl ApiVmIpAssignment {
@ -64,6 +66,8 @@ impl ApiVmIpAssignment {
.unwrap() .unwrap()
.to_string(), .to_string(),
gateway: range.gateway.to_string(), gateway: range.gateway.to_string(),
forward_dns: ip.dns_forward.clone(),
reverse_dns: ip.dns_reverse.clone(),
} }
} }
} }

View File

@ -74,16 +74,6 @@ async fn main() -> Result<(), Error> {
nostr_client.clone(), nostr_client.clone(),
); );
let sender = worker.sender(); 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 { tokio::spawn(async move {
loop { loop {
if let Err(e) = worker.handle().await { if let Err(e) = worker.handle().await {

92
src/dns/cloudflare.rs Normal file
View File

@ -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<BasicRecord> {
let id_response: CfResult<CfRecord> = 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<BasicRecord> {
let id_response: CfResult<CfRecord> = 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<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CfResult<T> {
pub success: bool,
pub result: T,
}

30
src/dns/mod.rs Normal file
View File

@ -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<BasicRecord>;
/// 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<BasicRecord>;
/// 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,
}

View File

@ -43,7 +43,7 @@ pub trait VmHostClient: Send + Sync {
pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result<Arc<dyn VmHostClient>> { pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result<Arc<dyn VmHostClient>> {
#[cfg(test)] #[cfg(test)]
{ {
Ok(Arc::new(crate::mocks::MockVmHost::default())) Ok(Arc::new(crate::mocks::MockVmHost::new()))
} }
#[cfg(not(test))] #[cfg(not(test))]
{ {
@ -56,16 +56,14 @@ pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result<Arc<dyn
ssh, ssh,
mac_prefix, mac_prefix,
}, },
) => Arc::new( ) => Arc::new(proxmox::ProxmoxClient::new(
proxmox::ProxmoxClient::new( host.ip.parse()?,
host.ip.parse()?, &host.name,
&host.name, &host.api_token,
mac_prefix.clone(), mac_prefix.clone(),
qemu.clone(), qemu.clone(),
ssh.clone(), ssh.clone(),
) )),
.with_api_token(&host.api_token),
),
_ => bail!("Unknown host config: {}", host.kind), _ => bail!("Unknown host config: {}", host.kind),
}) })
} }

View File

@ -1,4 +1,5 @@
use crate::host::{CreateVmRequest, VmHostClient}; use crate::host::{CreateVmRequest, VmHostClient};
use crate::json_api::JsonApi;
use crate::settings::{QemuConfig, SshConfig}; use crate::settings::{QemuConfig, SshConfig};
use crate::ssh_client::SshClient; use crate::ssh_client::SshClient;
use crate::status::{VmRunningState, VmState}; use crate::status::{VmRunningState, VmState};
@ -9,6 +10,7 @@ use ipnetwork::IpNetwork;
use lnvps_db::{async_trait, DiskType, IpRange, LNVpsDb, Vm, VmIpAssignment, VmOsImage}; use lnvps_db::{async_trait, DiskType, IpRange, LNVpsDb, Vm, VmIpAssignment, VmOsImage};
use log::{debug, info}; use log::{debug, info};
use rand::random; use rand::random;
use reqwest::header::{HeaderMap, AUTHORIZATION};
use reqwest::{ClientBuilder, Method, Url}; use reqwest::{ClientBuilder, Method, Url};
use serde::de::value::I32Deserializer; use serde::de::value::I32Deserializer;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
@ -25,9 +27,7 @@ use tokio_tungstenite::tungstenite::handshake::client::{generate_key, Request};
use tokio_tungstenite::{Connector, MaybeTlsStream, WebSocketStream}; use tokio_tungstenite::{Connector, MaybeTlsStream, WebSocketStream};
pub struct ProxmoxClient { pub struct ProxmoxClient {
base: Url, api: JsonApi,
token: String,
client: reqwest::Client,
config: QemuConfig, config: QemuConfig,
ssh: Option<SshConfig>, ssh: Option<SshConfig>,
mac_prefix: String, mac_prefix: String,
@ -38,19 +38,24 @@ impl ProxmoxClient {
pub fn new( pub fn new(
base: Url, base: Url,
node: &str, node: &str,
token: &str,
mac_prefix: Option<String>, mac_prefix: Option<String>,
config: QemuConfig, config: QemuConfig,
ssh: Option<SshConfig>, ssh: Option<SshConfig>,
) -> Self { ) -> Self {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
format!("PVEAPIToken={}", token).parse().unwrap(),
);
let client = ClientBuilder::new() let client = ClientBuilder::new()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.default_headers(headers)
.build() .build()
.expect("Failed to build client"); .expect("Failed to build client");
Self { Self {
base, api: JsonApi { base, client },
token: String::new(),
client,
config, config,
ssh, ssh,
node: node.to_string(), 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 /// Get version info
pub async fn version(&self) -> Result<VersionResponse> { pub async fn version(&self) -> Result<VersionResponse> {
let rsp: ResponseBase<VersionResponse> = self.get("/api2/json/version").await?; let rsp: ResponseBase<VersionResponse> = self.api.get("/api2/json/version").await?;
Ok(rsp.data) Ok(rsp.data)
} }
/// List nodes /// List nodes
pub async fn list_nodes(&self) -> Result<Vec<NodeResponse>> { pub async fn list_nodes(&self) -> Result<Vec<NodeResponse>> {
let rsp: ResponseBase<Vec<NodeResponse>> = self.get("/api2/json/nodes").await?; let rsp: ResponseBase<Vec<NodeResponse>> = self.api.get("/api2/json/nodes").await?;
Ok(rsp.data) Ok(rsp.data)
} }
pub async fn get_vm_status(&self, node: &str, vm_id: ProxmoxVmId) -> Result<VmInfo> { pub async fn get_vm_status(&self, node: &str, vm_id: ProxmoxVmId) -> Result<VmInfo> {
let rsp: ResponseBase<VmInfo> = self let rsp: ResponseBase<VmInfo> = self
.api
.get(&format!( .get(&format!(
"/api2/json/nodes/{node}/qemu/{vm_id}/status/current" "/api2/json/nodes/{node}/qemu/{vm_id}/status/current"
)) ))
@ -85,13 +86,16 @@ impl ProxmoxClient {
} }
pub async fn list_vms(&self, node: &str) -> Result<Vec<VmInfo>> { pub async fn list_vms(&self, node: &str) -> Result<Vec<VmInfo>> {
let rsp: ResponseBase<Vec<VmInfo>> = let rsp: ResponseBase<Vec<VmInfo>> = self
self.get(&format!("/api2/json/nodes/{node}/qemu")).await?; .api
.get(&format!("/api2/json/nodes/{node}/qemu"))
.await?;
Ok(rsp.data) Ok(rsp.data)
} }
pub async fn list_storage(&self, node: &str) -> Result<Vec<NodeStorage>> { pub async fn list_storage(&self, node: &str) -> Result<Vec<NodeStorage>> {
let rsp: ResponseBase<Vec<NodeStorage>> = self let rsp: ResponseBase<Vec<NodeStorage>> = self
.api
.get(&format!("/api2/json/nodes/{node}/storage")) .get(&format!("/api2/json/nodes/{node}/storage"))
.await?; .await?;
Ok(rsp.data) Ok(rsp.data)
@ -104,6 +108,7 @@ impl ProxmoxClient {
storage: &str, storage: &str,
) -> Result<Vec<StorageContentEntry>> { ) -> Result<Vec<StorageContentEntry>> {
let rsp: ResponseBase<Vec<StorageContentEntry>> = self let rsp: ResponseBase<Vec<StorageContentEntry>> = self
.api
.get(&format!( .get(&format!(
"/api2/json/nodes/{node}/storage/{storage}/content" "/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 /// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/qemu
pub async fn create_vm(&self, req: CreateVm) -> Result<TaskId> { pub async fn create_vm(&self, req: CreateVm) -> Result<TaskId> {
let rsp: ResponseBase<Option<String>> = self let rsp: ResponseBase<Option<String>> = self
.api
.post(&format!("/api2/json/nodes/{}/qemu", req.node), &req) .post(&format!("/api2/json/nodes/{}/qemu", req.node), &req)
.await?; .await?;
if let Some(id) = rsp.data { 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 /// 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<TaskId> { pub async fn configure_vm(&self, req: ConfigureVm) -> Result<TaskId> {
let rsp: ResponseBase<Option<String>> = self let rsp: ResponseBase<Option<String>> = self
.api
.post( .post(
&format!("/api2/json/nodes/{}/qemu/{}/config", req.node, req.vm_id), &format!("/api2/json/nodes/{}/qemu/{}/config", req.node, req.vm_id),
&req, &req,
@ -147,6 +154,7 @@ impl ProxmoxClient {
/// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/qemu /// 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<TaskId> { pub async fn delete_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> {
let rsp: ResponseBase<Option<String>> = self let rsp: ResponseBase<Option<String>> = self
.api
.req( .req(
Method::DELETE, Method::DELETE,
&format!("/api2/json/nodes/{node}/qemu/{vm}"), &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 /// 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<TaskStatus> { pub async fn get_task_status(&self, task: &TaskId) -> Result<TaskStatus> {
let rsp: ResponseBase<TaskStatus> = self let rsp: ResponseBase<TaskStatus> = self
.api
.get(&format!( .get(&format!(
"/api2/json/nodes/{}/tasks/{}/status", "/api2/json/nodes/{}/tasks/{}/status",
task.node, task.id task.node, task.id
@ -209,6 +218,7 @@ impl ProxmoxClient {
/// Download an image to the host disk /// Download an image to the host disk
pub async fn download_image(&self, req: DownloadUrlRequest) -> Result<TaskId> { pub async fn download_image(&self, req: DownloadUrlRequest) -> Result<TaskId> {
let rsp: ResponseBase<String> = self let rsp: ResponseBase<String> = self
.api
.post( .post(
&format!( &format!(
"/api2/json/nodes/{}/storage/{}/download-url", "/api2/json/nodes/{}/storage/{}/download-url",
@ -229,7 +239,7 @@ impl ProxmoxClient {
if let Some(ssh_config) = &self.ssh { if let Some(ssh_config) = &self.ssh {
let mut ses = SshClient::new()?; let mut ses = SshClient::new()?;
ses.connect( ses.connect(
(self.base.host().unwrap().to_string(), 22), (self.api.base.host().unwrap().to_string(), 22),
&ssh_config.user, &ssh_config.user,
&ssh_config.key, &ssh_config.key,
) )
@ -274,6 +284,7 @@ impl ProxmoxClient {
/// Resize a disk on a VM /// Resize a disk on a VM
pub async fn resize_disk(&self, req: ResizeDiskRequest) -> Result<TaskId> { pub async fn resize_disk(&self, req: ResizeDiskRequest) -> Result<TaskId> {
let rsp: ResponseBase<String> = self let rsp: ResponseBase<String> = self
.api
.req( .req(
Method::PUT, Method::PUT,
&format!("/api2/json/nodes/{}/qemu/{}/resize", &req.node, &req.vm_id), &format!("/api2/json/nodes/{}/qemu/{}/resize", &req.node, &req.vm_id),
@ -289,6 +300,7 @@ impl ProxmoxClient {
/// Start a VM /// Start a VM
pub async fn start_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> { pub async fn start_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> {
let rsp: ResponseBase<String> = self let rsp: ResponseBase<String> = self
.api
.post( .post(
&format!("/api2/json/nodes/{}/qemu/{}/status/start", node, vm), &format!("/api2/json/nodes/{}/qemu/{}/status/start", node, vm),
(), (),
@ -303,6 +315,7 @@ impl ProxmoxClient {
/// Stop a VM /// Stop a VM
pub async fn stop_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> { pub async fn stop_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> {
let rsp: ResponseBase<String> = self let rsp: ResponseBase<String> = self
.api
.post( .post(
&format!("/api2/json/nodes/{}/qemu/{}/status/stop", node, vm), &format!("/api2/json/nodes/{}/qemu/{}/status/stop", node, vm),
(), (),
@ -317,6 +330,7 @@ impl ProxmoxClient {
/// Stop a VM /// Stop a VM
pub async fn shutdown_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> { pub async fn shutdown_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> {
let rsp: ResponseBase<String> = self let rsp: ResponseBase<String> = self
.api
.post( .post(
&format!("/api2/json/nodes/{}/qemu/{}/status/shutdown", node, vm), &format!("/api2/json/nodes/{}/qemu/{}/status/shutdown", node, vm),
(), (),
@ -331,6 +345,7 @@ impl ProxmoxClient {
/// Stop a VM /// Stop a VM
pub async fn reset_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> { pub async fn reset_vm(&self, node: &str, vm: ProxmoxVmId) -> Result<TaskId> {
let rsp: ResponseBase<String> = self let rsp: ResponseBase<String> = self
.api
.post( .post(
&format!("/api2/json/nodes/{}/qemu/{}/status/reset", node, vm), &format!("/api2/json/nodes/{}/qemu/{}/status/reset", node, vm),
(), (),
@ -341,117 +356,6 @@ impl ProxmoxClient {
node: node.to_string(), node: node.to_string(),
}) })
} }
/// Create terminal proxy session
pub async fn terminal_proxy(&self, node: &str, vm: ProxmoxVmId) -> Result<TerminalProxyTicket> {
let rsp: ResponseBase<TerminalProxyTicket> = 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<WebSocketStream<MaybeTlsStream<TcpStream>>> {
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<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
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<T: DeserializeOwned, R: Serialize>(&self, path: &str, body: R) -> Result<T> {
self.req(Method::POST, path, body).await
}
async fn req<T: DeserializeOwned, R: Serialize>(
&self,
method: Method,
path: &str,
body: R,
) -> Result<T> {
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 { impl ProxmoxClient {

73
src/json_api.rs Normal file
View File

@ -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<Self> {
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<T: DeserializeOwned>(&self, path: &str) -> anyhow::Result<T> {
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<T: DeserializeOwned, R: Serialize>(
&self,
path: &str,
body: R,
) -> anyhow::Result<T> {
self.req(Method::POST, path, body).await
}
pub async fn req<T: DeserializeOwned, R: Serialize>(
&self,
method: Method,
path: &str,
body: R,
) -> anyhow::Result<T> {
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);
}
}
}

View File

@ -1,8 +1,10 @@
pub mod api; pub mod api;
pub mod cors; pub mod cors;
pub mod dns;
pub mod exchange; pub mod exchange;
pub mod host; pub mod host;
pub mod invoice; pub mod invoice;
pub mod json_api;
pub mod lightning; pub mod lightning;
pub mod nip98; pub mod nip98;
pub mod provisioner; pub mod provisioner;

View File

@ -1,91 +1,26 @@
use crate::api::WEBHOOK_BRIDGE; use crate::api::WEBHOOK_BRIDGE;
use crate::json_api::JsonApi;
use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode}; use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode};
use anyhow::bail; use anyhow::bail;
use futures::{Stream, StreamExt}; use futures::{Stream, StreamExt};
use lnvps_db::async_trait; 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 serde::{Deserialize, Serialize};
use std::pin::Pin; use std::pin::Pin;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
pub struct BitvoraNode { pub struct BitvoraNode {
base: Url, api: JsonApi,
client: reqwest::Client,
webhook_secret: String, webhook_secret: String,
} }
impl BitvoraNode { impl BitvoraNode {
pub fn new(api_token: &str, webhook_secret: &str) -> Self { pub fn new(api_token: &str, webhook_secret: &str) -> Self {
let mut headers = HeaderMap::new(); let auth = format!("Bearer {}", api_token);
headers.insert(
"Authorization",
format!("Bearer {}", api_token).parse().unwrap(),
);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.unwrap();
Self { Self {
base: Url::parse("https://api.bitvora.com/").unwrap(), api: JsonApi::token("https://api.bitvora.com/", &auth).unwrap(),
client,
webhook_secret: webhook_secret.to_string(), webhook_secret: webhook_secret.to_string(),
} }
} }
async fn get<T: DeserializeOwned>(&self, path: &str) -> anyhow::Result<T> {
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<T: DeserializeOwned, R: Serialize>(
&self,
path: &str,
body: R,
) -> anyhow::Result<T> {
self.req(Method::POST, path, body).await
}
async fn req<T: DeserializeOwned, R: Serialize>(
&self,
method: Method,
path: &str,
body: R,
) -> anyhow::Result<T> {
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] #[async_trait]
@ -98,7 +33,8 @@ impl LightningNode for BitvoraNode {
expiry_seconds: req.expire.unwrap_or(3600) as u64, expiry_seconds: req.expire.unwrap_or(3600) as u64,
}; };
let rsp: BitvoraResponse<CreateInvoiceResponse> = self let rsp: BitvoraResponse<CreateInvoiceResponse> = self
.req(Method::POST, "/v1/bitcoin/deposit/lightning-invoice", req) .api
.post("/v1/bitcoin/deposit/lightning-invoice", req)
.await?; .await?;
if rsp.status >= 400 { if rsp.status >= 400 {
bail!( bail!(

View File

@ -1,3 +1,4 @@
use crate::dns::{BasicRecord, DnsServer};
use crate::host::{CreateVmRequest, VmHostClient}; use crate::host::{CreateVmRequest, VmHostClient};
use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode}; use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode};
use crate::router::{ArpEntry, Router}; use crate::router::{ArpEntry, Router};
@ -406,11 +407,28 @@ impl LNVpsDb for MockDb {
ip_range_id: ip_assignment.ip_range_id, ip_range_id: ip_assignment.ip_range_id,
ip: ip_assignment.ip.clone(), ip: ip_assignment.ip.clone(),
deleted: false, 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) 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<Vec<VmIpAssignment>> { async fn list_vm_ip_assignments(&self, vm_id: u64) -> anyhow::Result<Vec<VmIpAssignment>> {
let ip_assignments = self.ip_assignments.lock().await; let ip_assignments = self.ip_assignments.lock().await;
Ok(ip_assignments Ok(ip_assignments
@ -475,12 +493,12 @@ pub struct MockRouter {
impl MockRouter { impl MockRouter {
pub fn new(policy: NetworkPolicy) -> Self { pub fn new(policy: NetworkPolicy) -> Self {
static ARP: LazyLock<Arc<Mutex<HashMap<u64, ArpEntry>>>> = static LAZY_ARP: LazyLock<Arc<Mutex<HashMap<u64, ArpEntry>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
Self { Self {
policy, policy,
arp: ARP.clone(), arp: LAZY_ARP.clone(),
} }
} }
} }
@ -535,6 +553,16 @@ struct MockInvoice {
settle_index: u64, settle_index: u64,
} }
impl MockNode {
pub fn new() -> Self {
static LAZY_INVOICES: LazyLock<Arc<Mutex<HashMap<String, MockInvoice>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
Self {
invoices: LAZY_INVOICES.clone(),
}
}
}
#[async_trait] #[async_trait]
impl LightningNode for MockNode { impl LightningNode for MockNode {
async fn add_invoice(&self, req: AddInvoiceRequest) -> anyhow::Result<AddInvoiceResult> { async fn add_invoice(&self, req: AddInvoiceRequest) -> anyhow::Result<AddInvoiceResult> {
@ -549,7 +577,7 @@ impl LightningNode for MockNode {
} }
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone)]
pub struct MockVmHost { pub struct MockVmHost {
vms: Arc<Mutex<HashMap<u64, MockVm>>>, vms: Arc<Mutex<HashMap<u64, MockVm>>>,
} }
@ -559,6 +587,16 @@ struct MockVm {
pub state: VmRunningState, pub state: VmRunningState,
} }
impl MockVmHost {
pub fn new() -> Self {
static LAZY_VMS: LazyLock<Arc<Mutex<HashMap<u64, MockVm>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
Self {
vms: LAZY_VMS.clone(),
}
}
}
#[async_trait] #[async_trait]
impl VmHostClient for MockVmHost { impl VmHostClient for MockVmHost {
async fn download_os_image(&self, image: &VmOsImage) -> anyhow::Result<()> { async fn download_os_image(&self, image: &VmOsImage) -> anyhow::Result<()> {
@ -633,3 +671,86 @@ impl VmHostClient for MockVmHost {
Ok(()) Ok(())
} }
} }
pub struct MockDnsServer {
pub forward: Arc<Mutex<HashMap<String, MockDnsEntry>>>,
pub reverse: Arc<Mutex<HashMap<String, MockDnsEntry>>>,
}
pub struct MockDnsEntry {
pub name: String,
pub value: String,
pub kind: String,
}
impl MockDnsServer {
pub fn new() -> Self {
static LAZY_FWD: LazyLock<Arc<Mutex<HashMap<String, MockDnsEntry>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
static LAZY_REV: LazyLock<Arc<Mutex<HashMap<String, MockDnsEntry>>>> =
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<BasicRecord> {
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<BasicRecord> {
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!()
}
}

View File

@ -1,3 +1,4 @@
use crate::dns::DnsServer;
use crate::exchange::{ExchangeRateService, Ticker}; use crate::exchange::{ExchangeRateService, Ticker};
use crate::host::{get_host_client, CreateVmRequest, VmHostClient}; use crate::host::{get_host_client, CreateVmRequest, VmHostClient};
use crate::lightning::{AddInvoiceRequest, LightningNode}; use crate::lightning::{AddInvoiceRequest, LightningNode};
@ -13,6 +14,7 @@ use nostr::util::hex;
use rand::random; use rand::random;
use rocket::futures::{SinkExt, StreamExt}; use rocket::futures::{SinkExt, StreamExt};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fmt::format;
use std::net::IpAddr; use std::net::IpAddr;
use std::ops::Add; use std::ops::Add;
use std::str::FromStr; use std::str::FromStr;
@ -31,6 +33,8 @@ pub struct LNVpsProvisioner {
rates: Arc<dyn ExchangeRateService>, rates: Arc<dyn ExchangeRateService>,
router: Option<Arc<dyn Router>>, router: Option<Arc<dyn Router>>,
dns: Option<Arc<dyn DnsServer>>,
network_policy: NetworkPolicy, network_policy: NetworkPolicy,
provisioner_config: ProvisionerConfig, provisioner_config: ProvisionerConfig,
} }
@ -47,6 +51,7 @@ impl LNVpsProvisioner {
node, node,
rates, rates,
router: settings.get_router().expect("router config"), router: settings.get_router().expect("router config"),
dns: settings.get_dns().expect("dns config"),
network_policy: settings.network_policy, network_policy: settings.network_policy,
provisioner_config: settings.provisioner, provisioner_config: settings.provisioner,
read_only: settings.read_only, read_only: settings.read_only,
@ -73,12 +78,14 @@ impl LNVpsProvisioner {
Ok(()) 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 // apply network policy
if let NetworkAccessPolicy::StaticArp { interface } = &self.network_policy.access { if let NetworkAccessPolicy::StaticArp { interface } = &self.network_policy.access {
if let Some(r) = self.router.as_ref() { if let Some(r) = self.router.as_ref() {
r.add_arp_entry( r.add_arp_entry(
IpAddr::from_str(&assignment.ip)?, ip.clone(),
&vm.mac_address, &vm.mac_address,
interface, interface,
Some(&format!("VM{}", vm.id)), 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 // save to db
self.db.insert_vm_ip_assignment(assignment).await?; self.db.insert_vm_ip_assignment(&assignment).await?;
Ok(()) Ok(())
} }
@ -106,15 +133,20 @@ impl LNVpsProvisioner {
let template = self.db.get_vm_template(vm.template_id).await?; let template = self.db.get_vm_template(vm.template_id).await?;
let ip = network.pick_ip_for_region(template.region_id).await?; let ip = network.pick_ip_for_region(template.region_id).await?;
let assignment = VmIpAssignment { let mut assignment = VmIpAssignment {
id: 0, id: 0,
vm_id, vm_id,
ip_range_id: ip.range_id, ip_range_id: ip.range_id,
ip: ip.ip.to_string(), ip: ip.ip.to_string(),
deleted: false, 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]) Ok(vec![assignment])
} }
@ -326,9 +358,7 @@ mod tests {
use super::*; use super::*;
use crate::exchange::DefaultRateCache; use crate::exchange::DefaultRateCache;
use crate::mocks::{MockDb, MockNode}; use crate::mocks::{MockDb, MockNode};
use crate::settings::{ use crate::settings::{DnsServerConfig, LightningConfig, QemuConfig, RouterConfig};
ApiConfig, Credentials, LndConfig, ProvisionerConfig, QemuConfig, RouterConfig,
};
use lnvps_db::UserSshKey; use lnvps_db::UserSshKey;
#[tokio::test] #[tokio::test]
@ -338,7 +368,7 @@ mod tests {
let settings = Settings { let settings = Settings {
listen: None, listen: None,
db: "".to_string(), db: "".to_string(),
lnd: LndConfig { lightning: LightningConfig::LND {
url: "".to_string(), url: "".to_string(),
cert: Default::default(), cert: Default::default(),
macaroon: Default::default(), macaroon: Default::default(),
@ -364,21 +394,23 @@ mod tests {
}, },
delete_after: 0, delete_after: 0,
smtp: None, smtp: None,
router: Some(RouterConfig::Mikrotik(ApiConfig { router: Some(RouterConfig::Mikrotik {
id: "mock-router".to_string(),
url: "https://localhost".to_string(), url: "https://localhost".to_string(),
credentials: Credentials::UsernamePassword { username: "admin".to_string(),
username: "admin".to_string(), password: "password123".to_string(),
password: "password123".to_string(), }),
}, dns: Some(DnsServerConfig::Cloudflare {
})), token: "abc".to_string(),
dns: None, forward_zone_id: "123".to_string(),
reverse_zone_id: "456".to_string(),
}),
nostr: None, nostr: None,
}; };
let db = Arc::new(MockDb::default()); let db = Arc::new(MockDb::default());
let node = Arc::new(MockNode::default()); let node = Arc::new(MockNode::default());
let rates = Arc::new(DefaultRateCache::default()); let rates = Arc::new(DefaultRateCache::default());
let router = settings.get_router().expect("router").unwrap(); 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 provisioner = LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone());
let pubkey: [u8; 32] = random(); let pubkey: [u8; 32] = random();
@ -408,9 +440,15 @@ mod tests {
let ips = db.list_vm_ip_assignments(vm.id).await?; let ips = db.list_vm_ip_assignments(vm.id).await?;
assert_eq!(1, ips.len()); assert_eq!(1, ips.len());
let ip = ips.first().unwrap(); let ip = ips.first().unwrap();
println!("{:?}", ip);
assert_eq!(ip.ip, arp.address); assert_eq!(ip.ip, arp.address);
assert_eq!(ip.ip_range_id, 1); assert_eq!(ip.ip_range_id, 1);
assert_eq!(ip.vm_id, vm.id); 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 IP address is not CIDR
assert!(IpAddr::from_str(&ip.ip).is_ok()); assert!(IpAddr::from_str(&ip.ip).is_ok());

View File

@ -116,7 +116,7 @@ mod tests {
vm_id: 0, vm_id: 0,
ip_range_id: ip.range_id, ip_range_id: ip.range_id,
ip: ip.ip.to_string(), ip: ip.ip.to_string(),
deleted: false, ..Default::default()
}) })
.await .await
.expect("Could not insert vm ip"); .expect("Could not insert vm ip");

View File

@ -1,65 +1,24 @@
use crate::json_api::JsonApi;
use crate::router::{ArpEntry, Router}; use crate::router::{ArpEntry, Router};
use anyhow::{bail, Result}; use anyhow::Result;
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use base64::Engine; use base64::Engine;
use log::debug; use reqwest::Method;
use reqwest::{Client, Method, Url};
use rocket::async_trait; use rocket::async_trait;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::net::IpAddr; use std::net::IpAddr;
pub struct MikrotikRouter { pub struct MikrotikRouter {
url: Url, api: JsonApi,
username: String,
password: String,
client: Client,
} }
impl MikrotikRouter { impl MikrotikRouter {
pub fn new(url: &str, username: &str, password: &str) -> Self { pub fn new(url: &str, username: &str, password: &str) -> Self {
let auth = format!(
"Basic {}",
STANDARD.encode(format!("{}:{}", username, password))
);
Self { Self {
url: url.parse().unwrap(), api: JsonApi::token(url, &auth).unwrap(),
username: username.to_string(),
password: password.to_string(),
client: Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap(),
}
}
async fn req<T: DeserializeOwned, R: Serialize>(
&self,
method: Method,
path: &str,
body: R,
) -> Result<T> {
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);
} }
} }
} }
@ -67,7 +26,7 @@ impl MikrotikRouter {
#[async_trait] #[async_trait]
impl Router for MikrotikRouter { impl Router for MikrotikRouter {
async fn list_arp_entry(&self) -> Result<Vec<ArpEntry>> { async fn list_arp_entry(&self) -> Result<Vec<ArpEntry>> {
let rsp: Vec<ArpEntry> = self.req(Method::GET, "/rest/ip/arp", ()).await?; let rsp: Vec<ArpEntry> = self.api.req(Method::GET, "/rest/ip/arp", ()).await?;
Ok(rsp) Ok(rsp)
} }
@ -79,6 +38,7 @@ impl Router for MikrotikRouter {
comment: Option<&str>, comment: Option<&str>,
) -> Result<()> { ) -> Result<()> {
let _rsp: ArpEntry = self let _rsp: ArpEntry = self
.api
.req( .req(
Method::PUT, Method::PUT,
"/rest/ip/arp", "/rest/ip/arp",
@ -97,6 +57,7 @@ impl Router for MikrotikRouter {
async fn remove_arp_entry(&self, id: &str) -> Result<()> { async fn remove_arp_entry(&self, id: &str) -> Result<()> {
let _rsp: ArpEntry = self let _rsp: ArpEntry = self
.api
.req(Method::DELETE, &format!("/rest/ip/arp/{id}"), ()) .req(Method::DELETE, &format!("/rest/ip/arp/{id}"), ())
.await?; .await?;

View File

@ -1,8 +1,9 @@
use crate::dns::DnsServer;
use crate::exchange::ExchangeRateService; use crate::exchange::ExchangeRateService;
use crate::lightning::LightningNode; use crate::lightning::LightningNode;
use crate::provisioner::LNVpsProvisioner; use crate::provisioner::LNVpsProvisioner;
use crate::router::{MikrotikRouter, Router}; use crate::router::Router;
use anyhow::{bail, Result}; use anyhow::Result;
use lnvps_db::LNVpsDb; use lnvps_db::LNVpsDb;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
@ -56,7 +57,7 @@ pub enum LightningConfig {
Bitvora { Bitvora {
token: String, token: String,
webhook_secret: String, webhook_secret: String,
} },
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -68,32 +69,22 @@ pub struct NostrConfig {
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum RouterConfig { pub enum RouterConfig {
Mikrotik(ApiConfig), Mikrotik {
url: String,
username: String,
password: String,
},
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum DnsServerConfig { pub enum DnsServerConfig {
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
Cloudflare { api: ApiConfig, zone_id: String }, Cloudflare {
} token: String,
forward_zone_id: String,
/// Generic remote API credentials reverse_zone_id: String,
#[derive(Debug, Clone, Deserialize, Serialize)] },
pub struct ApiConfig {
/// unique ID of this router, used in references
pub id: String,
/// http://<my-router>
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 },
} }
/// Policy that determines how packets arrive at the VM /// Policy that determines how packets arrive at the VM
@ -187,26 +178,52 @@ impl Settings {
Arc::new(LNVpsProvisioner::new(self.clone(), db, node, exchange)) Arc::new(LNVpsProvisioner::new(self.clone(), db, node, exchange))
} }
#[cfg(not(test))]
pub fn get_router(&self) -> Result<Option<Arc<dyn Router>>> { pub fn get_router(&self) -> Result<Option<Arc<dyn Router>>> {
match &self.router { #[cfg(test)]
Some(RouterConfig::Mikrotik(api)) => match &api.credentials { {
Credentials::UsernamePassword { username, password } => Ok(Some(Arc::new( if let Some(router) = &self.router {
MikrotikRouter::new(&api.url, username, password), let router = crate::mocks::MockRouter::new(self.network_policy.clone());
))), Ok(Some(Arc::new(router)))
_ => bail!("Only username/password is supported for Mikrotik routers"), } else {
}, Ok(None)
_ => 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_dns(&self) -> Result<Option<Arc<dyn DnsServer>>> {
pub fn get_router(&self) -> Result<Option<Arc<dyn Router>>> { #[cfg(test)]
if self.router.is_some() { {
let router = crate::mocks::MockRouter::new(self.network_policy.clone()); Ok(Some(Arc::new(crate::mocks::MockDnsServer::new())))
Ok(Some(Arc::new(router))) }
} else { #[cfg(not(test))]
Ok(None) {
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,
)))),
}
} }
} }
} }