This commit is contained in:
38
README.md
38
README.md
@ -13,6 +13,9 @@ A bitcoin powered VPS system.
|
||||
- [RevolutPay](https://www.revolut.com/business/revolut-pay/)
|
||||
- VM Backend:
|
||||
- Proxmox
|
||||
- Router:
|
||||
- Mikrotik
|
||||
- OVH dedicated server virtual mac
|
||||
|
||||
## Required Config
|
||||
|
||||
@ -47,15 +50,7 @@ provisioner:
|
||||
os-type: "l26"
|
||||
bridge: "vmbr0"
|
||||
cpu: "kvm64"
|
||||
vlan: 100
|
||||
kvm: false
|
||||
|
||||
# Networking policy
|
||||
network-policy:
|
||||
# Configure network equipment on provisioning IP resources
|
||||
access: "auto"
|
||||
# Use SLAAC to auto-configure VM ipv6 addresses
|
||||
ip6-slaac: true
|
||||
```
|
||||
|
||||
### Email notifications
|
||||
@ -93,30 +88,7 @@ nostr:
|
||||
|
||||
### Network Setup (Advanced)
|
||||
|
||||
When ARP is disabled (reply-only) on your router you may need to create static ARP entries when allocating
|
||||
IPs, we support managing ARP entries on routers directly as part of the provisioning process.
|
||||
|
||||
```yaml
|
||||
# (Optional)
|
||||
# When allocating IPs for VM's it may be necessary to create static ARP entries on
|
||||
# your router, at least one router can be configured
|
||||
#
|
||||
# Currently supports: Mikrotik
|
||||
router:
|
||||
mikrotik:
|
||||
# !! MAKE SURE TO USE HTTPS !!
|
||||
url: "https://my-router.net"
|
||||
username: "admin"
|
||||
password: "admin"
|
||||
network-policy:
|
||||
# How packets get to the VM
|
||||
# (default "auto", nothing to do, packets will always arrive)
|
||||
access:
|
||||
# Static ARP entries are added to the router for each provisioned IP
|
||||
static-arp:
|
||||
# Interface where the static ARP entry is added
|
||||
interface: "bridge1"
|
||||
```
|
||||
**TODO:** AccessPolicy is now managed in the database
|
||||
|
||||
### DNS (PTR/A/AAAA)
|
||||
|
||||
@ -124,8 +96,6 @@ To create PTR records automatically use the following config:
|
||||
```yaml
|
||||
dns:
|
||||
cloudflare:
|
||||
# The zone containing the reverse domain (eg. X.Y.Z.in-addr.arpa)
|
||||
reverse-zone-id: "my-reverse-zone-id"
|
||||
# The zone where forward (A/AAAA) entries are added (eg. lnvps.cloud zone)
|
||||
# We create forward entries with the format vm-<vmid>.lnvps.cloud
|
||||
forward-zone-id: "my-forward-zone-id"
|
||||
|
@ -0,0 +1,2 @@
|
||||
alter table vm_host
|
||||
add column vlan_id integer unsigned;
|
@ -86,6 +86,8 @@ pub struct VmHost {
|
||||
pub load_memory: f32,
|
||||
/// Disk load factor
|
||||
pub load_disk: f32,
|
||||
/// VLAN id assigned to all vms on the host
|
||||
pub vlan_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Clone, Debug, Default)]
|
||||
@ -220,7 +222,10 @@ pub struct Router {
|
||||
#[derive(Debug, Clone, sqlx::Type)]
|
||||
#[repr(u16)]
|
||||
pub enum RouterKind {
|
||||
/// Mikrotik router (JSON-Api)
|
||||
Mikrotik = 0,
|
||||
/// A pseudo-router which allows adding virtual mac addresses to a dedicated server
|
||||
OvhAdditionalIp = 1,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Clone, Debug)]
|
||||
|
@ -1,4 +1,8 @@
|
||||
use crate::{AccessPolicy, IpRange, LNVpsDb, Router, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate};
|
||||
use crate::{
|
||||
AccessPolicy, IpRange, LNVpsDb, Router, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing,
|
||||
VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment,
|
||||
VmOsImage, VmPayment, VmTemplate,
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{Executor, MySqlPool, Row};
|
||||
@ -318,13 +322,14 @@ impl LNVpsDb for LNVpsDbMysql {
|
||||
|
||||
async fn update_vm(&self, vm: &Vm) -> Result<()> {
|
||||
sqlx::query(
|
||||
"update vm set image_id=?,template_id=?,ssh_key_id=?,expires=?,disk_id=? where id=?",
|
||||
"update vm set image_id=?,template_id=?,ssh_key_id=?,expires=?,disk_id=?,mac_address=? where id=?",
|
||||
)
|
||||
.bind(vm.image_id)
|
||||
.bind(vm.template_id)
|
||||
.bind(vm.ssh_key_id)
|
||||
.bind(vm.expires)
|
||||
.bind(vm.disk_id)
|
||||
.bind(&vm.mac_address)
|
||||
.bind(vm.id)
|
||||
.execute(&self.db)
|
||||
.await
|
||||
|
@ -1,11 +1,12 @@
|
||||
use crate::exchange::{Currency, CurrencyAmount};
|
||||
use crate::fiat::{FiatPaymentInfo, FiatPaymentService};
|
||||
use crate::json_api::JsonApi;
|
||||
use crate::json_api::{JsonApi, TokenGen};
|
||||
use crate::settings::RevolutConfig;
|
||||
use anyhow::{bail, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use nostr::Url;
|
||||
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION};
|
||||
use reqwest::{Client, Method};
|
||||
use reqwest::{Client, Method, RequestBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
@ -15,22 +16,36 @@ pub struct RevolutApi {
|
||||
api: JsonApi,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RevolutTokenGen {
|
||||
pub token: String,
|
||||
pub api_version: String,
|
||||
}
|
||||
|
||||
impl TokenGen for RevolutTokenGen {
|
||||
fn generate_token(
|
||||
&self,
|
||||
_method: Method,
|
||||
_url: &Url,
|
||||
_body: Option<&str>,
|
||||
req: RequestBuilder,
|
||||
) -> Result<RequestBuilder> {
|
||||
Ok(req
|
||||
.header(AUTHORIZATION, format!("Bearer {}", &self.token))
|
||||
.header("Revolut-Api-Version", &self.api_version))
|
||||
}
|
||||
}
|
||||
|
||||
impl RevolutApi {
|
||||
pub fn new(config: RevolutConfig) -> Result<Self> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, format!("Bearer {}", config.token).parse()?);
|
||||
headers.insert(ACCEPT, "application/json".parse()?);
|
||||
headers.insert("Revolut-Api-Version", config.api_version.parse()?);
|
||||
let gen = RevolutTokenGen {
|
||||
token: config.token,
|
||||
api_version: config.api_version,
|
||||
};
|
||||
const DEFAULT_URL: &str = "https://merchant.revolut.com";
|
||||
|
||||
let client = Client::builder().default_headers(headers).build()?;
|
||||
Ok(Self {
|
||||
api: JsonApi {
|
||||
client,
|
||||
base: config
|
||||
.url
|
||||
.unwrap_or("https://merchant.revolut.com".to_string())
|
||||
.parse()?,
|
||||
},
|
||||
api: JsonApi::token_gen(&config.url.unwrap_or(DEFAULT_URL.to_string()), false, gen)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -96,6 +96,8 @@ pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result<Arc<dyn
|
||||
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
|
||||
@ -115,6 +117,7 @@ pub struct FullVmInfo {
|
||||
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?;
|
||||
@ -141,6 +144,7 @@ impl FullVmInfo {
|
||||
// create VM
|
||||
Ok(FullVmInfo {
|
||||
vm,
|
||||
host,
|
||||
template,
|
||||
custom_template,
|
||||
image,
|
||||
|
@ -39,19 +39,8 @@ impl ProxmoxClient {
|
||||
config: QemuConfig,
|
||||
ssh: Option<SshConfig>,
|
||||
) -> Self {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
format!("PVEAPIToken={}", token).parse().unwrap(),
|
||||
);
|
||||
let client = ClientBuilder::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.expect("Failed to build client");
|
||||
|
||||
Self {
|
||||
api: JsonApi { base, client },
|
||||
api: JsonApi::token(base.as_str(), &format!("PVEAPIToken={}", token), true).unwrap(),
|
||||
config,
|
||||
ssh,
|
||||
node: node.to_string(),
|
||||
@ -251,7 +240,7 @@ impl ProxmoxClient {
|
||||
if let Some(ssh_config) = &self.ssh {
|
||||
let mut ses = SshClient::new()?;
|
||||
ses.connect(
|
||||
(self.api.base.host().unwrap().to_string(), 22),
|
||||
(self.api.base().host().unwrap().to_string(), 22),
|
||||
&ssh_config.user,
|
||||
&ssh_config.key,
|
||||
)
|
||||
@ -429,7 +418,7 @@ impl ProxmoxClient {
|
||||
format!("virtio={}", value.vm.mac_address),
|
||||
format!("bridge={}", self.config.bridge),
|
||||
];
|
||||
if let Some(t) = self.config.vlan {
|
||||
if let Some(t) = value.host.vlan_id {
|
||||
net.push(format!("tag={}", t));
|
||||
}
|
||||
|
||||
@ -864,6 +853,7 @@ pub enum StorageContent {
|
||||
ISO,
|
||||
VZTmpL,
|
||||
Import,
|
||||
Snippets,
|
||||
}
|
||||
|
||||
impl FromStr for StorageContent {
|
||||
@ -877,6 +867,7 @@ impl FromStr for StorageContent {
|
||||
"iso" => Ok(StorageContent::ISO),
|
||||
"vztmpl" => Ok(StorageContent::VZTmpL),
|
||||
"import" => Ok(StorageContent::Import),
|
||||
"snippets" => Ok(StorageContent::Snippets),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
@ -896,7 +887,7 @@ impl NodeStorage {
|
||||
pub fn contents(&self) -> Vec<StorageContent> {
|
||||
self.content
|
||||
.split(",")
|
||||
.map_while(|s| s.parse().ok())
|
||||
.map_while(|s| StorageContent::from_str(&s).ok())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@ -1076,7 +1067,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{GB, MB, TB};
|
||||
use lnvps_db::{
|
||||
DiskInterface, IpRange, OsDistribution, UserSshKey, VmHostDisk, VmIpAssignment, VmTemplate,
|
||||
DiskInterface, IpRange, OsDistribution, UserSshKey, VmHost, VmHostDisk, VmIpAssignment,
|
||||
VmTemplate,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@ -1111,6 +1103,21 @@ mod tests {
|
||||
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,
|
||||
@ -1191,7 +1198,6 @@ mod tests {
|
||||
os_type: "l26".to_string(),
|
||||
bridge: "vmbr1".to_string(),
|
||||
cpu: "kvm64".to_string(),
|
||||
vlan: Some(100),
|
||||
kvm: true,
|
||||
};
|
||||
|
||||
@ -1209,6 +1215,7 @@ mod tests {
|
||||
assert_eq!(vm.cores, Some(template.cpu as i32));
|
||||
assert_eq!(vm.memory, Some((template.memory / MB).to_string()));
|
||||
assert_eq!(vm.on_boot, Some(true));
|
||||
assert!(vm.net.unwrap().contains("tag=100"));
|
||||
assert_eq!(
|
||||
vm.ip_config,
|
||||
Some(
|
||||
|
131
src/json_api.rs
131
src/json_api.rs
@ -1,21 +1,49 @@
|
||||
use anyhow::bail;
|
||||
use anyhow::{bail, Result};
|
||||
use log::debug;
|
||||
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION};
|
||||
use reqwest::{Client, Method, Url};
|
||||
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
|
||||
use reqwest::{Client, Method, RequestBuilder, Url};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub trait TokenGen: Send + Sync {
|
||||
fn generate_token(
|
||||
&self,
|
||||
method: Method,
|
||||
url: &Url,
|
||||
body: Option<&str>,
|
||||
req: RequestBuilder,
|
||||
) -> Result<RequestBuilder>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JsonApi {
|
||||
pub client: Client,
|
||||
pub base: Url,
|
||||
client: Client,
|
||||
base: Url,
|
||||
/// Custom token generator per request
|
||||
token_gen: Option<Arc<dyn TokenGen>>,
|
||||
}
|
||||
|
||||
impl JsonApi {
|
||||
pub fn token(base: &str, token: &str, allow_invalid_certs: bool) -> anyhow::Result<Self> {
|
||||
pub fn new(base: &str) -> Result<Self> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(USER_AGENT, "lnvps/1.0".parse()?);
|
||||
headers.insert(ACCEPT, "application/json; charset=utf-8".parse()?);
|
||||
|
||||
let client = Client::builder().default_headers(headers).build()?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
base: base.parse()?,
|
||||
token_gen: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn token(base: &str, token: &str, allow_invalid_certs: bool) -> Result<Self> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(USER_AGENT, "lnvps/1.0".parse()?);
|
||||
headers.insert(AUTHORIZATION, token.parse()?);
|
||||
headers.insert(ACCEPT, "application/json".parse()?);
|
||||
headers.insert(ACCEPT, "application/json; charset=utf-8".parse()?);
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(allow_invalid_certs)
|
||||
@ -24,28 +52,61 @@ impl JsonApi {
|
||||
Ok(Self {
|
||||
client,
|
||||
base: base.parse()?,
|
||||
token_gen: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> anyhow::Result<T> {
|
||||
pub fn token_gen(
|
||||
base: &str,
|
||||
allow_invalid_certs: bool,
|
||||
tg: impl TokenGen + 'static,
|
||||
) -> Result<Self> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(USER_AGENT, "lnvps/1.0".parse()?);
|
||||
headers.insert(ACCEPT, "application/json; charset=utf-8".parse()?);
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(allow_invalid_certs)
|
||||
.default_headers(headers)
|
||||
.build()?;
|
||||
Ok(Self {
|
||||
client,
|
||||
base: base.parse()?,
|
||||
token_gen: Some(Arc::new(tg)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base(&self) -> &Url {
|
||||
&self.base
|
||||
}
|
||||
|
||||
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||
let text = self.get_raw(path).await?;
|
||||
Ok(serde_json::from_str::<T>(&text)?)
|
||||
}
|
||||
|
||||
/// Get raw string response
|
||||
pub async fn get_raw(&self, path: &str) -> Result<String> {
|
||||
debug!(">> GET {}", path);
|
||||
let rsp = self.client.get(self.base.join(path)?).send().await?;
|
||||
let url = self.base.join(path)?;
|
||||
let mut req = self.client.request(Method::GET, url.clone());
|
||||
if let Some(gen) = &self.token_gen {
|
||||
req = gen.generate_token(Method::GET, &url, None, req)?;
|
||||
}
|
||||
let req = req.build()?;
|
||||
debug!(">> HEADERS {:?}", req.headers());
|
||||
let rsp = self.client.execute(req).await?;
|
||||
let status = rsp.status();
|
||||
let text = rsp.text().await?;
|
||||
#[cfg(debug_assertions)]
|
||||
debug!("<< {}", text);
|
||||
if status.is_success() {
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
Ok(text)
|
||||
} else {
|
||||
bail!("{}", status);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post<T: DeserializeOwned, R: Serialize>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: R,
|
||||
) -> anyhow::Result<T> {
|
||||
pub async fn post<T: DeserializeOwned, R: Serialize>(&self, path: &str, body: R) -> Result<T> {
|
||||
self.req(Method::POST, path, body).await
|
||||
}
|
||||
|
||||
@ -54,16 +115,20 @@ impl JsonApi {
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: R,
|
||||
) -> anyhow::Result<T> {
|
||||
) -> Result<T> {
|
||||
let body = serde_json::to_string(&body)?;
|
||||
debug!(">> {} {}: {}", method.clone(), path, &body);
|
||||
let rsp = self
|
||||
let url = self.base.join(path)?;
|
||||
let mut req = self
|
||||
.client
|
||||
.request(method.clone(), self.base.join(path)?)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
.request(method.clone(), url.clone())
|
||||
.header(CONTENT_TYPE, "application/json; charset=utf-8");
|
||||
if let Some(gen) = self.token_gen.as_ref() {
|
||||
req = gen.generate_token(method.clone(), &url, Some(&body), req)?;
|
||||
}
|
||||
let req = req.body(body).build()?;
|
||||
debug!(">> HEADERS {:?}", req.headers());
|
||||
let rsp = self.client.execute(req).await?;
|
||||
let status = rsp.status();
|
||||
let text = rsp.text().await?;
|
||||
#[cfg(debug_assertions)]
|
||||
@ -71,7 +136,7 @@ impl JsonApi {
|
||||
if status.is_success() {
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
} else {
|
||||
bail!("{} {}: {}: {}", method, path, status, &text);
|
||||
bail!("{} {}: {}: {}", method, url, status, &text);
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,16 +146,18 @@ impl JsonApi {
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: R,
|
||||
) -> anyhow::Result<u16> {
|
||||
) -> Result<u16> {
|
||||
let body = serde_json::to_string(&body)?;
|
||||
debug!(">> {} {}: {}", method.clone(), path, &body);
|
||||
let rsp = self
|
||||
let url = self.base.join(path)?;
|
||||
let mut req = self
|
||||
.client
|
||||
.request(method.clone(), self.base.join(path)?)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
.request(method.clone(), url.clone())
|
||||
.header(CONTENT_TYPE, "application/json; charset=utf-8");
|
||||
if let Some(gen) = &self.token_gen {
|
||||
req = gen.generate_token(method.clone(), &url, Some(&body), req)?;
|
||||
}
|
||||
let rsp = req.body(body).send().await?;
|
||||
let status = rsp.status();
|
||||
let text = rsp.text().await?;
|
||||
#[cfg(debug_assertions)]
|
||||
@ -98,7 +165,7 @@ impl JsonApi {
|
||||
if status.is_success() {
|
||||
Ok(status.as_u16())
|
||||
} else {
|
||||
bail!("{} {}: {}: {}", method, path, status, &text);
|
||||
bail!("{} {}: {}: {}", method, url, status, &text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -137,6 +137,7 @@ impl Default for MockDb {
|
||||
load_cpu: 1.5,
|
||||
load_memory: 2.0,
|
||||
load_disk: 3.0,
|
||||
vlan_id: Some(100)
|
||||
},
|
||||
);
|
||||
let mut host_disks = HashMap::new();
|
||||
@ -639,6 +640,10 @@ impl MockRouter {
|
||||
}
|
||||
#[async_trait]
|
||||
impl Router for MockRouter {
|
||||
async fn generate_mac(&self, ip: &str, comment: &str) -> anyhow::Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list_arp_entry(&self) -> anyhow::Result<Vec<ArpEntry>> {
|
||||
let arp = self.arp.lock().await;
|
||||
Ok(arp.values().cloned().collect())
|
||||
|
@ -6,14 +6,14 @@ use crate::lightning::{AddInvoiceRequest, LightningNode};
|
||||
use crate::provisioner::{
|
||||
CostResult, HostCapacityService, NetworkProvisioner, PricingEngine, ProvisionerMethod,
|
||||
};
|
||||
use crate::router::{ArpEntry, MikrotikRouter, Router};
|
||||
use crate::router::{ArpEntry, MikrotikRouter, OvhDedicatedServerVMacRouter, Router};
|
||||
use crate::settings::{ProvisionerConfig, Settings};
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use chrono::Utc;
|
||||
use isocountry::CountryCode;
|
||||
use lnvps_db::{
|
||||
AccessPolicy, LNVpsDb, NetworkAccessPolicy, PaymentMethod, RouterKind, Vm, VmCustomTemplate,
|
||||
VmIpAssignment, VmPayment,
|
||||
VmHost, VmIpAssignment, VmPayment,
|
||||
};
|
||||
use log::{info, warn};
|
||||
use nostr::util::hex;
|
||||
@ -72,6 +72,9 @@ impl LNVpsProvisioner {
|
||||
);
|
||||
Ok(Arc::new(MikrotikRouter::new(&cfg.url, username, password)))
|
||||
}
|
||||
RouterKind::OvhAdditionalIp => Ok(Arc::new(
|
||||
OvhDedicatedServerVMacRouter::new(&cfg.url, &cfg.name, &cfg.token).await?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,17 +94,7 @@ impl LNVpsProvisioner {
|
||||
)
|
||||
.await?;
|
||||
let vm = self.db.get_vm(assignment.vm_id).await?;
|
||||
let entry = ArpEntry::new(
|
||||
&vm,
|
||||
assignment,
|
||||
Some(
|
||||
policy
|
||||
.interface
|
||||
.as_ref()
|
||||
.context("Cannot apply static arp entry without an interface name")?
|
||||
.clone(),
|
||||
),
|
||||
)?;
|
||||
let entry = ArpEntry::new(&vm, assignment, policy.interface.clone())?;
|
||||
let arp = if let Some(_id) = &assignment.arp_ref {
|
||||
router.update_arp_entry(&entry).await?
|
||||
} else {
|
||||
@ -247,8 +240,43 @@ impl LNVpsProvisioner {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_mac_for_assignment(
|
||||
&self,
|
||||
host: &VmHost,
|
||||
vm: &Vm,
|
||||
assignment: &VmIpAssignment,
|
||||
) -> Result<ArpEntry> {
|
||||
let range = self.db.get_ip_range(assignment.ip_range_id).await?;
|
||||
|
||||
// ask router first if it wants to set the MAC
|
||||
if let Some(ap) = range.access_policy_id {
|
||||
let ap = self.db.get_access_policy(ap).await?;
|
||||
if let Some(rid) = ap.router_id {
|
||||
let router = self.get_router(rid).await?;
|
||||
|
||||
if let Some(mac) = router
|
||||
.generate_mac(&assignment.ip, &format!("VM{}", assignment.vm_id))
|
||||
.await?
|
||||
{
|
||||
return Ok(mac);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ask the host next to generate the mac
|
||||
let client = get_host_client(host, &self.provisioner_config)?;
|
||||
let mac = client.generate_mac(vm).await?;
|
||||
Ok(ArpEntry {
|
||||
id: None,
|
||||
address: assignment.ip.clone(),
|
||||
mac_address: mac,
|
||||
interface: None,
|
||||
comment: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn allocate_ips(&self, vm_id: u64) -> Result<Vec<VmIpAssignment>> {
|
||||
let vm = self.db.get_vm(vm_id).await?;
|
||||
let mut vm = self.db.get_vm(vm_id).await?;
|
||||
let existing_ips = self.db.list_vm_ip_assignments(vm_id).await?;
|
||||
if !existing_ips.is_empty() {
|
||||
return Ok(existing_ips);
|
||||
@ -260,19 +288,20 @@ impl LNVpsProvisioner {
|
||||
let host = self.db.get_host(vm.host_id).await?;
|
||||
let ip = network.pick_ip_for_region(host.region_id).await?;
|
||||
let mut assignment = VmIpAssignment {
|
||||
id: 0,
|
||||
vm_id,
|
||||
ip_range_id: ip.range_id,
|
||||
ip: ip.ip.to_string(),
|
||||
deleted: false,
|
||||
arp_ref: None,
|
||||
dns_forward: None,
|
||||
dns_forward_ref: None,
|
||||
dns_reverse: None,
|
||||
dns_reverse_ref: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
//generate mac address from ip assignment
|
||||
let mac = self.get_mac_for_assignment(&host, &vm, &assignment).await?;
|
||||
vm.mac_address = mac.mac_address;
|
||||
assignment.arp_ref = mac.id; // store ref if we got one
|
||||
self.db.update_vm(&vm).await?;
|
||||
|
||||
self.save_ip_assignment(&mut assignment).await?;
|
||||
|
||||
Ok(vec![assignment])
|
||||
}
|
||||
|
||||
@ -329,7 +358,6 @@ impl LNVpsProvisioner {
|
||||
bail!("No host disk found")
|
||||
};
|
||||
|
||||
let client = get_host_client(&host.host, &self.provisioner_config)?;
|
||||
let mut new_vm = Vm {
|
||||
id: 0,
|
||||
host_id: host.host.id,
|
||||
@ -341,14 +369,11 @@ impl LNVpsProvisioner {
|
||||
created: Utc::now(),
|
||||
expires: Utc::now(),
|
||||
disk_id: pick_disk.disk.id,
|
||||
mac_address: "NOT FILLED YET".to_string(),
|
||||
mac_address: "ff:ff:ff:ff:ff:ff".to_string(),
|
||||
deleted: false,
|
||||
ref_code,
|
||||
};
|
||||
|
||||
// ask host client to generate the mac address
|
||||
new_vm.mac_address = client.generate_mac(&new_vm).await?;
|
||||
|
||||
let new_id = self.db.insert_vm(&new_vm).await?;
|
||||
new_vm.id = new_id;
|
||||
Ok(new_vm)
|
||||
@ -387,7 +412,6 @@ impl LNVpsProvisioner {
|
||||
// insert custom templates
|
||||
let template_id = self.db.insert_custom_vm_template(&template).await?;
|
||||
|
||||
let client = get_host_client(&host.host, &self.provisioner_config)?;
|
||||
let mut new_vm = Vm {
|
||||
id: 0,
|
||||
host_id: host.host.id,
|
||||
@ -399,14 +423,11 @@ impl LNVpsProvisioner {
|
||||
created: Utc::now(),
|
||||
expires: Utc::now(),
|
||||
disk_id: pick_disk.disk.id,
|
||||
mac_address: "NOT FILLED YET".to_string(),
|
||||
mac_address: "ff:ff:ff:ff:ff:ff".to_string(),
|
||||
deleted: false,
|
||||
ref_code,
|
||||
};
|
||||
|
||||
// ask host client to generate the mac address
|
||||
new_vm.mac_address = client.generate_mac(&new_vm).await?;
|
||||
|
||||
let new_id = self.db.insert_vm(&new_vm).await?;
|
||||
new_vm.id = new_id;
|
||||
Ok(new_vm)
|
||||
|
@ -26,6 +26,11 @@ impl MikrotikRouter {
|
||||
|
||||
#[async_trait]
|
||||
impl Router for MikrotikRouter {
|
||||
async fn generate_mac(&self, _ip: &str, _comment: &str) -> Result<Option<ArpEntry>> {
|
||||
// Mikrotik router doesn't care what MAC address you use
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list_arp_entry(&self) -> Result<Vec<ArpEntry>> {
|
||||
let rsp: Vec<MikrotikArpEntry> = self.api.req(Method::GET, "/rest/ip/arp", ()).await?;
|
||||
Ok(rsp.into_iter().map(|e| e.into()).collect())
|
||||
@ -64,7 +69,7 @@ impl Router for MikrotikRouter {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MikrotikArpEntry {
|
||||
struct MikrotikArpEntry {
|
||||
#[serde(rename = ".id")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
|
@ -11,6 +11,8 @@ use rocket::async_trait;
|
||||
/// It also prevents people from re-assigning their IP to another in the range,
|
||||
#[async_trait]
|
||||
pub trait Router: Send + Sync {
|
||||
/// Generate mac address for a given IP address
|
||||
async fn generate_mac(&self, ip: &str, comment: &str) -> Result<Option<ArpEntry>>;
|
||||
async fn list_arp_entry(&self) -> Result<Vec<ArpEntry>>;
|
||||
async fn add_arp_entry(&self, entry: &ArpEntry) -> Result<ArpEntry>;
|
||||
async fn remove_arp_entry(&self, id: &str) -> Result<()>;
|
||||
@ -40,5 +42,9 @@ impl ArpEntry {
|
||||
|
||||
#[cfg(feature = "mikrotik")]
|
||||
mod mikrotik;
|
||||
mod ovh;
|
||||
|
||||
#[cfg(feature = "mikrotik")]
|
||||
pub use mikrotik::*;
|
||||
pub use mikrotik::MikrotikRouter;
|
||||
pub use ovh::OvhDedicatedServerVMacRouter;
|
||||
|
||||
|
355
src/router/ovh.rs
Normal file
355
src/router/ovh.rs
Normal file
@ -0,0 +1,355 @@
|
||||
use crate::json_api::{JsonApi, TokenGen};
|
||||
use crate::router::{ArpEntry, Router};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use lnvps_db::async_trait;
|
||||
use log::{info, warn};
|
||||
use nostr::hashes::{sha1, Hash};
|
||||
use nostr::Url;
|
||||
use reqwest::header::{HeaderName, HeaderValue, ACCEPT};
|
||||
use reqwest::{Method, RequestBuilder};
|
||||
use rocket::form::validate::Contains;
|
||||
use rocket::serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::ops::Sub;
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::AtomicI64;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// This router is not really a router, but it allows
|
||||
/// managing the virtual mac's for additional IPs on OVH dedicated servers
|
||||
pub struct OvhDedicatedServerVMacRouter {
|
||||
name: String,
|
||||
api: JsonApi,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OvhTokenGen {
|
||||
time_delta: i64,
|
||||
application_key: String,
|
||||
application_secret: String,
|
||||
consumer_key: String,
|
||||
}
|
||||
|
||||
impl OvhTokenGen {
|
||||
pub fn new(time_delta: i64, token: &str) -> Result<Self> {
|
||||
let mut t_split = token.split(":");
|
||||
Ok(Self {
|
||||
time_delta,
|
||||
application_key: t_split
|
||||
.next()
|
||||
.context("Missing application_key")?
|
||||
.to_string(),
|
||||
application_secret: t_split
|
||||
.next()
|
||||
.context("Missing application_secret")?
|
||||
.to_string(),
|
||||
consumer_key: t_split.next().context("Missing consumer_key")?.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute signature for OVH.
|
||||
fn build_sig(
|
||||
method: &str,
|
||||
query: &str,
|
||||
body: &str,
|
||||
timestamp: &str,
|
||||
aas: &str,
|
||||
ck: &str,
|
||||
) -> String {
|
||||
let sep = "+";
|
||||
let prefix = "$1$".to_string();
|
||||
|
||||
let capacity = 1
|
||||
+ aas.len()
|
||||
+ sep.len()
|
||||
+ ck.len()
|
||||
+ method.len()
|
||||
+ sep.len()
|
||||
+ query.len()
|
||||
+ sep.len()
|
||||
+ body.len()
|
||||
+ sep.len()
|
||||
+ timestamp.len();
|
||||
let mut signature = String::with_capacity(capacity);
|
||||
signature.push_str(aas);
|
||||
signature.push_str(sep);
|
||||
signature.push_str(ck);
|
||||
signature.push_str(sep);
|
||||
signature.push_str(method);
|
||||
signature.push_str(sep);
|
||||
signature.push_str(query);
|
||||
signature.push_str(sep);
|
||||
signature.push_str(body);
|
||||
signature.push_str(sep);
|
||||
signature.push_str(timestamp);
|
||||
|
||||
// debug!("Signature: {}", &signature);
|
||||
let sha1: sha1::Hash = Hash::hash(signature.as_bytes());
|
||||
let sig = hex::encode(sha1);
|
||||
prefix + &sig
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenGen for OvhTokenGen {
|
||||
fn generate_token(
|
||||
&self,
|
||||
method: Method,
|
||||
url: &Url,
|
||||
body: Option<&str>,
|
||||
req: RequestBuilder,
|
||||
) -> Result<RequestBuilder> {
|
||||
let now = Utc::now().timestamp().sub(self.time_delta);
|
||||
let now_string = now.to_string();
|
||||
let sig = Self::build_sig(
|
||||
method.as_str(),
|
||||
url.as_str(),
|
||||
body.unwrap_or(""),
|
||||
now_string.as_str(),
|
||||
&self.application_secret,
|
||||
&self.consumer_key,
|
||||
);
|
||||
Ok(req
|
||||
.header("X-Ovh-Application", &self.application_key)
|
||||
.header("X-Ovh-Consumer", &self.consumer_key)
|
||||
.header("X-Ovh-Timestamp", now_string)
|
||||
.header("X-Ovh-Signature", sig))
|
||||
}
|
||||
}
|
||||
|
||||
impl OvhDedicatedServerVMacRouter {
|
||||
pub async fn new(url: &str, name: &str, token: &str) -> Result<Self> {
|
||||
// load API time delta
|
||||
let time_api = JsonApi::new(url)?;
|
||||
let time = time_api.get_raw("v1/auth/time").await?;
|
||||
let delta: i64 = Utc::now().timestamp().sub(time.parse::<i64>()?);
|
||||
|
||||
Ok(Self {
|
||||
name: name.to_string(),
|
||||
api: JsonApi::token_gen(url, false, OvhTokenGen::new(delta, token)?)?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_task(&self, task_id: i64) -> Result<OvhTaskResponse> {
|
||||
self.api
|
||||
.get(&format!(
|
||||
"v1/dedicated/server/{}/task/{}",
|
||||
self.name, task_id
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Poll a task until it completes
|
||||
async fn wait_for_task_result(&self, task_id: i64) -> Result<OvhTaskResponse> {
|
||||
loop {
|
||||
let status = self.get_task(task_id).await?;
|
||||
match status.status {
|
||||
OvhTaskStatus::Cancelled => {
|
||||
return Err(anyhow!(
|
||||
"Task was cancelled: {}",
|
||||
status.comment.unwrap_or_default()
|
||||
))
|
||||
}
|
||||
OvhTaskStatus::CustomerError => {
|
||||
return Err(anyhow!(
|
||||
"Task failed: {}",
|
||||
status.comment.unwrap_or_default()
|
||||
))
|
||||
}
|
||||
OvhTaskStatus::Done => return Ok(status),
|
||||
OvhTaskStatus::OvhError => {
|
||||
return Err(anyhow!(
|
||||
"Task failed: {}",
|
||||
status.comment.unwrap_or_default()
|
||||
))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Router for OvhDedicatedServerVMacRouter {
|
||||
async fn generate_mac(&self, ip: &str, comment: &str) -> Result<Option<ArpEntry>> {
|
||||
info!("[OVH] Generating mac: {}={}", ip, comment);
|
||||
let rsp: OvhTaskResponse = self
|
||||
.api
|
||||
.post(
|
||||
&format!("v1/dedicated/server/{}/virtualMac", &self.name),
|
||||
OvhVMacRequest {
|
||||
ip_address: ip.to_string(),
|
||||
kind: OvhVMacType::Ovh,
|
||||
name: comment.to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.wait_for_task_result(rsp.task_id).await?;
|
||||
|
||||
// api is shit, lookup ip address in list of arp entries
|
||||
let e = self.list_arp_entry().await?;
|
||||
Ok(e.into_iter().find(|e| e.address == ip))
|
||||
}
|
||||
|
||||
async fn list_arp_entry(&self) -> Result<Vec<ArpEntry>> {
|
||||
let rsp: Vec<String> = self
|
||||
.api
|
||||
.get(&format!("v1/dedicated/server/{}/virtualMac", &self.name))
|
||||
.await?;
|
||||
|
||||
let mut ret = vec![];
|
||||
for mac in rsp {
|
||||
let rsp2: Vec<String> = self
|
||||
.api
|
||||
.get(&format!(
|
||||
"v1/dedicated/server/{}/virtualMac/{}/virtualAddress",
|
||||
&self.name, mac
|
||||
))
|
||||
.await?;
|
||||
|
||||
for addr in rsp2 {
|
||||
ret.push(ArpEntry {
|
||||
id: Some(format!("{}={}", mac, &addr)),
|
||||
address: addr,
|
||||
mac_address: mac.clone(),
|
||||
interface: None,
|
||||
comment: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
async fn add_arp_entry(&self, entry: &ArpEntry) -> Result<ArpEntry> {
|
||||
info!(
|
||||
"[OVH] Adding mac ip: {} {}",
|
||||
entry.mac_address, entry.address
|
||||
);
|
||||
#[derive(Serialize)]
|
||||
struct AddVMacAddressRequest {
|
||||
#[serde(rename = "ipAddress")]
|
||||
pub ip_address: String,
|
||||
#[serde(rename = "virtualMachineName")]
|
||||
pub comment: String,
|
||||
}
|
||||
let id = format!("{}={}", &entry.mac_address, &entry.address);
|
||||
let task: OvhTaskResponse = self
|
||||
.api
|
||||
.post(
|
||||
&format!(
|
||||
"v1/dedicated/server/{}/virtualMac/{}/virtualAddress",
|
||||
&self.name, &entry.mac_address
|
||||
),
|
||||
AddVMacAddressRequest {
|
||||
ip_address: entry.address.clone(),
|
||||
comment: entry.comment.clone().unwrap_or(String::new()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.wait_for_task_result(task.task_id).await?;
|
||||
|
||||
Ok(ArpEntry {
|
||||
id: Some(id),
|
||||
address: entry.address.clone(),
|
||||
mac_address: entry.mac_address.clone(),
|
||||
interface: None,
|
||||
comment: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn remove_arp_entry(&self, id: &str) -> Result<()> {
|
||||
let entries = self.list_arp_entry().await?;
|
||||
if let Some(this_entry) = entries.into_iter().find(|e| e.id == Some(id.to_string())) {
|
||||
info!(
|
||||
"[OVH] Deleting mac ip: {} {}",
|
||||
this_entry.mac_address, this_entry.address
|
||||
);
|
||||
let task: OvhTaskResponse = self
|
||||
.api
|
||||
.req(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"v1/dedicated/server/{}/virtualMac/{}/virtualAddress/{}",
|
||||
self.name, this_entry.mac_address, this_entry.address
|
||||
),
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
self.wait_for_task_result(task.task_id).await?;
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("Cannot remove arp entry, not found")
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_arp_entry(&self, entry: &ArpEntry) -> Result<ArpEntry> {
|
||||
// cant patch just return the entry
|
||||
warn!("[OVH] Updating virtual mac is not supported");
|
||||
Ok(entry.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OvhVMacRequest {
|
||||
#[serde(rename = "ipAddress")]
|
||||
pub ip_address: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: OvhVMacType,
|
||||
#[serde(rename = "virtualMachineName")]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum OvhVMacType {
|
||||
Ovh,
|
||||
VMWare,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OvhTaskResponse {
|
||||
pub comment: Option<String>,
|
||||
pub done_date: Option<DateTime<Utc>>,
|
||||
pub function: OvhTaskFunction,
|
||||
pub last_update: Option<DateTime<Utc>>,
|
||||
pub need_schedule: bool,
|
||||
pub note: Option<String>,
|
||||
pub planned_intervention_id: Option<i64>,
|
||||
pub start_date: DateTime<Utc>,
|
||||
pub status: OvhTaskStatus,
|
||||
pub tags: Option<Vec<KVSimple>>,
|
||||
pub task_id: i64,
|
||||
pub ticket_reference: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct KVSimple {
|
||||
pub key: Option<String>,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum OvhTaskStatus {
|
||||
Cancelled,
|
||||
CustomerError,
|
||||
Doing,
|
||||
Done,
|
||||
Init,
|
||||
OvhError,
|
||||
Todo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum OvhTaskFunction {
|
||||
AddVirtualMac,
|
||||
MoveVirtualMac,
|
||||
VirtualMacAdd,
|
||||
VirtualMacDelete,
|
||||
RemoveVirtualMac
|
||||
}
|
@ -136,8 +136,6 @@ pub struct QemuConfig {
|
||||
pub bridge: String,
|
||||
/// CPU type
|
||||
pub cpu: String,
|
||||
/// VLAN tag all spawned VM's
|
||||
pub vlan: Option<u16>,
|
||||
/// Enable virtualization inside VM
|
||||
pub kvm: bool,
|
||||
}
|
||||
@ -211,7 +209,6 @@ pub fn mock_settings() -> Settings {
|
||||
os_type: "l26".to_string(),
|
||||
bridge: "vmbr1".to_string(),
|
||||
cpu: "kvm64".to_string(),
|
||||
vlan: None,
|
||||
kvm: false,
|
||||
},
|
||||
ssh: None,
|
||||
|
Reference in New Issue
Block a user