Skip to main content

tailscale/
http.rs

1//! A [`hyper`]-compatible connector that routes outbound HTTP requests over the tailnet.
2//!
3//! This is the analog of Go `tsnet.Server.HTTPClient`, whose entire mechanism is
4//! `&http.Client{Transport: &http.Transport{DialContext: s.Dial}}` — a bare dialer injection with no
5//! extra client defaults. [`TailnetConnector`] is that injection for the Rust `hyper` ecosystem:
6//! given an `http://` request [`Uri`], it resolves the host as a MagicDNS name (or IPv4 literal) and
7//! dials it into the overlay (default port 80), so the request egresses over the tailnet rather than
8//! the host's network. Redirects, pooling, and timeouts are the hyper client's concern — the
9//! connector only supplies the transport, exactly like Go's `DialContext`.
10//!
11//! Obtain one from [`Device::http_connector`](crate::Device::http_connector) and hand it to
12//! `hyper_util::client::legacy::Client::builder(...).build(connector)`.
13//!
14//! Available only with the **`hyper`** crate feature.
15//!
16//! # TLS — this is a PLAINTEXT connector
17//!
18//! [`TailnetConnector`] yields a **plain** overlay TCP stream and performs **no TLS**. Unlike Go's
19//! `http.Transport` (which wraps the `DialContext` conn in TLS for `https://` itself), hyper's legacy
20//! `Client` does no TLS — it speaks HTTP directly over whatever stream the connector returns. So an
21//! `https://` request through a bare `TailnetConnector` would be sent **cleartext onto port 443**;
22//! this connector therefore **rejects** `https`/`wss` URIs (with `BadRequest`) rather than dial them
23//! into a silent plaintext-on-TLS-port failure. Traffic over the tailnet is still WireGuard-encrypted
24//! hop-to-hop (the host's origin IP never leaks), but there is no end-to-end TLS / peer-certificate
25//! validation.
26//!
27//! For real HTTPS over the tailnet, wrap this connector in a TLS connector — e.g.
28//! `hyper_rustls::HttpsConnectorBuilder::new().with_native_roots()?.https_or_http().enable_http1().wrap_connector(connector)`
29//! — which performs the TLS handshake over the tailnet stream this connector supplies.
30//!
31//! # IPv4-only
32//!
33//! Like the rest of this fork's tailnet surface, the connector is IPv4-only: hosts resolve to a
34//! tailnet IPv4 (or are dialed as an IPv4 literal). An IPv6-only destination is not reachable even
35//! with [`Config::enable_ipv6`](crate::Config), unlike [`Device::dial`](crate::Device::dial).
36//!
37//! # Example
38//!
39//! ```rust,no_run
40//! # #[tokio::main]
41//! # async fn main() -> Result<(), Box<dyn core::error::Error>> {
42//! # use tailscale::{Config, Device};
43//! use hyper_util::{client::legacy::Client, rt::TokioExecutor};
44//!
45//! let dev = Device::new(
46//!     &Config::default_with_key_file("tsrs_keys.json").await?,
47//!     Some("YOUR_AUTH_KEY".to_owned()),
48//! ).await?;
49//!
50//! // A hyper client that dials every (http://) request over the tailnet — the analog of Go
51//! // `tsnet.Server.HTTPClient`. (Body type `String` here just to name the generic; use whatever
52//! // `http_body::Body` your requests carry. For https, wrap `connector` in a TLS connector first.)
53//! let connector = dev.http_connector().await?;
54//! let client: Client<_, String> = Client::builder(TokioExecutor::new()).build(connector);
55//!
56//! let resp = client.get("http://my-peer:8080/".parse()?).await?;
57//! println!("status: {}", resp.status());
58//! #   Ok(())
59//! # }
60//! ```
61
62use std::{
63    future::Future,
64    pin::Pin,
65    task::{Context, Poll},
66};
67
68use hyper::Uri;
69use hyper_util::{
70    client::legacy::connect::{Connected, Connection},
71    rt::TokioIo,
72};
73use tower_service::Service;
74
75use crate::{Error, InternalErrorKind, loopback::OverlayDialer, netstack};
76
77/// A [`hyper`] connector that dials over the tailnet (the analog of Go `http.Transport.DialContext =
78/// tsnet.Server.Dial`). Build one with [`Device::http_connector`](crate::Device::http_connector) and
79/// pass it to `hyper_util::client::legacy::Client::builder(...).build(connector)`.
80///
81/// Cloneable and `Send`/`'static` (it holds only the `&Device`-free [`OverlayDialer`]), so it
82/// satisfies hyper-util's connector bounds and can back a pooled `Client`.
83#[derive(Clone)]
84pub struct TailnetConnector {
85    dialer: OverlayDialer,
86}
87
88impl TailnetConnector {
89    pub(crate) fn new(dialer: OverlayDialer) -> Self {
90        Self { dialer }
91    }
92}
93
94/// The connection [`TailnetConnector`] yields: a tailnet [`netstack::TcpStream`] wrapped so it
95/// satisfies hyper's IO + [`Connection`] requirements. [`TokioIo`] adapts the stream's tokio
96/// `AsyncRead`/`AsyncWrite` to hyper's `rt::{Read,Write}`, and the [`Connection`] impl reports the
97/// (unremarkable) connection metadata hyper needs.
98pub struct TailnetStream(TokioIo<netstack::TcpStream>);
99
100impl hyper::rt::Read for TailnetStream {
101    fn poll_read(
102        self: Pin<&mut Self>,
103        cx: &mut Context<'_>,
104        buf: hyper::rt::ReadBufCursor<'_>,
105    ) -> Poll<std::io::Result<()>> {
106        Pin::new(&mut self.get_mut().0).poll_read(cx, buf)
107    }
108}
109
110impl hyper::rt::Write for TailnetStream {
111    fn poll_write(
112        self: Pin<&mut Self>,
113        cx: &mut Context<'_>,
114        buf: &[u8],
115    ) -> Poll<std::io::Result<usize>> {
116        Pin::new(&mut self.get_mut().0).poll_write(cx, buf)
117    }
118
119    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
120        Pin::new(&mut self.get_mut().0).poll_flush(cx)
121    }
122
123    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
124        Pin::new(&mut self.get_mut().0).poll_shutdown(cx)
125    }
126}
127
128impl Connection for TailnetStream {
129    fn connected(&self) -> Connected {
130        // A plain overlay TCP connection: no proxy, and no ALPN to advertise (this connector does no
131        // TLS — if a caller wraps it in a TLS connector for https, that wrapper reports its own
132        // negotiated ALPN). `Connected::new()` is the correct unremarkable default.
133        Connected::new()
134    }
135}
136
137impl Service<Uri> for TailnetConnector {
138    type Response = TailnetStream;
139    type Error = Error;
140    type Future = Pin<Box<dyn Future<Output = Result<TailnetStream, Error>> + Send>>;
141
142    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
143        // The dialer is always ready; back-pressure (port allocation, handshake) is per-call inside
144        // `call`, matching how Go's `DialContext` does all its work per connection.
145        Poll::Ready(Ok(()))
146    }
147
148    fn call(&mut self, uri: Uri) -> Self::Future {
149        let dialer = self.dialer.clone();
150        Box::pin(async move {
151            let (host, port) = host_port(&uri)?;
152            dialer
153                .dial_host_port(&host, port)
154                .await
155                .map(|stream| TailnetStream(TokioIo::new(stream)))
156        })
157    }
158}
159
160/// Extract the `(host, port)` to dial from an **`http://`** request [`Uri`], defaulting the port to
161/// 80 when none is given. The host has any IPv6 brackets stripped (a literal `[::1]`-style authority
162/// must not reach the resolver with its brackets).
163///
164/// This connector is **plaintext-only** (it yields a bare overlay TCP stream and does no TLS — see
165/// [`TailnetConnector`]). A secure scheme (`https`/`wss`) is therefore **rejected** with
166/// [`InternalErrorKind::BadRequest`] rather than dialed: hyper's legacy `Client` does not wrap the
167/// returned stream in TLS, so honoring `https` here would send a cleartext request onto port 443 and
168/// silently fail. For HTTPS over the tailnet, wrap this connector in a TLS connector (see the module
169/// docs). The scheme is validated even when an explicit port is present, so `https://peer:443` /
170/// `wss://peer:443` cannot slip a plaintext dial onto a TLS port.
171///
172/// Distinct from [`crate::dial`]'s `split_host_port`: a `Uri` arrives already split into host + port,
173/// and HTTP supplies a scheme-default port (which the string dialer deliberately does not).
174fn host_port(uri: &Uri) -> Result<(String, u16), Error> {
175    // Plaintext connector: only the cleartext HTTP scheme (or a scheme-less authority, treated as
176    // http) is dialable. Reject https/wss (would be cleartext-on-TLS-port) and anything unknown.
177    match uri.scheme_str() {
178        Some("http") | None => {}
179        _ => return Err(Error::Internal(InternalErrorKind::BadRequest)),
180    }
181    let host = uri
182        .host()
183        .ok_or(Error::Internal(InternalErrorKind::BadRequest))?;
184    // `Uri::host` returns an IPv6 literal WITH its brackets (`[::1]`); strip them so the host is a
185    // bare address/name for the dialer.
186    let host = host
187        .strip_prefix('[')
188        .and_then(|h| h.strip_suffix(']'))
189        .unwrap_or(host)
190        .to_string();
191    // Default the cleartext-HTTP port to 80 when unspecified (what Go's `http.Transport` computes
192    // before calling `DialContext`).
193    let port = uri.port_u16().unwrap_or(80);
194    Ok((host, port))
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn host_port_defaults_http_to_80() {
203        let (h, p) = host_port(&"http://peer/path".parse().unwrap()).unwrap();
204        assert_eq!((h.as_str(), p), ("peer", 80));
205    }
206
207    #[test]
208    fn host_port_rejects_https() {
209        // Plaintext connector: https must be rejected (not dialed cleartext onto 443), even with an
210        // explicit port, so it can't slip a plaintext dial onto a TLS port.
211        for uri in [
212            "https://peer.tailnet.ts.net/",
213            "https://peer:443/",
214            "https://peer:8443/",
215        ] {
216            assert!(
217                matches!(
218                    host_port(&uri.parse().unwrap()).unwrap_err(),
219                    Error::Internal(InternalErrorKind::BadRequest)
220                ),
221                "https must be rejected: {uri}"
222            );
223        }
224    }
225
226    #[test]
227    fn host_port_rejects_wss_even_with_explicit_port() {
228        // wss with an explicit port must NOT bypass scheme validation into a plaintext dial.
229        let err = host_port(&"wss://peer:443/".parse().unwrap()).unwrap_err();
230        assert!(matches!(
231            err,
232            Error::Internal(InternalErrorKind::BadRequest)
233        ));
234    }
235
236    #[test]
237    fn host_port_explicit_port_wins() {
238        let (h, p) = host_port(&"http://peer:8080/".parse().unwrap()).unwrap();
239        assert_eq!((h.as_str(), p), ("peer", 8080));
240    }
241
242    #[test]
243    fn host_port_ipv4_literal() {
244        let (h, p) = host_port(&"http://100.64.0.1:9000/".parse().unwrap()).unwrap();
245        assert_eq!((h.as_str(), p), ("100.64.0.1", 9000));
246    }
247
248    #[test]
249    fn host_port_strips_ipv6_brackets() {
250        // `http://[::1]:80/` — the dialer must see `::1`, not `[::1]` (it will then fail the v4-only
251        // resolve, but the bracket-stripping itself must be correct).
252        let (h, p) = host_port(&"http://[::1]:80/".parse().unwrap()).unwrap();
253        assert_eq!((h.as_str(), p), ("::1", 80));
254    }
255
256    #[test]
257    fn host_port_unknown_scheme_without_port_rejected() {
258        // A non-http(s) scheme with no explicit port can't be dialed without guessing.
259        let err = host_port(&"ftp://peer/".parse().unwrap()).unwrap_err();
260        assert!(matches!(
261            err,
262            Error::Internal(InternalErrorKind::BadRequest)
263        ));
264    }
265}