feat: move network config to db
All checks were successful
continuous-integration/drone/push Build is passing

closes https://github.com/LNVPS/api/issues/16
This commit is contained in:
2025-03-25 14:53:14 +00:00
parent 2505082a59
commit 4bf8b06337
10 changed files with 294 additions and 181 deletions

View File

@ -0,0 +1,23 @@
create table router
(
id integer unsigned not null auto_increment primary key,
name varchar(100) not null,
enabled bit(1) not null,
kind smallint unsigned not null,
url varchar(255) not null,
token varchar(128) not null
);
create table access_policy
(
id integer unsigned not null auto_increment primary key,
name varchar(100) not null,
kind smallint unsigned not null,
router_id integer unsigned,
interface varchar(100),
constraint fk_access_policy_router foreign key (router_id) references router (id)
);
alter table ip_range
add column reverse_zone_id varchar(255),
add column access_policy_id integer unsigned;
alter table ip_range
add constraint fk_ip_range_access_policy foreign key (access_policy_id) references access_policy (id);

View File

@ -163,4 +163,10 @@ pub trait LNVpsDb: Sync + Send {
/// Return the list of disk prices for a given custom pricing model
async fn list_custom_pricing_disk(&self, pricing_id: u64) -> Result<Vec<VmCustomPricingDisk>>;
/// Get router config
async fn get_router(&self, router_id: u64) -> Result<Router>;
/// Get access policy
async fn get_access_policy(&self, access_policy_id: u64) -> Result<AccessPolicy>;
}

View File

@ -207,6 +207,22 @@ impl Display for VmOsImage {
}
}
#[derive(FromRow, Clone, Debug)]
pub struct Router {
pub id: u64,
pub name: String,
pub enabled: bool,
pub kind: RouterKind,
pub url: String,
pub token: String,
}
#[derive(Debug, Clone, sqlx::Type)]
#[repr(u16)]
pub enum RouterKind {
Mikrotik = 0,
}
#[derive(FromRow, Clone, Debug)]
pub struct IpRange {
pub id: u64,
@ -214,6 +230,27 @@ pub struct IpRange {
pub gateway: String,
pub enabled: bool,
pub region_id: u64,
pub reverse_zone_id: Option<String>,
pub access_policy_id: Option<u64>,
}
#[derive(FromRow, Clone, Debug)]
pub struct AccessPolicy {
pub id: u64,
pub name: String,
pub kind: NetworkAccessPolicy,
/// Router used to apply this network access policy
pub router_id: Option<u64>,
/// Interface name used to apply this policy
pub interface: Option<String>,
}
/// Policy that determines how packets arrive at the VM
#[derive(Debug, Clone, sqlx::Type)]
#[repr(u16)]
pub enum NetworkAccessPolicy {
/// ARP entries are added statically on the access router
StaticArp = 0,
}
#[derive(Clone, Debug, sqlx::Type)]

View File

@ -1,8 +1,4 @@
use crate::{
IpRange, LNVpsDb, 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};
@ -526,4 +522,20 @@ impl LNVpsDb for LNVpsDbMysql {
.await
.map_err(Error::new)
}
async fn get_router(&self, router_id: u64) -> Result<Router> {
sqlx::query_as("select * from router where id=?")
.bind(router_id)
.fetch_one(&self.db)
.await
.map_err(Error::new)
}
async fn get_access_policy(&self, access_policy_id: u64) -> Result<AccessPolicy> {
sqlx::query_as("select * from access_policy where id=?")
.bind(access_policy_id)
.fetch_one(&self.db)
.await
.map_err(Error::new)
}
}

View File

@ -2,7 +2,6 @@ use crate::settings::ProvisionerConfig;
use crate::status::VmState;
use anyhow::{bail, Result};
use futures::future::join_all;
use futures::{Sink, Stream};
use lnvps_db::{
async_trait, IpRange, LNVpsDb, UserSshKey, Vm, VmCustomTemplate, VmHost, VmHostDisk,
VmHostKind, VmIpAssignment, VmOsImage, VmTemplate,
@ -10,11 +9,8 @@ use lnvps_db::{
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::pin::Pin;
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::Semaphore;
//#[cfg(feature = "libvirt")]
//mod libvirt;

View File

@ -5,31 +5,22 @@ use crate::ssh_client::SshClient;
use crate::status::{VmRunningState, VmState};
use anyhow::{anyhow, bail, ensure, Result};
use chrono::Utc;
use futures::{Stream, StreamExt};
use futures::StreamExt;
use ipnetwork::IpNetwork;
use lnvps_db::{async_trait, DiskType, Vm, VmOsImage};
use log::{error, info, warn};
use log::{info, warn};
use rand::random;
use reqwest::header::{HeaderMap, AUTHORIZATION};
use reqwest::{ClientBuilder, Method, Url};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::HashMap;
use std::fmt::{Debug, Display, Formatter};
use std::io::{Read, Write};
use std::io::Write;
use std::net::IpAddr;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::sync::mpsc::channel;
use tokio::sync::Semaphore;
use tokio::time;
use tokio::time::sleep;
use tokio_tungstenite::tungstenite::protocol::Role;
use tokio_tungstenite::WebSocketStream;
use ws::stream::DuplexStream;
pub struct ProxmoxClient {
api: JsonApi,
@ -1140,7 +1131,8 @@ mod tests {
release_date: Utc::now(),
url: "http://localhost.com/ubuntu_server_24.04.img".to_string(),
},
ips: vec![VmIpAssignment {
ips: vec![
VmIpAssignment {
id: 1,
vm_id: 1,
ip_range_id: 1,
@ -1151,14 +1143,40 @@ mod tests {
dns_forward_ref: None,
dns_reverse: None,
dns_reverse_ref: None,
}],
ranges: vec![IpRange {
},
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,
},
],
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,
}],
reverse_zone_id: None,
access_policy_id: None,
},
IpRange {
id: 2,
cidr: "192.168.2.0/24".to_string(),
gateway: "10.10.10.10".to_string(),
enabled: true,
region_id: 2,
reverse_zone_id: None,
access_policy_id: None,
},
],
ssh_key: UserSshKey {
id: 1,
name: "test".to_string(),
@ -1193,7 +1211,10 @@ mod tests {
assert_eq!(vm.on_boot, Some(true));
assert_eq!(
vm.ip_config,
Some("ip=192.168.1.2/16,gw=192.168.1.1,ip6=auto".to_string())
Some(
"ip=192.168.1.2/16,gw=192.168.1.1,ip=192.168.2.2/24,gw=10.10.10.10,ip6=auto"
.to_string()
)
);
Ok(())
}

View File

@ -4,15 +4,15 @@ use crate::exchange::{ExchangeRateService, Ticker, TickerRate};
use crate::host::{FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient};
use crate::lightning::{AddInvoiceRequest, AddInvoiceResult, InvoiceUpdate, LightningNode};
use crate::router::{ArpEntry, Router};
use crate::settings::NetworkPolicy;
use crate::status::{VmRunningState, VmState};
use anyhow::{anyhow, bail, ensure, Context};
use chrono::{DateTime, TimeDelta, Utc};
use fedimint_tonic_lnd::tonic::codegen::tokio_stream::Stream;
use lnvps_db::{
async_trait, DiskInterface, DiskType, IpRange, LNVpsDb, OsDistribution, User, UserSshKey, Vm,
VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate,
VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate,
async_trait, AccessPolicy, DiskInterface, DiskType, IpRange, LNVpsDb, OsDistribution, User,
UserSshKey, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk,
VmCustomTemplate, VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage,
VmPayment, VmTemplate,
};
use std::collections::HashMap;
use std::ops::Add;
@ -37,6 +37,8 @@ pub struct MockDb {
pub custom_pricing_disk: Arc<Mutex<HashMap<u64, VmCustomPricingDisk>>>,
pub custom_template: Arc<Mutex<HashMap<u64, VmCustomTemplate>>>,
pub payments: Arc<Mutex<Vec<VmPayment>>>,
pub router: Arc<Mutex<HashMap<u64, lnvps_db::Router>>>,
pub access_policy: Arc<Mutex<HashMap<u64, AccessPolicy>>>,
}
impl MockDb {
@ -115,6 +117,8 @@ impl Default for MockDb {
gateway: "10.0.0.1/8".to_string(),
enabled: true,
region_id: 1,
reverse_zone_id: None,
access_policy_id: None,
},
);
let mut hosts = HashMap::new();
@ -181,6 +185,8 @@ impl Default for MockDb {
user_ssh_keys: Arc::new(Mutex::new(Default::default())),
custom_template: Arc::new(Default::default()),
payments: Arc::new(Default::default()),
router: Arc::new(Default::default()),
access_policy: Arc::new(Default::default()),
}
}
}
@ -602,21 +608,31 @@ impl LNVpsDb for MockDb {
.cloned()
.collect())
}
async fn get_router(&self, router_id: u64) -> anyhow::Result<lnvps_db::Router> {
let r = self.router.lock().await;
Ok(r.get(&router_id).cloned().context("no router")?)
}
async fn get_access_policy(&self, access_policy_id: u64) -> anyhow::Result<AccessPolicy> {
let p = self.access_policy.lock().await;
Ok(p.get(&access_policy_id)
.cloned()
.context("no access policy")?)
}
}
#[derive(Debug, Clone)]
pub struct MockRouter {
pub policy: NetworkPolicy,
arp: Arc<Mutex<HashMap<u64, ArpEntry>>>,
}
impl MockRouter {
pub fn new(policy: NetworkPolicy) -> Self {
pub fn new() -> Self {
static LAZY_ARP: LazyLock<Arc<Mutex<HashMap<u64, ArpEntry>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
Self {
policy,
arp: LAZY_ARP.clone(),
}
}

View File

@ -354,6 +354,8 @@ mod tests {
gateway: "10.0.0.1".to_string(),
enabled: true,
region_id: 1,
reverse_zone_id: None,
access_policy_id: None,
},
usage: 69,
}],

View File

@ -6,12 +6,15 @@ use crate::lightning::{AddInvoiceRequest, LightningNode};
use crate::provisioner::{
CostResult, HostCapacityService, NetworkProvisioner, PricingEngine, ProvisionerMethod,
};
use crate::router::{ArpEntry, Router};
use crate::settings::{NetworkAccessPolicy, NetworkPolicy, ProvisionerConfig, Settings};
use crate::router::{ArpEntry, MikrotikRouter, Router};
use crate::settings::{ProvisionerConfig, Settings};
use anyhow::{bail, ensure, Context, Result};
use chrono::Utc;
use isocountry::CountryCode;
use lnvps_db::{LNVpsDb, PaymentMethod, User, Vm, VmCustomTemplate, VmIpAssignment, VmPayment};
use lnvps_db::{
AccessPolicy, LNVpsDb, NetworkAccessPolicy, PaymentMethod, RouterKind, Vm, VmCustomTemplate,
VmIpAssignment, VmPayment,
};
use log::{info, warn};
use nostr::util::hex;
use std::collections::HashMap;
@ -30,11 +33,9 @@ pub struct LNVpsProvisioner {
rates: Arc<dyn ExchangeRateService>,
tax_rates: HashMap<CountryCode, f32>,
router: Option<Arc<dyn Router>>,
dns: Option<Arc<dyn DnsServer>>,
revolut: Option<Arc<dyn FiatPaymentService>>,
network_policy: NetworkPolicy,
provisioner_config: ProvisionerConfig,
}
@ -49,48 +50,90 @@ impl LNVpsProvisioner {
db,
node,
rates,
router: settings.get_router().expect("router config"),
dns: settings.get_dns().expect("dns config"),
revolut: settings.get_revolut().expect("revolut config"),
tax_rates: settings.tax_rate,
network_policy: settings.network_policy,
provisioner_config: settings.provisioner,
read_only: settings.read_only,
}
}
async fn get_router(&self, router_id: u64) -> Result<Arc<dyn Router>> {
#[cfg(test)]
return Ok(Arc::new(crate::mocks::MockRouter::new()));
let cfg = self.db.get_router(router_id).await?;
match cfg.kind {
RouterKind::Mikrotik => {
let mut t_split = cfg.token.split(":");
let (username, password) = (
t_split.next().context("Invalid username:password")?,
t_split.next().context("Invalid username:password")?,
);
Ok(Arc::new(MikrotikRouter::new(&cfg.url, username, password)))
}
}
}
/// Create or Update access policy for a given ip assignment, does not save to database!
pub async fn update_access_policy(&self, assignment: &mut VmIpAssignment) -> Result<()> {
pub async fn update_access_policy(
&self,
assignment: &mut VmIpAssignment,
policy: &AccessPolicy,
) -> Result<()> {
// apply network policy
if let NetworkAccessPolicy::StaticArp { interface } = &self.network_policy.access {
if let Some(r) = self.router.as_ref() {
if let NetworkAccessPolicy::StaticArp = policy.kind {
let router = self
.get_router(
policy
.router_id
.context("Cannot apply static arp policy with no router")?,
)
.await?;
let vm = self.db.get_vm(assignment.vm_id).await?;
let entry = ArpEntry::new(&vm, assignment, Some(interface.clone()))?;
let entry = ArpEntry::new(
&vm,
assignment,
Some(
policy
.interface
.as_ref()
.context("Cannot apply static arp entry without an interface name")?
.clone(),
),
)?;
let arp = if let Some(_id) = &assignment.arp_ref {
r.update_arp_entry(&entry).await?
router.update_arp_entry(&entry).await?
} else {
r.add_arp_entry(&entry).await?
router.add_arp_entry(&entry).await?
};
ensure!(arp.id.is_some(), "ARP id was empty");
assignment.arp_ref = arp.id;
} else {
bail!("No router found to apply static arp entry!")
}
}
Ok(())
}
/// Remove an access policy for a given ip assignment, does not save to database!
pub async fn remove_access_policy(&self, assignment: &mut VmIpAssignment) -> Result<()> {
pub async fn remove_access_policy(
&self,
assignment: &mut VmIpAssignment,
policy: &AccessPolicy,
) -> Result<()> {
// Delete access policy
if let NetworkAccessPolicy::StaticArp { .. } = &self.network_policy.access {
if let Some(r) = self.router.as_ref() {
if let NetworkAccessPolicy::StaticArp = &policy.kind {
let router = self
.get_router(
policy
.router_id
.context("Cannot apply static arp policy with no router")?,
)
.await?;
let id = if let Some(id) = &assignment.arp_ref {
Some(id.clone())
} else {
warn!("ARP REF not found, using arp list");
let ent = r.list_arp_entry().await?;
let ent = router.list_arp_entry().await?;
if let Some(ent) = ent.iter().find(|e| e.address == assignment.ip) {
ent.id.clone()
} else {
@ -100,13 +143,12 @@ impl LNVpsProvisioner {
};
if let Some(id) = id {
if let Err(e) = r.remove_arp_entry(&id).await {
if let Err(e) = router.remove_arp_entry(&id).await {
warn!("Failed to remove arp entry, skipping: {}", e);
}
}
assignment.arp_ref = None;
}
}
Ok(())
}
@ -169,8 +211,13 @@ impl LNVpsProvisioner {
pub async fn delete_ip_assignments(&self, vm_id: u64) -> Result<()> {
let ips = self.db.list_vm_ip_assignments(vm_id).await?;
for mut ip in ips {
// load range info to check access policy
let range = self.db.get_ip_range(ip.ip_range_id).await?;
if let Some(ap) = range.access_policy_id {
let ap = self.db.get_access_policy(ap).await?;
// remove access policy
self.remove_access_policy(&mut ip).await?;
self.remove_access_policy(&mut ip, &ap).await?;
}
// remove dns
self.remove_ip_dns(&mut ip).await?;
// save arp/dns changes
@ -183,8 +230,13 @@ impl LNVpsProvisioner {
}
async fn save_ip_assignment(&self, assignment: &mut VmIpAssignment) -> Result<()> {
// load range info to check access policy
let range = self.db.get_ip_range(assignment.ip_range_id).await?;
if let Some(ap) = range.access_policy_id {
let ap = self.db.get_access_policy(ap).await?;
// apply access policy
self.update_access_policy(assignment).await?;
self.update_access_policy(assignment, &ap).await?;
}
// Add DNS records
self.update_forward_ip_dns(assignment).await?;
@ -498,9 +550,7 @@ mod tests {
use super::*;
use crate::exchange::{DefaultRateCache, Ticker};
use crate::mocks::{MockDb, MockDnsServer, MockExchangeRate, MockNode, MockRouter};
use crate::settings::{
mock_settings, DnsServerConfig, LightningConfig, QemuConfig, RouterConfig,
};
use crate::settings::mock_settings;
use lnvps_db::{DiskInterface, DiskType, User, UserSshKey, VmTemplate};
use std::net::IpAddr;
use std::str::FromStr;
@ -509,9 +559,6 @@ mod tests {
pub fn settings() -> Settings {
let mut settings = mock_settings();
settings.network_policy.access = NetworkAccessPolicy::StaticArp {
interface: ROUTER_BRIDGE.to_string(),
};
settings
}
@ -540,7 +587,36 @@ mod tests {
const MOCK_RATE: f32 = 69_420.0;
rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await;
let router = MockRouter::new(settings.network_policy.clone());
// add static arp policy
{
let mut r = db.router.lock().await;
r.insert(
1,
lnvps_db::Router {
id: 1,
name: "mock-router".to_string(),
enabled: true,
kind: RouterKind::Mikrotik,
url: "https://localhost".to_string(),
token: "username:password".to_string(),
},
);
let mut p = db.access_policy.lock().await;
p.insert(
1,
AccessPolicy {
id: 1,
name: "static-arp".to_string(),
kind: NetworkAccessPolicy::StaticArp,
router_id: Some(1),
interface: Some(ROUTER_BRIDGE.to_string()),
},
);
let mut i = db.ip_range.lock().await;
let r = i.get_mut(&1).unwrap();
r.access_policy_id = Some(1);
}
let dns = MockDnsServer::new();
let provisioner = LNVpsProvisioner::new(settings, db.clone(), node.clone(), rates.clone());
@ -567,6 +643,7 @@ mod tests {
provisioner.spawn_vm(vm.id).await?;
// check resources
let router = MockRouter::new();
let arp = router.list_arp_entry().await?;
assert_eq!(1, arp.len());
let arp = arp.first().unwrap();

View File

@ -3,7 +3,6 @@ use crate::exchange::ExchangeRateService;
use crate::fiat::FiatPaymentService;
use crate::lightning::LightningNode;
use crate::provisioner::LNVpsProvisioner;
use crate::router::Router;
use anyhow::Result;
use isocountry::CountryCode;
use lnvps_db::LNVpsDb;
@ -33,19 +32,12 @@ pub struct Settings {
/// Provisioning profiles
pub provisioner: ProvisionerConfig,
#[serde(default)]
/// Network policy
pub network_policy: NetworkPolicy,
/// Number of days after an expired VM is deleted
pub delete_after: u16,
/// SMTP settings for sending emails
pub smtp: Option<SmtpConfig>,
/// Network router config
pub router: Option<RouterConfig>,
/// DNS configurations for PTR records
pub dns: Option<DnsServerConfig>,
@ -82,16 +74,6 @@ pub struct NostrConfig {
pub nsec: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RouterConfig {
Mikrotik {
url: String,
username: String,
password: String,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DnsServerConfig {
@ -103,30 +85,6 @@ pub enum DnsServerConfig {
},
}
/// Policy that determines how packets arrive at the VM
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum NetworkAccessPolicy {
/// No special procedure required for packets to arrive
#[default]
Auto,
/// ARP entries are added statically on the access router
StaticArp {
/// Interface used to add arp entries
interface: String,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct NetworkPolicy {
/// Policy that determines how packets arrive at the VM
pub access: NetworkAccessPolicy,
/// Use SLAAC for IPv6 allocation
pub ip6_slaac: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SmtpConfig {
/// Admin user id, for sending system notifications
@ -203,32 +161,6 @@ impl Settings {
Arc::new(LNVpsProvisioner::new(self.clone(), db, node, exchange))
}
pub fn get_router(&self) -> Result<Option<Arc<dyn Router>>> {
#[cfg(test)]
{
if let Some(_router) = &self.router {
let router = crate::mocks::MockRouter::new(self.network_policy.clone());
Ok(Some(Arc::new(router)))
} else {
Ok(None)
}
}
#[cfg(not(test))]
{
match &self.router {
#[cfg(feature = "mikrotik")]
Some(RouterConfig::Mikrotik {
url,
username,
password,
}) => Ok(Some(Arc::new(crate::router::MikrotikRouter::new(
url, username, password,
)))),
_ => Ok(None),
}
}
}
pub fn get_dns(&self) -> Result<Option<Arc<dyn DnsServer>>> {
#[cfg(test)]
{
@ -285,17 +217,8 @@ pub fn mock_settings() -> Settings {
ssh: None,
mac_prefix: Some("ff:ff:ff".to_string()),
},
network_policy: NetworkPolicy {
access: NetworkAccessPolicy::Auto,
ip6_slaac: None,
},
delete_after: 0,
smtp: None,
router: Some(RouterConfig::Mikrotik {
url: "https://localhost".to_string(),
username: "admin".to_string(),
password: "password123".to_string(),
}),
dns: Some(DnsServerConfig::Cloudflare {
token: "abc".to_string(),
forward_zone_id: "123".to_string(),