pub mod http;
pub mod netbox;
pub mod phpipam;
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde_json::{Map, Value};
use tracing::{info, warn};
use crate::config::IpamConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AddressNames {
Unknown,
Known(BTreeSet<String>),
}
impl AddressNames {
#[must_use]
pub fn known() -> Self {
Self::Known(BTreeSet::new())
}
pub fn insert(&mut self, value: &str) {
if let Self::Known(names) = self {
let name = normalize(value);
if !name.is_empty() {
names.insert(name);
}
}
}
#[must_use]
pub fn is_known(&self) -> bool {
matches!(self, Self::Known(_))
}
#[must_use]
pub fn names(&self) -> &BTreeSet<String> {
static EMPTY: std::sync::OnceLock<BTreeSet<String>> = std::sync::OnceLock::new();
match self {
Self::Known(names) => names,
Self::Unknown => EMPTY.get_or_init(BTreeSet::new),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct IpamError(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Source {
DnsName,
CustomField,
Device,
Vip,
Fhrp,
}
impl Source {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::DnsName => "dns_name",
Self::CustomField => "custom_field",
Self::Device => "device",
Self::Vip => "vip",
Self::Fhrp => "fhrp",
}
}
const ALL: &'static [Self] = &[
Self::DnsName,
Self::CustomField,
Self::Device,
Self::Vip,
Self::Fhrp,
];
fn parse(name: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|s| s.as_str() == name)
}
}
pub type Sources = BTreeSet<Source>;
pub(crate) fn parse_sources(
backend: &str,
setting: &str,
values: &[String],
supported: &[Source],
) -> anyhow::Result<Sources> {
anyhow::ensure!(
!values.is_empty(),
"{setting} is empty; an inventory trusted for nothing can never permit a name, so \
every request would be refused. List at least one of: {}",
names_of(supported)
);
let mut sources = Sources::new();
for value in values {
let name = value.trim();
let source = Source::parse(name).ok_or_else(|| {
anyhow::anyhow!(
"{setting}: unknown source `{name}`; known sources are {}",
names_of(Source::ALL)
)
})?;
anyhow::ensure!(
supported.contains(&source),
"{setting}: `{name}` is not a source {backend} has; it supports {}",
names_of(supported)
);
sources.insert(source);
}
Ok(sources)
}
fn names_of(sources: &[Source]) -> String {
sources
.iter()
.map(|source| format!("`{}`", source.as_str()))
.collect::<Vec<_>>()
.join(", ")
}
#[async_trait]
pub trait Ipam: Send + Sync {
fn name(&self) -> &'static str;
async fn names_for(&self, ip: IpAddr) -> Result<AddressNames, IpamError>;
}
pub struct IpamRegistry {
backend: Arc<dyn Ipam>,
timeout: Duration,
}
impl std::fmt::Debug for IpamRegistry {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("IpamRegistry")
.field("backend", &self.backend.name())
.field("timeout", &self.timeout)
.finish()
}
}
impl IpamRegistry {
#[must_use]
pub fn new(backend: Arc<dyn Ipam>, timeout: Duration) -> Self {
Self { backend, timeout }
}
#[must_use]
pub fn backend_name(&self) -> &'static str {
self.backend.name()
}
pub async fn names_for(&self, ip: IpAddr) -> Result<AddressNames, IpamError> {
match tokio::time::timeout(self.timeout, self.backend.names_for(ip)).await {
Ok(result) => result,
Err(_) => Err(IpamError(format!(
"{} lookup for {ip} timed out after {}ms",
self.backend.name(),
self.timeout.as_millis()
))),
}
}
}
pub fn from_config(
cfg: &IpamConfig,
outbound: crate::http_client::Outbound,
) -> anyhow::Result<Option<Arc<IpamRegistry>>> {
let backend: Arc<dyn Ipam> = match cfg.backend.trim() {
"" => return Ok(None),
"netbox" => Arc::new(netbox::NetboxBackend::from_config(&cfg.netbox, outbound)?),
"phpipam" => Arc::new(phpipam::PhpIpamBackend::from_config(
&cfg.phpipam,
outbound,
)?),
other => anyhow::bail!("unknown IPAM backend: {other} (expected `netbox` or `phpipam`)"),
};
info!(
event = "ipam_enabled",
outcome = "success",
backend = backend.name(),
timeout_ms = cfg.timeout_ms,
);
Ok(Some(Arc::new(IpamRegistry::new(
backend,
Duration::from_millis(cfg.timeout_ms),
))))
}
#[must_use]
pub fn normalize(value: &str) -> String {
value.trim().trim_end_matches('.').to_ascii_lowercase()
}
pub(crate) fn field_values(
fields: &Map<String, Value>,
field: &str,
backend: &'static str,
source: &str,
) -> Vec<String> {
match fields.get(field) {
None | Some(Value::Null) => Vec::new(),
Some(Value::String(one)) => vec![one.clone()],
Some(Value::Array(items)) => items
.iter()
.filter_map(|item| match item {
Value::String(name) => Some(name.clone()),
other => {
warn!(
event = "ipam_field_entry_ignored",
outcome = "advisory",
backend,
field,
source,
entry = %other,
"custom field entry is not a string"
);
None
}
})
.collect(),
Some(other) => {
warn!(
event = "ipam_field_ignored",
outcome = "advisory",
backend,
field,
source,
kind = value_kind(other),
"custom field is neither a string nor a list of strings"
);
Vec::new()
}
}
}
pub(crate) fn value_kind(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|v| (*v).to_string()).collect()
}
fn resolver() -> Arc<dyn crate::dns::Resolver> {
crate::challenge::build_resolver(None).unwrap()
}
#[test]
fn every_source_round_trips_through_its_name() {
for source in Source::ALL {
assert_eq!(Source::parse(source.as_str()), Some(*source));
}
assert_eq!(Source::parse("nope"), None);
}
#[test]
fn sources_parse_and_deduplicate() {
let parsed = parse_sources(
"NetBox",
"ipam.netbox.sources",
&strings(&["dns_name", "custom_field", "dns_name"]),
Source::ALL,
)
.unwrap();
assert_eq!(parsed.len(), 2);
assert!(parsed.contains(&Source::DnsName));
assert!(parsed.contains(&Source::CustomField));
}
#[test]
fn sources_are_trimmed() {
let parsed = parse_sources(
"NetBox",
"ipam.netbox.sources",
&strings(&[" dns_name "]),
Source::ALL,
)
.unwrap();
assert!(parsed.contains(&Source::DnsName));
}
#[test]
fn an_empty_sources_list_is_a_startup_error() {
let error = parse_sources("NetBox", "ipam.netbox.sources", &[], Source::ALL).unwrap_err();
let message = error.to_string();
assert!(
message.contains("ipam.netbox.sources is empty"),
"{message}"
);
assert!(message.contains("`dns_name`"), "{message}");
}
#[test]
fn an_unknown_source_is_a_startup_error_naming_it() {
let error = parse_sources(
"NetBox",
"ipam.netbox.sources",
&strings(&["dns_name", "typo"]),
Source::ALL,
)
.unwrap_err();
let message = error.to_string();
assert!(message.contains("unknown source `typo`"), "{message}");
assert!(message.contains("`fhrp`"), "{message}");
}
#[test]
fn a_source_another_backend_has_is_refused_by_name() {
let error = parse_sources(
"phpIPAM",
"ipam.phpipam.sources",
&strings(&["dns_name", "fhrp"]),
&[Source::DnsName, Source::CustomField, Source::Device],
)
.unwrap_err();
let message = error.to_string();
assert!(
message.contains("`fhrp` is not a source phpIPAM has"),
"{message}"
);
assert!(message.contains("`device`"), "{message}");
assert!(!message.contains("`vip`"), "{message}");
}
#[test]
fn an_unknown_address_is_not_an_empty_one() {
let unknown = AddressNames::Unknown;
let empty = AddressNames::known();
assert!(!unknown.is_known());
assert!(empty.is_known());
assert_eq!(unknown.names().len(), 0);
assert_ne!(unknown, empty);
}
#[test]
fn inserting_normalizes_and_skips_empties() {
let mut names = AddressNames::known();
names.insert("Host.Example.COM.");
names.insert(" ");
names.insert("");
names.insert("host.example.com");
assert_eq!(
names.names().iter().cloned().collect::<Vec<_>>(),
vec!["host.example.com".to_string()]
);
}
#[test]
fn inserting_into_an_unknown_address_does_nothing() {
let mut names = AddressNames::Unknown;
names.insert("host.example.com");
assert_eq!(names, AddressNames::Unknown);
}
#[test]
fn normalize_lowercases_and_strips_a_trailing_dot() {
assert_eq!(normalize(" Host.Example.COM. "), "host.example.com");
assert_eq!(normalize("*.Example.com"), "*.example.com");
}
#[test]
fn a_custom_field_may_be_a_string_or_a_list() {
let fields: Map<String, Value> = serde_json::from_value(json!({
"one": "a.example.com",
"many": ["a.example.com", "b.example.com"],
}))
.unwrap();
assert_eq!(field_values(&fields, "one", "NetBox", "address").len(), 1);
assert_eq!(field_values(&fields, "many", "NetBox", "address").len(), 2);
}
#[test]
fn an_unusable_custom_field_contributes_nothing() {
let fields: Map<String, Value> = serde_json::from_value(json!({
"absent": Value::Null,
"number": 7,
"object": {"a": 1},
"mixed": ["a.example.com", 7, {"b": 2}],
}))
.unwrap();
assert!(field_values(&fields, "missing", "NetBox", "address").is_empty());
assert!(field_values(&fields, "absent", "NetBox", "address").is_empty());
assert!(field_values(&fields, "number", "NetBox", "address").is_empty());
assert!(field_values(&fields, "object", "NetBox", "address").is_empty());
assert_eq!(field_values(&fields, "mixed", "NetBox", "address").len(), 1);
}
#[test]
fn value_kind_names_every_json_type() {
assert_eq!(value_kind(&Value::Null), "null");
assert_eq!(value_kind(&json!(true)), "bool");
assert_eq!(value_kind(&json!(1)), "number");
assert_eq!(value_kind(&json!("s")), "string");
assert_eq!(value_kind(&json!([])), "array");
assert_eq!(value_kind(&json!({})), "object");
}
#[test]
fn no_backend_builds_nothing() {
let cfg = IpamConfig::default();
assert!(
from_config(&cfg, crate::testutil::outbound_with(resolver()))
.unwrap()
.is_none()
);
}
#[test]
fn each_backend_builds() {
let netbox = from_config(
&IpamConfig {
backend: "netbox".to_string(),
netbox: crate::config::NetboxConfig {
url: "https://netbox.example.com".to_string(),
token: "t0ken".to_string(),
..crate::config::NetboxConfig::default()
},
..IpamConfig::default()
},
crate::testutil::outbound_with(resolver()),
)
.unwrap()
.unwrap();
assert_eq!(netbox.backend_name(), "NetBox");
let phpipam = from_config(
&IpamConfig {
backend: "phpipam".to_string(),
phpipam: crate::config::PhpIpamConfig {
url: "https://ipam.example.com".to_string(),
token: "t0ken".to_string(),
..crate::config::PhpIpamConfig::default()
},
..IpamConfig::default()
},
crate::testutil::outbound_with(resolver()),
)
.unwrap()
.unwrap();
assert_eq!(phpipam.backend_name(), "phpIPAM");
}
#[test]
fn an_unknown_backend_is_a_startup_error_naming_both_valid_ones() {
let cfg = IpamConfig {
backend: "racktables".to_string(),
..IpamConfig::default()
};
let error = from_config(&cfg, crate::testutil::outbound_with(resolver()))
.unwrap_err()
.to_string();
assert!(error.contains("racktables"), "{error}");
assert!(error.contains("netbox"), "{error}");
assert!(error.contains("phpipam"), "{error}");
}
struct Hanging;
#[async_trait]
impl Ipam for Hanging {
fn name(&self) -> &'static str {
"Hanging"
}
async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
tokio::time::sleep(Duration::from_secs(3600)).await;
unreachable!("the registry's budget expires first")
}
}
struct Answering;
#[async_trait]
impl Ipam for Answering {
fn name(&self) -> &'static str {
"Answering"
}
async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
let mut names = AddressNames::known();
names.insert("a.example.com");
Ok(names)
}
}
#[tokio::test]
async fn the_registry_applies_the_budget() {
let registry = IpamRegistry::new(Arc::new(Hanging), Duration::from_millis(10));
let error = registry
.names_for("10.0.0.5".parse().unwrap())
.await
.unwrap_err();
assert!(error.0.contains("timed out after 10ms"), "{error}");
assert!(error.0.contains("Hanging"), "{error}");
}
#[tokio::test]
async fn a_prompt_backend_answers_through_the_registry() {
let registry = IpamRegistry::new(Arc::new(Answering), Duration::from_secs(5));
let names = registry
.names_for("10.0.0.5".parse().unwrap())
.await
.unwrap();
assert!(names.names().contains("a.example.com"));
assert_eq!(registry.backend_name(), "Answering");
assert!(format!("{registry:?}").contains("Answering"));
}
}