1use std::fmt;
10use std::net::{IpAddr, SocketAddr};
11use std::sync::Arc;
12use std::time::Duration;
13
14use http_body_util::{BodyExt, Full};
15use hyper::body::{Body, Bytes, Frame, SizeHint};
16use hyper::header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, HOST, RETRY_AFTER};
17use hyper::{Request, StatusCode};
18use hyper_util::rt::TokioIo;
19use rustev_contract::remote::{ConnectionObservation, PhaseExpiry, TransportPhase};
20use tokio::io::{AsyncRead, AsyncWrite};
21use tokio::net::TcpStream;
22
23use crate::budget::Budget;
24use crate::phases::{PhaseClock, TransportPhases, phased};
25use crate::pool::{ConnectionPool, KeyMaterial, Lease, Pooled};
26use crate::wire::{IoHooks, SentTracker, SharedHooks, TrackedIo};
27
28#[derive(Clone, PartialEq, Eq)]
31pub struct Secret(String);
32
33impl Secret {
34 pub fn new(value: impl Into<String>) -> Self {
35 Secret(value.into())
36 }
37
38 pub fn expose(&self) -> &str {
40 &self.0
41 }
42}
43
44impl fmt::Debug for Secret {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 f.write_str("Secret(<redacted>)")
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Scheme {
52 Http,
53 Https,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Endpoint {
59 pub scheme: Scheme,
60 pub host: String,
62 pub port: u16,
63 pub base_path: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum EndpointError {
69 Syntax(String),
70 PlaintextNotLoopback,
72 Tls(String),
73}
74
75impl fmt::Display for EndpointError {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 match self {
78 EndpointError::Syntax(s) => write!(f, "endpoint: {s}"),
79 EndpointError::PlaintextNotLoopback => {
80 write!(f, "endpoint: TLS is required except for loopback")
81 }
82 EndpointError::Tls(s) => write!(f, "endpoint TLS configuration: {s}"),
83 }
84 }
85}
86
87impl std::error::Error for EndpointError {}
88
89impl Endpoint {
90 pub fn parse(url: &str) -> Result<Endpoint, EndpointError> {
93 let bad = |why: &str| EndpointError::Syntax(why.to_string());
94 let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
95 (Scheme::Https, r)
96 } else if let Some(r) = url.strip_prefix("http://") {
97 (Scheme::Http, r)
98 } else {
99 return Err(bad("the scheme must be http or https"));
100 };
101 if rest.contains(['@', '?', '#']) {
102 return Err(bad("user info, query and fragment are not allowed"));
103 }
104 let (authority, path) = match rest.find('/') {
105 Some(i) => (&rest[..i], &rest[i..]),
106 None => (rest, ""),
107 };
108 let default_port = match scheme {
109 Scheme::Http => 80,
110 Scheme::Https => 443,
111 };
112 let (host, port) = if let Some(v6) = authority.strip_prefix('[') {
113 let end = v6.find(']').ok_or_else(|| bad("unclosed IPv6 literal"))?;
114 let host = &v6[..end];
115 let port = match &v6[end + 1..] {
116 "" => default_port,
117 p => p
118 .strip_prefix(':')
119 .and_then(|p| p.parse().ok())
120 .ok_or_else(|| bad("bad port"))?,
121 };
122 (host.to_string(), port)
123 } else {
124 match authority.rsplit_once(':') {
125 Some((h, p)) => (h.to_string(), p.parse().map_err(|_| bad("bad port"))?),
126 None => (authority.to_string(), default_port),
127 }
128 };
129 if host.is_empty() {
130 return Err(bad("empty host"));
131 }
132 Ok(Endpoint {
133 scheme,
134 host,
135 port,
136 base_path: path.trim_end_matches('/').to_string(),
137 })
138 }
139
140 pub fn is_loopback(&self) -> bool {
143 self.host.eq_ignore_ascii_case("localhost")
144 || self.host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
145 }
146
147 fn authority(&self) -> String {
148 if self.host.contains(':') {
149 format!("[{}]:{}", self.host, self.port)
150 } else {
151 format!("{}:{}", self.host, self.port)
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct HttpReply {
159 pub status: u16,
160 pub retry_after: Option<String>,
162 pub body: Vec<u8>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum TransportError {
168 Unsent(String),
170 Interrupted(String),
172 TooLarge { limit: usize },
174 Expired { sent: bool },
176 PhaseExpired(PhaseExpiry),
179}
180
181pub fn default_tls() -> Result<Arc<rustls::ClientConfig>, EndpointError> {
183 let mut roots = rustls::RootCertStore::empty();
184 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
185 let config = rustls::ClientConfig::builder_with_provider(Arc::new(
186 rustls::crypto::ring::default_provider(),
187 ))
188 .with_safe_default_protocol_versions()
189 .map_err(|e| EndpointError::Tls(e.to_string()))?
190 .with_root_certificates(roots)
191 .with_no_client_auth();
192 Ok(Arc::new(config))
193}
194
195#[derive(Clone)]
197pub struct HttpTransport {
198 endpoint: Endpoint,
199 tls: Option<tokio_rustls::TlsConnector>,
200 bearer: Option<Secret>,
201 transport_timeout: Option<Duration>,
202 phases: TransportPhases,
203 pool: Option<PoolUse>,
204}
205
206pub struct HttpConn {
209 sender: hyper::client::conn::http1::SendRequest<SignalBody>,
210 hooks: SharedHooks,
211}
212
213impl Pooled for HttpConn {
214 fn is_closed(&self) -> bool {
215 self.sender.is_closed()
216 }
217}
218
219pub type HttpPool = ConnectionPool<HttpConn>;
221
222#[derive(Clone)]
223struct PoolUse {
224 pool: Arc<HttpPool>,
225 material: KeyMaterial,
226}
227
228impl fmt::Debug for HttpTransport {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 f.debug_struct("HttpTransport")
231 .field("endpoint", &self.endpoint)
232 .field("tls", &self.tls.is_some())
233 .field("bearer", &self.bearer)
234 .field("transport_timeout", &self.transport_timeout)
235 .field("phases", &self.phases)
236 .field("pooled", &self.pool.is_some())
237 .finish()
238 }
239}
240
241impl HttpTransport {
242 pub fn new(
245 endpoint: Endpoint,
246 tls: Option<Arc<rustls::ClientConfig>>,
247 bearer: Option<Secret>,
248 transport_timeout: Option<Duration>,
249 ) -> Result<Self, EndpointError> {
250 let tls = match endpoint.scheme {
251 Scheme::Http if !endpoint.is_loopback() => {
252 return Err(EndpointError::PlaintextNotLoopback);
253 }
254 Scheme::Http => None,
255 Scheme::Https => Some(tokio_rustls::TlsConnector::from(match tls {
256 Some(c) => c,
257 None => default_tls()?,
258 })),
259 };
260 Ok(HttpTransport {
261 endpoint,
262 tls,
263 bearer,
264 transport_timeout,
265 phases: TransportPhases::Disabled,
266 pool: None,
267 })
268 }
269
270 pub fn with_pool(mut self, pool: Arc<HttpPool>, material: KeyMaterial) -> Self {
274 self.pool = Some(PoolUse { pool, material });
275 self
276 }
277
278 pub fn with_phases(mut self, phases: TransportPhases) -> Self {
281 self.phases = phases;
282 self
283 }
284
285 pub fn phases(&self) -> &TransportPhases {
286 &self.phases
287 }
288
289 pub fn endpoint(&self) -> &Endpoint {
290 &self.endpoint
291 }
292
293 pub async fn post(
297 &self,
298 path: &str,
299 body: Vec<u8>,
300 max_response_bytes: usize,
301 budget: &Budget,
302 sent: &SentTracker,
303 ) -> Result<HttpReply, TransportError> {
304 let deadline = budget.phase_deadline(self.transport_timeout);
305 let clock = self
306 .phases
307 .policy()
308 .map(|p| PhaseClock::start(*p, *budget, TransportPhase::Resolution));
309 let run = async {
310 let exchange = self.exchange(path, body, max_response_bytes, sent, clock.clone());
311 match &clock {
312 Some(c) => phased(c, sent, exchange).await,
313 None => exchange.await,
314 }
315 };
316 match tokio::time::timeout_at(deadline, run).await {
317 Ok(r) => r,
318 Err(_) => Err(TransportError::Expired {
319 sent: sent.may_have_sent(),
320 }),
321 }
322 }
323
324 async fn connect(&self, clock: &Option<PhaseClock>) -> Result<TcpStream, TransportError> {
325 let unsent = |e: std::io::Error| TransportError::Unsent(format!("connect: {e}"));
326 let addrs: Vec<SocketAddr> =
327 tokio::net::lookup_host((self.endpoint.host.as_str(), self.endpoint.port))
328 .await
329 .map_err(|e| TransportError::Unsent(format!("resolve: {e}")))?
330 .filter(|a| self.endpoint.scheme == Scheme::Https || a.ip().is_loopback())
331 .collect();
332 if let Some(c) = clock {
333 c.enter(TransportPhase::Connect);
334 }
335 let mut last = TransportError::Unsent("resolve: no usable address".into());
336 for a in addrs {
337 match TcpStream::connect(a).await {
338 Ok(s) => {
339 let _ = s.set_nodelay(true);
340 return Ok(s);
341 }
342 Err(e) => last = unsent(e),
343 }
344 }
345 Err(last)
346 }
347
348 async fn exchange(
349 &self,
350 path: &str,
351 body: Vec<u8>,
352 max: usize,
353 sent: &SentTracker,
354 clock: Option<PhaseClock>,
355 ) -> Result<HttpReply, TransportError> {
356 let tcp = self.connect(&clock).await?;
357 let enter = |p| {
358 if let Some(c) = &clock {
359 c.enter(p);
360 }
361 };
362 match &self.tls {
363 None => {
364 enter(TransportPhase::RequestWrite);
365 let io = TrackedIo::new(tcp, sent.clone()).with_clock(clock.clone());
366 self.http1(io, path, body, max, sent, clock).await
367 }
368 Some(connector) => {
369 let name = rustls::pki_types::ServerName::try_from(self.endpoint.host.clone())
370 .map_err(|e| TransportError::Unsent(format!("server name: {e}")))?;
371 enter(TransportPhase::SecureHandshake);
372 let tls = connector
373 .connect(name, tcp)
374 .await
375 .map_err(|e| TransportError::Unsent(format!("TLS handshake: {e}")))?;
376 enter(TransportPhase::RequestWrite);
377 let io = TrackedIo::new(tls, sent.clone()).with_clock(clock.clone());
378 self.http1(io, path, body, max, sent, clock).await
379 }
380 }
381 }
382
383 async fn http1<S>(
384 &self,
385 stream: S,
386 path: &str,
387 body: Vec<u8>,
388 max: usize,
389 sent: &SentTracker,
390 clock: Option<PhaseClock>,
391 ) -> Result<HttpReply, TransportError>
392 where
393 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
394 {
395 let failed = |what: &str, e: &dyn fmt::Display| {
396 let d = format!("{what}: {e}");
397 if sent.may_have_sent() {
398 TransportError::Interrupted(d)
399 } else {
400 TransportError::Unsent(d)
401 }
402 };
403 let (mut sender, conn) =
404 hyper::client::conn::http1::handshake::<_, SignalBody>(TokioIo::new(stream))
405 .await
406 .map_err(|e| failed("http handshake", &e))?;
407 tokio::pin!(conn);
408 let req = self.request(path, body, clock)?;
409 let send = sender.send_request(req);
410 tokio::pin!(send);
411 let resp = tokio::select! {
412 r = &mut send => r,
413 c = &mut conn => match c {
414 Err(e) => return Err(failed("connection", &e)),
415 Ok(()) => (&mut send).await,
416 },
417 }
418 .map_err(|e| failed("request", &e))?;
419 let status = resp.status();
420 let retry_after = resp
421 .headers()
422 .get(RETRY_AFTER)
423 .map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned());
424 let declared = resp
425 .headers()
426 .get(CONTENT_LENGTH)
427 .and_then(|v| v.to_str().ok())
428 .and_then(|v| v.parse::<u64>().ok());
429 if declared.is_some_and(|n| n > max as u64) {
430 return Err(TransportError::TooLarge { limit: max });
431 }
432 let mut body = resp.into_body();
433 let mut buf = Vec::new();
434 let mut conn_done = false;
435 loop {
436 let frame = if conn_done {
437 body.frame().await
438 } else {
439 tokio::select! {
440 f = body.frame() => f,
441 c = &mut conn => {
442 conn_done = true;
443 if let Err(e) = c {
444 return Err(failed("connection", &e));
445 }
446 continue;
447 }
448 }
449 };
450 match frame {
451 None => break,
452 Some(Err(e)) => return Err(failed("response body", &e)),
453 Some(Ok(f)) => {
454 if let Ok(data) = f.into_data() {
455 if buf.len() + data.len() > max {
456 return Err(TransportError::TooLarge { limit: max });
457 }
458 buf.extend_from_slice(&data);
459 }
460 }
461 }
462 }
463 Ok(HttpReply {
464 status: status_code(status),
465 retry_after,
466 body: buf,
467 })
468 }
469}
470
471impl HttpTransport {
472 fn request(
473 &self,
474 path: &str,
475 body: Vec<u8>,
476 clock: Option<PhaseClock>,
477 ) -> Result<Request<SignalBody>, TransportError> {
478 let mut req = Request::post(format!("{}{}", self.endpoint.base_path, path))
479 .header(HOST, self.endpoint.authority())
480 .header(CONTENT_TYPE, "application/json")
481 .header(CONTENT_LENGTH, body.len());
482 if let Some(b) = &self.bearer {
483 req = req.header(AUTHORIZATION, format!("Bearer {}", b.expose()));
484 }
485 req.body(SignalBody {
486 inner: Full::new(Bytes::from(body)),
487 clock,
488 })
489 .map_err(|e| TransportError::Unsent(format!("request: {e}")))
490 }
491
492 pub async fn post_observed(
499 &self,
500 path: &str,
501 body: Vec<u8>,
502 max_response_bytes: usize,
503 budget: &Budget,
504 sent: &SentTracker,
505 principal_digest: Option<String>,
506 ) -> (
507 Result<HttpReply, TransportError>,
508 Option<ConnectionObservation>,
509 ) {
510 let Some(pu) = &self.pool else {
511 return (
512 self.post(path, body, max_response_bytes, budget, sent)
513 .await,
514 None,
515 );
516 };
517 let deadline = budget.phase_deadline(self.transport_timeout);
518 let mut material = pu.material.clone();
519 material.principal = principal_digest;
520 let key = material.key();
521 let mut lease = match tokio::time::timeout_at(deadline, pu.pool.acquire(&key, budget)).await
522 {
523 Ok(Ok(l)) => l,
524 _ => {
525 return (
526 Err(TransportError::Unsent(
527 "the connection pool has no room within the budget".into(),
528 )),
529 None,
530 );
531 }
532 };
533 let clock = self
534 .phases
535 .policy()
536 .map(|p| PhaseClock::start(*p, *budget, TransportPhase::Resolution));
537 let run = async {
538 let exchange = self.pooled(
539 &mut lease,
540 path,
541 body,
542 max_response_bytes,
543 sent,
544 clock.clone(),
545 );
546 match &clock {
547 Some(c) => phased(c, sent, exchange).await,
548 None => exchange.await,
549 }
550 };
551 let result = match tokio::time::timeout_at(deadline, run).await {
552 Ok(r) => r,
553 Err(_) => Err(TransportError::Expired {
554 sent: sent.may_have_sent(),
555 }),
556 };
557 let mut observed = lease.observation(key.digest.as_deref().unwrap_or("none"));
558 if let Some(c) = lease.conn() {
559 let idle = SentTracker::new();
561 idle.try_abort();
562 *c.hooks.lock().unwrap_or_else(|e| e.into_inner()) = IoHooks {
563 sent: idle,
564 clock: None,
565 };
566 }
567 let eviction = pu.pool.release(lease, result.is_ok());
568 if let Some(o) = &mut observed {
569 o.eviction = eviction;
570 }
571 (result, observed)
572 }
573
574 async fn pooled(
575 &self,
576 lease: &mut Lease<HttpConn>,
577 path: &str,
578 body: Vec<u8>,
579 max: usize,
580 sent: &SentTracker,
581 clock: Option<PhaseClock>,
582 ) -> Result<HttpReply, TransportError> {
583 let failed = |what: &str, e: &dyn fmt::Display| {
584 let d = format!("{what}: {e}");
585 if sent.may_have_sent() {
586 TransportError::Interrupted(d)
587 } else {
588 TransportError::Unsent(d)
589 }
590 };
591 let enter = |p| {
592 if let Some(c) = &clock {
593 c.enter(p);
594 }
595 };
596 let hooks = IoHooks {
597 sent: sent.clone(),
598 clock: clock.clone(),
599 };
600 if lease.conn().is_none() {
601 let tcp = self.connect(&clock).await?;
602 let shared: SharedHooks = Arc::new(std::sync::Mutex::new(hooks.clone()));
603 let sender = match &self.tls {
604 None => {
605 enter(TransportPhase::RequestWrite);
606 spawn_http1(TrackedIo::shared(tcp, shared.clone()))
607 .await
608 .map_err(|e| failed("http handshake", &e))?
609 }
610 Some(connector) => {
611 let name = rustls::pki_types::ServerName::try_from(self.endpoint.host.clone())
612 .map_err(|e| TransportError::Unsent(format!("server name: {e}")))?;
613 enter(TransportPhase::SecureHandshake);
614 let tls = connector
615 .connect(name, tcp)
616 .await
617 .map_err(|e| TransportError::Unsent(format!("TLS handshake: {e}")))?;
618 enter(TransportPhase::RequestWrite);
619 spawn_http1(TrackedIo::shared(tls, shared.clone()))
620 .await
621 .map_err(|e| failed("http handshake", &e))?
622 }
623 };
624 lease.fill(HttpConn {
625 sender,
626 hooks: shared,
627 });
628 } else {
629 enter(TransportPhase::RequestWrite);
632 }
633 let conn = lease.conn_mut().expect("filled above");
634 *conn.hooks.lock().unwrap_or_else(|e| e.into_inner()) = hooks;
635 conn.sender
637 .ready()
638 .await
639 .map_err(|e| failed("pooled connection", &e))?;
640 let req = self.request(path, body, clock)?;
641 let resp = conn
642 .sender
643 .send_request(req)
644 .await
645 .map_err(|e| failed("request", &e))?;
646 let status = resp.status();
647 let retry_after = resp
648 .headers()
649 .get(RETRY_AFTER)
650 .map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned());
651 let declared = resp
652 .headers()
653 .get(CONTENT_LENGTH)
654 .and_then(|v| v.to_str().ok())
655 .and_then(|v| v.parse::<u64>().ok());
656 if declared.is_some_and(|n| n > max as u64) {
657 return Err(TransportError::TooLarge { limit: max });
658 }
659 let mut body = resp.into_body();
660 let mut buf = Vec::new();
661 loop {
662 match body.frame().await {
663 None => break,
664 Some(Err(e)) => return Err(failed("response body", &e)),
665 Some(Ok(f)) => {
666 if let Ok(data) = f.into_data() {
667 if buf.len() + data.len() > max {
668 return Err(TransportError::TooLarge { limit: max });
669 }
670 buf.extend_from_slice(&data);
671 }
672 }
673 }
674 }
675 Ok(HttpReply {
676 status: status_code(status),
677 retry_after,
678 body: buf,
679 })
680 }
681}
682
683async fn spawn_http1<S>(
686 stream: S,
687) -> Result<hyper::client::conn::http1::SendRequest<SignalBody>, hyper::Error>
688where
689 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
690{
691 let (sender, conn) =
692 hyper::client::conn::http1::handshake::<_, SignalBody>(TokioIo::new(stream)).await?;
693 tokio::spawn(async move {
694 let _ = conn.await;
695 });
696 Ok(sender)
697}
698
699struct SignalBody {
702 inner: Full<Bytes>,
703 clock: Option<PhaseClock>,
704}
705
706impl Body for SignalBody {
707 type Data = Bytes;
708 type Error = std::convert::Infallible;
709
710 fn poll_frame(
711 mut self: std::pin::Pin<&mut Self>,
712 cx: &mut std::task::Context<'_>,
713 ) -> std::task::Poll<Option<Result<Frame<Bytes>, Self::Error>>> {
714 let r = std::pin::Pin::new(&mut self.inner).poll_frame(cx);
715 if r.is_ready()
716 && let Some(c) = &self.clock
717 {
718 c.body_taken();
719 }
720 r
721 }
722
723 fn is_end_stream(&self) -> bool {
724 self.inner.is_end_stream()
725 }
726
727 fn size_hint(&self) -> SizeHint {
728 self.inner.size_hint()
729 }
730}
731
732fn status_code(s: StatusCode) -> u16 {
733 s.as_u16()
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn endpoints_parse_and_plaintext_is_loopback_only() {
742 let e = Endpoint::parse("http://127.0.0.1:8080/base/").unwrap();
743 assert_eq!(
744 (e.scheme, e.host.as_str(), e.port, e.base_path.as_str()),
745 (Scheme::Http, "127.0.0.1", 8080, "/base")
746 );
747 assert!(e.is_loopback());
748 let v6 = Endpoint::parse("http://[::1]:9").unwrap();
749 assert!(v6.is_loopback());
750 assert_eq!(v6.authority(), "[::1]:9");
751 assert_eq!(
752 Endpoint::parse("https://example.invalid").unwrap().port,
753 443
754 );
755 for bad in [
756 "ftp://x",
757 "http://u:p@127.0.0.1",
758 "http://127.0.0.1/?q",
759 "http://:80",
760 "http://127.0.0.1:x",
761 ] {
762 assert!(Endpoint::parse(bad).is_err(), "{bad}");
763 }
764 let far = Endpoint::parse("http://192.0.2.1:80").unwrap();
765 assert_eq!(
766 HttpTransport::new(far, None, None, None).err(),
767 Some(EndpointError::PlaintextNotLoopback)
768 );
769 let tls = Endpoint::parse("https://192.0.2.1").unwrap();
770 assert!(HttpTransport::new(tls, None, None, None).is_ok());
771 }
772
773 #[test]
774 fn secrets_never_print() {
775 let s = Secret::new("SYNTHETIC-TOKEN");
776 assert!(!format!("{s:?}").contains("SYNTHETIC-TOKEN"));
777 let t = HttpTransport::new(
778 Endpoint::parse("http://127.0.0.1:1").unwrap(),
779 None,
780 Some(s),
781 None,
782 )
783 .unwrap();
784 assert!(!format!("{t:?}").contains("SYNTHETIC-TOKEN"));
785 }
786}