From a419383ee5ae80988f8cf1417c18b66826c09661 Mon Sep 17 00:00:00 2001 From: v0l Date: Fri, 10 Jul 2026 13:21:24 +0100 Subject: [PATCH 1/4] feat: DB-configured DNS providers + OVH reverse DNS Move DNS provider configuration out of static config.yaml into the database, mirroring the router pattern, and add an OVH reverse-DNS provider. - New `dns_server` table + model (`DnsServer`/`DnsServerKind`), base-trait CRUD, and admin API at `/api/admin/v1/dns_servers` (`dns_server` permission resource). - IP ranges gain `forward_dns_server_id`, `reverse_dns_server_id`, `forward_zone_id` (alongside the existing `reverse_zone_id`) selecting the forward/reverse provider + zone per range. - Generalize the `DnsServer` trait: zone/record ids modelled as a `DnsRef` enum (`Implicit` | `Id(String)`) instead of stuffing the IP into a string; factory `dns::get_dns_server(db, id)` dispatches on kind. - New `ovh` DNS provider: reverse DNS via `POST/DELETE /ip/{ip}/reverse` (reverse only). Shared OVH signing extracted to `crate::ovh` and reused by the router. - `vm_network` resolves DNS servers per range from the DB; `Settings.dns` is now a legacy migration-only field. - Startup data migration imports the legacy Cloudflare config and OVH additional-IP routers into `dns_server` rows, auto-maps reverse DNS on OVH-routed ranges, and force-refreshes stale Cloudflare reverse records to real OVH PTRs (idempotent, best-effort so a permission error never blocks startup). - New `PatchIpRangeDns` job + `POST /api/admin/v1/ip_ranges/{id}/patch_dns` to re-apply forward/reverse DNS for a whole range on demand. Fixes #78 --- ADMIN_API_ENDPOINTS.md | 110 ++++- API_CHANGELOG.md | 17 + lnvps_api/Cargo.toml | 3 + lnvps_api/src/data_migration/dns.rs | 426 ++++++++++++++++-- lnvps_api/src/dns/cloudflare.rs | 40 +- lnvps_api/src/dns/mod.rs | 214 ++++++++- lnvps_api/src/dns/ovh.rs | 127 ++++++ lnvps_api/src/lib.rs | 1 + lnvps_api/src/mocks.rs | 65 ++- lnvps_api/src/ovh/mod.rs | 127 ++++++ lnvps_api/src/provisioner/retry_tests.rs | 126 +++--- lnvps_api/src/provisioner/rollback_tests.rs | 44 +- lnvps_api/src/provisioner/vm.rs | 9 +- lnvps_api/src/provisioner/vm_network.rs | 135 +++--- lnvps_api/src/router/ovh.rs | 115 +---- lnvps_api/src/settings.rs | 59 ++- lnvps_api/src/worker.rs | 46 +- lnvps_api_admin/src/admin/dns_servers.rs | 127 ++++++ lnvps_api_admin/src/admin/ip_ranges.rs | 70 ++- lnvps_api_admin/src/admin/mod.rs | 2 + lnvps_api_admin/src/admin/model.rs | 114 +++++ lnvps_api_admin/src/bin/generate_demo_data.rs | 3 + lnvps_api_common/src/mock.rs | 99 +++- lnvps_api_common/src/work/mod.rs | 17 + .../migrations/20260710121414_dns_server.sql | 25 + lnvps_db/src/lib.rs | 28 ++ lnvps_db/src/model.rs | 51 +++ lnvps_db/src/mysql.rs | 126 +++++- work/dns-db-refactor.md | 115 +++++ 29 files changed, 2052 insertions(+), 389 deletions(-) create mode 100644 lnvps_api/src/dns/ovh.rs create mode 100644 lnvps_api/src/ovh/mod.rs create mode 100644 lnvps_api_admin/src/admin/dns_servers.rs create mode 100644 lnvps_db/migrations/20260710121414_dns_server.sql create mode 100644 work/dns-db-refactor.md diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 83cccbe1..332be529 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -2072,13 +2072,19 @@ Body: "region_id": number, // Required - Region ID "reverse_zone_id": "string | null", - // Optional - Reverse DNS zone ID + // Optional - Reverse DNS zone ID (provider specific, e.g. Cloudflare reverse zone id) "access_policy_id": "number | null", // Optional - Access policy ID "allocation_mode": "sequential", // IpRangeAllocationMode enum: "random", "sequential", or "slaac_eui64", default: "sequential" - "use_full_range": boolean + "use_full_range": boolean, // Optional - Use first and last IPs in range, default: false + "forward_dns_server_id": "number | null", + // Optional - dns_server id used for forward (A/AAAA) records + "reverse_dns_server_id": "number | null", + // Optional - dns_server id used for reverse (PTR) records + "forward_zone_id": "string | null" + // Optional - Forward DNS zone id (provider specific, e.g. Cloudflare forward zone id) } ``` @@ -2108,8 +2114,14 @@ Body (all optional): // Access policy ID (null to clear) "allocation_mode": "sequential", // IpRangeAllocationMode enum: "random", "sequential", or "slaac_eui64" - "use_full_range": boolean + "use_full_range": boolean, // Use first and last IPs in range + "forward_dns_server_id": "number | null", + // dns_server id for forward (A/AAAA) records (null to clear) + "reverse_dns_server_id": "number | null", + // dns_server id for reverse (PTR) records (null to clear) + "forward_zone_id": "string | null" + // Forward DNS zone id (null to clear) } ``` @@ -2151,6 +2163,20 @@ Response: } ``` +#### Patch DNS for Range + +``` +POST /api/admin/v1/ip_ranges/{id}/patch_dns +``` + +Required Permission: `ip_range::update` + +Queues a background job (`PatchIpRangeDns`) that re-applies forward + reverse DNS records for every +(non-deleted) IP assignment in the range, reconciling them to the range's current DNS server +configuration. Useful after changing a range's `forward_dns_server_id` / `reverse_dns_server_id` +(e.g. switching reverse DNS to OVH). Per-assignment DNS failures are logged and skipped so one bad +record can't abort the batch. Returns a `JobResponse` (`{ "job_id": string }`). + ### Access Policy Management #### List Access Policies @@ -2495,6 +2521,83 @@ Required Permission: `router::update` Remove the router's static default route(s). Idempotent — succeeds even when no default route is configured. Returns a `JobResponse`. +### DNS Server Management + +DNS providers are configured in the database (`dns_server` table) and referenced per IP range via +`forward_dns_server_id` / `reverse_dns_server_id` (see IP Range Management). This replaces the old static +`dns` block in `config.yaml`. + +#### List DNS Servers + +``` +GET /api/admin/v1/dns_servers +``` + +Query Parameters: + +- `limit`: number (optional) - Items per page (max 100, default 50) +- `offset`: number (optional) - Pagination offset + +Required Permission: `dns_server::view` + +Returns a paginated list of DNS servers. Each entry includes `id`, `name`, `enabled`, `kind`, `url`, and +`ip_range_count` (number of IP ranges referencing it for forward or reverse DNS). The `token` is never returned. + +#### Get DNS Server Details + +``` +GET /api/admin/v1/dns_servers/{dns_server_id} +``` + +Required Permission: `dns_server::view` + +#### Create DNS Server + +``` +POST /api/admin/v1/dns_servers +``` + +Required Permission: `dns_server::create` + +Body: + +```json +{ + "name": "string", + // Required - Display name + "enabled": boolean, + // Optional - Default: true + "kind": "cloudflare", + // DnsServerKind enum: "cloudflare" (forward + reverse) or "ovh" (reverse only) + "url": "string", + // Optional - API base url (required for OVH, e.g. https://eu.api.ovh.com) + "token": "string" + // Required - Credential token. Cloudflare: bearer token. + // OVH: "application_key:application_secret:consumer_key" +} +``` + +#### Update DNS Server + +``` +PATCH /api/admin/v1/dns_servers/{dns_server_id} +``` + +Required Permission: `dns_server::update` + +Body (all optional): `name`, `enabled`, `kind`, `url`, `token`. + +#### Delete DNS Server + +``` +DELETE /api/admin/v1/dns_servers/{dns_server_id} +``` + +Required Permission: `dns_server::delete` + +Note: DNS servers referenced by any IP range (forward or reverse) cannot be deleted. Remove the reference from all IP +ranges first. + ### VM IP Assignment Management VM IP assignments bind specific IP addresses from IP ranges to virtual machines. These endpoints provide comprehensive @@ -3018,6 +3121,7 @@ The RBAC system uses the following permission format: `resource::action` - `company` - Company/organization management - `ip_range` - IP address range management - `router` - Network router configuration +- `dns_server` - DNS provider configuration - `vm_custom_pricing` - Custom VM pricing models - `host_region` - Host region configuration - `vm_os_image` - VM operating system images diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 8daacc5c..04b652da 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to the LNVPS APIs are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Added + +- **2026-07-10** - Database-configured DNS providers + OVH reverse DNS (admin) + - DNS providers are now configured in the database (`dns_server` table) instead of the static `dns` block in `config.yaml`. Each provider has `kind` (`"cloudflare"` or `"ovh"`), `url`, and an encrypted `token`. + - `GET /api/admin/v1/dns_servers` — paginated list (`id`, `name`, `enabled`, `kind`, `url`, `ip_range_count`; token never returned). Requires `dns_server::view`. + - `GET /api/admin/v1/dns_servers/{id}` — get one. Requires `dns_server::view`. + - `POST /api/admin/v1/dns_servers` — create. Body: `{ name, enabled?, kind, url?, token }`. Cloudflare token is the bearer token; OVH token is `"application_key:application_secret:consumer_key"`. Requires `dns_server::create`. + - `PATCH /api/admin/v1/dns_servers/{id}` — update (all fields optional). Requires `dns_server::update`. + - `DELETE /api/admin/v1/dns_servers/{id}` — delete (blocked while referenced by any IP range). Requires `dns_server::delete`. + - IP ranges gained `forward_dns_server_id`, `reverse_dns_server_id`, and `forward_zone_id` fields (in addition to the existing `reverse_zone_id`) on the create/update/list admin endpoints, selecting which DNS provider + zone manages forward (A/AAAA) and reverse (PTR) records for the range. + - New `ovh` DNS provider implements reverse DNS (PTR) via OVH's `POST/DELETE /ip/{ip}/reverse` (reverse only; forward records must use another provider). Closes #78. + - New `dns_server` admin permission resource. + - `POST /api/admin/v1/ip_ranges/{id}/patch_dns` — queue a `PatchIpRangeDns` job that re-applies forward + reverse DNS for every IP assignment in a range, reconciling them to the range's current DNS server config (e.g. after switching reverse DNS to OVH). Returns a `JobResponse`. Requires `ip_range::update`. + - Migration: the legacy `config.yaml` `dns` block is migrated into a `dns_server` row on startup, and existing IP ranges are pointed at it automatically. OVH additional-IP routers are also imported as `Ovh` DNS servers (reusing their `url` + token), with reverse DNS auto-mapped onto the ranges they route. Existing OVH-routed IPs that still carry a stale Cloudflare reverse record are force-refreshed to a real OVH PTR (idempotent — keyed on the IP; working Cloudflare reverse records are left untouched). (The OVH consumer key may need `/ip/*/reverse` permissions granted for reverse DNS calls to succeed.) Record backfill/refresh is best-effort and never blocks startup. + ## [0.3.0] - 2026-07-09 ### Added diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index 618b0be7..d2769dc8 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -27,6 +27,7 @@ default = [ "linux-ssh", "lnd", "cloudflare", + "ovh", "revolut", "tokio/sync", "tokio/io-util", @@ -46,6 +47,8 @@ lnd = ["payments-rs/method-lnd"] # bitvora is disabled - service has been shut down bitvora = ["payments-rs/method-bitvora"] cloudflare = [] +# OVH reverse DNS provider +ovh = [] revolut = ["payments-rs/method-revolut"] stripe = ["payments-rs/method-stripe"] diff --git a/lnvps_api/src/data_migration/dns.rs b/lnvps_api/src/data_migration/dns.rs index cb12f7d4..a733c67d 100644 --- a/lnvps_api/src/data_migration/dns.rs +++ b/lnvps_api/src/data_migration/dns.rs @@ -1,66 +1,410 @@ use crate::data_migration::DataMigration; -use crate::dns::{BasicRecord, DnsServer}; +use crate::dns::{BasicRecord, DnsRef, get_dns_server}; use crate::settings::Settings; use anyhow::Result; -use lnvps_db::LNVpsDb; +use lnvps_db::{DnsServer, DnsServerKind, LNVpsDb, RouterKind}; +use log::warn; use std::future::Future; use std::pin::Pin; use std::sync::Arc; +/// Legacy Cloudflare DNS config (from `settings.dns`) to migrate into the DB. +#[derive(Clone)] +struct LegacyCloudflare { + kind: DnsServerKind, + token: String, + forward_zone_id: String, +} + +/// One-shot migration that moves DNS provider config into the `dns_server` table: +/// - the legacy `settings.dns` (Cloudflare) block, and +/// - OVH additional-IP routers (which share the same `url` + `app_key:app_secret:consumer_key` +/// token as OVH reverse DNS) are imported as `Ovh` DNS servers, auto-mapping reverse DNS +/// on the ranges they route. +/// +/// Then it backfills any missing forward/reverse records (best-effort). Idempotent — +/// safe to run on every startup. pub struct DnsDataMigration { db: Arc, - dns: Arc, - forward_zone_id: Option, + cloudflare: Option, } impl DnsDataMigration { pub fn new(db: Arc, settings: &Settings) -> Option { - let dns = settings.get_dns()?; - Some(Self { - db, - dns, - forward_zone_id: settings.dns.as_ref().map(|z| z.forward_zone_id.to_string()), - }) + let cloudflare = settings.dns.as_ref().map(|cfg| { + let (kind, token) = cfg.to_db_kind_token(); + LegacyCloudflare { + kind, + token, + forward_zone_id: cfg.forward_zone_id.clone(), + } + }); + Some(Self { db, cloudflare }) + } + + /// Find an existing DNS server by name, or create it, returning its id. + async fn ensure_dns_server( + db: &Arc, + name: &str, + kind: DnsServerKind, + url: &str, + token: lnvps_db::EncryptedString, + ) -> Result { + let existing = db.list_dns_servers().await?; + if let Some(row) = existing.iter().find(|r| r.name == name) { + return Ok(row.id); + } + let id = db + .insert_dns_server(&DnsServer { + id: 0, + name: name.to_string(), + enabled: true, + kind, + url: url.to_string(), + token, + }) + .await?; + Ok(id) + } + + /// Import the legacy Cloudflare config and point ranges at it (where unset). + async fn migrate_cloudflare(db: &Arc, cf: &LegacyCloudflare) -> Result<()> { + let dns_server_id = + Self::ensure_dns_server(db, "migrated-dns", cf.kind, "", cf.token.clone().into()) + .await?; + + let ranges = db.list_ip_range().await?; + for mut range in ranges { + let mut changed = false; + if range.forward_dns_server_id.is_none() { + range.forward_dns_server_id = Some(dns_server_id); + range.forward_zone_id = Some(cf.forward_zone_id.clone()); + changed = true; + } + if range.reverse_zone_id.is_some() && range.reverse_dns_server_id.is_none() { + range.reverse_dns_server_id = Some(dns_server_id); + changed = true; + } + if changed { + db.update_ip_range_dns(&range).await?; + } + } + Ok(()) + } + + /// Import OVH additional-IP routers as `Ovh` DNS servers and auto-map reverse DNS + /// on the ranges those routers serve (via their access policy). + async fn migrate_ovh(db: &Arc) -> Result<()> { + let routers = db.list_routers().await?; + // router_id -> dns_server_id for each OVH additional-IP router + let mut ovh_map: std::collections::HashMap = Default::default(); + for router in routers + .into_iter() + .filter(|r| matches!(r.kind, RouterKind::OvhAdditionalIp)) + { + let name = format!("ovh-reverse-{}", router.name); + let dns_id = Self::ensure_dns_server( + db, + &name, + DnsServerKind::Ovh, + &router.url, + router.token.clone(), + ) + .await?; + ovh_map.insert(router.id, dns_id); + } + + if ovh_map.is_empty() { + return Ok(()); + } + + // Map ranges whose access policy points at an OVH router to that OVH DNS server + // for reverse DNS (only where reverse is not already configured). + let ranges = db.list_ip_range().await?; + for mut range in ranges { + if range.reverse_dns_server_id.is_some() { + continue; + } + let Some(policy_id) = range.access_policy_id else { + continue; + }; + let Ok(policy) = db.get_access_policy(policy_id).await else { + continue; + }; + let Some(router_id) = policy.router_id else { + continue; + }; + if let Some(dns_id) = ovh_map.get(&router_id) { + range.reverse_dns_server_id = Some(*dns_id); + db.update_ip_range_dns(&range).await?; + } + } + Ok(()) + } + + /// Whether an IP's reverse record needs to be (re)created. + /// + /// - Missing reverse (and a name exists to point at) → create. + /// - Existing reverse: only OVH records whose stored ref isn't yet the OVH + /// implicit key (the IP itself) are force-refreshed — this replaces the + /// stale Cloudflare PTRs that OVH-routed IPs carried before this change. + /// Working Cloudflare reverse records are left untouched. + fn reverse_needs_create_or_refresh(ip: &lnvps_db::VmIpAssignment, is_ovh: bool) -> bool { + let has_name = ip.dns_forward.is_some() || ip.dns_reverse.is_some(); + if !has_name { + return false; + } + if ip.dns_reverse.is_none() { + return true; + } + is_ovh && ip.dns_reverse_ref.as_deref() != Some(ip.ip.as_str()) + } + + /// Best-effort backfill/refresh of forward/reverse records for existing IPs. + /// Failures are logged and skipped so a DNS/permission problem never aborts startup. + async fn backfill_records(db: &Arc) -> Result<()> { + let vms = db.list_vms().await?; + for vm in vms { + let mut ips = db.list_vm_ip_assignments(vm.id).await?; + for ip in &mut ips { + let range = db.get_ip_range(ip.ip_range_id).await?; + let mut did_change = false; + + if ip.dns_forward.is_none() + && let Some(fwd_id) = range.forward_dns_server_id + { + let rec = + BasicRecord::forward(ip, DnsRef::from_opt(range.forward_zone_id.clone()))?; + match get_dns_server(db, fwd_id).await { + Ok(dns) => match dns.add_record(&rec).await { + Ok(r) => { + ip.dns_forward = Some(r.name.clone()); + ip.dns_forward_ref = r.stored_ref(); + did_change = true; + } + Err(e) => warn!( + "[dns-migration] forward backfill failed for {}: {}", + ip.ip, e + ), + }, + Err(e) => warn!( + "[dns-migration] forward dns server {} unavailable: {}", + fwd_id, e + ), + } + } + + if let Some(rev_id) = range.reverse_dns_server_id { + // Determine the provider kind to decide whether to force-refresh. + let is_ovh = matches!( + db.get_dns_server(rev_id).await.map(|s| s.kind), + Ok(DnsServerKind::Ovh) + ); + if Self::reverse_needs_create_or_refresh(ip, is_ovh) { + let rec = BasicRecord::reverse_to_fwd( + ip, + DnsRef::from_opt(range.reverse_zone_id.clone()), + )?; + match get_dns_server(db, rev_id).await { + Ok(dns) => match dns.add_record(&rec).await { + Ok(r) => { + ip.dns_reverse = Some(r.value.clone()); + ip.dns_reverse_ref = r.stored_ref(); + did_change = true; + } + Err(e) => warn!( + "[dns-migration] reverse backfill failed for {}: {}", + ip.ip, e + ), + }, + Err(e) => warn!( + "[dns-migration] reverse dns server {} unavailable: {}", + rev_id, e + ), + } + } + } + + if did_change { + db.update_vm_ip_assignment(ip).await?; + } + } + } + Ok(()) } } impl DataMigration for DnsDataMigration { fn migrate(&self) -> Pin> + Send>> { let db = self.db.clone(); - let dns = self.dns.clone(); - let forward_zone_id = self.forward_zone_id.clone(); + let cloudflare = self.cloudflare.clone(); Box::pin(async move { - let zone_id = if let Some(z) = forward_zone_id { - z - } else { - return Ok(()); - }; - let vms = db.list_vms().await?; - - for vm in vms { - let mut ips = db.list_vm_ip_assignments(vm.id).await?; - for ip in &mut ips { - let mut did_change = false; - if ip.dns_forward.is_none() { - let rec = BasicRecord::forward(ip)?; - let r = dns.add_record(&zone_id, &rec).await?; - ip.dns_forward = Some(r.name); - ip.dns_forward_ref = r.id; - did_change = true; - } - if ip.dns_reverse.is_none() { - let rec = BasicRecord::reverse_to_fwd(ip)?; - let r = dns.add_record(&zone_id, &rec).await?; - ip.dns_reverse = Some(r.value); - ip.dns_reverse_ref = r.id; - did_change = true; - } - if did_change { - db.update_vm_ip_assignment(ip).await?; - } - } + if let Some(cf) = &cloudflare { + Self::migrate_cloudflare(&db, cf).await?; } + Self::migrate_ovh(&db).await?; + Self::backfill_records(&db).await?; Ok(()) }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::mock_settings; + use lnvps_api_common::MockDb; + + fn ip_with( + dns_forward: Option<&str>, + dns_reverse: Option<&str>, + rev_ref: Option<&str>, + ) -> lnvps_db::VmIpAssignment { + lnvps_db::VmIpAssignment { + ip: "15.235.3.225".to_string(), + dns_forward: dns_forward.map(|s| s.to_string()), + dns_reverse: dns_reverse.map(|s| s.to_string()), + dns_reverse_ref: rev_ref.map(|s| s.to_string()), + ..Default::default() + } + } + + #[test] + fn test_reverse_needs_create_or_refresh() { + // No name at all -> nothing to do + assert!(!DnsDataMigration::reverse_needs_create_or_refresh( + &ip_with(None, None, None), + true + )); + // Missing reverse but has a forward name -> create + assert!(DnsDataMigration::reverse_needs_create_or_refresh( + &ip_with(Some("vm-1.lnvps.cloud"), None, None), + false + )); + // Cloudflare reverse already set -> leave untouched + assert!(!DnsDataMigration::reverse_needs_create_or_refresh( + &ip_with( + Some("vm-1.lnvps.cloud"), + Some("vm-1.lnvps.cloud"), + Some("cf-abc") + ), + false + )); + // OVH range with a stale Cloudflare ref -> force refresh + assert!(DnsDataMigration::reverse_needs_create_or_refresh( + &ip_with( + Some("vm-1.lnvps.cloud"), + Some("vm-1.lnvps.cloud"), + Some("cf-abc") + ), + true + )); + // OVH range already keyed on the IP -> idempotent, skip + assert!(!DnsDataMigration::reverse_needs_create_or_refresh( + &ip_with( + Some("vm-1.lnvps.cloud"), + Some("vm-1.lnvps.cloud"), + Some("15.235.3.225") + ), + true + )); + } + + #[tokio::test] + async fn test_dns_migration_bootstraps_and_is_idempotent() -> anyhow::Result<()> { + let db_impl = Arc::new(MockDb::default()); + // Start from a clean slate: no dns servers, ranges without forward dns config. + db_impl.dns_servers.lock().await.clear(); + { + let mut ranges = db_impl.ip_range.lock().await; + for r in ranges.values_mut() { + r.forward_dns_server_id = None; + r.reverse_dns_server_id = None; + r.forward_zone_id = None; + r.reverse_zone_id = Some("rev-zone".to_string()); + } + } + + let db: Arc = db_impl.clone(); + let settings = mock_settings(); + let migration = DnsDataMigration::new(db.clone(), &settings).expect("migration enabled"); + + migration.migrate().await?; + + // A dns_server row was created and ranges were pointed at it. + let servers = db.list_dns_servers().await?; + assert_eq!(servers.len(), 1); + let sid = servers[0].id; + for r in db.list_ip_range().await? { + assert_eq!(r.forward_dns_server_id, Some(sid)); + assert_eq!(r.reverse_dns_server_id, Some(sid)); + assert_eq!(r.forward_zone_id.as_deref(), Some("mock-forward-zone-id")); + } + + // Running again must not create a second dns_server row. + migration.migrate().await?; + assert_eq!(db.list_dns_servers().await?.len(), 1); + Ok(()) + } + + #[tokio::test] + async fn test_dns_migration_imports_ovh_router() -> anyhow::Result<()> { + let db_impl = Arc::new(MockDb::default()); + db_impl.dns_servers.lock().await.clear(); + + // An OVH additional-IP router + db_impl.router.lock().await.insert( + 5, + lnvps_db::Router { + id: 5, + name: "ns1234.ovh.net".to_string(), + enabled: true, + kind: RouterKind::OvhAdditionalIp, + url: "https://eu.api.ovh.com".to_string(), + token: "ak:as:ck".into(), + }, + ); + // An access policy using that router + db_impl.access_policy.lock().await.insert( + 9, + lnvps_db::AccessPolicy { + id: 9, + name: "ovh".to_string(), + kind: lnvps_db::NetworkAccessPolicy::StaticArp, + router_id: Some(5), + interface: None, + }, + ); + // Point range 1 at that access policy, clear its DNS config + { + let mut ranges = db_impl.ip_range.lock().await; + let r = ranges.get_mut(&1).unwrap(); + r.access_policy_id = Some(9); + r.forward_dns_server_id = None; + r.reverse_dns_server_id = None; + r.reverse_zone_id = None; + } + + let db: Arc = db_impl.clone(); + // No legacy cloudflare config for this deployment + let mut settings = mock_settings(); + settings.dns = None; + let migration = DnsDataMigration::new(db.clone(), &settings).expect("migration enabled"); + + migration.migrate().await?; + + let servers = db.list_dns_servers().await?; + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].kind, DnsServerKind::Ovh); + assert_eq!(servers[0].name, "ovh-reverse-ns1234.ovh.net"); + let ovh_id = servers[0].id; + + let range = db.get_ip_range(1).await?; + assert_eq!(range.reverse_dns_server_id, Some(ovh_id)); + + // Idempotent + migration.migrate().await?; + assert_eq!(db.list_dns_servers().await?.len(), 1); + Ok(()) + } +} diff --git a/lnvps_api/src/dns/cloudflare.rs b/lnvps_api/src/dns/cloudflare.rs index fb96cf5f..db35c410 100644 --- a/lnvps_api/src/dns/cloudflare.rs +++ b/lnvps_api/src/dns/cloudflare.rs @@ -1,4 +1,4 @@ -use crate::dns::{BasicRecord, DnsServer}; +use crate::dns::{BasicRecord, DnsRef, DnsServer}; use crate::json_api::JsonApi; use anyhow::Context; use async_trait::async_trait; @@ -44,7 +44,11 @@ impl Cloudflare { #[async_trait] impl DnsServer for Cloudflare { - async fn add_record(&self, zone_id: &str, record: &BasicRecord) -> OpResult { + async fn add_record(&self, record: &BasicRecord) -> OpResult { + let zone_id = record + .zone + .as_id() + .context("zone id required for Cloudflare records")?; info!( "Adding record: [{}] {} => {}", record.kind, record.name, record.value @@ -65,13 +69,23 @@ impl DnsServer for Cloudflare { Ok(BasicRecord { name: id_response.result.name, value: id_response.result.content, - id: id_response.result.id, + id: id_response.result.id.map(DnsRef::Id), kind: record.kind.clone(), + ip: record.ip.clone(), + zone: record.zone.clone(), }) } - async fn delete_record(&self, zone_id: &str, record: &BasicRecord) -> OpResult<()> { - let record_id = record.id.as_ref().context("record id missing")?; + async fn delete_record(&self, record: &BasicRecord) -> OpResult<()> { + let zone_id = record + .zone + .as_id() + .context("zone id required for Cloudflare records")?; + let record_id = record + .id + .as_ref() + .and_then(DnsRef::as_id) + .context("record id missing")?; info!( "Deleting record: [{}] {} => {}", record.kind, record.name, record.value @@ -93,12 +107,20 @@ impl DnsServer for Cloudflare { Ok(()) } - async fn update_record(&self, zone_id: &str, record: &BasicRecord) -> OpResult { + async fn update_record(&self, record: &BasicRecord) -> OpResult { + let zone_id = record + .zone + .as_id() + .context("zone id required for Cloudflare records")?; info!( "Updating record: [{}] {} => {}", record.kind, record.name, record.value ); - let record_id = record.id.as_ref().context("record id missing")?; + let record_id = record + .id + .as_ref() + .and_then(DnsRef::as_id) + .context("record id missing")?; let id_response: CfResult = self .api .req( @@ -116,8 +138,10 @@ impl DnsServer for Cloudflare { Ok(BasicRecord { name: id_response.result.name, value: id_response.result.content, - id: id_response.result.id, + id: id_response.result.id.map(DnsRef::Id), kind: record.kind.clone(), + ip: record.ip.clone(), + zone: record.zone.clone(), }) } } diff --git a/lnvps_api/src/dns/mod.rs b/lnvps_api/src/dns/mod.rs index 8bc7501c..e4f47300 100644 --- a/lnvps_api/src/dns/mod.rs +++ b/lnvps_api/src/dns/mod.rs @@ -6,22 +6,65 @@ use std::fmt::{Display, Formatter}; use std::net::IpAddr; use std::str::FromStr; +use lnvps_api_common::retry::OpError; +use lnvps_db::{DnsServerKind, LNVpsDb}; +use std::sync::Arc; + #[cfg(feature = "cloudflare")] mod cloudflare; +#[cfg(feature = "ovh")] +mod ovh; use crate::provisioner::NetworkProvisioner; #[cfg(feature = "cloudflare")] pub use cloudflare::*; +#[cfg(feature = "ovh")] +pub use ovh::*; #[async_trait] pub trait DnsServer: Send + Sync { - /// Add PTR record to the reverse zone - async fn add_record(&self, zone_id: &str, record: &BasicRecord) -> OpResult; + /// Add a DNS record. The target zone (if any) is carried on `record.zone_id`. + async fn add_record(&self, record: &BasicRecord) -> OpResult; + + /// Delete a DNS record. The target zone (if any) is carried on `record.zone_id`. + async fn delete_record(&self, record: &BasicRecord) -> OpResult<()>; - /// Delete PTR record from the reverse zone - async fn delete_record(&self, zone_id: &str, record: &BasicRecord) -> OpResult<()>; + /// Update a DNS record. The target zone (if any) is carried on `record.zone_id`. + async fn update_record(&self, record: &BasicRecord) -> OpResult; +} - /// Update a record - async fn update_record(&self, zone_id: &str, record: &BasicRecord) -> OpResult; +/// Construct a DNS server client from a database `dns_server` row. +pub async fn get_dns_server( + db: &Arc, + dns_server_id: u64, +) -> OpResult> { + let cfg = db.get_dns_server(dns_server_id).await?; + match cfg.kind { + #[cfg(feature = "cloudflare")] + DnsServerKind::Cloudflare => Ok(Arc::new(cloudflare::Cloudflare::new(cfg.token.as_str()))), + #[cfg(not(feature = "cloudflare"))] + DnsServerKind::Cloudflare => Err(OpError::Fatal(anyhow::anyhow!( + "Cloudflare DNS support is not enabled in this build" + ))), + #[cfg(feature = "ovh")] + DnsServerKind::Ovh => Ok(Arc::new( + ovh::OvhDns::new(&cfg.url, cfg.token.as_str()).await?, + )), + #[cfg(not(feature = "ovh"))] + DnsServerKind::Ovh => Err(OpError::Fatal(anyhow::anyhow!( + "OVH DNS support is not enabled in this build" + ))), + DnsServerKind::MockDns => { + #[cfg(test)] + return Ok(Arc::new(crate::mocks::MockDnsServer::new())); + #[cfg(not(test))] + { + #[allow(unreachable_code)] + Err(OpError::Fatal(anyhow::anyhow!( + "Cannot use mock DNS server outside tests" + ))) + } + } + } } #[derive(Clone, Debug)] @@ -31,12 +74,62 @@ pub enum RecordType { PTR, } +/// A reference to a DNS zone or record. +/// +/// Some providers don't expose ids: OVH reverse DNS has no zones and addresses +/// records implicitly by the IP itself. Those use [`DnsRef::Implicit`]; zone- and +/// record-id based providers (e.g. Cloudflare) use [`DnsRef::Id`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DnsRef { + /// No provider-assigned id; addressed implicitly (e.g. OVH keys on the IP). + Implicit, + /// Explicit provider-assigned id (e.g. a Cloudflare zone or record id). + Id(String), +} + +impl DnsRef { + /// Build a reference from an optional stored id string; `None` → [`DnsRef::Implicit`]. + pub fn from_opt(s: Option) -> Self { + match s { + Some(v) => DnsRef::Id(v), + None => DnsRef::Implicit, + } + } + + /// The explicit id string, if this is [`DnsRef::Id`]. + pub fn as_id(&self) -> Option<&str> { + match self { + DnsRef::Id(s) => Some(s.as_str()), + DnsRef::Implicit => None, + } + } +} + #[derive(Debug, Clone)] pub struct BasicRecord { pub name: String, pub value: String, - pub id: Option, + /// The record's provider reference. `None` means the record has not been + /// created yet; `Some(_)` carries its reference once created. + pub id: Option, pub kind: RecordType, + /// The IP address this record refers to. Used by providers that key on the + /// IP directly (e.g. OVH reverse DNS) rather than a zone + record id. + pub ip: String, + /// The target zone. [`DnsRef::Implicit`] for zoneless providers (OVH). + pub zone: DnsRef, +} + +impl BasicRecord { + /// The id to persist after a create/update. Implicit providers (OVH) fall + /// back to the IP as their stable key so the record can be found later. + pub fn stored_ref(&self) -> Option { + match &self.id { + Some(DnsRef::Id(s)) => Some(s.clone()), + Some(DnsRef::Implicit) => Some(self.ip.clone()), + None => None, + } + } } impl Display for RecordType { @@ -50,20 +143,22 @@ impl Display for RecordType { } impl BasicRecord { - pub fn forward(ip: &VmIpAssignment) -> Result { + pub fn forward(ip: &VmIpAssignment, zone: DnsRef) -> Result { let addr = IpAddr::from_str(&ip.ip)?; Ok(Self { name: format!("vm-{}", &ip.vm_id), value: addr.to_string(), - id: ip.dns_forward_ref.clone(), + id: ip.dns_forward_ref.clone().map(DnsRef::Id), kind: match addr { IpAddr::V4(_) => RecordType::A, IpAddr::V6(_) => RecordType::AAAA, }, + ip: ip.ip.clone(), + zone, }) } - pub fn reverse_to_fwd(ip: &VmIpAssignment) -> Result { + pub fn reverse_to_fwd(ip: &VmIpAssignment, zone: DnsRef) -> Result { let addr = IpAddr::from_str(&ip.ip)?; // Use explicit reverse entry or use the forward entry @@ -83,12 +178,14 @@ impl BasicRecord { IpAddr::V6(i) => NetworkProvisioner::ipv6_to_ptr(&i)?, }, value: fwd, - id: ip.dns_reverse_ref.clone(), + id: ip.dns_reverse_ref.clone().map(DnsRef::Id), kind: RecordType::PTR, + ip: ip.ip.clone(), + zone, }) } - pub fn reverse(ip: &VmIpAssignment) -> Result { + pub fn reverse(ip: &VmIpAssignment, zone: DnsRef) -> Result { let addr = IpAddr::from_str(&ip.ip)?; let rev = ip .dns_reverse @@ -104,8 +201,10 @@ impl BasicRecord { IpAddr::V6(i) => NetworkProvisioner::ipv6_to_ptr(&i)?, }, value: rev, - id: ip.dns_reverse_ref.clone(), + id: ip.dns_reverse_ref.clone().map(DnsRef::Id), kind: RecordType::PTR, + ip: ip.ip.clone(), + zone, }) } } @@ -152,3 +251,92 @@ pub fn is_valid_fqdn(s: &str) -> bool { true } + +#[cfg(test)] +mod tests { + use super::*; + use lnvps_api_common::MockDb; + use lnvps_db::VmIpAssignment; + + fn v4_assignment() -> VmIpAssignment { + VmIpAssignment { + id: 1, + vm_id: 42, + ip_range_id: 1, + ip: "10.0.0.5".to_string(), + dns_reverse: Some("host.example.com".to_string()), + ..Default::default() + } + } + + #[test] + fn test_is_valid_fqdn() { + assert!(is_valid_fqdn("example.com")); + assert!(is_valid_fqdn("host.example.com.")); + assert!(!is_valid_fqdn("example")); + assert!(!is_valid_fqdn("")); + assert!(!is_valid_fqdn("-bad.example.com")); + } + + #[test] + fn test_dns_ref_helpers() { + assert_eq!(DnsRef::from_opt(None), DnsRef::Implicit); + assert_eq!(DnsRef::from_opt(Some("z".into())), DnsRef::Id("z".into())); + assert_eq!(DnsRef::Id("z".into()).as_id(), Some("z")); + assert_eq!(DnsRef::Implicit.as_id(), None); + } + + #[test] + fn test_stored_ref_implicit_uses_ip() { + let mut rec = BasicRecord::forward(&v4_assignment(), DnsRef::Implicit).unwrap(); + rec.id = Some(DnsRef::Implicit); + assert_eq!(rec.stored_ref().as_deref(), Some("10.0.0.5")); + rec.id = Some(DnsRef::Id("cf-123".into())); + assert_eq!(rec.stored_ref().as_deref(), Some("cf-123")); + rec.id = None; + assert_eq!(rec.stored_ref(), None); + } + + #[test] + fn test_forward_record_sets_ip_and_zone() -> anyhow::Result<()> { + let ip = v4_assignment(); + let rec = BasicRecord::forward(&ip, DnsRef::Id("zone-1".to_string()))?; + assert_eq!(rec.ip, "10.0.0.5"); + assert_eq!(rec.zone, DnsRef::Id("zone-1".to_string())); + assert!(matches!(rec.kind, RecordType::A)); + assert_eq!(rec.name, "vm-42"); + Ok(()) + } + + #[test] + fn test_reverse_records() -> anyhow::Result<()> { + let ip = v4_assignment(); + let rev = BasicRecord::reverse(&ip, DnsRef::Id("rev-zone".to_string()))?; + assert_eq!(rev.ip, "10.0.0.5"); + assert_eq!(rev.name, "5"); // last octet + assert_eq!(rev.value, "host.example.com"); + assert!(matches!(rev.kind, RecordType::PTR)); + + // reverse_to_fwd falls back to the forward name when reverse is unset + let mut ip2 = v4_assignment(); + ip2.dns_reverse = None; + ip2.dns_forward = Some("fwd.example.com".to_string()); + let rev2 = BasicRecord::reverse_to_fwd(&ip2, DnsRef::Implicit)?; + assert_eq!(rev2.value, "fwd.example.com"); + assert_eq!(rev2.zone, DnsRef::Implicit); + Ok(()) + } + + #[tokio::test] + async fn test_get_dns_server_mock() -> anyhow::Result<()> { + // MockDb::default() seeds a MockDns dns_server with id=1 + let db: Arc = Arc::new(MockDb::default()); + let dns = get_dns_server(&db, 1).await?; + + let rec = BasicRecord::forward(&v4_assignment(), DnsRef::Id("zone-x".to_string()))?; + let added = dns.add_record(&rec).await?; + assert!(added.id.is_some()); + dns.delete_record(&added).await?; + Ok(()) + } +} diff --git a/lnvps_api/src/dns/ovh.rs b/lnvps_api/src/dns/ovh.rs new file mode 100644 index 00000000..20b2bc37 --- /dev/null +++ b/lnvps_api/src/dns/ovh.rs @@ -0,0 +1,127 @@ +//! OVH reverse DNS provider. +//! +//! OVH exposes reverse DNS (PTR) management per-IP with no zones and no record +//! ids: . +//! Only reverse records are supported; forward (A/AAAA) records must be managed +//! by another provider (e.g. Cloudflare). + +use crate::dns::{BasicRecord, DnsRef, DnsServer, RecordType}; +use crate::json_api::JsonApi; +use crate::ovh::ovh_json_api; +use async_trait::async_trait; +use lnvps_api_common::op_fatal; +use lnvps_api_common::retry::OpResult; +use log::info; +use serde::{Deserialize, Serialize}; + +pub struct OvhDns { + api: JsonApi, +} + +impl OvhDns { + pub async fn new(url: &str, token: &str) -> OpResult { + Ok(Self { + api: ovh_json_api(url, token).await?, + }) + } + + /// Ensure the reverse target is a fully-qualified name with a trailing dot, + /// as required by the OVH API. + fn fqdn_with_dot(value: &str) -> String { + if value.ends_with('.') { + value.to_string() + } else { + format!("{value}.") + } + } + + /// Set (create or overwrite) the reverse record for an IP. + async fn set_reverse(&self, record: &BasicRecord) -> OpResult { + if !matches!(record.kind, RecordType::PTR) { + op_fatal!("OVH DNS only supports reverse (PTR) records"); + } + let reverse = Self::fqdn_with_dot(&record.value); + info!("[OVH] Setting reverse: {} => {}", record.ip, reverse); + + let _: OvhReverse = self + .api + .post( + &format!("v1/ip/{}/reverse", record.ip), + OvhReverseRequest { + ip_reverse: record.ip.clone(), + reverse: reverse.clone(), + }, + ) + .await?; + + Ok(BasicRecord { + name: record.ip.clone(), + value: reverse, + // OVH keys reverse records on the IP itself — there is no record id. + id: Some(DnsRef::Implicit), + kind: RecordType::PTR, + ip: record.ip.clone(), + zone: DnsRef::Implicit, + }) + } +} + +#[async_trait] +impl DnsServer for OvhDns { + async fn add_record(&self, record: &BasicRecord) -> OpResult { + self.set_reverse(record).await + } + + async fn update_record(&self, record: &BasicRecord) -> OpResult { + // OVH reverse is idempotent — a POST overwrites any existing entry. + self.set_reverse(record).await + } + + async fn delete_record(&self, record: &BasicRecord) -> OpResult<()> { + if !matches!(record.kind, RecordType::PTR) { + op_fatal!("OVH DNS only supports reverse (PTR) records"); + } + info!("[OVH] Deleting reverse: {}", record.ip); + self.api + .req::<(), ()>( + reqwest::Method::DELETE, + &format!("v1/ip/{}/reverse/{}", record.ip, record.ip), + None, + ) + .await?; + Ok(()) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct OvhReverseRequest { + ip_reverse: String, + reverse: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OvhReverse { + #[allow(dead_code)] + ip_reverse: String, + #[allow(dead_code)] + reverse: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fqdn_with_dot() { + assert_eq!( + OvhDns::fqdn_with_dot("host.example.com"), + "host.example.com." + ); + assert_eq!( + OvhDns::fqdn_with_dot("host.example.com."), + "host.example.com." + ); + } +} diff --git a/lnvps_api/src/lib.rs b/lnvps_api/src/lib.rs index 0df0e3ff..ee78824c 100644 --- a/lnvps_api/src/lib.rs +++ b/lnvps_api/src/lib.rs @@ -4,6 +4,7 @@ pub mod dns; pub mod host; pub mod json_api; pub mod notifications; +pub mod ovh; pub mod payment_factory; pub mod payments; pub mod provisioner; diff --git a/lnvps_api/src/mocks.rs b/lnvps_api/src/mocks.rs index d5f6eace..3d467763 100644 --- a/lnvps_api/src/mocks.rs +++ b/lnvps_api/src/mocks.rs @@ -1,5 +1,5 @@ #![allow(unused)] -use crate::dns::{BasicRecord, DnsServer, RecordType}; +use crate::dns::{BasicRecord, DnsRef, DnsServer, RecordType}; use crate::host::dummy_host::DummyVmHost; use crate::host::{ FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostInfo, @@ -432,13 +432,18 @@ impl MockDnsServer { #[async_trait] impl DnsServer for MockDnsServer { - async fn add_record(&self, zone_id: &str, record: &BasicRecord) -> OpResult { + async fn add_record(&self, record: &BasicRecord) -> OpResult { + let zone_id = record + .zone + .as_id() + .map(|s| s.to_string()) + .unwrap_or_else(|| record.ip.clone()); let mut zones = self.zones.lock().await; - let table = if let Some(t) = zones.get_mut(zone_id) { + let table = if let Some(t) = zones.get_mut(&zone_id) { t } else { - zones.insert(zone_id.to_string(), HashMap::new()); - zones.get_mut(zone_id).unwrap() + zones.insert(zone_id.clone(), HashMap::new()); + zones.get_mut(&zone_id).unwrap() }; if table @@ -467,38 +472,54 @@ impl DnsServer for MockDnsServer { _ => format!("{}.lnvps.mock", record.name), }, value: record.value.clone(), - id: Some(id), + id: Some(DnsRef::Id(id)), kind: record.kind.clone(), + ip: record.ip.clone(), + zone: record.zone.clone(), }) } - async fn delete_record(&self, zone_id: &str, record: &BasicRecord) -> OpResult<()> { + async fn delete_record(&self, record: &BasicRecord) -> OpResult<()> { + let zone_id = record + .zone + .as_id() + .map(|s| s.to_string()) + .unwrap_or_else(|| record.ip.clone()); let mut zones = self.zones.lock().await; - let table = if let Some(t) = zones.get_mut(zone_id) { + let table = if let Some(t) = zones.get_mut(&zone_id) { t } else { - zones.insert(zone_id.to_string(), HashMap::new()); - zones.get_mut(zone_id).unwrap() + zones.insert(zone_id.clone(), HashMap::new()); + zones.get_mut(&zone_id).unwrap() }; - if record.id.is_none() { - return Err(OpError::Fatal(anyhow::anyhow!("Id is missing"))); - } - table.remove(record.id.as_ref().unwrap()); + let record_id = record + .id + .as_ref() + .and_then(DnsRef::as_id) + .ok_or_else(|| OpError::Fatal(anyhow::anyhow!("Id is missing")))?; + table.remove(record_id); Ok(()) } - async fn update_record(&self, zone_id: &str, record: &BasicRecord) -> OpResult { + async fn update_record(&self, record: &BasicRecord) -> OpResult { + let zone_id = record + .zone + .as_id() + .map(|s| s.to_string()) + .unwrap_or_else(|| record.ip.clone()); let mut zones = self.zones.lock().await; - let table = if let Some(t) = zones.get_mut(zone_id) { + let table = if let Some(t) = zones.get_mut(&zone_id) { t } else { - zones.insert(zone_id.to_string(), HashMap::new()); - zones.get_mut(zone_id).unwrap() + zones.insert(zone_id.clone(), HashMap::new()); + zones.get_mut(&zone_id).unwrap() }; - if record.id.is_none() { - return Err(OpError::Fatal(anyhow::anyhow!("Id is missing"))); - } - if let Some(mut r) = table.get_mut(record.id.as_ref().unwrap()) { + let record_id = record + .id + .as_ref() + .and_then(DnsRef::as_id) + .ok_or_else(|| OpError::Fatal(anyhow::anyhow!("Id is missing")))?; + if let Some(mut r) = table.get_mut(record_id) { r.name = record.name.clone(); r.value = record.value.clone(); r.kind = record.kind.to_string(); diff --git a/lnvps_api/src/ovh/mod.rs b/lnvps_api/src/ovh/mod.rs new file mode 100644 index 00000000..ab7c7505 --- /dev/null +++ b/lnvps_api/src/ovh/mod.rs @@ -0,0 +1,127 @@ +//! Shared OVH API client helpers (authentication token generation). +//! +//! OVH uses a signed-request scheme: every request carries `X-Ovh-Application`, +//! `X-Ovh-Consumer`, `X-Ovh-Timestamp` and an `X-Ovh-Signature` computed as +//! `$1$` + SHA1(`app_secret+consumer_key+METHOD+URL+BODY+TIMESTAMP`). A clock +//! delta against OVH's `/auth/time` endpoint is applied so signatures stay valid. +//! +//! Both the additional-IP "router" ([`crate::router::OvhDedicatedServerVMacRouter`]) +//! and the reverse-DNS provider ([`crate::dns::OvhDns`]) share this code. + +use crate::json_api::{JsonApi, TokenGen}; +use anyhow::{Context, Result}; +use chrono::Utc; +use lnvps_api_common::retry::{OpError, OpResult}; +use nostr_sdk::hashes::{Hash, sha1}; +use reqwest::{Method, RequestBuilder, Url}; +use std::ops::Sub; + +/// Generates signed OVH API request headers from an `app_key:app_secret:consumer_key` token. +#[derive(Clone)] +pub struct OvhTokenGen { + time_delta: i64, + application_key: String, + application_secret: String, + consumer_key: String, +} + +impl OvhTokenGen { + /// Parse an OVH credential token of the form `application_key:application_secret:consumer_key`. + pub fn new(time_delta: i64, token: &str) -> Result { + 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); + + 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 { + 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)) + } +} + +/// Build a [`JsonApi`] authenticated with OVH signed requests, bootstrapping the +/// clock delta from OVH's `/auth/time` endpoint. +pub async fn ovh_json_api(url: &str, token: &str) -> OpResult { + let time_api = JsonApi::new(url).map_err(OpError::Fatal)?; + let time = time_api.get::("v1/auth/time").await?; + let delta: i64 = Utc::now().timestamp().sub(time); + + JsonApi::token_gen( + url, + false, + OvhTokenGen::new(delta, token).map_err(OpError::Fatal)?, + ) + .map_err(OpError::Fatal) +} diff --git a/lnvps_api/src/provisioner/retry_tests.rs b/lnvps_api/src/provisioner/retry_tests.rs index ae6900d1..3102164d 100644 --- a/lnvps_api/src/provisioner/retry_tests.rs +++ b/lnvps_api/src/provisioner/retry_tests.rs @@ -5,7 +5,7 @@ #[cfg(test)] mod tests { - use crate::dns::{BasicRecord, DnsServer, RecordType}; + use crate::dns::{BasicRecord, DnsRef, DnsServer, RecordType}; use crate::mocks::{MockDnsServer, MockNode, MockRouter}; use crate::router::{ArpEntry, Router}; use crate::settings::mock_settings; @@ -53,7 +53,7 @@ mod tests { #[async_trait] impl DnsServer for FailingDnsServer { - async fn add_record(&self, zone: &str, record: &BasicRecord) -> OpResult { + async fn add_record(&self, record: &BasicRecord) -> OpResult { let fails = self.add_record_fail_count.load(Ordering::SeqCst); if fails > 0 { self.add_record_fail_count.fetch_sub(1, Ordering::SeqCst); @@ -62,10 +62,10 @@ mod tests { fails - 1 ))); } - self.inner.add_record(zone, record).await + self.inner.add_record(record).await } - async fn update_record(&self, zone: &str, record: &BasicRecord) -> OpResult { + async fn update_record(&self, record: &BasicRecord) -> OpResult { let fails = self.update_record_fail_count.load(Ordering::SeqCst); if fails > 0 { self.update_record_fail_count.fetch_sub(1, Ordering::SeqCst); @@ -74,10 +74,10 @@ mod tests { fails - 1 ))); } - self.inner.update_record(zone, record).await + self.inner.update_record(record).await } - async fn delete_record(&self, zone: &str, record: &BasicRecord) -> OpResult<()> { + async fn delete_record(&self, record: &BasicRecord) -> OpResult<()> { let fails = self.delete_record_fail_count.load(Ordering::SeqCst); if fails > 0 { self.delete_record_fail_count.fetch_sub(1, Ordering::SeqCst); @@ -86,7 +86,7 @@ mod tests { fails - 1 ))); } - self.inner.delete_record(zone, record).await + self.inner.delete_record(record).await } } @@ -226,45 +226,42 @@ mod tests { // First attempt should fail let result = failing_dns - .add_record( - "test-zone", - &BasicRecord { - id: None, - name: "test1.example.com".to_string(), - value: "10.0.0.100".to_string(), - kind: RecordType::A, - }, - ) + .add_record(&BasicRecord { + id: None, + name: "test1.example.com".to_string(), + value: "10.0.0.100".to_string(), + kind: RecordType::A, + ip: "10.0.0.100".to_string(), + zone: DnsRef::Id("test-zone".to_string()), + }) .await; assert!(result.is_err(), "First attempt should fail"); assert_eq!(failing_dns.add_failures_remaining(), 1); // Second attempt should fail let result = failing_dns - .add_record( - "test-zone", - &BasicRecord { - id: None, - name: "test2.example.com".to_string(), - value: "10.0.0.101".to_string(), - kind: RecordType::A, - }, - ) + .add_record(&BasicRecord { + id: None, + name: "test2.example.com".to_string(), + value: "10.0.0.101".to_string(), + kind: RecordType::A, + ip: "10.0.0.101".to_string(), + zone: DnsRef::Id("test-zone".to_string()), + }) .await; assert!(result.is_err(), "Second attempt should fail"); assert_eq!(failing_dns.add_failures_remaining(), 0); // Third attempt should succeed (different record name to avoid duplicates) let result = failing_dns - .add_record( - "test-zone", - &BasicRecord { - id: None, - name: "test3.example.com".to_string(), - value: "10.0.0.102".to_string(), - kind: RecordType::A, - }, - ) + .add_record(&BasicRecord { + id: None, + name: "test3.example.com".to_string(), + value: "10.0.0.102".to_string(), + kind: RecordType::A, + ip: "10.0.0.102".to_string(), + zone: DnsRef::Id("test-zone".to_string()), + }) .await; assert!(result.is_ok(), "Third attempt should succeed"); assert_eq!(failing_dns.add_failures_remaining(), 0); @@ -282,15 +279,14 @@ mod tests { // All 3 retry attempts should fail for i in 0..3 { let result = failing_dns - .add_record( - "test-zone", - &BasicRecord { - id: None, - name: "test.example.com".to_string(), - value: "10.0.0.100".to_string(), - kind: RecordType::A, - }, - ) + .add_record(&BasicRecord { + id: None, + name: "test.example.com".to_string(), + value: "10.0.0.100".to_string(), + kind: RecordType::A, + ip: "10.0.0.100".to_string(), + zone: DnsRef::Id("test-zone".to_string()), + }) .await; assert!(result.is_err(), "Attempt {} should fail", i + 1); assert_eq!(failing_dns.add_failures_remaining(), 5 - i - 1); @@ -384,22 +380,21 @@ mod tests { // Add a record first (use unique name) let record = failing_dns - .add_record( - "test-zone", - &BasicRecord { - id: None, - name: "test-delete.example.com".to_string(), - value: "10.0.0.100".to_string(), - kind: RecordType::A, - }, - ) + .add_record(&BasicRecord { + id: None, + name: "test-delete.example.com".to_string(), + value: "10.0.0.100".to_string(), + kind: RecordType::A, + ip: "10.0.0.100".to_string(), + zone: DnsRef::Id("test-zone".to_string()), + }) .await?; assert_eq!(failing_dns.delete_failures_remaining(), 10); // Delete will fail 3 times (retry limit) for i in 0..3 { - let result = failing_dns.delete_record("test-zone", &record).await; + let result = failing_dns.delete_record(&record).await; assert!(result.is_err(), "Delete attempt {} should fail", i + 1); } @@ -416,15 +411,14 @@ mod tests { // Add a record first (use unique name to avoid conflicts with other tests) let record = failing_dns - .add_record( - "test-zone", - &BasicRecord { - id: None, - name: "test-update.example.com".to_string(), - value: "10.0.0.100".to_string(), - kind: RecordType::A, - }, - ) + .add_record(&BasicRecord { + id: None, + name: "test-update.example.com".to_string(), + value: "10.0.0.100".to_string(), + kind: RecordType::A, + ip: "10.0.0.100".to_string(), + zone: DnsRef::Id("test-zone".to_string()), + }) .await?; assert_eq!(failing_dns.update_failures_remaining(), 1); @@ -432,16 +426,12 @@ mod tests { // First update should fail let mut updated_record = record.clone(); updated_record.value = "10.0.0.101".to_string(); - let result = failing_dns - .update_record("test-zone", &updated_record) - .await; + let result = failing_dns.update_record(&updated_record).await; assert!(result.is_err()); assert_eq!(failing_dns.update_failures_remaining(), 0); // Second update should succeed - let result = failing_dns - .update_record("test-zone", &updated_record) - .await; + let result = failing_dns.update_record(&updated_record).await; assert!(result.is_ok()); Ok(()) diff --git a/lnvps_api/src/provisioner/rollback_tests.rs b/lnvps_api/src/provisioner/rollback_tests.rs index a094e712..ab7fac39 100644 --- a/lnvps_api/src/provisioner/rollback_tests.rs +++ b/lnvps_api/src/provisioner/rollback_tests.rs @@ -54,13 +54,33 @@ mod tests { ); drop(p); + let mut d = db.dns_servers.lock().await; + d.insert( + 1, + lnvps_db::DnsServer { + id: 1, + name: "mock-dns".to_string(), + enabled: true, + kind: lnvps_db::DnsServerKind::MockDns, + url: "https://localhost".to_string(), + token: "mock-token".into(), + }, + ); + drop(d); + let mut i = db.ip_range.lock().await; if let Some(range) = i.get_mut(&1) { range.access_policy_id = Some(1); range.reverse_zone_id = Some("mock-rev-zone-id".to_string()); + range.forward_dns_server_id = Some(1); + range.reverse_dns_server_id = Some(1); + range.forward_zone_id = Some("mock-forward-zone-id".to_string()); } if let Some(range) = i.get_mut(&2) { range.reverse_zone_id = Some("mock-v6-rev-zone-id".to_string()); + range.forward_dns_server_id = Some(1); + range.reverse_dns_server_id = Some(1); + range.forward_zone_id = Some("mock-forward-zone-id".to_string()); } drop(i); @@ -91,7 +111,7 @@ mod tests { let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); let rates = Arc::new(MockExchangeRate::new()); - let dns = Arc::new(MockDnsServer::new()); + let _dns = Arc::new(MockDnsServer::new()); const MOCK_RATE: f32 = 69_420.0; rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; @@ -149,7 +169,7 @@ mod tests { let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); let rates = Arc::new(MockExchangeRate::new()); - let dns = Arc::new(MockDnsServer::new()); + let _dns = Arc::new(MockDnsServer::new()); const MOCK_RATE: f32 = 69_420.0; rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; @@ -302,7 +322,7 @@ mod tests { let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); let rates = Arc::new(MockExchangeRate::new()); - let dns = Arc::new(MockDnsServer::new()); + let _dns = Arc::new(MockDnsServer::new()); const MOCK_RATE: f32 = 69_420.0; rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; @@ -401,7 +421,7 @@ mod tests { let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); let rates = Arc::new(MockExchangeRate::new()); - let dns = Arc::new(MockDnsServer::new()); + let _dns = Arc::new(MockDnsServer::new()); const MOCK_RATE: f32 = 69_420.0; rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; @@ -449,7 +469,7 @@ mod tests { let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); let rates = Arc::new(MockExchangeRate::new()); - let dns = Arc::new(MockDnsServer::new()); + let _dns = Arc::new(MockDnsServer::new()); const MOCK_RATE: f32 = 69_420.0; rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; @@ -524,16 +544,12 @@ mod tests { use try_procedure::RetryPolicy; let db = Arc::new(MockDb::default()); - let dns = Arc::new(MockDnsServer::new()); + let _dns = Arc::new(MockDnsServer::new()); setup_db_with_static_arp(&db).await?; - let network = VmNetworkProvisioner::new( - db.clone(), - Some(dns.clone()), - Some("mock-forward-zone-id".to_string()), - RetryPolicy::default().with_max_retries(0), - ); + let network = + VmNetworkProvisioner::new(db.clone(), RetryPolicy::default().with_max_retries(0)); // Create a VM let (user, ssh_key) = add_user(&db).await?; @@ -627,7 +643,7 @@ mod tests { let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); let rates = Arc::new(MockExchangeRate::new()); - let dns = Arc::new(MockDnsServer::new()); + let _dns = Arc::new(MockDnsServer::new()); const MOCK_RATE: f32 = 69_420.0; rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; @@ -676,7 +692,7 @@ mod tests { let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); let rates = Arc::new(MockExchangeRate::new()); - let dns = Arc::new(MockDnsServer::new()); + let _dns = Arc::new(MockDnsServer::new()); const MOCK_RATE: f32 = 69_420.0; rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; diff --git a/lnvps_api/src/provisioner/vm.rs b/lnvps_api/src/provisioner/vm.rs index a2da899a..49827fda 100644 --- a/lnvps_api/src/provisioner/vm.rs +++ b/lnvps_api/src/provisioner/vm.rs @@ -46,12 +46,7 @@ impl VmProvisioner { pub fn new(settings: Settings, db: Arc) -> Self { Self { - network: VmNetworkProvisioner::new( - db.clone(), - settings.get_dns(), - settings.dns.as_ref().map(|z| z.forward_zone_id.to_string()), - Self::retry_policy(), - ), + network: VmNetworkProvisioner::new(db.clone(), Self::retry_policy()), provisioner_config: settings.provisioner, read_only: settings.read_only, delete_after: settings.delete_after, @@ -893,8 +888,10 @@ mod tests { let r = i.get_mut(&1).unwrap(); r.access_policy_id = Some(1); r.reverse_zone_id = Some("mock-rev-zone-id".to_string()); + r.reverse_dns_server_id = Some(1); let r = i.get_mut(&2).unwrap(); r.reverse_zone_id = Some("mock-v6-rev-zone-id".to_string()); + r.reverse_dns_server_id = Some(1); } let wrk: Arc = Arc::new(ChannelWorkCommander::new()); diff --git a/lnvps_api/src/provisioner/vm_network.rs b/lnvps_api/src/provisioner/vm_network.rs index 2b19ae5a..ce2257ef 100644 --- a/lnvps_api/src/provisioner/vm_network.rs +++ b/lnvps_api/src/provisioner/vm_network.rs @@ -1,4 +1,4 @@ -use crate::dns::{BasicRecord, DnsServer}; +use crate::dns::{BasicRecord, DnsRef, get_dns_server}; use crate::router::{ArpEntry, get_router}; use anyhow::{Context, anyhow}; use ipnetwork::IpNetwork; @@ -15,27 +15,13 @@ use try_procedure::{OpError, RetryPolicy, retry_async}; #[derive(Clone)] pub struct VmNetworkProvisioner { db: Arc, - /// DNS server to add entries to - dns: Option>, - /// Forward zone to add ip dns - forward_zone_id: Option, /// Retry policy to use when calling external services retry_policy: RetryPolicy, } impl VmNetworkProvisioner { - pub fn new( - db: Arc, - dns: Option>, - forward_zone_id: Option, - retry_policy: RetryPolicy, - ) -> Self { - Self { - db, - dns, - forward_zone_id, - retry_policy, - } + pub fn new(db: Arc, retry_policy: RetryPolicy) -> Self { + Self { db, retry_policy } } /// Create or Update access policy for a given ip assignment, does not save to database! @@ -117,83 +103,96 @@ impl VmNetworkProvisioner { /// Delete DNS on the dns server, does not save to database! pub async fn remove_ip_dns(&self, assignment: &mut VmIpAssignment) -> OpResult<()> { - // Delete forward/reverse dns - if let Some(dns) = &self.dns { - let range = self.db.get_ip_range(assignment.ip_range_id).await?; + let range = self.db.get_ip_range(assignment.ip_range_id).await?; - if let (Some(z), Some(_ref)) = (&range.reverse_zone_id, &assignment.dns_reverse_ref) { - let rev = BasicRecord::reverse(assignment)?; + // Delete reverse dns + if let (Some(dns_id), Some(_ref)) = + (range.reverse_dns_server_id, &assignment.dns_reverse_ref) + { + let dns = get_dns_server(&self.db, dns_id).await?; + let rev = + BasicRecord::reverse(assignment, DnsRef::from_opt(range.reverse_zone_id.clone()))?; - if let Err(e) = retry_async(self.retry_policy.clone(), || async { - dns.delete_record(z, &rev).await - }) - .await - { - warn!("Failed to delete reverse record after retries: {}", e); - } - assignment.dns_reverse_ref = None; - assignment.dns_reverse = None; + if let Err(e) = retry_async(self.retry_policy.clone(), || async { + dns.delete_record(&rev).await + }) + .await + { + warn!("Failed to delete reverse record after retries: {}", e); } - if let (Some(z), Some(_ref)) = (&self.forward_zone_id, &assignment.dns_forward_ref) { - let fwd = BasicRecord::forward(assignment)?; + assignment.dns_reverse_ref = None; + assignment.dns_reverse = None; + } - if let Err(e) = retry_async(self.retry_policy.clone(), || async { - dns.delete_record(z, &fwd).await - }) - .await - { - warn!("Failed to delete forward record after retries: {}", e); - } - assignment.dns_forward_ref = None; - assignment.dns_forward = None; + // Delete forward dns + if let (Some(dns_id), Some(_ref)) = + (range.forward_dns_server_id, &assignment.dns_forward_ref) + { + let dns = get_dns_server(&self.db, dns_id).await?; + let fwd = + BasicRecord::forward(assignment, DnsRef::from_opt(range.forward_zone_id.clone()))?; + + if let Err(e) = retry_async(self.retry_policy.clone(), || async { + dns.delete_record(&fwd).await + }) + .await + { + warn!("Failed to delete forward record after retries: {}", e); } + assignment.dns_forward_ref = None; + assignment.dns_forward = None; } Ok(()) } /// Update DNS on the dns server, does not save to database! pub async fn update_forward_ip_dns(&self, assignment: &mut VmIpAssignment) -> OpResult<()> { - if let (Some(z), Some(dns)) = (&self.forward_zone_id, &self.dns) { - let fwd = BasicRecord::forward(assignment)?; + let range = self.db.get_ip_range(assignment.ip_range_id).await?; + if let Some(dns_id) = range.forward_dns_server_id { + let dns = get_dns_server(&self.db, dns_id).await?; + let fwd = + BasicRecord::forward(assignment, DnsRef::from_opt(range.forward_zone_id.clone()))?; let ret_fwd = retry_async(self.retry_policy.clone(), || async { if fwd.id.is_some() { - dns.update_record(z, &fwd).await + dns.update_record(&fwd).await } else { - dns.add_record(z, &fwd).await + dns.add_record(&fwd).await } }) .await?; - assignment.dns_forward = Some(ret_fwd.name); - assignment.dns_forward_ref = Some(ret_fwd.id.context("Record id is missing")?); + assignment.dns_forward = Some(ret_fwd.name.clone()); + assignment.dns_forward_ref = + Some(ret_fwd.stored_ref().context("Record id is missing")?); } Ok(()) } /// Update DNS on the dns server, does not save to database! pub async fn update_reverse_ip_dns(&self, assignment: &mut VmIpAssignment) -> OpResult<()> { - if let Some(dns) = &self.dns { - let range = self.db.get_ip_range(assignment.ip_range_id).await?; - if let Some(z) = &range.reverse_zone_id { - let has_ref = assignment.dns_reverse_ref.is_some(); - let rev_record = if has_ref { - BasicRecord::reverse(assignment)? + let range = self.db.get_ip_range(assignment.ip_range_id).await?; + if let Some(dns_id) = range.reverse_dns_server_id { + let dns = get_dns_server(&self.db, dns_id).await?; + let has_ref = assignment.dns_reverse_ref.is_some(); + let zone = DnsRef::from_opt(range.reverse_zone_id.clone()); + let rev_record = if has_ref { + BasicRecord::reverse(assignment, zone)? + } else { + BasicRecord::reverse_to_fwd(assignment, zone)? + }; + + let ret_rev = retry_async(self.retry_policy.clone(), || async { + if has_ref { + dns.update_record(&rev_record).await } else { - BasicRecord::reverse_to_fwd(assignment)? - }; - - let ret_rev = retry_async(self.retry_policy.clone(), || async { - if has_ref { - dns.update_record(z, &rev_record).await - } else { - dns.add_record(z, &rev_record).await - } - }) - .await?; + dns.add_record(&rev_record).await + } + }) + .await?; - assignment.dns_reverse = Some(ret_rev.value); - assignment.dns_reverse_ref = Some(ret_rev.id.context("Record id is missing")?); - } + assignment.dns_reverse = Some(ret_rev.value.clone()); + assignment.dns_reverse_ref = + Some(ret_rev.stored_ref().context("Record id is missing")?); } Ok(()) } diff --git a/lnvps_api/src/router/ovh.rs b/lnvps_api/src/router/ovh.rs index 1d10d347..bf1499ef 100644 --- a/lnvps_api/src/router/ovh.rs +++ b/lnvps_api/src/router/ovh.rs @@ -1,15 +1,14 @@ -use crate::json_api::{JsonApi, TokenGen}; +use crate::json_api::JsonApi; +use crate::ovh::ovh_json_api; use crate::router::{ArpEntry, Router}; -use anyhow::{Context, Result}; +use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; use lnvps_api_common::op_transient; use lnvps_api_common::retry::{OpError, OpResult}; use log::{info, warn}; -use nostr_sdk::hashes::{Hash, sha1}; -use reqwest::{Method, RequestBuilder, Url}; +use reqwest::Method; use serde::{Deserialize, Serialize}; -use std::ops::Sub; /// This router is not really a router, but it allows /// managing the virtual mac's for additional IPs on OVH dedicated servers @@ -18,115 +17,11 @@ pub struct OvhDedicatedServerVMacRouter { 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 { - 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 { - 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) -> OpResult { - // load API time delta - let time_api = JsonApi::new(url).map_err(OpError::Fatal)?; - let time = time_api.get::("v1/auth/time").await?; - let delta: i64 = Utc::now().timestamp().sub(time); - Ok(Self { name: name.to_string(), - api: JsonApi::token_gen( - url, - false, - OvhTokenGen::new(delta, token).map_err(OpError::Fatal)?, - ) - .map_err(OpError::Fatal)?, + api: ovh_json_api(url, token).await?, }) } diff --git a/lnvps_api/src/settings.rs b/lnvps_api/src/settings.rs index b7bf2149..03e54dd6 100644 --- a/lnvps_api/src/settings.rs +++ b/lnvps_api/src/settings.rs @@ -1,4 +1,3 @@ -use crate::dns::DnsServer; use anyhow::Result; use isocountry::CountryCode; use lnvps_api_common::RedisConfig; @@ -36,7 +35,9 @@ pub struct Settings { /// SMTP settings for sending emails pub smtp: Option, - /// DNS configurations for PTR records + /// Legacy DNS configuration. Runtime DNS config now lives in the `dns_server` + /// DB table (referenced per IP range). This field is only consumed once, by + /// `DnsDataMigration`, to bootstrap those DB rows for existing deployments. pub dns: Option, /// Nostr config for sending DMs @@ -133,6 +134,7 @@ pub struct WhatsAppConfig { pub verify_template_lang: String, } +/// Legacy DNS configuration, migrated into the `dns_server` DB table on startup. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct DnsServerConfig { @@ -150,6 +152,19 @@ pub enum DnsServerApi { Mock, } +impl DnsServerConfig { + /// Map the legacy config to a database `dns_server` row kind + credential token. + pub fn to_db_kind_token(&self) -> (lnvps_db::DnsServerKind, String) { + match &self.api { + DnsServerApi::Cloudflare { token } => { + (lnvps_db::DnsServerKind::Cloudflare, token.clone()) + } + #[cfg(test)] + DnsServerApi::Mock => (lnvps_db::DnsServerKind::MockDns, "mock-token".to_string()), + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SmtpConfig { /// Admin user id, for sending system notifications @@ -253,20 +268,6 @@ pub struct EncryptionConfig { } impl Settings { - pub fn get_dns(&self) -> Option> { - match &self.dns { - None => None, - Some(c) => match &c.api { - #[cfg(feature = "cloudflare")] - DnsServerApi::Cloudflare { token } => { - Some(Arc::new(crate::dns::Cloudflare::new(token))) - } - #[cfg(test)] - DnsServerApi::Mock => Some(Arc::new(crate::mocks::MockDnsServer::new())), - }, - } - } - pub fn get_revolut(&self) -> Result>> { match &self.revolut { #[cfg(feature = "revolut")] @@ -346,3 +347,29 @@ pub fn mock_settings() -> Settings { captcha: None, } } + +#[cfg(test)] +mod tests { + use super::*; + use lnvps_db::DnsServerKind; + + #[test] + fn test_dns_config_to_db_kind_token() { + let cfg = DnsServerConfig { + forward_zone_id: "z".to_string(), + api: DnsServerApi::Cloudflare { + token: "cf-token".to_string(), + }, + }; + let (kind, token) = cfg.to_db_kind_token(); + assert!(matches!(kind, DnsServerKind::Cloudflare)); + assert_eq!(token, "cf-token"); + + let mock = DnsServerConfig { + forward_zone_id: "z".to_string(), + api: DnsServerApi::Mock, + }; + let (kind, _) = mock.to_db_kind_token(); + assert!(matches!(kind, DnsServerKind::MockDns)); + } +} diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index b33f04a7..3d05ceda 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -1,7 +1,5 @@ use crate::host::{FullVmInfo, get_host_client}; -use crate::notifications::{ - Notification, NotificationChannel, build_channels, send_email, -}; +use crate::notifications::{Notification, NotificationChannel, build_channels, send_email}; use crate::provisioner::VmProvisioner; use crate::settings::{ProvisionerConfig, Settings, SmtpConfig, TelegramConfig, WhatsAppConfig}; use crate::ssh_client::SshClient; @@ -380,7 +378,6 @@ impl Worker { Ok(()) } - } /// Grace period (days) for a subscription, tiered by how long the subscription @@ -410,7 +407,6 @@ pub fn grace_period_days_for_sub(sub: &Subscription, now: DateTime, delete_ } impl Worker { - /// Grace period (in days) for a subscription, tiered by subscription age. /// Newer subscriptions get shorter grace windows so resources aren't held open /// for days after a brand-new VM expires. @@ -2315,10 +2311,50 @@ impl Worker { WorkJob::DownloadOsImages { image_id } => { self.download_os_images(*image_id).await?; } + WorkJob::PatchIpRangeDns { + ip_range_id, + admin_user_id: _, + } => { + let n = self.patch_ip_range_dns(*ip_range_id).await?; + return Ok(Some(format!( + "Patched DNS for {} IP assignment(s) in range {}", + n, ip_range_id + ))); + } } Ok(None) } + /// Re-apply forward + reverse DNS records for every (non-deleted) IP assignment + /// in a range, reconciling them to the range's current DNS server configuration. + /// Per-assignment DNS failures are logged and skipped so one bad record can't + /// abort the whole batch; only the DB save is treated as fatal. + async fn patch_ip_range_dns(&self, ip_range_id: u64) -> Result { + // Validate the range exists + let _range = self.db.get_ip_range(ip_range_id).await?; + let provisioner = self.subscription_handler.vm_provisioner(); + let network = &provisioner.network; + + let mut assignments = self.db.list_vm_ip_assignments_in_range(ip_range_id).await?; + info!( + "Patching DNS for {} IP assignment(s) in range {}", + assignments.len(), + ip_range_id + ); + let mut count = 0usize; + for a in &mut assignments { + if let Err(e) = network.update_forward_ip_dns(a).await { + warn!("[patch-dns] forward failed for {}: {}", a.ip, e); + } + if let Err(e) = network.update_reverse_ip_dns(a).await { + warn!("[patch-dns] reverse failed for {}: {}", a.ip, e); + } + self.db.update_vm_ip_assignment(a).await?; + count += 1; + } + Ok(count) + } + async fn download_os_images(&self, image_id: Option) -> Result<()> { let images = if let Some(id) = image_id { vec![self.db.get_os_image(id).await?] diff --git a/lnvps_api_admin/src/admin/dns_servers.rs b/lnvps_api_admin/src/admin/dns_servers.rs new file mode 100644 index 00000000..d744cf09 --- /dev/null +++ b/lnvps_api_admin/src/admin/dns_servers.rs @@ -0,0 +1,127 @@ +use crate::admin::RouterState; +use crate::admin::auth::AdminAuth; +use crate::admin::model::{AdminDnsServerDetail, CreateDnsServerRequest, UpdateDnsServerRequest}; +use axum::extract::{Path, Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use lnvps_api_common::{ApiData, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery}; +use lnvps_db::{AdminAction, AdminResource}; + +pub fn router() -> Router { + Router::new() + .route( + "/api/admin/v1/dns_servers", + get(admin_list_dns_servers).post(admin_create_dns_server), + ) + .route( + "/api/admin/v1/dns_servers/{id}", + get(admin_get_dns_server) + .patch(admin_update_dns_server) + .delete(admin_delete_dns_server), + ) +} + +async fn detail_with_count(this: &RouterState, dns: lnvps_db::DnsServer) -> AdminDnsServerDetail { + let mut detail = AdminDnsServerDetail::from(dns.clone()); + detail.ip_range_count = this + .db + .count_dns_server_ip_ranges(dns.id) + .await + .unwrap_or(0); + detail +} + +async fn admin_list_dns_servers( + auth: AdminAuth, + State(this): State, + Query(params): Query, +) -> ApiPaginatedResult { + auth.require_permission(AdminResource::DnsServer, AdminAction::View)?; + + let limit = params.limit.unwrap_or(50).min(100); + let offset = params.offset.unwrap_or(0); + + let (servers, total) = this.db.list_dns_servers_paginated(limit, offset).await?; + + let mut out = Vec::new(); + for server in servers { + out.push(detail_with_count(&this, server).await); + } + + ApiPaginatedData::ok(out, total, limit, offset) +} + +async fn admin_get_dns_server( + auth: AdminAuth, + State(this): State, + Path(dns_server_id): Path, +) -> ApiResult { + auth.require_permission(AdminResource::DnsServer, AdminAction::View)?; + + let server = this.db.get_dns_server(dns_server_id).await?; + ApiData::ok(detail_with_count(&this, server).await) +} + +async fn admin_create_dns_server( + auth: AdminAuth, + State(this): State, + Json(request): Json, +) -> ApiResult { + auth.require_permission(AdminResource::DnsServer, AdminAction::Create)?; + + if request.name.trim().is_empty() { + return ApiData::err("Name cannot be empty"); + } + if request.token.trim().is_empty() { + return ApiData::err("Token cannot be empty"); + } + + let dns = request.to_dns_server(); + let id = this.db.insert_dns_server(&dns).await?; + let created = this.db.get_dns_server(id).await?; + + ApiData::ok(detail_with_count(&this, created).await) +} + +async fn admin_update_dns_server( + auth: AdminAuth, + State(this): State, + Path(dns_server_id): Path, + Json(request): Json, +) -> ApiResult { + auth.require_permission(AdminResource::DnsServer, AdminAction::Update)?; + + let mut dns = this.db.get_dns_server(dns_server_id).await?; + + if let Some(name) = &request.name { + dns.name = name.trim().to_string(); + } + if let Some(enabled) = request.enabled { + dns.enabled = enabled; + } + if let Some(kind) = &request.kind { + dns.kind = lnvps_db::DnsServerKind::from(*kind); + } + if let Some(url) = &request.url { + dns.url = url.trim().to_string(); + } + if let Some(token) = &request.token { + dns.token = token.as_str().into(); + } + + this.db.update_dns_server(&dns).await?; + let updated = this.db.get_dns_server(dns_server_id).await?; + + ApiData::ok(detail_with_count(&this, updated).await) +} + +async fn admin_delete_dns_server( + auth: AdminAuth, + State(this): State, + Path(dns_server_id): Path, +) -> ApiResult<()> { + auth.require_permission(AdminResource::DnsServer, AdminAction::Delete)?; + + this.db.delete_dns_server(dns_server_id).await?; + ApiData::ok(()) +} diff --git a/lnvps_api_admin/src/admin/ip_ranges.rs b/lnvps_api_admin/src/admin/ip_ranges.rs index b2cd3ccf..34376d4f 100644 --- a/lnvps_api_admin/src/admin/ip_ranges.rs +++ b/lnvps_api_admin/src/admin/ip_ranges.rs @@ -2,13 +2,14 @@ use crate::admin::RouterState; use crate::admin::auth::AdminAuth; use crate::admin::model::{ AdminIpRangeAllocationMode, AdminIpRangeInfo, AdminIpRangeRouter, CreateIpRangeRequest, - UpdateIpRangeRequest, + JobResponse, UpdateIpRangeRequest, }; use axum::extract::{Path, Query, State}; -use axum::routing::get; +use axum::routing::{get, post}; use axum::{Json, Router}; use lnvps_api_common::{ - ApiData, ApiPaginatedData, ApiPaginatedResult, ApiResult, NetworkProvisioner, parse_gateway, + ApiData, ApiPaginatedData, ApiPaginatedResult, ApiResult, NetworkProvisioner, WorkJob, + parse_gateway, }; use lnvps_db::{AdminAction, AdminResource, IpRangeAllocationMode}; use serde::Deserialize; @@ -29,6 +30,32 @@ pub fn router() -> Router { "/api/admin/v1/ip_ranges/{id}/free_ips", get(admin_list_free_ips), ) + .route( + "/api/admin/v1/ip_ranges/{id}/patch_dns", + post(admin_patch_ip_range_dns), + ) +} + +/// Queue a job to re-apply forward + reverse DNS for every IP assignment in a range. +/// Useful after changing the range's DNS server configuration. Returns a `JobResponse`. +async fn admin_patch_ip_range_dns( + auth: AdminAuth, + State(this): State, + Path(id): Path, +) -> ApiResult { + auth.require_permission(AdminResource::IpRange, AdminAction::Update)?; + + // Verify the range exists + let _range = this.db.admin_get_ip_range(id).await?; + + let job_id = this + .work_commander + .send(WorkJob::PatchIpRangeDns { + ip_range_id: id, + admin_user_id: Some(auth.user_id), + }) + .await?; + ApiData::ok(JobResponse { job_id }) } /// Resolve the routers that route a given IP range via its access policy. @@ -218,6 +245,18 @@ async fn admin_create_ip_range( return ApiData::err("Specified access policy does not exist"); } + // Validate DNS servers if provided + if let Some(dns_id) = req.forward_dns_server_id + && this.db.get_dns_server(dns_id).await.is_err() + { + return ApiData::err("Specified forward DNS server does not exist"); + } + if let Some(dns_id) = req.reverse_dns_server_id + && this.db.get_dns_server(dns_id).await.is_err() + { + return ApiData::err("Specified reverse DNS server does not exist"); + } + // Create IP range object with normalized gateway let mut ip_range = req.to_ip_range()?; ip_range.gateway = normalized_gateway; @@ -334,6 +373,31 @@ async fn admin_update_ip_range( ip_range.use_full_range = use_full_range; } + if let Some(forward_dns_server_id) = &req.forward_dns_server_id { + if let Some(dns_id) = forward_dns_server_id + && this.db.get_dns_server(*dns_id).await.is_err() + { + return ApiData::err("Specified forward DNS server does not exist"); + } + ip_range.forward_dns_server_id = *forward_dns_server_id; + } + + if let Some(reverse_dns_server_id) = &req.reverse_dns_server_id { + if let Some(dns_id) = reverse_dns_server_id + && this.db.get_dns_server(*dns_id).await.is_err() + { + return ApiData::err("Specified reverse DNS server does not exist"); + } + ip_range.reverse_dns_server_id = *reverse_dns_server_id; + } + + if let Some(forward_zone_id) = &req.forward_zone_id { + ip_range.forward_zone_id = forward_zone_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + } + // Update IP range in database this.db.admin_update_ip_range(&ip_range).await?; diff --git a/lnvps_api_admin/src/admin/mod.rs b/lnvps_api_admin/src/admin/mod.rs index 215d7bc8..fd446be4 100644 --- a/lnvps_api_admin/src/admin/mod.rs +++ b/lnvps_api_admin/src/admin/mod.rs @@ -10,6 +10,7 @@ mod bulk_message; mod companies; mod cost_plans; mod custom_pricing; +mod dns_servers; mod docs; mod hosts; mod ip_ranges; @@ -61,6 +62,7 @@ pub fn admin_router( .merge(ip_space::router()) .merge(access_policies::router()) .merge(routers::router()) + .merge(dns_servers::router()) .merge(vm_ip_assignments::router()) .merge(subscriptions::router()) .merge(reports::router()) diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 7dee52a3..42d07de1 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -126,6 +126,89 @@ impl From for RouterKind { } } +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AdminDnsServerKind { + Cloudflare, + Ovh, +} + +impl From for AdminDnsServerKind { + fn from(kind: lnvps_db::DnsServerKind) -> Self { + match kind { + lnvps_db::DnsServerKind::Cloudflare => AdminDnsServerKind::Cloudflare, + lnvps_db::DnsServerKind::Ovh => AdminDnsServerKind::Ovh, + // MockDns is a test-only variant; map to Cloudflare as a safe fallback. + lnvps_db::DnsServerKind::MockDns => AdminDnsServerKind::Cloudflare, + } + } +} + +impl From for lnvps_db::DnsServerKind { + fn from(kind: AdminDnsServerKind) -> Self { + match kind { + AdminDnsServerKind::Cloudflare => lnvps_db::DnsServerKind::Cloudflare, + AdminDnsServerKind::Ovh => lnvps_db::DnsServerKind::Ovh, + } + } +} + +#[derive(Serialize)] +pub struct AdminDnsServerDetail { + pub id: u64, + pub name: String, + pub enabled: bool, + pub kind: AdminDnsServerKind, + pub url: String, + /// Number of IP ranges referencing this DNS server (forward or reverse) + pub ip_range_count: u64, +} + +impl From for AdminDnsServerDetail { + fn from(dns: lnvps_db::DnsServer) -> Self { + Self { + id: dns.id, + name: dns.name, + enabled: dns.enabled, + kind: AdminDnsServerKind::from(dns.kind), + url: dns.url, + ip_range_count: 0, // Will be filled by handler + } + } +} + +#[derive(Deserialize)] +pub struct CreateDnsServerRequest { + pub name: String, + pub enabled: Option, // Default: true + pub kind: AdminDnsServerKind, + #[serde(default)] + pub url: String, + pub token: String, +} + +#[derive(Deserialize)] +pub struct UpdateDnsServerRequest { + pub name: Option, + pub enabled: Option, + pub kind: Option, + pub url: Option, + pub token: Option, +} + +impl CreateDnsServerRequest { + pub fn to_dns_server(&self) -> lnvps_db::DnsServer { + lnvps_db::DnsServer { + id: 0, // Will be set by database + name: self.name.trim().to_string(), + enabled: self.enabled.unwrap_or(true), + kind: lnvps_db::DnsServerKind::from(self.kind), + url: self.url.trim().to_string(), + token: self.token.as_str().into(), + } + } +} + #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AdminUserStatus { @@ -1634,6 +1717,9 @@ pub struct AdminIpRangeInfo { pub access_policy_name: Option, // Populated with access policy name pub allocation_mode: AdminIpRangeAllocationMode, pub use_full_range: bool, + pub forward_dns_server_id: Option, + pub reverse_dns_server_id: Option, + pub forward_zone_id: Option, pub assignment_count: u64, // Number of active IP assignments in this range #[serde(skip_serializing_if = "Option::is_none")] pub available_ips: Option, // Number of available IPs (only for IPv4 ranges) @@ -1659,6 +1745,9 @@ pub struct CreateIpRangeRequest { pub access_policy_id: Option, pub allocation_mode: Option, // default: "sequential" pub use_full_range: Option, // Default: false + pub forward_dns_server_id: Option, + pub reverse_dns_server_id: Option, + pub forward_zone_id: Option, } #[derive(Deserialize)] @@ -1679,6 +1768,21 @@ pub struct UpdateIpRangeRequest { pub access_policy_id: Option>, pub allocation_mode: Option, pub use_full_range: Option, + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub forward_dns_server_id: Option>, + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub reverse_dns_server_id: Option>, + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub forward_zone_id: Option>, } // Access Policy Models for IP range management @@ -1705,6 +1809,9 @@ impl From for AdminIpRangeInfo { access_policy_name: None, // Will be filled by handler allocation_mode: AdminIpRangeAllocationMode::from(ip_range.allocation_mode), use_full_range: ip_range.use_full_range, + forward_dns_server_id: ip_range.forward_dns_server_id, + reverse_dns_server_id: ip_range.reverse_dns_server_id, + forward_zone_id: ip_range.forward_zone_id, assignment_count: 0, // Will be filled by handler available_ips: None, // Will be filled by handler for IPv4 ranges routers: Vec::new(), // Will be filled by handler @@ -1745,6 +1852,13 @@ impl CreateIpRangeRequest { access_policy_id: self.access_policy_id, allocation_mode: db_allocation_mode, use_full_range: self.use_full_range.unwrap_or(false), + forward_dns_server_id: self.forward_dns_server_id, + reverse_dns_server_id: self.reverse_dns_server_id, + forward_zone_id: self + .forward_zone_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), }) } } diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index b4deef04..46a7c059 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -255,6 +255,9 @@ async fn create_ip_ranges(db: &LNVpsDbMysql, regions: &[VmHostRegion]) -> Result access_policy_id: None, allocation_mode: IpRangeAllocationMode::Random, use_full_range: false, + forward_dns_server_id: None, + reverse_dns_server_id: None, + forward_zone_id: None, }; let id = db.admin_create_ip_range(&ip_range).await?; diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 6074c183..be779a84 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -4,14 +4,14 @@ use chrono::{Days, Months, TimeDelta, Utc}; use lnvps_db::nostr::LNVPSNostrDb; use lnvps_db::{ AccessPolicy, AvailableIpSpace, Company, CpuArch, CpuMfg, DbError, DbResult, DiskInterface, - DiskType, IntervalType, IpRange, IpRangeAllocationMode, IpRangeSubscription, IpSpacePricing, - LNVpsDbBase, NostrDomain, NostrDomainHandle, OsDistribution, PaymentMethod, - PaymentMethodConfig, Referral, ReferralCostUsage, ReferralPayout, Router, RouterBgpRoute, - RouterBgpSession, RouterTunnel, RouterTunnelTraffic, Subscription, SubscriptionLineItem, - SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, - VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmFirewallPolicy, VmFirewallRule, - VmHistory, VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, - VmTemplate, + DiskType, DnsServer, DnsServerKind, IntervalType, IpRange, IpRangeAllocationMode, + IpRangeSubscription, IpSpacePricing, LNVpsDbBase, NostrDomain, NostrDomainHandle, + OsDistribution, PaymentMethod, PaymentMethodConfig, Referral, ReferralCostUsage, + ReferralPayout, Router, RouterBgpRoute, RouterBgpSession, RouterTunnel, RouterTunnelTraffic, + Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, + UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, + VmFirewallPolicy, VmFirewallRule, VmHistory, VmHost, VmHostDisk, VmHostKind, VmHostRegion, + VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; use async_trait::async_trait; @@ -40,6 +40,7 @@ pub struct MockDb { pub custom_template: Arc>>, pub payments: Arc>>, pub router: Arc>>, + pub dns_servers: Arc>>, pub access_policy: Arc>>, pub companies: Arc>>, pub vm_history: Arc>>, @@ -137,6 +138,19 @@ impl Default for MockDb { company_id: 1, // Link to default company }, ); + // Default mock DNS server (forward records via the shared MockDnsServer). + let mut dns_servers = HashMap::new(); + dns_servers.insert( + 1, + DnsServer { + id: 1, + name: "mock-dns".to_string(), + enabled: true, + kind: DnsServerKind::MockDns, + url: "https://localhost".to_string(), + token: "mock-token".into(), + }, + ); let mut ip_ranges = HashMap::new(); ip_ranges.insert( 1, @@ -147,6 +161,8 @@ impl Default for MockDb { enabled: true, region_id: 1, allocation_mode: IpRangeAllocationMode::Random, // use random due to race conditions + forward_dns_server_id: Some(1), + forward_zone_id: Some("mock-forward-zone-id".to_string()), ..Default::default() }, ); @@ -159,6 +175,8 @@ impl Default for MockDb { enabled: true, region_id: 1, allocation_mode: IpRangeAllocationMode::SlaacEui64, + forward_dns_server_id: Some(1), + forward_zone_id: Some("mock-forward-zone-id".to_string()), ..Default::default() }, ); @@ -237,6 +255,7 @@ impl Default for MockDb { custom_template: Arc::new(Default::default()), payments: Arc::new(Default::default()), router: Arc::new(Default::default()), + dns_servers: Arc::new(Mutex::new(dns_servers)), access_policy: Arc::new(Default::default()), companies: Arc::new(Mutex::new({ let mut companies = HashMap::new(); @@ -1200,6 +1219,70 @@ impl LNVpsDbBase for MockDb { Ok(routers.values().cloned().collect()) } + async fn get_dns_server(&self, dns_server_id: u64) -> DbResult { + let d = self.dns_servers.lock().await; + Ok(d.get(&dns_server_id).cloned().context("no dns server")?) + } + + async fn list_dns_servers(&self) -> DbResult> { + let d = self.dns_servers.lock().await; + Ok(d.values().cloned().collect()) + } + + async fn list_dns_servers_paginated( + &self, + _limit: u64, + _offset: u64, + ) -> DbResult<(Vec, u64)> { + let d = self.dns_servers.lock().await; + let all: Vec = d.values().cloned().collect(); + let total = all.len() as u64; + Ok((all, total)) + } + + async fn insert_dns_server(&self, dns_server: &DnsServer) -> DbResult { + let mut d = self.dns_servers.lock().await; + let id = d.keys().max().copied().unwrap_or(0) + 1; + let mut new = dns_server.clone(); + new.id = id; + d.insert(id, new); + Ok(id) + } + + async fn update_dns_server(&self, dns_server: &DnsServer) -> DbResult<()> { + let mut d = self.dns_servers.lock().await; + d.insert(dns_server.id, dns_server.clone()); + Ok(()) + } + + async fn delete_dns_server(&self, dns_server_id: u64) -> DbResult<()> { + let mut d = self.dns_servers.lock().await; + d.remove(&dns_server_id); + Ok(()) + } + + async fn count_dns_server_ip_ranges(&self, dns_server_id: u64) -> DbResult { + let ranges = self.ip_range.lock().await; + Ok(ranges + .values() + .filter(|r| { + r.forward_dns_server_id == Some(dns_server_id) + || r.reverse_dns_server_id == Some(dns_server_id) + }) + .count() as u64) + } + + async fn update_ip_range_dns(&self, range: &IpRange) -> DbResult<()> { + let mut ranges = self.ip_range.lock().await; + if let Some(existing) = ranges.get_mut(&range.id) { + existing.forward_dns_server_id = range.forward_dns_server_id; + existing.reverse_dns_server_id = range.reverse_dns_server_id; + existing.forward_zone_id = range.forward_zone_id.clone(); + existing.reverse_zone_id = range.reverse_zone_id.clone(); + } + Ok(()) + } + async fn list_router_tunnels(&self, router_id: u64) -> DbResult> { let t = self.router_tunnels.lock().await; Ok(t.values() diff --git a/lnvps_api_common/src/work/mod.rs b/lnvps_api_common/src/work/mod.rs index 20d9b71e..1fd719dd 100644 --- a/lnvps_api_common/src/work/mod.rs +++ b/lnvps_api_common/src/work/mod.rs @@ -150,6 +150,14 @@ pub enum WorkJob { name: String, enabled: bool, }, + /// Re-apply forward + reverse DNS records for every IP assignment in a range. + /// + /// Used after changing a range's DNS server configuration (e.g. switching + /// reverse DNS to OVH) to reconcile existing assignments to the current config. + PatchIpRangeDns { + ip_range_id: u64, + admin_user_id: Option, + }, } impl WorkJob { @@ -197,6 +205,7 @@ impl fmt::Display for WorkJob { WorkJob::SetRouterDefaultRoute { .. } => write!(f, "SetRouterDefaultRoute"), WorkJob::ClearRouterDefaultRoute { .. } => write!(f, "ClearRouterDefaultRoute"), WorkJob::ToggleTunnel { .. } => write!(f, "ToggleTunnel"), + WorkJob::PatchIpRangeDns { .. } => write!(f, "PatchIpRangeDns"), } } } @@ -228,5 +237,13 @@ mod tests { .to_string(), "ToggleTunnel" ); + assert_eq!( + WorkJob::PatchIpRangeDns { + ip_range_id: 3, + admin_user_id: Some(1), + } + .to_string(), + "PatchIpRangeDns" + ); } } diff --git a/lnvps_db/migrations/20260710121414_dns_server.sql b/lnvps_db/migrations/20260710121414_dns_server.sql new file mode 100644 index 00000000..ea9367f3 --- /dev/null +++ b/lnvps_db/migrations/20260710121414_dns_server.sql @@ -0,0 +1,25 @@ +-- DNS provider configuration moved from static settings into the database, +-- mirroring the `router` table. Each row is an external DNS provider (Cloudflare, +-- OVH reverse, ...) with an encrypted credential token. +create table dns_server +( + id integer unsigned not null auto_increment primary key, + name varchar(100) not null, + enabled bit(1) not null default 1, + kind smallint unsigned not null, + -- API base url (provider specific, e.g. https://eu.api.ovh.com). May be empty for Cloudflare. + url varchar(255) not null default '', + -- Encrypted credential token (Cloudflare: bearer token; OVH: app_key:app_secret:consumer_key) + token varchar(255) not null +); + +-- Per-IP-range DNS server references + forward zone id. +-- `reverse_zone_id` already exists (added in 20250325113115_extend_ip_range.sql). +alter table ip_range + add column forward_dns_server_id integer unsigned, + add column reverse_dns_server_id integer unsigned, + add column forward_zone_id varchar(255); + +alter table ip_range + add constraint fk_ip_range_forward_dns_server foreign key (forward_dns_server_id) references dns_server (id), + add constraint fk_ip_range_reverse_dns_server foreign key (reverse_dns_server_id) references dns_server (id); diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 38f32dee..78a796c7 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -414,6 +414,34 @@ pub trait LNVpsDbBase: Send + Sync { /// List all routers async fn list_routers(&self) -> DbResult>; + /// Get DNS server config + async fn get_dns_server(&self, dns_server_id: u64) -> DbResult; + + /// List all DNS servers + async fn list_dns_servers(&self) -> DbResult>; + + /// List DNS servers with pagination, returning (rows, total_count) + async fn list_dns_servers_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)>; + + /// Insert a new DNS server, returning its id + async fn insert_dns_server(&self, dns_server: &DnsServer) -> DbResult; + + /// Update an existing DNS server + async fn update_dns_server(&self, dns_server: &DnsServer) -> DbResult<()>; + + /// Delete a DNS server (only if not referenced by any IP ranges) + async fn delete_dns_server(&self, dns_server_id: u64) -> DbResult<()>; + + /// Count IP ranges referencing a DNS server (forward or reverse) + async fn count_dns_server_ip_ranges(&self, dns_server_id: u64) -> DbResult; + + /// Update only the DNS server references + zone ids for an IP range + async fn update_ip_range_dns(&self, range: &IpRange) -> DbResult<()>; + /// List cached tunnels for a router async fn list_router_tunnels(&self, router_id: u64) -> DbResult>; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 6ed3b4fb..8687d441 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -705,6 +705,32 @@ pub enum RouterKind { MockRouter = u16::MAX, } +/// An external DNS provider used to manage forward (A/AAAA) and/or reverse (PTR) +/// records for IP assignments. Configured per-row in the database and referenced +/// from `ip_range` (see `forward_dns_server_id` / `reverse_dns_server_id`). +#[derive(FromRow, Clone, Debug)] +pub struct DnsServer { + pub id: u64, + pub name: String, + pub enabled: bool, + pub kind: DnsServerKind, + /// API base url (provider specific). May be empty for providers with a fixed endpoint. + pub url: String, + /// Encrypted credential token (Cloudflare: bearer token; OVH: app_key:app_secret:consumer_key) + pub token: EncryptedString, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] +#[repr(u16)] +pub enum DnsServerKind { + /// Cloudflare DNS (zone + record based, forward & reverse) + Cloudflare = 0, + /// OVH reverse DNS (per-IP PTR records, reverse only) + Ovh = 1, + /// Mock DNS server for tests + MockDns = u16::MAX, +} + /// Cached tunnel inventory discovered on a router (GRE/VXLAN/WireGuard) #[derive(FromRow, Clone, Debug)] pub struct RouterTunnel { @@ -793,6 +819,12 @@ pub struct IpRange { pub allocation_mode: IpRangeAllocationMode, /// Use all IPs in the range, including first and last pub use_full_range: bool, + /// DNS server used to manage forward (A/AAAA) records for IPs in this range + pub forward_dns_server_id: Option, + /// DNS server used to manage reverse (PTR) records for IPs in this range + pub reverse_dns_server_id: Option, + /// Forward zone id (provider specific, e.g. Cloudflare zone id) for forward records + pub forward_zone_id: Option, } #[derive(Debug, Clone, Copy, sqlx::Type, Default)] @@ -1510,6 +1542,7 @@ pub enum AdminResource { SubscriptionPayments = 19, IpSpace = 20, PaymentMethodConfig = 21, + DnsServer = 22, } /// Actions that can be performed on administrative resources @@ -1547,6 +1580,7 @@ impl Display for AdminResource { AdminResource::SubscriptionPayments => write!(f, "subscription_payments"), AdminResource::IpSpace => write!(f, "ip_space"), AdminResource::PaymentMethodConfig => write!(f, "payment_method_config"), + AdminResource::DnsServer => write!(f, "dns_server"), } } } @@ -1578,6 +1612,7 @@ impl FromStr for AdminResource { "subscription_payments" => Ok(AdminResource::SubscriptionPayments), "ip_space" => Ok(AdminResource::IpSpace), "payment_method_config" => Ok(AdminResource::PaymentMethodConfig), + "dns_server" => Ok(AdminResource::DnsServer), _ => Err(anyhow!("unknown admin resource: {}", s)), } } @@ -1610,6 +1645,7 @@ impl TryFrom for AdminResource { 19 => Ok(AdminResource::SubscriptionPayments), 20 => Ok(AdminResource::IpSpace), 21 => Ok(AdminResource::PaymentMethodConfig), + 22 => Ok(AdminResource::DnsServer), _ => Err(anyhow!("unknown admin resource value: {}", value)), } } @@ -1641,6 +1677,7 @@ impl AdminResource { AdminResource::SubscriptionPayments, AdminResource::IpSpace, AdminResource::PaymentMethodConfig, + AdminResource::DnsServer, ] } } @@ -1925,6 +1962,20 @@ mod tests { assert_eq!(InternetRegistry::AFRINIC.min_ipv6_prefix_size(), 48); } + #[test] + fn test_admin_resource_dns_server_roundtrip() { + assert_eq!( + "dns_server".parse::().unwrap(), + AdminResource::DnsServer + ); + assert_eq!(AdminResource::DnsServer.to_string(), "dns_server"); + assert_eq!( + AdminResource::try_from(22u16).unwrap(), + AdminResource::DnsServer + ); + assert!(AdminResource::all().contains(&AdminResource::DnsServer)); + } + #[test] fn test_cpu_mfg_from_str() { assert_eq!("intel".parse::().unwrap(), CpuMfg::Intel); diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index c274273c..75399b62 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,5 +1,5 @@ use crate::{ - AccessPolicy, AvailableIpSpace, Company, DbError, DbResult, IntervalType, IpRange, + AccessPolicy, AvailableIpSpace, Company, DbError, DbResult, DnsServer, IntervalType, IpRange, IpRangeSubscription, IpSpacePricing, LNVpsDbBase, PaymentMethod, PaymentMethodConfig, PaymentType, Referral, ReferralCostUsage, ReferralPayout, RegionStats, Router, RouterBgpRoute, RouterBgpSession, RouterTunnel, RouterTunnelTraffic, Subscription, SubscriptionLineItem, @@ -1344,6 +1344,117 @@ impl LNVpsDbBase for LNVpsDbMysql { .await?) } + async fn get_dns_server(&self, dns_server_id: u64) -> DbResult { + Ok(sqlx::query_as("select * from dns_server where id=?") + .bind(dns_server_id) + .fetch_one(&self.db) + .await?) + } + + async fn list_dns_servers(&self) -> DbResult> { + Ok(sqlx::query_as("select * from dns_server") + .fetch_all(&self.db) + .await?) + } + + async fn list_dns_servers_paginated( + &self, + limit: u64, + offset: u64, + ) -> DbResult<(Vec, u64)> { + let servers = sqlx::query_as::<_, DnsServer>( + "SELECT * FROM dns_server ORDER BY name LIMIT ? OFFSET ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + + let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM dns_server") + .fetch_one(&self.db) + .await?; + + Ok((servers, total.0 as u64)) + } + + async fn insert_dns_server(&self, dns_server: &DnsServer) -> DbResult { + let result = sqlx::query( + "INSERT INTO dns_server (name, enabled, kind, url, token) VALUES (?, ?, ?, ?, ?)", + ) + .bind(&dns_server.name) + .bind(dns_server.enabled) + .bind(dns_server.kind) + .bind(&dns_server.url) + .bind(&dns_server.token) + .execute(&self.db) + .await?; + + Ok(result.last_insert_id()) + } + + async fn update_dns_server(&self, dns_server: &DnsServer) -> DbResult<()> { + sqlx::query( + "UPDATE dns_server SET name = ?, enabled = ?, kind = ?, url = ?, token = ? WHERE id = ?", + ) + .bind(&dns_server.name) + .bind(dns_server.enabled) + .bind(dns_server.kind) + .bind(&dns_server.url) + .bind(&dns_server.token) + .bind(dns_server.id) + .execute(&self.db) + .await?; + + Ok(()) + } + + async fn delete_dns_server(&self, dns_server_id: u64) -> DbResult<()> { + let count = self.count_dns_server_ip_ranges(dns_server_id).await?; + if count > 0 { + return Err(DbError::Source( + anyhow!( + "Cannot delete DNS server: {} IP ranges are using this DNS server", + count + ) + .into_boxed_dyn_error(), + )); + } + + sqlx::query("DELETE FROM dns_server WHERE id = ?") + .bind(dns_server_id) + .execute(&self.db) + .await?; + + Ok(()) + } + + async fn count_dns_server_ip_ranges(&self, dns_server_id: u64) -> DbResult { + let count: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM ip_range WHERE forward_dns_server_id = ? OR reverse_dns_server_id = ?", + ) + .bind(dns_server_id) + .bind(dns_server_id) + .fetch_one(&self.db) + .await?; + + Ok(count.0 as u64) + } + + async fn update_ip_range_dns(&self, range: &IpRange) -> DbResult<()> { + sqlx::query( + "UPDATE ip_range SET forward_dns_server_id = ?, reverse_dns_server_id = ?, forward_zone_id = ?, reverse_zone_id = ? WHERE id = ?", + ) + .bind(range.forward_dns_server_id) + .bind(range.reverse_dns_server_id) + .bind(&range.forward_zone_id) + .bind(&range.reverse_zone_id) + .bind(range.id) + .execute(&self.db) + .await?; + + Ok(()) + } + async fn list_router_tunnels(&self, router_id: u64) -> DbResult> { Ok( sqlx::query_as("select * from router_tunnel where router_id=?") @@ -4512,8 +4623,8 @@ impl AdminDb for LNVpsDbMysql { async fn admin_create_ip_range(&self, ip_range: &IpRange) -> DbResult { let result = sqlx::query( - r#"INSERT INTO ip_range (cidr, gateway, enabled, region_id, reverse_zone_id, access_policy_id, allocation_mode, use_full_range) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)"#, + r#"INSERT INTO ip_range (cidr, gateway, enabled, region_id, reverse_zone_id, access_policy_id, allocation_mode, use_full_range, forward_dns_server_id, reverse_dns_server_id, forward_zone_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, ) .bind(&ip_range.cidr) .bind(&ip_range.gateway) @@ -4523,6 +4634,9 @@ impl AdminDb for LNVpsDbMysql { .bind(ip_range.access_policy_id) .bind(ip_range.allocation_mode as u16) .bind(ip_range.use_full_range) + .bind(ip_range.forward_dns_server_id) + .bind(ip_range.reverse_dns_server_id) + .bind(&ip_range.forward_zone_id) .execute(&self.db) .await?; @@ -4533,7 +4647,8 @@ impl AdminDb for LNVpsDbMysql { sqlx::query( r#"UPDATE ip_range SET cidr = ?, gateway = ?, enabled = ?, region_id = ?, - reverse_zone_id = ?, access_policy_id = ?, allocation_mode = ?, use_full_range = ? + reverse_zone_id = ?, access_policy_id = ?, allocation_mode = ?, use_full_range = ?, + forward_dns_server_id = ?, reverse_dns_server_id = ?, forward_zone_id = ? WHERE id = ?"#, ) .bind(&ip_range.cidr) @@ -4544,6 +4659,9 @@ impl AdminDb for LNVpsDbMysql { .bind(ip_range.access_policy_id) .bind(ip_range.allocation_mode as u16) .bind(ip_range.use_full_range) + .bind(ip_range.forward_dns_server_id) + .bind(ip_range.reverse_dns_server_id) + .bind(&ip_range.forward_zone_id) .bind(ip_range.id) .execute(&self.db) .await?; diff --git a/work/dns-db-refactor.md b/work/dns-db-refactor.md new file mode 100644 index 00000000..4608def5 --- /dev/null +++ b/work/dns-db-refactor.md @@ -0,0 +1,115 @@ +# DNS Forward/Reverse DB-Driven Refactor + OVH Reverse DNS + +**Status:** complete +**Started:** 2026-07-10 +**Last updated:** 2026-07-10 + +## Summary (complete) + +Done in a single PR. DNS providers are now DB rows (`dns_server` table, encrypted token), +referenced per IP range via `forward_dns_server_id` / `reverse_dns_server_id` + +`forward_zone_id` (reverse zone already existed). The `DnsServer` trait was generalized +(zone/ip folded onto `BasicRecord`), a `dns::get_dns_server(db,id)` factory dispatches on +kind, and a new `ovh` provider implements reverse DNS via `POST/DELETE /ip/{ip}/reverse` +(reuses shared `crate::ovh::OvhTokenGen`, extracted from `router/ovh.rs`). `vm_network.rs` +resolves DNS servers per range from the DB; the static `Settings.dns` is now legacy, +consumed once by `DnsDataMigration` to bootstrap the DB rows. Admin CRUD at +`/api/admin/v1/dns_servers` (+ new `dns_server` permission, `AdminResource::DnsServer = 22`). +All unit tests pass; clippy clean on new files; API_CHANGELOG + ADMIN_API_ENDPOINTS updated. +Closes #78 (and finishes #16). Not done: authoritative DNS server crate (#110, out of scope). + +## Goal + +Refactor the DNS subsystem so forward and reverse DNS are configured entirely in the +database (mirroring the existing `router` pattern) instead of the global `settings.yaml`, +and add an OVH reverse-DNS provider. Closes GitHub issue #78 ("OVH Reverse"). + +Done looks like: +- A `dns_server` DB table (`id, name, enabled, kind, url, token`) with an encrypted token, + a Rust model, DB trait CRUD methods, and admin API — mirroring `router`. +- IP ranges reference a **forward** and a **reverse** DNS server (per-range, per user's choice) + plus provider-specific zone fields; the static `Settings.dns` config is removed. +- A generalized `DnsServer` trait + factory `dns::get_dns_server(db, id)` dispatching on kind. +- A new `dns/ovh.rs` provider implementing reverse DNS via `POST /ip/{ip}/reverse` and + `DELETE /ip/{ip}/reverse/{ip}` (forward DNS unsupported by OVH — reverse only). +- `vm_network.rs` resolves DNS servers per IP-range from the DB. +- Data migration moving existing config (`Settings.dns` + `IpRange.reverse_zone_id`) into the + new tables, and updated tests + `MockDnsServer` + `API_CHANGELOG.md`. + +## Findings + +### Current architecture (config split, needs unifying) +- Provider + creds: `settings.yaml` → `Settings.dns` = `DnsServerConfig { forward_zone_id, api: DnsServerApi::Cloudflare { token } }` (single global). `lnvps_api/src/settings.rs:138`, factory `get_dns()` at `settings.rs:256`. +- Forward zone: global `DnsServerConfig.forward_zone_id`. +- Reverse zone: DB `IpRange.reverse_zone_id: Option` (`lnvps_db/src/model.rs:791`). Column added in `lnvps_db/migrations/20250325113115_extend_ip_range.sql`. +- Record refs: DB `VmIpAssignment.dns_forward/dns_forward_ref/dns_reverse/dns_reverse_ref` (`model.rs:1061-1068`). + +### Key files +- `lnvps_api/src/dns/mod.rs` — `DnsServer` trait (`add_record`/`update_record`/`delete_record` all take `zone_id: &str`), `BasicRecord`, `RecordType`, `is_valid_fqdn`. +- `lnvps_api/src/dns/cloudflare.rs` — only provider; zone+record-id based. +- `lnvps_api/src/provisioner/vm_network.rs:119-200` — `remove_ip_dns` / `update_forward_ip_dns` / `update_reverse_ip_dns`. Currently uses `self.dns` (single) + `self.forward_zone_id` + `range.reverse_zone_id`. +- `lnvps_api/src/data_migration/dns.rs` — existing forward/reverse backfill using global settings; model the new data migration on this. +- `lnvps_api/src/mocks.rs` — `MockDnsServer`. + +### Blueprint to mirror: the `router` subsystem +- DB table `router (id, name, enabled bit(1), kind smallint, url varchar(255), token varchar(128))` — migration `20250325113115_extend_ip_range.sql`. +- Model `Router { id, name, enabled, kind: RouterKind, url, token: EncryptedString }` (`model.rs:686`). +- `RouterKind` enum: Mikrotik=0, OvhAdditionalIp=1, LinuxSsh=2, MockRouter=u16::MAX (`model.rs:697`). +- Factory `router::get_router(db, router_id) -> OpResult>` dispatches on kind (`router/mod.rs:370`). +- DB trait CRUD: `get_router`/`list_routers`/... (`lnvps_db/src/lib.rs:411`). +- Admin API: `lnvps_api_admin/src/admin/routers.rs` (CRUD at `/api/admin/v1/routers`). +- **OVH auth already implemented**: `lnvps_api/src/router/ovh.rs` has `OvhTokenGen` (app_key:app_secret:consumer_key token, SHA1 signature, `X-Ovh-*` headers) + time-delta bootstrap via `v1/auth/time`. Extract this into a shared module so both router and DNS OVH clients reuse it. + +### OVH reverse DNS API (issue #78) +- `POST /ip/{ip}/reverse` body `{ ipReverse: "", reverse: "host.example.com." }` — sets PTR. +- `GET /ip/{ip}/reverse/{ipReverse}` — read; `DELETE /ip/{ip}/reverse/{ipReverse}` — remove. +- **No zones, no record IDs.** The "ref" is just the IP. This is why the zone-based `DnsServer` trait must be generalized (make zone optional/opaque; treat the returned ref opaquely). OVH provider supports **reverse only** — return an error/no-op for forward records. + +### Migrations note +- New migration timestamp must be unique 14-digit; generate with `date +%Y%m%d%H%M%S`. Latest existing is `20260624140000`. Use `NOT NULL DEFAULT`/nullable columns so existing rows survive. +- `EncryptedString` type is used for `router.token`; reuse for `dns_server.token`. + +### Related issues +- **#16 "Cloudflare zone ids" (CLOSED, completed 2025-03-25)** — predecessor. Commit `c570222 "feat: move zone-id configuration"` moved only the **reverse** zone id onto `ip_range.reverse_zone_id`; the **forward** zone id + provider token stayed global in `settings.yaml`. This refactor finishes that job (moves forward zone onto `ip_range`, removes `Settings.dns`). Reference #16 in the PR. +- **#110 "DNS server" (OPEN)** — distinct, larger: build an authoritative `lnvps_dns` crate on `trust-dns-server` to host PTR/A/AAAA ourselves. Out of scope here, but it becomes another `DnsServerKind` (self-hosted) that plugs into this DB-driven abstraction. This refactor is an enabler for #110. + +## Design decisions (confirmed with user) +- **Full DB-driven refactor** (not minimal OVH-only). +- Forward DNS configured **per IP range** (store both `forward_dns_server_id` and `reverse_dns_server_id` on `ip_range`, alongside zone fields). + +## Tasks + +### Increment 1 — DB schema + model + trait CRUD (size: M) +- [ ] Extract OVH token/signature helper from `router/ovh.rs` into a shared module (e.g. `lnvps_api/src/ovh/mod.rs` or `json_api`), reused by router + dns. (Can be deferred to Increment 3 if cleaner.) +- [ ] Migration: `create table dns_server (id, name, enabled bit(1), kind smallint, url varchar(255), token varchar(128))`. Add columns to `ip_range`: `forward_dns_server_id`, `reverse_dns_server_id` (both `integer unsigned` nullable, FKs to `dns_server`), and `forward_zone_id varchar(255)` (reverse already exists as `reverse_zone_id`). Keep `reverse_zone_id`. +- [ ] Model: `DnsServer` DB struct + `DnsServerKind` enum (Cloudflare=0, Ovh=1, MockDns=u16::MAX). Extend `IpRange` with the new fields. +- [ ] DB trait + mysql impl: `get_dns_server`/`list_dns_servers`/`insert_dns_server`/`update_dns_server`/`delete_dns_server`. Update `ip_range` queries (get/list/insert/update) for new columns. +- [ ] Note DB model type name clash: DB `DnsServer` struct vs api `DnsServer` trait — rename one (e.g. DB `DnsServerConfig`/`DnsServerRow`, or trait stays `DnsServer` and DB row is `DnsServer` in `lnvps_db` namespace — pick and document). + +### Increment 2 — Generalize DnsServer trait + factory (size: M) +- [ ] Change trait signatures so zone is optional context (e.g. carry an optional zone on `BasicRecord` or pass `&IpRange`), and record ref is opaque. Keep Cloudflare working. +- [ ] Add `dns::get_dns_server(db, dns_server_id) -> OpResult>` factory dispatching on `DnsServerKind` (Cloudflare/Ovh/Mock), decrypting token. +- [ ] Update `MockDnsServer` and any callers to the new trait shape. + +### Increment 3 — OVH reverse DNS provider (size: S/M) +- [ ] `lnvps_api/src/dns/ovh.rs`: implement `DnsServer` for reverse via `POST/GET/DELETE /ip/{ip}/reverse`. Reuse shared OVH token gen. Forward = unsupported error. Feature-flag `ovh-dns` (or reuse existing `ovh`/router feature). +- [ ] Wire into factory + Cargo features. + +### Increment 4 — vm_network + provisioner wiring (size: M) +- [ ] `vm_network.rs`: resolve forward+reverse DNS servers from the IP range via factory instead of `self.dns`/`self.forward_zone_id`. Update `remove_ip_dns`/`update_forward_ip_dns`/`update_reverse_ip_dns`. +- [ ] Remove `forward_zone_id`/`dns` constructor params where they came from settings; source from DB per range. +- [ ] Remove `Settings.dns` / `DnsServerConfig` / `DnsServerApi` from `settings.rs` and `get_dns()`. Update all constructors/tests. + +### Increment 5 — Admin API for dns_server CRUD (size: M) +- [ ] `lnvps_api_admin/src/admin/dns_servers.rs` mirroring `routers.rs` (list/create/get/patch/delete) with `AdminResource` permission. Add route wiring + admin model types. Expose ip_range forward/reverse dns server + zone fields in the ip_range admin API (`lnvps_api_admin/src/admin/ip_ranges.rs`). +- [ ] Update `ADMIN_API_ENDPOINTS.md`. + +### Increment 6 — Data migration + docs + tests (size: M) +- [ ] Data migration: create a `dns_server` row from the old `Settings.dns` Cloudflare token, point existing IP ranges' `forward_dns_server_id` at it + set `forward_zone_id` from old global, and `reverse_dns_server_id` at it where `reverse_zone_id` is set. Model on `data_migration/dns.rs` (which then becomes/needs updating). +- [ ] Update/extend E2E + unit tests, mocks, and demo-data generator (`generate_demo_data.rs`). +- [ ] `API_CHANGELOG.md` under `## [Unreleased]`. Label issue #78 (`enhancement`, `api`, `database`) and open PR `Fixes #78`. + +## Notes +- Coverage rule: 100% function coverage required for added/modified functions — add tests per increment. +- One PR per increment (each L-or-smaller). Ask user before committing/pushing. +- Watch the `DnsServer` name collision between the api trait and a DB model struct. From 92010b368ed6b4f166b3b0a2aad25370d6ef7111 Mon Sep 17 00:00:00 2001 From: v0l Date: Fri, 10 Jul 2026 13:35:31 +0100 Subject: [PATCH 2/4] docs: how to test changes against a production DB backup Add docs/agents/testing-db-backups.md describing how to safely restore a prod mariadb dump into an isolated DB and test branch migrations/startup against it: swap all hosts to the Dummy/mock kind, wipe user contact preferences, neutralise ENC: encrypted secrets, repoint external integrations to inert endpoints, seed the dummy-host state so VMs report running, and apply migrations. Linked from AGENTS.md. --- AGENTS.md | 1 + docs/agents/testing-db-backups.md | 184 ++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 docs/agents/testing-db-backups.md diff --git a/AGENTS.md b/AGENTS.md index 45a8d4d2..dbf8a201 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ These docs apply to all projects using this agent structure: | [docs/agents/bug-fixes.md](docs/agents/bug-fixes.md) | Resolving bugs — LNVPS-specific additions | | [docs/agents/coverage.md](docs/agents/coverage.md) | Function coverage — LNVPS-specific additions | | [docs/agents/e2e-tests.md](docs/agents/e2e-tests.md) | Writing or running E2E integration tests (`lnvps_e2e` crate) | +| [docs/agents/testing-db-backups.md](docs/agents/testing-db-backups.md) | Testing branch changes (migrations, startup) against a restored production DB backup safely (mock hosts, no user notifications) | | [docs/agents/fw-testing.md](docs/agents/fw-testing.md) | Running or extending the firewall XDP netns test harness (`lnvps_fw`) | ## Pull Request Procedure diff --git a/docs/agents/testing-db-backups.md b/docs/agents/testing-db-backups.md new file mode 100644 index 00000000..8cd18df1 --- /dev/null +++ b/docs/agents/testing-db-backups.md @@ -0,0 +1,184 @@ +# Testing Changes Against a Production Database Backup + +This describes how to safely test branch changes (schema migrations, data +migrations, startup behaviour) against a **real production database dump** without +ever touching real infrastructure or notifying real users. + +The golden rules: + +1. **Never** run against the live DB or the shared dev DB — always restore into a + throwaway database. +2. **Sanitise before running anything**: swap all hosts to the mock (`Dummy`) kind, + wipe user contact preferences, and neutralise encrypted secrets. +3. Point every external integration (DNS providers, routers, lightning) at + something local/inert so no real API is ever called. + +## Prerequisites + +- Docker running with the project MariaDB (`docker compose up -d db`, port `3376`, + `root`/`root`). +- A production dump, e.g. `~/lnvps-YYYYMMDDHHMMSS.sql.gz` (a `mariadb-dump` of the + `lnvps` database). +- **Encryption key caveat.** Production encrypts sensitive columns (`ENC:` prefix) + with a key you almost certainly don't have locally. Two options: + - **Recommended:** neutralise the encrypted columns (step 2c) so the app can read + them as plaintext with a locally auto-generated key. Safe because hosts are + `Dummy` and external URLs are inert. + - Or, if you have the production `encryption.key`, place it where `config.yaml`'s + `encryption.key-file` points and skip step 2c (rows decrypt normally). + +## Step 1 — Restore into an isolated database + +Never reuse the dev `lnvps` schema; restore into a separate database name. + +```bash +gunzip -c ~/lnvps-YYYYMMDDHHMMSS.sql.gz > /tmp/lnvps_backup.sql + +docker exec lnvps_api-db-1 mariadb -uroot -proot \ + -e "DROP DATABASE IF EXISTS lnvps_restore; CREATE DATABASE lnvps_restore CHARACTER SET utf8mb4;" + +docker exec -i lnvps_api-db-1 mariadb -uroot -proot lnvps_restore < /tmp/lnvps_backup.sql + +# sanity check +docker exec lnvps_api-db-1 mariadb -uroot -proot lnvps_restore -e \ + "SELECT (SELECT COUNT(*) FROM vm) vms, (SELECT COUNT(*) FROM users) users, \ + (SELECT COUNT(*) FROM vm_host) hosts, (SELECT COUNT(*) FROM ip_range) ranges;" +``` + +## Step 2 — Sanitise the restored data + +Run this **before** pointing any app at the database. It is idempotent. + +```sql +-- 2a) Make every host the Dummy/mock kind. The Dummy host (VmHostKind::Dummy = 65535) +-- answers all hypervisor calls in-memory, so no real Proxmox/libVirt is contacted. +UPDATE vm_host SET kind = 65535, api_token = 'mock', ssh_key = NULL; + +-- 2b) Wipe contact preferences so NO real user can ever be notified. +UPDATE users SET contact_nip17 = 0, contact_email = 0, email = '', nwc_connection_string = NULL; + +-- 2c) Neutralise remaining encrypted secrets (skip only if you have the prod key). +-- Every encrypted value has an 'ENC:' prefix. Replace with safe plaintext dummies. +UPDATE router SET token = 'mock:mock:mock'; -- app_key:app_secret:consumer_key shape +UPDATE user_ssh_key SET key_data = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleBackupTestKeyOnly'; +UPDATE vm_payment SET external_data = '' WHERE external_data LIKE 'ENC:%'; +-- If present in your dump, also blank any other ENC: columns (e.g. payment_method_config.config). + +-- 2d) Point external integrations at inert endpoints so any stray call fails instantly +-- (connection refused) instead of hitting real OVH/Cloudflare/Mikrotik APIs. +UPDATE router SET url = 'http://127.0.0.1:1'; +``` + +Verify nothing is left that startup would try to decrypt or call: + +```sql +SELECT COUNT(*) FROM users WHERE contact_nip17 = 1 OR contact_email = 1; -- expect 0 +SELECT COUNT(*) FROM vm_host WHERE kind <> 65535; -- expect 0 +SELECT COUNT(*) FROM router WHERE token LIKE 'ENC:%'; -- expect 0 +SELECT COUNT(*) FROM user_ssh_key WHERE key_data LIKE 'ENC:%'; -- expect 0 +``` + +## Step 3 — Make the VMs appear "up" + +The persistent Dummy host reads its VM state from `/tmp/lnvps_dummy_vms.json` +(`HashMap`). Seed every VM as `running` so the worker doesn't try to +(re)provision them and status APIs report healthy VMs: + +```bash +NOW=$(date +%s) +docker exec lnvps_api-db-1 mariadb -uroot -proot lnvps_restore -N -B \ + -e "SELECT id FROM vm WHERE deleted = 0" \ +| awk -v now="$NOW" 'BEGIN{printf "{"} {printf "%s\"%s\":{\"state\":\"running\",\"uptime_secs\":3600,\"net_in\":0,\"net_out\":0,\"disk_read\":0,\"disk_write\":0,\"last_tick\":%s}", (NR>1?",":""), $1, now} END{printf "}"}' \ +> /tmp/lnvps_dummy_vms.json + +# (If you skip this, the worker will still only ever create/start VMs *in-memory* +# on the Dummy host — never on real hardware — but reconciliation is slow.) +``` + +## Step 4 — Apply migrations + +Migrations (`db.migrate()`, embedded `sqlx::migrate!()`) are pure DDL — no +decryption, no external calls — so this is the safe, high-value check that your +branch's schema migrations apply cleanly on top of real production data. + +The quickest way without extra tooling is a tiny throwaway binary. Create +`lnvps_api/src/bin/_backup_migrate.rs` (delete it afterwards — do **not** commit it): + +```rust +use lnvps_db::{LNVpsDbBase, LNVpsDbMysql}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let db = LNVpsDbMysql::new(&url).await?; + db.migrate().await?; + println!("OK: all schema migrations applied cleanly"); + Ok(()) +} +``` + +```bash +DATABASE_URL="mysql://root:root@localhost:3376/lnvps_restore" \ + cargo run -q -p lnvps_api --bin _backup_migrate +rm lnvps_api/src/bin/_backup_migrate.rs +``` + +A clean run proves your migration (and every migration newer than the dump) applies +to real prod data — FK constraints, column types, and data volume included. + +Then inspect the resulting schema / preview what a data migration will touch, e.g.: + +```sql +-- new columns/tables added by your migration +SHOW TABLES LIKE 'dns_server'; +SHOW COLUMNS FROM ip_range WHERE Field LIKE '%dns_server_id'; + +-- e.g. which ranges are OVH-routed (what the DNS data migration will map) +SELECT r.id, r.cidr, rt.name router, rt.kind +FROM ip_range r +JOIN access_policy ap ON r.access_policy_id = ap.id +JOIN router rt ON ap.router_id = rt.id +WHERE rt.kind = 1; +``` + +## Step 5 — (Optional) Run the full app against the backup + +Running the real binary also exercises the **data migrations** and worker. It +additionally needs redis and a lightning node — point them at your local dev +instances. Copy `lnvps_api/config.yaml`, then set `db:` to the restored database: + +```yaml +db: "mysql://root:root@localhost:3376/lnvps_restore" +read-only: true # extra safety: avoids provisioning side effects +# lightning/redis: point at local regtest / local redis +# encryption: auto-generate a local key (works because step 2c removed ENC: values) +``` + +With steps 2–3 done, this is side-effect-free: hosts are `Dummy`, users are +un-notifiable, external URLs are inert (calls fail fast and are handled +best-effort), and VMs report `running`. Data migrations that make outbound calls +(e.g. DNS record backfill) will log warnings on the inert URLs and continue. + +> Note: data migrations that read encrypted columns require step 2c (or the real +> key). Some (e.g. the vm_payment → subscription backfill) decode `external_data`; +> blanking it to `''` keeps startup moving for a test. + +## Step 6 — Clean up + +```bash +docker exec lnvps_api-db-1 mariadb -uroot -proot -e "DROP DATABASE lnvps_restore;" +rm -f /tmp/lnvps_backup.sql /tmp/lnvps_dummy_vms.json +# ensure no throwaway bin was committed +git status --porcelain | grep _backup_migrate || true +``` + +## Checklist (paste-ready summary) + +- [ ] Restored into `lnvps_restore` (not `lnvps`). +- [ ] `vm_host.kind` all = `65535` (Dummy). +- [ ] `users.contact_nip17` / `contact_email` all = `0`. +- [ ] No `ENC:` values remain (or the real key is in place). +- [ ] `router.url` repointed to an inert endpoint. +- [ ] `/tmp/lnvps_dummy_vms.json` seeded (VMs report running). +- [ ] `db.migrate()` ran clean. +- [ ] Throwaway migrate bin deleted, DB dropped afterwards. From 778c8d9d7548dc03544b0be9eeea2bd0490c3410 Mon Sep 17 00:00:00 2001 From: v0l Date: Fri, 10 Jul 2026 13:49:31 +0100 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20db-backup=20testing=20=E2=80=94=20v?= =?UTF-8?q?erified=20full=20API=20run=20+=20mandatory=20URL=20repoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the full lnvps_api binary against a restored 1577-VM prod snapshot (Polar regtest LND + redis). It boots cleanly to "Listening on 0.0.0.0:8000" and serves HTTP 200; the vm_subscription backfill (1577 subs / 3429 payments, 0 errors), encryption re-encrypt, and the DNS data migration (OVH dns_server rows + reverse wiring on the OVH-routed ranges) all run. Key finding folded into the doc: read-only mode does NOT stop startup data migrations or the worker (expired-VM cleanup, DNS reverse backfill, arp_ref_fixer) from making real router/DNS API calls, so repointing all router URLs to an inert endpoint is mandatory. Expanded step 5 with the verified full-run procedure and an "expected output" section. --- docs/agents/testing-db-backups.md | 79 ++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/docs/agents/testing-db-backups.md b/docs/agents/testing-db-backups.md index 8cd18df1..575c01d8 100644 --- a/docs/agents/testing-db-backups.md +++ b/docs/agents/testing-db-backups.md @@ -10,8 +10,13 @@ The golden rules: throwaway database. 2. **Sanitise before running anything**: swap all hosts to the mock (`Dummy`) kind, wipe user contact preferences, and neutralise encrypted secrets. -3. Point every external integration (DNS providers, routers, lightning) at - something local/inert so no real API is ever called. +3. **Repoint every external integration (routers) to an inert endpoint.** This is + **mandatory, not optional**. `read-only` mode only blocks VM *spawning* — it does + **not** stop startup data migrations and the worker from calling real router/DNS + APIs (OVH reverse DNS, Mikrotik, ARP). A restored backup is full of long-expired + VMs, and the worker will try to **delete** them and remove their DNS/ARP records. + With real credentials that would mutate production. (In testing this was only + saved by the router token being a dummy, so OVH returned `403 INVALID_KEY`.) ## Prerequisites @@ -64,8 +69,12 @@ UPDATE user_ssh_key SET key_data = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampl UPDATE vm_payment SET external_data = '' WHERE external_data LIKE 'ENC:%'; -- If present in your dump, also blank any other ENC: columns (e.g. payment_method_config.config). --- 2d) Point external integrations at inert endpoints so any stray call fails instantly --- (connection refused) instead of hitting real OVH/Cloudflare/Mikrotik APIs. +-- 2d) MANDATORY: point every router at an inert endpoint so all outbound calls fail +-- instantly (connection refused) instead of hitting real OVH/Mikrotik APIs. +-- Startup data migrations (DNS reverse backfill, arp_ref_fixer) and the worker +-- (expired-VM cleanup, router state sync) WILL call these URLs — read-only does +-- not prevent it. The dns_server rows the DNS migration creates inherit the +-- router url, so repointing the routers also neutralises DNS calls. UPDATE router SET url = 'http://127.0.0.1:1'; ``` @@ -141,26 +150,62 @@ JOIN router rt ON ap.router_id = rt.id WHERE rt.kind = 1; ``` -## Step 5 — (Optional) Run the full app against the backup +## Step 5 — Run the full API against the backup -Running the real binary also exercises the **data migrations** and worker. It -additionally needs redis and a lightning node — point them at your local dev -instances. Copy `lnvps_api/config.yaml`, then set `db:` to the restored database: +Running the real binary exercises the **whole system**: schema migrations, the +startup `vm_subscription` backfill, the 7 data migrations (including the DNS one), +re-encryption of secrets with your local key, the worker, and the HTTP server. It +additionally needs redis and a lightning node. + +1. Start redis: `docker compose up -d redis`. +2. Start a local lightning node. The dev `lnvps_api/config.yaml` points at a Polar + regtest LND; bring that network up + (`docker compose -f ~/.polar/networks//docker-compose.yml up -d`) and note the + LND host gRPC port (Polar `alice` maps `10009` → host `10001`). +3. Create an override config `config.backup-test.yaml` (do **not** commit it): ```yaml db: "mysql://root:root@localhost:3376/lnvps_restore" -read-only: true # extra safety: avoids provisioning side effects -# lightning/redis: point at local regtest / local redis -# encryption: auto-generate a local key (works because step 2c removed ENC: values) +read-only: true # blocks VM spawning (but NOT external DNS/ARP calls — see step 2d) +lightning: + lnd: + url: "https://127.0.0.1:10001" + cert: "/home//.polar/networks//volumes/lnd/alice/tls.cert" + macaroon: "/home//.polar/networks//volumes/lnd/alice/data/chain/bitcoin/regtest/admin.macaroon" ``` -With steps 2–3 done, this is side-effect-free: hosts are `Dummy`, users are -un-notifiable, external URLs are inert (calls fail fast and are handled -best-effort), and VMs report `running`. Data migrations that make outbound calls -(e.g. DNS record backfill) will log warnings on the inert URLs and continue. +4. Run it, layering the override on top of the dev config (later files win): + +```bash +cargo build -p lnvps_api --bin lnvps_api +RUST_LOG=info,sqlx=warn ./target/debug/lnvps_api \ + --config lnvps_api/config.yaml \ + --config config.backup-test.yaml 2>&1 | tee /tmp/lnvps_run.log +``` -> Note: data migrations that read encrypted columns require step 2c (or the real -> key). Some (e.g. the vm_payment → subscription backfill) decode `external_data`; +A healthy run ends with `Listening on 0.0.0.0:8000`; then +`curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/v1/vm/templates` +returns `200`. + +### What to expect (verified against a real 1577-VM snapshot) + +- `vm_subscription` backfill runs first (Phase 1 creates a subscription per VM, + Phase 2 copies `vm_payment` → `subscription_payment`). On a Feb-2026-era backup + this created 1577 subscriptions + 3429 payments with **0 errors**. +- The encryption migration **re-encrypts** the now-plaintext secrets with your local + key — expected and harmless. +- The DNS data migration imports OVH routers into `dns_server` rows and wires + reverse DNS onto the OVH-routed ranges. Its record backfill and `arp_ref_fixer` + make **real router/DNS API calls** — these fail fast on the inert URLs from step + 2d (or return `403` with dummy tokens) and are logged best-effort. +- The worker reconciles VMs. Because the backup's VMs are long-expired, it attempts + **expired-VM cleanup** (removing DNS/ARP) — another reason step 2d is mandatory. + `Cant spawn VM's in read-only mode` errors are expected and harmless. +- `Failed to get VMxxx state: VM not found` warnings occur for VMs not seeded into + the Dummy host state file (step 3) — harmless. + +> Encryption note: data migrations that read encrypted columns require step 2c (or +> the real key). The vm_payment → subscription backfill decodes `external_data`; > blanking it to `''` keeps startup moving for a test. ## Step 6 — Clean up From 515ba008a45f54d699aa8f71b066e0d882fa0540 Mon Sep 17 00:00:00 2001 From: v0l Date: Fri, 10 Jul 2026 14:08:58 +0100 Subject: [PATCH 4/4] test(e2e): fix swapped prefix sizes in ip_space create test test_admin_create_ip_space_persists_company_id sent min_prefix_size=24 / max_prefix_size=32, which the pre-existing to_available_ip_space() validation rejects (requires min >= max; for a /24 RIPE block: min=/32, max=/24, matching dev_setup.sql). The endpoint returned 500 "min_prefix_size must be greater than or equal to max_prefix_size", so the test asserted != 200 and failed. This was a latent failure introduced when the test was added (135258c), unrelated to the DNS changes on this branch; reproduced by POSTing to a locally-run admin server against a freshly-migrated DB (503 -> 200 after the swap). --- lnvps_e2e/src/admin_api.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lnvps_e2e/src/admin_api.rs b/lnvps_e2e/src/admin_api.rs index 7a3f4036..ea354b47 100644 --- a/lnvps_e2e/src/admin_api.rs +++ b/lnvps_e2e/src/admin_api.rs @@ -986,11 +986,13 @@ mod tests { .and_then(|c| c["id"].as_u64()) .unwrap_or(1); + // min_prefix_size is the smallest subdivision (largest prefix number) and + // must be >= max_prefix_size; for a /24 RIPE block that is min=/32, max=/24. let create_body = serde_json::json!({ "company_id": company_id, "cidr": "203.0.113.0/24", - "min_prefix_size": 24, - "max_prefix_size": 32, + "min_prefix_size": 32, + "max_prefix_size": 24, "registry": 1 }); let resp = client