feat: ovh virtual mac router
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-03-26 15:48:15 +00:00
parent 4bf8b06337
commit a57c85fa2c
14 changed files with 599 additions and 135 deletions

View File

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

View File

@ -0,0 +1,2 @@
alter table vm_host
add column vlan_id integer unsigned;

View File

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

View File

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

View File

@ -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)?,
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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
}

View File

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