From 80ae12b33f63f3328c3269d08ba6c5d9fcbff031 Mon Sep 17 00:00:00 2001 From: kieran Date: Mon, 3 Mar 2025 14:14:40 +0000 Subject: [PATCH] feat: setup webhook bridge for bitvora --- Cargo.lock | 2 + Cargo.toml | 11 ++++-- README.md | 26 ++++++++++--- src/api/mod.rs | 11 +++++- src/api/webhook.rs | 84 ++++++++++++++++++++++++++++++++++++++++ src/lightning/bitvora.rs | 25 +++++++++--- src/lightning/lnd.rs | 1 - src/lightning/mod.rs | 2 +- src/settings.rs | 2 + 9 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 src/api/webhook.rs diff --git a/Cargo.lock b/Cargo.lock index e327341..5ea7904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2037,6 +2037,7 @@ dependencies = [ "ssh-key", "ssh2", "tokio", + "tokio-stream", "tokio-tungstenite 0.21.0", "urlencoding", "virt", @@ -4210,6 +4211,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b5d9a93..62c1644 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,17 +7,17 @@ edition = "2021" name = "api" [features] -default = ["mikrotik", "nostr-dm", "proxmox", "lnd"] +default = ["mikrotik", "nostr-dm", "proxmox", "lnd", "bitvora"] 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"] +bitvora = ["dep:reqwest", "dep:tokio-stream"] [dependencies] lnvps_db = { path = "lnvps_db" } -tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros"] } +tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros", "sync"] } anyhow = "1.0.83" config = { version = "0.15.8", features = ["yaml"] } log = "0.4.21" @@ -45,7 +45,7 @@ nostr = { version = "0.39.0", default-features = false, features = ["std"] } nostr-sdk = { version = "0.39.0", optional = true, default-features = false, features = ["nip44", "nip59"] } #proxmox -tokio-tungstenite = { version = "^0.21", features = ["native-tls"], optional = true} +tokio-tungstenite = { version = "^0.21", features = ["native-tls"], optional = true } ssh2 = { version = "0.9.4", optional = true } reqwest = { version = "0.12.8", optional = true } @@ -54,3 +54,6 @@ virt = { version = "0.4.2", optional = true } #lnd fedimint-tonic-lnd = { version = "0.2.0", default-features = false, features = ["invoicesrpc"], optional = true } + +#bitvora +tokio-stream = { version = "0.1.17", features = ["sync"], optional = true } \ No newline at end of file diff --git a/README.md b/README.md index 218a49f..957c577 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ A bitcoin powered VPS system. ## Requirements - MySql database -- LND node +- Lightning node: + - LND + - [Bitvora](https://bitvora.com?r=lnvps) - Proxmox server ## Required Config @@ -14,22 +16,26 @@ A bitcoin powered VPS system. # MySql database connection string db: "mysql://root:root@localhost:3376/lnvps" -# LND node connection details +# LN node connection details (Only 1 allowed) lightning: lnd: url: "https://127.0.0.1:10003" cert: "$HOME/.lnd/tls.cert" macaroon: "$HOME/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" - + #bitvora: + # token: "my-api-token" + # webhook-secret: "my-webhook-secret" + # Number of days after a VM expires to delete delete-after: 3 + +# Read-only mode prevents spawning VM's +read-only: false # Provisioner is the main process which handles creating/deleting VM's # Currently supports: Proxmox provisioner: proxmox: - # Read-only mode prevents spawning VM's - read-only: false # Proxmox (QEMU) settings used for spawning VM's qemu: bios: "ovmf" @@ -39,9 +45,17 @@ provisioner: cpu: "kvm64" vlan: 100 kvm: false + +# Networking policy +network-policy: + # Configure network equipment on provisioning IP resources + access: "auto" + # Use SLAAC to auto-configure VM ipv6 addresses + ip6-slaac: true ``` ### Email notifications + Email notifications can be enabled, this is primarily intended for admin notifications. ```yaml @@ -75,7 +89,7 @@ nostr: ### Network Setup (Advanced) -When ARP is disabled (reply-only) on your router you may need to create static ARP entries when allocating +When ARP is disabled (reply-only) on your router you may need to create static ARP entries when allocating IPs, we support managing ARP entries on routers directly as part of the provisioning process. ```yaml diff --git a/src/api/mod.rs b/src/api/mod.rs index fb9221d..eb89078 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,13 @@ +use rocket::{routes, Route}; + mod model; mod routes; +mod webhook; -pub use routes::routes; +pub fn routes() -> Vec { + let mut r = routes::routes(); + r.append(&mut webhook::routes()); + r +} + +pub use webhook::WEBHOOK_BRIDGE; \ No newline at end of file diff --git a/src/api/webhook.rs b/src/api/webhook.rs new file mode 100644 index 0000000..ebe2fed --- /dev/null +++ b/src/api/webhook.rs @@ -0,0 +1,84 @@ +use anyhow::anyhow; +use lettre::message::header::Headers; +use log::warn; +use reqwest::header::HeaderMap; +use reqwest::Request; +use rocket::data::{ByteUnit, FromData, ToByteUnit}; +use rocket::http::Status; +use rocket::outcome::IntoOutcome; +use rocket::request::{FromRequest, Outcome}; +use rocket::{post, routes, Data, Route}; +use std::collections::HashMap; +use std::sync::LazyLock; +use tokio::io::AsyncReadExt; +use tokio::sync::broadcast; + +/// Messaging bridge for webhooks to other parts of the system (bitvora) +pub static WEBHOOK_BRIDGE: LazyLock = LazyLock::new(|| WebhookBridge::new()); + +pub fn routes() -> Vec { + if cfg!(feature = "bitvora") { + routes![bitvora_webhook] + } else { + routes![] + } +} + +#[post("/api/v1/webhook/bitvora", data = "")] +async fn bitvora_webhook(req: WebhookMessage) -> Status { + WEBHOOK_BRIDGE.send(req); + Status::Ok +} + +#[derive(Debug, Clone)] +pub struct WebhookMessage { + pub body: Vec, + pub headers: HashMap, +} + +#[rocket::async_trait] +impl<'r> FromData<'r> for WebhookMessage { + type Error = (); + + async fn from_data( + req: &'r rocket::Request<'_>, + data: Data<'r>, + ) -> rocket::data::Outcome<'r, Self, Self::Error> { + let header = req + .headers() + .iter() + .map(|v| (v.name.to_string(), v.value.to_string())) + .collect(); + let body = if let Ok(d) = data.open(4.megabytes()).into_bytes().await { + d + } else { + return rocket::data::Outcome::Error((Status::BadRequest, ())); + }; + let msg = WebhookMessage { + headers: header, + body: body.value.to_vec(), + }; + rocket::data::Outcome::Success(msg) + } +} +#[derive(Debug)] +pub struct WebhookBridge { + tx: broadcast::Sender, +} + +impl WebhookBridge { + pub fn new() -> Self { + let (tx, _rx) = broadcast::channel(100); + Self { tx } + } + + pub fn send(&self, message: WebhookMessage) { + if let Err(e) = self.tx.send(message) { + warn!("Failed to send webhook message: {}", e); + } + } + + pub fn listen(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} diff --git a/src/lightning/bitvora.rs b/src/lightning/bitvora.rs index bb9fd80..943cce7 100644 --- a/src/lightning/bitvora.rs +++ b/src/lightning/bitvora.rs @@ -1,21 +1,25 @@ +use crate::api::WEBHOOK_BRIDGE; use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode}; use anyhow::bail; -use futures::Stream; +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, + webhook_secret: String, } impl BitvoraNode { - pub fn new(api_token: &str) -> Self { + pub fn new(api_token: &str, webhook_secret: &str) -> Self { let mut headers = HeaderMap::new(); headers.insert( "Authorization", @@ -30,6 +34,7 @@ impl BitvoraNode { Self { base: Url::parse("https://api.bitvora.com/").unwrap(), client, + webhook_secret: webhook_secret.to_string(), } } @@ -95,6 +100,13 @@ impl LightningNode for BitvoraNode { let rsp: BitvoraResponse = self .req(Method::POST, "/v1/bitcoin/deposit/lightning-invoice", req) .await?; + if rsp.status >= 400 { + bail!( + "API error: {} {}", + rsp.status, + rsp.message.unwrap_or_default() + ); + } Ok(AddInvoiceResult { pr: rsp.data.payment_request, payment_hash: rsp.data.r_hash, @@ -103,9 +115,11 @@ impl LightningNode for BitvoraNode { async fn subscribe_invoices( &self, - from_payment_hash: Option>, + _from_payment_hash: Option>, ) -> anyhow::Result + Send>>> { - todo!() + let rx = BroadcastStream::new(WEBHOOK_BRIDGE.listen()); + let mapped = rx.then(|r| async move { InvoiceUpdate::Unknown }); + Ok(Box::pin(mapped)) } } @@ -118,8 +132,7 @@ struct CreateInvoiceRequest { } #[derive(Debug, Clone, Deserialize)] -struct BitvoraResponse -{ +struct BitvoraResponse { pub status: isize, pub message: Option, pub data: T, diff --git a/src/lightning/lnd.rs b/src/lightning/lnd.rs index 505aebd..2afcce2 100644 --- a/src/lightning/lnd.rs +++ b/src/lightning/lnd.rs @@ -1,6 +1,5 @@ use std::path::Path; use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode}; -use crate::settings::LndConfig; use anyhow::Result; use fedimint_tonic_lnd::invoicesrpc::lookup_invoice_msg::InvoiceRef; use fedimint_tonic_lnd::invoicesrpc::LookupInvoiceMsg; diff --git a/src/lightning/mod.rs b/src/lightning/mod.rs index 00e6969..beeb0cd 100644 --- a/src/lightning/mod.rs +++ b/src/lightning/mod.rs @@ -53,7 +53,7 @@ pub async fn get_node(settings: &Settings) -> Result> { macaroon, } => Ok(Arc::new(lnd::LndNode::new(url, cert, macaroon).await?)), #[cfg(feature = "bitvora")] - LightningConfig::Bitvora { token } => Ok(Arc::new(bitvora::BitvoraNode::new(token))), + LightningConfig::Bitvora { token, webhook_secret } => Ok(Arc::new(bitvora::BitvoraNode::new(token, webhook_secret))), _ => anyhow::bail!("Unsupported lightning config!"), } } diff --git a/src/settings.rs b/src/settings.rs index 220aa3f..9c7bd56 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -52,8 +52,10 @@ pub enum LightningConfig { cert: PathBuf, macaroon: PathBuf, }, + #[serde(rename_all = "kebab-case")] Bitvora { token: String, + webhook_secret: String, } }