Skip to main content

temporalio_client/
proxy.rs

1use base64::prelude::*;
2use http_body_util::Empty;
3use hyper::{body::Bytes, header};
4use hyper_util::{
5    client::legacy::{
6        Client,
7        connect::{Connected, Connection},
8    },
9    rt::{TokioExecutor, TokioIo},
10};
11use std::{
12    future::Future,
13    io,
14    pin::Pin,
15    task::{Context, Poll},
16};
17use tokio::{
18    io::{AsyncRead, AsyncWrite, ReadBuf},
19    net::TcpStream,
20};
21use tonic::transport::{Channel, Endpoint};
22use tower::{Service, service_fn};
23
24#[cfg(unix)]
25use tokio::net::UnixStream;
26
27/// Options for HTTP CONNECT proxy.
28#[derive(Clone, Debug, bon::Builder)]
29#[builder(start_fn = new, on(String, into))]
30#[non_exhaustive]
31pub struct HttpConnectProxyOptions {
32    /// The host:port to proxy through for TCP, or unix:/path/to/unix.sock for
33    /// Unix socket (which means it must start with "unix:/").
34    #[builder(start_fn)]
35    pub target_addr: String,
36    /// Optional HTTP basic auth for the proxy as user/pass tuple.
37    pub basic_auth: Option<(String, String)>,
38}
39
40impl HttpConnectProxyOptions {
41    /// Create a channel from the given endpoint that uses the HTTP CONNECT proxy.
42    pub async fn connect_endpoint(
43        &self,
44        endpoint: &Endpoint,
45    ) -> Result<Channel, tonic::transport::Error> {
46        let proxy_options = self.clone();
47        let svc_fn = service_fn(move |uri: tonic::transport::Uri| {
48            let proxy_options = proxy_options.clone();
49            async move { proxy_options.connect(uri).await }
50        });
51        endpoint.connect_with_connector(svc_fn).await
52    }
53
54    async fn connect(
55        &self,
56        uri: tonic::transport::Uri,
57    ) -> anyhow::Result<hyper::upgrade::Upgraded> {
58        let uri = ensure_connect_authority_port(uri);
59        debug!("Connecting to {} via proxy at {}", uri, self.target_addr);
60        // Create CONNECT request
61        let mut req_build = hyper::Request::builder().method("CONNECT").uri(uri);
62        if let Some((user, pass)) = &self.basic_auth {
63            let creds = BASE64_STANDARD.encode(format!("{user}:{pass}"));
64            req_build = req_build.header(header::PROXY_AUTHORIZATION, format!("Basic {creds}"));
65        }
66        let req = req_build.body(Empty::<Bytes>::new())?;
67
68        // We have to create a client with a specific connector because Hyper is
69        // not letting us change the HTTP/2 authority
70        let client = Client::builder(TokioExecutor::new())
71            .build(OverrideAddrConnector(self.target_addr.clone()));
72
73        // Send request
74        let res = client.request(req).await?;
75        if res.status().is_success() {
76            Ok(hyper::upgrade::on(res).await?)
77        } else {
78            Err(anyhow::anyhow!(
79                "CONNECT call failed with status: {}",
80                res.status()
81            ))
82        }
83    }
84}
85
86#[derive(Clone)]
87struct OverrideAddrConnector(String);
88
89impl Service<hyper::Uri> for OverrideAddrConnector {
90    type Response = TokioIo<ProxyStream>;
91
92    type Error = anyhow::Error;
93
94    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
95
96    fn poll_ready(&mut self, _ctx: &mut Context<'_>) -> Poll<anyhow::Result<()>> {
97        Poll::Ready(Ok(()))
98    }
99
100    fn call(&mut self, _uri: hyper::Uri) -> Self::Future {
101        let target_addr = self.0.clone();
102        let fut = async move {
103            Ok(TokioIo::new(
104                ProxyStream::connect(target_addr.as_str()).await?,
105            ))
106        };
107        Box::pin(fut)
108    }
109}
110
111enum ProxyStream {
112    Tcp(TcpStream),
113    #[cfg(unix)]
114    Unix(UnixStream),
115}
116
117impl ProxyStream {
118    async fn connect(target_addr: &str) -> anyhow::Result<Self> {
119        if target_addr.starts_with("unix:/") {
120            #[cfg(unix)]
121            {
122                Ok(ProxyStream::Unix(
123                    UnixStream::connect(&target_addr[5..]).await?,
124                ))
125            }
126            #[cfg(not(unix))]
127            {
128                Err(anyhow::anyhow!(
129                    "Unix sockets are not supported on this platform"
130                ))
131            }
132        } else {
133            Ok(ProxyStream::Tcp(TcpStream::connect(target_addr).await?))
134        }
135    }
136}
137
138impl AsyncRead for ProxyStream {
139    fn poll_read(
140        self: Pin<&mut Self>,
141        cx: &mut Context<'_>,
142        buf: &mut ReadBuf<'_>,
143    ) -> Poll<io::Result<()>> {
144        match self.get_mut() {
145            ProxyStream::Tcp(s) => Pin::new(s).poll_read(cx, buf),
146            #[cfg(unix)]
147            ProxyStream::Unix(s) => Pin::new(s).poll_read(cx, buf),
148        }
149    }
150}
151
152impl AsyncWrite for ProxyStream {
153    fn poll_write(
154        self: Pin<&mut Self>,
155        cx: &mut Context<'_>,
156        buf: &[u8],
157    ) -> Poll<io::Result<usize>> {
158        match self.get_mut() {
159            ProxyStream::Tcp(s) => Pin::new(s).poll_write(cx, buf),
160            #[cfg(unix)]
161            ProxyStream::Unix(s) => Pin::new(s).poll_write(cx, buf),
162        }
163    }
164
165    fn poll_write_vectored(
166        self: Pin<&mut Self>,
167        cx: &mut Context<'_>,
168        bufs: &[io::IoSlice<'_>],
169    ) -> Poll<io::Result<usize>> {
170        match self.get_mut() {
171            ProxyStream::Tcp(s) => Pin::new(s).poll_write_vectored(cx, bufs),
172            #[cfg(unix)]
173            ProxyStream::Unix(s) => Pin::new(s).poll_write_vectored(cx, bufs),
174        }
175    }
176
177    fn is_write_vectored(&self) -> bool {
178        match self {
179            ProxyStream::Tcp(s) => s.is_write_vectored(),
180            #[cfg(unix)]
181            ProxyStream::Unix(s) => s.is_write_vectored(),
182        }
183    }
184
185    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
186        match self.get_mut() {
187            ProxyStream::Tcp(s) => Pin::new(s).poll_flush(cx),
188            #[cfg(unix)]
189            ProxyStream::Unix(s) => Pin::new(s).poll_flush(cx),
190        }
191    }
192
193    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
194        match self.get_mut() {
195            ProxyStream::Tcp(s) => Pin::new(s).poll_shutdown(cx),
196            #[cfg(unix)]
197            ProxyStream::Unix(s) => Pin::new(s).poll_shutdown(cx),
198        }
199    }
200}
201
202impl Connection for ProxyStream {
203    fn connected(&self) -> Connected {
204        match self {
205            ProxyStream::Tcp(s) => s.connected(),
206            // There is no special connected metadata for Unix sockets
207            #[cfg(unix)]
208            ProxyStream::Unix(_) => Connected::new(),
209        }
210    }
211}
212
213/// Ensure the URI authority includes an explicit port so that hyper emits a
214/// RFC 9110-compliant CONNECT request-target (authority-form requires host:port).
215fn ensure_connect_authority_port(uri: tonic::transport::Uri) -> tonic::transport::Uri {
216    if uri.port().is_some() {
217        return uri;
218    }
219    let port = match uri.scheme_str() {
220        Some("https") => 443,
221        Some("http") => 80,
222        _ => return uri,
223    };
224    let mut parts = uri.into_parts();
225    if let Some(ref authority) = parts.authority
226        && let Ok(new_auth) = format!("{}:{}", authority.host(), port).parse()
227    {
228        parts.authority = Some(new_auth);
229    }
230    tonic::transport::Uri::from_parts(parts).expect("adding port to valid URI should not fail")
231}
232
233#[cfg(test)]
234mod tests {
235    use super::{HttpConnectProxyOptions, ProxyStream};
236    use crate::{
237        Client, ClientOptions, Connection as TemporalConnection, ConnectionOptions, RetryOptions,
238        grpc::WorkflowService,
239    };
240    use base64::prelude::*;
241    use futures_util::{FutureExt, future::BoxFuture};
242    use http::{Request, Response};
243    use http_body_util::Empty;
244    use hyper::{
245        body::{Bytes, Incoming},
246        server::conn::http1,
247        service::service_fn,
248    };
249    use hyper_util::rt::TokioIo;
250    use std::{
251        convert::Infallible,
252        io,
253        sync::{
254            Arc,
255            atomic::{AtomicUsize, Ordering},
256        },
257        task::{Context, Poll},
258    };
259    use temporalio_common::protos::temporal::api::workflowservice::v1::ListNamespacesRequest;
260    #[cfg(unix)]
261    use tokio::net::UnixListener;
262    use tokio::{
263        io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
264        net::{TcpListener, TcpStream},
265        sync::oneshot,
266    };
267    use tokio_stream::wrappers::TcpListenerStream;
268    use tonic::{IntoRequest, body::Body, server::NamedService, transport::Server};
269    use tower::Service;
270    use tracing::warn;
271    use url::Url;
272
273    #[derive(Clone)]
274    struct FakeWorkflowService<F>(F);
275
276    impl<F> Service<Request<Body>> for FakeWorkflowService<F>
277    where
278        F: FnMut(Request<Body>) -> BoxFuture<'static, Response<Body>>,
279    {
280        type Response = Response<Body>;
281        type Error = Infallible;
282        type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
283
284        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
285            Poll::Ready(Ok(()))
286        }
287
288        fn call(&mut self, request: Request<Body>) -> Self::Future {
289            let response = (self.0)(request);
290            async move { Ok(response.await) }.boxed()
291        }
292    }
293
294    impl<F> NamedService for FakeWorkflowService<F> {
295        const NAME: &'static str = "temporal.api.workflowservice.v1.WorkflowService";
296    }
297
298    struct FakeServer {
299        addr: std::net::SocketAddr,
300        shutdown_tx: oneshot::Sender<()>,
301    }
302
303    async fn fake_server<F>(response_maker: F) -> FakeServer
304    where
305        F: FnMut(Request<Body>) -> BoxFuture<'static, Response<Body>>
306            + Clone
307            + Send
308            + Sync
309            + 'static,
310    {
311        let (shutdown_tx, shutdown_rx) = oneshot::channel();
312        let listener = TcpListener::bind("[::]:0").await.unwrap();
313        let addr = listener.local_addr().unwrap();
314        tokio::spawn(async move {
315            Server::builder()
316                .add_service(FakeWorkflowService(response_maker))
317                .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async move {
318                    let _ = shutdown_rx.await;
319                })
320                .await
321                .unwrap();
322        });
323        FakeServer { addr, shutdown_tx }
324    }
325
326    struct HttpProxy {
327        proxy_hits: Arc<AtomicUsize>,
328        shutdown_tx: oneshot::Sender<()>,
329    }
330
331    impl HttpProxy {
332        fn spawn_tcp(listener: TcpListener) -> Self {
333            Self::spawn(ProxyListener::Tcp(listener))
334        }
335
336        #[cfg(unix)]
337        fn spawn_unix(listener: UnixListener) -> Self {
338            Self::spawn(ProxyListener::Unix(listener))
339        }
340
341        fn spawn(listener: ProxyListener) -> Self {
342            let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
343            let proxy_hits = Arc::new(AtomicUsize::new(0));
344            let proxy_hits_for_task = proxy_hits.clone();
345            tokio::spawn(async move {
346                loop {
347                    let proxy_hits = proxy_hits_for_task.clone();
348                    tokio::select! {
349                        _ = &mut shutdown_rx => break,
350                        stream = listener.accept() => {
351                            let stream = match stream {
352                                Ok(stream) => stream,
353                                Err(error) => {
354                                    warn!(%error, "Proxy accept failed");
355                                    continue;
356                                }
357                            };
358                            tokio::spawn(async move {
359                                if let Err(error) = http1::Builder::new()
360                                    .serve_connection(
361                                        TokioIo::new(stream),
362                                        service_fn(move |request| {
363                                            handle_connect(request, proxy_hits.clone())
364                                        }),
365                                    )
366                                    .with_upgrades()
367                                    .await
368                                {
369                                    warn!(%error, "Proxy connection failed");
370                                }
371                            });
372                        }
373                    }
374                }
375            });
376            Self {
377                proxy_hits,
378                shutdown_tx,
379            }
380        }
381
382        fn hit_count(&self) -> usize {
383            self.proxy_hits.load(Ordering::SeqCst)
384        }
385
386        fn shutdown(self) {
387            let _ = self.shutdown_tx.send(());
388        }
389    }
390
391    async fn handle_connect(
392        request: Request<Incoming>,
393        counter: Arc<AtomicUsize>,
394    ) -> Result<Response<Empty<Bytes>>, hyper::Error> {
395        if request.method() != hyper::Method::CONNECT {
396            return Ok(Response::builder()
397                .status(hyper::StatusCode::METHOD_NOT_ALLOWED)
398                .body(Empty::new())
399                .unwrap());
400        }
401
402        counter.fetch_add(1, Ordering::SeqCst);
403        tokio::spawn(async move {
404            if let Some(addr) = request
405                .uri()
406                .authority()
407                .map(|authority| authority.as_str())
408                && let Ok(mut server_stream) = TcpStream::connect(addr).await
409                && let Ok(upgraded) = hyper::upgrade::on(request).await
410            {
411                let mut upgraded = TokioIo::new(upgraded);
412                let _ = tokio::io::copy_bidirectional(&mut upgraded, &mut server_stream).await;
413            }
414        });
415
416        Ok(Response::builder()
417            .status(hyper::StatusCode::OK)
418            .body(Empty::new())
419            .unwrap())
420    }
421
422    enum ProxyListener {
423        Tcp(TcpListener),
424        #[cfg(unix)]
425        Unix(UnixListener),
426    }
427
428    impl ProxyListener {
429        async fn accept(&self) -> io::Result<ProxyStream> {
430            match self {
431                ProxyListener::Tcp(listener) => listener
432                    .accept()
433                    .await
434                    .map(|(stream, _)| ProxyStream::Tcp(stream)),
435                #[cfg(unix)]
436                ProxyListener::Unix(listener) => listener
437                    .accept()
438                    .await
439                    .map(|(stream, _)| ProxyStream::Unix(stream)),
440            }
441        }
442    }
443
444    struct CapturedConnect {
445        request_line: String,
446        headers: Vec<String>,
447    }
448
449    async fn mock_proxy() -> (String, tokio::task::JoinHandle<CapturedConnect>) {
450        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
451        let addr = listener.local_addr().unwrap().to_string();
452        let handle = tokio::spawn(async move {
453            let (stream, _) = listener.accept().await.unwrap();
454            let mut reader = BufReader::new(stream);
455            let mut request_line = String::new();
456            reader.read_line(&mut request_line).await.unwrap();
457            let mut headers = Vec::new();
458            loop {
459                let mut line = String::new();
460                reader.read_line(&mut line).await.unwrap();
461                if line == "\r\n" {
462                    break;
463                }
464                headers.push(line.trim_end().to_string());
465            }
466            reader
467                .into_inner()
468                .write_all(b"HTTP/1.1 200 OK\r\n\r\n")
469                .await
470                .unwrap();
471            CapturedConnect {
472                request_line,
473                headers,
474            }
475        });
476        (addr, handle)
477    }
478
479    #[rstest::rstest]
480    #[case("https://example.com/some/path", "CONNECT example.com:443 HTTP/1.1")]
481    #[case("http://example.com", "CONNECT example.com:80 HTTP/1.1")]
482    #[case("https://example.com:7233", "CONNECT example.com:7233 HTTP/1.1")]
483    #[tokio::test]
484    async fn connect_request_line(#[case] uri: &str, #[case] expected: &str) {
485        let (proxy_addr, handle) = mock_proxy().await;
486        let opts = HttpConnectProxyOptions::new(proxy_addr).build();
487        let uri: tonic::transport::Uri = uri.parse().unwrap();
488        let _ = opts.connect(uri).await;
489
490        let captured = handle.await.unwrap();
491        assert_eq!(captured.request_line.trim(), expected);
492    }
493
494    #[tokio::test]
495    async fn connect_includes_basic_auth() {
496        let (proxy_addr, handle) = mock_proxy().await;
497        let opts = HttpConnectProxyOptions::new(proxy_addr)
498            .basic_auth(("user".to_string(), "pass".to_string()))
499            .build();
500        let uri: tonic::transport::Uri = "https://example.com:7233".parse().unwrap();
501        let _ = opts.connect(uri).await;
502
503        let captured = handle.await.unwrap();
504        let creds = BASE64_STANDARD.encode("user:pass");
505        let auth_header = captured
506            .headers
507            .iter()
508            .find(|h| h.to_lowercase().starts_with("proxy-authorization:"))
509            .expect("missing proxy-authorization header");
510        assert_eq!(
511            auth_header.trim(),
512            format!("proxy-authorization: Basic {creds}")
513        );
514    }
515
516    #[tokio::test]
517    async fn connection_uses_http_connect_proxy() {
518        let call_count = Arc::new(AtomicUsize::new(0));
519        let call_count_for_server = call_count.clone();
520        let server = fake_server(move |_| {
521            call_count_for_server.fetch_add(1, Ordering::SeqCst);
522            async { Response::new(Body::empty()) }.boxed()
523        })
524        .await;
525
526        let tcp_proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
527        let tcp_proxy_addr = tcp_proxy_listener.local_addr().unwrap();
528        let tcp_proxy = HttpProxy::spawn_tcp(tcp_proxy_listener);
529
530        let mut options = ConnectionOptions::new(
531            Url::parse(&format!("http://[::1]:{}", server.addr.port())).unwrap(),
532        )
533        .retry_options(RetryOptions::no_retries())
534        .skip_get_system_info(true)
535        .build();
536
537        let connection = TemporalConnection::connect(options.clone()).await.unwrap();
538        let client_options = ClientOptions::new("my-namespace").build();
539        let client = Client::new(connection, client_options).unwrap();
540        let _ = WorkflowService::list_namespaces(
541            &mut client.clone(),
542            ListNamespacesRequest::default().into_request(),
543        )
544        .await;
545        assert_eq!(call_count.load(Ordering::SeqCst), 1);
546        assert_eq!(tcp_proxy.hit_count(), 0);
547
548        options.http_connect_proxy =
549            Some(HttpConnectProxyOptions::new(tcp_proxy_addr.to_string()).build());
550        options.dns_load_balancing = None;
551        let connection = TemporalConnection::connect(options.clone()).await.unwrap();
552        let client_options = ClientOptions::new("my-namespace").build();
553        let proxied_client = Client::new(connection, client_options).unwrap();
554        let _ = WorkflowService::list_namespaces(
555            &mut proxied_client.clone(),
556            ListNamespacesRequest::default().into_request(),
557        )
558        .await;
559        assert_eq!(call_count.load(Ordering::SeqCst), 2);
560        assert_eq!(tcp_proxy.hit_count(), 1);
561
562        #[cfg(unix)]
563        {
564            let socket_dir = tempfile::tempdir().unwrap();
565            let socket_path = socket_dir.path().join("http-proxy.sock");
566            let unix_proxy = HttpProxy::spawn_unix(UnixListener::bind(&socket_path).unwrap());
567
568            options.http_connect_proxy = Some(
569                HttpConnectProxyOptions::new(format!("unix:{}", socket_path.display())).build(),
570            );
571            let connection = TemporalConnection::connect(options).await.unwrap();
572            let client_options = ClientOptions::new("my-namespace").build();
573            let proxied_client = Client::new(connection, client_options).unwrap();
574            let _ = WorkflowService::list_namespaces(
575                &mut proxied_client.clone(),
576                ListNamespacesRequest::default().into_request(),
577            )
578            .await;
579            assert_eq!(call_count.load(Ordering::SeqCst), 3);
580            assert_eq!(unix_proxy.hit_count(), 1);
581
582            unix_proxy.shutdown();
583        }
584
585        let _ = server.shutdown_tx.send(());
586        tcp_proxy.shutdown();
587    }
588}