use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use arti_client::{StreamPrefs, TorClient as ArtiClient};
use http::Uri;
use hyper::rt::{Read, ReadBufCursor, Write};
use hyper_util::client::legacy::connect::{Connected, Connection};
use hyper_util::rt::TokioIo;
use tor_rtcompat::PreferredRuntime;
use tracing::debug;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::isolation::IsolationToken;
use crate::stream::TorStream;
use crate::tls::TlsConnector;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Target {
pub host: String,
pub port: u16,
pub tls: bool,
}
impl Target {
pub(crate) fn from_uri(uri: &Uri) -> Result<Self> {
let scheme = uri
.scheme_str()
.ok_or_else(|| Error::invalid_url("URL has no scheme; expected http:// or https://"))?;
let tls = match scheme {
"http" => false,
"https" => true,
other => {
return Err(Error::invalid_url(format!(
"unsupported scheme `{other}`; expected http:// or https://"
)));
}
};
let host = uri
.host()
.ok_or_else(|| Error::invalid_url("URL has no host"))?;
let host = host
.trim_start_matches('[')
.trim_end_matches(']')
.to_owned();
if host.is_empty() {
return Err(Error::invalid_url("URL has an empty host"));
}
let port = uri.port_u16().unwrap_or(if tls { 443 } else { 80 });
let target = Self { host, port, tls };
target.check_onion_address()?;
Ok(target)
}
fn check_onion_address(&self) -> Result<()> {
if !self.is_onion() {
return Ok(());
}
let label = self
.host
.rsplit_once('.')
.map(|(label, _)| label)
.unwrap_or(&self.host);
let label = label.rsplit('.').next().unwrap_or(label);
if label.len() == 56
&& label.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)
})
{
return Ok(());
}
if label.len() == 16 {
return Err(Error::invalid_url(
"this is a version 2 onion address; v2 onion services were \
retired from the Tor network in 2021 and cannot be reached. \
The service may publish a 56-character v3 address instead",
));
}
Err(Error::invalid_url(format!(
"`{label}.onion` is not a valid onion address: expected 56 \
characters of base32, found {}",
label.len()
)))
}
pub(crate) fn is_onion(&self) -> bool {
self.host
.rsplit('.')
.next()
.is_some_and(|tld| tld.eq_ignore_ascii_case("onion"))
}
}
#[derive(Clone)]
pub(crate) struct TorConnector {
tor: Arc<ArtiClient<PreferredRuntime>>,
tls: Option<TlsConnector>,
config: Arc<Config>,
isolation: Option<IsolationToken>,
}
impl TorConnector {
pub(crate) fn new(
tor: Arc<ArtiClient<PreferredRuntime>>,
tls: Option<TlsConnector>,
config: Arc<Config>,
isolation: Option<IsolationToken>,
) -> Self {
Self {
tor,
tls,
config,
isolation,
}
}
async fn dial(self, uri: Uri) -> Result<TorIo> {
let target = Target::from_uri(&uri)?;
let mut prefs = StreamPrefs::new();
if let Some(token) = self.isolation {
prefs.set_isolation(token.inner());
}
debug!(
port = target.port,
tls = target.tls,
onion = target.is_onion(),
"opening Tor stream"
);
let connect = self
.tor
.connect_with_prefs((target.host.as_str(), target.port), &prefs);
let data_stream = tokio::time::timeout(self.config.connect_timeout, connect)
.await
.map_err(|_| Error::timeout("Tor connect", self.config.connect_timeout))?
.map_err(|e| Error::connect(&target.host, target.port, e))?;
let stream = if target.tls {
let tls = self.tls.as_ref().ok_or_else(|| {
Error::tls("https:// requires a TLS backend; enable the `rustls` feature")
})?;
let handshake = tls.connect(data_stream, &target.host);
tokio::time::timeout(self.config.connect_timeout, handshake)
.await
.map_err(|_| Error::timeout("TLS handshake", self.config.connect_timeout))?
} else {
Ok(TorStream::plain(data_stream))
}?;
let negotiated_h2 = stream.alpn_is_h2();
Ok(TorIo {
io: TokioIo::new(stream),
negotiated_h2,
})
}
}
impl tower_service::Service<Uri> for TorConnector {
type Response = TorIo;
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<TorIo>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<()>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, uri: Uri) -> Self::Future {
let this = self.clone();
Box::pin(this.dial(uri))
}
}
pub(crate) struct TorIo {
io: TokioIo<TorStream>,
negotiated_h2: bool,
}
impl Connection for TorIo {
fn connected(&self) -> Connected {
let connected = Connected::new();
if self.negotiated_h2 {
connected.negotiated_h2()
} else {
connected
}
}
}
impl Read for TorIo {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: ReadBufCursor<'_>,
) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.io).poll_read(cx, buf)
}
}
impl Write for TorIo {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
Pin::new(&mut self.io).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.io).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.io).poll_shutdown(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn uri(s: &str) -> Uri {
s.parse().expect("test URI parses")
}
const V3: &str = "duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad";
#[test]
fn parses_default_ports() {
let http = Target::from_uri(&uri(&format!("http://{V3}.onion/x"))).expect("valid");
assert_eq!(http.port, 80);
assert!(!http.tls);
let https = Target::from_uri(&uri("https://example.com/x")).expect("valid");
assert_eq!(https.port, 443);
assert!(https.tls);
}
#[test]
fn honours_explicit_ports() {
let t = Target::from_uri(&uri(&format!("http://{V3}.onion:8080/"))).expect("valid");
assert_eq!(t.port, 8080);
}
#[test]
fn strips_ipv6_brackets_for_arti() {
let t = Target::from_uri(&uri("http://[::1]:8080/")).expect("valid");
assert_eq!(t.host, "::1");
}
#[test]
fn rejects_schemes_tor_cannot_carry() {
assert!(Target::from_uri(&uri("ftp://example.com/")).is_err());
assert!(Target::from_uri(&uri(&format!("ws://{V3}.onion/socket"))).is_err());
}
#[test]
fn rejects_urls_without_a_scheme_or_host() {
assert!(Target::from_uri(&uri("/just/a/path")).is_err());
assert!(Target::from_uri(&uri("example.onion")).is_err());
}
#[test]
fn detects_onion_addresses() {
assert!(
Target::from_uri(&uri(&format!("http://{V3}.onion/")))
.expect("valid")
.is_onion()
);
assert!(
Target::from_uri(&uri(&format!("http://{}.ONION/", V3.to_uppercase())))
.expect("valid")
.is_onion()
);
assert!(
!Target::from_uri(&uri("http://onion.example.com/"))
.expect("valid")
.is_onion()
);
assert!(
!Target::from_uri(&uri("http://notonion/"))
.expect("valid")
.is_onion()
);
}
#[test]
fn a_v3_onion_address_with_a_subdomain_is_accepted() {
assert!(Target::from_uri(&uri(&format!("http://www.{V3}.onion/"))).is_ok());
}
#[test]
fn version_2_onion_addresses_are_refused_by_name() {
let err = Target::from_uri(&uri("http://expyuzz4wqqyqhjn.onion/")).expect_err("refused");
assert!(err.to_string().contains("version 2"), "unhelpful: {err}");
}
#[test]
fn a_malformed_onion_address_is_refused_before_a_circuit_is_built() {
for bad in ["http://nonsense.onion/", "http://TOO-SHORT.onion/"] {
assert!(Target::from_uri(&uri(bad)).is_err(), "should refuse {bad}");
}
}
}