use std::sync::atomic::AtomicBool;
use serde::Deserialize;
use super::{Provider, ProviderError, ProviderHost, map_ureq_error};
pub struct NetBox {
pub base_url: String,
pub verify_tls: bool,
pub filter: String,
}
const V2_TOKEN_PREFIX: &str = "nbt_";
const DEFAULT_FILTER: &str = "status=active";
fn auth_header(token: &str) -> String {
if token.starts_with(V2_TOKEN_PREFIX) {
format!("Bearer {token}")
} else {
format!("Token {token}")
}
}
fn normalize_filter(filter: &str) -> String {
let trimmed = filter.trim().trim_start_matches(['?', '&']);
if trimmed.is_empty() {
DEFAULT_FILTER.to_string()
} else {
trimmed.to_string()
}
}
#[derive(Deserialize)]
struct NetBoxListResponse {
#[serde(default)]
next: Option<String>,
#[serde(default)]
results: Vec<NetBoxObject>,
}
#[derive(Deserialize)]
struct NetBoxObject {
id: u64,
#[serde(default)]
name: Option<String>,
#[serde(default)]
status: Option<NetBoxStatus>,
#[serde(default)]
primary_ip4: Option<NetBoxIp>,
#[serde(default)]
primary_ip6: Option<NetBoxIp>,
#[serde(default)]
tags: Vec<NetBoxTag>,
#[serde(default)]
site: Option<NetBoxNamedRef>,
#[serde(default, alias = "device_role")]
role: Option<NetBoxNamedRef>,
#[serde(default)]
platform: Option<NetBoxNamedRef>,
#[serde(default)]
device_type: Option<NetBoxDeviceType>,
#[serde(default)]
cluster: Option<NetBoxNamedRef>,
}
#[derive(Deserialize)]
struct NetBoxStatus {
#[serde(default)]
value: String,
}
#[derive(Deserialize)]
struct NetBoxIp {
#[serde(default)]
address: String,
}
#[derive(Deserialize)]
struct NetBoxTag {
#[serde(default)]
slug: String,
}
#[derive(Deserialize)]
struct NetBoxNamedRef {
#[serde(default)]
name: String,
}
#[derive(Deserialize)]
struct NetBoxDeviceType {
#[serde(default)]
model: String,
}
#[derive(Clone, Copy)]
enum ObjectKind {
Device,
Vm,
}
fn map_object(obj: &NetBoxObject, kind: ObjectKind) -> Option<ProviderHost> {
let name = obj.name.as_deref().unwrap_or("").trim();
if name.is_empty() {
return None;
}
let ip = obj
.primary_ip4
.as_ref()
.filter(|ip| !ip.address.is_empty())
.or_else(|| obj.primary_ip6.as_ref().filter(|ip| !ip.address.is_empty()))
.map(|ip| super::strip_cidr(&ip.address).to_string())?;
let mut tags: Vec<String> = obj
.tags
.iter()
.map(|t| t.slug.clone())
.filter(|s| !s.is_empty())
.collect();
tags.sort();
let mut metadata = super::ProviderMetadata::new();
let named =
|r: &Option<NetBoxNamedRef>| r.as_ref().map(|n| n.name.clone()).filter(|n| !n.is_empty());
metadata.push_opt("location", named(&obj.site));
metadata.push_opt("role", named(&obj.role));
metadata.push_opt("os", named(&obj.platform));
match kind {
ObjectKind::Device => {
metadata.push_opt(
"type",
obj.device_type
.as_ref()
.map(|d| d.model.clone())
.filter(|m| !m.is_empty()),
);
}
ObjectKind::Vm => {
metadata.push_opt("cluster", named(&obj.cluster));
}
}
metadata.push_opt(
"status",
obj.status
.as_ref()
.map(|s| s.value.clone())
.filter(|v| !v.is_empty()),
);
let prefix = match kind {
ObjectKind::Device => "device",
ObjectKind::Vm => "vm",
};
Some(ProviderHost {
server_id: format!("{prefix}-{}", obj.id),
name: name.to_string(),
ip,
tags,
metadata: metadata.finish(),
..Default::default()
})
}
const PAGE_SIZE: u64 = 100;
const DEVICES_ENDPOINT: &str = "/api/dcim/devices/";
const VMS_ENDPOINT: &str = "/api/virtualization/virtual-machines/";
impl NetBox {
fn make_agent(&self) -> Result<ureq::Agent, ProviderError> {
if self.verify_tls {
Ok(super::http_agent())
} else {
super::http_agent_insecure()
}
}
fn fetch_from(
&self,
base_url: &str,
token: &str,
cancel: &AtomicBool,
) -> Result<Vec<ProviderHost>, ProviderError> {
let agent = self.make_agent()?;
let auth = auth_header(token);
let base = base_url.trim_end_matches('/');
let filter = normalize_filter(&self.filter);
enum Stage {
Devices,
Vms,
}
let mut stage = Stage::Devices;
let mut offset = 0u64;
super::paginate(cancel, |_idx| {
let (endpoint, kind) = match stage {
Stage::Devices => (DEVICES_ENDPOINT, ObjectKind::Device),
Stage::Vms => (VMS_ENDPOINT, ObjectKind::Vm),
};
let url = format!("{base}{endpoint}?limit={PAGE_SIZE}&offset={offset}&{filter}");
let resp: NetBoxListResponse = agent
.get(&url)
.header("Authorization", &auth)
.call()
.map_err(map_ureq_error)?
.body_mut()
.read_json()
.map_err(|e| ProviderError::Parse(e.to_string()))?;
let hosts = resp
.results
.iter()
.filter_map(|obj| map_object(obj, kind))
.collect();
let more = if resp.next.is_some() {
offset += PAGE_SIZE;
true
} else {
match stage {
Stage::Devices => {
stage = Stage::Vms;
offset = 0;
true
}
Stage::Vms => false,
}
};
Ok(super::PageResult { hosts, more })
})
}
}
impl Provider for NetBox {
fn name(&self) -> &str {
"netbox"
}
fn short_label(&self) -> &str {
"nb"
}
fn fetch_hosts_cancellable(
&self,
token: &str,
cancel: &AtomicBool,
_env: &crate::runtime::env::Env,
) -> Result<Vec<ProviderHost>, ProviderError> {
let base = self.base_url.trim();
if base.is_empty() {
return Err(ProviderError::Http("No NetBox URL configured.".to_string()));
}
if !base.to_ascii_lowercase().starts_with("https://") {
return Err(ProviderError::Http(
"NetBox URL must use HTTPS. Update the URL in ~/.purple/providers.".to_string(),
));
}
self.fetch_from(base, token, cancel)
}
}
#[cfg(test)]
#[path = "netbox_tests.rs"]
mod tests;