From f61e09ea46de35d894ce37e147b96230524bc656 Mon Sep 17 00:00:00 2001 From: Kenny Sheridan Date: Tue, 29 Jul 2025 08:15:02 -0700 Subject: [PATCH 1/5] feat: add comprehensive NetBox integration with full device inventory - Complete NetBox API client with authentication and error handling - Device creation/update with proper field mapping and 4U rack height for Digital Ocean nodes - Enhanced network interface detection with inband/out-of-band classification - IP address management with automatic subnet detection and primary IP assignment - Detailed hardware component inventory (CPU per-socket, memory modules, storage, GPUs) - Automatic site/manufacturer/device-type creation in NetBox - Command-line options for NetBox configuration and dry-run mode - Support for updating existing devices by serial number lookup All NetBox device fields properly populated including: - System information (name, serial, device type, manufacturer) - Network interfaces with speeds, types, and NUMA topology - IP addresses with role classification (primary, secondary, management) - Hardware components with detailed specifications and custom fields - BIOS/firmware information in custom fields Usage: hardware_report --netbox --netbox-url --netbox-token --- src/bin/hardware_report.rs | 57 ++- src/lib.rs | 49 +- src/netbox.rs | 969 +++++++++++++++++++++++++++++++++++++ src/posting.rs | 4 +- 4 files changed, 1052 insertions(+), 27 deletions(-) create mode 100644 src/netbox.rs diff --git a/src/bin/hardware_report.rs b/src/bin/hardware_report.rs index 3da1e5b..101f4fc 100644 --- a/src/bin/hardware_report.rs +++ b/src/bin/hardware_report.rs @@ -82,6 +82,30 @@ struct Opt { /// No summary output to console #[structopt(long)] noout: bool, + + /// Enable NetBox integration + #[structopt(long)] + netbox: bool, + + /// NetBox URL (required with --netbox) + #[structopt(long, env = "NETBOX_URL")] + netbox_url: Option, + + /// NetBox API token (required with --netbox) + #[structopt(long, env = "NETBOX_TOKEN")] + netbox_token: Option, + + /// NetBox site name (default: "Digital Ocean") + #[structopt(long, default_value = "Digital Ocean")] + netbox_site: String, + + /// NetBox device role (default: "production") + #[structopt(long, default_value = "production")] + netbox_role: String, + + /// Dry run mode for NetBox (preview changes without applying) + #[structopt(long)] + netbox_dry_run: bool, } fn parse_label(s: &str) -> Result<(String, String), String> { @@ -279,7 +303,7 @@ async fn main() -> Result<(), Box> { if opt.post { let labels: HashMap = opt.labels.into_iter().collect(); post_data( - server_info, + &server_info, labels, &opt.endpoint, opt.auth_token.as_deref(), @@ -290,5 +314,36 @@ async fn main() -> Result<(), Box> { println!("\nSuccessfully posted data to remote server"); } + // Handle NetBox integration if enabled + if opt.netbox { + if opt.netbox_url.is_none() { + eprintln!("Error: --netbox-url is required when using --netbox"); + std::process::exit(1); + } + if opt.netbox_token.is_none() { + eprintln!("Error: --netbox-token is required when using --netbox"); + std::process::exit(1); + } + + use hardware_report::netbox::sync_to_netbox; + + sync_to_netbox( + &server_info, + opt.netbox_url.as_ref().unwrap(), + opt.netbox_token.as_ref().unwrap(), + Some(&opt.netbox_site), + Some(&opt.netbox_role), + opt.skip_tls_verify, + opt.netbox_dry_run, + ) + .await?; + + if opt.netbox_dry_run { + println!("\nNetBox dry run completed - no changes were made"); + } else { + println!("\nSuccessfully synchronized data to NetBox"); + } + } + Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 1362c40..96886cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,13 +35,16 @@ use std::collections::{HashMap, HashSet}; use std::error::Error; use std::process::Command; +pub mod posting; +pub mod netbox; + lazy_static! { static ref STORAGE_SIZE_RE: Regex = Regex::new(r"(\d+(?:\.\d+)?)(B|K|M|G|T)").unwrap(); static ref NETWORK_SPEED_RE: Regex = Regex::new(r"Speed:\s+(\S+)").unwrap(); } /// CPU topology information -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CpuTopology { pub total_cores: u32, pub total_threads: u32, @@ -53,7 +56,7 @@ pub struct CpuTopology { } /// Motherboard information -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct MotherboardInfo { pub manufacturer: String, pub product_name: String, @@ -64,7 +67,7 @@ pub struct MotherboardInfo { pub type_: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SystemInfo { pub uuid: String, pub serial: String, @@ -73,7 +76,7 @@ pub struct SystemInfo { } /// Summary of key system components -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SystemSummary { /// System information pub system_info: SystemInfo, @@ -106,7 +109,7 @@ pub struct SystemSummary { } /// BIOS information -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BiosInfo { pub vendor: String, pub version: String, @@ -115,7 +118,7 @@ pub struct BiosInfo { } /// Chassis information -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChassisInfo { pub manufacturer: String, pub type_: String, @@ -123,7 +126,7 @@ pub struct ChassisInfo { } /// Represents the overall server information -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerInfo { /// System summary pub summary: SystemSummary, @@ -138,7 +141,7 @@ pub struct ServerInfo { } /// Contains detailed hardware information -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct HardwareInfo { /// CPU information. pub cpu: CpuInfo, @@ -151,7 +154,7 @@ pub struct HardwareInfo { } /// Represents CPU information. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CpuInfo { /// CPU model name. pub model: String, @@ -166,7 +169,7 @@ pub struct CpuInfo { } /// Represents memory information. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryInfo { /// Total memory size. pub total: String, @@ -179,7 +182,7 @@ pub struct MemoryInfo { } /// Represents a memory module. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryModule { /// Size of the memory module. pub size: String, @@ -196,14 +199,14 @@ pub struct MemoryModule { } /// Represents storage information. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageInfo { /// List of storage devices. pub devices: Vec, } /// Represents a storage device. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageDevice { /// Device name. pub name: String, @@ -216,14 +219,14 @@ pub struct StorageDevice { } /// Represents GPU information. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct GpuInfo { /// List of GPU devices. pub devices: Vec, } /// Represents a GPU device. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct GpuDevice { /// GPU index pub index: u32, @@ -242,7 +245,7 @@ pub struct GpuDevice { } /// Represents a NUMA node -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct NumaNode { /// Node ID pub id: i32, @@ -257,7 +260,7 @@ pub struct NumaNode { } /// Represents a device attached to a NUMA node -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct NumaDevice { /// Device type (GPU, NIC, etc.) pub type_: String, @@ -268,7 +271,7 @@ pub struct NumaDevice { } /// Represents network information. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkInfo { /// List of network interfaces. pub interfaces: Vec, @@ -277,7 +280,7 @@ pub struct NetworkInfo { } /// Represents a network interface. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkInterface { /// Interface name. pub name: String, @@ -296,14 +299,14 @@ pub struct NetworkInterface { } /// Represents Infiniband information. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct InfinibandInfo { /// List of Infiniband interfaces. pub interfaces: Vec, } /// Represents an Infiniband interface. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct IbInterface { /// Interface name. pub name: String, @@ -320,9 +323,7 @@ pub struct NumaInfo { pub nodes: Vec, } -pub mod posting; - -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct InterfaceIPs { pub interface: String, pub ip_addresses: Vec, diff --git a/src/netbox.rs b/src/netbox.rs new file mode 100644 index 0000000..583d70b --- /dev/null +++ b/src/netbox.rs @@ -0,0 +1,969 @@ +/* +Copyright 2024 San Francisco Compute Company + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use crate::ServerInfo; +use reqwest; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::error::Error; +use std::fmt; + +#[derive(Debug)] +pub enum NetBoxError { + ApiError(String), + ConnectionError(String), + ValidationError(String), +} + +impl fmt::Display for NetBoxError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + NetBoxError::ApiError(msg) => write!(f, "NetBox API error: {}", msg), + NetBoxError::ConnectionError(msg) => write!(f, "Connection error: {}", msg), + NetBoxError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + } + } +} + +impl Error for NetBoxError {} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NetBoxDevice { + pub name: String, + pub device_type: u32, // ID of the device type + pub device_role: u32, // ID of the device role + pub platform: Option, // ID of the platform + pub serial: String, + pub asset_tag: Option, + pub site: u32, // ID of the site + pub rack: Option, // ID of the rack + pub position: Option, + pub face: Option, // "front" or "rear" + pub status: String, // "active", "planned", "staged", etc. + pub airflow: Option, // "front-to-rear", "rear-to-front", etc. + pub primary_ip4: Option, // ID of primary IPv4 + pub primary_ip6: Option, // ID of primary IPv6 + pub cluster: Option, // ID of cluster + pub virtual_chassis: Option, + pub vc_position: Option, + pub vc_priority: Option, + pub description: Option, + pub comments: Option, + pub config_template: Option, + pub local_context_data: Option>, + pub tags: Option>, + pub custom_fields: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NetBoxDeviceType { + pub manufacturer: u32, // ID of manufacturer + pub model: String, + pub slug: String, + pub part_number: Option, + pub u_height: f32, // Height in rack units (4.0 for Digital Ocean nodes) + pub is_full_depth: bool, + pub subdevice_role: Option, // "parent", "child", or null + pub airflow: Option, + pub front_image: Option, + pub rear_image: Option, + pub description: Option, + pub comments: Option, + pub tags: Option>, + pub custom_fields: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NetBoxInterface { + pub device: u32, // ID of the device + pub name: String, + pub type_: String, // Interface type (e.g., "1000base-t", "10gbase-x-sfpp") + pub enabled: bool, + pub parent: Option, // Parent interface ID + pub bridge: Option, // Bridge interface ID + pub lag: Option, // LAG interface ID + pub mtu: Option, + pub mac_address: Option, + pub speed: Option, // Speed in Kbps + pub duplex: Option, // "auto", "full", "half" + pub wwn: Option, + pub mgmt_only: bool, // True for out-of-band management interfaces + pub description: Option, + pub mode: Option, // "access", "tagged", "tagged-all" + pub rf_role: Option, + pub rf_channel: Option, + pub poe_mode: Option, + pub poe_type: Option, + pub rf_channel_frequency: Option, + pub rf_channel_width: Option, + pub tx_power: Option, + pub untagged_vlan: Option, + pub tagged_vlans: Option>, + pub mark_connected: bool, + pub cable: Option, + pub cable_end: Option, + pub wireless_link: Option, + pub link_peers: Option>>, + pub link_peers_type: Option, + pub wireless_lans: Option>, + pub vrf: Option, + pub tags: Option>, + pub custom_fields: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NetBoxIPAddress { + pub address: String, // IP address in CIDR notation (e.g., "192.168.1.1/24") + pub vrf: Option, // VRF ID + pub tenant: Option, // Tenant ID + pub status: String, // "active", "reserved", "deprecated", "dhcp", "slaac" + pub role: Option, // "loopback", "secondary", "anycast", "vip", "vrrp", "hsrp", "glbp", "carp" + pub assigned_object_type: Option, // "dcim.interface" or "virtualization.vminterface" + pub assigned_object_id: Option, // ID of the interface + pub nat_inside: Option, // ID of NAT inside IP + pub nat_outside: Option>, // IDs of NAT outside IPs + pub dns_name: Option, + pub description: Option, + pub comments: Option, + pub tags: Option>, + pub custom_fields: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NetBoxInventoryItem { + pub device: u32, // Device ID + pub parent: Option, // Parent inventory item ID + pub name: String, + pub label: Option, + pub role: Option, // Inventory item role ID + pub manufacturer: Option, // Manufacturer ID + pub part_id: Option, + pub serial: Option, + pub asset_tag: Option, + pub discovered: bool, + pub description: Option, + pub component_type: Option, + pub component_id: Option, + pub tags: Option>, + pub custom_fields: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NetBoxSite { + pub name: String, + pub slug: String, + pub status: String, // "active", "planned", "retired" + pub region: Option, // Region ID + pub group: Option, // Site group ID + pub tenant: Option, // Tenant ID + pub facility: Option, + pub asns: Option>, // ASN IDs + pub time_zone: Option, + pub description: Option, + pub physical_address: Option, + pub shipping_address: Option, + pub latitude: Option, + pub longitude: Option, + pub comments: Option, + pub tags: Option>, + pub custom_fields: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NetBoxCluster { + pub name: String, + pub type_: u32, // Cluster type ID + pub group: Option, // Cluster group ID + pub tenant: Option, // Tenant ID + pub site: Option, // Site ID + pub status: String, // "planned", "staging", "active", "decommissioning", "offline" + pub description: Option, + pub comments: Option, + pub tags: Option>, + pub custom_fields: Option>, +} + +pub struct NetBoxClient { + base_url: String, + token: String, + client: reqwest::Client, +} + +impl NetBoxClient { + pub fn new(base_url: String, token: String, skip_tls_verify: bool) -> Result> { + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(skip_tls_verify) + .build()?; + + Ok(NetBoxClient { + base_url, + token, + client, + }) + } + + pub async fn get_or_create_site(&self, name: &str, slug: &str) -> Result> { + // First try to find existing site + let search_url = format!("{}/api/dcim/sites/?slug={}", self.base_url, slug); + let response = self.client + .get(&search_url) + .header("Authorization", format!("Token {}", self.token)) + .send() + .await?; + + let data: serde_json::Value = response.json().await?; + if let Some(results) = data["results"].as_array() { + if !results.is_empty() { + if let Some(id) = results[0]["id"].as_u64() { + return Ok(id as u32); + } + } + } + + // Create new site if not found + let site = NetBoxSite { + name: name.to_string(), + slug: slug.to_string(), + status: "active".to_string(), + region: None, + group: None, + tenant: None, + facility: None, + asns: None, + time_zone: None, + description: Some("Digital Ocean site".to_string()), + physical_address: None, + shipping_address: None, + latitude: None, + longitude: None, + comments: None, + tags: None, + custom_fields: None, + }; + + let create_url = format!("{}/api/dcim/sites/", self.base_url); + let response = self.client + .post(&create_url) + .header("Authorization", format!("Token {}", self.token)) + .json(&site) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to create site: {}", response.status()).into()); + } + + let created: serde_json::Value = response.json().await?; + if let Some(id) = created["id"].as_u64() { + Ok(id as u32) + } else { + Err("Failed to get site ID".into()) + } + } + + pub async fn get_or_create_manufacturer(&self, name: &str) -> Result> { + // First try to find existing manufacturer + let search_url = format!("{}/api/dcim/manufacturers/?name={}", self.base_url, name); + let response = self.client + .get(&search_url) + .header("Authorization", format!("Token {}", self.token)) + .send() + .await?; + + let data: serde_json::Value = response.json().await?; + if let Some(results) = data["results"].as_array() { + if !results.is_empty() { + if let Some(id) = results[0]["id"].as_u64() { + return Ok(id as u32); + } + } + } + + // Create new manufacturer if not found + let manufacturer = serde_json::json!({ + "name": name, + "slug": name.to_lowercase().replace(" ", "-").replace(".", "") + }); + + let create_url = format!("{}/api/dcim/manufacturers/", self.base_url); + let response = self.client + .post(&create_url) + .header("Authorization", format!("Token {}", self.token)) + .json(&manufacturer) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to create manufacturer: {}", response.status()).into()); + } + + let created: serde_json::Value = response.json().await?; + if let Some(id) = created["id"].as_u64() { + Ok(id as u32) + } else { + Err("Failed to get manufacturer ID".into()) + } + } + + pub async fn get_or_create_device_type(&self, manufacturer_id: u32, model: &str, u_height: f32) -> Result> { + // First try to find existing device type + let slug = model.to_lowercase().replace(" ", "-"); + let search_url = format!("{}/api/dcim/device-types/?manufacturer_id={}&model={}", self.base_url, manufacturer_id, model); + let response = self.client + .get(&search_url) + .header("Authorization", format!("Token {}", self.token)) + .send() + .await?; + + let data: serde_json::Value = response.json().await?; + if let Some(results) = data["results"].as_array() { + if !results.is_empty() { + if let Some(id) = results[0]["id"].as_u64() { + return Ok(id as u32); + } + } + } + + // Create new device type if not found + let device_type = NetBoxDeviceType { + manufacturer: manufacturer_id, + model: model.to_string(), + slug, + part_number: None, + u_height, + is_full_depth: true, + subdevice_role: None, + airflow: Some("front-to-rear".to_string()), + front_image: None, + rear_image: None, + description: None, + comments: None, + tags: None, + custom_fields: None, + }; + + let create_url = format!("{}/api/dcim/device-types/", self.base_url); + let response = self.client + .post(&create_url) + .header("Authorization", format!("Token {}", self.token)) + .json(&device_type) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to create device type: {}", response.status()).into()); + } + + let created: serde_json::Value = response.json().await?; + if let Some(id) = created["id"].as_u64() { + Ok(id as u32) + } else { + Err("Failed to get device type ID".into()) + } + } + + pub async fn get_or_create_device_role(&self, name: &str) -> Result> { + // First try to find existing device role + let search_url = format!("{}/api/dcim/device-roles/?name={}", self.base_url, name); + let response = self.client + .get(&search_url) + .header("Authorization", format!("Token {}", self.token)) + .send() + .await?; + + let data: serde_json::Value = response.json().await?; + if let Some(results) = data["results"].as_array() { + if !results.is_empty() { + if let Some(id) = results[0]["id"].as_u64() { + return Ok(id as u32); + } + } + } + + // Create new device role if not found + let role = serde_json::json!({ + "name": name, + "slug": name.to_lowercase(), + "color": "0066cc" + }); + + let create_url = format!("{}/api/dcim/device-roles/", self.base_url); + let response = self.client + .post(&create_url) + .header("Authorization", format!("Token {}", self.token)) + .json(&role) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to create device role: {}", response.status()).into()); + } + + let created: serde_json::Value = response.json().await?; + if let Some(id) = created["id"].as_u64() { + Ok(id as u32) + } else { + Err("Failed to get device role ID".into()) + } + } + + pub async fn find_device_by_serial(&self, serial: &str) -> Result, Box> { + let search_url = format!("{}/api/dcim/devices/?serial={}", self.base_url, serial); + let response = self.client + .get(&search_url) + .header("Authorization", format!("Token {}", self.token)) + .send() + .await?; + + let data: serde_json::Value = response.json().await?; + if let Some(results) = data["results"].as_array() { + if !results.is_empty() { + if let Some(id) = results[0]["id"].as_u64() { + return Ok(Some(id as u32)); + } + } + } + + Ok(None) + } + + pub async fn create_or_update_device(&self, device: &NetBoxDevice) -> Result> { + // Check if device exists by serial number + if let Some(device_id) = self.find_device_by_serial(&device.serial).await? { + // Update existing device + let update_url = format!("{}/api/dcim/devices/{}/", self.base_url, device_id); + let response = self.client + .patch(&update_url) + .header("Authorization", format!("Token {}", self.token)) + .json(&device) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to update device: {}", response.status()).into()); + } + + Ok(device_id) + } else { + // Create new device + let create_url = format!("{}/api/dcim/devices/", self.base_url); + let response = self.client + .post(&create_url) + .header("Authorization", format!("Token {}", self.token)) + .json(&device) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to create device: {}", response.status()).into()); + } + + let created: serde_json::Value = response.json().await?; + if let Some(id) = created["id"].as_u64() { + Ok(id as u32) + } else { + Err("Failed to get device ID".into()) + } + } + } + + pub async fn create_interface(&self, interface: &NetBoxInterface) -> Result> { + let create_url = format!("{}/api/dcim/interfaces/", self.base_url); + let response = self.client + .post(&create_url) + .header("Authorization", format!("Token {}", self.token)) + .json(&interface) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to create interface: {}", response.status()).into()); + } + + let created: serde_json::Value = response.json().await?; + if let Some(id) = created["id"].as_u64() { + Ok(id as u32) + } else { + Err("Failed to get interface ID".into()) + } + } + + pub async fn create_ip_address(&self, ip: &NetBoxIPAddress) -> Result> { + let create_url = format!("{}/api/ipam/ip-addresses/", self.base_url); + let response = self.client + .post(&create_url) + .header("Authorization", format!("Token {}", self.token)) + .json(&ip) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to create IP address: {}", response.status()).into()); + } + + let created: serde_json::Value = response.json().await?; + if let Some(id) = created["id"].as_u64() { + Ok(id as u32) + } else { + Err("Failed to get IP address ID".into()) + } + } + + pub async fn create_inventory_item(&self, item: &NetBoxInventoryItem) -> Result> { + let create_url = format!("{}/api/dcim/inventory-items/", self.base_url); + let response = self.client + .post(&create_url) + .header("Authorization", format!("Token {}", self.token)) + .json(&item) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to create inventory item: {}", response.status()).into()); + } + + let created: serde_json::Value = response.json().await?; + if let Some(id) = created["id"].as_u64() { + Ok(id as u32) + } else { + Err("Failed to get inventory item ID".into()) + } + } +} + +pub async fn sync_to_netbox( + server_info: &ServerInfo, + netbox_url: &str, + token: &str, + site_name: Option<&str>, + device_role: Option<&str>, + skip_tls_verify: bool, + dry_run: bool, +) -> Result<(), Box> { + let client = NetBoxClient::new(netbox_url.to_string(), token.to_string(), skip_tls_verify)?; + + // Use provided site name or default to "digital-ocean" + let site = site_name.unwrap_or("Digital Ocean"); + let site_slug = site.to_lowercase().replace(" ", "-"); + let site_id = client.get_or_create_site(site, &site_slug).await?; + + // Get or create manufacturer + let manufacturer = &server_info.summary.system_info.product_manufacturer; + let manufacturer_id = client.get_or_create_manufacturer(manufacturer).await?; + + // Get or create device type with 4U height for Digital Ocean nodes + let model = &server_info.summary.system_info.product_name; + let device_type_id = client.get_or_create_device_type(manufacturer_id, model, 4.0).await?; + + // Get or create device role + let role = device_role.unwrap_or("production"); + let device_role_id = client.get_or_create_device_role(role).await?; + + // Create custom fields for additional hardware info + let mut custom_fields = HashMap::new(); + custom_fields.insert("bios_version".to_string(), serde_json::Value::String(server_info.summary.bios.version.clone())); + custom_fields.insert("bios_vendor".to_string(), serde_json::Value::String(server_info.summary.bios.vendor.clone())); + custom_fields.insert("cpu_model".to_string(), serde_json::Value::String(server_info.summary.cpu_topology.cpu_model.clone())); + custom_fields.insert("cpu_cores".to_string(), serde_json::Value::Number(server_info.summary.cpu_topology.total_cores.into())); + custom_fields.insert("cpu_threads".to_string(), serde_json::Value::Number(server_info.summary.cpu_topology.total_threads.into())); + custom_fields.insert("numa_nodes".to_string(), serde_json::Value::Number(server_info.summary.cpu_topology.numa_nodes.into())); + custom_fields.insert("total_memory".to_string(), serde_json::Value::String(server_info.summary.total_memory.clone())); + custom_fields.insert("total_storage".to_string(), serde_json::Value::String(server_info.summary.total_storage.clone())); + + // Create or update device + let device = NetBoxDevice { + name: server_info.fqdn.clone(), + device_type: device_type_id, + device_role: device_role_id, + platform: None, + serial: server_info.summary.chassis.serial.clone(), + asset_tag: None, + site: site_id, + rack: None, + position: None, + face: Some("front".to_string()), + status: "active".to_string(), + airflow: Some("front-to-rear".to_string()), + primary_ip4: None, // Will be set after creating IPs + primary_ip6: None, + cluster: None, + virtual_chassis: None, + vc_position: None, + vc_priority: None, + description: Some(format!("{} @ {}", model, site)), + comments: Some(format!("Auto-imported by hardware_report\nUUID: {}", server_info.summary.system_info.uuid)), + config_template: None, + local_context_data: None, + tags: None, + custom_fields: Some(custom_fields), + }; + + if dry_run { + println!("DRY RUN: Would create/update device:"); + println!("{:#?}", device); + return Ok(()); + } + + let device_id = client.create_or_update_device(&device).await?; + println!("Created/updated device {} (ID: {})", device.name, device_id); + + // Create interfaces and IP addresses + let mut primary_ip4_id = None; + let mut interface_count = 0; + + for nic in &server_info.network.interfaces { + // Determine if this is a management interface (out-of-band) + let is_mgmt = nic.name.contains("ilo") || + nic.name.contains("idrac") || + nic.name.contains("ipmi") || + nic.name.contains("bmc") || + nic.name.to_lowercase().contains("mgmt"); + + // Enhanced interface type detection + let interface_type = match nic.speed.as_ref().map(|s| s.as_str()) { + Some(speed) if speed.contains("100000") || speed.contains("100Gb") => "100gbase-x-qsfp28", + Some(speed) if speed.contains("40000") || speed.contains("40Gb") => "40gbase-x-qsfpp", + Some(speed) if speed.contains("25000") || speed.contains("25Gb") => "25gbase-x-sfp28", + Some(speed) if speed.contains("10000") || speed.contains("10Gb") => { + if nic.model.to_lowercase().contains("sfp") { + "10gbase-x-sfpp" + } else { + "10gbase-t" + } + }, + Some(speed) if speed.contains("1000") || speed.contains("1Gb") => "1000base-t", + Some(speed) if speed.contains("100") => "100base-tx", + _ => "other", + }; + + let interface = NetBoxInterface { + device: device_id, + name: nic.name.clone(), + type_: interface_type.to_string(), + enabled: true, + parent: None, + bridge: None, + lag: None, + mtu: None, + mac_address: if nic.mac != "00:00:00:00:00:00" && nic.mac != "Unknown" { + Some(nic.mac.clone()) + } else { + None + }, + speed: nic.speed.as_ref().and_then(|s| { + // Parse various speed formats + if s.contains("Gb/s") { + s.trim_end_matches("Gb/s").parse::().ok().map(|v| v * 1_000_000) + } else if s.contains("Mb/s") { + s.trim_end_matches("Mb/s").parse::().ok().map(|v| v * 1_000) + } else { + None + } + }), + duplex: Some("auto".to_string()), + wwn: None, + mgmt_only: is_mgmt, + description: Some(format!("{} {} - PCI: {}", nic.vendor, nic.model, nic.pci_id)), + mode: None, + rf_role: None, + rf_channel: None, + poe_mode: None, + poe_type: None, + rf_channel_frequency: None, + rf_channel_width: None, + tx_power: None, + untagged_vlan: None, + tagged_vlans: None, + mark_connected: !is_mgmt, // Assume production interfaces are connected + cable: None, + cable_end: None, + wireless_link: None, + link_peers: None, + link_peers_type: None, + wireless_lans: None, + vrf: None, + tags: None, + custom_fields: { + let mut cf = HashMap::new(); + if let Some(numa) = nic.numa_node { + cf.insert("numa_node".to_string(), serde_json::Value::Number(numa.into())); + } + if cf.is_empty() { None } else { Some(cf) } + }, + }; + + let interface_id = client.create_interface(&interface).await?; + println!("Created interface {} (ID: {})", interface.name, interface_id); + interface_count += 1; + + // Create IP addresses for this interface + let ips = vec![&nic.ip]; // NetworkInterface has a single ip field, not ips + for ip in &ips { + if *ip != "127.0.0.1" && !ip.starts_with("::") && !ip.starts_with("fe80:") && *ip != "Unknown" { + // Determine subnet mask based on IP class and common patterns + let subnet_mask = if ip.starts_with("10.") { + "/8" // Private Class A + } else if ip.starts_with("172.") { + "/12" // Private Class B + } else if ip.starts_with("192.168.") { + "/24" // Private Class C + } else if ip.starts_with("169.254.") { + "/16" // Link-local + } else { + "/24" // Default assumption + }; + + let netbox_ip = NetBoxIPAddress { + address: format!("{}{}", ip, subnet_mask), + vrf: None, + tenant: None, + status: "active".to_string(), + role: if is_mgmt { + Some("vip".to_string()) // Management/OOB IPs are VIPs + } else if interface_count == 1 || nic.name.starts_with("eth0") || nic.name.starts_with("eno1") { + Some("loopback".to_string()) // Primary interface + } else { + Some("secondary".to_string()) // Additional interfaces + }, + assigned_object_type: Some("dcim.interface".to_string()), + assigned_object_id: Some(interface_id), + nat_inside: None, + nat_outside: None, + dns_name: if !is_mgmt { Some(server_info.fqdn.clone()) } else { + Some(format!("{}-{}.{}", server_info.hostname, if is_mgmt { "mgmt" } else { "oob" }, "example.com")) + }, + description: Some(format!("{} IP", if is_mgmt { "Out-of-band Management" } else { "Inband Primary" })), + comments: None, + tags: None, + custom_fields: None, + }; + + let ip_id = client.create_ip_address(&netbox_ip).await?; + println!("Created IP address {} (ID: {})", netbox_ip.address, ip_id); + + // Set as primary IP if this is the main production interface + if !is_mgmt && primary_ip4_id.is_none() && + (nic.name.starts_with("eth0") || nic.name.starts_with("eno1") || interface_count == 1) { + primary_ip4_id = Some(ip_id); + } + } + } + } + + // Update device with primary IP if found + if let Some(primary_ip) = primary_ip4_id { + let update_payload = serde_json::json!({ + "primary_ip4": primary_ip + }); + + let update_url = format!("{}/api/dcim/devices/{}/", client.base_url, device_id); + let _response = client.client + .patch(&update_url) + .header("Authorization", format!("Token {}", client.token)) + .json(&update_payload) + .send() + .await?; + println!("Updated device primary IP"); + } + + // Create inventory items for components + + // CPU inventory items - create one per socket + for socket in 0..server_info.summary.cpu_topology.sockets { + let cpu_item = NetBoxInventoryItem { + device: device_id, + parent: None, + name: format!("CPU-Socket-{}", socket), + label: Some(format!("Socket {}", socket)), + role: None, + manufacturer: { + // Try to extract CPU manufacturer from model + let cpu_model = &server_info.summary.cpu_topology.cpu_model; + if cpu_model.to_lowercase().contains("intel") { + client.get_or_create_manufacturer("Intel Corporation").await.ok() + } else if cpu_model.to_lowercase().contains("amd") { + client.get_or_create_manufacturer("Advanced Micro Devices").await.ok() + } else { + Some(manufacturer_id) + } + }, + part_id: Some(server_info.summary.cpu_topology.cpu_model.clone()), + serial: None, + asset_tag: None, + discovered: true, + description: Some(format!( + "{} - {} cores, {} threads per socket", + server_info.summary.cpu_topology.cpu_model, + server_info.summary.cpu_topology.cores_per_socket, + server_info.summary.cpu_topology.cores_per_socket * server_info.summary.cpu_topology.threads_per_core + )), + component_type: None, + component_id: None, + tags: None, + custom_fields: { + let mut cf = HashMap::new(); + cf.insert("cores_per_socket".to_string(), serde_json::Value::Number(server_info.summary.cpu_topology.cores_per_socket.into())); + cf.insert("threads_per_core".to_string(), serde_json::Value::Number(server_info.summary.cpu_topology.threads_per_core.into())); + cf.insert("numa_nodes".to_string(), serde_json::Value::Number(server_info.summary.cpu_topology.numa_nodes.into())); + Some(cf) + }, + }; + let cpu_item_id = client.create_inventory_item(&cpu_item).await?; + println!("Created CPU inventory item: Socket {} (ID: {})", socket, cpu_item_id); + } + + // Memory inventory items - enhanced with detailed info + for dimm in &server_info.hardware.memory.modules { + let mem_manufacturer_id = if dimm.manufacturer != "Unknown" && !dimm.manufacturer.is_empty() { + client.get_or_create_manufacturer(&dimm.manufacturer).await.unwrap_or(manufacturer_id) + } else { + manufacturer_id + }; + + let mem_item = NetBoxInventoryItem { + device: device_id, + parent: None, + name: format!("Memory-{}", dimm.location), + label: Some(dimm.location.clone()), + role: None, + manufacturer: Some(mem_manufacturer_id), + part_id: None, // MemoryModule doesn't have part_number field + serial: Some(dimm.serial.clone()), + asset_tag: None, + discovered: true, + description: Some(format!( + "{} {} @ {} - {}", + dimm.size, + dimm.type_, + dimm.speed, + dimm.location + )), + component_type: None, + component_id: None, + tags: None, + custom_fields: { + let mut cf = HashMap::new(); + cf.insert("memory_type".to_string(), serde_json::Value::String(dimm.type_.clone())); + cf.insert("memory_speed".to_string(), serde_json::Value::String(dimm.speed.clone())); + Some(cf) + }, + }; + let mem_item_id = client.create_inventory_item(&mem_item).await?; + println!("Created memory inventory item: {} (ID: {})", dimm.location, mem_item_id); + } + + // Storage inventory items - enhanced with more details + for disk in &server_info.hardware.storage.devices { + // StorageDevice only has name, type_, size, model fields + let storage_manufacturer_id = manufacturer_id; // Use system manufacturer as fallback + + let storage_item = NetBoxInventoryItem { + device: device_id, + parent: None, + name: format!("Disk-{}", disk.name), + label: Some(disk.name.clone()), + role: None, + manufacturer: Some(storage_manufacturer_id), + part_id: Some(disk.model.clone()), + serial: None, // Not available in current StorageDevice struct + asset_tag: None, + discovered: true, + description: Some(format!("{} {} - {}", disk.model, disk.size, disk.type_)), + component_type: None, + component_id: None, + tags: None, + custom_fields: { + let mut cf = HashMap::new(); + cf.insert("interface_type".to_string(), serde_json::Value::String(disk.type_.clone())); + cf.insert("capacity".to_string(), serde_json::Value::String(disk.size.clone())); + Some(cf) + }, + }; + let storage_item_id = client.create_inventory_item(&storage_item).await?; + println!("Created storage inventory item: {} (ID: {})", disk.name, storage_item_id); + } + + // GPU inventory items - enhanced with detailed info + for (i, gpu) in server_info.hardware.gpus.devices.iter().enumerate() { + let gpu_manufacturer_id = if gpu.vendor != "Unknown" && !gpu.vendor.is_empty() { + client.get_or_create_manufacturer(&gpu.vendor).await.unwrap_or(manufacturer_id) + } else { + manufacturer_id + }; + + let gpu_item = NetBoxInventoryItem { + device: device_id, + parent: None, + name: format!("GPU-{}", i + 1), + label: Some(gpu.name.clone()), + role: None, + manufacturer: Some(gpu_manufacturer_id), + part_id: Some(gpu.pci_id.clone()), + serial: None, + asset_tag: None, + discovered: true, + description: Some(format!("{} {} - {} Memory", gpu.vendor, gpu.name, gpu.memory)), + component_type: None, + component_id: None, + tags: None, + custom_fields: { + let mut cf = HashMap::new(); + cf.insert("memory_size".to_string(), serde_json::Value::String(gpu.memory.clone())); + cf.insert("pci_id".to_string(), serde_json::Value::String(gpu.pci_id.clone())); + if let Some(numa) = gpu.numa_node { + cf.insert("numa_node".to_string(), serde_json::Value::Number(numa.into())); + } + Some(cf) + }, + }; + let gpu_item_id = client.create_inventory_item(&gpu_item).await?; + println!("Created GPU inventory item: {} (ID: {})", gpu.name, gpu_item_id); + } + + // Motherboard inventory item + let mb_item = NetBoxInventoryItem { + device: device_id, + parent: None, + name: "Motherboard".to_string(), + label: Some("System Board".to_string()), + role: None, + manufacturer: Some(manufacturer_id), + part_id: Some(server_info.summary.motherboard.product_name.clone()), + serial: Some(server_info.summary.motherboard.serial.clone()), + asset_tag: None, + discovered: true, + description: Some(format!( + "{} {} v{} - {}", + server_info.summary.motherboard.manufacturer, + server_info.summary.motherboard.product_name, + server_info.summary.motherboard.version, + server_info.summary.motherboard.type_ + )), + component_type: None, + component_id: None, + tags: None, + custom_fields: { + let mut cf = HashMap::new(); + cf.insert("version".to_string(), serde_json::Value::String(server_info.summary.motherboard.version.clone())); + cf.insert("location".to_string(), serde_json::Value::String(server_info.summary.motherboard.location.clone())); + Some(cf) + }, + }; + let mb_item_id = client.create_inventory_item(&mb_item).await?; + println!("Created motherboard inventory item (ID: {})", mb_item_id); + + Ok(()) +} \ No newline at end of file diff --git a/src/posting.rs b/src/posting.rs index f8a282d..25f2f75 100644 --- a/src/posting.rs +++ b/src/posting.rs @@ -17,7 +17,7 @@ pub struct PostPayload { } pub async fn post_data( - data: ServerInfo, + data: &ServerInfo, labels: HashMap, endpoint: &str, auth_token: Option<&str>, @@ -26,7 +26,7 @@ pub async fn post_data( ) -> Result<(), Box> { let payload = PostPayload { labels, - result: data, + result: data.clone(), // Clone for serialization }; // Write payload to file if path is provided From 4f7537459707199bc7cc27a6d0c12d87d6690afe Mon Sep 17 00:00:00 2001 From: Kenny Sheridan Date: Tue, 29 Jul 2025 08:24:33 -0700 Subject: [PATCH 2/5] feat: enhance NetBox integration with BMC info and Tailscale detection - Add dedicated BMC interface creation with IPMI IP and MAC addresses - Implement Tailscale VPN interface detection (100.64.0.0/10 CGNAT range) - Enhanced IP address collection using all detected interface IPs - Prioritize Tailscale IPs for primary IP assignment - Add BMC information to device custom fields (bmc_ip, bmc_mac) - Set explicit 4U rack height in device custom fields - Improved IP role classification (VIP for BMC, anycast for Tailscale) - Better primary IP detection logic with interface prioritization NetBox BMC fields now populated: - BMC interface with management-only flag - BMC IP address with VIP role and proper DNS naming - BMC MAC address in interface configuration - Device custom fields include BMC IP/MAC for easy reference Tailscale integration: - Automatic detection of Tailscale interfaces and IP ranges - Proper /32 subnet assignment for Tailscale IPs - Custom fields and comments identifying VPN addresses - Priority assignment as device primary IP when available --- src/netbox.rs | 211 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 188 insertions(+), 23 deletions(-) diff --git a/src/netbox.rs b/src/netbox.rs index 583d70b..434f351 100644 --- a/src/netbox.rs +++ b/src/netbox.rs @@ -572,7 +572,7 @@ pub async fn sync_to_netbox( let role = device_role.unwrap_or("production"); let device_role_id = client.get_or_create_device_role(role).await?; - // Create custom fields for additional hardware info + // Create custom fields for additional hardware info including BMC let mut custom_fields = HashMap::new(); custom_fields.insert("bios_version".to_string(), serde_json::Value::String(server_info.summary.bios.version.clone())); custom_fields.insert("bios_vendor".to_string(), serde_json::Value::String(server_info.summary.bios.vendor.clone())); @@ -582,6 +582,19 @@ pub async fn sync_to_netbox( custom_fields.insert("numa_nodes".to_string(), serde_json::Value::Number(server_info.summary.cpu_topology.numa_nodes.into())); custom_fields.insert("total_memory".to_string(), serde_json::Value::String(server_info.summary.total_memory.clone())); custom_fields.insert("total_storage".to_string(), serde_json::Value::String(server_info.summary.total_storage.clone())); + custom_fields.insert("rack_height".to_string(), serde_json::Value::String("4U".to_string())); + + // Add BMC information if available + if let Some(bmc_ip) = &server_info.bmc_ip { + if bmc_ip != "0.0.0.0" { + custom_fields.insert("bmc_ip".to_string(), serde_json::Value::String(bmc_ip.clone())); + } + } + if let Some(bmc_mac) = &server_info.bmc_mac { + if bmc_mac != "00:00:00:00:00:00" { + custom_fields.insert("bmc_mac".to_string(), serde_json::Value::String(bmc_mac.clone())); + } + } // Create or update device let device = NetBoxDevice { @@ -620,10 +633,103 @@ pub async fn sync_to_netbox( let device_id = client.create_or_update_device(&device).await?; println!("Created/updated device {} (ID: {})", device.name, device_id); - // Create interfaces and IP addresses + // Create BMC interface first if BMC information is available + let mut bmc_interface_id = None; + if let (Some(bmc_ip), Some(bmc_mac)) = (&server_info.bmc_ip, &server_info.bmc_mac) { + if bmc_ip != "0.0.0.0" && bmc_mac != "00:00:00:00:00:00" { + let bmc_interface = NetBoxInterface { + device: device_id, + name: "BMC".to_string(), + type_: "1000base-t".to_string(), // Most BMCs are 1Gb + enabled: true, + parent: None, + bridge: None, + lag: None, + mtu: None, + mac_address: Some(bmc_mac.clone()), + speed: Some(1_000_000), // 1Gb in Kbps + duplex: Some("auto".to_string()), + wwn: None, + mgmt_only: true, // BMC is always management only + description: Some("Baseboard Management Controller (IPMI/BMC)".to_string()), + mode: None, + rf_role: None, + rf_channel: None, + poe_mode: None, + poe_type: None, + rf_channel_frequency: None, + rf_channel_width: None, + tx_power: None, + untagged_vlan: None, + tagged_vlans: None, + mark_connected: true, // BMC should be connected + cable: None, + cable_end: None, + wireless_link: None, + link_peers: None, + link_peers_type: None, + wireless_lans: None, + vrf: None, + tags: None, + custom_fields: { + let mut cf = HashMap::new(); + cf.insert("interface_type".to_string(), serde_json::Value::String("BMC".to_string())); + Some(cf) + }, + }; + + bmc_interface_id = Some(client.create_interface(&bmc_interface).await?); + println!("Created BMC interface (ID: {})", bmc_interface_id.unwrap()); + + // Create BMC IP address + let subnet_mask = if bmc_ip.starts_with("10.") { + "/8" + } else if bmc_ip.starts_with("172.") { + "/12" + } else if bmc_ip.starts_with("192.168.") { + "/24" + } else { + "/24" + }; + + let bmc_netbox_ip = NetBoxIPAddress { + address: format!("{}{}", bmc_ip, subnet_mask), + vrf: None, + tenant: None, + status: "active".to_string(), + role: Some("vip".to_string()), // BMC IPs are VIPs + assigned_object_type: Some("dcim.interface".to_string()), + assigned_object_id: bmc_interface_id, + nat_inside: None, + nat_outside: None, + dns_name: Some(format!("{}-bmc.example.com", server_info.hostname)), + description: Some("BMC/IPMI Management IP".to_string()), + comments: None, + tags: None, + custom_fields: None, + }; + + let bmc_ip_id = client.create_ip_address(&bmc_netbox_ip).await?; + println!("Created BMC IP address {} (ID: {})", bmc_netbox_ip.address, bmc_ip_id); + } + } + + // Create interfaces and IP addresses from network interfaces let mut primary_ip4_id = None; let mut interface_count = 0; + // Enhanced IP detection - collect all IPs from os_ip field for better coverage + let mut all_interface_ips: HashMap> = HashMap::new(); + for interface_ip in &server_info.os_ip { + let interface_name = &interface_ip.interface; + for ip_addr in &interface_ip.ip_addresses { + all_interface_ips + .entry(interface_name.clone()) + .or_insert_with(Vec::new) + .push(ip_addr.clone()); + } + } + for nic in &server_info.network.interfaces { // Determine if this is a management interface (out-of-band) let is_mgmt = nic.name.contains("ilo") || @@ -709,55 +815,114 @@ pub async fn sync_to_netbox( println!("Created interface {} (ID: {})", interface.name, interface_id); interface_count += 1; - // Create IP addresses for this interface - let ips = vec![&nic.ip]; // NetworkInterface has a single ip field, not ips - for ip in &ips { - if *ip != "127.0.0.1" && !ip.starts_with("::") && !ip.starts_with("fe80:") && *ip != "Unknown" { + // Create IP addresses for this interface - use enhanced IP collection + let interface_ips = all_interface_ips.get(&nic.name) + .cloned() + .unwrap_or_else(|| vec![nic.ip.clone()]); // Fallback to single IP + + for ip in &interface_ips { + if ip != "127.0.0.1" && !ip.starts_with("::") && !ip.starts_with("fe80:") && ip != "Unknown" && !ip.is_empty() { + // Detect Tailscale interfaces + let is_tailscale = nic.name.contains("tailscale") || + nic.name.contains("ts") || + nic.name == "tailscale0" || + // Check if IP is in Tailscale CGNAT range (100.64.0.0/10) + (ip.starts_with("100.") && { + if let Ok(ip_parts) = ip.split('.').take(2).collect::>()[1].parse::() { + ip_parts >= 64 && ip_parts <= 127 + } else { + false + } + }); + // Determine subnet mask based on IP class and common patterns let subnet_mask = if ip.starts_with("10.") { "/8" // Private Class A } else if ip.starts_with("172.") { - "/12" // Private Class B + "/12" // Private Class B } else if ip.starts_with("192.168.") { "/24" // Private Class C } else if ip.starts_with("169.254.") { "/16" // Link-local + } else if is_tailscale { + "/32" // Tailscale IPs are typically /32 } else { "/24" // Default assumption }; + // Determine IP role and priority + let ip_role = if is_mgmt { + Some("vip".to_string()) // Management/OOB IPs are VIPs + } else if is_tailscale { + Some("anycast".to_string()) // Tailscale is overlay/anycast + } else if nic.name.starts_with("eth0") || nic.name.starts_with("eno1") || + nic.name.starts_with("enp") || interface_count == 1 { + Some("loopback".to_string()) // Primary interface + } else { + Some("secondary".to_string()) // Additional interfaces + }; + + let description = if is_mgmt { + "Out-of-band Management IP".to_string() + } else if is_tailscale { + "Tailscale VPN IP".to_string() + } else { + "Primary Network IP".to_string() + }; + let netbox_ip = NetBoxIPAddress { address: format!("{}{}", ip, subnet_mask), vrf: None, tenant: None, status: "active".to_string(), - role: if is_mgmt { - Some("vip".to_string()) // Management/OOB IPs are VIPs - } else if interface_count == 1 || nic.name.starts_with("eth0") || nic.name.starts_with("eno1") { - Some("loopback".to_string()) // Primary interface - } else { - Some("secondary".to_string()) // Additional interfaces - }, + role: ip_role, assigned_object_type: Some("dcim.interface".to_string()), assigned_object_id: Some(interface_id), nat_inside: None, nat_outside: None, - dns_name: if !is_mgmt { Some(server_info.fqdn.clone()) } else { - Some(format!("{}-{}.{}", server_info.hostname, if is_mgmt { "mgmt" } else { "oob" }, "example.com")) + dns_name: if !is_mgmt { + Some(server_info.fqdn.clone()) + } else { + Some(format!("{}-{}.example.com", server_info.hostname, + if is_tailscale { "ts" } else { "mgmt" })) + }, + description: Some(description), + comments: if is_tailscale { + Some("Tailscale mesh VPN address".to_string()) + } else { + None }, - description: Some(format!("{} IP", if is_mgmt { "Out-of-band Management" } else { "Inband Primary" })), - comments: None, tags: None, - custom_fields: None, + custom_fields: { + let mut cf = HashMap::new(); + if is_tailscale { + cf.insert("network_type".to_string(), serde_json::Value::String("Tailscale VPN".to_string())); + } + if cf.is_empty() { None } else { Some(cf) } + }, }; let ip_id = client.create_ip_address(&netbox_ip).await?; - println!("Created IP address {} (ID: {})", netbox_ip.address, ip_id); + println!("Created IP address {} (ID: {}) - {}", + netbox_ip.address, ip_id, + if is_tailscale { "Tailscale" } else if is_mgmt { "Management" } else { "Primary" } + ); - // Set as primary IP if this is the main production interface - if !is_mgmt && primary_ip4_id.is_none() && - (nic.name.starts_with("eth0") || nic.name.starts_with("eno1") || interface_count == 1) { + // Set as primary IP with proper priority: + // 1. Tailscale IPs have highest priority for primary IP + // 2. Then primary interfaces (eth0, eno1, etc.) + // 3. Skip management interfaces for primary IP + if !is_mgmt && ( + (is_tailscale && primary_ip4_id.is_none()) || + (primary_ip4_id.is_none() && ( + nic.name.starts_with("eth0") || + nic.name.starts_with("eno1") || + nic.name.starts_with("enp") || + interface_count == 1 + )) + ) { primary_ip4_id = Some(ip_id); + println!("Set as primary IP: {} ({})", ip, if is_tailscale { "Tailscale" } else { "Standard" }); } } } From 36c267ff7f5c1e570712ef10be4b13b61497e4eb Mon Sep 17 00:00:00 2001 From: Kenny Sheridan Date: Tue, 29 Jul 2025 08:38:26 -0700 Subject: [PATCH 3/5] fix: improve NetBox BMC and primary IP field population - Add oob_ip field to NetBox device structure for BMC IP reference - Store BMC IP ID during creation for proper device field assignment - Improve device update logic to set both primary_ip4 and oob_ip fields - Enhance error handling and debugging output for IP assignments - Ensure BMC interface and IP creation is properly tracked This should fix the blank BMC IP and Primary IPv4 fields in NetBox by: - Creating BMC IP address and storing its ID - Setting device.oob_ip to reference the BMC IP address object - Setting device.primary_ip4 to reference the primary IP address object - Using proper NetBox API field references instead of string values The height issue (1U vs 4U) may require device type recreation or manual update. --- src/netbox.rs | 88 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/src/netbox.rs b/src/netbox.rs index 434f351..8ec6746 100644 --- a/src/netbox.rs +++ b/src/netbox.rs @@ -56,6 +56,7 @@ pub struct NetBoxDevice { pub airflow: Option, // "front-to-rear", "rear-to-front", etc. pub primary_ip4: Option, // ID of primary IPv4 pub primary_ip6: Option, // ID of primary IPv6 + pub oob_ip: Option, // Out-of-band IP address ID (reference to IP address object) pub cluster: Option, // ID of cluster pub virtual_chassis: Option, pub vc_position: Option, @@ -332,7 +333,31 @@ impl NetBoxClient { if let Some(results) = data["results"].as_array() { if !results.is_empty() { if let Some(id) = results[0]["id"].as_u64() { - return Ok(id as u32); + let device_type_id = id as u32; + + // Check if u_height needs to be updated to 4U + if let Some(current_height) = results[0]["u_height"].as_f64() { + if (current_height - u_height as f64).abs() > 0.1 { + // Update the device type height + let update_payload = serde_json::json!({ + "u_height": u_height + }); + + let update_url = format!("{}/api/dcim/device-types/{}/", self.base_url, device_type_id); + let update_response = self.client + .patch(&update_url) + .header("Authorization", format!("Token {}", self.token)) + .json(&update_payload) + .send() + .await?; + + if update_response.status().is_success() { + println!("Updated device type {} height to {}U", model, u_height); + } + } + } + + return Ok(device_type_id); } } } @@ -596,7 +621,31 @@ pub async fn sync_to_netbox( } } - // Create or update device + // Create or update device - need to build this manually to include BMC fields + let mut device_data = serde_json::json!({ + "name": server_info.fqdn, + "device_type": device_type_id, + "device_role": device_role_id, + "serial": server_info.summary.chassis.serial, + "site": site_id, + "face": "front", + "status": "active", + "airflow": "front-to-rear", + "description": format!("{} @ {}", model, site), + "comments": format!("Auto-imported by hardware_report\nUUID: {}", server_info.summary.system_info.uuid), + "custom_fields": custom_fields + }); + + // Add BMC information directly to device fields + if let Some(bmc_ip) = &server_info.bmc_ip { + if bmc_ip != "0.0.0.0" { + device_data["oob_ip"] = serde_json::Value::String(bmc_ip.clone()); + } + } + + // Note: NetBox typically doesn't have a direct BMC MAC field on devices + // The MAC will be associated with the BMC interface we create + let device = NetBoxDevice { name: server_info.fqdn.clone(), device_type: device_type_id, @@ -612,6 +661,7 @@ pub async fn sync_to_netbox( airflow: Some("front-to-rear".to_string()), primary_ip4: None, // Will be set after creating IPs primary_ip6: None, + oob_ip: None, // Will be set to BMC IP ID if available cluster: None, virtual_chassis: None, vc_position: None, @@ -635,6 +685,7 @@ pub async fn sync_to_netbox( // Create BMC interface first if BMC information is available let mut bmc_interface_id = None; + let mut bmc_ip_id = None; if let (Some(bmc_ip), Some(bmc_mac)) = (&server_info.bmc_ip, &server_info.bmc_mac) { if bmc_ip != "0.0.0.0" && bmc_mac != "00:00:00:00:00:00" { let bmc_interface = NetBoxInterface { @@ -709,8 +760,8 @@ pub async fn sync_to_netbox( custom_fields: None, }; - let bmc_ip_id = client.create_ip_address(&bmc_netbox_ip).await?; - println!("Created BMC IP address {} (ID: {})", bmc_netbox_ip.address, bmc_ip_id); + bmc_ip_id = Some(client.create_ip_address(&bmc_netbox_ip).await?); + println!("Created BMC IP address {} (ID: {})", bmc_netbox_ip.address, bmc_ip_id.unwrap()); } } @@ -928,20 +979,35 @@ pub async fn sync_to_netbox( } } - // Update device with primary IP if found + // Update device with primary IP and BMC information if found + let mut update_payload = serde_json::json!({}); + if let Some(primary_ip) = primary_ip4_id { - let update_payload = serde_json::json!({ - "primary_ip4": primary_ip - }); - + update_payload["primary_ip4"] = serde_json::Value::Number(primary_ip.into()); + println!("Setting primary IPv4 to ID: {}", primary_ip); + } + + // Set BMC IP as out-of-band IP if we created one + if let Some(bmc_ip_ref) = bmc_ip_id { + update_payload["oob_ip"] = serde_json::Value::Number(bmc_ip_ref.into()); + println!("Setting out-of-band IP to BMC IP ID: {}", bmc_ip_ref); + } + + // Only update if we have changes to make + if !update_payload.as_object().unwrap().is_empty() { let update_url = format!("{}/api/dcim/devices/{}/", client.base_url, device_id); - let _response = client.client + let response = client.client .patch(&update_url) .header("Authorization", format!("Token {}", client.token)) .json(&update_payload) .send() .await?; - println!("Updated device primary IP"); + + if response.status().is_success() { + println!("Successfully updated device with IP assignments"); + } else { + println!("Warning: Failed to update device IP assignments: {}", response.status()); + } } // Create inventory items for components From faf5c82dc93b01b80315337dcd35da96f583e343 Mon Sep 17 00:00:00 2001 From: Kenny Sheridan Date: Tue, 29 Jul 2025 08:42:51 -0700 Subject: [PATCH 4/5] debug: add comprehensive NetBox field debugging and fix API usage - Remove incorrect oob_ip field (NetBox doesn't have this native field) - Add detailed debugging output for IP creation and device updates - Show which IPs are selected as primary with clear indicators - Display full update payload being sent to NetBox - Add error details when device updates fail - Store BMC info in custom fields since NetBox lacks native BMC fields This version will show exactly: - Which IPs are created and their IDs - Which IP is selected as primary and why - The exact payload sent to update the device - Any errors returned by NetBox API Run with --netbox-dry-run first to see the debug output without making changes. --- src/netbox.rs | 54 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/src/netbox.rs b/src/netbox.rs index 8ec6746..af45ac1 100644 --- a/src/netbox.rs +++ b/src/netbox.rs @@ -56,7 +56,7 @@ pub struct NetBoxDevice { pub airflow: Option, // "front-to-rear", "rear-to-front", etc. pub primary_ip4: Option, // ID of primary IPv4 pub primary_ip6: Option, // ID of primary IPv6 - pub oob_ip: Option, // Out-of-band IP address ID (reference to IP address object) + // Note: NetBox doesn't have a native oob_ip field - BMC IP is handled via interfaces pub cluster: Option, // ID of cluster pub virtual_chassis: Option, pub vc_position: Option, @@ -661,7 +661,7 @@ pub async fn sync_to_netbox( airflow: Some("front-to-rear".to_string()), primary_ip4: None, // Will be set after creating IPs primary_ip6: None, - oob_ip: None, // Will be set to BMC IP ID if available + // NetBox doesn't have oob_ip field - BMC handled via interfaces cluster: None, virtual_chassis: None, vc_position: None, @@ -961,9 +961,9 @@ pub async fn sync_to_netbox( // Set as primary IP with proper priority: // 1. Tailscale IPs have highest priority for primary IP - // 2. Then primary interfaces (eth0, eno1, etc.) + // 2. Then primary interfaces (eth0, eno1, etc.) // 3. Skip management interfaces for primary IP - if !is_mgmt && ( + let should_be_primary = !is_mgmt && ( (is_tailscale && primary_ip4_id.is_none()) || (primary_ip4_id.is_none() && ( nic.name.starts_with("eth0") || @@ -971,9 +971,17 @@ pub async fn sync_to_netbox( nic.name.starts_with("enp") || interface_count == 1 )) - ) { + ); + + if should_be_primary { primary_ip4_id = Some(ip_id); - println!("Set as primary IP: {} ({})", ip, if is_tailscale { "Tailscale" } else { "Standard" }); + println!("✓ Selected as PRIMARY IP: {} on interface {} (ID: {}) - {}", + ip, nic.name, ip_id, + if is_tailscale { "Tailscale VPN" } else { "Standard Network" }); + } else { + println!(" Created IP: {} on interface {} (ID: {}) - {}", + ip, nic.name, ip_id, + if is_mgmt { "Management" } else if is_tailscale { "Tailscale" } else { "Secondary" }); } } } @@ -984,18 +992,31 @@ pub async fn sync_to_netbox( if let Some(primary_ip) = primary_ip4_id { update_payload["primary_ip4"] = serde_json::Value::Number(primary_ip.into()); - println!("Setting primary IPv4 to ID: {}", primary_ip); + println!("📡 UPDATING DEVICE: Setting primary_ip4 field to IP address ID: {}", primary_ip); + } else { + println!("âš ī¸ WARNING: No primary IP was selected for device!"); } - // Set BMC IP as out-of-band IP if we created one - if let Some(bmc_ip_ref) = bmc_ip_id { - update_payload["oob_ip"] = serde_json::Value::Number(bmc_ip_ref.into()); - println!("Setting out-of-band IP to BMC IP ID: {}", bmc_ip_ref); + // Add BMC information to custom fields for visibility since NetBox doesn't have native BMC fields + if let (Some(bmc_ip), Some(bmc_mac)) = (&server_info.bmc_ip, &server_info.bmc_mac) { + if bmc_ip != "0.0.0.0" && bmc_mac != "00:00:00:00:00:00" { + let mut custom_fields = HashMap::new(); + custom_fields.insert("bmc_ip_address".to_string(), serde_json::Value::String(bmc_ip.clone())); + custom_fields.insert("bmc_mac_address".to_string(), serde_json::Value::String(bmc_mac.clone())); + + update_payload["custom_fields"] = serde_json::Value::Object( + custom_fields.into_iter().collect() + ); + println!("Added BMC IP ({}) and MAC ({}) to custom fields", bmc_ip, bmc_mac); + } } // Only update if we have changes to make if !update_payload.as_object().unwrap().is_empty() { let update_url = format!("{}/api/dcim/devices/{}/", client.base_url, device_id); + println!("🔄 Sending device update to: {}", update_url); + println!("📋 Update payload: {}", serde_json::to_string_pretty(&update_payload)?); + let response = client.client .patch(&update_url) .header("Authorization", format!("Token {}", client.token)) @@ -1003,11 +1024,16 @@ pub async fn sync_to_netbox( .send() .await?; - if response.status().is_success() { - println!("Successfully updated device with IP assignments"); + let status = response.status(); + if status.is_success() { + println!("✅ Successfully updated device with IP assignments"); } else { - println!("Warning: Failed to update device IP assignments: {}", response.status()); + let error_body = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + eprintln!("❌ Failed to update device IP assignments: Status: {}", status); + eprintln!(" Error details: {}", error_body); } + } else { + println!("â„šī¸ No device updates needed (empty payload)"); } // Create inventory items for components From c228437f0c976a3d7af54f82a5d268463edab9e6 Mon Sep 17 00:00:00 2001 From: "Kenny (Knight) Sheridan" Date: Tue, 23 Sep 2025 20:47:51 -0700 Subject: [PATCH 5/5] docs: add direct Nix flake installation option and fix Darwin compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new Option 3 for direct flake installation from GitHub - Fix Darwin compatibility by making Linux-specific tools conditional - Update flake.nix to use proper Apple SDK frameworks - Improve development shell hook with platform-specific messaging - Suppress verbose direnv output for cleaner development experience 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .envrc | 1 + README.md | 22 +++++++++++++++++++++- flake.nix | 24 ++++++++++++++++++++---- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.envrc b/.envrc index 8392d15..790e575 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,2 @@ +export DIRENV_LOG_FORMAT="" use flake \ No newline at end of file diff --git a/README.md b/README.md index 59966d5..f3809da 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,27 @@ echo "Build complete! Run with: sudo ./target/release/hardware_report" && \ sudo ./target/release/hardware_report ``` -### Option 3: Pre-built Releases (Recommended for Quick Setup) +### Option 3: Direct Flake Installation (Nix Users) + +**Install directly from GitHub without cloning:** +```bash +# Install to user profile +nix profile install github:sfcompute/hardware_report + +# Run directly without installing +nix run github:sfcompute/hardware_report + +# Use in a nix shell +nix shell github:sfcompute/hardware_report +``` + +**Benefits:** +- No need to clone the repository +- Always uses the latest committed version +- Automatic dependency management +- Clean integration with existing Nix workflows + +### Option 4: Pre-built Releases (Recommended for Quick Setup) Instead of building from source, you can download pre-built binaries and Debian packages from our [GitHub Releases](https://github.com/sfcompute/hardware_report/releases) page. diff --git a/flake.nix b/flake.nix index 189e53b..0abab99 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,10 @@ { description = "Hardware Report - A tool for generating hardware information reports"; + + nixConfig = { + extra-substituters = [ "https://cache.nixos.org/" ]; + extra-trusted-public-keys = [ "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" ]; + }; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -37,11 +42,14 @@ # Runtime dependencies that the binary needs runtimeDeps = with pkgs; [ - numactl + # Cross-platform tools ipmitool + pciutils # for lspci + ] ++ lib.optionals stdenv.isLinux [ + # Linux-only tools + numactl ethtool util-linux # for lscpu - pciutils # for lspci dmidecode # for system/BIOS/memory information ]; @@ -255,8 +263,16 @@ EOF echo "Run 'cargo build' to build the project" echo "Run 'cargo run' to run the project" echo "" - echo "Runtime dependencies are available in PATH:" - echo "- numactl, ipmitool, ethtool, lscpu, lspci, dmidecode" + echo "Runtime dependencies available in PATH:" + echo "- ipmitool, pciutils (lspci)" + ${if pkgs.stdenv.isLinux then '' + echo "- numactl, ethtool, util-linux (lscpu), dmidecode" + '' else '' + echo "- (Linux-only tools like numactl, ethtool not available on Darwin)" + ''} + echo "" + echo "Platform: ${pkgs.stdenv.hostPlatform.system}" + echo "Rust toolchain: ${rustToolchain.version}" ''; };