feat: separate db models from api models

feat: generate openapi docs
This commit is contained in:
2025-02-25 15:06:43 +00:00
parent c61cfde5c1
commit 870995e1fc
16 changed files with 1188 additions and 868 deletions

1242
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,20 +15,22 @@ nostr-dm = ["dep:nostr-sdk"]
lnvps_db = { path = "lnvps_db" }
tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros"] }
anyhow = "1.0.83"
config = { version = "0.14.0", features = ["yaml"] }
config = { version = "0.15.8", features = ["yaml"] }
log = "0.4.21"
pretty_env_logger = "0.5.0"
serde = { version = "1.0.213", features = ["derive"] }
reqwest = { version = "0.12.8" }
serde_json = "1.0.132"
reqwest = { version = "0.12.8" }
rocket = { version = "0.5.1", features = ["json"] }
rocket_okapi = { version = "0.9.0", features = ["swagger", "rapidoc"] }
schemars = { version = "0.8.22", features = ["chrono"] }
chrono = { version = "0.4.38", features = ["serde"] }
nostr = { version = "0.37.0", default-features = false, features = ["std"] }
nostr = { version = "0.39.0", default-features = false, features = ["std"] }
base64 = { version = "0.22.1", features = ["alloc"] }
urlencoding = "2.1.3"
fedimint-tonic-lnd = { version = "0.2.0", default-features = false, features = ["invoicesrpc"] }
ipnetwork = "0.20.0"
rand = "0.8.5"
ipnetwork = "0.21.1"
rand = "0.9.0"
clap = { version = "4.5.21", features = ["derive"] }
ssh2 = "0.9.4"
ssh-key = "0.6.7"
@ -38,5 +40,7 @@ tokio-tungstenite = { version = "^0.21", features = ["native-tls"] }
native-tls = "0.2.12"
#nostr-dm
nostr-sdk = { version = "0.37.0", optional = true, default-features = false, features = ["nip44", "nip59"] }
nostr-sdk = { version = "0.39.0", optional = true, default-features = false, features = ["nip44", "nip59"] }

View File

@ -42,7 +42,7 @@
<div class="page">
<div class="header">
LNVPS
<img height="48" src="https://lnvps.net/logo.jpg" alt="logo"/>
<img height="48" width="48" src="https://lnvps.net/logo.jpg" alt="logo"/>
</div>
<hr/>
<p>%%_MESSAGE_%%</p>

View File

@ -10,8 +10,6 @@ mysql = ["sqlx/mysql"]
[dependencies]
anyhow = "1.0.83"
sqlx = { version = "0.8.2", features = ["chrono", "migrate", "runtime-tokio"] }
serde = { version = "1.0.213", features = ["derive"] }
serde_with = { version = "3.11.0", features = ["macros", "hex"] }
chrono = { version = "0.4.38", features = ["serde"] }
async-trait = "0.1.83"
url = "2.5.4"

View File

@ -1,63 +0,0 @@
use crate::{LNVpsDb, Vm, VmIpAssignment, VmTemplate};
use anyhow::Result;
use async_trait::async_trait;
use std::ops::Deref;
#[async_trait]
pub trait Hydrate<D> {
/// Load parent resources
async fn hydrate_up(&mut self, db: &D) -> Result<()>;
/// Load child resources
async fn hydrate_down(&mut self, db: &D) -> Result<()>;
}
#[async_trait]
impl<D: Deref<Target = dyn LNVpsDb> + Sync> Hydrate<D> for Vm {
async fn hydrate_up(&mut self, db: &D) -> Result<()> {
let image = db.get_os_image(self.image_id).await?;
let template = db.get_vm_template(self.template_id).await?;
let ssh_key = db.get_user_ssh_key(self.ssh_key_id).await?;
self.image = Some(image);
self.template = Some(template);
self.ssh_key = Some(ssh_key);
Ok(())
}
async fn hydrate_down(&mut self, db: &D) -> Result<()> {
//let payments = db.list_vm_payment(self.id).await?;
let ips = db.list_vm_ip_assignments(self.id).await?;
//self.payments = Some(payments);
self.ip_assignments = Some(ips);
Ok(())
}
}
#[async_trait]
impl<D: Deref<Target = dyn LNVpsDb> + Sync> Hydrate<D> for VmTemplate {
async fn hydrate_up(&mut self, db: &D) -> Result<()> {
let cost_plan = db.get_cost_plan(self.cost_plan_id).await?;
let region = db.get_host_region(self.region_id).await?;
self.cost_plan = Some(cost_plan);
self.region = Some(region);
Ok(())
}
async fn hydrate_down(&mut self, db: &D) -> Result<()> {
todo!()
}
}
#[async_trait]
impl<D: Deref<Target = dyn LNVpsDb> + Sync> Hydrate<D> for VmIpAssignment {
async fn hydrate_up(&mut self, db: &D) -> Result<()> {
self.ip_range = Some(db.get_ip_range(self.ip_range_id).await?);
Ok(())
}
async fn hydrate_down(&mut self, db: &D) -> Result<()> {
todo!()
}
}

View File

@ -1,7 +1,6 @@
use anyhow::Result;
use async_trait::async_trait;
pub mod hydrate;
mod model;
#[cfg(feature = "mysql")]
mod mysql;

View File

@ -1,61 +1,49 @@
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use sqlx::FromRow;
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use url::Url;
#[serde_as]
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
#[derive(FromRow, Clone, Debug)]
/// Users who buy VM's
pub struct User {
/// Unique ID of this user (database generated)
pub id: u64,
/// The nostr public key for this user
#[serde_as(as = "serde_with::hex::Hex")]
pub pubkey: Vec<u8>,
/// When this user first started using the service (first login)
pub created: DateTime<Utc>,
pub email: Option<String>,
pub contact_nip4: bool,
pub contact_nip17: bool,
pub contact_email: bool,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug, Default)]
#[derive(FromRow, Clone, Debug, Default)]
pub struct UserSshKey {
pub id: u64,
pub name: String,
pub user_id: u64,
pub created: DateTime<Utc>,
#[serde(skip_serializing)]
pub key_data: String,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub vms: Option<Vec<Vm>>,
}
#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type)]
#[derive(Clone, Debug, sqlx::Type)]
#[repr(u16)]
/// The type of VM host
pub enum VmHostKind {
Proxmox = 0,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
#[derive(FromRow, Clone, Debug)]
pub struct VmHostRegion {
pub id: u64,
pub name: String,
pub enabled: bool,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
#[derive(FromRow, Clone, Debug)]
/// A VM host
pub struct VmHost {
/// Unique id of this host
@ -78,7 +66,7 @@ pub struct VmHost {
pub api_token: String,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
#[derive(FromRow, Clone, Debug)]
pub struct VmHostDisk {
pub id: u64,
pub host_id: u64,
@ -89,8 +77,7 @@ pub struct VmHostDisk {
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type, Default)]
#[serde(rename_all = "lowercase")]
#[derive(Clone, Debug, sqlx::Type, Default)]
#[repr(u16)]
pub enum DiskType {
#[default]
@ -98,8 +85,7 @@ pub enum DiskType {
SSD = 1,
}
#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type, Default)]
#[serde(rename_all = "lowercase")]
#[derive(Clone, Debug, sqlx::Type, Default)]
#[repr(u16)]
pub enum DiskInterface {
#[default]
@ -108,7 +94,7 @@ pub enum DiskInterface {
PCIe = 2,
}
#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type, Default)]
#[derive(Clone, Debug, sqlx::Type, Default)]
#[repr(u16)]
pub enum OsDistribution {
#[default]
@ -124,7 +110,7 @@ pub enum OsDistribution {
/// OS Images are templates which are used as a basis for
/// provisioning new vms
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
#[derive(FromRow, Clone, Debug)]
pub struct VmOsImage {
pub id: u64,
pub distribution: OsDistribution,
@ -132,8 +118,6 @@ pub struct VmOsImage {
pub version: String,
pub enabled: bool,
pub release_date: DateTime<Utc>,
#[serde(skip_serializing)]
/// URL location of cloud image
pub url: String,
}
@ -158,7 +142,7 @@ impl Display for VmOsImage {
}
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
#[derive(FromRow, Clone, Debug)]
pub struct IpRange {
pub id: u64,
pub cidr: String,
@ -167,8 +151,7 @@ pub struct IpRange {
pub region_id: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[derive(Clone, Debug, sqlx::Type)]
#[repr(u16)]
pub enum VmCostPlanIntervalType {
Day = 0,
@ -176,7 +159,7 @@ pub enum VmCostPlanIntervalType {
Year = 2,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
#[derive(FromRow, Clone, Debug)]
pub struct VmCostPlan {
pub id: u64,
pub name: String,
@ -189,13 +172,12 @@ pub struct VmCostPlan {
/// Offers.
/// These are the same as the offers visible to customers
#[derive(Serialize, Deserialize, FromRow, Clone, Debug, Default)]
#[derive(FromRow, Clone, Debug, Default)]
pub struct VmTemplate {
pub id: u64,
pub name: String,
pub enabled: bool,
pub created: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<DateTime<Utc>>,
pub cpu: u16,
pub memory: u64,
@ -204,16 +186,9 @@ pub struct VmTemplate {
pub disk_interface: DiskInterface,
pub cost_plan_id: u64,
pub region_id: u64,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cost_plan: Option<VmCostPlan>,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<VmHostRegion>,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug, Default)]
#[derive(FromRow, Clone, Debug, Default)]
pub struct Vm {
/// Unique VM ID (Same in proxmox)
pub id: u64,
@ -237,35 +212,14 @@ pub struct Vm {
pub mac_address: String,
/// Is the VM deleted
pub deleted: bool,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<VmOsImage>,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<VmTemplate>,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_key: Option<UserSshKey>,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub payments: Option<Vec<VmPayment>>,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_assignments: Option<Vec<VmIpAssignment>>,
}
#[derive(Serialize, Deserialize, FromRow, Clone, Debug, Default)]
#[derive(FromRow, Clone, Debug, Default)]
pub struct VmIpAssignment {
pub id: u64,
pub vm_id: u64,
pub ip_range_id: u64,
pub ip: String,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_range: Option<IpRange>,
}
impl Display for VmIpAssignment {
@ -274,11 +228,9 @@ impl Display for VmIpAssignment {
}
}
#[serde_as]
#[derive(Serialize, Deserialize, FromRow, Clone, Debug, Default)]
#[derive(FromRow, Clone, Debug, Default)]
pub struct VmPayment {
/// Payment hash
#[serde_as(as = "serde_with::hex::Hex")]
pub id: Vec<u8>,
pub vm_id: u64,
pub created: DateTime<Utc>,
@ -288,11 +240,7 @@ pub struct VmPayment {
pub is_paid: bool,
/// Exchange rate
pub rate: f32,
/// Number of seconds this payment will add to vm expiry
#[serde(skip_serializing)]
pub time_value: u64,
#[serde(skip_serializing)]
pub settle_index: Option<u64>,
}

4
src/api/mod.rs Normal file
View File

@ -0,0 +1,4 @@
mod routes;
mod model;
pub use routes::routes;

277
src/api/model.rs Normal file
View File

@ -0,0 +1,277 @@
use nostr::util::hex;
use crate::status::VmState;
use chrono::{DateTime, Utc};
use lnvps_db::VmHostRegion;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiVmStatus {
/// Unique VM ID (Same in proxmox)
pub id: u64,
/// When the VM was created
pub created: DateTime<Utc>,
/// When the VM expires
pub expires: DateTime<Utc>,
/// Network MAC address
pub mac_address: String,
/// OS Image in use
pub image: ApiVmOsImage,
/// VM template
pub template: ApiVmTemplate,
/// SSH key attached to this VM
pub ssh_key: ApiUserSshKey,
/// IPs assigned to this VM
pub ip_assignments: Vec<ApiVmIpAssignment>,
/// Current running state of the VM
pub status: VmState,
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiUserSshKey {
pub id: u64,
pub name: String,
pub created: DateTime<Utc>,
}
impl From<lnvps_db::UserSshKey> for ApiUserSshKey {
fn from(ssh_key: lnvps_db::UserSshKey) -> Self {
ApiUserSshKey {
id: ssh_key.id,
name: ssh_key.name,
created: ssh_key.created,
}
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiVmIpAssignment {
pub id: u64,
pub ip: String,
pub range: String,
}
impl ApiVmIpAssignment {
pub fn from(ip: lnvps_db::VmIpAssignment, range: &lnvps_db::IpRange) -> Self {
ApiVmIpAssignment {
id: ip.id,
ip: ip.ip,
range: range.cidr.clone(),
}
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum DiskType {
HDD = 0,
SSD = 1,
}
impl From<lnvps_db::DiskType> for DiskType {
fn from(value: lnvps_db::DiskType) -> Self {
match value {
lnvps_db::DiskType::HDD => Self::HDD,
lnvps_db::DiskType::SSD => Self::SSD,
}
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum DiskInterface {
SATA = 0,
SCSI = 1,
PCIe = 2,
}
impl From<lnvps_db::DiskInterface> for DiskInterface {
fn from(value: lnvps_db::DiskInterface) -> Self {
match value {
lnvps_db::DiskInterface::SATA => Self::SATA,
lnvps_db::DiskInterface::SCSI => Self::SCSI,
lnvps_db::DiskInterface::PCIe => Self::PCIe,
}
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiVmTemplate {
pub id: u64,
pub name: String,
pub created: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<DateTime<Utc>>,
pub cpu: u16,
pub memory: u64,
pub disk_size: u64,
pub disk_type: DiskType,
pub disk_interface: DiskInterface,
pub cost_plan: ApiVmCostPlan,
pub region: ApiVmHostRegion,
}
impl ApiVmTemplate {
pub fn from(
template: lnvps_db::VmTemplate,
cost_plan: lnvps_db::VmCostPlan,
region: VmHostRegion,
) -> Self {
Self {
id: template.id,
name: template.name,
created: template.created,
expires: template.expires,
cpu: template.cpu,
memory: template.memory,
disk_size: template.disk_size,
disk_type: template.disk_type.into(),
disk_interface: template.disk_interface.into(),
cost_plan: ApiVmCostPlan {
id: cost_plan.id,
name: cost_plan.name,
amount: cost_plan.amount,
currency: cost_plan.currency,
interval_amount: cost_plan.interval_amount,
interval_type: cost_plan.interval_type.into(),
},
region: ApiVmHostRegion {
id: region.id,
name: region.name,
},
}
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ApiVmCostPlanIntervalType {
Day = 0,
Month = 1,
Year = 2,
}
impl From<lnvps_db::VmCostPlanIntervalType> for ApiVmCostPlanIntervalType {
fn from(value: lnvps_db::VmCostPlanIntervalType) -> Self {
match value {
lnvps_db::VmCostPlanIntervalType::Day => Self::Day,
lnvps_db::VmCostPlanIntervalType::Month => Self::Month,
lnvps_db::VmCostPlanIntervalType::Year => Self::Year,
}
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiVmCostPlan {
pub id: u64,
pub name: String,
pub amount: u64,
pub currency: String,
pub interval_amount: u64,
pub interval_type: ApiVmCostPlanIntervalType,
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiVmHostRegion {
pub id: u64,
pub name: String,
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct VMPatchRequest {
/// SSH key assigned to vm
pub ssh_key_id: Option<u64>,
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct AccountPatchRequest {
pub email: Option<String>,
pub contact_nip17: bool,
pub contact_email: bool,
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct CreateVmRequest {
pub template_id: u64,
pub image_id: u64,
pub ssh_key_id: u64,
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct CreateSshKey {
pub name: String,
pub key_data: String,
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub enum ApiOsDistribution {
Ubuntu = 0,
Debian = 1,
CentOS = 2,
Fedora = 3,
FreeBSD = 4,
OpenSUSE = 5,
ArchLinux = 6,
RedHatEnterprise = 7,
}
impl From<lnvps_db::OsDistribution> for ApiOsDistribution {
fn from(value: lnvps_db::OsDistribution) -> Self {
match value {
lnvps_db::OsDistribution::Ubuntu => Self::Ubuntu,
lnvps_db::OsDistribution::Debian => Self::Debian,
lnvps_db::OsDistribution::CentOS => Self::CentOS,
lnvps_db::OsDistribution::Fedora => Self::Fedora,
lnvps_db::OsDistribution::FreeBSD => Self::FreeBSD,
lnvps_db::OsDistribution::OpenSUSE => Self::OpenSUSE,
lnvps_db::OsDistribution::ArchLinux => Self::ArchLinux,
lnvps_db::OsDistribution::RedHatEnterprise => Self::RedHatEnterprise,
}
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiVmOsImage {
pub id: u64,
pub distribution: ApiOsDistribution,
pub flavour: String,
pub version: String,
pub release_date: DateTime<Utc>,
}
impl From<lnvps_db::VmOsImage> for ApiVmOsImage {
fn from(image: lnvps_db::VmOsImage) -> Self {
ApiVmOsImage {
id: image.id,
distribution: image.distribution.into(),
flavour: image.flavour,
version: image.version,
release_date: image.release_date,
}
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiVmPayment {
/// Payment hash hex
pub id: String,
pub vm_id: u64,
pub created: DateTime<Utc>,
pub expires: DateTime<Utc>,
pub amount: u64,
pub invoice: String,
pub is_paid: bool,
}
impl From<lnvps_db::VmPayment> for ApiVmPayment {
fn from(value: lnvps_db::VmPayment) -> Self {
Self {
id: hex::encode(&value.id),
vm_id: value.vm_id,
created: value.created,
expires: value.expires,
amount: value.amount,
invoice: value.invoice,
is_paid: value.is_paid,
}
}
}

View File

@ -1,23 +1,33 @@
use crate::api::model::{
AccountPatchRequest, ApiUserSshKey, ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus,
ApiVmTemplate, CreateSshKey, CreateVmRequest, VMPatchRequest,
};
use crate::nip98::Nip98Auth;
use crate::provisioner::Provisioner;
use crate::status::{VmState, VmStateCache};
use crate::worker::WorkJob;
use anyhow::{bail, Result};
use lnvps_db::hydrate::Hydrate;
use lnvps_db::{LNVpsDb, UserSshKey, Vm, VmOsImage, VmPayment, VmTemplate};
use lnvps_db::{IpRange, LNVpsDb};
use log::{debug, error};
use nostr::util::hex;
use nostr_sdk::async_utility::futures_util::future::join_all;
use rocket::futures::{Sink, SinkExt, StreamExt};
use rocket::serde::json::Json;
use rocket::{get, patch, post, routes, Responder, Route, State};
use rocket::{get, patch, post, 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 schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use ssh_key::PublicKey;
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use tokio::sync::mpsc::UnboundedSender;
use ws::Message;
pub fn routes() -> Vec<Route> {
routes![
openapi_get_routes![
v1_get_account,
v1_patch_account,
v1_list_vms,
@ -32,14 +42,13 @@ pub fn routes() -> Vec<Route> {
v1_start_vm,
v1_stop_vm,
v1_restart_vm,
v1_terminal_proxy,
v1_patch_vm
]
}
type ApiResult<T> = Result<Json<ApiData<T>>, ApiError>;
#[derive(Serialize)]
#[derive(Serialize, Deserialize, JsonSchema)]
struct ApiData<T: Serialize> {
pub data: T,
}
@ -53,7 +62,7 @@ impl<T: Serialize> ApiData<T> {
}
}
#[derive(Responder)]
#[derive(Serialize, Deserialize, JsonSchema, Responder)]
#[response(status = 500)]
struct ApiError {
pub error: String,
@ -67,25 +76,14 @@ impl<T: ToString> From<T> for ApiError {
}
}
#[derive(Serialize)]
struct ApiVmStatus {
#[serde(flatten)]
pub vm: Vm,
pub status: VmState,
}
#[derive(Serialize, Deserialize)]
struct VMPatchRequest {
pub ssh_key_id: Option<u64>,
}
#[derive(Serialize, Deserialize)]
struct AccountPatchRequest {
pub email: Option<String>,
pub contact_nip17: bool,
pub contact_email: bool,
impl OpenApiResponderInner for ApiError {
fn responses(_gen: &mut OpenApiGenerator) -> rocket_okapi::Result<Responses> {
Ok(Responses::default())
}
}
/// Update user account
#[openapi(tag = "Account")]
#[patch("/api/v1/account", format = "json", data = "<req>")]
async fn v1_patch_account(
auth: Nip98Auth,
@ -104,6 +102,8 @@ async fn v1_patch_account(
ApiData::ok(())
}
/// Get user account detail
#[openapi(tag = "Account")]
#[get("/api/v1/account")]
async fn v1_get_account(
auth: Nip98Auth,
@ -111,7 +111,7 @@ async fn v1_get_account(
) -> ApiResult<AccountPatchRequest> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = db.upsert_user(&pubkey).await?;
let mut user = db.get_user(uid).await?;
let user = db.get_user(uid).await?;
ApiData::ok(AccountPatchRequest {
email: user.email,
@ -120,6 +120,49 @@ async fn v1_get_account(
})
}
async fn vm_to_status(
db: &Box<dyn LNVpsDb>,
vm: lnvps_db::Vm,
state: Option<VmState>,
) -> Result<ApiVmStatus> {
let image = db.get_os_image(vm.image_id).await?;
let template = db.get_vm_template(vm.template_id).await?;
let region = db.get_host_region(template.region_id).await?;
let cost_plan = db.get_cost_plan(template.cost_plan_id).await?;
let ssh_key = db.get_user_ssh_key(vm.ssh_key_id).await?;
let ips = db.list_vm_ip_assignments(vm.id).await?;
let ip_range_ids: HashSet<u64> = ips.iter().map(|i| i.ip_range_id).collect();
let ip_ranges: Vec<_> = ip_range_ids.iter().map(|i| db.get_ip_range(*i)).collect();
let ip_ranges: HashMap<u64, IpRange> = join_all(ip_ranges)
.await
.into_iter()
.filter_map(Result::ok)
.map(|i| (i.id, i))
.collect();
Ok(ApiVmStatus {
id: vm.id,
created: vm.created,
expires: vm.expires,
mac_address: vm.mac_address,
image: image.into(),
template: ApiVmTemplate::from(template, cost_plan, region),
ssh_key: ssh_key.into(),
status: state.unwrap_or_default(),
ip_assignments: ips
.into_iter()
.map(|i| {
let range = ip_ranges
.get(&i.ip_range_id)
.expect("ip range id not found");
ApiVmIpAssignment::from(i, range)
})
.collect(),
})
}
/// List VMs belonging to user
#[openapi(tag = "VM")]
#[get("/api/v1/vm")]
async fn v1_list_vms(
auth: Nip98Auth,
@ -130,23 +173,16 @@ async fn v1_list_vms(
let uid = db.upsert_user(&pubkey).await?;
let vms = db.list_user_vms(uid).await?;
let mut ret = vec![];
for mut vm in vms {
vm.hydrate_up(db.inner()).await?;
vm.hydrate_down(db.inner()).await?;
if let Some(t) = &mut vm.template {
t.hydrate_up(db.inner()).await?;
}
let state = vm_state.get_state(vm.id).await;
ret.push(ApiVmStatus {
vm,
status: state.unwrap_or_default(),
});
for vm in vms {
let vm_id = vm.id;
ret.push(vm_to_status(db, vm, vm_state.get_state(vm_id).await).await?);
}
ApiData::ok(ret)
}
/// Get status of a VM
#[openapi(tag = "VM")]
#[get("/api/v1/vm/<id>")]
async fn v1_get_vm(
auth: Nip98Auth,
@ -156,22 +192,15 @@ async fn v1_get_vm(
) -> ApiResult<ApiVmStatus> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = db.upsert_user(&pubkey).await?;
let mut vm = db.get_vm(id).await?;
let vm = db.get_vm(id).await?;
if vm.user_id != uid {
return ApiData::err("VM doesnt belong to you");
}
vm.hydrate_up(db.inner()).await?;
vm.hydrate_down(db.inner()).await?;
if let Some(t) = &mut vm.template {
t.hydrate_up(db.inner()).await?;
}
let state = vm_state.get_state(vm.id).await;
ApiData::ok(ApiVmStatus {
vm,
status: state.unwrap_or_default(),
})
ApiData::ok(vm_to_status(db, vm, vm_state.get_state(id).await).await?)
}
/// Update a VM config
#[openapi(tag = "VM")]
#[patch("/api/v1/vm/<id>", data = "<data>", format = "json")]
async fn v1_patch_vm(
auth: Nip98Auth,
@ -201,39 +230,88 @@ async fn v1_patch_vm(
ApiData::ok(())
}
/// List available VM OS images
#[openapi(tag = "Image")]
#[get("/api/v1/image")]
async fn v1_list_vm_images(db: &State<Box<dyn LNVpsDb>>) -> ApiResult<Vec<VmOsImage>> {
let vms = db.list_os_image().await?;
let vms: Vec<VmOsImage> = vms.into_iter().filter(|i| i.enabled).collect();
ApiData::ok(vms)
}
#[get("/api/v1/vm/templates")]
async fn v1_list_vm_templates(db: &State<Box<dyn LNVpsDb>>) -> ApiResult<Vec<VmTemplate>> {
let mut vms = db.list_vm_templates().await?;
for vm in &mut vms {
vm.hydrate_up(db.inner()).await?;
}
let ret: Vec<VmTemplate> = vms.into_iter().filter(|v| v.enabled).collect();
async fn v1_list_vm_images(db: &State<Box<dyn LNVpsDb>>) -> ApiResult<Vec<ApiVmOsImage>> {
let images = db.list_os_image().await?;
let ret = images
.into_iter()
.filter(|i| i.enabled)
.map(|i| i.into())
.collect();
ApiData::ok(ret)
}
/// List available VM templates (Offers)
#[openapi(tag = "Template")]
#[get("/api/v1/vm/templates")]
async fn v1_list_vm_templates(db: &State<Box<dyn LNVpsDb>>) -> ApiResult<Vec<ApiVmTemplate>> {
let templates = db.list_vm_templates().await?;
let cost_plans: HashSet<u64> = templates.iter().map(|t| t.cost_plan_id).collect();
let regions: HashSet<u64> = templates.iter().map(|t| t.region_id).collect();
let cost_plans: Vec<_> = cost_plans
.into_iter()
.map(|i| db.get_cost_plan(i))
.collect();
let regions: Vec<_> = regions.into_iter().map(|r| db.get_host_region(r)).collect();
let cost_plans: HashMap<u64, lnvps_db::VmCostPlan> = join_all(cost_plans)
.await
.into_iter()
.filter_map(|c| {
let c = c.ok()?;
Some((c.id, c))
})
.collect();
let regions: HashMap<u64, lnvps_db::VmHostRegion> = join_all(regions)
.await
.into_iter()
.filter_map(|c| {
let c = c.ok()?;
Some((c.id, c))
})
.collect();
let ret = templates
.into_iter()
.filter(|v| v.enabled)
.filter_map(|i| {
let cp = cost_plans.get(&i.cost_plan_id)?;
let hr = regions.get(&i.region_id)?;
Some(ApiVmTemplate::from(i, cp.clone(), hr.clone()))
})
.collect();
ApiData::ok(ret)
}
/// List user SSH keys
#[openapi(tag = "Account")]
#[get("/api/v1/ssh-key")]
async fn v1_list_ssh_keys(
auth: Nip98Auth,
db: &State<Box<dyn LNVpsDb>>,
) -> ApiResult<Vec<UserSshKey>> {
) -> ApiResult<Vec<ApiUserSshKey>> {
let uid = db.upsert_user(&auth.event.pubkey.to_bytes()).await?;
let keys = db.list_user_ssh_key(uid).await?;
ApiData::ok(keys)
let ret = db
.list_user_ssh_key(uid)
.await?
.into_iter()
.map(|i| i.into())
.collect();
ApiData::ok(ret)
}
/// Add new SSH key to account
#[openapi(tag = "Account")]
#[post("/api/v1/ssh-key", data = "<req>", format = "json")]
async fn v1_add_ssh_key(
auth: Nip98Auth,
db: &State<Box<dyn LNVpsDb>>,
req: Json<CreateSshKey>,
) -> ApiResult<UserSshKey> {
) -> ApiResult<ApiUserSshKey> {
let uid = db.upsert_user(&auth.event.pubkey.to_bytes()).await?;
let pk: PublicKey = req.key_data.parse()?;
@ -242,7 +320,7 @@ async fn v1_add_ssh_key(
} else {
pk.comment()
};
let mut new_key = UserSshKey {
let mut new_key = lnvps_db::UserSshKey {
name: key_name.to_string(),
user_id: uid,
key_data: pk.to_openssh()?,
@ -251,35 +329,42 @@ async fn v1_add_ssh_key(
let key_id = db.insert_user_ssh_key(&new_key).await?;
new_key.id = key_id;
ApiData::ok(new_key)
ApiData::ok(new_key.into())
}
/// Create a new VM order
///
/// After order is created please use /api/v1/vm/{id}/renew to pay for VM,
/// VM's are initially created in "expired" state
///
/// Unpaid VM orders will be deleted after 24hrs
#[openapi(tag = "VM")]
#[post("/api/v1/vm", data = "<req>", format = "json")]
async fn v1_create_vm_order(
auth: Nip98Auth,
db: &State<Box<dyn LNVpsDb>>,
provisioner: &State<Box<dyn Provisioner>>,
req: Json<CreateVmRequest>,
) -> ApiResult<Vm> {
) -> ApiResult<ApiVmStatus> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = db.upsert_user(&pubkey).await?;
let req = req.0;
let mut rsp = provisioner
let rsp = provisioner
.provision(uid, req.template_id, req.image_id, req.ssh_key_id)
.await?;
rsp.hydrate_up(db.inner()).await?;
ApiData::ok(rsp)
ApiData::ok(vm_to_status(db, rsp, None).await?)
}
/// Renew(Extend) a VM
#[openapi(tag = "VM")]
#[get("/api/v1/vm/<id>/renew")]
async fn v1_renew_vm(
auth: Nip98Auth,
db: &State<Box<dyn LNVpsDb>>,
provisioner: &State<Box<dyn Provisioner>>,
id: u64,
) -> ApiResult<VmPayment> {
) -> ApiResult<ApiVmPayment> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = db.upsert_user(&pubkey).await?;
let vm = db.get_vm(id).await?;
@ -288,9 +373,11 @@ async fn v1_renew_vm(
}
let rsp = provisioner.renew(id).await?;
ApiData::ok(rsp)
ApiData::ok(rsp.into())
}
/// Start a VM
#[openapi(tag = "VM")]
#[patch("/api/v1/vm/<id>/start")]
async fn v1_start_vm(
auth: Nip98Auth,
@ -311,6 +398,8 @@ async fn v1_start_vm(
ApiData::ok(())
}
/// Stop a VM
#[openapi(tag = "VM")]
#[patch("/api/v1/vm/<id>/stop")]
async fn v1_stop_vm(
auth: Nip98Auth,
@ -331,6 +420,8 @@ async fn v1_stop_vm(
ApiData::ok(())
}
/// Restart a VM
#[openapi(tag = "VM")]
#[patch("/api/v1/vm/<id>/restart")]
async fn v1_restart_vm(
auth: Nip98Auth,
@ -351,12 +442,14 @@ async fn v1_restart_vm(
ApiData::ok(())
}
/// Get payment status (for polling)
#[openapi(tag = "Payment")]
#[get("/api/v1/payment/<id>")]
async fn v1_get_payment(
auth: Nip98Auth,
db: &State<Box<dyn LNVpsDb>>,
id: &str,
) -> ApiResult<VmPayment> {
) -> ApiResult<ApiVmPayment> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = db.upsert_user(&pubkey).await?;
let id = if let Ok(i) = hex::decode(id) {
@ -370,7 +463,7 @@ async fn v1_get_payment(
return ApiData::err("VM does not belong to you");
}
ApiData::ok(payment)
ApiData::ok(payment.into())
}
#[get("/api/v1/console/<id>?<auth>")]
@ -478,25 +571,3 @@ async fn v1_terminal_proxy(
})
}))
}
#[derive(Deserialize)]
struct CreateVmRequest {
template_id: u64,
image_id: u64,
ssh_key_id: u64,
}
impl From<CreateVmRequest> for VmTemplate {
fn from(val: CreateVmRequest) -> Self {
VmTemplate {
id: val.template_id,
..Default::default()
}
}
}
#[derive(Deserialize)]
struct CreateSshKey {
name: String,
key_data: String,
}

View File

@ -14,6 +14,7 @@ use lnvps_db::{LNVpsDb, LNVpsDbMysql};
use log::error;
use nostr::Keys;
use nostr_sdk::Client;
use rocket_okapi::swagger_ui::{make_swagger_ui, SwaggerUIConfig};
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use tokio::time::sleep;
@ -156,6 +157,13 @@ async fn main() -> Result<(), Error> {
.manage(exchange)
.manage(sender)
.mount("/", api::routes())
.mount(
"/swagger",
make_swagger_ui(&SwaggerUIConfig {
url: "../openapi.json".to_owned(),
..Default::default()
}),
)
.launch()
.await
{

View File

@ -7,6 +7,9 @@ use rocket::http::uri::{Absolute, Uri};
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome};
use rocket::{async_trait, Request};
use rocket_okapi::gen::OpenApiGenerator;
use rocket_okapi::okapi::openapi3::{SecurityRequirement, SecurityScheme, SecuritySchemeData};
use rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput};
pub struct Nip98Auth {
pub event: Event,
@ -106,3 +109,27 @@ impl<'r> FromRequest<'r> for Nip98Auth {
}
}
}
impl OpenApiFromRequest<'_> for Nip98Auth {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> rocket_okapi::Result<RequestHeaderInput> {
let security_scheme = SecurityScheme {
description: Some("Requires an Bearer token to access".to_owned()),
data: SecuritySchemeData::Http {
scheme: "Nostr".to_owned(),
bearer_format: Some("base64-encoded-auth-event".to_owned()),
},
extensions: Default::default(),
};
let mut security_req = SecurityRequirement::new();
security_req.insert("NostrAuth".to_owned(), Vec::new());
Ok(RequestHeaderInput::Security(
"NostrAuth".to_owned(),
security_scheme,
security_req,
))
}
}

View File

@ -14,13 +14,13 @@ use fedimint_tonic_lnd::lnrpc::Invoice;
use fedimint_tonic_lnd::tonic::async_trait;
use fedimint_tonic_lnd::Client;
use ipnetwork::IpNetwork;
use lnvps_db::hydrate::Hydrate;
use lnvps_db::{DiskType, IpRange, LNVpsDb, Vm, VmCostPlanIntervalType, VmIpAssignment, VmPayment};
use log::{debug, info, warn};
use nostr::util::hex;
use rand::random;
use rand::seq::IteratorRandom;
use reqwest::Url;
use rocket::futures::future::join_all;
use rocket::futures::{SinkExt, StreamExt};
use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
@ -81,10 +81,17 @@ impl LNVpsProvisioner {
ips = self.allocate_ips(vm.id).await?;
}
// load ranges
for ip in &mut ips {
ip.hydrate_up(&self.db).await?;
}
let ip_range_ids: HashSet<u64> = ips.iter().map(|i| i.ip_range_id).collect();
let ip_ranges: Vec<_> = ip_range_ids
.iter()
.map(|i| self.db.get_ip_range(*i))
.collect();
let ip_ranges: HashMap<u64, IpRange> = join_all(ip_ranges)
.await
.into_iter()
.filter_map(Result::ok)
.map(|i| (i.id, i))
.collect();
let mut ip_config = ips
.iter()
@ -92,11 +99,8 @@ impl LNVpsProvisioner {
if let Ok(net) = ip.ip.parse::<IpNetwork>() {
Some(match net {
IpNetwork::V4(addr) => {
format!(
"ip={},gw={}",
addr,
ip.ip_range.as_ref().map(|r| &r.gateway).unwrap()
)
let range = ip_ranges.get(&ip.ip_range_id)?;
format!("ip={},gw={}", addr, range.gateway)
}
IpNetwork::V6(addr) => format!("ip6={}", addr),
})
@ -122,11 +126,7 @@ impl LNVpsProvisioner {
bail!("No host drive found!")
};
let template = if let Some(t) = &vm.template {
t
} else {
&self.db.get_vm_template(vm.template_id).await?
};
let template = self.db.get_vm_template(vm.template_id).await?;
Ok(VmConfig {
cpu: Some(self.config.cpu.clone()),
@ -302,18 +302,18 @@ impl Provisioner for LNVpsProvisioner {
}
async fn allocate_ips(&self, vm_id: u64) -> Result<Vec<VmIpAssignment>> {
let mut vm = self.db.get_vm(vm_id).await?;
let vm = self.db.get_vm(vm_id).await?;
let template = self.db.get_vm_template(vm.template_id).await?;
let ips = self.db.list_vm_ip_assignments(vm.id).await?;
if !ips.is_empty() {
bail!("IP resources are already assigned");
}
vm.hydrate_up(&self.db).await?;
let ip_ranges = self.db.list_ip_range().await?;
let ip_ranges: Vec<IpRange> = ip_ranges
.into_iter()
.filter(|i| i.region_id == vm.template.as_ref().unwrap().region_id && i.enabled)
.filter(|i| i.region_id == template.region_id && i.enabled)
.collect();
if ip_ranges.is_empty() {

View File

@ -73,6 +73,7 @@ pub struct SmtpConfig {
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProvisionerConfig {
Proxmox {
/// Readonly mode, don't spawn any VM's
@ -147,7 +148,7 @@ impl RouterConfig {
username,
password,
arp_interface,
} => MikrotikRouter::new(&url, &username, &password, &arp_interface),
} => MikrotikRouter::new(url, username, password, arp_interface),
}
}
}

View File

@ -2,9 +2,11 @@ use anyhow::Result;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use rocket::serde::Deserialize;
use schemars::JsonSchema;
use tokio::sync::RwLock;
#[derive(Clone, Serialize, Default)]
#[derive(Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum VmRunningState {
Running,
@ -14,7 +16,7 @@ pub enum VmRunningState {
Deleting,
}
#[derive(Clone, Serialize, Default)]
#[derive(Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct VmState {
pub timestamp: u64,
pub state: VmRunningState,

View File

@ -48,11 +48,11 @@ pub struct WorkerSettings {
pub smtp: Option<SmtpConfig>,
}
impl Into<WorkerSettings> for &Settings {
fn into(self) -> WorkerSettings {
impl From<&Settings> for WorkerSettings {
fn from(val: &Settings) -> Self {
WorkerSettings {
delete_after: self.delete_after,
smtp: self.smtp.clone(),
delete_after: val.delete_after,
smtp: val.smtp.clone(),
}
}
}
@ -206,7 +206,7 @@ impl Worker {
if let Err(e) = self.handle_vm_info(vm).await {
error!("{}", e);
self.queue_admin_notification(
format!("Failed to check VM {}:\n{}", vm_id, e.to_string()),
format!("Failed to check VM {}:\n{}", vm_id, e),
Some("Job Failed".to_string()),
)?
}
@ -331,7 +331,7 @@ impl Worker {
"Failed to check VM {}:\n{:?}\n{}",
vm_id,
&job,
e.to_string()
e
),
Some("Job Failed".to_string()),
)?
@ -351,7 +351,7 @@ impl Worker {
format!(
"Failed to send notification:\n{:?}\n{}",
&job,
e.to_string()
e
),
Some("Job Failed".to_string()),
)?
@ -361,7 +361,7 @@ impl Worker {
if let Err(e) = self.check_vms().await {
error!("Failed to check VMs: {}", e);
self.queue_admin_notification(
format!("Failed to check VM's:\n{:?}\n{}", &job, e.to_string()),
format!("Failed to check VM's:\n{:?}\n{}", &job, e),
Some("Job Failed".to_string()),
)?
}