diff --git a/Cargo.lock b/Cargo.lock index b2b383a..7a392db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,7 @@ version = "0.1.0" dependencies = [ "anyhow", "assert_cmd", + "base64", "chrono", "clap", "crossterm", @@ -190,6 +191,7 @@ dependencies = [ "mockito", "openssl", "predicates", + "rand", "ratatui", "reqwest", "serde", @@ -198,6 +200,7 @@ dependencies = [ "tokio", "toml", "uuid", + "x25519-dalek", ] [[package]] @@ -385,6 +388,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -411,6 +423,32 @@ dependencies = [ "winapi", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "difflib" version = "0.4.0" @@ -511,6 +549,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "float-cmp" version = "0.10.0" @@ -1652,6 +1696,15 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1756,6 +1809,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.210" @@ -1916,6 +1975,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.58" @@ -2607,6 +2672,18 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + [[package]] name = "yoke" version = "0.7.5" @@ -2673,6 +2750,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerovec" version = "0.10.4" diff --git a/Cargo.toml b/Cargo.toml index acb8611..fbe1ace 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,10 @@ toml = "0.8.8" # Networking & Security uuid = { version = "1.4", features = ["v4", "serde"] } +base64 = "0.21" +rand = "0.8" +x25519-dalek = { version = "2", features = ["static_secrets"] } + [dev-dependencies] assert_cmd = "2.0" mockito = "1.2" diff --git a/src/api/vyos.rs b/src/api/vyos.rs index 2e4d2de..c3c26e5 100644 --- a/src/api/vyos.rs +++ b/src/api/vyos.rs @@ -193,6 +193,445 @@ impl VyOSClient { pub async fn get_system_info(&mut self) -> Result { self.api_call("system", "GET", None).await } + + // ------------------------------------------------------------------------- + // VyOS configure API helpers + // ------------------------------------------------------------------------- + + /// Send a single `set` operation to the VyOS configure API. + /// + /// `path` is a slice of path components, e.g. + /// `["interfaces", "wireguard", "wg0", "address"]`. + /// `value` is the optional leaf value string. + pub async fn configure_set( + &mut self, + path: &[&str], + value: Option<&str>, + ) -> Result<()> { + let mut body = serde_json::json!({ + "op": "set", + "path": path + }); + if let Some(v) = value { + body["value"] = serde_json::Value::String(v.to_string()); + } + self.configure_op(body).await?; + Ok(()) + } + + /// Send a single `delete` operation to the VyOS configure API. + pub async fn configure_delete(&mut self, path: &[&str]) -> Result<()> { + let body = serde_json::json!({ + "op": "delete", + "path": path + }); + self.configure_op(body).await?; + Ok(()) + } + + /// Send a raw operation body to `POST /configure`. + async fn configure_op(&mut self, body: serde_json::Value) -> Result { + self.init_http_client()?; + let api_key = self.config.api_key.clone() + .ok_or_else(|| anyhow!("API key is required for HTTP API operations"))?; + let client = self.http_client.as_ref().unwrap(); + let url = format!("https://{}:{}/configure", self.config.host, self.config.api_port); + debug!("VyOS configure op: POST {} {:?}", url, body); + let response = client + .post(&url) + .header("X-API-Key", api_key) + .json(&body) + .send() + .await + .context("Failed to execute VyOS configure request")?; + let status = response.status(); + let resp_body = response.json::() + .await + .context("Failed to parse VyOS configure response")?; + if status.is_success() { + Ok(resp_body) + } else { + Err(anyhow!("VyOS configure failed: {} – {}", status, resp_body)) + } + } + + /// Query an operational-show endpoint (`GET /show/...`). + async fn show_op(&mut self, path: &str) -> Result { + self.init_http_client()?; + let api_key = self.config.api_key.clone() + .ok_or_else(|| anyhow!("API key is required for HTTP API operations"))?; + let client = self.http_client.as_ref().unwrap(); + let url = format!("https://{}:{}/show/{}", self.config.host, self.config.api_port, path); + debug!("VyOS show: GET {}", url); + let response = client + .get(&url) + .header("X-API-Key", api_key) + .send() + .await + .context("Failed to execute VyOS show request")?; + let status = response.status(); + let body = response.json::() + .await + .context("Failed to parse VyOS show response")?; + if status.is_success() { + Ok(body) + } else { + Err(anyhow!("VyOS show failed: {} – {}", status, body)) + } + } + + // ------------------------------------------------------------------------- + // WireGuard configuration + // ------------------------------------------------------------------------- + + /// Configure a WireGuard interface on VyOS. + /// + /// `interface` – e.g. `"wg0"` + /// `address` – CIDR, e.g. `"172.27.1.1/32"` + /// `private_key` – base64-encoded WireGuard private key + /// `port` – UDP listen port + /// `description` – optional human-readable description + pub async fn configure_wireguard_interface( + &mut self, + interface: &str, + address: &str, + private_key: &str, + port: u16, + description: Option<&str>, + ) -> Result<()> { + self.configure_set( + &["interfaces", "wireguard", interface, "address"], + Some(address), + ).await?; + self.configure_set( + &["interfaces", "wireguard", interface, "private-key"], + Some(private_key), + ).await?; + self.configure_set( + &["interfaces", "wireguard", interface, "port"], + Some(&port.to_string()), + ).await?; + if let Some(desc) = description { + self.configure_set( + &["interfaces", "wireguard", interface, "description"], + Some(desc), + ).await?; + } + info!("Configured WireGuard interface {} on {}", interface, self.config.host); + Ok(()) + } + + /// Add a WireGuard peer to an existing interface. + /// + /// `interface` – e.g. `"wg0"` + /// `peer_name` – peer identifier, e.g. `"PE2"` + /// `public_key` – base64-encoded peer public key + /// `endpoint` – optional `"ip:port"` string + /// `allowed_ips` – list of CIDRs the peer is allowed to send + /// `keepalive` – optional persistent keepalive in seconds + pub async fn configure_wireguard_peer( + &mut self, + interface: &str, + peer_name: &str, + public_key: &str, + endpoint: Option<&str>, + allowed_ips: &[&str], + keepalive: Option, + ) -> Result<()> { + self.configure_set( + &["interfaces", "wireguard", interface, "peer", peer_name, "public-key"], + Some(public_key), + ).await?; + for cidr in allowed_ips { + self.configure_set( + &["interfaces", "wireguard", interface, "peer", peer_name, "allowed-ips"], + Some(cidr), + ).await?; + } + if let Some(ep) = endpoint { + self.configure_set( + &["interfaces", "wireguard", interface, "peer", peer_name, "address"], + Some(ep), + ).await?; + } + if let Some(ka) = keepalive { + self.configure_set( + &["interfaces", "wireguard", interface, "peer", peer_name, "persistent-keepalive"], + Some(&ka.to_string()), + ).await?; + } + info!("Configured WireGuard peer {} on {}/{}", peer_name, self.config.host, interface); + Ok(()) + } + + // ------------------------------------------------------------------------- + // VXLAN configuration + // ------------------------------------------------------------------------- + + /// Configure a VXLAN interface on VyOS. + /// + /// `vni` – VXLAN Network Identifier + /// `source_address` – local VTEP IP + /// `mtu` – MTU (typically 9000) + /// `vrf` – optional VRF to attach the VXLAN interface to + /// `remote` – optional remote VTEP IP (unicast mode) + pub async fn configure_vxlan( + &mut self, + vni: u32, + source_address: &str, + mtu: u16, + vrf: Option<&str>, + remote: Option<&str>, + ) -> Result<()> { + let iface = format!("vxlan{}", vni); + self.configure_set( + &["interfaces", "vxlan", &iface, "vni"], + Some(&vni.to_string()), + ).await?; + self.configure_set( + &["interfaces", "vxlan", &iface, "source-address"], + Some(source_address), + ).await?; + self.configure_set( + &["interfaces", "vxlan", &iface, "mtu"], + Some(&mtu.to_string()), + ).await?; + if let Some(remote_ip) = remote { + self.configure_set( + &["interfaces", "vxlan", &iface, "remote"], + Some(remote_ip), + ).await?; + } + if let Some(vrf_name) = vrf { + self.configure_set( + &["interfaces", "vxlan", &iface, "vrf"], + Some(vrf_name), + ).await?; + } + info!("Configured VXLAN {} (VNI {}) on {}", iface, vni, self.config.host); + Ok(()) + } + + // ------------------------------------------------------------------------- + // BGP / EVPN configuration + // ------------------------------------------------------------------------- + + /// Set the BGP system AS number and router-ID. + pub async fn configure_bgp_system( + &mut self, + system_as: u32, + router_id: &str, + ) -> Result<()> { + self.configure_set( + &["protocols", "bgp", "system-as"], + Some(&system_as.to_string()), + ).await?; + self.configure_set( + &["protocols", "bgp", "parameters", "router-id"], + Some(router_id), + ).await?; + info!("Configured BGP AS {} router-id {} on {}", system_as, router_id, self.config.host); + Ok(()) + } + + /// Add a BGP EVPN peer (iBGP neighbor with l2vpn-evpn address-family). + pub async fn configure_bgp_evpn_peer( + &mut self, + peer_ip: &str, + remote_as: u32, + update_source: &str, + ) -> Result<()> { + self.configure_set( + &["protocols", "bgp", "neighbor", peer_ip, "remote-as"], + Some(&remote_as.to_string()), + ).await?; + self.configure_set( + &["protocols", "bgp", "neighbor", peer_ip, "update-source"], + Some(update_source), + ).await?; + self.configure_set( + &["protocols", "bgp", "neighbor", peer_ip, "address-family", "l2vpn-evpn", "activate"], + None, + ).await?; + info!("Configured BGP EVPN peer {} on {}", peer_ip, self.config.host); + Ok(()) + } + + /// Enable BGP EVPN VNI advertisement (`advertise-all-vni`). + pub async fn enable_bgp_evpn(&mut self) -> Result<()> { + self.configure_set( + &["protocols", "bgp", "address-family", "l2vpn-evpn", "advertise-all-vni"], + None, + ).await?; + info!("Enabled BGP EVPN advertise-all-vni on {}", self.config.host); + Ok(()) + } + + // ------------------------------------------------------------------------- + // L3VPN / VRF configuration + // ------------------------------------------------------------------------- + + /// Create an L3VPN VRF and set its BGP route-targets. + /// + /// `vrf_name` – e.g. `"tenant-1"` + /// `table_id` – kernel routing table ID, e.g. `1001` + /// `route_target_export` – e.g. `"65000:1001"` + /// `route_target_import` – e.g. `"65000:1001"` + pub async fn configure_vrf( + &mut self, + vrf_name: &str, + table_id: u32, + route_target_export: &str, + route_target_import: &str, + ) -> Result<()> { + self.configure_set( + &["vrf", "name", vrf_name, "table"], + Some(&table_id.to_string()), + ).await?; + self.configure_set( + &["vrf", "name", vrf_name, "protocols", "bgp", "address-family", + "ipv4-unicast", "route-target", "vpn", "export"], + Some(route_target_export), + ).await?; + self.configure_set( + &["vrf", "name", vrf_name, "protocols", "bgp", "address-family", + "ipv4-unicast", "route-target", "vpn", "import"], + Some(route_target_import), + ).await?; + info!("Configured VRF {} (table {}) on {}", vrf_name, table_id, self.config.host); + Ok(()) + } + + // ------------------------------------------------------------------------- + // VRRP configuration + // ------------------------------------------------------------------------- + + /// Configure a VRRP group for HA gateway redundancy. + /// + /// `group_id` – VRRP group name + /// `interface` – network interface to run VRRP on + /// `virtual_ip` – shared virtual IP address (CIDR or plain IP) + /// `vrid` – VRRP virtual router ID (1–255) + /// `priority` – router priority (1–254; higher = master preference) + pub async fn configure_vrrp( + &mut self, + group_id: &str, + interface: &str, + virtual_ip: &str, + vrid: u8, + priority: u8, + ) -> Result<()> { + self.configure_set( + &["high-availability", "vrrp", "group", group_id, "interface"], + Some(interface), + ).await?; + self.configure_set( + &["high-availability", "vrrp", "group", group_id, "virtual-address"], + Some(virtual_ip), + ).await?; + self.configure_set( + &["high-availability", "vrrp", "group", group_id, "vrid"], + Some(&vrid.to_string()), + ).await?; + self.configure_set( + &["high-availability", "vrrp", "group", group_id, "priority"], + Some(&priority.to_string()), + ).await?; + info!( + "Configured VRRP group {} on {}/{} (vrid {}, priority {})", + group_id, self.config.host, interface, vrid, priority + ); + Ok(()) + } + + // ------------------------------------------------------------------------- + // Tenant provisioning + // ------------------------------------------------------------------------- + + /// Provision all networking for a single tenant on this VyOS router. + /// + /// This is a convenience wrapper that calls the individual configure + /// methods in the correct order and commits the session at the end. + /// + /// Returns the VXLAN VNI used for this tenant. + pub async fn provision_tenant( + &mut self, + tenant: &crate::models::tenant::Tenant, + ) -> Result { + info!("Provisioning tenant '{}' (id={}) on {}", tenant.name, tenant.tenant_id, self.config.host); + + // 1. Create VRF + self.configure_vrf( + &tenant.vrf.name, + tenant.vrf.table_id, + &tenant.vrf.route_target_export, + &tenant.vrf.route_target_import, + ).await?; + + // 2. Configure VXLAN + self.configure_vxlan( + tenant.vxlan.vni, + &tenant.vxlan.source_address, + tenant.vxlan.mtu, + Some(&tenant.vrf.name), + tenant.vxlan.remote.as_deref(), + ).await?; + + // 3. Configure WireGuard interface for the tenant + let wg_iface = format!("wg{}", tenant.wireguard.interface_index); + self.configure_wireguard_interface( + &wg_iface, + &tenant.wireguard.address, + &tenant.wireguard.private_key, + tenant.wireguard.port, + Some(&format!("Tenant {} WireGuard", tenant.name)), + ).await?; + + // 4. Optional VRRP + if let Some(vrrp) = &tenant.vrrp { + self.configure_vrrp( + &vrrp.group_id, + &vrrp.interface, + &vrrp.virtual_ip, + vrrp.vrid, + vrrp.priority, + ).await?; + } + + // 5. Commit and save + self.commit().await?; + self.save().await?; + + info!( + "Tenant '{}' provisioned successfully on {} (VNI {})", + tenant.name, self.config.host, tenant.vxlan.vni + ); + Ok(tenant.vxlan.vni) + } + + // ------------------------------------------------------------------------- + // Monitoring / operational show + // ------------------------------------------------------------------------- + + /// Get BGP summary (all address families). + pub async fn get_bgp_summary(&mut self) -> Result { + self.show_op("bgp/summary/json").await + } + + /// Get VXLAN interface status. + pub async fn get_vxlan_status(&mut self) -> Result { + self.show_op("interfaces/vxlan/json").await + } + + /// Get the routing table for all VRFs. + pub async fn get_vrf_routes(&mut self) -> Result { + self.show_op("ip/route/vrf/all/json").await + } + + /// Get WireGuard interface status. + pub async fn get_wireguard_status(&mut self) -> Result { + self.show_op("interfaces/wireguard/json").await + } } impl Provider for VyOSClient { diff --git a/src/lib.rs b/src/lib.rs index c42b758..10182fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod api; pub mod models; pub mod config; pub mod services; +pub mod network; // Re-export commonly used types pub use app::AppResult; diff --git a/src/main.rs b/src/main.rs index 8c8517e..fe359be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ pub mod api; pub mod models; pub mod config; pub mod services; +pub mod network; #[derive(Parser)] #[command(author, version, about, long_about = None)] @@ -55,6 +56,16 @@ enum Commands { #[command(subcommand)] action: NetworksCommands, }, + /// Manage tenants (E2E encrypted multi-tenant networking) + Tenants { + #[command(subcommand)] + action: TenantsCommands, + }, + /// WireGuard key utilities + Wireguard { + #[command(subcommand)] + action: WireguardCommands, + }, /// Test connectivity to a VyOS router TestVyOS { /// VyOS host to connect to @@ -183,6 +194,65 @@ enum NetworksCommands { }, } +#[derive(Subcommand)] +enum TenantsCommands { + /// List all tenants + List, + /// Create a new tenant with auto-generated WireGuard keys + Create { + /// Tenant name + name: String, + /// BGP AS number for the fabric (default: 65000) + #[arg(long, default_value = "65000")] + bgp_as: u32, + /// Source address (VTEP / loopback IP) used for VXLAN + #[arg(long)] + source_address: Option, + }, + /// Show details for a tenant + Show { + /// Tenant UUID + id: String, + }, + /// Delete a tenant + Delete { + /// Tenant UUID + id: String, + }, +} + +#[derive(Subcommand)] +enum WireguardCommands { + /// Generate a new WireGuard key pair + GenerateKeys, + /// Derive the public key from a base64 private key + PublicKey { + /// Base64-encoded WireGuard private key + private_key: String, + }, + /// Generate a client configuration file + ClientConfig { + /// Client private key (base64) + #[arg(long)] + private_key: String, + /// Client IP address (CIDR, e.g. 100.64.1.2/24) + #[arg(long)] + address: String, + /// Server public key (base64) + #[arg(long)] + server_public_key: String, + /// Server endpoint (host:port) + #[arg(long)] + server_endpoint: String, + /// Allowed IPs (comma-separated CIDRs, default: 0.0.0.0/0) + #[arg(long, default_value = "0.0.0.0/0")] + allowed_ips: String, + /// Persistent keepalive in seconds (default: 25) + #[arg(long, default_value = "25")] + keepalive: u16, + }, +} + fn cli_handler(cli: Cli) -> AppResult<()> { match cli.command { Some(Commands::Init { name }) => { @@ -290,6 +360,69 @@ fn cli_handler(cli: Cli) -> AppResult<()> { } } } + Some(Commands::Tenants { action }) => { + match action { + TenantsCommands::List => { + println!("Listing tenants..."); + println!("ID\t\t\t\t\tNAME\tTENANT_ID\tSTATUS\tVRF\t\tVXLAN VNI"); + println!("(No tenants provisioned yet – use 'bbctl tenants create' to add one)"); + } + TenantsCommands::Create { name, bgp_as, source_address } => { + // Generate keys and print the result + let kp = crate::network::generate_wireguard_keypair() + .map_err(|e| format!("Failed to generate WireGuard keys: {}", e))?; + println!("Creating tenant '{}'", name); + println!(" BGP AS : {}", bgp_as); + println!(" Source addr : {}", source_address.as_deref().unwrap_or("(use provider default)")); + println!(" WG public key: {}", kp.public_key); + println!(" WG private key: {} (store securely!)", kp.private_key); + println!(); + println!("Tenant '{}' created. Use 'bbctl tenants show ' to inspect it.", name); + println!("To push configuration to a VyOS router, use the API or TUI interface."); + } + TenantsCommands::Show { id } => { + println!("Tenant details for '{}':", id); + println!("Use the TUI dashboard (run 'bbctl' without arguments) to view tenant details."); + } + TenantsCommands::Delete { id } => { + println!("Deleting tenant '{}'", id); + } + } + } + Some(Commands::Wireguard { action }) => { + match action { + WireguardCommands::GenerateKeys => { + let kp = crate::network::generate_wireguard_keypair() + .map_err(|e| format!("Failed to generate WireGuard key pair: {}", e))?; + println!("Private key: {}", kp.private_key); + println!("Public key: {}", kp.public_key); + } + WireguardCommands::PublicKey { private_key } => { + let public_key = crate::network::derive_public_key(&private_key) + .map_err(|e| format!("Failed to derive public key: {}", e))?; + println!("{}", public_key); + } + WireguardCommands::ClientConfig { + private_key, + address, + server_public_key, + server_endpoint, + allowed_ips, + keepalive, + } => { + let allowed: Vec<&str> = allowed_ips.split(',').map(|s| s.trim()).collect(); + let config = crate::network::generate_client_config( + &private_key, + &address, + &server_public_key, + &server_endpoint, + &allowed, + keepalive, + ); + print!("{}", config); + } + } + } Some(Commands::TestVyOS { host, port, username, .. }) => { // This would block, so we need to call it outside the CLI handler // Will be implemented in main() diff --git a/src/models/mod.rs b/src/models/mod.rs index ac9f5a6..30b8778 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod instance; pub mod volume; pub mod network; -pub mod provider; \ No newline at end of file +pub mod provider; +pub mod tenant; \ No newline at end of file diff --git a/src/models/tenant.rs b/src/models/tenant.rs new file mode 100644 index 0000000..b6d207f --- /dev/null +++ b/src/models/tenant.rs @@ -0,0 +1,196 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +/// Tenant status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TenantStatus { + Active, + Creating, + Deleting, + Error, + Suspended, +} + +impl std::fmt::Display for TenantStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TenantStatus::Active => write!(f, "active"), + TenantStatus::Creating => write!(f, "creating"), + TenantStatus::Deleting => write!(f, "deleting"), + TenantStatus::Error => write!(f, "error"), + TenantStatus::Suspended => write!(f, "suspended"), + } + } +} + +/// VRF (Virtual Routing and Forwarding) configuration for a tenant. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VrfConfig { + /// VRF name (e.g. "customer-1") + pub name: String, + /// VRF route-distinguisher table ID (e.g. 1000) + pub table_id: u32, + /// BGP route-target for export (e.g. "65000:1000") + pub route_target_export: String, + /// BGP route-target for import (e.g. "65000:1000") + pub route_target_import: String, +} + +/// VXLAN tunnel configuration for a tenant. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VxlanConfig { + /// VXLAN Network Identifier + pub vni: u32, + /// Source VTEP address + pub source_address: String, + /// MTU (typically 9000 for jumbo frames) + pub mtu: u16, + /// Remote VTEP address (optional for multicast/EVPN mode) + pub remote: Option, +} + +/// WireGuard interface configuration for a tenant. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TenantWireguardConfig { + /// WireGuard interface index (appended to "wg", e.g. 1 → "wg1") + pub interface_index: u32, + /// Tenant WireGuard address (CIDR, e.g. "100.64.1.1/24") + pub address: String, + /// Pre-generated public key (base64) + pub public_key: String, + /// Pre-generated private key (base64) – stored securely + pub private_key: String, + /// UDP listen port + pub port: u16, +} + +/// VRRP configuration for high-availability gateway. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VrrpConfig { + /// VRRP group identifier + pub group_id: String, + /// Interface to run VRRP on + pub interface: String, + /// Virtual IP address + pub virtual_ip: String, + /// VRRP virtual router ID (1-255) + pub vrid: u8, + /// Priority (higher = preferred master, 1-254) + pub priority: u8, +} + +/// A fully provisioned tenant with all networking configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tenant { + /// Unique tenant identifier + pub id: Uuid, + /// Numeric tenant ID used for addressing (e.g. VXLAN VNI offset) + pub tenant_id: u32, + /// Human-readable tenant name + pub name: String, + /// Current status + pub status: TenantStatus, + /// L3VPN VRF configuration + pub vrf: VrfConfig, + /// VXLAN data-plane configuration + pub vxlan: VxlanConfig, + /// WireGuard management-plane configuration + pub wireguard: TenantWireguardConfig, + /// Optional VRRP HA gateway configuration + pub vrrp: Option, + /// BGP AS number used across the fabric + pub bgp_as: u32, + /// Tenant network CIDR (e.g. "100.65.1.0/24") + pub network_cidr: String, + /// Additional metadata + pub tags: HashMap, + /// Creation timestamp + pub created_at: DateTime, + /// Last update timestamp + pub updated_at: DateTime, +} + +impl Tenant { + /// Create a new tenant with default networking parameters derived from the + /// numeric `tenant_id`. + /// + /// Addressing conventions: + /// - WireGuard: `100.64..1/24` + /// - VXLAN VNI: `10000 + tenant_id` + /// - VRF table: `1000 + tenant_id` + /// - BGP route-target: `:<1000 + tenant_id>` + pub fn new( + name: String, + tenant_id: u32, + bgp_as: u32, + source_address: String, + wg_public_key: String, + wg_private_key: String, + ) -> Self { + let now = Utc::now(); + let vni = 10000 + tenant_id; + let table_id = 1000 + tenant_id; + let rt = format!("{}:{}", bgp_as, table_id); + let wg_address = format!("100.64.{}.1/24", tenant_id); + let network_cidr = format!("100.65.{}.0/24", tenant_id); + + Self { + id: Uuid::new_v4(), + tenant_id, + name: name.clone(), + status: TenantStatus::Creating, + vrf: VrfConfig { + name: format!("tenant-{}", tenant_id), + table_id, + route_target_export: rt.clone(), + route_target_import: rt, + }, + vxlan: VxlanConfig { + vni, + source_address, + mtu: 9000, + remote: None, + }, + wireguard: TenantWireguardConfig { + interface_index: tenant_id, + address: wg_address, + public_key: wg_public_key, + private_key: wg_private_key, + // Keep port in the valid range (51820–52819) using modulo 1000 + port: 51820 + (tenant_id as u16 % 1000), + }, + vrrp: None, + bgp_as, + network_cidr, + tags: HashMap::new(), + created_at: now, + updated_at: now, + } + } + + /// Mark the tenant as active (provisioning complete). + pub fn set_active(&mut self) { + self.status = TenantStatus::Active; + self.updated_at = Utc::now(); + } + + /// Mark the tenant as error state. + pub fn set_error(&mut self) { + self.status = TenantStatus::Error; + self.updated_at = Utc::now(); + } + + /// Attach a VRRP HA configuration to the tenant. + pub fn set_vrrp(&mut self, vrrp: VrrpConfig) { + self.vrrp = Some(vrrp); + self.updated_at = Utc::now(); + } + + /// Add a metadata tag. + pub fn add_tag(&mut self, key: String, value: String) { + self.tags.insert(key, value); + self.updated_at = Utc::now(); + } +} diff --git a/src/network.rs b/src/network.rs index fea395e..2b21dc0 100644 --- a/src/network.rs +++ b/src/network.rs @@ -1,22 +1,13 @@ use anyhow::{anyhow, Context, Result}; -use boringtun::noise::{Tunn, TunnResult}; -use log::{debug, info, warn}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use log::debug; +use rand::RngCore; use serde::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - net::{IpAddr, Ipv4Addr, SocketAddr}, - time::Duration, -}; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::UdpSocket, - sync::mpsc, - time::sleep, -}; +use x25519_dalek::{PublicKey, StaticSecret}; const WIREGUARD_PORT: u16 = 51820; -const MAX_PACKET_SIZE: usize = 1500; +/// A WireGuard peer configuration entry. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WireguardPeer { pub public_key: String, @@ -25,6 +16,7 @@ pub struct WireguardPeer { pub persistent_keepalive: u16, } +/// A WireGuard interface configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WireguardConfig { pub private_key: String, @@ -33,222 +25,121 @@ pub struct WireguardConfig { pub peers: Vec, } -#[derive(Debug, Clone)] -pub struct WireguardTunnel { - config: WireguardConfig, - socket: UdpSocket, - tunnel: Tunn, - peer_map: HashMap, +/// A generated WireGuard key pair (base64-encoded). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireguardKeypair { + pub private_key: String, + pub public_key: String, } -impl WireguardTunnel { - pub async fn new(config: WireguardConfig) -> Result { - // Parse private key - let private_key = base64::decode(&config.private_key) - .context("Failed to decode private key")?; - if private_key.len() != 32 { - return Err(anyhow!("Invalid private key length")); - } - - let mut private_key_bytes = [0u8; 32]; - private_key_bytes.copy_from_slice(&private_key); - - // Create tunnel - let tunnel = Tunn::new( - private_key_bytes, - None, - None, - None, - 0, - None - ).context("Failed to create WireGuard tunnel")?; - - // Bind to UDP socket - let socket = UdpSocket::bind(format!("0.0.0.0:{}", config.port.unwrap_or(WIREGUARD_PORT))) - .await - .context("Failed to bind WireGuard socket")?; - - // Create peer map - let mut peer_map = HashMap::new(); - for peer in &config.peers { - let endpoint: SocketAddr = peer.endpoint.parse() - .context("Failed to parse peer endpoint")?; - - // Parse peer public key - let public_key = base64::decode(&peer.public_key) - .context("Failed to decode peer public key")?; - if public_key.len() != 32 { - return Err(anyhow!("Invalid peer public key length")); - } - - peer_map.insert(peer.public_key.clone(), endpoint); - } - - Ok(Self { - config, - socket, - tunnel, - peer_map, - }) - } - - // Start the WireGuard tunnel - pub async fn start(&mut self) -> Result<()> { - info!("Starting WireGuard tunnel..."); - - // Create channels for communication - let (inbound_tx, mut inbound_rx) = mpsc::channel::<(Vec, SocketAddr)>(1000); - let (outbound_tx, mut outbound_rx) = mpsc::channel::<(Vec, SocketAddr)>(1000); - - // Clone socket for the receiver task - let recv_socket = self.socket.clone(); - - // Spawn receiver task - tokio::spawn(async move { - let mut buf = vec![0u8; MAX_PACKET_SIZE]; - loop { - match recv_socket.recv_from(&mut buf).await { - Ok((n, addr)) => { - let packet = buf[..n].to_vec(); - if let Err(e) = inbound_tx.send((packet, addr)).await { - warn!("Failed to send packet to processor: {}", e); - } - } - Err(e) => { - warn!("Error receiving from socket: {}", e); - sleep(Duration::from_millis(100)).await; - } - } - } - }); - - // Clone socket for the sender task - let send_socket = self.socket.clone(); - - // Spawn sender task - tokio::spawn(async move { - while let Some((packet, addr)) = outbound_rx.recv().await { - if let Err(e) = send_socket.send_to(&packet, addr).await { - warn!("Error sending to {}: {}", addr, e); - } - } - }); - - // Main processing loop - loop { - tokio::select! { - Some((packet, addr)) = inbound_rx.recv() => { - debug!("Received {} bytes from {}", packet.len(), addr); - - // Process the packet through WireGuard - match self.tunnel.decapsulate(None, &packet) { - TunnResult::WriteToNetwork(packet) => { - debug!("Sending {} bytes to {}", packet.len(), addr); - if let Err(e) = outbound_tx.send((packet.to_vec(), addr)).await { - warn!("Failed to send packet: {}", e); - } - } - TunnResult::WriteToTunnelV4(packet, _) => { - debug!("Received IPv4 packet from tunnel: {} bytes", packet.len()); - // Here you would forward the packet to the TUN device - // or your application logic - } - TunnResult::WriteToTunnelV6(packet, _) => { - debug!("Received IPv6 packet from tunnel: {} bytes", packet.len()); - // Here you would forward the packet to the TUN device - // or your application logic - } - TunnResult::HandshakeComplete => { - info!("WireGuard handshake completed with {}", addr); - } - TunnResult::Done => { - // Nothing to do - } - } - } - else => { - // All channels closed - break; - } - } - } - - Ok(()) - } - - // Generate WireGuard configuration - pub fn generate_client_config(&self, client_private_key: &str, client_ip: &str) -> Result { - // Find server public key (first peer) - let server_peer = self.config.peers.first() - .ok_or_else(|| anyhow!("No server peer found"))?; - - // Generate client config - let config = format!( - "[Interface]\n\ - PrivateKey = {}\n\ - Address = {}\n\ - DNS = 1.1.1.1\n\ - \n\ - [Peer]\n\ - PublicKey = {}\n\ - AllowedIPs = {}\n\ - Endpoint = {}\n\ - PersistentKeepalive = {}\n", - client_private_key, - client_ip, - server_peer.public_key, - server_peer.allowed_ips.join(", "), - server_peer.endpoint, - server_peer.persistent_keepalive - ); - - Ok(config) +/// Generate a WireGuard X25519 key pair. +/// +/// Returns a `WireguardKeypair` where both keys are standard base64-encoded strings +/// compatible with the `wg` CLI tool and VyOS configuration. +pub fn generate_wireguard_keypair() -> Result { + let mut rng = rand::thread_rng(); + let mut secret_bytes = [0u8; 32]; + rng.fill_bytes(&mut secret_bytes); + + let secret = StaticSecret::from(secret_bytes); + let public = PublicKey::from(&secret); + + let private_key = BASE64.encode(secret.as_bytes()); + let public_key = BASE64.encode(public.as_bytes()); + + debug!("Generated WireGuard keypair (public key: {})", public_key); + Ok(WireguardKeypair { + private_key, + public_key, + }) +} + +/// Derive the public key from a base64-encoded WireGuard private key. +pub fn derive_public_key(private_key_b64: &str) -> Result { + let private_bytes = BASE64 + .decode(private_key_b64) + .context("Failed to base64-decode private key")?; + if private_bytes.len() != 32 { + return Err(anyhow!( + "Invalid private key length: expected 32 bytes, got {}", + private_bytes.len() + )); } + let mut key_array = [0u8; 32]; + key_array.copy_from_slice(&private_bytes); + let secret = StaticSecret::from(key_array); + let public = PublicKey::from(&secret); + Ok(BASE64.encode(public.as_bytes())) } -// Utility function to generate WireGuard keypair -pub fn generate_wireguard_keypair() -> Result<(String, String)> { - // Generate a random private key - let mut private_key = [0u8; 32]; - getrandom::getrandom(&mut private_key) - .context("Failed to generate random private key")?; - - // Derive public key from private key - let public_key = x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(private_key)); - - // Encode keys to base64 - let private_key_base64 = base64::encode(private_key); - let public_key_base64 = base64::encode(public_key.as_bytes()); - - Ok((private_key_base64, public_key_base64)) +/// Generate a WireGuard client configuration file string. +pub fn generate_client_config( + client_private_key: &str, + client_ip: &str, + server_public_key: &str, + server_endpoint: &str, + allowed_ips: &[&str], + persistent_keepalive: u16, +) -> String { + format!( + "[Interface]\n\ + PrivateKey = {private_key}\n\ + Address = {address}\n\ + DNS = 1.1.1.1\n\ + \n\ + [Peer]\n\ + PublicKey = {pub_key}\n\ + AllowedIPs = {allowed}\n\ + Endpoint = {endpoint}\n\ + PersistentKeepalive = {keepalive}\n", + private_key = client_private_key, + address = client_ip, + pub_key = server_public_key, + allowed = allowed_ips.join(", "), + endpoint = server_endpoint, + keepalive = persistent_keepalive, + ) } -// Helper function to parse WireGuard configuration from file +/// Parse a WireGuard configuration file into a [`WireguardConfig`]. pub async fn parse_wireguard_config(config_path: &str) -> Result { let content = tokio::fs::read_to_string(config_path) .await .context("Failed to read WireGuard config file")?; - + let mut private_key = String::new(); let mut address = String::new(); let mut port = WIREGUARD_PORT; - let mut peers = Vec::new(); - - let mut current_section = None; - let mut current_peer = None; - + let mut peers: Vec = Vec::new(); + + let mut current_section: Option = None; + let mut current_peer: Option = None; + for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { + // Flush a completed peer when we hit a blank line + if line.is_empty() { + if current_section.as_deref() == Some("Peer") { + if let Some(peer) = current_peer.take() { + if !peer.public_key.is_empty() && !peer.endpoint.is_empty() { + peers.push(peer); + } + } + } + } continue; } - + if line.starts_with('[') && line.ends_with(']') { - let section = line[1..line.len()-1].to_string(); - if section == "Interface" { - current_section = Some("Interface".to_string()); - } else if section == "Peer" { - current_section = Some("Peer".to_string()); + // Flush peer on new section header + if let Some(peer) = current_peer.take() { + if !peer.public_key.is_empty() && !peer.endpoint.is_empty() { + peers.push(peer); + } + } + let section = line[1..line.len() - 1].to_string(); + if section == "Peer" { current_peer = Some(WireguardPeer { public_key: String::new(), endpoint: String::new(), @@ -256,77 +147,70 @@ pub async fn parse_wireguard_config(config_path: &str) -> Result private_key = value, - "Address" => address = value.split('/').next().unwrap_or(&value).to_string(), - "ListenPort" => { - if let Ok(p) = value.parse::() { - port = p; - } + + if let Some(idx) = line.find('=') { + let key = line[..idx].trim(); + let value = line[idx + 1..].trim().to_string(); + + match current_section.as_deref() { + Some("Interface") => match key { + "PrivateKey" => private_key = value, + "Address" => { + address = value.split('/').next().unwrap_or(&value).to_string() + } + "ListenPort" => { + if let Ok(p) = value.parse::() { + port = p; } - _ => {} } - } else if section == "Peer" { + _ => {} + }, + Some("Peer") => { if let Some(peer) = &mut current_peer { - match key.as_str() { + match key { "PublicKey" => peer.public_key = value, "Endpoint" => peer.endpoint = value, "AllowedIPs" => { - peer.allowed_ips = value.split(',') + peer.allowed_ips = value + .split(',') .map(|s| s.trim().to_string()) .collect(); } "PersistentKeepalive" => { - if let Ok(keep) = value.parse::() { - peer.persistent_keepalive = keep; + if let Ok(k) = value.parse::() { + peer.persistent_keepalive = k; } } _ => {} } } } + _ => {} } } - - // If we have a complete peer, add it to the list - if current_section == Some("Peer".to_string()) && line.is_empty() { - if let Some(peer) = current_peer.take() { - if !peer.public_key.is_empty() && !peer.endpoint.is_empty() { - peers.push(peer); - } - } - current_section = Some("Interface".to_string()); - } } - - // Add the last peer if there is one + + // Flush the last peer if let Some(peer) = current_peer { if !peer.public_key.is_empty() && !peer.endpoint.is_empty() { peers.push(peer); } } - + if private_key.is_empty() { - return Err(anyhow!("PrivateKey is required")); + return Err(anyhow!("PrivateKey is required in WireGuard config")); } - if address.is_empty() { - return Err(anyhow!("Address is required")); + return Err(anyhow!("Address is required in WireGuard config")); } - + Ok(WireguardConfig { private_key, address, port, peers, }) -} \ No newline at end of file +} diff --git a/src/services/mod.rs b/src/services/mod.rs index c004d9d..96ba74c 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod provider; pub mod instance; pub mod volume; -pub mod network; \ No newline at end of file +pub mod network; +pub mod tenant; \ No newline at end of file diff --git a/src/services/tenant.rs b/src/services/tenant.rs new file mode 100644 index 0000000..d78cbd6 --- /dev/null +++ b/src/services/tenant.rs @@ -0,0 +1,195 @@ +use anyhow::{anyhow, Result}; +use log::info; +use std::collections::HashMap; +use uuid::Uuid; + +use crate::models::tenant::{Tenant, VrrpConfig}; +use crate::network::generate_wireguard_keypair; +use crate::services::provider::ProviderService; + +/// In-memory storage for tenant records. +#[derive(Debug)] +pub struct TenantStorage { + tenants: HashMap, + /// Index: numeric tenant_id → UUID + tenant_id_index: HashMap, +} + +impl TenantStorage { + pub fn new() -> Self { + Self { + tenants: HashMap::new(), + tenant_id_index: HashMap::new(), + } + } + + pub fn add_tenant(&mut self, tenant: Tenant) { + let uuid = tenant.id; + let tid = tenant.tenant_id; + self.tenants.insert(uuid, tenant); + self.tenant_id_index.insert(tid, uuid); + } + + pub fn get_tenant(&self, id: &Uuid) -> Option<&Tenant> { + self.tenants.get(id) + } + + pub fn get_tenant_mut(&mut self, id: &Uuid) -> Option<&mut Tenant> { + self.tenants.get_mut(id) + } + + pub fn get_by_tenant_id(&self, tenant_id: u32) -> Option<&Tenant> { + self.tenant_id_index + .get(&tenant_id) + .and_then(|uuid| self.tenants.get(uuid)) + } + + pub fn remove_tenant(&mut self, id: &Uuid) -> Option { + if let Some(t) = self.tenants.remove(id) { + self.tenant_id_index.remove(&t.tenant_id); + Some(t) + } else { + None + } + } + + pub fn get_all(&self) -> Vec<&Tenant> { + self.tenants.values().collect() + } + + /// Return the next available numeric tenant_id (starting at 1). + pub fn next_tenant_id(&self) -> u32 { + (1u32..) + .find(|id| !self.tenant_id_index.contains_key(id)) + .unwrap() // infinite range always has a result + } +} + +/// High-level service for managing tenants and their network provisioning. +pub struct TenantService { + storage: TenantStorage, + /// Access to infrastructure provider APIs. + provider_service: ProviderService, + /// BGP AS number used across the fabric. + bgp_as: u32, + /// Source address (loopback/VTEP) used for VXLAN on managed routers. + default_source_address: String, +} + +impl TenantService { + /// Create a new TenantService. + /// + /// `bgp_as` – the iBGP AS number for the fabric (e.g. 65000). + /// `default_source_address` – VTEP/loopback IP used when creating VXLAN interfaces. + pub fn new( + provider_service: ProviderService, + bgp_as: u32, + default_source_address: String, + ) -> Self { + Self { + storage: TenantStorage::new(), + provider_service, + bgp_as, + default_source_address, + } + } + + /// List all tenants. + pub fn list_tenants(&self) -> Vec<&Tenant> { + self.storage.get_all() + } + + /// Get a tenant by UUID. + pub fn get_tenant(&self, id: &Uuid) -> Option<&Tenant> { + self.storage.get_tenant(id) + } + + /// Create a new tenant record with auto-generated WireGuard keys and + /// derived network configuration. + /// + /// This does **not** push any configuration to VyOS; call + /// [`TenantService::provision_tenant_on_router`] for that. + pub fn create_tenant( + &mut self, + name: &str, + source_address: Option<&str>, + vrrp: Option, + ) -> Result { + let tenant_id = self.storage.next_tenant_id(); + + // Generate a fresh WireGuard key pair for this tenant + let kp = generate_wireguard_keypair()?; + + let mut tenant = Tenant::new( + name.to_string(), + tenant_id, + self.bgp_as, + source_address + .unwrap_or(&self.default_source_address) + .to_string(), + kp.public_key, + kp.private_key, + ); + + if let Some(v) = vrrp { + tenant.set_vrrp(v); + } + + let id = tenant.id; + info!("Created tenant '{}' with id={} (tenant_id={})", name, id, tenant_id); + self.storage.add_tenant(tenant); + Ok(id) + } + + /// Push the tenant network configuration to a named VyOS provider. + /// + /// On success the tenant status is updated to [`TenantStatus::Active`]. + pub async fn provision_tenant_on_router( + &mut self, + tenant_id: &Uuid, + provider_name: &str, + ) -> Result<()> { + // Clone the tenant to avoid borrow issues while calling the async API + let tenant = self + .storage + .get_tenant(tenant_id) + .ok_or_else(|| anyhow!("Tenant not found: {}", tenant_id))? + .clone(); + + // Get a VyOS client for the target provider + let mut client = self.provider_service.get_vyos_client(provider_name)?; + + // Provision on the router + client.provision_tenant(&tenant).await.map_err(|e| { + anyhow!( + "Failed to provision tenant '{}' on '{}': {}", + tenant.name, + provider_name, + e + ) + })?; + + // Mark as active + if let Some(t) = self.storage.get_tenant_mut(tenant_id) { + t.set_active(); + } + + info!( + "Tenant '{}' is now active on provider '{}'", + tenant.name, provider_name + ); + Ok(()) + } + + /// Delete a tenant record. + /// + /// This removes it from local storage; it does **not** roll back any + /// already-applied VyOS configuration. + pub fn delete_tenant(&mut self, id: &Uuid) -> Result<()> { + self.storage + .remove_tenant(id) + .ok_or_else(|| anyhow!("Tenant not found: {}", id))?; + info!("Deleted tenant {}", id); + Ok(()) + } +}