use std::net::IpAddr;
use async_trait::async_trait;
use hyper::{StatusCode, header::HeaderName};
use serde::Deserialize;
use serde_json::{Map, Value};
use super::{PhpIpamAddress, PhpIpamApi};
use crate::config::PhpIpamConfig;
use crate::ipam::http::{JsonApi, JsonApiError, tls_config};
const TOKEN_HEADER: &str = "token";
#[derive(Debug)]
pub struct PhpIpamClient {
api: JsonApi,
app_id: String,
}
impl PhpIpamClient {
pub fn new(
cfg: &PhpIpamConfig,
outbound: crate::http_client::Outbound,
) -> anyhow::Result<Self> {
anyhow::ensure!(
!cfg.url.trim().is_empty(),
"ipam.backend is `phpipam` but ipam.phpipam.url is empty; give the base URL of the \
phpIPAM instance"
);
anyhow::ensure!(
!cfg.token.trim().is_empty(),
"ipam.backend is `phpipam` but ipam.phpipam.token is empty; supply the API \
application's app code, preferably through ACME_PROXY_IPAM__PHPIPAM__TOKEN"
);
let app_id = cfg.app_id.trim().to_string();
anyhow::ensure!(
!app_id.is_empty()
&& app_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"ipam.phpipam.app_id must be a single path segment of letters, digits, `-` or `_` \
(the API application's identifier in phpIPAM); got `{}`",
cfg.app_id
);
let header = HeaderName::from_static(TOKEN_HEADER);
Ok(Self {
api: JsonApi::new(
&cfg.url,
"ipam.phpipam.url",
vec![(header, cfg.token.clone())],
tls_config(
&cfg.ca_cert_path,
cfg.insecure_skip_verify,
"ipam.phpipam.ca_cert_path",
)?,
outbound,
)?,
app_id,
})
}
}
#[derive(Debug, Deserialize)]
struct Envelope {
#[serde(default)]
data: Value,
}
#[derive(Debug, Deserialize)]
struct AddressRow {
#[serde(default)]
hostname: Option<String>,
#[serde(rename = "deviceId", default)]
device_id: Option<Value>,
#[serde(flatten)]
fields: Map<String, Value>,
}
impl From<AddressRow> for PhpIpamAddress {
fn from(row: AddressRow) -> Self {
Self {
hostname: row.hostname.unwrap_or_default(),
device_id: row.device_id.as_ref().and_then(numeric_id),
fields: row.fields,
}
}
}
fn numeric_id(value: &Value) -> Option<u64> {
let id = match value {
Value::Number(number) => number.as_u64()?,
Value::String(text) => text.trim().parse().ok()?,
_ => return None,
};
(id != 0).then_some(id)
}
#[async_trait]
impl PhpIpamApi for PhpIpamClient {
async fn search(&self, ip: IpAddr) -> Result<Option<Vec<PhpIpamAddress>>, String> {
let path = format!("/api/{}/addresses/search/{ip}/", self.app_id);
let body = match self.api.get(&path).await {
Ok(body) => body,
Err(JsonApiError {
status: Some(StatusCode::NOT_FOUND),
..
}) => return Ok(None),
Err(error) => return Err(error.message),
};
let envelope: Envelope = serde_json::from_value(body)
.map_err(|error| format!("unexpected addresses/search response: {error}"))?;
let rows: Vec<AddressRow> = serde_json::from_value(envelope.data)
.map_err(|error| format!("unexpected addresses/search data: {error}"))?;
Ok(Some(rows.into_iter().map(PhpIpamAddress::from).collect()))
}
async fn device(&self, id: u64) -> Result<Map<String, Value>, String> {
let path = format!("/api/{}/devices/{id}/", self.app_id);
let body = self
.api
.get(&path)
.await
.map_err(|error: JsonApiError| error.message)?;
let envelope: Envelope = serde_json::from_value(body)
.map_err(|error| format!("unexpected response for {path}: {error}"))?;
match envelope.data {
Value::Object(fields) => Ok(fields),
Value::Null | Value::Array(_) => Ok(Map::new()),
other => Err(format!(
"unexpected response for {path}: data is a {}",
crate::ipam::value_kind(&other)
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ipam::http::testing::{
closed_port, ok, serve_once, serve_once_tls, status, test_resolver,
};
use serde_json::json;
fn config(url: &str) -> PhpIpamConfig {
PhpIpamConfig {
url: url.to_string(),
token: "t0ken".to_string(),
..PhpIpamConfig::default()
}
}
#[test]
fn an_empty_url_is_a_startup_error() {
let error = PhpIpamClient::new(
&config(" "),
crate::testutil::outbound_with(test_resolver()),
)
.unwrap_err()
.to_string();
assert!(error.contains("ipam.phpipam.url"), "{error}");
}
#[test]
fn an_empty_token_is_a_startup_error() {
let cfg = PhpIpamConfig {
token: String::new(),
..config("https://ipam.example.com")
};
let error = PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
.unwrap_err()
.to_string();
assert!(error.contains("ipam.phpipam.token"), "{error}");
assert!(error.contains("ACME_PROXY_IPAM__PHPIPAM__TOKEN"), "{error}");
}
#[test]
fn an_app_id_that_is_not_one_path_segment_is_a_startup_error() {
for bad in ["", " ", "a/b", "a?b", "a b", "../admin"] {
let cfg = PhpIpamConfig {
app_id: bad.to_string(),
..config("https://ipam.example.com")
};
let error = PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
.unwrap_err()
.to_string();
assert!(error.contains("ipam.phpipam.app_id"), "{bad}: {error}");
}
}
#[test]
fn a_missing_ca_certificate_is_a_startup_error() {
let cfg = PhpIpamConfig {
ca_cert_path: "/nonexistent/ipam-ca.pem".to_string(),
..config("https://ipam.example.com")
};
let error = PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
.unwrap_err()
.to_string();
assert!(error.contains("ipam.phpipam.ca_cert_path"), "{error}");
}
#[test]
fn the_debug_impl_never_renders_the_token() {
let client = PhpIpamClient::new(
&config("https://ipam.example.com"),
crate::testutil::outbound_with(test_resolver()),
)
.unwrap();
let rendered = format!("{client:?}");
assert!(!rendered.contains("t0ken"), "{rendered}");
}
#[test]
fn a_device_id_reads_from_either_json_type() {
assert_eq!(numeric_id(&json!(3)), Some(3));
assert_eq!(numeric_id(&json!("3")), Some(3));
assert_eq!(numeric_id(&json!(" 3 ")), Some(3));
assert_eq!(numeric_id(&json!(0)), None);
assert_eq!(numeric_id(&json!("0")), None);
assert_eq!(numeric_id(&Value::Null), None);
assert_eq!(numeric_id(&json!("")), None);
assert_eq!(numeric_id(&json!([3])), None);
}
mod loopback {
use super::*;
fn client(port: u16) -> PhpIpamClient {
PhpIpamClient::new(
&config(&format!("http://127.0.0.1:{port}")),
crate::testutil::outbound_with(test_resolver()),
)
.unwrap()
}
fn one_address() -> Value {
json!({
"code": 200,
"success": true,
"data": [{
"id": "12",
"subnetId": "4",
"ip": "10.0.0.5",
"hostname": "host.example.com",
"deviceId": "3",
"custom_acme_domains": "www.example.com"
}]
})
}
#[tokio::test]
async fn searches_the_address_and_authenticates() {
let (port, server) = serve_once(ok(one_address())).await;
let objects = client(port)
.search("10.0.0.5".parse().unwrap())
.await
.unwrap()
.unwrap();
assert_eq!(objects.len(), 1);
assert_eq!(objects[0].hostname, "host.example.com");
assert_eq!(objects[0].device_id, Some(3));
assert_eq!(
objects[0].fields["custom_acme_domains"],
json!("www.example.com")
);
let request = server.await.unwrap();
assert!(
request.starts_with("GET /api/acme/addresses/search/10.0.0.5/ HTTP/1.1"),
"{request}"
);
assert!(request.contains("token: t0ken"), "{request}");
assert!(!request.contains("authorization:"), "{request}");
}
#[tokio::test]
async fn the_app_id_is_part_of_the_path() {
let (port, server) = serve_once(ok(json!({ "data": [] }))).await;
let cfg = PhpIpamConfig {
app_id: "certs".to_string(),
..config(&format!("http://127.0.0.1:{port}"))
};
PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
.unwrap()
.search("10.0.0.5".parse().unwrap())
.await
.unwrap();
let request = server.await.unwrap();
assert!(
request.starts_with("GET /api/certs/addresses/search/"),
"{request}"
);
}
#[tokio::test]
async fn an_ipv6_address_keeps_its_colons_in_the_path() {
let (port, server) = serve_once(ok(json!({ "data": [] }))).await;
client(port)
.search("2001:db8::5".parse().unwrap())
.await
.unwrap();
let request = server.await.unwrap();
assert!(
request.starts_with("GET /api/acme/addresses/search/2001:db8::5/"),
"{request}"
);
}
#[tokio::test]
async fn a_404_reads_as_no_such_address_rather_than_a_failure() {
let (port, _server) = serve_once(status(
404,
"Not Found",
r#"{"code":404,"success":false,"message":"No addresses found"}"#,
))
.await;
let found = client(port)
.search("10.0.0.5".parse().unwrap())
.await
.expect("a 404 must not be an error");
assert!(found.is_none());
}
#[tokio::test]
async fn a_refused_token_is_still_a_failure() {
let (port, _server) = serve_once(status(
401,
"Unauthorized",
r#"{"code":401,"message":"Invalid app code"}"#,
))
.await;
let error = client(port)
.search("10.0.0.5".parse().unwrap())
.await
.unwrap_err();
assert!(error.contains("401"), "{error}");
assert!(error.contains("Invalid app code"), "{error}");
}
#[tokio::test]
async fn a_server_error_is_a_failure() {
let (port, _server) = serve_once(status(500, "Internal Server Error", "boom!")).await;
let error = client(port)
.search("10.0.0.5".parse().unwrap())
.await
.unwrap_err();
assert!(error.contains("500"), "{error}");
}
#[tokio::test]
async fn fetches_a_devices_columns() {
let (port, server) = serve_once(ok(json!({
"code": 200,
"data": { "id": "3", "hostname": "srv1",
"custom_acme_domains": "machine.example.com" }
})))
.await;
let fields = client(port).device(3).await.unwrap();
assert_eq!(fields["custom_acme_domains"], json!("machine.example.com"));
let request = server.await.unwrap();
assert!(
request.starts_with("GET /api/acme/devices/3/ HTTP/1.1"),
"{request}"
);
}
#[tokio::test]
async fn an_absent_device_lends_no_names_without_erroring() {
let (port, _server) = serve_once(ok(json!({ "code": 200, "data": [] }))).await;
assert!(client(port).device(3).await.unwrap().is_empty());
}
#[tokio::test]
async fn a_device_response_of_the_wrong_shape_is_an_error() {
let (port, _server) = serve_once(ok(json!({ "data": "nope" }))).await;
let error = client(port).device(3).await.unwrap_err();
assert!(error.contains("data is a string"), "{error}");
}
#[tokio::test]
async fn a_search_response_of_the_wrong_shape_is_an_error() {
let (port, _server) = serve_once(ok(json!({ "data": "nope" }))).await;
let error = client(port)
.search("10.0.0.5".parse().unwrap())
.await
.unwrap_err();
assert!(
error.contains("unexpected addresses/search data"),
"{error}"
);
}
#[tokio::test]
async fn an_unreadable_envelope_is_an_error() {
let (port, _server) = serve_once(ok(json!([1, 2, 3]))).await;
let error = client(port)
.search("10.0.0.5".parse().unwrap())
.await
.unwrap_err();
assert!(
error.contains("unexpected addresses/search response"),
"{error}"
);
}
#[tokio::test]
async fn a_closed_port_is_a_connect_error() {
let port = closed_port().await;
let error = client(port)
.search("10.0.0.5".parse().unwrap())
.await
.unwrap_err();
assert!(error.contains("connecting to 127.0.0.1"), "{error}");
}
}
mod tls {
use super::*;
fn https_config(port: u16, skip: bool) -> PhpIpamConfig {
PhpIpamConfig {
insecure_skip_verify: skip,
..config(&format!("https://localhost:{port}"))
}
}
#[tokio::test]
async fn a_self_signed_phpipam_is_refused_by_default() {
let port = serve_once_tls(json!({ "data": [] })).await;
let error = PhpIpamClient::new(
&https_config(port, false),
crate::testutil::outbound_with(test_resolver()),
)
.unwrap()
.search("10.0.0.5".parse().unwrap())
.await
.unwrap_err();
assert!(error.contains("TLS handshake"), "{error}");
}
#[tokio::test]
async fn skipping_verification_reaches_the_same_phpipam() {
let port = serve_once_tls(json!({
"data": [{ "hostname": "host.example.com" }]
}))
.await;
let objects = PhpIpamClient::new(
&https_config(port, true),
crate::testutil::outbound_with(test_resolver()),
)
.unwrap()
.search("10.0.0.5".parse().unwrap())
.await
.expect("skip-verify must accept a self-signed certificate")
.unwrap();
assert_eq!(objects[0].hostname, "host.example.com");
}
}
}