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}