Skip to main content

acme_proxy/ipam/
http.rs

1//! The JSON-over-HTTP transport both IPAM backends speak.
2//!
3//! ## Why this exists where the four other clients did not get one
4//!
5//! [`http_client`](crate::http_client) already owns the plumbing every outbound
6//! client in this tree shares — picking a URL apart, connecting through the
7//! shared resolver, the TLS handshake, the hyper handshake — and its module doc
8//! is explicit that *policy* stays per-module, because
9//! [`challenge::http_01`](crate::challenge::http_01) must validate no
10//! certificate at all while the others must, and each caps its body, sets its
11//! headers and shapes its errors differently.
12//!
13//! Two IPAM backends are the case that argument does not cover: they have the
14//! *same* policy. Both authenticate with a static token in a header, both read
15//! a small JSON document, both trust the public roots plus an operator's own
16//! CA, both treat an unreachable inventory as this server's failure rather than
17//! the client's. Writing that out twice is the shape
18//! [`script_hook`](crate::script_hook) exists to prevent — it owns the hardening
19//! the three `custom` hooks used to repeat token-for-token.
20//!
21//! What stays per-backend is what genuinely differs: the header name, the paths,
22//! the wire shapes, and — the reason [`JsonApiError`] carries a status —
23//! **what a 404 means**. NetBox answers an unknown address with an empty result
24//! list; phpIPAM answers it with a 404. One is a failure and one is an answer,
25//! and only the backend knows which.
26//!
27//! ## TLS
28//!
29//! Unlike the challenge validators — where the certificate is deliberately not
30//! checked because the *proof* is what matters — an inventory's certificate is
31//! the only thing identifying the service whose answers decide who may have a
32//! name certified. So the public roots apply, plus any operator-supplied CA,
33//! and the one way to switch that off is explicit, logged at startup and
34//! documented as temporary.
35
36use 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
44/// Cap on an inventory response body. An address query returns a handful of
45/// small objects; anything approaching this is not an answer a filter can use.
46use crate::http_client::{MAX_RESPONSE_BYTES, error_excerpt};
47
48/// Why a request to an inventory did not produce a usable document.
49///
50/// `status` is `Some` only when the server answered at all, which is what lets
51/// a backend read one particular code as an answer rather than a failure. The
52/// message is already worded for an operator and names the URL.
53#[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
69/// A base URL, a fixed set of headers, and one TLS configuration.
70pub(crate) struct JsonApi {
71    /// Base URL with no trailing slash, so an instance under a subpath keeps it.
72    base: String,
73    headers: Vec<(HeaderName, String)>,
74    tls: Arc<rustls::ClientConfig>,
75    /// Where every outbound hop resolves and whether it goes through a
76    /// proxy — `dns.resolver` and `[proxy]`, bundled.
77    outbound: crate::http_client::Outbound,
78}
79
80impl std::fmt::Debug for JsonApi {
81    /// Never renders the headers: one of them is the credential.
82    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    /// Validates the URL and keeps the headers every request will carry.
92    ///
93    /// `setting` is the configuration key the URL came from, so an error names
94    /// what an operator has to go and edit. Nothing is contacted here.
95    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        // Restricted to the two schemes an inventory is served over — which
105        // also guarantees a host, since `url` requires one for both.
106        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    /// The base URL, with no trailing slash.
120    #[cfg(test)]
121    pub(crate) fn base(&self) -> &str {
122        &self.base
123    }
124
125    /// One `GET`, returning the parsed JSON body.
126    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        // Origin-form directly, absolute-form when this connection forwards
142        // through a proxy — which is why the connection is opened first.
143        let request_target = connection.request_target(&url);
144
145        // hyper 1.x's low-level client sends exactly what it is given, `Host`
146        // included — see `HyperFetcher`, whose loopback test is what caught it.
147        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
164/// Sends the request over an established stream and parses the answer.
165async 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    // `Limited` errors once the cap is passed; a body that large is not an
177    // answer worth parsing, so it is refused rather than read further.
178    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        // A 401/403 is a misconfigured token and a 5xx is an outage: both are
190        // this server failing to reach a decision, never a statement about the
191        // client. A backend that reads one particular code as an answer says so
192        // itself, off `status`.
193        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
206/// The rustls configuration an inventory client dials with.
207///
208/// `setting` names the `ca_cert_path` key, so an unusable certificate is
209/// reported against the key that has to change.
210pub(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        // The same "accept anything" configuration the challenge validators
217        // use, reused rather than re-derived — it already passes the crypto
218        // provider explicitly, which `install_default` must never be used for.
219        // No ALPN: this is an ordinary https request.
220        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    // Provider passed explicitly rather than installed as the process default:
242    // `install_default` panics on a second call, which would make `cargo test`
243    // depend on which tests happen to run together.
244    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/// Loopback servers both backends' client tests drive the real transport
256/// against. A stub trait impl proves the policy; only these prove the request
257/// line and the headers are what the product actually needs.
258#[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    /// These reach a loopback listener by IP literal, which `dns::connect`
266    /// short-circuits without a lookup, so the system resolver is fine.
267    pub(crate) fn test_resolver() -> Arc<dyn crate::dns::Resolver> {
268        Arc::new(crate::dns::HickoryResolver::from_system_uncached().unwrap())
269    }
270
271    /// Serves one canned response and returns the request it received.
272    pub(crate) async fn serve_once(response: String) -> (u16, tokio::task::JoinHandle<String>) {
273        serve_many(vec![response]).await
274    }
275
276    /// Serves `responses` in order over as many connections, and returns every
277    /// request text joined by a form feed — a backend that makes several
278    /// requests to answer one question needs all of them asserted on.
279    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    /// An HTTP/1.1 response with `body` as its JSON payload.
302    pub(crate) fn ok(body: Value) -> String {
303        status(200, "OK", &body.to_string())
304    }
305
306    /// An HTTP/1.1 response with a hand-written status and body. The
307    /// `Content-Length` is computed, never guessed — a wrong one leaves hyper
308    /// waiting for a body that never arrives.
309    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    /// A port nothing is listening on: bound then dropped, so it is almost
318    /// certainly free.
319    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    /// Serves one https request with a self-signed certificate for `localhost`,
325    /// then stops. Both backends use it to prove `insecure_skip_verify` does
326    /// what it says: the same server is unreachable with verification on and
327    /// readable with it off.
328    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            // A client that refuses the certificate fails here, which is
374            // exactly what one of the two tests is asserting.
375            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    /// A JSON body larger than [`MAX_RESPONSE_BYTES`], for the cap test.
387    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    // ------------------------------------------------------ startup checks
411
412    #[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    /// A base URL under a subpath keeps it: the API path is appended, never
441    /// substituted the way `Url::join` on an absolute path would.
442    #[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    /// With verification off nothing is loaded, so even an unusable
478    /// `ca_cert_path` cannot fail startup — the branch never opens it.
479    #[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    // ------------------------------------------------------------ requests
486
487    #[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    /// A refused token is the operator's problem, not the client's — it must
531    /// surface as an error the caller turns into a 500, never as an empty
532    /// answer that would read as "this address owns no names".
533    #[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    /// The status is what lets phpIPAM read a 404 as an answer while NetBox
548    /// keeps reading it as a failure.
549    #[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    /// A body past the cap is refused rather than read further — and it is a
566    /// transport failure, with no status, since nothing was parsed.
567    #[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}