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"
[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"] }

View File

@ -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-<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
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
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)]
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<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 {

View File

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

View File

@ -51,6 +51,8 @@ pub struct ApiVmIpAssignment {
pub id: u64,
pub ip: String,
pub gateway: String,
pub forward_dns: Option<String>,
pub reverse_dns: Option<String>,
}
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(),
}
}
}

View File

@ -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 {

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>> {
#[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<dyn
ssh,
mac_prefix,
},
) => 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),
})
}

View File

@ -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<SshConfig>,
mac_prefix: String,
@ -38,19 +38,24 @@ impl ProxmoxClient {
pub fn new(
base: Url,
node: &str,
token: &str,
mac_prefix: Option<String>,
config: QemuConfig,
ssh: Option<SshConfig>,
) -> 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<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)
}
/// List nodes
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)
}
pub async fn get_vm_status(&self, node: &str, vm_id: ProxmoxVmId) -> Result<VmInfo> {
let rsp: ResponseBase<VmInfo> = 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<Vec<VmInfo>> {
let rsp: ResponseBase<Vec<VmInfo>> =
self.get(&format!("/api2/json/nodes/{node}/qemu")).await?;
let rsp: ResponseBase<Vec<VmInfo>> = self
.api
.get(&format!("/api2/json/nodes/{node}/qemu"))
.await?;
Ok(rsp.data)
}
pub async fn list_storage(&self, node: &str) -> Result<Vec<NodeStorage>> {
let rsp: ResponseBase<Vec<NodeStorage>> = self
.api
.get(&format!("/api2/json/nodes/{node}/storage"))
.await?;
Ok(rsp.data)
@ -104,6 +108,7 @@ impl ProxmoxClient {
storage: &str,
) -> Result<Vec<StorageContentEntry>> {
let rsp: ResponseBase<Vec<StorageContentEntry>> = 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<TaskId> {
let rsp: ResponseBase<Option<String>> = 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<TaskId> {
let rsp: ResponseBase<Option<String>> = 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<TaskId> {
let rsp: ResponseBase<Option<String>> = 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<TaskStatus> {
let rsp: ResponseBase<TaskStatus> = 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<TaskId> {
let rsp: ResponseBase<String> = 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<TaskId> {
let rsp: ResponseBase<String> = 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<TaskId> {
let rsp: ResponseBase<String> = 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<TaskId> {
let rsp: ResponseBase<String> = 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<TaskId> {
let rsp: ResponseBase<String> = 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<TaskId> {
let rsp: ResponseBase<String> = 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<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 {

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

View File

@ -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<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]
@ -98,7 +33,8 @@ impl LightningNode for BitvoraNode {
expiry_seconds: req.expire.unwrap_or(3600) as u64,
};
let rsp: BitvoraResponse<CreateInvoiceResponse> = self
.req(Method::POST, "/v1/bitcoin/deposit/lightning-invoice", req)
.api
.post("/v1/bitcoin/deposit/lightning-invoice", req)
.await?;
if rsp.status >= 400 {
bail!(

View File

@ -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<Vec<VmIpAssignment>> {
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<Arc<Mutex<HashMap<u64, ArpEntry>>>> =
static LAZY_ARP: LazyLock<Arc<Mutex<HashMap<u64, ArpEntry>>>> =
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<Arc<Mutex<HashMap<String, MockInvoice>>>> =
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<AddInvoiceResult> {
@ -549,7 +577,7 @@ impl LightningNode for MockNode {
}
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
pub struct MockVmHost {
vms: Arc<Mutex<HashMap<u64, MockVm>>>,
}
@ -559,6 +587,16 @@ struct MockVm {
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]
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<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::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<dyn ExchangeRateService>,
router: Option<Arc<dyn Router>>,
dns: Option<Arc<dyn DnsServer>>,
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());

View File

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

View File

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

View File

@ -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://<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 },
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<Option<Arc<dyn Router>>> {
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<Option<Arc<dyn Router>>> {
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<Option<Arc<dyn DnsServer>>> {
#[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,
)))),
}
}
}
}