From c432f603ec6649e41513d307c7fead9def4682fe Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 3 Apr 2025 12:56:20 +0100 Subject: [PATCH] feat: nostr domain hosting --- Cargo.lock | 20 +- Cargo.toml | 12 +- Dockerfile | 6 +- lnvps_api/README.md => README.md | 0 lnvps_api/Cargo.toml | 26 ++- lnvps_api/src/api/mod.rs | 2 + lnvps_api/src/api/model.rs | 13 +- lnvps_api/src/api/nostr_domain.rs | 202 ++++++++++++++++++ lnvps_api/src/api/routes.rs | 15 +- lnvps_api/src/bin/api.rs | 22 +- lnvps_api/src/data_migration/ip6_init.rs | 13 +- lnvps_api/src/data_migration/mod.rs | 15 +- lnvps_api/src/dns/mod.rs | 2 +- lnvps_api/src/host/mod.rs | 11 +- lnvps_api/src/lib.rs | 1 - lnvps_api/src/mocks.rs | 59 ++++- lnvps_api/src/router/mod.rs | 5 +- lnvps_api/src/router/ovh.rs | 2 +- lnvps_api/src/settings.rs | 4 + lnvps_common/Cargo.toml | 7 + {lnvps_api => lnvps_common}/src/cors.rs | 0 lnvps_common/src/lib.rs | 2 + lnvps_db/Cargo.toml | 1 + .../20250402115943_nostr_address.sql | 25 +++ lnvps_db/src/lib.rs | 39 +++- lnvps_db/src/model.rs | 21 ++ lnvps_db/src/mysql.rs | 118 +++++++++- lnvps_nostr/Cargo.toml | 12 +- lnvps_nostr/README.md | 3 + lnvps_nostr/config.yaml | 5 + lnvps_nostr/src/main.rs | 66 +++++- lnvps_nostr/src/routes.rs | 65 ++++++ 32 files changed, 724 insertions(+), 70 deletions(-) rename lnvps_api/README.md => README.md (100%) create mode 100644 lnvps_api/src/api/nostr_domain.rs create mode 100644 lnvps_common/Cargo.toml rename {lnvps_api => lnvps_common}/src/cors.rs (100%) create mode 100644 lnvps_common/src/lib.rs create mode 100644 lnvps_db/migrations/20250402115943_nostr_address.sql create mode 100644 lnvps_nostr/README.md create mode 100644 lnvps_nostr/config.yaml create mode 100644 lnvps_nostr/src/routes.rs diff --git a/Cargo.lock b/Cargo.lock index bded489..8254d45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2038,7 +2038,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] -name = "lnvps" +name = "lnvps_api" version = "0.1.0" dependencies = [ "anyhow", @@ -2054,6 +2054,7 @@ dependencies = [ "ipnetwork", "isocountry", "lettre", + "lnvps_common", "lnvps_db", "log", "native-tls", @@ -2079,6 +2080,13 @@ dependencies = [ "virt", ] +[[package]] +name = "lnvps_common" +version = "0.1.0" +dependencies = [ + "rocket", +] + [[package]] name = "lnvps_db" version = "0.1.0" @@ -2094,7 +2102,17 @@ dependencies = [ name = "lnvps_nostr" version = "0.1.0" dependencies = [ + "anyhow", + "config", + "env_logger", + "hex", + "lnvps_common", "lnvps_db", + "log", + "rocket", + "serde", + "serde_json", + "tokio", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 30bfaff..9c0d13e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,17 @@ resolver = "3" members = [ "lnvps_db", "lnvps_api", - "lnvps_nostr" + "lnvps_nostr", + "lnvps_common" ] [workspace.dependencies] -tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros", "sync", "io-util"] } +tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros"] } anyhow = "1.0.83" log = "0.4.21" -env_logger = "0.11.7" \ No newline at end of file +env_logger = "0.11.7" +serde = { version = "1.0.213", features = ["derive"] } +serde_json = "1.0.132" +rocket = { version = "0.5.1", features = ["json"] } +config = { version = "0.15.8", features = ["yaml"] } +hex = "0.4.3" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d9009fb..a7cc06b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,11 @@ FROM $IMAGE AS build WORKDIR /app/src COPY . . RUN apt update && apt -y install protobuf-compiler libvirt-dev -RUN cargo test && cargo install --path lnvps_api --root /app/build +RUN cargo test \ + && cargo install --root /app/build --path lnvps_api \ + && cargo install --root /app/build --path lnvps_nostr FROM $IMAGE AS runner WORKDIR /app COPY --from=build /app/build . -ENTRYPOINT ["./bin/api"] \ No newline at end of file +ENTRYPOINT ["./bin/lnvps_api"] \ No newline at end of file diff --git a/lnvps_api/README.md b/README.md similarity index 100% rename from lnvps_api/README.md rename to README.md diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index 2e5819f..a5fab0e 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -1,25 +1,30 @@ [package] -name = "lnvps" +name = "lnvps_api" version = "0.1.0" edition = "2021" [[bin]] -name = "api" +name = "lnvps_api" +path = "src/bin/api.rs" [features] default = [ "mikrotik", "nostr-dm", "nostr-dvm", + "nostr-domain", "proxmox", "lnd", "cloudflare", "revolut", - "bitvora" + "bitvora", + "tokio/sync", + "tokio/io-util" ] mikrotik = ["dep:reqwest"] nostr-dm = ["dep:nostr-sdk"] nostr-dvm = ["dep:nostr-sdk"] +nostr-domain = ["lnvps_db/nostr-domain"] proxmox = ["dep:reqwest", "dep:ssh2", "dep:tokio-tungstenite"] libvirt = ["dep:virt", "dep:uuid", "dep:quick-xml"] lnd = ["dep:fedimint-tonic-lnd"] @@ -29,15 +34,16 @@ revolut = ["dep:reqwest", "dep:sha2", "dep:hmac"] [dependencies] lnvps_db = { path = "../lnvps_db" } +lnvps_common = { path = "../lnvps_common" } anyhow.workspace = true log.workspace = true env_logger.workspace = true - -tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros", "sync", "io-util"] } -config = { version = "0.15.8", features = ["yaml"] } -serde = { version = "1.0.213", features = ["derive"] } -serde_json = "1.0.132" -rocket = { version = "0.5.1", features = ["json"] } +tokio.workspace = true +config.workspace = true +serde.workspace = true +serde_json.workspace = true +rocket.workspace = true +hex.workspace = true rocket_okapi = { version = "0.9.0", features = ["swagger"] } schemars = { version = "0.8.22", features = ["chrono"] } chrono = { version = "0.4.38", features = ["serde"] } @@ -50,7 +56,7 @@ ssh-key = "0.6.7" lettre = { version = "0.11.10", features = ["tokio1-native-tls"] } ws = { package = "rocket_ws", version = "0.1.1" } native-tls = "0.2.12" -hex = "0.4.3" + futures = "0.3.31" isocountry = "0.3.2" diff --git a/lnvps_api/src/api/mod.rs b/lnvps_api/src/api/mod.rs index a76d55b..40762fa 100644 --- a/lnvps_api/src/api/mod.rs +++ b/lnvps_api/src/api/mod.rs @@ -1,6 +1,8 @@ use rocket::Route; mod model; +#[cfg(feature = "nostr-domain")] +mod nostr_domain; mod routes; mod webhook; diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 732bf24..f860c68 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive(Serialize, JsonSchema)] pub struct ApiVmStatus { /// Unique VM ID (Same in proxmox) pub id: u64, @@ -37,7 +37,7 @@ pub struct ApiVmStatus { pub status: VmState, } -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive(Serialize, JsonSchema)] pub struct ApiUserSshKey { pub id: u64, pub name: String, @@ -54,7 +54,7 @@ impl From for ApiUserSshKey { } } -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive(Serialize, JsonSchema)] pub struct ApiVmIpAssignment { pub id: u64, pub ip: String, @@ -133,7 +133,7 @@ impl From for lnvps_db::DiskInterface { } } -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive(Serialize, JsonSchema)] pub struct ApiTemplatesResponse { pub templates: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -157,7 +157,7 @@ impl ApiTemplatesResponse { Ok(()) } } -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive(Serialize, JsonSchema)] pub struct ApiCustomTemplateParams { pub id: u64, pub name: String, @@ -196,8 +196,7 @@ impl ApiCustomTemplateParams { .filter_map(|d| { Some(ApiCustomTemplateDiskParam { min_disk: GB * 5, - max_disk: *max_disk - .get(&(d.kind.into(), d.interface.into()))?, + max_disk: *max_disk.get(&(d.kind.into(), d.interface.into()))?, disk_type: d.kind.into(), disk_interface: d.interface.into(), }) diff --git a/lnvps_api/src/api/nostr_domain.rs b/lnvps_api/src/api/nostr_domain.rs new file mode 100644 index 0000000..83b781a --- /dev/null +++ b/lnvps_api/src/api/nostr_domain.rs @@ -0,0 +1,202 @@ +use crate::api::routes::{ApiData, ApiResult}; +use crate::nip98::Nip98Auth; +use crate::settings::Settings; +use chrono::{DateTime, Utc}; +use lnvps_db::{LNVPSNostrDb, LNVpsDb, NostrDomain, NostrDomainHandle}; +use rocket::serde::json::Json; +use rocket::serde::{Deserialize, Serialize}; +use rocket::{delete, get, post, routes, Route, State}; +use rocket_okapi::okapi::openapi3::OpenApi; +use rocket_okapi::settings::OpenApiSettings; +use rocket_okapi::{openapi, openapi_get_routes, openapi_routes, JsonSchema}; +use std::sync::Arc; + +pub fn routes() -> Vec { + routes![ + v1_nostr_domains, + v1_create_nostr_domain, + v1_list_nostr_domain_handles, + v1_create_nostr_domain_handle, + v1_delete_nostr_domain_handle + ] +} + +#[openapi(tag = "NIP05")] +#[get("/api/v1/nostr/domain")] +async fn v1_nostr_domains( + auth: Nip98Auth, + db: &State>, + settings: &State, +) -> ApiResult { + let pubkey = auth.event.pubkey.to_bytes(); + let uid = db.upsert_user(&pubkey).await?; + + let domains = db.list_domains(uid).await?; + ApiData::ok(ApiDomainsResponse { + domains: domains.into_iter().map(|d| d.into()).collect(), + cname: settings.nostr_address_host.clone().unwrap_or_default(), + }) +} + +#[openapi(tag = "NIP05")] +#[post("/api/v1/nostr/domain", format = "json", data = "")] +async fn v1_create_nostr_domain( + auth: Nip98Auth, + db: &State>, + data: Json, +) -> ApiResult { + let pubkey = auth.event.pubkey.to_bytes(); + let uid = db.upsert_user(&pubkey).await?; + + let mut dom = NostrDomain { + owner_id: uid, + name: data.name.clone(), + ..Default::default() + }; + let dom_id = db.insert_domain(&dom).await?; + dom.id = dom_id; + + ApiData::ok(dom.into()) +} + +#[openapi(tag = "NIP05")] +#[get("/api/v1/nostr/domain//handle")] +async fn v1_list_nostr_domain_handles( + auth: Nip98Auth, + db: &State>, + dom: u64, +) -> ApiResult> { + let pubkey = auth.event.pubkey.to_bytes(); + let uid = db.upsert_user(&pubkey).await?; + + let domain = db.get_domain(dom).await?; + if domain.owner_id != uid { + return ApiData::err("Access denied"); + } + + let handles = db.list_handles(domain.id).await?; + ApiData::ok(handles.into_iter().map(|h| h.into()).collect()) +} + +#[openapi(tag = "NIP05")] +#[post("/api/v1/nostr/domain//handle", format = "json", data = "")] +async fn v1_create_nostr_domain_handle( + auth: Nip98Auth, + db: &State>, + dom: u64, + data: Json, +) -> ApiResult { + let pubkey = auth.event.pubkey.to_bytes(); + let uid = db.upsert_user(&pubkey).await?; + + let domain = db.get_domain(dom).await?; + if domain.owner_id != uid { + return ApiData::err("Access denied"); + } + + let h_pubkey = hex::decode(&data.pubkey)?; + if h_pubkey.len() != 32 { + return ApiData::err("Invalid public key"); + } + + let mut handle = NostrDomainHandle { + domain_id: domain.id, + handle: data.name.clone(), + pubkey: h_pubkey, + ..Default::default() + }; + let id = db.insert_handle(&handle).await?; + handle.id = id; + + ApiData::ok(handle.into()) +} + +#[openapi(tag = "NIP05")] +#[delete("/api/v1/nostr/domain//handle/")] +async fn v1_delete_nostr_domain_handle( + auth: Nip98Auth, + db: &State>, + dom: u64, + handle: u64, +) -> ApiResult<()> { + let pubkey = auth.event.pubkey.to_bytes(); + let uid = db.upsert_user(&pubkey).await?; + + let domain = db.get_domain(dom).await?; + if domain.owner_id != uid { + return ApiData::err("Access denied"); + } + db.delete_handle(handle).await?; + ApiData::ok(()) +} + +#[derive(Deserialize, JsonSchema)] +struct NameRequest { + pub name: String, +} + +#[derive(Deserialize, JsonSchema)] +struct HandleRequest { + pub pubkey: String, + pub name: String, +} + +#[derive(Serialize, JsonSchema)] +struct ApiNostrDomain { + pub id: u64, + pub name: String, + pub enabled: bool, + pub handles: u64, + pub created: DateTime, + pub relays: Vec, +} + +impl From for ApiNostrDomain { + fn from(value: NostrDomain) -> Self { + Self { + id: value.id, + name: value.name, + enabled: value.enabled, + handles: value.handles as u64, + created: value.created, + relays: if let Some(r) = value.relays { + r.split(',').map(|s| s.to_string()).collect() + } else { + vec![] + }, + } + } +} + +#[derive(Serialize, JsonSchema)] +struct ApiNostrDomainHandle { + pub id: u64, + pub domain_id: u64, + pub handle: String, + pub created: DateTime, + pub pubkey: String, + pub relays: Vec, +} + +impl From for ApiNostrDomainHandle { + fn from(value: NostrDomainHandle) -> Self { + Self { + id: value.id, + domain_id: value.domain_id, + created: value.created, + handle: value.handle, + pubkey: hex::encode(value.pubkey), + relays: if let Some(r) = value.relays { + r.split(',').map(|s| s.to_string()).collect() + } else { + vec![] + }, + } + } +} + +#[derive(Serialize, JsonSchema)] +struct ApiDomainsResponse { + pub domains: Vec, + pub cname: String, +} diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 98c40af..d786c71 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -25,7 +25,7 @@ use rocket::{get, patch, post, routes, Responder, Route, State}; use rocket_okapi::gen::OpenApiGenerator; use rocket_okapi::okapi::openapi3::Responses; use rocket_okapi::response::OpenApiResponderInner; -use rocket_okapi::{openapi, openapi_get_routes}; +use rocket_okapi::{openapi, openapi_get_routes, openapi_routes}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ssh_key::PublicKey; @@ -38,7 +38,7 @@ use tokio::sync::mpsc::{Sender, UnboundedSender}; pub fn routes() -> Vec { let mut routes = vec![]; - routes.append(&mut openapi_get_routes![ + let mut api_routes = openapi_get_routes![ v1_get_account, v1_patch_account, v1_list_vms, @@ -59,17 +59,20 @@ pub fn routes() -> Vec { v1_custom_template_calc, v1_create_custom_vm_order, v1_get_payment_methods - ]); + ]; + #[cfg(feature = "nostr-domain")] + api_routes.append(&mut super::nostr_domain::routes()); + routes.append(&mut api_routes); routes.append(&mut routes![v1_terminal_proxy]); routes } -type ApiResult = Result>, ApiError>; +pub type ApiResult = Result>, ApiError>; #[derive(Serialize, Deserialize, JsonSchema)] -struct ApiData { +pub struct ApiData { pub data: T, } @@ -84,7 +87,7 @@ impl ApiData { #[derive(Serialize, Deserialize, JsonSchema, Responder)] #[response(status = 500)] -struct ApiError { +pub struct ApiError { pub error: String, } diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index 07af928..d1c7cf5 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -1,16 +1,16 @@ use anyhow::Error; use clap::Parser; use config::{Config, File}; -use lnvps::api; -use lnvps::cors::CORS; -use lnvps::data_migration::run_data_migrations; -use lnvps::dvm::start_dvms; -use lnvps::exchange::{DefaultRateCache, ExchangeRateService}; -use lnvps::lightning::get_node; -use lnvps::payments::listen_all_payments; -use lnvps::settings::Settings; -use lnvps::status::VmStateCache; -use lnvps::worker::{WorkJob, Worker}; +use lnvps_api::api; +use lnvps_api::data_migration::run_data_migrations; +use lnvps_api::dvm::start_dvms; +use lnvps_api::exchange::{DefaultRateCache, ExchangeRateService}; +use lnvps_api::lightning::get_node; +use lnvps_api::payments::listen_all_payments; +use lnvps_api::settings::Settings; +use lnvps_api::status::VmStateCache; +use lnvps_api::worker::{WorkJob, Worker}; +use lnvps_common::CORS; use lnvps_db::{LNVpsDb, LNVpsDbMysql}; use log::error; use nostr::Keys; @@ -168,7 +168,7 @@ async fn main() -> Result<(), Error> { .launch() .await { - error!("{}", e); + error!("{:?}", e); } Ok(()) diff --git a/lnvps_api/src/data_migration/ip6_init.rs b/lnvps_api/src/data_migration/ip6_init.rs index a6d93a4..377bb5e 100644 --- a/lnvps_api/src/data_migration/ip6_init.rs +++ b/lnvps_api/src/data_migration/ip6_init.rs @@ -1,13 +1,13 @@ use crate::data_migration::DataMigration; use crate::provisioner::{LNVpsProvisioner, NetworkProvisioner}; +use chrono::Utc; use ipnetwork::IpNetwork; use lnvps_db::LNVpsDb; +use log::info; use std::future::Future; use std::pin::Pin; use std::str::FromStr; use std::sync::Arc; -use chrono::Utc; -use log::info; pub struct Ip6InitDataMigration { db: Arc, @@ -35,11 +35,10 @@ impl DataMigration for Ip6InitDataMigration { let ips = db.list_vm_ip_assignments(vm.id).await?; // if no ipv6 address is picked already pick one if ips.iter().all(|i| { - IpNetwork::from_str(&i.ip) - .map(|i| i.is_ipv4()) - .unwrap_or(false) - }) - { + IpNetwork::from_str(&i.ip) + .map(|i| i.is_ipv4()) + .unwrap_or(false) + }) { let ips_pick = net.pick_ip_for_region(host.region_id).await?; if let Some(mut v6) = ips_pick.ip6 { info!("Assigning ip {} to vm {}", v6.ip, vm.id); diff --git a/lnvps_api/src/data_migration/mod.rs b/lnvps_api/src/data_migration/mod.rs index d8482c7..c050dc7 100644 --- a/lnvps_api/src/data_migration/mod.rs +++ b/lnvps_api/src/data_migration/mod.rs @@ -1,4 +1,6 @@ use crate::data_migration::dns::DnsDataMigration; +use crate::data_migration::ip6_init::Ip6InitDataMigration; +use crate::provisioner::LNVpsProvisioner; use crate::settings::Settings; use anyhow::Result; use lnvps_db::LNVpsDb; @@ -6,8 +8,6 @@ use log::{error, info}; use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use crate::data_migration::ip6_init::Ip6InitDataMigration; -use crate::provisioner::LNVpsProvisioner; mod dns; mod ip6_init; @@ -17,9 +17,16 @@ pub trait DataMigration: Send + Sync { fn migrate(&self) -> Pin> + Send>>; } -pub async fn run_data_migrations(db: Arc, lnvps: Arc, settings: &Settings) -> Result<()> { +pub async fn run_data_migrations( + db: Arc, + lnvps: Arc, + settings: &Settings, +) -> Result<()> { let mut migrations: Vec> = vec![]; - migrations.push(Box::new(Ip6InitDataMigration::new(db.clone(), lnvps.clone()))); + migrations.push(Box::new(Ip6InitDataMigration::new( + db.clone(), + lnvps.clone(), + ))); if let Some(d) = DnsDataMigration::new(db.clone(), settings) { migrations.push(Box::new(d)); diff --git a/lnvps_api/src/dns/mod.rs b/lnvps_api/src/dns/mod.rs index af9c103..7e45862 100644 --- a/lnvps_api/src/dns/mod.rs +++ b/lnvps_api/src/dns/mod.rs @@ -6,9 +6,9 @@ use std::str::FromStr; #[cfg(feature = "cloudflare")] mod cloudflare; +use crate::provisioner::NetworkProvisioner; #[cfg(feature = "cloudflare")] pub use cloudflare::*; -use crate::provisioner::NetworkProvisioner; #[async_trait] pub trait DnsServer: Send + Sync { diff --git a/lnvps_api/src/host/mod.rs b/lnvps_api/src/host/mod.rs index df45327..a2ed836 100644 --- a/lnvps_api/src/host/mod.rs +++ b/lnvps_api/src/host/mod.rs @@ -221,10 +221,13 @@ pub struct VmHostDiskInfo { #[cfg(test)] mod tests { - use chrono::Utc; - use lnvps_db::{DiskInterface, DiskType, IpRange, IpRangeAllocationMode, OsDistribution, UserSshKey, Vm, VmHost, VmHostDisk, VmIpAssignment, VmOsImage, VmTemplate}; - use crate::{GB, TB}; use crate::host::FullVmInfo; + use crate::{GB, TB}; + use chrono::Utc; + use lnvps_db::{ + DiskInterface, DiskType, IpRange, IpRangeAllocationMode, OsDistribution, UserSshKey, Vm, + VmHost, VmHostDisk, VmIpAssignment, VmOsImage, VmTemplate, + }; pub fn mock_full_vm() -> FullVmInfo { let template = VmTemplate { @@ -367,4 +370,4 @@ mod tests { }, } } -} \ No newline at end of file +} diff --git a/lnvps_api/src/lib.rs b/lnvps_api/src/lib.rs index 7537386..54f4f0f 100644 --- a/lnvps_api/src/lib.rs +++ b/lnvps_api/src/lib.rs @@ -1,5 +1,4 @@ pub mod api; -pub mod cors; pub mod data_migration; pub mod dns; pub mod exchange; diff --git a/lnvps_api/src/mocks.rs b/lnvps_api/src/mocks.rs index 694b461..846c437 100644 --- a/lnvps_api/src/mocks.rs +++ b/lnvps_api/src/mocks.rs @@ -11,10 +11,10 @@ use anyhow::{anyhow, bail, ensure, Context}; use chrono::{DateTime, TimeDelta, Utc}; use fedimint_tonic_lnd::tonic::codegen::tokio_stream::Stream; use lnvps_db::{ - async_trait, AccessPolicy, DiskInterface, DiskType, IpRange, IpRangeAllocationMode, LNVpsDb, - OsDistribution, User, UserSshKey, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, - VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostKind, VmHostRegion, - VmIpAssignment, VmOsImage, VmPayment, VmTemplate, + async_trait, AccessPolicy, DiskInterface, DiskType, IpRange, IpRangeAllocationMode, + LNVPSNostrDb, LNVpsDb, NostrDomain, NostrDomainHandle, OsDistribution, User, UserSshKey, Vm, + VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, + VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; use std::collections::HashMap; use std::ops::Add; @@ -206,6 +206,57 @@ impl Default for MockDb { } } +#[async_trait] +impl LNVPSNostrDb for MockDb { + async fn get_handle(&self, handle_id: u64) -> anyhow::Result { + todo!() + } + + async fn get_handle_by_name( + &self, + domain_id: u64, + handle: &str, + ) -> anyhow::Result { + todo!() + } + + async fn insert_handle(&self, handle: &NostrDomainHandle) -> anyhow::Result { + todo!() + } + + async fn update_handle(&self, handle: &NostrDomainHandle) -> anyhow::Result<()> { + todo!() + } + + async fn delete_handle(&self, handle_id: u64) -> anyhow::Result<()> { + todo!() + } + + async fn list_handles(&self, domain_id: u64) -> anyhow::Result> { + todo!() + } + + async fn get_domain(&self, id: u64) -> anyhow::Result { + todo!() + } + + async fn get_domain_by_name(&self, name: &str) -> anyhow::Result { + todo!() + } + + async fn list_domains(&self, owner_id: u64) -> anyhow::Result> { + todo!() + } + + async fn insert_domain(&self, domain: &NostrDomain) -> anyhow::Result { + todo!() + } + + async fn delete_domain(&self, domain_id: u64) -> anyhow::Result<()> { + todo!() + } +} + #[async_trait] impl LNVpsDb for MockDb { async fn migrate(&self) -> anyhow::Result<()> { diff --git a/lnvps_api/src/router/mod.rs b/lnvps_api/src/router/mod.rs index 5ac1082..781b72b 100644 --- a/lnvps_api/src/router/mod.rs +++ b/lnvps_api/src/router/mod.rs @@ -30,7 +30,10 @@ pub struct ArpEntry { impl ArpEntry { pub fn new(vm: &Vm, ip: &VmIpAssignment, interface: Option) -> Result { - ensure!(vm.mac_address != "ff:ff:ff:ff:ff:ff", "MAC address is invalid because its blank"); + ensure!( + vm.mac_address != "ff:ff:ff:ff:ff:ff", + "MAC address is invalid because its blank" + ); Ok(Self { id: ip.arp_ref.clone(), address: ip.ip.clone(), diff --git a/lnvps_api/src/router/ovh.rs b/lnvps_api/src/router/ovh.rs index 8955c4b..0bd25b3 100644 --- a/lnvps_api/src/router/ovh.rs +++ b/lnvps_api/src/router/ovh.rs @@ -351,5 +351,5 @@ enum OvhTaskFunction { MoveVirtualMac, VirtualMacAdd, VirtualMacDelete, - RemoveVirtualMac + RemoveVirtualMac, } diff --git a/lnvps_api/src/settings.rs b/lnvps_api/src/settings.rs index 4aa9e89..ea6ba41 100644 --- a/lnvps_api/src/settings.rs +++ b/lnvps_api/src/settings.rs @@ -50,6 +50,9 @@ pub struct Settings { #[serde(default)] /// Tax rates to change per country as a percent of the amount pub tax_rate: HashMap, + + /// public host of lnvps_nostr service + pub nostr_address_host: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -237,5 +240,6 @@ pub fn mock_settings() -> Settings { nostr: None, revolut: None, tax_rate: HashMap::from([(CountryCode::IRL, 23.0), (CountryCode::USA, 1.0)]), + nostr_address_host: None, } } diff --git a/lnvps_common/Cargo.toml b/lnvps_common/Cargo.toml new file mode 100644 index 0000000..cbe15db --- /dev/null +++ b/lnvps_common/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "lnvps_common" +version = "0.1.0" +edition = "2024" + +[dependencies] +rocket.workspace = true \ No newline at end of file diff --git a/lnvps_api/src/cors.rs b/lnvps_common/src/cors.rs similarity index 100% rename from lnvps_api/src/cors.rs rename to lnvps_common/src/cors.rs diff --git a/lnvps_common/src/lib.rs b/lnvps_common/src/lib.rs new file mode 100644 index 0000000..072f95e --- /dev/null +++ b/lnvps_common/src/lib.rs @@ -0,0 +1,2 @@ +pub mod cors; +pub use cors::*; diff --git a/lnvps_db/Cargo.toml b/lnvps_db/Cargo.toml index 285b434..b08ffaf 100644 --- a/lnvps_db/Cargo.toml +++ b/lnvps_db/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [features] default = ["mysql"] mysql = ["sqlx/mysql"] +nostr-domain = [] [dependencies] anyhow.workspace = true diff --git a/lnvps_db/migrations/20250402115943_nostr_address.sql b/lnvps_db/migrations/20250402115943_nostr_address.sql new file mode 100644 index 0000000..fe397b9 --- /dev/null +++ b/lnvps_db/migrations/20250402115943_nostr_address.sql @@ -0,0 +1,25 @@ +-- Add migration script here +create table nostr_domain +( + id integer unsigned not null auto_increment primary key, + owner_id integer unsigned not null, + name varchar(200) not null, + enabled bit(1) not null default 0, + created timestamp not null default current_timestamp, + relays varchar(1024), + + unique key ix_domain_unique (name), + constraint fk_nostr_domain_user foreign key (owner_id) references users (id) +); +create table nostr_domain_handle +( + id integer unsigned not null auto_increment primary key, + domain_id integer unsigned not null, + handle varchar(100) not null, + created timestamp not null default current_timestamp, + pubkey binary(32) not null, + relays varchar(1024), + + unique key ix_domain_handle_unique (domain_id, handle), + constraint fk_nostr_domain_handle_domain foreign key (domain_id) references nostr_domain (id) on delete cascade +) \ No newline at end of file diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index b50c655..51dbf33 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -10,7 +10,7 @@ pub use mysql::*; pub use async_trait::async_trait; #[async_trait] -pub trait LNVpsDb: Sync + Send { +pub trait LNVpsDb: LNVPSNostrDb + Send + Sync { /// Migrate database async fn migrate(&self) -> Result<()>; @@ -173,3 +173,40 @@ pub trait LNVpsDb: Sync + Send { /// Get access policy async fn get_access_policy(&self, access_policy_id: u64) -> Result; } + +#[cfg(feature = "nostr-domain")] +#[async_trait] +pub trait LNVPSNostrDb: Sync + Send { + /// Get single handle for a domain + async fn get_handle(&self, handle_id: u64) -> Result; + + /// Get single handle for a domain + async fn get_handle_by_name(&self, domain_id: u64, handle: &str) -> Result; + + /// Insert a new handle + async fn insert_handle(&self, handle: &NostrDomainHandle) -> Result; + + /// Update an existing domain handle + async fn update_handle(&self, handle: &NostrDomainHandle) -> Result<()>; + + /// Delete handle entry + async fn delete_handle(&self, handle_id: u64) -> Result<()>; + + /// List handles + async fn list_handles(&self, domain_id: u64) -> Result>; + + /// Get domain object by id + async fn get_domain(&self, id: u64) -> Result; + + /// Get domain object by name + async fn get_domain_by_name(&self, name: &str) -> Result; + + /// List domains owned by a user + async fn list_domains(&self, owner_id: u64) -> Result>; + + /// Insert a new domain + async fn insert_domain(&self, domain: &NostrDomain) -> Result; + + /// Delete a domain + async fn delete_domain(&self, domain_id: u64) -> Result<()>; +} diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 9a0016b..4937943 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -489,3 +489,24 @@ impl FromStr for PaymentMethod { } } } + +#[derive(FromRow, Clone, Debug, Default)] +pub struct NostrDomain { + pub id: u64, + pub owner_id: u64, + pub name: String, + pub created: DateTime, + pub enabled: bool, + pub relays: Option, + pub handles: i64, +} + +#[derive(FromRow, Clone, Debug, Default)] +pub struct NostrDomainHandle { + pub id: u64, + pub domain_id: u64, + pub handle: String, + pub created: DateTime, + pub pubkey: Vec, + pub relays: Option, +} diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 4de2ecf..c15b6b1 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,7 +1,7 @@ use crate::{ - AccessPolicy, IpRange, LNVpsDb, Router, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing, - VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, - VmOsImage, VmPayment, VmTemplate, + AccessPolicy, IpRange, LNVPSNostrDb, LNVpsDb, NostrDomain, NostrDomainHandle, Router, User, + UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHost, + VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; use anyhow::{bail, Error, Result}; use async_trait::async_trait; @@ -556,3 +556,115 @@ impl LNVpsDb for LNVpsDbMysql { .map_err(Error::new) } } + +#[cfg(feature = "nostr-domain")] +#[async_trait] +impl LNVPSNostrDb for LNVpsDbMysql { + async fn get_handle(&self, handle_id: u64) -> Result { + sqlx::query_as("select * from nostr_domain_handle where id=?") + .bind(handle_id) + .fetch_one(&self.db) + .await + .map_err(Error::new) + } + + async fn get_handle_by_name(&self, domain_id: u64, handle: &str) -> Result { + sqlx::query_as("select * from nostr_domain_handle where domain_id=? and handle=?") + .bind(domain_id) + .bind(handle) + .fetch_one(&self.db) + .await + .map_err(Error::new) + } + + async fn insert_handle(&self, handle: &NostrDomainHandle) -> Result { + Ok( + sqlx::query( + "insert into nostr_domain_handle(domain_id,handle,pubkey,relays) values(?,?,?,?) returning id", + ) + .bind(handle.domain_id) + .bind(&handle.handle) + .bind(&handle.pubkey) + .bind(&handle.relays) + .fetch_one(&self.db) + .await + .map_err(Error::new)? + .try_get(0)?, + ) + } + + async fn update_handle(&self, handle: &NostrDomainHandle) -> Result<()> { + sqlx::query("update nostr_domain_handle set handle=?,pubkey=?,relays=? where id=?") + .bind(&handle.handle) + .bind(&handle.pubkey) + .bind(&handle.relays) + .bind(handle.id) + .execute(&self.db) + .await?; + Ok(()) + } + + async fn delete_handle(&self, handle_id: u64) -> Result<()> { + sqlx::query("delete from nostr_domain_handle where id=?") + .bind(handle_id) + .execute(&self.db) + .await?; + Ok(()) + } + + async fn list_handles(&self, domain_id: u64) -> Result> { + sqlx::query_as("select * from nostr_domain_handle where domain_id=?") + .bind(domain_id) + .fetch_all(&self.db) + .await + .map_err(Error::new) + } + + async fn get_domain(&self, id: u64) -> Result { + sqlx::query_as("select *,(select count(1) from nostr_domain_handle where domain_id=nostr_domain.id) handles from nostr_domain where id=?") + .bind(id) + .fetch_one(&self.db) + .await + .map_err(Error::new) + } + + async fn get_domain_by_name(&self, name: &str) -> Result { + sqlx::query_as("select *,(select count(1) from nostr_domain_handle where domain_id=nostr_domain.id) handles from nostr_domain where name=?") + .bind(name) + .fetch_one(&self.db) + .await + .map_err(Error::new) + } + + async fn list_domains(&self, owner_id: u64) -> Result> { + sqlx::query_as("select *,(select count(1) from nostr_domain_handle where domain_id=nostr_domain.id) handles from nostr_domain where owner_id=?") + .bind(owner_id) + .fetch_all(&self.db) + .await + .map_err(Error::new) + } + + async fn insert_domain(&self, domain: &NostrDomain) -> Result { + Ok( + sqlx::query( + "insert into nostr_domain(owner_id,name,relays) values(?,?,?) returning id", + ) + .bind(domain.owner_id) + .bind(&domain.name) + .bind(&domain.relays) + .fetch_one(&self.db) + .await + .map_err(Error::new)? + .try_get(0)?, + ) + } + + async fn delete_domain(&self, domain_id: u64) -> Result<()> { + sqlx::query("update nostr_domain set deleted = current_timestamp where id = ?") + .bind(domain_id) + .fetch_one(&self.db) + .await + .map_err(Error::new)?; + Ok(()) + } +} diff --git a/lnvps_nostr/Cargo.toml b/lnvps_nostr/Cargo.toml index f3dedff..11a7895 100644 --- a/lnvps_nostr/Cargo.toml +++ b/lnvps_nostr/Cargo.toml @@ -4,4 +4,14 @@ version = "0.1.0" edition = "2024" [dependencies] -lnvps_db = { path = "../lnvps_db" } \ No newline at end of file +lnvps_db = { path = "../lnvps_db", features = ["nostr-domain"] } +lnvps_common = { path = "../lnvps_common" } +env_logger.workspace = true +log.workspace = true +anyhow.workspace = true +tokio.workspace = true +serde.workspace = true +config.workspace = true +serde_json.workspace = true +rocket.workspace = true +hex.workspace = true diff --git a/lnvps_nostr/README.md b/lnvps_nostr/README.md new file mode 100644 index 0000000..9b9c12f --- /dev/null +++ b/lnvps_nostr/README.md @@ -0,0 +1,3 @@ +# LNVPS Nostr Services + +A simple webserver hosting various nostr based services for lnvps.net \ No newline at end of file diff --git a/lnvps_nostr/config.yaml b/lnvps_nostr/config.yaml new file mode 100644 index 0000000..094935d --- /dev/null +++ b/lnvps_nostr/config.yaml @@ -0,0 +1,5 @@ +# Connection string to lnvps database +db: "mysql://root:root@localhost:3376/lnvps" + +# Listen address for http server +listen: "127.0.0.1:8001" \ No newline at end of file diff --git a/lnvps_nostr/src/main.rs b/lnvps_nostr/src/main.rs index e7a11a9..d7deac4 100644 --- a/lnvps_nostr/src/main.rs +++ b/lnvps_nostr/src/main.rs @@ -1,3 +1,65 @@ -fn main() { - println!("Hello, world!"); +mod routes; + +use crate::routes::routes; +use anyhow::Result; +use config::{Config, File}; +use lnvps_common::CORS; +use lnvps_db::{LNVPSNostrDb, LNVpsDbMysql}; +use log::error; +use rocket::http::Method; +use serde::Deserialize; +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Clone, Deserialize)] +struct Settings { + /// Database connection string + db: String, + /// Listen address for http server + listen: Option, +} + +#[rocket::main] +async fn main() -> Result<()> { + env_logger::init(); + + let settings: Settings = Config::builder() + .add_source(File::from(PathBuf::from("config.yaml"))) + .build()? + .try_deserialize()?; + + // Connect database + let db = LNVpsDbMysql::new(&settings.db).await?; + let db: Arc = Arc::new(db); + + let mut config = rocket::Config::default(); + let ip: SocketAddr = match &settings.listen { + Some(i) => i.parse()?, + None => SocketAddr::new(IpAddr::from([0, 0, 0, 0]), 8000), + }; + config.address = ip.ip(); + config.port = ip.port(); + + if let Err(e) = rocket::Rocket::custom(config) + .manage(db.clone()) + .manage(settings.clone()) + .attach(CORS) + .mount("/", routes()) + .mount( + "/", + vec![rocket::Route::ranked( + isize::MAX, + Method::Options, + "/", + CORS, + )], + ) + .launch() + .await + { + error!("{}", e); + } + + Ok(()) } diff --git a/lnvps_nostr/src/routes.rs b/lnvps_nostr/src/routes.rs new file mode 100644 index 0000000..108d242 --- /dev/null +++ b/lnvps_nostr/src/routes.rs @@ -0,0 +1,65 @@ +use lnvps_db::LNVPSNostrDb; +use log::info; +use rocket::request::{FromRequest, Outcome}; +use rocket::serde::json::Json; +use rocket::{Request, Route, State, routes}; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; + +pub fn routes() -> Vec { + routes![nostr_address] +} + +#[derive(Serialize)] +struct NostrJson { + pub names: HashMap, + pub relays: HashMap>, +} + +struct HostInfo<'r> { + pub host: Option<&'r str>, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for HostInfo<'r> { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> Outcome { + Outcome::Success(HostInfo { + host: request.host().map(|h| h.domain().as_str()), + }) + } +} + +#[rocket::get("/.well-known/nostr.json?")] +async fn nostr_address( + host: HostInfo<'_>, + db: &State>, + name: Option<&str>, +) -> Result, &'static str> { + let name = name.unwrap_or("_"); + let host = host.host.unwrap_or("lnvps.net"); + info!("Got request for {} on host {}", name, host); + let domain = db + .get_domain_by_name(host) + .await + .map_err(|_| "Domain not found")?; + let handle = db + .get_handle_by_name(domain.id, name) + .await + .map_err(|_| "Handle not found")?; + + let pubkey_hex = hex::encode(handle.pubkey); + let relays = if let Some(r) = handle.relays { + r.split(",").map(|x| x.to_string()).collect() + } else if let Some(r) = domain.relays { + r.split(",").map(|x| x.to_string()).collect() + } else { + vec![] + }; + Ok(Json(NostrJson { + names: HashMap::from([(name.to_string(), pubkey_hex.clone())]), + relays: HashMap::from([(pubkey_hex, relays)]), + })) +}