1use std::net::IpAddr;
34
35use async_trait::async_trait;
36use hyper::{StatusCode, header::HeaderName};
37use serde::Deserialize;
38use serde_json::{Map, Value};
39
40use super::{PhpIpamAddress, PhpIpamApi};
41use crate::config::PhpIpamConfig;
42use crate::ipam::http::{JsonApi, JsonApiError, tls_config};
43
44const TOKEN_HEADER: &str = "token";
46
47#[derive(Debug)]
49pub struct PhpIpamClient {
50 api: JsonApi,
51 app_id: String,
52}
53
54impl PhpIpamClient {
55 pub fn new(
57 cfg: &PhpIpamConfig,
58 outbound: crate::http_client::Outbound,
59 ) -> anyhow::Result<Self> {
60 anyhow::ensure!(
61 !cfg.url.trim().is_empty(),
62 "ipam.backend is `phpipam` but ipam.phpipam.url is empty; give the base URL of the \
63 phpIPAM instance"
64 );
65 anyhow::ensure!(
66 !cfg.token.trim().is_empty(),
67 "ipam.backend is `phpipam` but ipam.phpipam.token is empty; supply the API \
68 application's app code, preferably through ACME_PROXY_IPAM__PHPIPAM__TOKEN"
69 );
70 let app_id = cfg.app_id.trim().to_string();
73 anyhow::ensure!(
74 !app_id.is_empty()
75 && app_id
76 .chars()
77 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
78 "ipam.phpipam.app_id must be a single path segment of letters, digits, `-` or `_` \
79 (the API application's identifier in phpIPAM); got `{}`",
80 cfg.app_id
81 );
82
83 let header = HeaderName::from_static(TOKEN_HEADER);
84
85 Ok(Self {
86 api: JsonApi::new(
87 &cfg.url,
88 "ipam.phpipam.url",
89 vec![(header, cfg.token.clone())],
90 tls_config(
91 &cfg.ca_cert_path,
92 cfg.insecure_skip_verify,
93 "ipam.phpipam.ca_cert_path",
94 )?,
95 outbound,
96 )?,
97 app_id,
98 })
99 }
100}
101
102#[derive(Debug, Deserialize)]
105struct Envelope {
106 #[serde(default)]
107 data: Value,
108}
109
110#[derive(Debug, Deserialize)]
113struct AddressRow {
114 #[serde(default)]
115 hostname: Option<String>,
116 #[serde(rename = "deviceId", default)]
117 device_id: Option<Value>,
118 #[serde(flatten)]
119 fields: Map<String, Value>,
120}
121
122impl From<AddressRow> for PhpIpamAddress {
123 fn from(row: AddressRow) -> Self {
124 Self {
125 hostname: row.hostname.unwrap_or_default(),
126 device_id: row.device_id.as_ref().and_then(numeric_id),
127 fields: row.fields,
128 }
129 }
130}
131
132fn numeric_id(value: &Value) -> Option<u64> {
136 let id = match value {
137 Value::Number(number) => number.as_u64()?,
138 Value::String(text) => text.trim().parse().ok()?,
139 _ => return None,
140 };
141 (id != 0).then_some(id)
142}
143
144#[async_trait]
145impl PhpIpamApi for PhpIpamClient {
146 async fn search(&self, ip: IpAddr) -> Result<Option<Vec<PhpIpamAddress>>, String> {
147 let path = format!("/api/{}/addresses/search/{ip}/", self.app_id);
151
152 let body = match self.api.get(&path).await {
153 Ok(body) => body,
154 Err(JsonApiError {
156 status: Some(StatusCode::NOT_FOUND),
157 ..
158 }) => return Ok(None),
159 Err(error) => return Err(error.message),
160 };
161
162 let envelope: Envelope = serde_json::from_value(body)
163 .map_err(|error| format!("unexpected addresses/search response: {error}"))?;
164 let rows: Vec<AddressRow> = serde_json::from_value(envelope.data)
165 .map_err(|error| format!("unexpected addresses/search data: {error}"))?;
166
167 Ok(Some(rows.into_iter().map(PhpIpamAddress::from).collect()))
168 }
169
170 async fn device(&self, id: u64) -> Result<Map<String, Value>, String> {
171 let path = format!("/api/{}/devices/{id}/", self.app_id);
172
173 let body = self
174 .api
175 .get(&path)
176 .await
177 .map_err(|error: JsonApiError| error.message)?;
178
179 let envelope: Envelope = serde_json::from_value(body)
180 .map_err(|error| format!("unexpected response for {path}: {error}"))?;
181
182 match envelope.data {
183 Value::Object(fields) => Ok(fields),
184 Value::Null | Value::Array(_) => Ok(Map::new()),
187 other => Err(format!(
188 "unexpected response for {path}: data is a {}",
189 crate::ipam::value_kind(&other)
190 )),
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::ipam::http::testing::{
199 closed_port, ok, serve_once, serve_once_tls, status, test_resolver,
200 };
201 use serde_json::json;
202
203 fn config(url: &str) -> PhpIpamConfig {
204 PhpIpamConfig {
205 url: url.to_string(),
206 token: "t0ken".to_string(),
207 ..PhpIpamConfig::default()
208 }
209 }
210
211 #[test]
214 fn an_empty_url_is_a_startup_error() {
215 let error = PhpIpamClient::new(
216 &config(" "),
217 crate::testutil::outbound_with(test_resolver()),
218 )
219 .unwrap_err()
220 .to_string();
221 assert!(error.contains("ipam.phpipam.url"), "{error}");
222 }
223
224 #[test]
225 fn an_empty_token_is_a_startup_error() {
226 let cfg = PhpIpamConfig {
227 token: String::new(),
228 ..config("https://ipam.example.com")
229 };
230 let error = PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
231 .unwrap_err()
232 .to_string();
233 assert!(error.contains("ipam.phpipam.token"), "{error}");
234 assert!(error.contains("ACME_PROXY_IPAM__PHPIPAM__TOKEN"), "{error}");
235 }
236
237 #[test]
240 fn an_app_id_that_is_not_one_path_segment_is_a_startup_error() {
241 for bad in ["", " ", "a/b", "a?b", "a b", "../admin"] {
242 let cfg = PhpIpamConfig {
243 app_id: bad.to_string(),
244 ..config("https://ipam.example.com")
245 };
246 let error = PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
247 .unwrap_err()
248 .to_string();
249 assert!(error.contains("ipam.phpipam.app_id"), "{bad}: {error}");
250 }
251 }
252
253 #[test]
254 fn a_missing_ca_certificate_is_a_startup_error() {
255 let cfg = PhpIpamConfig {
256 ca_cert_path: "/nonexistent/ipam-ca.pem".to_string(),
257 ..config("https://ipam.example.com")
258 };
259 let error = PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
260 .unwrap_err()
261 .to_string();
262 assert!(error.contains("ipam.phpipam.ca_cert_path"), "{error}");
263 }
264
265 #[test]
266 fn the_debug_impl_never_renders_the_token() {
267 let client = PhpIpamClient::new(
268 &config("https://ipam.example.com"),
269 crate::testutil::outbound_with(test_resolver()),
270 )
271 .unwrap();
272 let rendered = format!("{client:?}");
273 assert!(!rendered.contains("t0ken"), "{rendered}");
274 }
275
276 #[test]
281 fn a_device_id_reads_from_either_json_type() {
282 assert_eq!(numeric_id(&json!(3)), Some(3));
283 assert_eq!(numeric_id(&json!("3")), Some(3));
284 assert_eq!(numeric_id(&json!(" 3 ")), Some(3));
285 assert_eq!(numeric_id(&json!(0)), None);
286 assert_eq!(numeric_id(&json!("0")), None);
287 assert_eq!(numeric_id(&Value::Null), None);
288 assert_eq!(numeric_id(&json!("")), None);
289 assert_eq!(numeric_id(&json!([3])), None);
290 }
291
292 mod loopback {
295 use super::*;
296
297 fn client(port: u16) -> PhpIpamClient {
298 PhpIpamClient::new(
299 &config(&format!("http://127.0.0.1:{port}")),
300 crate::testutil::outbound_with(test_resolver()),
301 )
302 .unwrap()
303 }
304
305 fn one_address() -> Value {
306 json!({
307 "code": 200,
308 "success": true,
309 "data": [{
310 "id": "12",
311 "subnetId": "4",
312 "ip": "10.0.0.5",
313 "hostname": "host.example.com",
314 "deviceId": "3",
315 "custom_acme_allowed_names": "www.example.com"
316 }]
317 })
318 }
319
320 #[tokio::test]
321 async fn searches_the_address_and_authenticates() {
322 let (port, server) = serve_once(ok(one_address())).await;
323
324 let objects = client(port)
325 .search("10.0.0.5".parse().unwrap())
326 .await
327 .unwrap()
328 .unwrap();
329
330 assert_eq!(objects.len(), 1);
331 assert_eq!(objects[0].hostname, "host.example.com");
332 assert_eq!(objects[0].device_id, Some(3));
333 assert_eq!(
334 objects[0].fields["custom_acme_allowed_names"],
335 json!("www.example.com")
336 );
337
338 let request = server.await.unwrap();
339 assert!(
340 request.starts_with("GET /api/acme/addresses/search/10.0.0.5/ HTTP/1.1"),
341 "{request}"
342 );
343 assert!(request.contains("token: t0ken"), "{request}");
345 assert!(!request.contains("authorization:"), "{request}");
346 }
347
348 #[tokio::test]
349 async fn the_app_id_is_part_of_the_path() {
350 let (port, server) = serve_once(ok(json!({ "data": [] }))).await;
351 let cfg = PhpIpamConfig {
352 app_id: "certs".to_string(),
353 ..config(&format!("http://127.0.0.1:{port}"))
354 };
355
356 PhpIpamClient::new(&cfg, crate::testutil::outbound_with(test_resolver()))
357 .unwrap()
358 .search("10.0.0.5".parse().unwrap())
359 .await
360 .unwrap();
361
362 let request = server.await.unwrap();
363 assert!(
364 request.starts_with("GET /api/certs/addresses/search/"),
365 "{request}"
366 );
367 }
368
369 #[tokio::test]
370 async fn an_ipv6_address_keeps_its_colons_in_the_path() {
371 let (port, server) = serve_once(ok(json!({ "data": [] }))).await;
372
373 client(port)
374 .search("2001:db8::5".parse().unwrap())
375 .await
376 .unwrap();
377
378 let request = server.await.unwrap();
379 assert!(
380 request.starts_with("GET /api/acme/addresses/search/2001:db8::5/"),
381 "{request}"
382 );
383 }
384
385 #[tokio::test]
388 async fn a_404_reads_as_no_such_address_rather_than_a_failure() {
389 let (port, _server) = serve_once(status(
390 404,
391 "Not Found",
392 r#"{"code":404,"success":false,"message":"No addresses found"}"#,
393 ))
394 .await;
395
396 let found = client(port)
397 .search("10.0.0.5".parse().unwrap())
398 .await
399 .expect("a 404 must not be an error");
400 assert!(found.is_none());
401 }
402
403 #[tokio::test]
406 async fn a_refused_token_is_still_a_failure() {
407 let (port, _server) = serve_once(status(
408 401,
409 "Unauthorized",
410 r#"{"code":401,"message":"Invalid app code"}"#,
411 ))
412 .await;
413
414 let error = client(port)
415 .search("10.0.0.5".parse().unwrap())
416 .await
417 .unwrap_err();
418 assert!(error.contains("401"), "{error}");
419 assert!(error.contains("Invalid app code"), "{error}");
420 }
421
422 #[tokio::test]
423 async fn a_server_error_is_a_failure() {
424 let (port, _server) = serve_once(status(500, "Internal Server Error", "boom!")).await;
425
426 let error = client(port)
427 .search("10.0.0.5".parse().unwrap())
428 .await
429 .unwrap_err();
430 assert!(error.contains("500"), "{error}");
431 }
432
433 #[tokio::test]
434 async fn fetches_a_devices_columns() {
435 let (port, server) = serve_once(ok(json!({
436 "code": 200,
437 "data": { "id": "3", "hostname": "srv1",
438 "custom_acme_allowed_names": "machine.example.com" }
439 })))
440 .await;
441
442 let fields = client(port).device(3).await.unwrap();
443 assert_eq!(
444 fields["custom_acme_allowed_names"],
445 json!("machine.example.com")
446 );
447
448 let request = server.await.unwrap();
449 assert!(
450 request.starts_with("GET /api/acme/devices/3/ HTTP/1.1"),
451 "{request}"
452 );
453 }
454
455 #[tokio::test]
458 async fn an_absent_device_lends_no_names_without_erroring() {
459 let (port, _server) = serve_once(ok(json!({ "code": 200, "data": [] }))).await;
460
461 assert!(client(port).device(3).await.unwrap().is_empty());
462 }
463
464 #[tokio::test]
465 async fn a_device_response_of_the_wrong_shape_is_an_error() {
466 let (port, _server) = serve_once(ok(json!({ "data": "nope" }))).await;
467
468 let error = client(port).device(3).await.unwrap_err();
469 assert!(error.contains("data is a string"), "{error}");
470 }
471
472 #[tokio::test]
473 async fn a_search_response_of_the_wrong_shape_is_an_error() {
474 let (port, _server) = serve_once(ok(json!({ "data": "nope" }))).await;
475
476 let error = client(port)
477 .search("10.0.0.5".parse().unwrap())
478 .await
479 .unwrap_err();
480 assert!(
481 error.contains("unexpected addresses/search data"),
482 "{error}"
483 );
484 }
485
486 #[tokio::test]
487 async fn an_unreadable_envelope_is_an_error() {
488 let (port, _server) = serve_once(ok(json!([1, 2, 3]))).await;
489
490 let error = client(port)
491 .search("10.0.0.5".parse().unwrap())
492 .await
493 .unwrap_err();
494 assert!(
495 error.contains("unexpected addresses/search response"),
496 "{error}"
497 );
498 }
499
500 #[tokio::test]
501 async fn a_closed_port_is_a_connect_error() {
502 let port = closed_port().await;
503
504 let error = client(port)
505 .search("10.0.0.5".parse().unwrap())
506 .await
507 .unwrap_err();
508 assert!(error.contains("connecting to 127.0.0.1"), "{error}");
509 }
510 }
511
512 mod tls {
515 use super::*;
516
517 fn https_config(port: u16, skip: bool) -> PhpIpamConfig {
518 PhpIpamConfig {
519 insecure_skip_verify: skip,
520 ..config(&format!("https://localhost:{port}"))
521 }
522 }
523
524 #[tokio::test]
525 async fn a_self_signed_phpipam_is_refused_by_default() {
526 let port = serve_once_tls(json!({ "data": [] })).await;
527
528 let error = PhpIpamClient::new(
529 &https_config(port, false),
530 crate::testutil::outbound_with(test_resolver()),
531 )
532 .unwrap()
533 .search("10.0.0.5".parse().unwrap())
534 .await
535 .unwrap_err();
536 assert!(error.contains("TLS handshake"), "{error}");
537 }
538
539 #[tokio::test]
540 async fn skipping_verification_reaches_the_same_phpipam() {
541 let port = serve_once_tls(json!({
542 "data": [{ "hostname": "host.example.com" }]
543 }))
544 .await;
545
546 let objects = PhpIpamClient::new(
547 &https_config(port, true),
548 crate::testutil::outbound_with(test_resolver()),
549 )
550 .unwrap()
551 .search("10.0.0.5".parse().unwrap())
552 .await
553 .expect("skip-verify must accept a self-signed certificate")
554 .unwrap();
555 assert_eq!(objects[0].hostname, "host.example.com");
556 }
557 }
558}