feat: nostr domain hosting
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-04-03 12:56:20 +01:00
parent a4850b4e06
commit c432f603ec
32 changed files with 724 additions and 70 deletions

20
Cargo.lock generated
View File

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

View File

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

View File

@ -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"]
ENTRYPOINT ["./bin/lnvps_api"]

View File

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

View File

@ -1,6 +1,8 @@
use rocket::Route;
mod model;
#[cfg(feature = "nostr-domain")]
mod nostr_domain;
mod routes;
mod webhook;

View File

@ -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<lnvps_db::UserSshKey> for ApiUserSshKey {
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[derive(Serialize, JsonSchema)]
pub struct ApiVmIpAssignment {
pub id: u64,
pub ip: String,
@ -133,7 +133,7 @@ impl From<DiskInterface> for lnvps_db::DiskInterface {
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[derive(Serialize, JsonSchema)]
pub struct ApiTemplatesResponse {
pub templates: Vec<ApiVmTemplate>,
#[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(),
})

View File

@ -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<Route> {
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<Arc<dyn LNVpsDb>>,
settings: &State<Settings>,
) -> ApiResult<ApiDomainsResponse> {
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 = "<data>")]
async fn v1_create_nostr_domain(
auth: Nip98Auth,
db: &State<Arc<dyn LNVpsDb>>,
data: Json<NameRequest>,
) -> ApiResult<ApiNostrDomain> {
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/<dom>/handle")]
async fn v1_list_nostr_domain_handles(
auth: Nip98Auth,
db: &State<Arc<dyn LNVpsDb>>,
dom: u64,
) -> ApiResult<Vec<ApiNostrDomainHandle>> {
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/<dom>/handle", format = "json", data = "<data>")]
async fn v1_create_nostr_domain_handle(
auth: Nip98Auth,
db: &State<Arc<dyn LNVpsDb>>,
dom: u64,
data: Json<HandleRequest>,
) -> ApiResult<ApiNostrDomainHandle> {
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/<dom>/handle/<handle>")]
async fn v1_delete_nostr_domain_handle(
auth: Nip98Auth,
db: &State<Arc<dyn LNVpsDb>>,
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<Utc>,
pub relays: Vec<String>,
}
impl From<NostrDomain> 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<Utc>,
pub pubkey: String,
pub relays: Vec<String>,
}
impl From<NostrDomainHandle> 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<ApiNostrDomain>,
pub cname: String,
}

View File

@ -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<Route> {
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<Route> {
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<T> = Result<Json<ApiData<T>>, ApiError>;
pub type ApiResult<T> = Result<Json<ApiData<T>>, ApiError>;
#[derive(Serialize, Deserialize, JsonSchema)]
struct ApiData<T: Serialize> {
pub struct ApiData<T: Serialize> {
pub data: T,
}
@ -84,7 +87,7 @@ impl<T: Serialize> ApiData<T> {
#[derive(Serialize, Deserialize, JsonSchema, Responder)]
#[response(status = 500)]
struct ApiError {
pub struct ApiError {
pub error: String,
}

View File

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

View File

@ -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<dyn LNVpsDb>,
@ -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);

View File

@ -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<Box<dyn Future<Output = Result<()>> + Send>>;
}
pub async fn run_data_migrations(db: Arc<dyn LNVpsDb>, lnvps: Arc<LNVpsProvisioner>, settings: &Settings) -> Result<()> {
pub async fn run_data_migrations(
db: Arc<dyn LNVpsDb>,
lnvps: Arc<LNVpsProvisioner>,
settings: &Settings,
) -> Result<()> {
let mut migrations: Vec<Box<dyn DataMigration>> = 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));

View File

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

View File

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

View File

@ -1,5 +1,4 @@
pub mod api;
pub mod cors;
pub mod data_migration;
pub mod dns;
pub mod exchange;

View File

@ -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<NostrDomainHandle> {
todo!()
}
async fn get_handle_by_name(
&self,
domain_id: u64,
handle: &str,
) -> anyhow::Result<NostrDomainHandle> {
todo!()
}
async fn insert_handle(&self, handle: &NostrDomainHandle) -> anyhow::Result<u64> {
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<Vec<NostrDomainHandle>> {
todo!()
}
async fn get_domain(&self, id: u64) -> anyhow::Result<NostrDomain> {
todo!()
}
async fn get_domain_by_name(&self, name: &str) -> anyhow::Result<NostrDomain> {
todo!()
}
async fn list_domains(&self, owner_id: u64) -> anyhow::Result<Vec<NostrDomain>> {
todo!()
}
async fn insert_domain(&self, domain: &NostrDomain) -> anyhow::Result<u64> {
todo!()
}
async fn delete_domain(&self, domain_id: u64) -> anyhow::Result<()> {
todo!()
}
}
#[async_trait]
impl LNVpsDb for MockDb {
async fn migrate(&self) -> anyhow::Result<()> {

View File

@ -30,7 +30,10 @@ pub struct ArpEntry {
impl ArpEntry {
pub fn new(vm: &Vm, ip: &VmIpAssignment, interface: Option<String>) -> Result<Self> {
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(),

View File

@ -351,5 +351,5 @@ enum OvhTaskFunction {
MoveVirtualMac,
VirtualMacAdd,
VirtualMacDelete,
RemoveVirtualMac
RemoveVirtualMac,
}

View File

@ -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<CountryCode, f32>,
/// public host of lnvps_nostr service
pub nostr_address_host: Option<String>,
}
#[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,
}
}

7
lnvps_common/Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[package]
name = "lnvps_common"
version = "0.1.0"
edition = "2024"
[dependencies]
rocket.workspace = true

2
lnvps_common/src/lib.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod cors;
pub use cors::*;

View File

@ -6,6 +6,7 @@ edition = "2021"
[features]
default = ["mysql"]
mysql = ["sqlx/mysql"]
nostr-domain = []
[dependencies]
anyhow.workspace = true

View File

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

View File

@ -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<AccessPolicy>;
}
#[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<NostrDomainHandle>;
/// Get single handle for a domain
async fn get_handle_by_name(&self, domain_id: u64, handle: &str) -> Result<NostrDomainHandle>;
/// Insert a new handle
async fn insert_handle(&self, handle: &NostrDomainHandle) -> Result<u64>;
/// 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<Vec<NostrDomainHandle>>;
/// Get domain object by id
async fn get_domain(&self, id: u64) -> Result<NostrDomain>;
/// Get domain object by name
async fn get_domain_by_name(&self, name: &str) -> Result<NostrDomain>;
/// List domains owned by a user
async fn list_domains(&self, owner_id: u64) -> Result<Vec<NostrDomain>>;
/// Insert a new domain
async fn insert_domain(&self, domain: &NostrDomain) -> Result<u64>;
/// Delete a domain
async fn delete_domain(&self, domain_id: u64) -> Result<()>;
}

View File

@ -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<Utc>,
pub enabled: bool,
pub relays: Option<String>,
pub handles: i64,
}
#[derive(FromRow, Clone, Debug, Default)]
pub struct NostrDomainHandle {
pub id: u64,
pub domain_id: u64,
pub handle: String,
pub created: DateTime<Utc>,
pub pubkey: Vec<u8>,
pub relays: Option<String>,
}

View File

@ -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<NostrDomainHandle> {
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<NostrDomainHandle> {
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<u64> {
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<Vec<NostrDomainHandle>> {
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<NostrDomain> {
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<NostrDomain> {
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<Vec<NostrDomain>> {
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<u64> {
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(())
}
}

View File

@ -4,4 +4,14 @@ version = "0.1.0"
edition = "2024"
[dependencies]
lnvps_db = { path = "../lnvps_db" }
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

3
lnvps_nostr/README.md Normal file
View File

@ -0,0 +1,3 @@
# LNVPS Nostr Services
A simple webserver hosting various nostr based services for lnvps.net

5
lnvps_nostr/config.yaml Normal file
View File

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

View File

@ -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<String>,
}
#[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<dyn LNVPSNostrDb> = 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,
"/<catch_all_options_route..>",
CORS,
)],
)
.launch()
.await
{
error!("{}", e);
}
Ok(())
}

65
lnvps_nostr/src/routes.rs Normal file
View File

@ -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<Route> {
routes![nostr_address]
}
#[derive(Serialize)]
struct NostrJson {
pub names: HashMap<String, String>,
pub relays: HashMap<String, Vec<String>>,
}
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<Self, Self::Error> {
Outcome::Success(HostInfo {
host: request.host().map(|h| h.domain().as_str()),
})
}
}
#[rocket::get("/.well-known/nostr.json?<name>")]
async fn nostr_address(
host: HostInfo<'_>,
db: &State<Arc<dyn LNVPSNostrDb>>,
name: Option<&str>,
) -> Result<Json<NostrJson>, &'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)]),
}))
}