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