1use std::sync::Arc;
37
38use bytes::Bytes;
39use http_body_util::{BodyExt, Empty, Limited};
40use hyper::{Request, StatusCode, header::HeaderName};
41use serde_json::Value;
42use url::Url;
43
44use crate::http_client::{MAX_RESPONSE_BYTES, error_excerpt};
47
48#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
54#[error("{message}")]
55pub(crate) struct JsonApiError {
56 pub status: Option<StatusCode>,
57 pub message: String,
58}
59
60impl JsonApiError {
61 fn transport(message: String) -> Self {
62 Self {
63 status: None,
64 message,
65 }
66 }
67}
68
69pub(crate) struct JsonApi {
71 base: String,
73 headers: Vec<(HeaderName, String)>,
74 tls: Arc<rustls::ClientConfig>,
75 outbound: crate::http_client::Outbound,
78}
79
80impl std::fmt::Debug for JsonApi {
81 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 formatter
84 .debug_struct("JsonApi")
85 .field("base", &self.base)
86 .finish_non_exhaustive()
87 }
88}
89
90impl JsonApi {
91 pub(crate) fn new(
96 url: &str,
97 setting: &str,
98 headers: Vec<(HeaderName, String)>,
99 tls: Arc<rustls::ClientConfig>,
100 outbound: crate::http_client::Outbound,
101 ) -> anyhow::Result<Self> {
102 let parsed = Url::parse(url.trim())
103 .map_err(|error| anyhow::anyhow!("{setting}: {url} is not a URL: {error}"))?;
104 match parsed.scheme() {
107 "http" | "https" => {}
108 other => anyhow::bail!("{setting}: unsupported scheme {other}, expected http or https"),
109 }
110
111 Ok(Self {
112 base: parsed.as_str().trim_end_matches('/').to_string(),
113 headers,
114 tls,
115 outbound,
116 })
117 }
118
119 #[cfg(test)]
121 pub(crate) fn base(&self) -> &str {
122 &self.base
123 }
124
125 pub(crate) async fn get(&self, path_and_query: &str) -> Result<Value, JsonApiError> {
127 let target = format!("{}{path_and_query}", self.base);
128 let url = Url::parse(&target)
129 .map_err(|error| JsonApiError::transport(format!("{target} is not a URL: {error}")))?;
130
131 let endpoint = crate::http_client::Endpoint::from_url(&url).map_err(|error| {
132 JsonApiError::transport(format!("{target} is not a usable endpoint: {error}"))
133 })?;
134
135 let connection = self
136 .outbound
137 .connect(&endpoint, &self.tls)
138 .await
139 .map_err(JsonApiError::transport)?;
140
141 let request_target = connection.request_target(&url);
144
145 let mut builder = Request::builder()
148 .uri(request_target)
149 .header(hyper::header::HOST, endpoint.authority())
150 .header(hyper::header::USER_AGENT, "acme-proxy")
151 .header(hyper::header::ACCEPT, "application/json")
152 .header(hyper::header::CONNECTION, "close");
153 for (name, value) in &self.headers {
154 builder = builder.header(name, value);
155 }
156 let request = builder
157 .body(Empty::<Bytes>::new())
158 .map_err(|error| JsonApiError::transport(format!("building the request: {error}")))?;
159
160 exchange(connection, request, &url).await
161 }
162}
163
164async fn exchange(
166 mut connection: crate::http_client::Connection<Empty<Bytes>>,
167 request: Request<Empty<Bytes>>,
168 url: &Url,
169) -> Result<Value, JsonApiError> {
170 let response = connection
171 .send_request(request)
172 .await
173 .map_err(|error| JsonApiError::transport(format!("request to {url} failed: {error}")))?;
174
175 let status = response.status();
176 let body = Limited::new(response.into_body(), MAX_RESPONSE_BYTES)
179 .collect()
180 .await
181 .map_err(|_| {
182 JsonApiError::transport(format!(
183 "response from {url} exceeds {MAX_RESPONSE_BYTES} bytes"
184 ))
185 })?
186 .to_bytes();
187
188 if !status.is_success() {
189 let excerpt = error_excerpt(&body);
194 return Err(JsonApiError {
195 status: Some(status),
196 message: format!("{url} answered {status}: {excerpt}"),
197 });
198 }
199
200 serde_json::from_slice(&body).map_err(|error| JsonApiError {
201 status: Some(status),
202 message: format!("{url} returned unreadable JSON: {error}"),
203 })
204}
205
206pub(crate) fn tls_config(
211 ca_cert_path: &str,
212 insecure_skip_verify: bool,
213 setting: &str,
214) -> anyhow::Result<Arc<rustls::ClientConfig>> {
215 if insecure_skip_verify {
216 return crate::challenge::tls_alpn_01::accept_any_client_config(&[]);
221 }
222
223 let mut roots = rustls::RootCertStore {
224 roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
225 };
226
227 if !ca_cert_path.trim().is_empty() {
228 let path = std::path::Path::new(ca_cert_path.trim());
229 let extra = crate::pemfile::read_certificates(path)
230 .map_err(|error| anyhow::anyhow!("{setting}: {error}"))?;
231 for certificate in extra {
232 roots.add(certificate).map_err(|error| {
233 anyhow::anyhow!(
234 "{setting}: {} is not a usable CA certificate: {error}",
235 path.display()
236 )
237 })?;
238 }
239 }
240
241 let config = rustls::ClientConfig::builder_with_provider(Arc::new(
245 rustls::crypto::ring::default_provider(),
246 ))
247 .with_safe_default_protocol_versions()
248 .map_err(|error| anyhow::anyhow!("building the TLS client configuration: {error}"))?
249 .with_root_certificates(roots)
250 .with_no_client_auth();
251
252 Ok(Arc::new(config))
253}
254
255#[cfg(test)]
259pub(crate) mod testing {
260 use super::*;
261 use serde_json::json;
262 use tokio::io::{AsyncReadExt, AsyncWriteExt};
263 use tokio::net::TcpListener;
264
265 pub(crate) fn test_resolver() -> Arc<dyn crate::dns::Resolver> {
268 Arc::new(crate::dns::HickoryResolver::from_system_uncached().unwrap())
269 }
270
271 pub(crate) async fn serve_once(response: String) -> (u16, tokio::task::JoinHandle<String>) {
273 serve_many(vec![response]).await
274 }
275
276 pub(crate) async fn serve_many(
280 responses: Vec<String>,
281 ) -> (u16, tokio::task::JoinHandle<String>) {
282 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
283 let port = listener.local_addr().unwrap().port();
284
285 let handle = tokio::spawn(async move {
286 let mut seen = Vec::new();
287 for response in responses {
288 let (mut stream, _) = listener.accept().await.unwrap();
289 let mut buffer = vec![0u8; 4096];
290 let read = stream.read(&mut buffer).await.unwrap();
291 stream.write_all(response.as_bytes()).await.unwrap();
292 stream.shutdown().await.unwrap();
293 seen.push(String::from_utf8_lossy(&buffer[..read]).into_owned());
294 }
295 seen.join("\u{c}")
296 });
297
298 (port, handle)
299 }
300
301 pub(crate) fn ok(body: Value) -> String {
303 status(200, "OK", &body.to_string())
304 }
305
306 pub(crate) fn status(code: u16, reason: &str, body: &str) -> String {
310 format!(
311 "HTTP/1.1 {code} {reason}\r\nContent-Type: application/json\r\n\
312 Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
313 body.len()
314 )
315 }
316
317 pub(crate) async fn closed_port() -> u16 {
320 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
321 listener.local_addr().unwrap().port()
322 }
323
324 pub(crate) async fn serve_once_tls(body: Value) -> u16 {
329 use rcgen::{CertificateParams, KeyPair};
330 use rustls::pki_types::{CertificateDer, PrivateKeyDer};
331 use rustls::{ServerConfig, sign::CertifiedKey};
332 use tokio_rustls::TlsAcceptor;
333
334 #[derive(Debug)]
335 struct FixedCert(Arc<CertifiedKey>);
336
337 impl rustls::server::ResolvesServerCert for FixedCert {
338 fn resolve(
339 &self,
340 _hello: rustls::server::ClientHello<'_>,
341 ) -> Option<Arc<CertifiedKey>> {
342 Some(self.0.clone())
343 }
344 }
345
346 let key_pair = KeyPair::generate().unwrap();
347 let key = PrivateKeyDer::try_from(key_pair.serialize_der()).unwrap();
348 let mut params = CertificateParams::new(vec!["localhost".to_string()]).unwrap();
349 params.distinguished_name = rcgen::DistinguishedName::new();
350 let der = params
351 .self_signed(&key_pair)
352 .unwrap()
353 .der()
354 .as_ref()
355 .to_vec();
356
357 let provider = rustls::crypto::ring::default_provider();
358 let signing_key = provider.key_provider.load_private_key(key).unwrap();
359 let certified = CertifiedKey::new(vec![CertificateDer::from(der)], signing_key);
360 let config = ServerConfig::builder_with_provider(Arc::new(provider))
361 .with_safe_default_protocol_versions()
362 .unwrap()
363 .with_no_client_auth()
364 .with_cert_resolver(Arc::new(FixedCert(Arc::new(certified))));
365
366 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
367 let port = listener.local_addr().unwrap().port();
368 let acceptor = TlsAcceptor::from(Arc::new(config));
369 let response = ok(body);
370
371 tokio::spawn(async move {
372 let (stream, _) = listener.accept().await.unwrap();
373 if let Ok(mut stream) = acceptor.accept(stream).await {
376 let mut buffer = vec![0u8; 4096];
377 let _ = stream.read(&mut buffer).await;
378 let _ = stream.write_all(response.as_bytes()).await;
379 let _ = stream.shutdown().await;
380 }
381 });
382
383 port
384 }
385
386 pub(crate) fn oversized_body() -> String {
388 let filler = "x".repeat(MAX_RESPONSE_BYTES + 1024);
389 json!({ "results": [], "filler": filler }).to_string()
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::testing::*;
396 use super::*;
397 use serde_json::json;
398
399 fn api(port: u16, path: &str) -> JsonApi {
400 JsonApi::new(
401 &format!("http://127.0.0.1:{port}{path}"),
402 "ipam.test.url",
403 vec![(hyper::header::AUTHORIZATION, "Token t0ken".to_string())],
404 tls_config("", false, "ipam.test.ca_cert_path").unwrap(),
405 crate::testutil::outbound_with(test_resolver()),
406 )
407 .unwrap()
408 }
409
410 #[test]
413 fn an_unparsable_url_names_the_setting() {
414 let error = JsonApi::new(
415 "not a url",
416 "ipam.netbox.url",
417 Vec::new(),
418 tls_config("", false, "x").unwrap(),
419 crate::testutil::outbound_with(test_resolver()),
420 )
421 .unwrap_err()
422 .to_string();
423 assert!(error.contains("ipam.netbox.url"), "{error}");
424 }
425
426 #[test]
427 fn a_non_http_scheme_is_a_startup_error() {
428 let error = JsonApi::new(
429 "ftp://netbox.example.com",
430 "ipam.netbox.url",
431 Vec::new(),
432 tls_config("", false, "x").unwrap(),
433 crate::testutil::outbound_with(test_resolver()),
434 )
435 .unwrap_err()
436 .to_string();
437 assert!(error.contains("unsupported scheme ftp"), "{error}");
438 }
439
440 #[test]
443 fn a_base_url_under_a_subpath_is_preserved() {
444 let api = JsonApi::new(
445 "https://example.com/netbox/",
446 "ipam.netbox.url",
447 Vec::new(),
448 tls_config("", false, "x").unwrap(),
449 crate::testutil::outbound_with(test_resolver()),
450 )
451 .unwrap();
452 assert_eq!(api.base(), "https://example.com/netbox");
453 }
454
455 #[test]
456 fn the_debug_impl_never_renders_a_header() {
457 let api = JsonApi::new(
458 "https://example.com",
459 "ipam.netbox.url",
460 vec![(hyper::header::AUTHORIZATION, "Token t0ken".to_string())],
461 tls_config("", false, "x").unwrap(),
462 crate::testutil::outbound_with(test_resolver()),
463 )
464 .unwrap();
465 let rendered = format!("{api:?}");
466 assert!(!rendered.contains("t0ken"), "{rendered}");
467 }
468
469 #[test]
470 fn a_missing_ca_certificate_names_the_setting() {
471 let error = tls_config("/nonexistent/ca.pem", false, "ipam.netbox.ca_cert_path")
472 .unwrap_err()
473 .to_string();
474 assert!(error.contains("ipam.netbox.ca_cert_path"), "{error}");
475 }
476
477 #[test]
480 fn skipping_verification_ignores_the_ca_certificate_entirely() {
481 tls_config("/nonexistent/ca.pem", true, "ipam.netbox.ca_cert_path")
482 .expect("skip-verify must not read ca_cert_path");
483 }
484
485 #[tokio::test]
488 async fn sends_the_configured_headers_and_parses_the_body() {
489 let (port, server) = serve_once(ok(json!({ "results": [] }))).await;
490
491 let body = api(port, "").get("/api/thing/?a=b").await.unwrap();
492 assert_eq!(body, json!({ "results": [] }));
493
494 let request = server.await.unwrap();
495 assert!(
496 request.starts_with("GET /api/thing/?a=b HTTP/1.1"),
497 "{request}"
498 );
499 assert!(request.contains("authorization: Token t0ken"), "{request}");
500 assert!(
501 request.contains(&format!("host: 127.0.0.1:{port}")),
502 "{request}"
503 );
504 assert!(request.contains("accept: application/json"), "{request}");
505 assert!(request.contains("user-agent: acme-proxy"), "{request}");
506 }
507
508 #[tokio::test]
509 async fn a_subpath_base_url_prefixes_the_api_path() {
510 let (port, server) = serve_once(ok(json!({}))).await;
511
512 api(port, "/netbox").get("/api/thing/").await.unwrap();
513
514 let request = server.await.unwrap();
515 assert!(
516 request.starts_with("GET /netbox/api/thing/ HTTP/1.1"),
517 "{request}"
518 );
519 }
520
521 #[tokio::test]
522 async fn a_server_error_is_reported_with_its_status_and_an_excerpt() {
523 let (port, _server) = serve_once(status(500, "Internal Server Error", "boom!")).await;
524
525 let error = api(port, "").get("/api/thing/").await.unwrap_err();
526 assert_eq!(error.status, Some(StatusCode::INTERNAL_SERVER_ERROR));
527 assert!(error.message.contains("boom!"), "{error}");
528 }
529
530 #[tokio::test]
534 async fn a_refused_token_is_reported_rather_than_parsed() {
535 let (port, _server) = serve_once(status(
536 401,
537 "Unauthorized",
538 r#"{"detail":"Invalid token header."}"#,
539 ))
540 .await;
541
542 let error = api(port, "").get("/api/thing/").await.unwrap_err();
543 assert_eq!(error.status, Some(StatusCode::UNAUTHORIZED));
544 assert!(error.message.contains("Invalid token header"), "{error}");
545 }
546
547 #[tokio::test]
550 async fn a_404_is_reported_with_its_status_intact() {
551 let (port, _server) = serve_once(status(404, "Not Found", r#"{"code":404}"#)).await;
552
553 let error = api(port, "").get("/api/thing/").await.unwrap_err();
554 assert_eq!(error.status, Some(StatusCode::NOT_FOUND));
555 }
556
557 #[tokio::test]
558 async fn an_unreadable_body_is_an_error() {
559 let (port, _server) = serve_once(status(200, "OK", "not json!")).await;
560
561 let error = api(port, "").get("/api/thing/").await.unwrap_err();
562 assert!(error.message.contains("unreadable JSON"), "{error}");
563 }
564
565 #[tokio::test]
568 async fn an_oversized_body_is_refused() {
569 let (port, _server) = serve_once(status(200, "OK", &oversized_body())).await;
570
571 let error = api(port, "").get("/api/thing/").await.unwrap_err();
572 assert!(error.message.contains("exceeds"), "{error}");
573 assert_eq!(error.status, None);
574 }
575
576 #[tokio::test]
577 async fn a_closed_port_is_a_connect_error() {
578 let port = closed_port().await;
579
580 let error = api(port, "").get("/api/thing/").await.unwrap_err();
581 assert_eq!(error.status, None);
582 assert!(error.message.contains("connecting to 127.0.0.1"), "{error}");
583 }
584
585 #[test]
586 fn the_error_displays_as_its_message() {
587 let error = JsonApiError::transport("nope".to_string());
588 assert_eq!(error.to_string(), "nope");
589 }
590}