Compare commits

...

30 Commits

Author SHA1 Message Date
b6c4ab43fc fix: ensure now & expired are exactly the same
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-16 10:11:59 +01:00
51b8f42407 fix: delete unpaid only if created and expires timestamp are the same
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-16 10:10:05 +01:00
ee01eae498 chore: disable exchange rate logs
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-16 10:04:45 +01:00
e463e06a35 fix: missing country code in invoice template
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-02 10:57:33 +01:00
1dda3a561d feat: generate invoices
All checks were successful
continuous-integration/drone/push Build is passing
closes #29
2025-05-01 17:46:37 +01:00
179d70edb0 feat: return time value in vm_payment 2025-05-01 15:19:57 +01:00
c859c153c1 fix: prevent extending deleted vms 2025-05-01 15:19:39 +01:00
cd7ac9e967 feat: billing info
All checks were successful
continuous-integration/drone/push Build is passing
closes #30
2025-05-01 14:51:57 +01:00
f6a756db78 feat: list vm payments 2025-05-01 14:21:06 +01:00
7e10e0dd6e feat: lnurl extend
All checks were successful
continuous-integration/drone/push Build is passing
closes https://github.com/LNVPS/api/issues/31
2025-04-30 15:47:45 +01:00
4db6aa1897 fix: allow full range in capacity checks
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-30 13:54:51 +01:00
4dfc33bca2 feat: index page
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-03 14:16:40 +01:00
c432f603ec feat: nostr domain hosting
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-03 12:56:20 +01:00
a4850b4e06 chore: update grafana dashboard 2025-04-03 10:03:06 +01:00
9296e571ec refactor: convert to workspace
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-02 13:18:18 +01:00
2ae158c31a feat: custom order disk max sizes
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-01 11:40:05 +01:00
b7d7027eec feat: libvirt domain xml progress 2025-04-01 11:22:10 +01:00
b9bec36843 chore: add missing impl
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-31 12:20:17 +01:00
97d631ce5d feat: automatically delete expired vms on client 2025-03-31 12:19:41 +01:00
36069bb6a7 fix: docker build 2025-03-31 12:04:44 +01:00
6a8f1826bb fix: dont assign ipv6 to expired vm 2025-03-31 10:57:53 +01:00
7deed82a7c feat: libvirt setup
Some checks failed
continuous-integration/drone/push Build is failing
fix: ip assigment index
feat: default username
2025-03-31 10:40:29 +01:00
6ca8283040 fix: tests
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-29 11:03:30 +00:00
b8ab61c48f fix: prevent creating arp entries with empty mac
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-29 10:48:20 +00:00
70a4d9c638 fix: assign mac before arp entry is saved
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-29 10:39:42 +00:00
d316ccacd7 fix: avoid unwrap in mikrotik
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 21:55:07 +00:00
396cb8a7ef fix: avoid unwrap in mikrotik
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 21:45:53 +00:00
8068b7d5bc feat: add more currencies
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 13:52:57 +00:00
e2d6d84439 fix: improve patch host code
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 13:38:49 +00:00
ea6499558d chore: add grafana dashboard 2025-03-28 12:58:15 +00:00
72 changed files with 9182 additions and 738 deletions

295
Cargo.lock generated
View File

@ -27,6 +27,17 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.11" version = "0.8.11"
@ -309,6 +320,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base58ck"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f"
dependencies = [
"bitcoin-internals 0.3.0",
"bitcoin_hashes 0.14.0",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@ -350,25 +371,62 @@ dependencies = [
"unicode-normalization", "unicode-normalization",
] ]
[[package]]
name = "bitcoin"
version = "0.32.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026"
dependencies = [
"base58ck",
"bech32",
"bitcoin-internals 0.3.0",
"bitcoin-io",
"bitcoin-units",
"bitcoin_hashes 0.14.0",
"hex-conservative 0.2.1",
"hex_lit",
"secp256k1",
"serde",
]
[[package]] [[package]]
name = "bitcoin-internals" name = "bitcoin-internals"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb"
[[package]]
name = "bitcoin-internals"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitcoin-io" name = "bitcoin-io"
version = "0.1.3" version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf"
[[package]]
name = "bitcoin-units"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2"
dependencies = [
"bitcoin-internals 0.3.0",
"serde",
]
[[package]] [[package]]
name = "bitcoin_hashes" name = "bitcoin_hashes"
version = "0.13.0" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b"
dependencies = [ dependencies = [
"bitcoin-internals", "bitcoin-internals 0.2.0",
"hex-conservative 0.1.2", "hex-conservative 0.1.2",
] ]
@ -526,9 +584,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.34" version = "4.5.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e958897981290da2a852763fe9cdb89cd36977a5d729023127095fa94d95e2ff" checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -536,9 +594,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.34" version = "4.5.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83b0f35019843db2160b5bb19ae09b4e6411ac33fc6a712003c33e03090e2489" checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -920,9 +978,12 @@ dependencies = [
[[package]] [[package]]
name = "email_address" name = "email_address"
version = "0.2.9" version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" checksum = "c1019fa28f600f5b581b7a603d515c3f1635da041ca211b5055804788673abfe"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
@ -939,21 +1000,21 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
dependencies = [ dependencies = [
"log", "log 0.4.27",
"regex", "regex",
] ]
[[package]] [[package]]
name = "env_logger" name = "env_logger"
version = "0.11.7" version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3716d7a920fb4fac5d84e9d4bce8ceb321e9414b4409da61b07b75c1e3d0697" checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
"env_filter", "env_filter",
"jiff", "jiff",
"log", "log 0.4.27",
] ]
[[package]] [[package]]
@ -1205,7 +1266,7 @@ checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
"log", "log 0.4.27",
"rustversion", "rustversion",
"windows 0.48.0", "windows 0.48.0",
] ]
@ -1394,6 +1455,12 @@ dependencies = [
"arrayvec", "arrayvec",
] ]
[[package]]
name = "hex_lit"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
[[package]] [[package]]
name = "hkdf" name = "hkdf"
version = "0.12.4" version = "0.12.4"
@ -1605,9 +1672,9 @@ dependencies = [
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.10" version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
@ -1615,6 +1682,7 @@ dependencies = [
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1", "http-body 1.0.1",
"hyper 1.6.0", "hyper 1.6.0",
"libc",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio", "tokio",
@ -1624,17 +1692,17 @@ dependencies = [
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.62" version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2fd658b06e56721792c5df4475705b6cda790e9298d19d2f8af083457bcd127" checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [ dependencies = [
"android_system_properties", "android_system_properties",
"core-foundation-sys", "core-foundation-sys",
"iana-time-zone-haiku", "iana-time-zone-haiku",
"js-sys", "js-sys",
"log", "log 0.4.27",
"wasm-bindgen", "wasm-bindgen",
"windows-core", "windows-core 0.61.0",
] ]
[[package]] [[package]]
@ -1901,7 +1969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c102670231191d07d37a35af3eb77f1f0dbf7a71be51a962dcd57ea607be7260" checksum = "c102670231191d07d37a35af3eb77f1f0dbf7a71be51a962dcd57ea607be7260"
dependencies = [ dependencies = [
"jiff-static", "jiff-static",
"log", "log 0.4.27",
"portable-atomic", "portable-atomic",
"portable-atomic-util", "portable-atomic-util",
"serde", "serde",
@ -2037,7 +2105,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"
[[package]] [[package]]
name = "lnvps" name = "lnurl-rs"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41eacdd87b675792f7752f3dd0937a00241a504c3956c47f72986490662e1db4"
dependencies = [
"aes",
"anyhow",
"base64 0.22.1",
"bech32",
"bitcoin",
"cbc",
"email_address",
"serde",
"serde_json",
"url",
]
[[package]]
name = "lnvps_api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
@ -2053,11 +2139,15 @@ dependencies = [
"ipnetwork", "ipnetwork",
"isocountry", "isocountry",
"lettre", "lettre",
"lnurl-rs",
"lnvps_common",
"lnvps_db", "lnvps_db",
"log", "log 0.4.27",
"mustache",
"native-tls", "native-tls",
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"quick-xml",
"rand 0.9.0", "rand 0.9.0",
"reqwest", "reqwest",
"rocket", "rocket",
@ -2073,9 +2163,17 @@ dependencies = [
"tokio-stream", "tokio-stream",
"tokio-tungstenite 0.21.0", "tokio-tungstenite 0.21.0",
"urlencoding", "urlencoding",
"uuid",
"virt", "virt",
] ]
[[package]]
name = "lnvps_common"
version = "0.1.0"
dependencies = [
"rocket",
]
[[package]] [[package]]
name = "lnvps_db" name = "lnvps_db"
version = "0.1.0" version = "0.1.0"
@ -2087,6 +2185,23 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "lnvps_nostr"
version = "0.1.0"
dependencies = [
"anyhow",
"config",
"env_logger",
"hex",
"lnvps_common",
"lnvps_db",
"log 0.4.27",
"rocket",
"serde",
"serde_json",
"tokio",
]
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.12" version = "0.4.12"
@ -2097,6 +2212,15 @@ dependencies = [
"scopeguard", "scopeguard",
] ]
[[package]]
name = "log"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b"
dependencies = [
"log 0.4.27",
]
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.27" version = "0.4.27"
@ -2206,6 +2330,16 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
[[package]]
name = "mustache"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51956ef1c5d20a1384524d91e616fb44dfc7d8f249bf696d49c97dd3289ecab5"
dependencies = [
"log 0.3.9",
"serde",
]
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.14" version = "0.2.14"
@ -2213,7 +2347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log 0.4.27",
"openssl", "openssl",
"openssl-probe", "openssl-probe",
"openssl-sys", "openssl-sys",
@ -2399,7 +2533,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a64853d7ab065474e87696f7601cee817d200e86c42e04004e005cb3e20c3c5" checksum = "9a64853d7ab065474e87696f7601cee817d200e86c42e04004e005cb3e20c3c5"
dependencies = [ dependencies = [
"log", "log 0.4.27",
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",
@ -2407,9 +2541,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.1" version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "opaque-debug" name = "opaque-debug"
@ -2824,7 +2958,7 @@ dependencies = [
"bytes", "bytes",
"heck", "heck",
"itertools", "itertools",
"log", "log 0.4.27",
"multimap", "multimap",
"once_cell", "once_cell",
"petgraph", "petgraph",
@ -2867,6 +3001,16 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "quick-xml"
version = "0.37.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.40"
@ -3042,7 +3186,7 @@ dependencies = [
"hyper-util", "hyper-util",
"ipnet", "ipnet",
"js-sys", "js-sys",
"log", "log 0.4.27",
"mime", "mime",
"native-tls", "native-tls",
"once_cell", "once_cell",
@ -3104,7 +3248,7 @@ dependencies = [
"figment", "figment",
"futures", "futures",
"indexmap 2.8.0", "indexmap 2.8.0",
"log", "log 0.4.27",
"memchr", "memchr",
"multer", "multer",
"num_cpus", "num_cpus",
@ -3156,7 +3300,7 @@ dependencies = [
"http 0.2.12", "http 0.2.12",
"hyper 0.14.32", "hyper 0.14.32",
"indexmap 2.8.0", "indexmap 2.8.0",
"log", "log 0.4.27",
"memchr", "memchr",
"pear", "pear",
"percent-encoding", "percent-encoding",
@ -3177,7 +3321,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "074297bec35db2fc7ebb6ade6a955b5566de66f83d9af5b5602a350a71bdef43" checksum = "074297bec35db2fc7ebb6ade6a955b5566de66f83d9af5b5602a350a71bdef43"
dependencies = [ dependencies = [
"log", "log 0.4.27",
"okapi", "okapi",
"rocket", "rocket",
"rocket_okapi_codegen", "rocket_okapi_codegen",
@ -3261,9 +3405,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.0.3" version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96" checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf"
dependencies = [ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
"errno", "errno",
@ -3278,7 +3422,7 @@ version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [ dependencies = [
"log", "log 0.4.27",
"ring", "ring",
"rustls-webpki 0.101.7", "rustls-webpki 0.101.7",
"sct", "sct",
@ -3453,6 +3597,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [ dependencies = [
"bitcoin_hashes 0.14.0",
"rand 0.8.5", "rand 0.8.5",
"secp256k1-sys", "secp256k1-sys",
"serde", "serde",
@ -3630,9 +3775,9 @@ dependencies = [
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.5.8" version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@ -3689,7 +3834,7 @@ dependencies = [
"hashbrown 0.15.2", "hashbrown 0.15.2",
"hashlink", "hashlink",
"indexmap 2.8.0", "indexmap 2.8.0",
"log", "log 0.4.27",
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
@ -3768,7 +3913,7 @@ dependencies = [
"hkdf", "hkdf",
"hmac", "hmac",
"itoa", "itoa",
"log", "log 0.4.27",
"md-5", "md-5",
"memchr", "memchr",
"once_cell", "once_cell",
@ -3808,7 +3953,7 @@ dependencies = [
"hmac", "hmac",
"home", "home",
"itoa", "itoa",
"log", "log 0.4.27",
"md-5", "md-5",
"memchr", "memchr",
"once_cell", "once_cell",
@ -3839,7 +3984,7 @@ dependencies = [
"futures-intrusive", "futures-intrusive",
"futures-util", "futures-util",
"libsqlite3-sys", "libsqlite3-sys",
"log", "log 0.4.27",
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_urlencoded", "serde_urlencoded",
@ -4263,7 +4408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"log", "log 0.4.27",
"native-tls", "native-tls",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
@ -4277,7 +4422,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"log", "log 0.4.27",
"rustls 0.23.25", "rustls 0.23.25",
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
@ -4429,7 +4574,7 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [ dependencies = [
"log", "log 0.4.27",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@ -4462,7 +4607,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [ dependencies = [
"log", "log 0.4.27",
"once_cell", "once_cell",
"tracing-core", "tracing-core",
] ]
@ -4508,7 +4653,7 @@ dependencies = [
"data-encoding", "data-encoding",
"http 1.3.1", "http 1.3.1",
"httparse", "httparse",
"log", "log 0.4.27",
"native-tls", "native-tls",
"rand 0.8.5", "rand 0.8.5",
"sha1", "sha1",
@ -4527,7 +4672,7 @@ dependencies = [
"data-encoding", "data-encoding",
"http 1.3.1", "http 1.3.1",
"httparse", "httparse",
"log", "log 0.4.27",
"rand 0.9.0", "rand 0.9.0",
"rustls 0.23.25", "rustls 0.23.25",
"rustls-pki-types", "rustls-pki-types",
@ -4669,6 +4814,10 @@ name = "uuid"
version = "1.16.0" version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
dependencies = [
"getrandom 0.3.2",
"serde",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
@ -4691,8 +4840,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "virt" name = "virt"
version = "0.4.2" version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://gitlab.com/libvirt/libvirt-rust.git#70394aad4d9597c9ff87c0ada6711ed4f9528991"
checksum = "77a05f77c836efa9be343b5419663cf829d75203b813579993cdd9c44f51767e"
dependencies = [ dependencies = [
"libc", "libc",
"uuid", "uuid",
@ -4702,8 +4850,7 @@ dependencies = [
[[package]] [[package]]
name = "virt-sys" name = "virt-sys"
version = "0.3.0" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://gitlab.com/libvirt/libvirt-rust.git#70394aad4d9597c9ff87c0ada6711ed4f9528991"
checksum = "c504e459878f09177f41bf2f8bb3e9a8af4fca7a09e73152fee02535d501601c"
dependencies = [ dependencies = [
"libc", "libc",
"pkg-config", "pkg-config",
@ -4758,7 +4905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"log", "log 0.4.27",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn 2.0.100",
@ -4876,7 +5023,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [ dependencies = [
"windows-core", "windows-core 0.52.0",
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
@ -4889,6 +5036,41 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-core"
version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings 0.4.0",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]]
name = "windows-interface"
version = "0.59.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.1.1" version = "0.1.1"
@ -4902,7 +5084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [ dependencies = [
"windows-result", "windows-result",
"windows-strings", "windows-strings 0.3.1",
"windows-targets 0.53.0", "windows-targets 0.53.0",
] ]
@ -4924,6 +5106,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-strings"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"

View File

@ -1,67 +1,19 @@
[package] [workspace]
name = "lnvps" resolver = "3"
version = "0.1.0" members = [
edition = "2021" "lnvps_db",
"lnvps_api",
"lnvps_nostr",
"lnvps_common"
]
[[bin]] [workspace.dependencies]
name = "api" tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros"] }
[features]
default = ["mikrotik", "nostr-dm", "nostr-dvm", "proxmox", "lnd", "cloudflare", "revolut", "bitvora"]
mikrotik = ["dep:reqwest"]
nostr-dm = ["dep:nostr-sdk"]
nostr-dvm = ["dep:nostr-sdk"]
proxmox = ["dep:reqwest", "dep:ssh2", "dep:tokio-tungstenite"]
libvirt = ["dep:virt"]
lnd = ["dep:fedimint-tonic-lnd"]
bitvora = ["dep:reqwest", "dep:tokio-stream"]
cloudflare = ["dep:reqwest"]
revolut = ["dep:reqwest", "dep:sha2", "dep:hmac"]
[dependencies]
lnvps_db = { path = "lnvps_db" }
tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros", "sync", "io-util"] }
anyhow = "1.0.83" anyhow = "1.0.83"
config = { version = "0.15.8", features = ["yaml"] }
log = "0.4.21" log = "0.4.21"
env_logger = "0.11.7"
serde = { version = "1.0.213", features = ["derive"] } serde = { version = "1.0.213", features = ["derive"] }
serde_json = "1.0.132" serde_json = "1.0.132"
rocket = { version = "0.5.1", features = ["json"] } rocket = { version = "0.5.1", features = ["json"] }
rocket_okapi = { version = "0.9.0", features = ["swagger"] } config = { version = "0.15.8", features = ["yaml"] }
schemars = { version = "0.8.22", features = ["chrono"] }
chrono = { version = "0.4.38", features = ["serde"] }
base64 = { version = "0.22.1", features = ["alloc"] }
urlencoding = "2.1.3"
ipnetwork = { git = "https://git.v0l.io/Kieran/ipnetwork.git", rev = "35977adc8103cfc232bc95fbc32f4e34f2b6a6d7" }
rand = "0.9.0"
clap = { version = "4.5.21", features = ["derive"] }
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" hex = "0.4.3"
futures = "0.3.31"
isocountry = "0.3.2"
#nostr-dm
nostr = { version = "0.40.0", default-features = false, features = ["std"] }
nostr-sdk = { version = "0.40.0", optional = true, default-features = false, features = ["nip44", "nip59"] }
#proxmox
tokio-tungstenite = { version = "^0.21", features = ["native-tls"], optional = true }
ssh2 = { version = "0.9.4", optional = true }
reqwest = { version = "0.12.8", optional = true }
#libvirt
virt = { version = "0.4.2", optional = true }
#lnd
fedimint-tonic-lnd = { version = "0.2.0", default-features = false, features = ["invoicesrpc"], optional = true }
#bitvora
tokio-stream = { version = "0.1.17", features = ["sync"], optional = true }
#revolut
sha2 = { version = "0.10.8", optional = true }
hmac = { version = "0.12.1", optional = true }
env_logger = "0.11.7"

View File

@ -3,10 +3,12 @@ ARG IMAGE=rust:bookworm
FROM $IMAGE AS build FROM $IMAGE AS build
WORKDIR /app/src WORKDIR /app/src
COPY . . COPY . .
RUN apt update && apt -y install protobuf-compiler RUN apt update && apt -y install protobuf-compiler libvirt-dev
RUN cargo test && cargo install --path . --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 FROM $IMAGE AS runner
WORKDIR /app WORKDIR /app
COPY --from=build /app/build . COPY --from=build /app/build .
ENTRYPOINT ["./bin/api"] ENTRYPOINT ["./bin/lnvps_api"]

View File

@ -13,6 +13,7 @@ A bitcoin powered VPS system.
- [RevolutPay](https://www.revolut.com/business/revolut-pay/) - [RevolutPay](https://www.revolut.com/business/revolut-pay/)
- VM Backend: - VM Backend:
- Proxmox - Proxmox
- LibVirt (WIP)
- Network Resources: - Network Resources:
- Mikrotik JSON-API - Mikrotik JSON-API
- OVH API (dedicated server virtual mac) - OVH API (dedicated server virtual mac)
@ -42,10 +43,18 @@ delete-after: 3
read-only: false read-only: false
# Provisioner is the main process which handles creating/deleting VM's # Provisioner is the main process which handles creating/deleting VM's
# Currently supports: Proxmox
provisioner: provisioner:
proxmox: proxmox:
# Proxmox (QEMU) settings used for spawning VM's # QEMU settings used for spawning VM's
qemu:
bios: "ovmf"
machine: "q35"
os-type: "l26"
bridge: "vmbr0"
cpu: "kvm64"
kvm: false
libvirt:
# QEMU settings used for spawning VM's
qemu: qemu:
bios: "ovmf" bios: "ovmf"
machine: "q35" machine: "q35"

861
grafana.json Normal file
View File

@ -0,0 +1,861 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 1,
"links": [],
"panels": [
{
"datasource": {
"type": "mysql",
"uid": "cegjfe9u9181sf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "left",
"cellOptions": {
"type": "auto"
},
"filterable": false,
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "cpu"
},
"properties": [
{
"id": "custom.width",
"value": 62
}
]
},
{
"matcher": {
"id": "byName",
"options": "gb_memory"
},
"properties": [
{
"id": "custom.width",
"value": 88
}
]
},
{
"matcher": {
"id": "byName",
"options": "gb_ssd"
},
"properties": [
{
"id": "custom.width",
"value": 81
}
]
},
{
"matcher": {
"id": "byName",
"options": "gb_hdd"
},
"properties": [
{
"id": "custom.width",
"value": 75
}
]
},
{
"matcher": {
"id": "byName",
"options": "load_factor"
},
"properties": [
{
"id": "custom.width",
"value": 93
}
]
},
{
"matcher": {
"id": "byName",
"options": "sold_cpu"
},
"properties": [
{
"id": "custom.width",
"value": 87
}
]
},
{
"matcher": {
"id": "byName",
"options": "sold_gb_memory"
},
"properties": [
{
"id": "custom.width",
"value": 133
}
]
},
{
"matcher": {
"id": "byName",
"options": "sold_gb_ssd_disk"
},
"properties": [
{
"id": "custom.width",
"value": 141
}
]
},
{
"matcher": {
"id": "byName",
"options": "name"
},
"properties": [
{
"id": "custom.width",
"value": 205
}
]
},
{
"matcher": {
"id": "byName",
"options": "vms"
},
"properties": [
{
"id": "custom.width",
"value": 63
}
]
}
]
},
"gridPos": {
"h": 5,
"w": 24,
"x": 0,
"y": 0
},
"id": 3,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "11.5.2",
"targets": [
{
"dataset": "lnvps",
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT \nh.name,\nh.cpu,\nh.memory / 1024 / 1024 / 1024 gb_memory,\n(select count(*) from vm where vm.host_id = h.id and vm.expires > current_timestamp and vm.deleted = 0) as vms,\n(select sum(size) from vm_host_disk hd where hd.host_id = h.id and hd.enabled = 1 and hd.kind = 1) / 1024 / 1024 / 1024 gb_ssd,\n(select sum(size) from vm_host_disk hd where hd.host_id = h.id and hd.enabled = 1 and hd.kind = 0) / 1024 / 1024 / 1024 gb_hdd,\n(select sum(case when v.template_id is null then (select cpu from vm_custom_template vct where vct.id = v.custom_template_id) else (select cpu from vm_template vt where vt.id = v.template_id) end) from vm v where v.host_id = h.id and expires > current_timestamp()) sold_cpu,\n(select sum(case when v.template_id is null then (select memory from vm_custom_template vct where vct.id = v.custom_template_id) else (select memory from vm_template vt where vt.id = v.template_id) end) from vm v where v.host_id = h.id and expires > current_timestamp()) / 1024 / 1024 / 1024 sold_gb_memory,\n(select sum(case when v.template_id is null then (select disk_size from vm_custom_template vct where vct.id = v.custom_template_id and vct.disk_type = 1) else (select disk_size from vm_template vt where vt.id = v.template_id and vt.disk_type = 1) end) from vm v where v.host_id = h.id and expires > current_timestamp()) / 1024 / 1024 / 1024 sold_gb_ssd_disk,\n(select sum(case when v.template_id is null then (select disk_size from vm_custom_template vct where vct.id = v.custom_template_id and vct.disk_type = 0) else (select disk_size from vm_template vt where vt.id = v.template_id and vt.disk_type = 0) end) from vm v where v.host_id = h.id and expires > current_timestamp()) / 1024 / 1024 / 1024 sold_gb_hdd_disk,\n(select sum(case when v.template_id is null then 0 else (select (case when cp.currency = 'BTC' then (cp.amount/1e9) else cp.amount end) from vm_template vt,vm_cost_plan cp where vt.id = v.template_id and vt.cost_plan_id = cp.id) end) from vm v where v.host_id = h.id and deleted = 0) income\nfrom vm_host h",
"refId": "A",
"sql": {
"columns": [
{
"parameters": [],
"type": "function"
}
],
"groupBy": [
{
"property": {
"type": "string"
},
"type": "groupBy"
}
],
"limit": 50
}
}
],
"title": "Host Allocation",
"type": "table"
},
{
"datasource": {
"type": "mysql",
"uid": "cegjfe9u9181sf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 13,
"w": 12,
"x": 0,
"y": 5
},
"id": 2,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true
},
"pluginVersion": "11.5.2",
"targets": [
{
"dataset": "lnvps",
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "select\nv.id,\ndatediff(current_timestamp(), v.created) age,\ndatediff(v.expires, current_timestamp()) days_to_expire,\n(select sum((case when currency = 'BTC' then amount / 1e11 else amount end) * rate) from lnvps.vm_payment where vm_id = v.id and is_paid = 1) total_payments\nfrom vm v\nwhere v.deleted = 0\norder by 3 asc",
"refId": "A",
"sql": {
"columns": [
{
"parameters": [],
"type": "function"
}
],
"groupBy": [
{
"property": {
"type": "string"
},
"type": "groupBy"
}
],
"limit": 50
}
}
],
"title": "Renewals",
"type": "table"
},
{
"datasource": {
"type": "mysql",
"uid": "cegjfe9u9181sf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "id"
},
"properties": [
{
"id": "custom.width",
"value": 49
}
]
},
{
"matcher": {
"id": "byName",
"options": "ref_code"
},
"properties": [
{
"id": "custom.width",
"value": 91
}
]
},
{
"matcher": {
"id": "byName",
"options": "created"
},
"properties": [
{
"id": "custom.width"
}
]
},
{
"matcher": {
"id": "byName",
"options": "amount"
},
"properties": [
{
"id": "custom.width",
"value": 71
}
]
},
{
"matcher": {
"id": "byName",
"options": "currency"
},
"properties": [
{
"id": "custom.width",
"value": 78
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 5
},
"id": 4,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "11.5.2",
"targets": [
{
"dataset": "lnvps",
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "select v.id, \nv.ref_code, v.created, \n(case when vp.currency = 'BTC' then vp.amount / 1000 else vp.amount / 100 end) amount,\nvp.currency,\n(case when vp.currency = 'BTC' then vp.amount / 1000 else vp.amount / 100 end) * 0.33 comission\nfrom vm v, vm_payment vp\nwhere v.ref_code is not null\nand v.id = vp.vm_id\nand vp.is_paid = 1\norder by vp.created desc",
"refId": "A",
"sql": {
"columns": [
{
"parameters": [],
"type": "function"
}
],
"groupBy": [
{
"property": {
"type": "string"
},
"type": "groupBy"
}
],
"limit": 50
}
}
],
"title": "RefCodes",
"type": "table"
},
{
"datasource": {
"type": "mysql",
"uid": "cegjfe9u9181sf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 12,
"x": 12,
"y": 13
},
"id": 5,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true
},
"pluginVersion": "11.5.2",
"targets": [
{
"dataset": "lnvps",
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "select vm_id, created, \n(case when currency = 'BTC' then (amount / 1e3) else amount / 100 end) amount, \n(case when currency = 'BTC' then (amount / 1e11) * rate else amount * rate end) amount_eur,\ncurrency,\n(case when payment_method = 0 then 'LN' else 'Revolut' end) method\nfrom vm_payment\nwhere is_paid = 1\norder by created desc\nlimit 20",
"refId": "A",
"sql": {
"columns": [
{
"parameters": [],
"type": "function"
}
],
"groupBy": [
{
"property": {
"type": "string"
},
"type": "groupBy"
}
],
"limit": 50
}
}
],
"title": "Payments",
"type": "table"
},
{
"datasource": {
"type": "mysql",
"uid": "cegjfe9u9181sf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "free"
},
"properties": [
{
"id": "unit",
"value": "percentunit"
}
]
},
{
"matcher": {
"id": "byName",
"options": "region"
},
"properties": [
{
"id": "custom.width",
"value": 70
}
]
},
{
"matcher": {
"id": "byName",
"options": "used"
},
"properties": [
{
"id": "custom.width",
"value": 59
}
]
},
{
"matcher": {
"id": "byName",
"options": "size"
},
"properties": [
{
"id": "custom.width",
"value": 70
}
]
},
{
"matcher": {
"id": "byName",
"options": "size"
},
"properties": [
{
"id": "unit",
"value": "sishort"
}
]
}
]
},
"gridPos": {
"h": 5,
"w": 12,
"x": 0,
"y": 18
},
"id": 1,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "11.5.2",
"targets": [
{
"dataset": "lnvps",
"datasource": {
"type": "mysql",
"uid": "cegjfe9u9181sf"
},
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "select i.cidr, i.region, i.used, i.size, (1-i.used/i.size) as free\nfrom (\nselect r.cidr, \n(select count(id) from lnvps.vm_ip_assignment where ip_range_id = r.id and deleted = 0) used,\nhr.name as region,\npow(2, (case when r.cidr like '%:%' then 128 else 32 end)-substring_index(r.cidr, '/', -1)) as size\nfrom ip_range r, vm_host_region hr\nwhere r.region_id = hr.id) i",
"refId": "A",
"sql": {
"columns": [
{
"parameters": [],
"type": "function"
}
],
"groupBy": [
{
"property": {
"type": "string"
},
"type": "groupBy"
}
],
"limit": 50
}
}
],
"title": "IP Ranges",
"type": "table"
},
{
"datasource": {
"type": "mysql",
"uid": "cegjfe9u9181sf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"fillOpacity": 80,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineWidth": 1,
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "amount_sats"
},
"properties": [
{
"id": "unit",
"value": "locale"
}
]
},
{
"matcher": {
"id": "byName",
"options": "amount_eur"
},
"properties": [
{
"id": "unit",
"value": "currencyEUR"
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 23
},
"id": 6,
"options": {
"barRadius": 0,
"barWidth": 0.97,
"fullHighlight": false,
"groupWidth": 0.7,
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"orientation": "auto",
"showValue": "auto",
"stacking": "none",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
},
"xTickLabelRotation": 0,
"xTickLabelSpacing": 0
},
"pluginVersion": "11.5.2",
"targets": [
{
"dataset": "lnvps",
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "select DATE_FORMAT(created, '%Y-%m') as month,\nsum((case when currency = 'BTC' then (amount / 1e3) else amount / 100 end)) amount_sats, \nsum((case when currency = 'BTC' then (amount / 1e11) * rate else amount * rate end)) amount_eur\nfrom vm_payment\nwhere is_paid = 1\ngroup by DATE_FORMAT(created, '%Y-%m')\norder by created asc\n",
"refId": "A",
"sql": {
"columns": [
{
"parameters": [],
"type": "function"
}
],
"groupBy": [
{
"property": {
"type": "string"
},
"type": "groupBy"
}
],
"limit": 50
}
}
],
"title": "Income",
"transformations": [
{
"id": "convertFieldType",
"options": {
"conversions": [
{
"destinationType": "time",
"targetField": "month"
}
],
"fields": {}
}
}
],
"type": "barchart"
}
],
"preload": false,
"refresh": "",
"schemaVersion": 40,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"hidden": true
},
"timezone": "browser",
"title": "LNVPS",
"uid": "begjfxfrjwu80e",
"version": 26,
"weekStart": ""
}

5314
lnvps_api/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

88
lnvps_api/Cargo.toml Normal file
View File

@ -0,0 +1,88 @@
[package]
name = "lnvps_api"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "lnvps_api"
path = "src/bin/api.rs"
[features]
default = [
"mikrotik",
"nostr-dm",
"nostr-dvm",
"nostr-domain",
"proxmox",
"lnd",
"cloudflare",
"revolut",
"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"]
bitvora = ["dep:reqwest", "dep:tokio-stream"]
cloudflare = ["dep:reqwest"]
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.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"] }
base64 = { version = "0.22.1", features = ["alloc"] }
urlencoding = "2.1.3"
ipnetwork = { git = "https://git.v0l.io/Kieran/ipnetwork.git", rev = "35977adc8103cfc232bc95fbc32f4e34f2b6a6d7" }
rand = "0.9.0"
clap = { version = "4.5.21", features = ["derive"] }
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"
lnurl-rs = { version = "0.9.0", default-features = false }
mustache = "0.9.0"
futures = "0.3.31"
isocountry = "0.3.2"
#nostr-dm
nostr = { version = "0.40.0", default-features = false, features = ["std"] }
nostr-sdk = { version = "0.40.0", optional = true, default-features = false, features = ["nip44", "nip59"] }
#proxmox
tokio-tungstenite = { version = "^0.21", features = ["native-tls"], optional = true }
ssh2 = { version = "0.9.4", optional = true }
reqwest = { version = "0.12.8", optional = true }
#libvirt
virt = { git = "https://gitlab.com/libvirt/libvirt-rust.git", optional = true }
#virtxml = {git = "https://gitlab.com/libvirt/libvirt-rust-xml.git", optional = true}
uuid = { version = "1.16.0", features = ["v4", "serde"], optional = true }
quick-xml = { version = "0.37.3", features = ["serde", "serialize"], optional = true }
#lnd
fedimint-tonic-lnd = { version = "0.2.0", default-features = false, features = ["invoicesrpc"], optional = true }
#bitvora
tokio-stream = { version = "0.1.17", features = ["sync"], optional = true }
#revolut
sha2 = { version = "0.10.8", optional = true }
hmac = { version = "0.12.1", optional = true }

161
lnvps_api/invoice.html Normal file
View File

@ -0,0 +1,161 @@
<!doctype html>
<html lang="en">
<head>
<title>{{payment.id}}</title>
<meta charset="UTF-8"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link
href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap"
rel="stylesheet"
/>
<style>
html, body {
margin: 0;
font-size: 12px;
font-family: "Source Code Pro", monospace;
}
@media screen {
.page {
margin-left: 4rem;
margin-right: 4rem;
}
}
.header {
display: flex;
gap: 2rem;
align-items: center;
justify-content: space-between;
font-size: 3rem;
margin: 2rem 0;
}
.billing {
display: grid;
grid-template-columns: 1fr 1fr;
}
.flex-col {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
table {
width: 100%;
border-collapse: collapse;
}
td, th {
border: 1px solid #ccc;
padding: 0.4em 0.1em;
}
.total {
text-align: end;
font-size: 16px;
font-weight: bold;
padding: 0.5em 0.2em;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
LNVPS
<img height="48" width="48" src="https://lnvps.net/logo.jpg" alt="logo"/>
</div>
<hr/>
<h2>Invoice</h2>
<div class="flex-col">
<div>
<b>ID:</b>
{{payment.id}}
</div>
<div>
<b>Date:</b>
{{payment.created}}
</div>
<div>
<b>Status:</b>
{{#payment.is_paid}}Paid{{/payment.is_paid}}
{{^payment.is_paid}}Unpaid{{/payment.is_paid}}
</div>
<div>
<b>Nostr Pubkey:</b>
{{npub}}
</div>
</div>
<div class="billing">
<div class="flex-col">
<h2>Bill To:</h2>
<div>{{user.name}}</div>
<div>{{user.address_1}}</div>
<div>{{user.address_2}}</div>
<div>{{user.city}}</div>
<div>{{user.state}}</div>
<div>{{user.postcode}}</div>
<div>{{user.country}}</div>
<div>{{user.country_code}}</div>
<div>{{user.tax_id}}</div>
</div>
{{#company}}
<div class="flex-col">
<h2>&nbsp;</h2>
<div>{{company.name}}</div>
<div>{{company.address_1}}</div>
<div>{{company.address_2}}</div>
<div>{{company.city}}</div>
<div>{{company.state}}</div>
<div>{{company.postcode}}</div>
<div>{{company.country}}</div>
<div>{{company.country_code}}</div>
<div>{{company.tax_id}}</div>
</div>
{{/company}}
</div>
<hr/>
<h2>Details:</h2>
<table>
<thead>
<tr>
<th>Description</th>
<th>Currency</th>
<th>Gross</th>
<th>Taxes</th>
</tr>
</thead>
<tbody>
<tr>
<td>
VM Renewal #{{vm.id}}
- {{vm.template.name}}
- {{vm.image.distribution}} {{vm.image.version}}
- {{payment.time}} seconds
</td>
<td>{{payment.currency}}</td>
<td>{{payment.amount}}</td>
<td>{{payment.tax}}</td>
</tr>
</tbody>
<tbody>
<tr>
<td colspan="4" class="total">
Total: {{total}}
</td>
</tr>
</tbody>
</table>
<br/>
<b>
All BTC amounts are in milli-satoshis and all fiat amounts are in cents.
</b>
<hr/>
<small>
(c) {{year}} LNVPS.net - Generated at {{current_date}}
</small>
</div>
</body>
</html>

View File

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

View File

@ -15,7 +15,7 @@ use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
#[derive(Serialize, Deserialize, JsonSchema)] #[derive(Serialize, JsonSchema)]
pub struct ApiVmStatus { pub struct ApiVmStatus {
/// Unique VM ID (Same in proxmox) /// Unique VM ID (Same in proxmox)
pub id: u64, pub id: u64,
@ -37,7 +37,7 @@ pub struct ApiVmStatus {
pub status: VmState, pub status: VmState,
} }
#[derive(Serialize, Deserialize, JsonSchema)] #[derive(Serialize, JsonSchema)]
pub struct ApiUserSshKey { pub struct ApiUserSshKey {
pub id: u64, pub id: u64,
pub name: String, 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 struct ApiVmIpAssignment {
pub id: u64, pub id: u64,
pub ip: String, pub ip: String,
@ -80,7 +80,7 @@ impl ApiVmIpAssignment {
} }
} }
#[derive(Clone, Copy, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum DiskType { pub enum DiskType {
HDD = 0, HDD = 0,
@ -105,7 +105,7 @@ impl From<DiskType> for lnvps_db::DiskType {
} }
} }
#[derive(Clone, Copy, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum DiskInterface { pub enum DiskInterface {
SATA = 0, SATA = 0,
@ -133,7 +133,7 @@ impl From<DiskInterface> for lnvps_db::DiskInterface {
} }
} }
#[derive(Serialize, Deserialize, JsonSchema)] #[derive(Serialize, JsonSchema)]
pub struct ApiTemplatesResponse { pub struct ApiTemplatesResponse {
pub templates: Vec<ApiVmTemplate>, pub templates: Vec<ApiVmTemplate>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -149,7 +149,7 @@ impl ApiTemplatesResponse {
CurrencyAmount::from_f32(template.cost_plan.currency, template.cost_plan.amount); CurrencyAmount::from_f32(template.cost_plan.currency, template.cost_plan.amount);
for alt_price in alt_prices(&rates, list_price) { for alt_price in alt_prices(&rates, list_price) {
template.cost_plan.other_price.push(ApiPrice { template.cost_plan.other_price.push(ApiPrice {
currency: alt_price.0, currency: alt_price.currency(),
amount: alt_price.value_f32(), amount: alt_price.value_f32(),
}); });
} }
@ -157,7 +157,7 @@ impl ApiTemplatesResponse {
Ok(()) Ok(())
} }
} }
#[derive(Serialize, Deserialize, JsonSchema)] #[derive(Serialize, JsonSchema)]
pub struct ApiCustomTemplateParams { pub struct ApiCustomTemplateParams {
pub id: u64, pub id: u64,
pub name: String, pub name: String,
@ -166,8 +166,6 @@ pub struct ApiCustomTemplateParams {
pub min_cpu: u16, pub min_cpu: u16,
pub min_memory: u64, pub min_memory: u64,
pub max_memory: u64, pub max_memory: u64,
pub min_disk: u64,
pub max_disk: u64,
pub disks: Vec<ApiCustomTemplateDiskParam>, pub disks: Vec<ApiCustomTemplateDiskParam>,
} }
@ -178,7 +176,7 @@ impl ApiCustomTemplateParams {
region: &VmHostRegion, region: &VmHostRegion,
max_cpu: u16, max_cpu: u16,
max_memory: u64, max_memory: u64,
max_disk: u64, max_disk: &HashMap<(DiskType, DiskInterface), u64>,
) -> Result<Self> { ) -> Result<Self> {
const GB: u64 = 1024 * 1024 * 1024; const GB: u64 = 1024 * 1024 * 1024;
Ok(ApiCustomTemplateParams { Ok(ApiCustomTemplateParams {
@ -192,14 +190,16 @@ impl ApiCustomTemplateParams {
min_cpu: 1, min_cpu: 1,
min_memory: GB, min_memory: GB,
max_memory, max_memory,
min_disk: GB * 5,
max_disk,
disks: disks disks: disks
.iter() .iter()
.filter(|d| d.pricing_id == pricing.id) .filter(|d| d.pricing_id == pricing.id)
.map(|d| ApiCustomTemplateDiskParam { .filter_map(|d| {
disk_type: d.kind.into(), Some(ApiCustomTemplateDiskParam {
disk_interface: d.interface.into(), min_disk: GB * 5,
max_disk: *max_disk.get(&(d.kind.into(), d.interface.into()))?,
disk_type: d.kind.into(),
disk_interface: d.interface.into(),
})
}) })
.collect(), .collect(),
}) })
@ -207,6 +207,8 @@ impl ApiCustomTemplateParams {
} }
#[derive(Clone, Serialize, Deserialize, JsonSchema)] #[derive(Clone, Serialize, Deserialize, JsonSchema)]
pub struct ApiCustomTemplateDiskParam { pub struct ApiCustomTemplateDiskParam {
pub min_disk: u64,
pub max_disk: u64,
pub disk_type: DiskType, pub disk_type: DiskType,
pub disk_interface: DiskInterface, pub disk_interface: DiskInterface,
} }
@ -253,7 +255,7 @@ pub struct ApiPrice {
impl From<CurrencyAmount> for ApiPrice { impl From<CurrencyAmount> for ApiPrice {
fn from(value: CurrencyAmount) -> Self { fn from(value: CurrencyAmount) -> Self {
Self { Self {
currency: value.0, currency: value.currency(),
amount: value.value_f32(), amount: value.value_f32(),
} }
} }
@ -401,10 +403,44 @@ pub struct VMPatchRequest {
#[derive(Serialize, Deserialize, JsonSchema)] #[derive(Serialize, Deserialize, JsonSchema)]
pub struct AccountPatchRequest { pub struct AccountPatchRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>, pub email: Option<String>,
pub contact_nip17: bool, pub contact_nip17: bool,
pub contact_email: bool, pub contact_email: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub country_code: Option<String>, pub country_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address_1: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address_2: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub city: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub postcode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_id: Option<String>,
}
impl From<lnvps_db::User> for AccountPatchRequest {
fn from(user: lnvps_db::User) -> Self {
AccountPatchRequest {
email: user.email,
contact_nip17: user.contact_nip17,
contact_email: user.contact_email,
country_code: user.country_code,
name: user.billing_name,
address_1: user.billing_address_1,
address_2: user.billing_address_2,
state: user.billing_state,
city: user.billing_city,
postcode: user.billing_postcode,
tax_id: user.billing_tax_id,
}
}
} }
#[derive(Serialize, Deserialize, JsonSchema)] #[derive(Serialize, Deserialize, JsonSchema)]
@ -455,6 +491,7 @@ pub struct ApiVmOsImage {
pub flavour: String, pub flavour: String,
pub version: String, pub version: String,
pub release_date: DateTime<Utc>, pub release_date: DateTime<Utc>,
pub default_username: Option<String>,
} }
impl From<lnvps_db::VmOsImage> for ApiVmOsImage { impl From<lnvps_db::VmOsImage> for ApiVmOsImage {
@ -465,6 +502,7 @@ impl From<lnvps_db::VmOsImage> for ApiVmOsImage {
flavour: image.flavour, flavour: image.flavour,
version: image.version, version: image.version,
release_date: image.release_date, release_date: image.release_date,
default_username: image.default_username,
} }
} }
} }
@ -480,6 +518,7 @@ pub struct ApiVmPayment {
pub currency: String, pub currency: String,
pub is_paid: bool, pub is_paid: bool,
pub data: ApiPaymentData, pub data: ApiPaymentData,
pub time: u64,
} }
impl From<lnvps_db::VmPayment> for ApiVmPayment { impl From<lnvps_db::VmPayment> for ApiVmPayment {
@ -493,6 +532,7 @@ impl From<lnvps_db::VmPayment> for ApiVmPayment {
tax: value.tax, tax: value.tax,
currency: value.currency, currency: value.currency,
is_paid: value.is_paid, is_paid: value.is_paid,
time: value.time_value,
data: match &value.payment_method { data: match &value.payment_method {
PaymentMethod::Lightning => ApiPaymentData::Lightning(value.external_data), PaymentMethod::Lightning => ApiPaymentData::Lightning(value.external_data),
PaymentMethod::Revolut => { PaymentMethod::Revolut => {
@ -552,3 +592,45 @@ impl From<PaymentMethod> for ApiPaymentMethod {
} }
} }
} }
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct ApiCompany {
pub id: u64,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address_1: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address_2: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub city: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub postcode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
}
impl From<lnvps_db::Company> for ApiCompany {
fn from(value: lnvps_db::Company) -> Self {
Self {
email: value.email,
country_code: value.country_code,
name: value.name,
id: value.id,
address_1: value.address_1,
address_2: value.address_2,
state: value.state,
city: value.city,
postcode: value.postcode,
tax_id: value.tax_id,
phone: value.phone,
}
}
}

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

@ -1,10 +1,10 @@
use crate::api::model::{ use crate::api::model::{
AccountPatchRequest, ApiCustomTemplateParams, ApiCustomVmOrder, ApiCustomVmRequest, AccountPatchRequest, ApiCompany, ApiCustomTemplateParams, ApiCustomVmOrder, ApiCustomVmRequest,
ApiPaymentInfo, ApiPaymentMethod, ApiPrice, ApiTemplatesResponse, ApiUserSshKey, ApiPaymentInfo, ApiPaymentMethod, ApiPrice, ApiTemplatesResponse, ApiUserSshKey,
ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, ApiVmTemplate, CreateSshKey, ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, ApiVmTemplate, CreateSshKey,
CreateVmRequest, VMPatchRequest, CreateVmRequest, VMPatchRequest,
}; };
use crate::exchange::{Currency, ExchangeRateService}; use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService};
use crate::host::{get_host_client, FullVmInfo, TimeSeries, TimeSeriesData}; use crate::host::{get_host_client, FullVmInfo, TimeSeries, TimeSeriesData};
use crate::nip98::Nip98Auth; use crate::nip98::Nip98Auth;
use crate::provisioner::{HostCapacityService, LNVpsProvisioner, PricingEngine}; use crate::provisioner::{HostCapacityService, LNVpsProvisioner, PricingEngine};
@ -12,25 +12,31 @@ use crate::settings::Settings;
use crate::status::{VmState, VmStateCache}; use crate::status::{VmState, VmStateCache};
use crate::worker::WorkJob; use crate::worker::WorkJob;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use chrono::{DateTime, Datelike, Utc};
use futures::future::join_all; use futures::future::join_all;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use isocountry::CountryCode; use isocountry::CountryCode;
use lnurl::pay::{LnURLPayInvoice, PayResponse};
use lnurl::Tag;
use lnvps_db::{ use lnvps_db::{
IpRange, LNVpsDb, PaymentMethod, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, IpRange, LNVpsDb, PaymentMethod, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate,
}; };
use log::{error, info}; use log::{error, info};
use nostr::util::hex; use nostr::util::hex;
use nostr::{ToBech32, Url};
use rocket::http::ContentType;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::{get, patch, post, routes, Responder, Route, State}; use rocket::{get, patch, post, routes, Responder, Route, State};
use rocket_okapi::gen::OpenApiGenerator; use rocket_okapi::gen::OpenApiGenerator;
use rocket_okapi::okapi::openapi3::Responses; use rocket_okapi::okapi::openapi3::Responses;
use rocket_okapi::response::OpenApiResponderInner; 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 schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ssh_key::PublicKey; use ssh_key::PublicKey;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fmt::Display; use std::fmt::Display;
use std::io::{BufWriter, Cursor};
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc::{Sender, UnboundedSender}; use tokio::sync::mpsc::{Sender, UnboundedSender};
@ -38,7 +44,7 @@ use tokio::sync::mpsc::{Sender, UnboundedSender};
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
let mut routes = vec![]; let mut routes = vec![];
routes.append(&mut openapi_get_routes![ let mut api_routes = openapi_get_routes![
v1_get_account, v1_get_account,
v1_patch_account, v1_patch_account,
v1_list_vms, v1_list_vms,
@ -58,18 +64,27 @@ pub fn routes() -> Vec<Route> {
v1_time_series, v1_time_series,
v1_custom_template_calc, v1_custom_template_calc,
v1_create_custom_vm_order, v1_create_custom_vm_order,
v1_get_payment_methods v1_get_payment_methods,
]); v1_payment_history
];
#[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.append(&mut routes![
v1_terminal_proxy,
v1_lnurlp,
v1_renew_vm_lnurlp,
v1_get_payment_invoice
]);
routes routes
} }
type ApiResult<T> = Result<Json<ApiData<T>>, ApiError>; pub type ApiResult<T> = Result<Json<ApiData<T>>, ApiError>;
#[derive(Serialize, Deserialize, JsonSchema)] #[derive(Serialize, Deserialize, JsonSchema)]
struct ApiData<T: Serialize> { pub struct ApiData<T: Serialize> {
pub data: T, pub data: T,
} }
@ -84,7 +99,7 @@ impl<T: Serialize> ApiData<T> {
#[derive(Serialize, Deserialize, JsonSchema, Responder)] #[derive(Serialize, Deserialize, JsonSchema, Responder)]
#[response(status = 500)] #[response(status = 500)]
struct ApiError { pub struct ApiError {
pub error: String, pub error: String,
} }
@ -122,6 +137,13 @@ async fn v1_patch_account(
.as_ref() .as_ref()
.and_then(|c| CountryCode::for_alpha3(c).ok()) .and_then(|c| CountryCode::for_alpha3(c).ok())
.map(|c| c.alpha3().to_string()); .map(|c| c.alpha3().to_string());
user.billing_name = req.name.clone();
user.billing_address_1 = req.address_1.clone();
user.billing_address_2 = req.address_2.clone();
user.billing_city = req.city.clone();
user.billing_state = req.state.clone();
user.billing_postcode = req.postcode.clone();
user.billing_tax_id = req.tax_id.clone();
db.update_user(&user).await?; db.update_user(&user).await?;
ApiData::ok(()) ApiData::ok(())
@ -138,12 +160,7 @@ async fn v1_get_account(
let uid = db.upsert_user(&pubkey).await?; let uid = db.upsert_user(&pubkey).await?;
let user = db.get_user(uid).await?; let user = db.get_user(uid).await?;
ApiData::ok(AccountPatchRequest { ApiData::ok(user.into())
email: user.email,
contact_nip17: user.contact_nip17,
contact_email: user.contact_email,
country_code: user.country_code,
})
} }
async fn vm_to_status( async fn vm_to_status(
@ -356,9 +373,18 @@ async fn v1_list_vm_templates(
let max_memory = templates.iter().map(|t| t.memory).max().unwrap_or(GB * 2); let max_memory = templates.iter().map(|t| t.memory).max().unwrap_or(GB * 2);
let max_disk = templates let max_disk = templates
.iter() .iter()
.map(|t| t.disk_size) .map(|t| (t.disk_type, t.disk_interface, t.disk_size))
.max() .fold(HashMap::new(), |mut acc, v| {
.unwrap_or(GB * 5); let k = (v.0.into(), v.1.into());
if let Some(mut x) = acc.get_mut(&k) {
if *x < v.2 {
*x = v.2;
}
} else {
acc.insert(k, v.2);
}
return acc;
});
Some( Some(
custom_templates custom_templates
.into_iter() .into_iter()
@ -370,7 +396,7 @@ async fn v1_list_vm_templates(
region, region,
max_cpu, max_cpu,
max_memory, max_memory,
max_disk, &max_disk,
) )
.ok() .ok()
}) })
@ -528,6 +554,65 @@ async fn v1_renew_vm(
ApiData::ok(rsp.into()) ApiData::ok(rsp.into())
} }
/// Extend a VM by LNURL payment
#[get("/api/v1/vm/<id>/renew-lnurlp?<amount>")]
async fn v1_renew_vm_lnurlp(
db: &State<Arc<dyn LNVpsDb>>,
provisioner: &State<Arc<LNVpsProvisioner>>,
id: u64,
amount: u64,
) -> Result<Json<LnURLPayInvoice>, &'static str> {
let vm = db.get_vm(id).await.map_err(|_e| "VM not found")?;
if vm.deleted {
return Err("VM not found");
}
if amount < 1000 {
return Err("Amount must be greater than 1000");
}
let rsp = provisioner
.renew_amount(
id,
CurrencyAmount::millisats(amount),
PaymentMethod::Lightning,
)
.await
.map_err(|_| "Error generating invoice")?;
// external_data is pr for lightning payment method
Ok(Json(LnURLPayInvoice::new(rsp.external_data)))
}
/// LNURL ad-hoc extend vm
#[get("/.well-known/lnurlp/<id>")]
async fn v1_lnurlp(
db: &State<Arc<dyn LNVpsDb>>,
settings: &State<Settings>,
id: u64,
) -> Result<Json<PayResponse>, &'static str> {
let vm = db.get_vm(id).await.map_err(|_e| "VM not found")?;
if vm.deleted {
return Err("VM not found");
}
let meta = vec![vec!["text/plain".to_string(), format!("Extend VM {}", id)]];
let rsp = PayResponse {
callback: Url::parse(&settings.public_url)
.map_err(|_| "Invalid public url")?
.join(&format!("/api/v1/vm/{}/renew-lnurlp", id))
.map_err(|_| "Could not get callback url")?
.to_string(),
max_sendable: 1_000_000_000,
min_sendable: 1_000, // TODO: calc min by using 1s extend time
tag: Tag::PayRequest,
metadata: serde_json::to_string(&meta).map_err(|_e| "Failed to serialize metadata")?,
comment_allowed: None,
allows_nostr: None,
nostr_pubkey: None,
};
Ok(Json(rsp))
}
/// Start a VM /// Start a VM
#[openapi(tag = "VM")] #[openapi(tag = "VM")]
#[patch("/api/v1/vm/<id>/start")] #[patch("/api/v1/vm/<id>/start")]
@ -800,3 +885,115 @@ async fn v1_get_payment(
ApiData::ok(payment.into()) ApiData::ok(payment.into())
} }
/// Print payment invoice
#[get("/api/v1/payment/<id>/invoice?<auth>")]
async fn v1_get_payment_invoice(
db: &State<Arc<dyn LNVpsDb>>,
id: &str,
auth: &str,
) -> Result<(ContentType, Vec<u8>), &'static str> {
let auth = Nip98Auth::from_base64(auth).map_err(|e| "Missing or invalid auth param")?;
if auth
.check(&format!("/api/v1/payment/{id}/invoice"), "GET")
.is_err()
{
return Err("Invalid auth event");
}
let pubkey = auth.event.pubkey.to_bytes();
let uid = db.upsert_user(&pubkey).await.map_err(|_| "Insert failed")?;
let id = if let Ok(i) = hex::decode(id) {
i
} else {
return Err("Invalid payment id");
};
let payment = db
.get_vm_payment(&id)
.await
.map_err(|_| "Payment not found")?;
let vm = db.get_vm(payment.vm_id).await.map_err(|_| "VM not found")?;
if vm.user_id != uid {
return Err("VM does not belong to you");
}
if !payment.is_paid {
return Err("Payment is not paid, can't generate invoice");
}
#[derive(Serialize)]
struct PaymentInfo {
year: i32,
current_date: DateTime<Utc>,
vm: ApiVmStatus,
payment: ApiVmPayment,
user: AccountPatchRequest,
npub: String,
total: u64,
company: Option<ApiCompany>,
}
let host = db
.get_host(vm.host_id)
.await
.map_err(|_| "Host not found")?;
let region = db
.get_host_region(host.region_id)
.await
.map_err(|_| "Region not found")?;
let company = if let Some(c) = region.company_id {
Some(db.get_company(c).await.map_err(|_| "Company not found")?)
} else {
None
};
let user = db.get_user(uid).await.map_err(|_| "User not found")?;
#[cfg(debug_assertions)]
let template =
mustache::compile_path("lnvps_api/invoice.html").map_err(|_| "Invalid template")?;
#[cfg(not(debug_assertions))]
let template = mustache::compile_str(include_str!("../../invoice.html"))
.map_err(|_| "Invalid template")?;
let now = Utc::now();
let mut html = Cursor::new(Vec::new());
template
.render(
&mut html,
&PaymentInfo {
year: now.year(),
current_date: now,
vm: vm_to_status(db, vm, None)
.await
.map_err(|_| "Failed to get VM state")?,
total: payment.amount + payment.tax,
payment: payment.into(),
npub: nostr::PublicKey::from_slice(&user.pubkey)
.map_err(|_| "Invalid pubkey")?
.to_bech32()
.unwrap(),
user: user.into(),
company: company.map(|c| c.into()),
},
)
.map_err(|_| "Failed to generate invoice")?;
Ok((ContentType::HTML, html.into_inner()))
}
/// List payment history of a VM
#[openapi(tag = "VM")]
#[get("/api/v1/vm/<id>/payments")]
async fn v1_payment_history(
auth: Nip98Auth,
db: &State<Arc<dyn LNVpsDb>>,
id: u64,
) -> ApiResult<Vec<ApiVmPayment>> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = db.upsert_user(&pubkey).await?;
let vm = db.get_vm(id).await?;
if vm.user_id != uid {
return ApiData::err("VM does not belong to you");
}
let payments = db.list_vm_payment(id).await?;
ApiData::ok(payments.into_iter().map(|i| i.into()).collect())
}

View File

@ -1,16 +1,16 @@
use anyhow::Error; use anyhow::Error;
use clap::Parser; use clap::Parser;
use config::{Config, File}; use config::{Config, File};
use lnvps::api; use lnvps_api::api;
use lnvps::cors::CORS; use lnvps_api::data_migration::run_data_migrations;
use lnvps::data_migration::run_data_migrations; use lnvps_api::dvm::start_dvms;
use lnvps::dvm::start_dvms; use lnvps_api::exchange::{DefaultRateCache, ExchangeRateService};
use lnvps::exchange::{DefaultRateCache, ExchangeRateService}; use lnvps_api::lightning::get_node;
use lnvps::lightning::get_node; use lnvps_api::payments::listen_all_payments;
use lnvps::payments::listen_all_payments; use lnvps_api::settings::Settings;
use lnvps::settings::Settings; use lnvps_api::status::VmStateCache;
use lnvps::status::VmStateCache; use lnvps_api::worker::{WorkJob, Worker};
use lnvps::worker::{WorkJob, Worker}; use lnvps_common::CORS;
use lnvps_db::{LNVpsDb, LNVpsDbMysql}; use lnvps_db::{LNVpsDb, LNVpsDbMysql};
use log::error; use log::error;
use nostr::Keys; use nostr::Keys;
@ -168,7 +168,7 @@ async fn main() -> Result<(), Error> {
.launch() .launch()
.await .await
{ {
error!("{}", e); error!("{:?}", e);
} }
Ok(()) Ok(())

View File

@ -1,12 +1,13 @@
use crate::data_migration::DataMigration; use crate::data_migration::DataMigration;
use crate::provisioner::{LNVpsProvisioner, NetworkProvisioner}; use crate::provisioner::{LNVpsProvisioner, NetworkProvisioner};
use chrono::Utc;
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
use lnvps_db::LNVpsDb; use lnvps_db::LNVpsDb;
use log::info;
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use log::info;
pub struct Ip6InitDataMigration { pub struct Ip6InitDataMigration {
db: Arc<dyn LNVpsDb>, db: Arc<dyn LNVpsDb>,
@ -27,15 +28,17 @@ impl DataMigration for Ip6InitDataMigration {
let net = NetworkProvisioner::new(db.clone()); let net = NetworkProvisioner::new(db.clone());
let vms = db.list_vms().await?; let vms = db.list_vms().await?;
for vm in vms { for vm in vms {
if vm.expires < Utc::now() {
continue;
}
let host = db.get_host(vm.host_id).await?; let host = db.get_host(vm.host_id).await?;
let ips = db.list_vm_ip_assignments(vm.id).await?; let ips = db.list_vm_ip_assignments(vm.id).await?;
// if no ipv6 address is picked already pick one // if no ipv6 address is picked already pick one
if ips.iter().all(|i| { if ips.iter().all(|i| {
IpNetwork::from_str(&i.ip) IpNetwork::from_str(&i.ip)
.map(|i| i.is_ipv4()) .map(|i| i.is_ipv4())
.unwrap_or(false) .unwrap_or(false)
}) }) {
{
let ips_pick = net.pick_ip_for_region(host.region_id).await?; let ips_pick = net.pick_ip_for_region(host.region_id).await?;
if let Some(mut v6) = ips_pick.ip6 { if let Some(mut v6) = ips_pick.ip6 {
info!("Assigning ip {} to vm {}", v6.ip, vm.id); 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::dns::DnsDataMigration;
use crate::data_migration::ip6_init::Ip6InitDataMigration;
use crate::provisioner::LNVpsProvisioner;
use crate::settings::Settings; use crate::settings::Settings;
use anyhow::Result; use anyhow::Result;
use lnvps_db::LNVpsDb; use lnvps_db::LNVpsDb;
@ -6,8 +8,6 @@ use log::{error, info};
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use crate::data_migration::ip6_init::Ip6InitDataMigration;
use crate::provisioner::LNVpsProvisioner;
mod dns; mod dns;
mod ip6_init; mod ip6_init;
@ -17,9 +17,16 @@ pub trait DataMigration: Send + Sync {
fn migrate(&self) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>; 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![]; 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) { if let Some(d) = DnsDataMigration::new(db.clone(), settings) {
migrations.push(Box::new(d)); migrations.push(Box::new(d));

View File

@ -6,9 +6,9 @@ use std::str::FromStr;
#[cfg(feature = "cloudflare")] #[cfg(feature = "cloudflare")]
mod cloudflare; mod cloudflare;
use crate::provisioner::NetworkProvisioner;
#[cfg(feature = "cloudflare")] #[cfg(feature = "cloudflare")]
pub use cloudflare::*; pub use cloudflare::*;
use crate::provisioner::NetworkProvisioner;
#[async_trait] #[async_trait]
pub trait DnsServer: Send + Sync { pub trait DnsServer: Send + Sync {

View File

@ -1,6 +1,6 @@
use anyhow::{anyhow, ensure, Result}; use anyhow::{anyhow, ensure, Result};
use lnvps_db::async_trait; use lnvps_db::async_trait;
use log::info; use log::{info, trace};
use rocket::serde::Deserialize; use rocket::serde::Deserialize;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Serialize; use serde::Serialize;
@ -15,6 +15,11 @@ pub enum Currency {
EUR, EUR,
BTC, BTC,
USD, USD,
GBP,
CAD,
CHF,
AUD,
JPY,
} }
impl Display for Currency { impl Display for Currency {
@ -23,6 +28,11 @@ impl Display for Currency {
Currency::EUR => write!(f, "EUR"), Currency::EUR => write!(f, "EUR"),
Currency::BTC => write!(f, "BTC"), Currency::BTC => write!(f, "BTC"),
Currency::USD => write!(f, "USD"), Currency::USD => write!(f, "USD"),
Currency::GBP => write!(f, "GBP"),
Currency::CAD => write!(f, "CAD"),
Currency::CHF => write!(f, "CHF"),
Currency::AUD => write!(f, "AUD"),
Currency::JPY => write!(f, "JPY"),
} }
} }
} }
@ -35,6 +45,11 @@ impl FromStr for Currency {
"eur" => Ok(Currency::EUR), "eur" => Ok(Currency::EUR),
"usd" => Ok(Currency::USD), "usd" => Ok(Currency::USD),
"btc" => Ok(Currency::BTC), "btc" => Ok(Currency::BTC),
"gbp" => Ok(Currency::GBP),
"cad" => Ok(Currency::CAD),
"chf" => Ok(Currency::CHF),
"aud" => Ok(Currency::AUD),
"jpy" => Ok(Currency::JPY),
_ => Err(()), _ => Err(()),
} }
} }
@ -60,21 +75,25 @@ impl Display for Ticker {
pub struct TickerRate(pub Ticker, pub f32); pub struct TickerRate(pub Ticker, pub f32);
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct CurrencyAmount(pub Currency, u64); pub struct CurrencyAmount(Currency, u64);
impl CurrencyAmount { impl CurrencyAmount {
const MILLI_SATS: f64 = 1.0e11; const MILLI_SATS: f64 = 1.0e11;
pub fn millisats(amount: u64) -> Self {
CurrencyAmount(Currency::BTC, amount)
}
pub fn from_u64(currency: Currency, amount: u64) -> Self { pub fn from_u64(currency: Currency, amount: u64) -> Self {
CurrencyAmount(currency, amount) CurrencyAmount(currency, amount)
} }
pub fn from_f32(currency: Currency, amount: f32) -> Self { pub fn from_f32(currency: Currency, amount: f32) -> Self {
CurrencyAmount( CurrencyAmount(
currency, currency,
match currency { match currency {
Currency::EUR => (amount * 100.0) as u64, // cents
Currency::BTC => (amount as f64 * Self::MILLI_SATS) as u64, // milli-sats Currency::BTC => (amount as f64 * Self::MILLI_SATS) as u64, // milli-sats
Currency::USD => (amount * 100.0) as u64, // cents _ => (amount * 100.0) as u64, // cents
}, },
) )
} }
@ -85,11 +104,14 @@ impl CurrencyAmount {
pub fn value_f32(&self) -> f32 { pub fn value_f32(&self) -> f32 {
match self.0 { match self.0 {
Currency::EUR => self.1 as f32 / 100.0,
Currency::BTC => (self.1 as f64 / Self::MILLI_SATS) as f32, Currency::BTC => (self.1 as f64 / Self::MILLI_SATS) as f32,
Currency::USD => self.1 as f32 / 100.0, _ => self.1 as f32 / 100.0,
} }
} }
pub fn currency(&self) -> Currency {
self.0
}
} }
impl TickerRate { impl TickerRate {
@ -167,13 +189,28 @@ impl ExchangeRateService for DefaultRateCache {
if let Some(eur) = rates.eur { if let Some(eur) = rates.eur {
ret.push(TickerRate(Ticker(Currency::BTC, Currency::EUR), eur)); ret.push(TickerRate(Ticker(Currency::BTC, Currency::EUR), eur));
} }
if let Some(gbp) = rates.gbp {
ret.push(TickerRate(Ticker(Currency::BTC, Currency::GBP), gbp));
}
if let Some(cad) = rates.cad {
ret.push(TickerRate(Ticker(Currency::BTC, Currency::CAD), cad));
}
if let Some(chf) = rates.chf {
ret.push(TickerRate(Ticker(Currency::BTC, Currency::CHF), chf));
}
if let Some(aud) = rates.aud {
ret.push(TickerRate(Ticker(Currency::BTC, Currency::AUD), aud));
}
if let Some(jpy) = rates.jpy {
ret.push(TickerRate(Ticker(Currency::BTC, Currency::JPY), jpy));
}
Ok(ret) Ok(ret)
} }
async fn set_rate(&self, ticker: Ticker, amount: f32) { async fn set_rate(&self, ticker: Ticker, amount: f32) {
let mut cache = self.cache.write().await; let mut cache = self.cache.write().await;
info!("{}: {}", &ticker, amount); trace!("{}: {}", &ticker, amount);
cache.insert(ticker, amount); cache.insert(ticker, amount);
} }
@ -194,6 +231,16 @@ struct MempoolRates {
pub usd: Option<f32>, pub usd: Option<f32>,
#[serde(rename = "EUR")] #[serde(rename = "EUR")]
pub eur: Option<f32>, pub eur: Option<f32>,
#[serde(rename = "CAD")]
pub cad: Option<f32>,
#[serde(rename = "GBP")]
pub gbp: Option<f32>,
#[serde(rename = "CHF")]
pub chf: Option<f32>,
#[serde(rename = "AUD")]
pub aud: Option<f32>,
#[serde(rename = "JPY")]
pub jpy: Option<f32>,
} }
#[cfg(test)] #[cfg(test)]

View File

@ -6,7 +6,7 @@ use anyhow::{bail, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use nostr::Url; use nostr::Url;
use reqwest::header::AUTHORIZATION; use reqwest::header::AUTHORIZATION;
use reqwest::{Client, Method, RequestBuilder}; use reqwest::{Method, RequestBuilder};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
@ -89,11 +89,10 @@ impl RevolutApi {
.post( .post(
"/api/orders", "/api/orders",
CreateOrderRequest { CreateOrderRequest {
currency: amount.0.to_string(), currency: amount.currency().to_string(),
amount: match amount.0 { amount: match amount.currency() {
Currency::BTC => bail!("Bitcoin amount not allowed for fiat payments"), Currency::BTC => bail!("Bitcoin amount not allowed for fiat payments"),
Currency::EUR => amount.value(), _ => amount.value(),
Currency::USD => amount.value(),
}, },
description, description,
}, },

View File

@ -0,0 +1,657 @@
use crate::host::{
FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostDiskInfo,
VmHostInfo,
};
use crate::settings::QemuConfig;
use crate::status::{VmRunningState, VmState};
use crate::KB;
use anyhow::{bail, ensure, Context, Result};
use chrono::Utc;
use lnvps_db::{async_trait, LNVpsDb, Vm, VmOsImage};
use log::info;
use rand::random;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use std::sync::Arc;
use uuid::Uuid;
use virt::connect::Connect;
use virt::domain::Domain;
use virt::sys::{
virDomainCreate, VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE, VIR_DOMAIN_START_VALIDATE,
};
#[derive(Debug)]
pub struct LibVirtHost {
connection: Connect,
qemu: QemuConfig,
}
impl LibVirtHost {
pub fn new(url: &str, qemu: QemuConfig) -> Result<Self> {
Ok(Self {
connection: Connect::open(Some(url))?,
qemu,
})
}
pub fn import_disk_image(&self, vm: &Vm, image: &VmOsImage) -> Result<()> {
// https://libvirt.org/html/libvirt-libvirt-storage.html#virStorageVolUpload
// https://libvirt.org/html/libvirt-libvirt-storage.html#virStorageVolResize
Ok(())
}
pub fn create_domain_xml(&self, cfg: &FullVmInfo) -> Result<DomainXML> {
let storage = self
.connection
.list_all_storage_pools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE)?;
// check the storage disk exists, we don't need anything else from it for now
let _storage_disk = if let Some(d) = storage
.iter()
.find(|s| s.get_name().map(|n| n == cfg.disk.name).unwrap_or(false))
{
d
} else {
bail!(
"Disk \"{}\" not found on host! Available pools: {}",
cfg.disk.name,
storage
.iter()
.filter_map(|s| s.get_name().ok())
.collect::<Vec<_>>()
.join(",")
);
};
let resources = cfg.resources()?;
let mut devices = vec![];
// primary disk
devices.push(DomainDevice::Disk(Disk {
kind: DiskType::File,
device: DiskDevice::Disk,
source: DiskSource {
file: Some(format!("{}:vm-{}-disk0", cfg.disk.name, cfg.vm.id)),
..Default::default()
},
target: DiskTarget {
dev: "vda".to_string(),
bus: Some(DiskBus::VirtIO),
},
}));
devices.push(DomainDevice::Interface(NetworkInterface {
kind: NetworkKind::Bridge,
mac: Some(NetworkMac {
address: cfg.vm.mac_address.clone(),
}),
source: Some(NetworkSource {
bridge: Some(self.qemu.bridge.clone()),
}),
target: None,
vlan: cfg.host.vlan_id.map(|v| NetworkVlan {
tags: vec![NetworkVlanTag { id: v as u32 }],
}),
}));
Ok(DomainXML {
kind: DomainType::KVM,
id: Some(cfg.vm.id),
name: Some(format!("VM{}", cfg.vm.id)),
uuid: None,
title: None,
description: None,
os: DomainOs {
kind: DomainOsType {
kind: DomainOsTypeKind::Hvm,
arch: Some(DomainOsArch::from_str(&self.qemu.arch)?),
machine: Some(DomainOsMachine::from_str(&self.qemu.machine)?),
},
firmware: Some(DomainOsFirmware::EFI),
loader: Some(DomainOsLoader {
read_only: None,
kind: None,
secure: Some(true),
stateless: None,
format: None,
}),
boot: DomainOsBoot {
dev: DomainOsBootDev::HardDrive,
},
},
vcpu: resources.cpu,
memory: resources.memory,
devices: DomainDevices { contents: devices },
})
}
}
#[async_trait]
impl VmHostClient for LibVirtHost {
async fn get_info(&self) -> Result<VmHostInfo> {
let info = self.connection.get_node_info()?;
let storage = self
.connection
.list_all_storage_pools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE)?;
Ok(VmHostInfo {
cpu: info.cpus as u16,
memory: info.memory * KB,
disks: storage
.iter()
.filter_map(|p| {
let info = p.get_info().ok()?;
Some(VmHostDiskInfo {
name: p.get_name().context("storage pool name is missing").ok()?,
size: info.capacity,
used: info.allocation,
})
})
.collect(),
})
}
async fn download_os_image(&self, image: &VmOsImage) -> Result<()> {
// TODO: download ISO images to host (somehow, ssh?)
Ok(())
}
async fn generate_mac(&self, _vm: &Vm) -> Result<String> {
Ok(format!(
"52:54:00:{}:{}:{}",
hex::encode([random::<u8>()]),
hex::encode([random::<u8>()]),
hex::encode([random::<u8>()])
))
}
async fn start_vm(&self, vm: &Vm) -> Result<()> {
Ok(())
}
async fn stop_vm(&self, vm: &Vm) -> Result<()> {
Ok(())
}
async fn reset_vm(&self, vm: &Vm) -> Result<()> {
Ok(())
}
async fn create_vm(&self, cfg: &FullVmInfo) -> Result<()> {
let domain = self.create_domain_xml(cfg)?;
let xml = quick_xml::se::to_string(&domain)?;
let domain = Domain::create_xml(&self.connection, &xml, VIR_DOMAIN_START_VALIDATE)?;
Ok(())
}
async fn delete_vm(&self, vm: &Vm) -> Result<()> {
todo!()
}
async fn reinstall_vm(&self, cfg: &FullVmInfo) -> Result<()> {
todo!()
}
async fn get_vm_state(&self, vm: &Vm) -> Result<VmState> {
Ok(VmState {
timestamp: Utc::now().timestamp() as u64,
state: VmRunningState::Stopped,
cpu_usage: 0.0,
mem_usage: 0.0,
uptime: 0,
net_in: 0,
net_out: 0,
disk_write: 0,
disk_read: 0,
})
}
async fn configure_vm(&self, vm: &FullVmInfo) -> Result<()> {
todo!()
}
async fn get_time_series_data(
&self,
vm: &Vm,
series: TimeSeries,
) -> Result<Vec<TimeSeriesData>> {
todo!()
}
async fn connect_terminal(&self, vm: &Vm) -> Result<TerminalStream> {
todo!()
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename = "domain")]
struct DomainXML {
#[serde(rename = "@type")]
pub kind: DomainType,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@id")]
pub id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uuid: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub os: DomainOs,
pub vcpu: u16,
pub memory: u64,
pub devices: DomainDevices,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename = "devices")]
struct DomainDevices {
#[serde(rename = "$value")]
pub contents: Vec<DomainDevice>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DomainType {
#[default]
KVM,
XEN,
HVF,
QEMU,
LXC,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename = "os")]
struct DomainOs {
#[serde(rename = "type")]
pub kind: DomainOsType,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@firmware")]
pub firmware: Option<DomainOsFirmware>,
#[serde(skip_serializing_if = "Option::is_none")]
pub loader: Option<DomainOsLoader>,
pub boot: DomainOsBoot,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DomainOsFirmware {
#[default]
EFI,
BIOS,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
struct DomainOsType {
#[serde(rename = "$text")]
pub kind: DomainOsTypeKind,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@arch")]
pub arch: Option<DomainOsArch>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@machine")]
pub machine: Option<DomainOsMachine>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DomainOsTypeKind {
#[default]
Hvm,
Xen,
Linux,
XenPvh,
Exe,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DomainOsMachine {
#[default]
Q35,
PC,
}
impl FromStr for DomainOsMachine {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"q35" => Ok(DomainOsMachine::Q35),
"pc" => Ok(DomainOsMachine::PC),
v => bail!("Unknown machine type {}", v),
}
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DomainOsArch {
#[default]
X86_64,
I686,
}
impl FromStr for DomainOsArch {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"x86_64" => Ok(Self::X86_64),
"i686" => Ok(Self::I686),
v => bail!("unsupported arch {}", v),
}
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename = "loader")]
struct DomainOsLoader {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@readonly")]
pub read_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@type")]
pub kind: Option<DomainOsLoaderType>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@secure")]
pub secure: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@stateless")]
pub stateless: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@format")]
pub format: Option<DomainOsLoaderFormat>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DomainOsLoaderType {
#[default]
ROM,
PFlash,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DomainOsLoaderFormat {
Raw,
#[default]
QCow2,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
struct DomainOsBoot {
#[serde(rename = "@dev")]
pub dev: DomainOsBootDev,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DomainOsBootDev {
#[serde(rename = "fd")]
Floppy,
#[serde(rename = "hd")]
#[default]
HardDrive,
CdRom,
Network,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename = "vcpu")]
struct DomainVCPU {
#[serde(rename = "$text")]
pub count: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
enum DomainDevice {
#[serde(rename = "disk")]
Disk(Disk),
#[serde(rename = "interface")]
Interface(NetworkInterface),
#[serde(other)]
Other,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename = "interface")]
struct NetworkInterface {
#[serde(rename = "@type")]
pub kind: NetworkKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub mac: Option<NetworkMac>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<NetworkSource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<NetworkTarget>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vlan: Option<NetworkVlan>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename = "vlan")]
struct NetworkVlan {
#[serde(rename = "tag")]
pub tags: Vec<NetworkVlanTag>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename = "tag")]
struct NetworkVlanTag {
#[serde(rename = "@id")]
pub id: u32,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum NetworkKind {
Network,
#[default]
Bridge,
User,
Ethernet,
Direct,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename = "mac")]
struct NetworkMac {
#[serde(rename = "@address")]
pub address: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename = "source")]
struct NetworkSource {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@bridge")]
pub bridge: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename = "target")]
struct NetworkTarget {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@dev")]
pub dev: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename = "disk")]
struct Disk {
#[serde(rename = "@type")]
pub kind: DiskType,
#[serde(rename = "@device")]
pub device: DiskDevice,
pub source: DiskSource,
pub target: DiskTarget,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DiskType {
#[default]
File,
Block,
Dir,
Network,
Volume,
Nvme,
VHostUser,
VHostVdpa,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DiskDevice {
Floppy,
#[default]
Disk,
CdRom,
Lun,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename = "source")]
struct DiskSource {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@file")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@dir")]
pub dir: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename = "target")]
struct DiskTarget {
/// Device name (hint)
#[serde(rename = "@dev")]
pub dev: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "@bus")]
pub bus: Option<DiskBus>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "lowercase")]
enum DiskBus {
#[default]
IDE,
SCSI,
VirtIO,
XEN,
USB,
SATA,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::tests::mock_full_vm;
fn cfg() -> FullVmInfo {
let mut cfg = mock_full_vm();
// adjust mock data for libvirt test driver
cfg.disk.name = "default-pool".to_string();
cfg
}
#[test]
fn test_xml_os() -> Result<()> {
let tag = "<os firmware=\"efi\"><type>hvm</type><boot dev=\"hd\"/></os>";
let test = DomainOs {
kind: DomainOsType {
kind: DomainOsTypeKind::Hvm,
arch: None,
machine: None,
},
firmware: Some(DomainOsFirmware::EFI),
loader: None,
boot: DomainOsBoot {
dev: DomainOsBootDev::HardDrive,
},
};
let xml = quick_xml::se::to_string(&test)?;
assert_eq!(tag, xml);
Ok(())
}
#[test]
fn text_xml_disk() -> Result<()> {
let tag = "<disk type=\"file\" device=\"disk\"><source file=\"/var/lib/libvirt/images/disk.qcow2\"/><target dev=\"vda\" bus=\"virtio\"/></disk>";
let test = Disk {
kind: DiskType::File,
device: DiskDevice::Disk,
source: DiskSource {
file: Some("/var/lib/libvirt/images/disk.qcow2".to_string()),
..Default::default()
},
target: DiskTarget {
dev: "vda".to_string(),
bus: Some(DiskBus::VirtIO),
},
};
let xml = quick_xml::se::to_string(&test)?;
assert_eq!(tag, xml);
Ok(())
}
#[test]
fn text_config_to_domain() -> Result<()> {
let cfg = cfg();
let template = cfg.template.clone().unwrap();
let q_cfg = QemuConfig {
machine: "q35".to_string(),
os_type: "l26".to_string(),
bridge: "vmbr0".to_string(),
cpu: "kvm64".to_string(),
kvm: true,
arch: "x86_64".to_string(),
};
let host = LibVirtHost::new("test:///default", q_cfg)?;
let xml = host.create_domain_xml(&cfg)?;
let res = cfg.resources()?;
assert_eq!(xml.vcpu, res.cpu);
assert_eq!(xml.memory, res.memory);
let xml = quick_xml::se::to_string(&xml)?;
println!("{}", xml);
let output = r#"<domain type="kvm" id="1"><name>VM1</name><os firmware="efi"><type arch="x86_64" machine="q35">hvm</type><loader secure="true"/><boot dev="hd"/></os><vcpu>2</vcpu><memory>2147483648</memory><devices><disk type="file" device="disk"><source file="default-pool:vm-1-disk0"/><target dev="vda" bus="virtio"/></disk><interface type="bridge"><mac address="ff:ff:ff:ff:ff:fe"/><source bridge="vmbr0"/><vlan><tag id="100"/></vlan></interface></devices></domain>"#;
assert_eq!(xml, output);
Ok(())
}
#[ignore]
#[tokio::test]
async fn text_vm_lifecycle() -> Result<()> {
let cfg = cfg();
let template = cfg.template.clone().unwrap();
let q_cfg = QemuConfig {
machine: "q35".to_string(),
os_type: "l26".to_string(),
bridge: "vmbr0".to_string(),
cpu: "kvm64".to_string(),
kvm: true,
arch: "x86_64".to_string(),
};
let host = LibVirtHost::new("test:///default", q_cfg)?;
println!("{:?}", host.get_info().await?);
host.create_vm(&cfg).await?;
Ok(())
}
}

373
lnvps_api/src/host/mod.rs Normal file
View File

@ -0,0 +1,373 @@
use crate::settings::ProvisionerConfig;
use crate::status::VmState;
use anyhow::{bail, Result};
use futures::future::join_all;
use lnvps_db::{
async_trait, IpRange, LNVpsDb, UserSshKey, Vm, VmCustomTemplate, VmHost, VmHostDisk,
VmHostKind, VmIpAssignment, VmOsImage, VmTemplate,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::mpsc::{Receiver, Sender};
#[cfg(feature = "libvirt")]
mod libvirt;
#[cfg(feature = "proxmox")]
mod proxmox;
pub struct TerminalStream {
pub rx: Receiver<Vec<u8>>,
pub tx: Sender<Vec<u8>>,
}
/// Generic type for creating VM's
#[async_trait]
pub trait VmHostClient: Send + Sync {
async fn get_info(&self) -> Result<VmHostInfo>;
/// Download OS image to the host
async fn download_os_image(&self, image: &VmOsImage) -> Result<()>;
/// Create a random MAC address for the NIC
async fn generate_mac(&self, vm: &Vm) -> Result<String>;
/// Start a VM
async fn start_vm(&self, vm: &Vm) -> Result<()>;
/// Stop a VM
async fn stop_vm(&self, vm: &Vm) -> Result<()>;
/// Reset VM (Hard)
async fn reset_vm(&self, vm: &Vm) -> Result<()>;
/// Spawn a VM
async fn create_vm(&self, cfg: &FullVmInfo) -> Result<()>;
/// Delete a VM
async fn delete_vm(&self, vm: &Vm) -> Result<()>;
/// Re-install a vm OS
async fn reinstall_vm(&self, cfg: &FullVmInfo) -> Result<()>;
/// Get the running status of a VM
async fn get_vm_state(&self, vm: &Vm) -> Result<VmState>;
/// Apply vm configuration (patch)
async fn configure_vm(&self, cfg: &FullVmInfo) -> Result<()>;
/// Get resource usage data
async fn get_time_series_data(
&self,
vm: &Vm,
series: TimeSeries,
) -> Result<Vec<TimeSeriesData>>;
/// Connect to terminal serial port
async fn connect_terminal(&self, vm: &Vm) -> Result<TerminalStream>;
}
pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result<Arc<dyn VmHostClient>> {
#[cfg(test)]
return Ok(Arc::new(crate::mocks::MockVmHost::new()));
Ok(match host.kind.clone() {
#[cfg(feature = "proxmox")]
VmHostKind::Proxmox if cfg.proxmox.is_some() => {
let cfg = cfg.proxmox.clone().unwrap();
Arc::new(proxmox::ProxmoxClient::new(
host.ip.parse()?,
&host.name,
&host.api_token,
cfg.mac_prefix,
cfg.qemu,
cfg.ssh,
))
}
#[cfg(feature = "libvirt")]
VmHostKind::LibVirt if cfg.libvirt.is_some() => {
let cfg = cfg.libvirt.clone().unwrap();
Arc::new(libvirt::LibVirtHost::new(&host.ip, cfg.qemu)?)
}
_ => bail!("Unknown host config: {}", host.kind),
})
}
/// All VM info necessary to provision a VM and its associated resources
pub struct FullVmInfo {
/// Instance to create
pub vm: Vm,
/// Host where the VM will be spawned
pub host: VmHost,
/// Disk where this VM will be saved on the host
pub disk: VmHostDisk,
/// VM template resources
pub template: Option<VmTemplate>,
/// VM custom template resources
pub custom_template: Option<VmCustomTemplate>,
/// The OS image used to create the VM
pub image: VmOsImage,
/// List of IP resources assigned to this VM
pub ips: Vec<VmIpAssignment>,
/// Ranges associated with [ips]
pub ranges: Vec<IpRange>,
/// SSH key to access the VM
pub ssh_key: UserSshKey,
}
impl FullVmInfo {
pub async fn load(vm_id: u64, db: Arc<dyn LNVpsDb>) -> Result<Self> {
let vm = db.get_vm(vm_id).await?;
let host = db.get_host(vm.host_id).await?;
let image = db.get_os_image(vm.image_id).await?;
let disk = db.get_host_disk(vm.disk_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 ranges: Vec<IpRange> = join_all(ip_ranges)
.await
.into_iter()
.filter_map(Result::ok)
.collect();
let template = if let Some(t) = vm.template_id {
Some(db.get_vm_template(t).await?)
} else {
None
};
let custom_template = if let Some(t) = vm.custom_template_id {
Some(db.get_custom_vm_template(t).await?)
} else {
None
};
// create VM
Ok(FullVmInfo {
vm,
host,
template,
custom_template,
image,
ips,
disk,
ranges,
ssh_key,
})
}
/// CPU cores
pub fn resources(&self) -> Result<VmResources> {
if let Some(t) = &self.template {
Ok(VmResources {
cpu: t.cpu,
memory: t.memory,
disk_size: t.disk_size,
})
} else if let Some(t) = &self.custom_template {
Ok(VmResources {
cpu: t.cpu,
memory: t.memory,
disk_size: t.disk_size,
})
} else {
bail!("Invalid VM config, no template");
}
}
}
#[derive(Clone)]
pub struct VmResources {
pub cpu: u16,
pub memory: u64,
pub disk_size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TimeSeriesData {
pub timestamp: u64,
pub cpu: f32,
pub memory: f32,
pub memory_size: u64,
pub net_in: f32,
pub net_out: f32,
pub disk_write: f32,
pub disk_read: f32,
}
#[derive(Debug, Clone)]
pub enum TimeSeries {
Hourly,
Daily,
Weekly,
Monthly,
Yearly,
}
#[derive(Debug, Clone)]
pub struct VmHostInfo {
pub cpu: u16,
pub memory: u64,
pub disks: Vec<VmHostDiskInfo>,
}
#[derive(Debug, Clone)]
pub struct VmHostDiskInfo {
pub name: String,
pub size: u64,
pub used: u64,
}
#[cfg(test)]
mod tests {
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 {
id: 1,
name: "example".to_string(),
enabled: true,
created: Default::default(),
expires: None,
cpu: 2,
memory: 2 * GB,
disk_size: 100 * GB,
disk_type: DiskType::SSD,
disk_interface: DiskInterface::PCIe,
cost_plan_id: 1,
region_id: 1,
};
FullVmInfo {
vm: Vm {
id: 1,
host_id: 1,
user_id: 1,
image_id: 1,
template_id: Some(template.id),
custom_template_id: None,
ssh_key_id: 1,
created: Default::default(),
expires: Default::default(),
disk_id: 1,
mac_address: "ff:ff:ff:ff:ff:fe".to_string(),
deleted: false,
ref_code: None,
},
host: VmHost {
id: 1,
kind: Default::default(),
region_id: 1,
name: "mock".to_string(),
ip: "https://localhost:8006".to_string(),
cpu: 20,
memory: 128 * GB,
enabled: true,
api_token: "mock".to_string(),
load_cpu: 1.0,
load_memory: 1.0,
load_disk: 1.0,
vlan_id: Some(100),
},
disk: VmHostDisk {
id: 1,
host_id: 1,
name: "ssd".to_string(),
size: TB * 20,
kind: DiskType::SSD,
interface: DiskInterface::PCIe,
enabled: true,
},
template: Some(template.clone()),
custom_template: None,
image: VmOsImage {
id: 1,
distribution: OsDistribution::Ubuntu,
flavour: "Server".to_string(),
version: "24.04.03".to_string(),
enabled: true,
release_date: Utc::now(),
url: "http://localhost.com/ubuntu_server_24.04.img".to_string(),
default_username: None,
},
ips: vec![
VmIpAssignment {
id: 1,
vm_id: 1,
ip_range_id: 1,
ip: "192.168.1.2".to_string(),
deleted: false,
arp_ref: None,
dns_forward: None,
dns_forward_ref: None,
dns_reverse: None,
dns_reverse_ref: None,
},
VmIpAssignment {
id: 2,
vm_id: 1,
ip_range_id: 2,
ip: "192.168.2.2".to_string(),
deleted: false,
arp_ref: None,
dns_forward: None,
dns_forward_ref: None,
dns_reverse: None,
dns_reverse_ref: None,
},
VmIpAssignment {
id: 3,
vm_id: 1,
ip_range_id: 3,
ip: "fd00::ff:ff:ff:ff:ff".to_string(),
deleted: false,
arp_ref: None,
dns_forward: None,
dns_forward_ref: None,
dns_reverse: None,
dns_reverse_ref: None,
},
],
ranges: vec![
IpRange {
id: 1,
cidr: "192.168.1.0/24".to_string(),
gateway: "192.168.1.1/16".to_string(),
enabled: true,
region_id: 1,
..Default::default()
},
IpRange {
id: 2,
cidr: "192.168.2.0/24".to_string(),
gateway: "10.10.10.10".to_string(),
enabled: true,
region_id: 2,
..Default::default()
},
IpRange {
id: 3,
cidr: "fd00::/64".to_string(),
gateway: "fd00::1".to_string(),
enabled: true,
region_id: 1,
allocation_mode: IpRangeAllocationMode::SlaacEui64,
..Default::default()
},
],
ssh_key: UserSshKey {
id: 1,
name: "test".to_string(),
user_id: 1,
created: Default::default(),
key_data: "ssh-ed25519 AAA=".to_string(),
},
}
}
}

View File

@ -6,7 +6,7 @@ use crate::json_api::JsonApi;
use crate::settings::{QemuConfig, SshConfig}; use crate::settings::{QemuConfig, SshConfig};
use crate::ssh_client::SshClient; use crate::ssh_client::SshClient;
use crate::status::{VmRunningState, VmState}; use crate::status::{VmRunningState, VmState};
use anyhow::{anyhow, bail, ensure, Result}; use anyhow::{anyhow, bail, ensure, Context, Result};
use chrono::Utc; use chrono::Utc;
use futures::StreamExt; use futures::StreamExt;
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
@ -500,14 +500,25 @@ impl VmHostClient for ProxmoxClient {
if let Some(n) = nodes.iter().find(|n| n.name == self.node) { if let Some(n) = nodes.iter().find(|n| n.name == self.node) {
let storages = self.list_storage(&n.name).await?; let storages = self.list_storage(&n.name).await?;
let info = VmHostInfo { let info = VmHostInfo {
cpu: n.max_cpu.unwrap_or(0), cpu: n.max_cpu
memory: n.max_mem.unwrap_or(0), .context("Missing cpu count, please make sure you have Sys.Audit permission")?,
memory: n.max_mem
.context("Missing memory size, please make sure you have Sys.Audit permission")?,
disks: storages disks: storages
.into_iter() .into_iter()
.map(|s| VmHostDiskInfo { .filter_map(|s| {
name: s.storage, let size = s.total
size: s.total.unwrap_or(0), .context("Missing disk size, please make sure you have Datastore.Audit permission")
used: s.used.unwrap_or(0), .ok()?;
let used = s.used
.context("Missing used disk, please make sure you have Datastore.Audit permission")
.ok()?;
Some(VmHostDiskInfo {
name: s.storage,
size,
used,
})
}) })
.collect(), .collect(),
}; };
@ -600,6 +611,32 @@ impl VmHostClient for ProxmoxClient {
Ok(()) Ok(())
} }
async fn delete_vm(&self, vm: &Vm) -> Result<()> {
let vm_id: ProxmoxVmId = vm.id.into();
// NOT IMPLEMENTED
//let t = self.delete_vm(&self.node, vm_id).await?;
//self.wait_for_task(&t).await?;
if let Some(ssh) = &self.ssh {
let mut ses = SshClient::new()?;
ses.connect(
(self.api.base().host().unwrap().to_string(), 22),
&ssh.user,
&ssh.key,
)
.await?;
let cmd = format!("/usr/sbin/qm destroy {}", vm_id,);
let (code, rsp) = ses.execute(cmd.as_str()).await?;
info!("{}", rsp);
if code != 0 {
bail!("Failed to destroy vm, exit-code {}, {}", code, rsp);
}
}
Ok(())
}
async fn reinstall_vm(&self, req: &FullVmInfo) -> Result<()> { async fn reinstall_vm(&self, req: &FullVmInfo) -> Result<()> {
let vm_id = req.vm.id.into(); let vm_id = req.vm.id.into();
@ -1115,152 +1152,13 @@ impl From<RrdDataPoint> for TimeSeriesData {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{GB, MB, TB}; use crate::host::tests::mock_full_vm;
use lnvps_db::{ use crate::MB;
DiskInterface, IpRange, IpRangeAllocationMode, OsDistribution, UserSshKey, VmHost,
VmHostDisk, VmIpAssignment, VmTemplate,
};
#[test] #[test]
fn test_config() -> Result<()> { fn test_config() -> Result<()> {
let template = VmTemplate { let cfg = mock_full_vm();
id: 1, let template = cfg.template.clone().unwrap();
name: "example".to_string(),
enabled: true,
created: Default::default(),
expires: None,
cpu: 2,
memory: 2 * GB,
disk_size: 100 * GB,
disk_type: DiskType::SSD,
disk_interface: DiskInterface::PCIe,
cost_plan_id: 1,
region_id: 1,
};
let cfg = FullVmInfo {
vm: Vm {
id: 1,
host_id: 1,
user_id: 1,
image_id: 1,
template_id: Some(template.id),
custom_template_id: None,
ssh_key_id: 1,
created: Default::default(),
expires: Default::default(),
disk_id: 1,
mac_address: "ff:ff:ff:ff:ff:fe".to_string(),
deleted: false,
ref_code: None,
},
host: VmHost {
id: 1,
kind: Default::default(),
region_id: 1,
name: "mock".to_string(),
ip: "https://localhost:8006".to_string(),
cpu: 20,
memory: 128 * GB,
enabled: true,
api_token: "mock".to_string(),
load_cpu: 1.0,
load_memory: 1.0,
load_disk: 1.0,
vlan_id: Some(100),
},
disk: VmHostDisk {
id: 1,
host_id: 1,
name: "ssd".to_string(),
size: TB * 20,
kind: DiskType::SSD,
interface: DiskInterface::PCIe,
enabled: true,
},
template: Some(template.clone()),
custom_template: None,
image: VmOsImage {
id: 1,
distribution: OsDistribution::Ubuntu,
flavour: "Server".to_string(),
version: "24.04.03".to_string(),
enabled: true,
release_date: Utc::now(),
url: "http://localhost.com/ubuntu_server_24.04.img".to_string(),
},
ips: vec![
VmIpAssignment {
id: 1,
vm_id: 1,
ip_range_id: 1,
ip: "192.168.1.2".to_string(),
deleted: false,
arp_ref: None,
dns_forward: None,
dns_forward_ref: None,
dns_reverse: None,
dns_reverse_ref: None,
},
VmIpAssignment {
id: 2,
vm_id: 1,
ip_range_id: 2,
ip: "192.168.2.2".to_string(),
deleted: false,
arp_ref: None,
dns_forward: None,
dns_forward_ref: None,
dns_reverse: None,
dns_reverse_ref: None,
},
VmIpAssignment {
id: 3,
vm_id: 1,
ip_range_id: 3,
ip: "fd00::ff:ff:ff:ff:ff".to_string(),
deleted: false,
arp_ref: None,
dns_forward: None,
dns_forward_ref: None,
dns_reverse: None,
dns_reverse_ref: None,
},
],
ranges: vec![
IpRange {
id: 1,
cidr: "192.168.1.0/24".to_string(),
gateway: "192.168.1.1/16".to_string(),
enabled: true,
region_id: 1,
..Default::default()
},
IpRange {
id: 2,
cidr: "192.168.2.0/24".to_string(),
gateway: "10.10.10.10".to_string(),
enabled: true,
region_id: 2,
..Default::default()
},
IpRange {
id: 3,
cidr: "fd00::/64".to_string(),
gateway: "fd00::1".to_string(),
enabled: true,
region_id: 1,
allocation_mode: IpRangeAllocationMode::SlaacEui64,
..Default::default()
},
],
ssh_key: UserSshKey {
id: 1,
name: "test".to_string(),
user_id: 1,
created: Default::default(),
key_data: "ssh-ed25519 AAA=".to_string(),
},
};
let q_cfg = QemuConfig { let q_cfg = QemuConfig {
machine: "q35".to_string(), machine: "q35".to_string(),
@ -1268,6 +1166,7 @@ mod tests {
bridge: "vmbr1".to_string(), bridge: "vmbr1".to_string(),
cpu: "kvm64".to_string(), cpu: "kvm64".to_string(),
kvm: true, kvm: true,
arch: "x86_64".to_string(),
}; };
let p = ProxmoxClient::new( let p = ProxmoxClient::new(

View File

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

View File

@ -1,14 +1,16 @@
#![allow(unused)] #![allow(unused)]
use crate::dns::{BasicRecord, DnsServer, RecordType}; use crate::dns::{BasicRecord, DnsServer, RecordType};
use crate::exchange::{ExchangeRateService, Ticker, TickerRate}; use crate::exchange::{ExchangeRateService, Ticker, TickerRate};
use crate::host::{FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostInfo}; use crate::host::{
FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostInfo,
};
use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode}; use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode};
use crate::router::{ArpEntry, Router}; use crate::router::{ArpEntry, Router};
use crate::status::{VmRunningState, VmState}; use crate::status::{VmRunningState, VmState};
use anyhow::{anyhow, bail, ensure, Context}; use anyhow::{anyhow, bail, ensure, Context};
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use fedimint_tonic_lnd::tonic::codegen::tokio_stream::Stream; 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}; use lnvps_db::{async_trait, AccessPolicy, Company, 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::collections::HashMap;
use std::ops::Add; use std::ops::Add;
use std::pin::Pin; use std::pin::Pin;
@ -101,6 +103,7 @@ impl Default for MockDb {
id: 1, id: 1,
name: "Mock".to_string(), name: "Mock".to_string(),
enabled: true, enabled: true,
company_id: None,
}, },
); );
let mut ip_ranges = HashMap::new(); let mut ip_ranges = HashMap::new();
@ -174,6 +177,7 @@ impl Default for MockDb {
enabled: true, enabled: true,
release_date: Utc::now(), release_date: Utc::now(),
url: "https://example.com/debian_12.img".to_string(), url: "https://example.com/debian_12.img".to_string(),
default_username: None,
}, },
); );
Self { Self {
@ -198,6 +202,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] #[async_trait]
impl LNVpsDb for MockDb { impl LNVpsDb for MockDb {
async fn migrate(&self) -> anyhow::Result<()> { async fn migrate(&self) -> anyhow::Result<()> {
@ -216,10 +271,8 @@ impl LNVpsDb for MockDb {
id: max + 1, id: max + 1,
pubkey: pubkey.to_vec(), pubkey: pubkey.to_vec(),
created: Utc::now(), created: Utc::now(),
email: None,
contact_nip17: false,
contact_email: false,
country_code: Some("USA".to_string()), country_code: Some("USA".to_string()),
..Default::default()
}, },
); );
Ok(max + 1) Ok(max + 1)
@ -475,6 +528,7 @@ impl LNVpsDb for MockDb {
let mut vms = self.vms.lock().await; let mut vms = self.vms.lock().await;
if let Some(v) = vms.get_mut(&vm.id) { if let Some(v) = vms.get_mut(&vm.id) {
v.ssh_key_id = vm.ssh_key_id; v.ssh_key_id = vm.ssh_key_id;
v.mac_address = vm.mac_address.clone();
} }
Ok(()) Ok(())
} }
@ -637,6 +691,10 @@ impl LNVpsDb for MockDb {
.cloned() .cloned()
.context("no access policy")?) .context("no access policy")?)
} }
async fn get_company(&self, company_id: u64) -> anyhow::Result<Company> {
todo!()
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -826,6 +884,12 @@ impl VmHostClient for MockVmHost {
Ok(()) Ok(())
} }
async fn delete_vm(&self, vm: &Vm) -> anyhow::Result<()> {
let mut vms = self.vms.lock().await;
vms.remove(&vm.id);
Ok(())
}
async fn reinstall_vm(&self, cfg: &FullVmInfo) -> anyhow::Result<()> { async fn reinstall_vm(&self, cfg: &FullVmInfo) -> anyhow::Result<()> {
todo!() todo!()
} }
@ -896,7 +960,10 @@ impl DnsServer for MockDnsServer {
zones.get_mut(zone_id).unwrap() zones.get_mut(zone_id).unwrap()
}; };
if table.values().any(|v| v.name == record.name && v.kind == record.kind.to_string()) { if table
.values()
.any(|v| v.name == record.name && v.kind == record.kind.to_string())
{
bail!("Duplicate record with name {}", record.name); bail!("Duplicate record with name {}", record.name);
} }

View File

@ -304,9 +304,6 @@ pub struct IPRangeCapacity {
} }
impl IPRangeCapacity { impl IPRangeCapacity {
// first/last/gw
const RESERVED: u128 = 3;
/// Total number of IPs free /// Total number of IPs free
pub fn available_capacity(&self) -> u128 { pub fn available_capacity(&self) -> u128 {
let net: IpNetwork = self.range.cidr.parse().unwrap(); let net: IpNetwork = self.range.cidr.parse().unwrap();
@ -315,7 +312,11 @@ impl IPRangeCapacity {
NetworkSize::V4(s) => (s as u128).saturating_sub(self.usage), NetworkSize::V4(s) => (s as u128).saturating_sub(self.usage),
NetworkSize::V6(s) => s.saturating_sub(self.usage), NetworkSize::V6(s) => s.saturating_sub(self.usage),
} }
.saturating_sub(Self::RESERVED) .saturating_sub(if self.range.use_full_range {
1 // gw
} else {
3 // first/last/gw
})
} }
} }

View File

@ -286,22 +286,6 @@ impl LNVpsProvisioner {
}) })
} }
pub async fn assign_available_v4_to_vm(
&self,
vm: &Vm,
v4: &AvailableIp,
) -> Result<VmIpAssignment> {
let mut assignment = VmIpAssignment {
vm_id: vm.id,
ip_range_id: v4.range_id,
ip: v4.ip.ip().to_string(),
..Default::default()
};
self.save_ip_assignment(&mut assignment).await?;
Ok(assignment)
}
pub async fn assign_available_v6_to_vm( pub async fn assign_available_v6_to_vm(
&self, &self,
vm: &Vm, vm: &Vm,
@ -342,7 +326,12 @@ impl LNVpsProvisioner {
let mut assignments = vec![]; let mut assignments = vec![];
match ip.ip4 { match ip.ip4 {
Some(v4) => { Some(v4) => {
let mut assignment = self.assign_available_v4_to_vm(&vm, &v4).await?; let mut assignment = VmIpAssignment {
vm_id: vm.id,
ip_range_id: v4.range_id,
ip: v4.ip.ip().to_string(),
..Default::default()
};
//generate mac address from ip assignment //generate mac address from ip assignment
let mac = self.get_mac_for_assignment(&host, &vm, &assignment).await?; let mac = self.get_mac_for_assignment(&host, &vm, &assignment).await?;
@ -350,6 +339,7 @@ impl LNVpsProvisioner {
assignment.arp_ref = mac.id; // store ref if we got one assignment.arp_ref = mac.id; // store ref if we got one
self.db.update_vm(&vm).await?; self.db.update_vm(&vm).await?;
self.save_ip_assignment(&mut assignment).await?;
assignments.push(assignment); assignments.push(assignment);
} }
/// TODO: add expected number of IPS per templates /// TODO: add expected number of IPS per templates
@ -469,6 +459,7 @@ impl LNVpsProvisioner {
// insert custom templates // insert custom templates
let template_id = self.db.insert_custom_vm_template(&template).await?; let template_id = self.db.insert_custom_vm_template(&template).await?;
let now = Utc::now();
let mut new_vm = Vm { let mut new_vm = Vm {
id: 0, id: 0,
host_id: host.host.id, host_id: host.host.id,
@ -477,8 +468,8 @@ impl LNVpsProvisioner {
template_id: None, template_id: None,
custom_template_id: Some(template_id), custom_template_id: Some(template_id),
ssh_key_id: ssh_key.id, ssh_key_id: ssh_key.id,
created: Utc::now(), created: now,
expires: Utc::now(), expires: now,
disk_id: pick_disk.disk.id, disk_id: pick_disk.disk.id,
mac_address: "ff:ff:ff:ff:ff:ff".to_string(), mac_address: "ff:ff:ff:ff:ff:ff".to_string(),
deleted: false, deleted: false,
@ -493,8 +484,23 @@ impl LNVpsProvisioner {
/// Create a renewal payment /// Create a renewal payment
pub async fn renew(&self, vm_id: u64, method: PaymentMethod) -> Result<VmPayment> { pub async fn renew(&self, vm_id: u64, method: PaymentMethod) -> Result<VmPayment> {
let pe = PricingEngine::new(self.db.clone(), self.rates.clone(), self.tax_rates.clone()); let pe = PricingEngine::new(self.db.clone(), self.rates.clone(), self.tax_rates.clone());
let price = pe.get_vm_cost(vm_id, method).await?; let price = pe.get_vm_cost(vm_id, method).await?;
self.price_to_payment(vm_id, method, price).await
}
/// Renew a VM using a specific amount
pub async fn renew_amount(&self, vm_id: u64, amount: CurrencyAmount, method: PaymentMethod) -> Result<VmPayment> {
let pe = PricingEngine::new(self.db.clone(), self.rates.clone(), self.tax_rates.clone());
let price = pe.get_cost_by_amount(vm_id, amount, method).await?;
self.price_to_payment(vm_id, method, price).await
}
async fn price_to_payment(
&self,
vm_id: u64,
method: PaymentMethod,
price: CostResult,
) -> Result<VmPayment> {
match price { match price {
CostResult::Existing(p) => Ok(p), CostResult::Existing(p) => Ok(p),
CostResult::New { CostResult::New {
@ -603,8 +609,13 @@ impl LNVpsProvisioner {
/// Delete a VM and its associated resources /// Delete a VM and its associated resources
pub async fn delete_vm(&self, vm_id: u64) -> Result<()> { pub async fn delete_vm(&self, vm_id: u64) -> Result<()> {
// host client currently doesn't support delete (proxmox) let vm = self.db.get_vm(vm_id).await?;
// VM should already be stopped by [Worker] let host = self.db.get_host(vm.host_id).await?;
let client = get_host_client(&host, &self.provisioner_config)?;
if let Err(e) = client.delete_vm(&vm).await {
warn!("Failed to delete VM: {}", e);
}
self.delete_ip_assignments(vm_id).await?; self.delete_ip_assignments(vm_id).await?;
self.db.delete_vm(vm_id).await?; self.db.delete_vm(vm_id).await?;
@ -723,6 +734,7 @@ mod tests {
// spawn vm // spawn vm
provisioner.spawn_vm(vm.id).await?; provisioner.spawn_vm(vm.id).await?;
let vm = db.get_vm(vm.id).await?;
// check resources // check resources
let router = MockRouter::new(); let router = MockRouter::new();
let arp = router.list_arp_entry().await?; let arp = router.list_arp_entry().await?;
@ -771,7 +783,12 @@ mod tests {
assert_eq!(zones.get("mock-v6-rev-zone-id").unwrap().len(), 1); assert_eq!(zones.get("mock-v6-rev-zone-id").unwrap().len(), 1);
assert_eq!(zones.get("mock-forward-zone-id").unwrap().len(), 2); assert_eq!(zones.get("mock-forward-zone-id").unwrap().len(), 2);
let v6 = zones.get("mock-v6-rev-zone-id").unwrap().iter().next().unwrap(); let v6 = zones
.get("mock-v6-rev-zone-id")
.unwrap()
.iter()
.next()
.unwrap();
assert_eq!(v6.1.kind, "PTR"); assert_eq!(v6.1.kind, "PTR");
assert!(v6.1.name.ends_with("0.0.d.f.ip6.arpa")); assert!(v6.1.name.ends_with("0.0.d.f.ip6.arpa"));
} }

View File

@ -1,5 +1,5 @@
use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService, Ticker, TickerRate}; use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService, Ticker, TickerRate};
use anyhow::{bail, Result}; use anyhow::{bail, ensure, Result};
use chrono::{DateTime, Days, Months, TimeDelta, Utc}; use chrono::{DateTime, Days, Months, TimeDelta, Utc};
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
use isocountry::CountryCode; use isocountry::CountryCode;
@ -34,6 +34,49 @@ impl PricingEngine {
} }
} }
/// Get amount of time a certain currency amount will extend a vm in seconds
pub async fn get_cost_by_amount(
&self,
vm_id: u64,
input: CurrencyAmount,
method: PaymentMethod,
) -> Result<CostResult> {
let vm = self.db.get_vm(vm_id).await?;
let cost = if vm.template_id.is_some() {
self.get_template_vm_cost(&vm, method).await?
} else {
self.get_custom_vm_cost(&vm, method).await?
};
match cost {
CostResult::Existing(_) => bail!("Invalid response"),
CostResult::New {
currency,
amount,
rate,
time_value,
..
} => {
ensure!(currency == input.currency(), "Invalid currency");
// scale cost
let scale = input.value() as f64 / amount as f64;
let new_time = (time_value as f64 * scale).floor() as u64;
ensure!(new_time > 0, "Extend time is less than 1 second");
Ok(CostResult::New {
amount: input.value(),
currency,
time_value: new_time,
new_expiry: vm.expires.add(TimeDelta::seconds(new_time as i64)),
rate,
tax: self.get_tax_for_user(vm.user_id, input.value()).await?,
})
}
}
}
/// Get VM cost (for renewal) /// Get VM cost (for renewal)
pub async fn get_vm_cost(&self, vm_id: u64, method: PaymentMethod) -> Result<CostResult> { pub async fn get_vm_cost(&self, vm_id: u64, method: PaymentMethod) -> Result<CostResult> {
let vm = self.db.get_vm(vm_id).await?; let vm = self.db.get_vm(vm_id).await?;
@ -160,13 +203,13 @@ impl PricingEngine {
} }
async fn get_msats_amount(&self, amount: CurrencyAmount) -> Result<(u64, f32)> { async fn get_msats_amount(&self, amount: CurrencyAmount) -> Result<(u64, f32)> {
let rate = self.get_ticker(amount.0).await?; let rate = self.get_ticker(amount.currency()).await?;
let cost_btc = amount.value_f32() / rate.1; let cost_btc = amount.value_f32() / rate.1;
let cost_msats = (cost_btc as f64 * crate::BTC_SATS) as u64 * 1000; let cost_msats = (cost_btc as f64 * crate::BTC_SATS) as u64 * 1000;
Ok((cost_msats, rate.1)) Ok((cost_msats, rate.1))
} }
fn next_template_expire(vm: &Vm, cost_plan: &VmCostPlan) -> u64 { pub fn next_template_expire(vm: &Vm, cost_plan: &VmCostPlan) -> u64 {
let next_expire = match cost_plan.interval_type { let next_expire = match cost_plan.interval_type {
VmCostPlanIntervalType::Day => vm.expires.add(Days::new(cost_plan.interval_amount)), VmCostPlanIntervalType::Day => vm.expires.add(Days::new(cost_plan.interval_amount)),
VmCostPlanIntervalType::Month => vm VmCostPlanIntervalType::Month => vm
@ -209,7 +252,7 @@ impl PricingEngine {
list_price: CurrencyAmount, list_price: CurrencyAmount,
method: PaymentMethod, method: PaymentMethod,
) -> Result<(Currency, u64, f32)> { ) -> Result<(Currency, u64, f32)> {
Ok(match (list_price.0, method) { Ok(match (list_price.currency(), method) {
(c, PaymentMethod::Lightning) if c != Currency::BTC => { (c, PaymentMethod::Lightning) if c != Currency::BTC => {
let new_price = self.get_msats_amount(list_price).await?; let new_price = self.get_msats_amount(list_price).await?;
(Currency::BTC, new_price.0, new_price.1) (Currency::BTC, new_price.0, new_price.1)
@ -351,11 +394,8 @@ mod tests {
User { User {
id: 1, id: 1,
pubkey: vec![], pubkey: vec![],
created: Default::default(),
email: None,
contact_nip17: false,
contact_email: false,
country_code: Some("USA".to_string()), country_code: Some("USA".to_string()),
..Default::default()
}, },
); );
u.insert( u.insert(
@ -363,11 +403,8 @@ mod tests {
User { User {
id: 2, id: 2,
pubkey: vec![], pubkey: vec![],
created: Default::default(),
email: None,
contact_nip17: false,
contact_email: false,
country_code: Some("IRL".to_string()), country_code: Some("IRL".to_string()),
..Default::default()
}, },
); );
} }
@ -400,6 +437,25 @@ mod tests {
_ => bail!("??"), _ => bail!("??"),
} }
// from amount
let price = pe
.get_cost_by_amount(1, CurrencyAmount::millisats(1000), PaymentMethod::Lightning)
.await?;
// full month price in msats
let mo_price = (plan.amount / MOCK_RATE * 1.0e11) as u64;
let time_scale = 1000f64 / mo_price as f64;
let vm = db.get_vm(1).await?;
let next_expire = PricingEngine::next_template_expire(&vm, &plan);
match price {
CostResult::New { amount, time_value, tax, .. } => {
let expect_time = (next_expire as f64 * time_scale) as u64;
assert_eq!(expect_time, time_value);
assert_eq!(0, tax);
assert_eq!(amount, 1000);
}
_ => bail!("??"),
}
Ok(()) Ok(())
} }
} }

View File

@ -1,6 +1,6 @@
use crate::json_api::JsonApi; use crate::json_api::JsonApi;
use crate::router::{ArpEntry, Router}; use crate::router::{ArpEntry, Router};
use anyhow::{ensure, Result}; use anyhow::{ensure, Context, Result};
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use base64::Engine; use base64::Engine;
use log::debug; use log::debug;
@ -33,14 +33,14 @@ impl Router for MikrotikRouter {
async fn list_arp_entry(&self) -> Result<Vec<ArpEntry>> { async fn list_arp_entry(&self) -> Result<Vec<ArpEntry>> {
let rsp: Vec<MikrotikArpEntry> = self.api.req(Method::GET, "/rest/ip/arp", ()).await?; let rsp: Vec<MikrotikArpEntry> = self.api.req(Method::GET, "/rest/ip/arp", ()).await?;
Ok(rsp.into_iter().map(|e| e.into()).collect()) Ok(rsp.into_iter().filter_map(|e| e.try_into().ok()).collect())
} }
async fn add_arp_entry(&self, entry: &ArpEntry) -> Result<ArpEntry> { async fn add_arp_entry(&self, entry: &ArpEntry) -> Result<ArpEntry> {
let req: MikrotikArpEntry = entry.clone().into(); let req: MikrotikArpEntry = entry.clone().into();
let rsp: MikrotikArpEntry = self.api.req(Method::PUT, "/rest/ip/arp", req).await?; let rsp: MikrotikArpEntry = self.api.req(Method::PUT, "/rest/ip/arp", req).await?;
debug!("{:?}", rsp); debug!("{:?}", rsp);
Ok(rsp.into()) Ok(rsp.try_into()?)
} }
async fn remove_arp_entry(&self, id: &str) -> Result<()> { async fn remove_arp_entry(&self, id: &str) -> Result<()> {
@ -64,7 +64,7 @@ impl Router for MikrotikRouter {
) )
.await?; .await?;
debug!("{:?}", rsp); debug!("{:?}", rsp);
Ok(rsp.into()) Ok(rsp.try_into()?)
} }
} }
@ -82,15 +82,17 @@ struct MikrotikArpEntry {
pub comment: Option<String>, pub comment: Option<String>,
} }
impl From<MikrotikArpEntry> for ArpEntry { impl TryFrom<MikrotikArpEntry> for ArpEntry {
fn from(val: MikrotikArpEntry) -> Self { type Error = anyhow::Error;
ArpEntry {
id: val.id, fn try_from(value: MikrotikArpEntry) -> std::result::Result<Self, Self::Error> {
address: val.address, Ok(ArpEntry {
mac_address: val.mac_address.unwrap(), id: value.id,
interface: Some(val.interface), address: value.address,
comment: val.comment, mac_address: value.mac_address.context("Mac address is empty")?,
} interface: Some(value.interface),
comment: value.comment,
})
} }
} }

View File

@ -1,4 +1,4 @@
use anyhow::Result; use anyhow::{ensure, Result};
use lnvps_db::{Vm, VmIpAssignment}; use lnvps_db::{Vm, VmIpAssignment};
use rocket::async_trait; use rocket::async_trait;
@ -30,6 +30,10 @@ pub struct ArpEntry {
impl ArpEntry { impl ArpEntry {
pub fn new(vm: &Vm, ip: &VmIpAssignment, interface: Option<String>) -> Result<Self> { 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"
);
Ok(Self { Ok(Self {
id: ip.arp_ref.clone(), id: ip.arp_ref.clone(),
address: ip.ip.clone(), address: ip.ip.clone(),
@ -47,4 +51,3 @@ mod ovh;
#[cfg(feature = "mikrotik")] #[cfg(feature = "mikrotik")]
pub use mikrotik::MikrotikRouter; pub use mikrotik::MikrotikRouter;
pub use ovh::OvhDedicatedServerVMacRouter; pub use ovh::OvhDedicatedServerVMacRouter;

View File

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

View File

@ -50,6 +50,9 @@ pub struct Settings {
#[serde(default)] #[serde(default)]
/// Tax rates to change per country as a percent of the amount /// Tax rates to change per country as a percent of the amount
pub tax_rate: HashMap<CountryCode, f32>, pub tax_rate: HashMap<CountryCode, f32>,
/// public host of lnvps_nostr service
pub nostr_address_host: Option<String>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -104,16 +107,27 @@ pub struct SmtpConfig {
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum ProvisionerConfig { pub struct ProvisionerConfig {
#[serde(rename_all = "kebab-case")] pub proxmox: Option<ProxmoxConfig>,
Proxmox { pub libvirt: Option<LibVirtConfig>,
/// Generic VM configuration }
qemu: QemuConfig,
/// SSH config for issuing commands via CLI #[derive(Debug, Clone, Deserialize, Serialize)]
ssh: Option<SshConfig>, #[serde(rename_all = "kebab-case")]
/// MAC address prefix for NIC (eg. bc:24:11) pub struct ProxmoxConfig {
mac_prefix: Option<String>, /// Generic VM configuration
}, pub qemu: QemuConfig,
/// SSH config for issuing commands via CLI
pub ssh: Option<SshConfig>,
/// MAC address prefix for NIC (eg. bc:24:11)
pub mac_prefix: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct LibVirtConfig {
/// Generic VM configuration
pub qemu: QemuConfig,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -137,6 +151,8 @@ pub struct QemuConfig {
pub cpu: String, pub cpu: String,
/// Enable virtualization inside VM /// Enable virtualization inside VM
pub kvm: bool, pub kvm: bool,
/// CPU architecture
pub arch: String,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -198,16 +214,20 @@ pub fn mock_settings() -> Settings {
macaroon: Default::default(), macaroon: Default::default(),
}, },
read_only: false, read_only: false,
provisioner: ProvisionerConfig::Proxmox { provisioner: ProvisionerConfig {
qemu: QemuConfig { proxmox: Some(ProxmoxConfig {
machine: "q35".to_string(), qemu: QemuConfig {
os_type: "l26".to_string(), machine: "q35".to_string(),
bridge: "vmbr1".to_string(), os_type: "l26".to_string(),
cpu: "kvm64".to_string(), bridge: "vmbr1".to_string(),
kvm: false, cpu: "kvm64".to_string(),
}, kvm: false,
ssh: None, arch: "x86_64".to_string(),
mac_prefix: Some("ff:ff:ff".to_string()), },
ssh: None,
mac_prefix: Some("ff:ff:ff".to_string()),
}),
libvirt: None,
}, },
delete_after: 0, delete_after: 0,
smtp: None, smtp: None,
@ -220,5 +240,6 @@ pub fn mock_settings() -> Settings {
nostr: None, nostr: None,
revolut: None, revolut: None,
tax_rate: HashMap::from([(CountryCode::IRL, 23.0), (CountryCode::USA, 1.0)]), tax_rate: HashMap::from([(CountryCode::IRL, 23.0), (CountryCode::USA, 1.0)]),
nostr_address_host: None,
} }
} }

View File

@ -2,13 +2,13 @@ use crate::host::get_host_client;
use crate::provisioner::LNVpsProvisioner; use crate::provisioner::LNVpsProvisioner;
use crate::settings::{ProvisionerConfig, Settings, SmtpConfig}; use crate::settings::{ProvisionerConfig, Settings, SmtpConfig};
use crate::status::{VmRunningState, VmState, VmStateCache}; use crate::status::{VmRunningState, VmState, VmStateCache};
use anyhow::Result; use anyhow::{bail, Result};
use chrono::{DateTime, Datelike, Days, Utc}; use chrono::{DateTime, Datelike, Days, Utc};
use lettre::message::{MessageBuilder, MultiPart}; use lettre::message::{MessageBuilder, MultiPart};
use lettre::transport::smtp::authentication::Credentials; use lettre::transport::smtp::authentication::Credentials;
use lettre::AsyncTransport; use lettre::AsyncTransport;
use lettre::{AsyncSmtpTransport, Tokio1Executor}; use lettre::{AsyncSmtpTransport, Tokio1Executor};
use lnvps_db::{LNVpsDb, Vm}; use lnvps_db::{LNVpsDb, Vm, VmHost};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use nostr::{EventBuilder, PublicKey, ToBech32}; use nostr::{EventBuilder, PublicKey, ToBech32};
use nostr_sdk::Client; use nostr_sdk::Client;
@ -196,7 +196,8 @@ impl Worker {
self.check_vm(vm).await?; self.check_vm(vm).await?;
// delete vm if not paid (in new state) // delete vm if not paid (in new state)
if vm.expires < Utc::now().sub(Days::new(1)) { if vm.created == vm.expires && !vm.deleted && vm.expires < Utc::now().sub(Days::new(1))
{
info!("Deleting unpaid VM {}", vm.id); info!("Deleting unpaid VM {}", vm.id);
self.provisioner.delete_vm(vm.id).await?; self.provisioner.delete_vm(vm.id).await?;
} }
@ -280,45 +281,49 @@ impl Worker {
Ok(()) Ok(())
} }
async fn patch_host(&self, host: &mut VmHost) -> Result<()> {
let client = match get_host_client(host, &self.settings.provisioner_config) {
Ok(h) => h,
Err(e) => bail!("Failed to get host client: {} {}", host.name, e),
};
let info = client.get_info().await?;
let needs_update = info.cpu != host.cpu || info.memory != host.memory;
if needs_update {
host.cpu = info.cpu;
host.memory = info.memory;
self.db.update_host(host).await?;
info!(
"Updated host {}: cpu={}, memory={}",
host.name, host.cpu, host.memory
);
}
let mut host_disks = self.db.list_host_disks(host.id).await?;
for disk in &info.disks {
if let Some(mut hd) = host_disks.iter_mut().find(|d| d.name == disk.name) {
if hd.size != disk.size {
hd.size = disk.size;
self.db.update_host_disk(hd).await?;
info!(
"Updated host disk {}: size={},type={},interface={}",
hd.name, hd.size, hd.kind, hd.interface
);
}
} else {
warn!("Un-mapped host disk {}", disk.name);
}
}
Ok(())
}
async fn try_job(&mut self, job: &WorkJob) -> Result<()> { async fn try_job(&mut self, job: &WorkJob) -> Result<()> {
match job { match job {
WorkJob::PatchHosts => { WorkJob::PatchHosts => {
let mut hosts = self.db.list_hosts().await?; let mut hosts = self.db.list_hosts().await?;
for mut host in &mut hosts { for mut host in &mut hosts {
info!("Patching host {}", host.name); info!("Patching host {}", host.name);
let client = match get_host_client(host, &self.settings.provisioner_config) { if let Err(e) = self.patch_host(&mut host).await {
Ok(h) => h, error!("Failed to patch host {}: {}", host.name, e);
Err(e) => {
warn!("Failed to get host client: {} {}", host.name, e);
continue;
}
};
let info = client.get_info().await?;
let needs_update = info.cpu != host.cpu || info.memory != host.memory;
if needs_update {
host.cpu = info.cpu;
host.memory = info.memory;
self.db.update_host(host).await?;
info!(
"Updated host {}: cpu={}, memory={}",
host.name, host.cpu, host.memory
);
}
let mut host_disks = self.db.list_host_disks(host.id).await?;
for disk in &info.disks {
if let Some(mut hd) = host_disks.iter_mut().find(|d| d.name == disk.name) {
if hd.size != disk.size {
hd.size = disk.size;
self.db.update_host_disk(hd).await?;
info!(
"Updated host disk {}: size={},type={},interface={}",
hd.name, hd.size, hd.kind, hd.interface
);
}
} else {
warn!("Un-mapped host disk {}", disk.name);
}
} }
} }
} }

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,9 +6,10 @@ edition = "2021"
[features] [features]
default = ["mysql"] default = ["mysql"]
mysql = ["sqlx/mysql"] mysql = ["sqlx/mysql"]
nostr-domain = []
[dependencies] [dependencies]
anyhow = "1.0.83" anyhow.workspace = true
sqlx = { version = "0.8.2", features = ["chrono", "migrate", "runtime-tokio"] } sqlx = { version = "0.8.2", features = ["chrono", "migrate", "runtime-tokio"] }
chrono = { version = "0.4.38", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] }
async-trait = "0.1.83" async-trait = "0.1.83"

View File

@ -0,0 +1,4 @@
-- Add migration script here
ALTER TABLE vm_ip_assignment DROP KEY ix_vm_ip_assignment_ip;
alter table vm_os_image
add column default_username varchar(50);

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

@ -0,0 +1,9 @@
-- Add migration script here
alter table users
add column billing_name varchar(200),
add column billing_address_1 varchar(200),
add column billing_address_2 varchar(200),
add column billing_city varchar(100),
add column billing_state varchar(100),
add column billing_postcode varchar(50),
add column billing_tax_id varchar(50);

View File

@ -0,0 +1,19 @@
-- Add migration script here
create table company
(
id integer unsigned not null auto_increment primary key,
created timestamp not null default current_timestamp,
name varchar(100) not null,
email varchar(100) not null,
phone varchar(100),
address_1 varchar(200),
address_2 varchar(200),
city varchar(100),
state varchar(100),
postcode varchar(50),
country_code varchar(3),
tax_id varchar(50)
);
alter table vm_host_region
add column company_id integer unsigned,
add constraint fk_host_region_company foreign key (company_id) references company (id);

View File

@ -10,7 +10,7 @@ pub use mysql::*;
pub use async_trait::async_trait; pub use async_trait::async_trait;
#[async_trait] #[async_trait]
pub trait LNVpsDb: Sync + Send { pub trait LNVpsDb: LNVPSNostrDb + Send + Sync {
/// Migrate database /// Migrate database
async fn migrate(&self) -> Result<()>; async fn migrate(&self) -> Result<()>;
@ -172,4 +172,44 @@ pub trait LNVpsDb: Sync + Send {
/// Get access policy /// Get access policy
async fn get_access_policy(&self, access_policy_id: u64) -> Result<AccessPolicy>; async fn get_access_policy(&self, access_policy_id: u64) -> Result<AccessPolicy>;
/// Get company
async fn get_company(&self, company_id: u64) -> Result<Company>;
}
#[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

@ -6,7 +6,7 @@ use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use url::Url; use url::Url;
#[derive(FromRow, Clone, Debug)] #[derive(FromRow, Clone, Debug, Default)]
/// Users who buy VM's /// Users who buy VM's
pub struct User { pub struct User {
/// Unique ID of this user (database generated) /// Unique ID of this user (database generated)
@ -23,6 +23,20 @@ pub struct User {
pub contact_email: bool, pub contact_email: bool,
/// Users country /// Users country
pub country_code: Option<String>, pub country_code: Option<String>,
/// Name to show on invoices
pub billing_name: Option<String>,
/// Billing address line 1
pub billing_address_1: Option<String>,
/// Billing address line 2
pub billing_address_2: Option<String>,
/// Billing city
pub billing_city: Option<String>,
/// Billing state/county
pub billing_state: Option<String>,
/// Billing postcode/zip
pub billing_postcode: Option<String>,
/// Billing tax id
pub billing_tax_id: Option<String>,
} }
#[derive(FromRow, Clone, Debug, Default)] #[derive(FromRow, Clone, Debug, Default)]
@ -57,6 +71,7 @@ pub struct VmHostRegion {
pub id: u64, pub id: u64,
pub name: String, pub name: String,
pub enabled: bool, pub enabled: bool,
pub company_id: Option<u64>,
} }
#[derive(FromRow, Clone, Debug, Default)] #[derive(FromRow, Clone, Debug, Default)]
@ -206,6 +221,7 @@ pub struct VmOsImage {
pub release_date: DateTime<Utc>, pub release_date: DateTime<Utc>,
/// URL location of cloud image /// URL location of cloud image
pub url: String, pub url: String,
pub default_username: Option<String>,
} }
impl VmOsImage { impl VmOsImage {
@ -488,3 +504,40 @@ 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>,
}
#[derive(FromRow, Clone, Debug, Default)]
pub struct Company {
pub id: u64,
pub created: DateTime<Utc>,
pub name: String,
pub address_1: Option<String>,
pub address_2: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
pub country_code: Option<String>,
pub tax_id: Option<String>,
pub postcode: Option<String>,
pub phone: Option<String>,
pub email: Option<String>,
}

View File

@ -1,8 +1,4 @@
use crate::{ use crate::{AccessPolicy, Company, IpRange, LNVPSNostrDb, LNVpsDb, NostrDomain, NostrDomainHandle, Router, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate};
AccessPolicy, IpRange, LNVpsDb, Router, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing,
VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment,
VmOsImage, VmPayment, VmTemplate,
};
use anyhow::{bail, Error, Result}; use anyhow::{bail, Error, Result};
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::{Executor, MySqlPool, Row}; use sqlx::{Executor, MySqlPool, Row};
@ -60,12 +56,19 @@ impl LNVpsDb for LNVpsDbMysql {
async fn update_user(&self, user: &User) -> Result<()> { async fn update_user(&self, user: &User) -> Result<()> {
sqlx::query( sqlx::query(
"update users set email=?, contact_nip17=?, contact_email=?, country_code=? where id = ?", "update users set email=?, contact_nip17=?, contact_email=?, country_code=?, billing_name=?, billing_address_1=?, billing_address_2=?, billing_city=?, billing_state=?, billing_postcode=?, billing_tax_id=? where id = ?",
) )
.bind(&user.email) .bind(&user.email)
.bind(user.contact_nip17) .bind(user.contact_nip17)
.bind(user.contact_email) .bind(user.contact_email)
.bind(&user.country_code) .bind(&user.country_code)
.bind(&user.billing_name)
.bind(&user.billing_address_1)
.bind(&user.billing_address_2)
.bind(&user.billing_city)
.bind(&user.billing_state)
.bind(&user.billing_postcode)
.bind(&user.billing_tax_id)
.bind(user.id) .bind(user.id)
.execute(&self.db) .execute(&self.db)
.await?; .await?;
@ -555,4 +558,124 @@ impl LNVpsDb for LNVpsDbMysql {
.await .await
.map_err(Error::new) .map_err(Error::new)
} }
async fn get_company(&self, company_id: u64) -> Result<Company> {
sqlx::query_as("select * from company where id=?")
.bind(company_id)
.fetch_one(&self.db)
.await
.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(())
}
} }

7
lnvps_nostr/Cargo.lock generated Normal file
View File

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "lnvps_nostr"
version = "0.1.0"

17
lnvps_nostr/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "lnvps_nostr"
version = "0.1.0"
edition = "2024"
[dependencies]
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"

47
lnvps_nostr/index.html Normal file
View File

@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<title>LNVPS</title>
<meta charset="UTF-8"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link
href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap"
rel="stylesheet"
/>
<style>
html, body {
margin: 0;
font-size: 15px;
font-family: "Source Code Pro", monospace;
color: white;
background-color: black;
}
.page {
margin-left: 4rem;
margin-right: 4rem;
}
.header {
display: flex;
gap: 2rem;
align-items: center;
justify-content: space-between;
font-size: 3rem;
margin: 2rem 0;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
LNVPS
<img height="48" width="48" src="https://lnvps.net/logo.jpg" alt="logo"/>
</div>
<hr/>
<p>This domain is using LNVPS' free NIP-05 hosting, get your own at <a href="https://lnvps.net">LNVPS.net</a></p>
</div>
</body>
</html>

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

@ -0,0 +1,65 @@
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(())
}

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

@ -0,0 +1,72 @@
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;
use rocket::http::ContentType;
pub fn routes() -> Vec<Route> {
routes![get_index, 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("/", format = "html")]
fn get_index() -> (ContentType, &'static str) {
const HTML: &str = include_str!("../index.html");
(ContentType::HTML, HTML)
}
#[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)]),
}))
}

View File

@ -1,52 +0,0 @@
use crate::host::{FullVmInfo, TimeSeries, TimeSeriesData, VmHostClient};
use crate::status::VmState;
use lnvps_db::{async_trait, Vm, VmOsImage};
pub struct LibVirt {}
#[async_trait]
impl VmHostClient for LibVirt {
async fn download_os_image(&self, image: &VmOsImage) -> anyhow::Result<()> {
todo!()
}
async fn generate_mac(&self, vm: &Vm) -> anyhow::Result<String> {
todo!()
}
async fn start_vm(&self, vm: &Vm) -> anyhow::Result<()> {
todo!()
}
async fn stop_vm(&self, vm: &Vm) -> anyhow::Result<()> {
todo!()
}
async fn reset_vm(&self, vm: &Vm) -> anyhow::Result<()> {
todo!()
}
async fn create_vm(&self, cfg: &FullVmInfo) -> anyhow::Result<()> {
todo!()
}
async fn reinstall_vm(&self, cfg: &FullVmInfo) -> anyhow::Result<()> {
todo!()
}
async fn get_vm_state(&self, vm: &Vm) -> anyhow::Result<VmState> {
todo!()
}
async fn configure_vm(&self, vm: &FullVmInfo) -> anyhow::Result<()> {
todo!()
}
async fn get_time_series_data(
&self,
vm: &Vm,
series: TimeSeries,
) -> anyhow::Result<Vec<TimeSeriesData>> {
todo!()
}
}

View File

@ -1,220 +0,0 @@
use crate::settings::ProvisionerConfig;
use crate::status::VmState;
use anyhow::{bail, Result};
use futures::future::join_all;
use lnvps_db::{
async_trait, IpRange, LNVpsDb, UserSshKey, Vm, VmCustomTemplate, VmHost, VmHostDisk,
VmHostKind, VmIpAssignment, VmOsImage, VmTemplate,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::mpsc::{Receiver, Sender};
//#[cfg(feature = "libvirt")]
//mod libvirt;
#[cfg(feature = "proxmox")]
mod proxmox;
pub struct TerminalStream {
pub rx: Receiver<Vec<u8>>,
pub tx: Sender<Vec<u8>>,
}
/// Generic type for creating VM's
#[async_trait]
pub trait VmHostClient: Send + Sync {
async fn get_info(&self) -> Result<VmHostInfo>;
/// Download OS image to the host
async fn download_os_image(&self, image: &VmOsImage) -> Result<()>;
/// Create a random MAC address for the NIC
async fn generate_mac(&self, vm: &Vm) -> Result<String>;
/// Start a VM
async fn start_vm(&self, vm: &Vm) -> Result<()>;
/// Stop a VM
async fn stop_vm(&self, vm: &Vm) -> Result<()>;
/// Reset VM (Hard)
async fn reset_vm(&self, vm: &Vm) -> Result<()>;
/// Spawn a VM
async fn create_vm(&self, cfg: &FullVmInfo) -> Result<()>;
/// Re-install a vm OS
async fn reinstall_vm(&self, cfg: &FullVmInfo) -> Result<()>;
/// Get the running status of a VM
async fn get_vm_state(&self, vm: &Vm) -> Result<VmState>;
/// Apply vm configuration (patch)
async fn configure_vm(&self, cfg: &FullVmInfo) -> Result<()>;
/// Get resource usage data
async fn get_time_series_data(
&self,
vm: &Vm,
series: TimeSeries,
) -> Result<Vec<TimeSeriesData>>;
/// Connect to terminal serial port
async fn connect_terminal(&self, vm: &Vm) -> Result<TerminalStream>;
}
pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result<Arc<dyn VmHostClient>> {
#[cfg(test)]
{
Ok(Arc::new(crate::mocks::MockVmHost::new()))
}
#[cfg(not(test))]
{
Ok(match (host.kind.clone(), &cfg) {
#[cfg(feature = "proxmox")]
(
VmHostKind::Proxmox,
ProvisionerConfig::Proxmox {
qemu,
ssh,
mac_prefix,
},
) => Arc::new(proxmox::ProxmoxClient::new(
host.ip.parse()?,
&host.name,
&host.api_token,
mac_prefix.clone(),
qemu.clone(),
ssh.clone(),
)),
_ => bail!("Unknown host config: {}", host.kind),
})
}
}
/// All VM info necessary to provision a VM and its associated resources
pub struct FullVmInfo {
/// Instance to create
pub vm: Vm,
/// Host where the VM will be spawned
pub host: VmHost,
/// Disk where this VM will be saved on the host
pub disk: VmHostDisk,
/// VM template resources
pub template: Option<VmTemplate>,
/// VM custom template resources
pub custom_template: Option<VmCustomTemplate>,
/// The OS image used to create the VM
pub image: VmOsImage,
/// List of IP resources assigned to this VM
pub ips: Vec<VmIpAssignment>,
/// Ranges associated with [ips]
pub ranges: Vec<IpRange>,
/// SSH key to access the VM
pub ssh_key: UserSshKey,
}
impl FullVmInfo {
pub async fn load(vm_id: u64, db: Arc<dyn LNVpsDb>) -> Result<Self> {
let vm = db.get_vm(vm_id).await?;
let host = db.get_host(vm.host_id).await?;
let image = db.get_os_image(vm.image_id).await?;
let disk = db.get_host_disk(vm.disk_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 ranges: Vec<IpRange> = join_all(ip_ranges)
.await
.into_iter()
.filter_map(Result::ok)
.collect();
let template = if let Some(t) = vm.template_id {
Some(db.get_vm_template(t).await?)
} else {
None
};
let custom_template = if let Some(t) = vm.custom_template_id {
Some(db.get_custom_vm_template(t).await?)
} else {
None
};
// create VM
Ok(FullVmInfo {
vm,
host,
template,
custom_template,
image,
ips,
disk,
ranges,
ssh_key,
})
}
/// CPU cores
pub fn resources(&self) -> Result<VmResources> {
if let Some(t) = &self.template {
Ok(VmResources {
cpu: t.cpu,
memory: t.memory,
disk_size: t.disk_size,
})
} else if let Some(t) = &self.custom_template {
Ok(VmResources {
cpu: t.cpu,
memory: t.memory,
disk_size: t.disk_size,
})
} else {
bail!("Invalid VM config, no template");
}
}
}
#[derive(Clone)]
pub struct VmResources {
pub cpu: u16,
pub memory: u64,
pub disk_size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TimeSeriesData {
pub timestamp: u64,
pub cpu: f32,
pub memory: f32,
pub memory_size: u64,
pub net_in: f32,
pub net_out: f32,
pub disk_write: f32,
pub disk_read: f32,
}
#[derive(Debug, Clone)]
pub enum TimeSeries {
Hourly,
Daily,
Weekly,
Monthly,
Yearly,
}
#[derive(Debug, Clone)]
pub struct VmHostInfo {
pub cpu: u16,
pub memory: u64,
pub disks: Vec<VmHostDiskInfo>,
}
#[derive(Debug, Clone)]
pub struct VmHostDiskInfo {
pub name: String,
pub size: u64,
pub used: u64,
}