#[cfg(feature = "tls")]
use crate::http::response::Body;
use crate::server::Server;
use crate::server::conn::{NO_PEER_ADDR as ONION_PEER_ADDR, serve_connection};
use crate::server::http::hyper_handler;
use arti_client::config::{CfgPath, TorClientConfigBuilder};
use arti_client::{TorClient, TorClientConfig};
use futures_util::StreamExt as _;
#[cfg(feature = "tls")]
use hyper::{Request, Response};
use safelog::DisplayRedacted as _;
use std::path::PathBuf;
use std::sync::Arc;
#[cfg(feature = "tls")]
use tokio_rustls::TlsAcceptor;
use tor_cell::relaycell::msg::Connected;
use tor_config::ExplicitOrAuto;
use tor_guardmgr::VanguardMode;
use tor_hsservice::config::OnionServiceConfigBuilder;
use tor_hsservice::{HsNickname, StreamRequest};
use tor_proto::stream::IncomingStreamRequest;
use tor_rtcompat::Runtime;
const ONION_HTTP_PORT: u16 = 80;
#[cfg(feature = "tls")]
const ONION_HTTPS_PORT: u16 = 443;
#[derive(Clone)]
enum OnionTls {
None,
#[cfg(feature = "cert-gen")]
SelfSigned,
#[cfg(feature = "tls")]
Custom(Arc<rustls::ServerConfig>),
}
impl std::fmt::Debug for OnionTls {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => f.write_str("None"),
#[cfg(feature = "cert-gen")]
Self::SelfSigned => f.write_str("SelfSigned"),
#[cfg(feature = "tls")]
Self::Custom(_) => f.write_str("Custom(..)"),
}
}
}
type OnReadyHook = Box<dyn FnOnce(&str) + Send>;
pub struct OnionConfig {
nickname: String,
state_dir: Option<PathBuf>,
cache_dir: Option<PathBuf>,
tls: OnionTls,
redirect_http: bool,
vanguards: bool,
on_ready: Option<OnReadyHook>,
}
impl std::fmt::Debug for OnionConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OnionConfig")
.field("nickname", &self.nickname)
.field("state_dir", &self.state_dir)
.field("cache_dir", &self.cache_dir)
.field("tls", &self.tls)
.field("redirect_http", &self.redirect_http)
.field("vanguards", &self.vanguards)
.finish_non_exhaustive()
}
}
impl OnionConfig {
#[must_use]
pub fn new(nickname: impl Into<String>) -> Self {
Self {
nickname: nickname.into(),
state_dir: None,
cache_dir: None,
#[cfg(feature = "cert-gen")]
tls: OnionTls::SelfSigned,
#[cfg(not(feature = "cert-gen"))]
tls: OnionTls::None,
redirect_http: false,
vanguards: true,
on_ready: None,
}
}
#[must_use]
pub fn state_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.state_dir = Some(dir.into());
self
}
#[must_use]
pub fn cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.cache_dir = Some(dir.into());
self
}
#[cfg_attr(not(feature = "tls"), allow(clippy::missing_const_for_fn))]
#[must_use]
pub fn no_tls(mut self) -> Self {
self.tls = OnionTls::None;
self
}
#[cfg(feature = "cert-gen")]
#[must_use]
pub fn self_signed_tls(mut self) -> Self {
self.tls = OnionTls::SelfSigned;
self
}
#[cfg(feature = "tls")]
#[must_use]
pub fn tls_config(mut self, config: rustls::ServerConfig) -> Self {
self.tls = OnionTls::Custom(Arc::new(config));
self
}
#[must_use]
pub const fn redirect_http(mut self, enable: bool) -> Self {
self.redirect_http = enable;
self
}
#[must_use]
pub const fn vanguards(mut self, enabled: bool) -> Self {
self.vanguards = enabled;
self
}
#[must_use]
pub fn on_ready(mut self, f: impl FnOnce(&str) + Send + 'static) -> Self {
self.on_ready = Some(Box::new(f));
self
}
#[must_use]
pub fn nickname(&self) -> &str {
&self.nickname
}
#[must_use]
pub const fn tls_enabled(&self) -> bool {
!matches!(self.tls, OnionTls::None)
}
#[must_use]
pub const fn redirect_http_enabled(&self) -> bool {
self.redirect_http
}
#[must_use]
pub const fn vanguards_enabled(&self) -> bool {
self.vanguards
}
}
impl<S> Server<S>
where
S: Clone + Send + Sync + 'static,
{
pub async fn serve_tor(
self,
nickname: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(feature = "tls")]
self.effective_tls_policy().install_as_process_default();
let client = TorClient::create_bootstrapped(TorClientConfig::default()).await?;
self.serve_tor_with_client(&client, nickname).await
}
pub async fn serve_tor_with_client<R>(
self,
client: &TorClient<R>,
nickname: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
R: Runtime,
{
crate::server::enforce_fips_compliance()?;
let hs_nickname = parse_nickname(nickname)?;
let svc_cfg = OnionServiceConfigBuilder::default()
.nickname(hs_nickname)
.build()?;
let Some((service, request_stream)) = client.launch_onion_service(svc_cfg)? else {
return Err("onion services are disabled in this TorClient's config".into());
};
if let Some(addr) = service.onion_address() {
tracing::info!(
"[tor] onion service published at {}",
addr.display_unredacted()
);
}
wait_until_reachable(&service).await;
let state = Arc::new(self);
let stream_requests = tor_hsservice::handle_rend_requests(request_stream);
tokio::pin!(stream_requests);
while let Some(stream_request) = stream_requests.next().await {
let state = state.clone();
drop(tokio::spawn(async move {
if let Err(e) = handle_plaintext_only_stream(state, stream_request).await {
tracing::debug!("[tor] connection error: {e}");
}
}));
}
drop(service);
Ok(())
}
pub async fn serve_onion(
self,
config: OnionConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut builder = TorClientConfigBuilder::default();
if let Some(state_dir) = &config.state_dir {
builder
.storage()
.state_dir(CfgPath::new_literal(state_dir.clone()));
}
if let Some(cache_dir) = &config.cache_dir {
builder
.storage()
.cache_dir(CfgPath::new_literal(cache_dir.clone()));
}
if !config.vanguards {
builder
.vanguards()
.mode(ExplicitOrAuto::Explicit(VanguardMode::Disabled));
}
#[cfg(feature = "tls")]
self.effective_tls_policy().install_as_process_default();
let client_config = builder.build()?;
let client = TorClient::create_bootstrapped(client_config).await?;
self.serve_onion_with_client(&client, config).await
}
pub async fn serve_onion_with_client<R>(
self,
client: &TorClient<R>,
config: OnionConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
R: Runtime,
{
crate::server::enforce_fips_compliance()?;
let hs_nickname = parse_nickname(&config.nickname)?;
let svc_cfg = OnionServiceConfigBuilder::default()
.nickname(hs_nickname)
.build()?;
let Some((service, request_stream)) = client.launch_onion_service(svc_cfg)? else {
return Err("onion services are disabled in this TorClient's config".into());
};
let onion_host = service
.onion_address()
.map(|addr| addr.display_unredacted().to_string());
if let Some(host) = &onion_host {
tracing::info!("[tor] onion service published at {host}");
}
tracing::info!(
vanguards = config.vanguards,
tls = config.tls_enabled(),
"[tor] hardening posture: vanguards={}, tls={}",
if config.vanguards { "on" } else { "off" },
if config.tls_enabled() { "on" } else { "off" },
);
wait_until_reachable(&service).await;
if let Some(on_ready) = config.on_ready
&& let Some(host) = &onion_host
{
on_ready(host);
}
#[cfg(feature = "tls")]
{
let tls_acceptor = match &config.tls {
OnionTls::None => None,
#[cfg(feature = "cert-gen")]
OnionTls::SelfSigned => {
let domain = onion_host
.clone()
.unwrap_or_else(|| "onion-service.invalid".to_string());
let cert = crate::tls::generate_self_signed_cert(vec![domain])?;
let server_config = self.effective_tls_policy().server_config_from_pem(
cert.cert_pem.as_bytes(),
cert.key_pem.as_bytes(),
)?;
Some(TlsAcceptor::from(Arc::new(server_config)))
}
OnionTls::Custom(server_config) => Some(TlsAcceptor::from(server_config.clone())),
};
let onion_host: Arc<str> = Arc::from(onion_host.unwrap_or_default());
let redirect_http = config.redirect_http;
let state = Arc::new(self);
let stream_requests = tor_hsservice::handle_rend_requests(request_stream);
tokio::pin!(stream_requests);
while let Some(stream_request) = stream_requests.next().await {
let state = state.clone();
let tls_acceptor = tls_acceptor.clone();
let onion_host = onion_host.clone();
drop(tokio::spawn(async move {
if let Err(e) = handle_onion_stream(
state,
stream_request,
tls_acceptor,
redirect_http,
onion_host,
)
.await
{
tracing::debug!("[tor] connection error: {e}");
}
}));
}
drop(service);
Ok(())
}
#[cfg(not(feature = "tls"))]
{
let state = Arc::new(self);
let stream_requests = tor_hsservice::handle_rend_requests(request_stream);
tokio::pin!(stream_requests);
while let Some(stream_request) = stream_requests.next().await {
let state = state.clone();
drop(tokio::spawn(async move {
if let Err(e) = handle_plaintext_only_stream(state, stream_request).await {
tracing::debug!("[tor] connection error: {e}");
}
}));
}
drop(service);
Ok(())
}
}
}
fn parse_nickname(nickname: &str) -> Result<HsNickname, Box<dyn std::error::Error + Send + Sync>> {
nickname
.parse()
.map_err(|e| format!("invalid onion service nickname {nickname:?}: {e}").into())
}
async fn wait_until_reachable(service: &tor_hsservice::RunningOnionService) {
let mut status_events = service.status_events();
let mut last_state = None;
loop {
let Some(status) = status_events.next().await else {
tracing::warn!(
"[tor] onion service status stream ended before reporting full reachability"
);
return;
};
let state = status.state();
if last_state != Some(state) {
tracing::info!("[tor] onion service status: {state:?}");
last_state = Some(state);
}
if state.is_fully_reachable() {
break;
}
}
tracing::info!("[tor] onion service is fully reachable");
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnionAction {
Reject,
ServePlaintext,
#[cfg(feature = "tls")]
Redirect,
#[cfg(feature = "tls")]
ServeTls,
}
#[cfg_attr(not(feature = "tls"), allow(unused_variables))]
const fn route_onion_request(port: u16, tls_enabled: bool, redirect_http: bool) -> OnionAction {
match port {
#[cfg(feature = "tls")]
ONION_HTTP_PORT if tls_enabled && redirect_http => OnionAction::Redirect,
ONION_HTTP_PORT => OnionAction::ServePlaintext,
#[cfg(feature = "tls")]
ONION_HTTPS_PORT if tls_enabled => OnionAction::ServeTls,
_ => OnionAction::Reject,
}
}
#[cfg(feature = "tls")]
fn redirect_location(onion_host: &str, path_and_query: &str) -> String {
format!("https://{onion_host}{path_and_query}")
}
async fn handle_plaintext_only_stream<S>(
state: Arc<Server<S>>,
stream_request: StreamRequest,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
S: Clone + Send + Sync + 'static,
{
let IncomingStreamRequest::Begin(begin) = stream_request.request() else {
stream_request.shutdown_circuit()?;
return Ok(());
};
if route_onion_request(begin.port(), false, false) != OnionAction::ServePlaintext {
stream_request.shutdown_circuit()?;
return Ok(());
}
let onion_stream = stream_request.accept(Connected::new_empty()).await?;
let svc =
hyper::service::service_fn(move |req| hyper_handler(state.clone(), req, ONION_PEER_ADDR));
serve_connection(onion_stream, svc).await
}
#[cfg(feature = "tls")]
async fn handle_onion_stream<S>(
state: Arc<Server<S>>,
stream_request: StreamRequest,
tls_acceptor: Option<TlsAcceptor>,
redirect_http: bool,
onion_host: Arc<str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
S: Clone + Send + Sync + 'static,
{
let IncomingStreamRequest::Begin(begin) = stream_request.request() else {
stream_request.shutdown_circuit()?;
return Ok(());
};
match route_onion_request(begin.port(), tls_acceptor.is_some(), redirect_http) {
OnionAction::Reject => {
stream_request.shutdown_circuit()?;
Ok(())
}
OnionAction::ServePlaintext => {
let onion_stream = stream_request.accept(Connected::new_empty()).await?;
let svc = hyper::service::service_fn(move |req| {
hyper_handler(state.clone(), req, ONION_PEER_ADDR)
});
serve_connection(onion_stream, svc).await
}
OnionAction::Redirect => {
let onion_stream = stream_request.accept(Connected::new_empty()).await?;
let svc = hyper::service::service_fn(move |req: Request<hyper::body::Incoming>| {
let onion_host = onion_host.clone();
async move { Ok::<_, std::io::Error>(redirect_response(&req, &onion_host)) }
});
serve_connection(onion_stream, svc).await
}
OnionAction::ServeTls => {
let Some(acceptor) = tls_acceptor else {
stream_request.shutdown_circuit()?;
return Ok(());
};
let onion_stream = stream_request.accept(Connected::new_empty()).await?;
let tls_stream = tokio::time::timeout(
crate::server::TLS_HANDSHAKE_TIMEOUT,
acceptor.accept(onion_stream),
)
.await
.map_err(|_| "TLS handshake timed out")??;
let svc = hyper::service::service_fn(move |req| {
hyper_handler(state.clone(), req, ONION_PEER_ADDR)
});
serve_connection(tls_stream, svc).await
}
}
}
#[cfg(feature = "tls")]
fn redirect_response(req: &Request<hyper::body::Incoming>, onion_host: &str) -> Response<Body> {
let path_and_query = req
.uri()
.path_and_query()
.map_or("/", hyper::http::uri::PathAndQuery::as_str);
let location = redirect_location(onion_host, path_and_query);
Response::builder()
.status(308) .header("location", location)
.body(Body::empty())
.unwrap_or_else(|_| Response::new(Body::empty()))
}
#[cfg(test)]
mod tests {
#[cfg(feature = "tls")]
use super::redirect_location;
use super::{OnionAction, OnionConfig, parse_nickname, route_onion_request};
#[test]
fn plaintext_serves_port_80_and_rejects_everything_else() {
assert_eq!(
route_onion_request(80, false, false),
OnionAction::ServePlaintext
);
assert_eq!(route_onion_request(443, false, false), OnionAction::Reject);
assert_eq!(route_onion_request(22, false, false), OnionAction::Reject);
}
#[cfg(feature = "tls")]
#[test]
fn tls_dual_stack_serves_both_ports_without_redirect() {
assert_eq!(
route_onion_request(80, true, false),
OnionAction::ServePlaintext
);
assert_eq!(route_onion_request(443, true, false), OnionAction::ServeTls);
}
#[cfg(feature = "tls")]
#[test]
fn tls_with_redirect_forces_port_80_to_redirect() {
assert_eq!(route_onion_request(80, true, true), OnionAction::Redirect);
assert_eq!(route_onion_request(443, true, true), OnionAction::ServeTls);
}
#[test]
fn redirect_only_applies_when_tls_is_enabled() {
assert_eq!(
route_onion_request(80, false, true),
OnionAction::ServePlaintext
);
}
#[test]
fn unknown_ports_are_always_rejected() {
assert_eq!(route_onion_request(8080, false, false), OnionAction::Reject);
assert_eq!(route_onion_request(8080, true, true), OnionAction::Reject);
}
#[cfg(feature = "tls")]
#[test]
fn redirect_location_builds_the_https_equivalent_url() {
assert_eq!(
redirect_location("abcd1234.onion", "/foo?x=1"),
"https://abcd1234.onion/foo?x=1"
);
assert_eq!(
redirect_location("abcd1234.onion", "/"),
"https://abcd1234.onion/"
);
}
#[cfg(feature = "cert-gen")]
#[test]
fn onion_config_defaults_to_self_signed_tls_when_cert_gen_is_enabled() {
let config = OnionConfig::new("test-nickname");
assert_eq!(config.nickname, "test-nickname");
assert!(config.vanguards);
assert!(!config.redirect_http);
assert!(matches!(config.tls, super::OnionTls::SelfSigned));
}
#[cfg(not(feature = "cert-gen"))]
#[test]
fn onion_config_defaults_to_no_tls_without_cert_gen() {
let config = OnionConfig::new("test-nickname");
assert_eq!(config.nickname, "test-nickname");
assert!(config.vanguards);
assert!(!config.redirect_http);
assert!(matches!(config.tls, super::OnionTls::None));
}
#[test]
fn onion_config_builder_methods_are_chainable() {
let config = OnionConfig::new("nick")
.state_dir("/tmp/state")
.cache_dir("/tmp/cache")
.redirect_http(true)
.vanguards(false)
.no_tls();
assert_eq!(
config.state_dir.as_deref(),
Some(std::path::Path::new("/tmp/state"))
);
assert_eq!(
config.cache_dir.as_deref(),
Some(std::path::Path::new("/tmp/cache"))
);
assert!(config.redirect_http);
assert!(!config.vanguards);
assert!(matches!(config.tls, super::OnionTls::None));
}
#[test]
fn parse_nickname_accepts_a_valid_name() {
assert!(parse_nickname("valid-nickname").is_ok());
}
#[test]
fn parse_nickname_rejects_an_invalid_name_and_echoes_it_back() {
let err = parse_nickname("not a valid nickname!!").unwrap_err();
assert!(err.to_string().contains("not a valid nickname!!"));
}
#[test]
fn onion_config_debug_does_not_panic() {
let debug = format!("{:?}", OnionConfig::new("nick"));
assert!(debug.contains("OnionConfig"));
assert!(debug.contains("nick"));
}
#[cfg(all(feature = "tls", feature = "cert-gen"))]
#[test]
fn tls_config_switches_to_a_custom_server_config() {
let policy = crate::tls::TlsPolicy::hardened();
let cert = crate::tls::generate_self_signed_cert(vec!["nick.onion".to_string()])
.expect("generate self-signed cert");
let server_config = policy
.server_config_from_pem(cert.cert_pem.as_bytes(), cert.key_pem.as_bytes())
.expect("build server config");
let config = OnionConfig::new("nick").tls_config(server_config);
assert!(matches!(config.tls, super::OnionTls::Custom(_)));
assert!(format!("{config:?}").contains("nickname"));
}
#[test]
fn on_ready_stores_the_callback() {
let config = OnionConfig::new("nick").on_ready(|_addr| {});
assert!(config.on_ready.is_some());
}
#[cfg(all(feature = "tls", feature = "http1"))]
#[tokio::test]
async fn redirect_response_builds_a_308_to_the_https_equivalent() {
use hyper::Request;
use hyper::service::service_fn;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (mut client_io, server_io) = tokio::io::duplex(8 * 1024);
let onion_host: std::sync::Arc<str> = std::sync::Arc::from("abcd1234.onion");
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let onion_host = onion_host.clone();
async move { Ok::<_, std::io::Error>(super::redirect_response(&req, &onion_host)) }
});
let server = tokio::spawn(async move {
hyper::server::conn::http1::Builder::new()
.serve_connection(hyper_util::rt::TokioIo::new(server_io), svc)
.await
});
client_io
.write_all(b"GET /foo?x=1 HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n")
.await
.expect("write request");
let mut buf = Vec::new();
client_io
.read_to_end(&mut buf)
.await
.expect("read response");
let response = String::from_utf8_lossy(&buf);
assert!(response.contains("308"), "unexpected response: {response}");
assert!(
response.contains("location: https://abcd1234.onion/foo?x=1"),
"unexpected response: {response}"
);
server
.await
.expect("server task join")
.expect("serve_connection ok");
}
}