Skip to main content

tachyon_web/server/
tor.rs

1//! Native Tor `.onion` hidden-service support (see the `tor` feature).
2//!
3//! Wraps [`arti-client`](https://docs.rs/arti-client) and
4//! [`tor-hsservice`](https://docs.rs/tor-hsservice) so a Tachyon [`Server`] can be published
5//! directly as a v3 Tor hidden service — no external `tor` daemon, no reverse proxy — with the
6//! same `serve_*` ergonomics as [`Server::serve_https`](crate::server::Server::serve_https).
7//!
8//! Two entry points are available:
9//!
10//! - [`Server::serve_tor`] / [`Server::serve_tor_with_client`] — the simplest possible onion
11//!   service: plaintext HTTP on virtual port 80, nothing else configurable. Always available
12//!   under the `tor` feature alone — no TLS stack required.
13//! - [`Server::serve_onion`] / [`Server::serve_onion_with_client`], driven by an [`OnionConfig`] —
14//!   adds custom state/cache directories, a vanguards toggle, and an `on_ready` hook for reading
15//!   the published `.onion` address, all available under `tor` alone. Native HTTPS (virtual port
16//!   443, terminated with the *same* `rustls::ServerConfig` type used by
17//!   [`Server::serve_https_config`](crate::server::Server::serve_https_config), so a
18//!   FIPS-constrained crypto provider or custom cert chain can be shared between the clearnet and
19//!   onion listeners) is additionally available when the `tls` feature is enabled alongside
20//!   `tor` — the self-signed-certificate convenience ([`OnionConfig::self_signed_tls`], the
21//!   default whenever it's available) further requires `cert-gen`.
22//!
23//! # Example
24//!
25//! ```rust,no_run
26//! use tachyon_web::{Router, Server, get};
27//!
28//! async fn hello() -> &'static str { "Hello from an onion service!" }
29//!
30//! #[tokio::main]
31//! async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
32//!     let app = Router::new().route("/", get(hello));
33//!
34//!     // Publishes the service, prints its `.onion` address once reachable, then
35//!     // blocks serving requests arriving over Tor rendezvous circuits.
36//!     Server::new(app).serve_tor("my-hidden-service").await?;
37//!     Ok(())
38//! }
39//! ```
40//!
41//! # HTTPS, custom directories, and vanguards
42//!
43//! HTTPS support (this example) requires enabling `tls` (and `cert-gen` for the self-signed
44//! certificate shown here) alongside `tor` — see the [module docs](self) above.
45//!
46//! ```rust,no_run
47//! use tachyon_web::{Router, Server, get};
48//! use tachyon_web::server::tor::OnionConfig;
49//!
50//! async fn hello() -> &'static str { "Hello, secure onion world!" }
51//!
52//! #[tokio::main]
53//! async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
54//!     let app = Router::new().route("/", get(hello));
55//!
56//!     let config = OnionConfig::new("my-hidden-service")
57//!         .state_dir("/var/lib/tachyon/tor/state")
58//!         .cache_dir("/var/lib/tachyon/tor/cache")
59//!         // Default (with `cert-gen` enabled) is a self-signed cert for the onion address;
60//!         // pass your own instead:
61//!         // .tls_config(my_rustls_server_config)
62//!         .redirect_http(false) // dual-stack by default: plaintext AND TLS both work
63//!         .on_ready(|addr| tracing::info!("reachable at https://{addr}"));
64//!
65//!     Server::new(app).serve_onion(config).await?;
66//!     Ok(())
67//! }
68//! ```
69//!
70//! Persistent onion service keys and Arti's own state/cache are stored under Arti's default
71//! state directory unless overridden via [`OnionConfig::state_dir`]/[`OnionConfig::cache_dir`];
72//! reusing the same `nickname` (and directories) across restarts keeps the same `.onion` address.
73//! Pass an already-bootstrapped client (e.g. one configured with custom bridges) via
74//! [`Server::serve_onion_with_client`]/[`Server::serve_tor_with_client`] instead of bootstrapping
75//! a fresh one per service.
76//!
77//! # Vanguards
78//!
79//! [Vanguards](https://blog.torproject.org/vanguards-onion-services/) harden onion services
80//! against guard-discovery attacks and are enabled by default (arti's own "lite" mode).
81//! [`OnionConfig::vanguards`] overrides this at runtime, e.g. `OnionConfig::new(nickname).vanguards(false)`.
82
83#[cfg(feature = "tls")]
84use crate::http::response::Body;
85use crate::server::Server;
86use crate::server::conn::{NO_PEER_ADDR as ONION_PEER_ADDR, serve_connection};
87use crate::server::http::hyper_handler;
88use arti_client::config::{CfgPath, TorClientConfigBuilder};
89use arti_client::{TorClient, TorClientConfig};
90use futures_util::StreamExt as _;
91#[cfg(feature = "tls")]
92use hyper::{Request, Response};
93use safelog::DisplayRedacted as _;
94use std::path::PathBuf;
95use std::sync::Arc;
96#[cfg(feature = "tls")]
97use tokio_rustls::TlsAcceptor;
98use tor_cell::relaycell::msg::Connected;
99use tor_config::ExplicitOrAuto;
100use tor_guardmgr::VanguardMode;
101use tor_hsservice::config::OnionServiceConfigBuilder;
102use tor_hsservice::{HsNickname, StreamRequest};
103use tor_proto::stream::IncomingStreamRequest;
104use tor_rtcompat::Runtime;
105
106/// The virtual port plaintext HTTP clients connect to — mirrors how a clearnet browser assumes
107/// port 80 for a bare `http://` URL, regardless of what port the service actually listens on
108/// inside the Tor network.
109const ONION_HTTP_PORT: u16 = 80;
110/// The virtual port HTTPS clients connect to, matching the clearnet `https://` convention.
111/// Only meaningful when the `tls` feature is enabled.
112#[cfg(feature = "tls")]
113const ONION_HTTPS_PORT: u16 = 443;
114
115/// How (or whether) an onion service published via [`OnionConfig`] terminates TLS.
116#[derive(Clone)]
117enum OnionTls {
118    /// Plaintext only — no virtual port 443 listener.
119    None,
120    /// TLS on virtual port 443 using an ephemeral self-signed certificate, generated for the
121    /// onion address once it's known. This is the default whenever `cert-gen` is enabled.
122    /// Requires the `cert-gen` feature.
123    #[cfg(feature = "cert-gen")]
124    SelfSigned,
125    /// TLS on virtual port 443 using a caller-supplied config — e.g. the same
126    /// `rustls::ServerConfig` used for a clearnet [`Server::serve_https_config`] listener, so a
127    /// custom crypto provider or FIPS constraints carry over unchanged. Requires the `tls`
128    /// feature.
129    #[cfg(feature = "tls")]
130    Custom(Arc<rustls::ServerConfig>),
131}
132
133impl std::fmt::Debug for OnionTls {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        match self {
136            Self::None => f.write_str("None"),
137            #[cfg(feature = "cert-gen")]
138            Self::SelfSigned => f.write_str("SelfSigned"),
139            #[cfg(feature = "tls")]
140            Self::Custom(_) => f.write_str("Custom(..)"),
141        }
142    }
143}
144
145/// Callback invoked with the published `.onion` address — see [`OnionConfig::on_ready`].
146type OnReadyHook = Box<dyn FnOnce(&str) + Send>;
147
148/// Configuration for publishing a Tor `.onion` hidden service via
149/// [`Server::serve_onion`]/[`Server::serve_onion_with_client`].
150///
151/// See the [module docs](self) for a full example.
152pub struct OnionConfig {
153    nickname: String,
154    state_dir: Option<PathBuf>,
155    cache_dir: Option<PathBuf>,
156    tls: OnionTls,
157    redirect_http: bool,
158    vanguards: bool,
159    on_ready: Option<OnReadyHook>,
160}
161
162impl std::fmt::Debug for OnionConfig {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.debug_struct("OnionConfig")
165            .field("nickname", &self.nickname)
166            .field("state_dir", &self.state_dir)
167            .field("cache_dir", &self.cache_dir)
168            .field("tls", &self.tls)
169            .field("redirect_http", &self.redirect_http)
170            .field("vanguards", &self.vanguards)
171            .finish_non_exhaustive()
172    }
173}
174
175impl OnionConfig {
176    /// Creates a new configuration for a service published under `nickname`.
177    ///
178    /// Defaults: TLS enabled with a self-signed certificate if the `cert-gen` feature is
179    /// enabled (virtual port 443, alongside plaintext on virtual port 80 — see
180    /// [`redirect_http`](Self::redirect_http)); plaintext-only otherwise (or always, if the
181    /// `tls` feature isn't enabled at all — see the [module docs](self)). No forced
182    /// HTTP→HTTPS redirect, and vanguards on — see [`vanguards`](Self::vanguards) to change it.
183    /// `nickname` is validated (as an [`HsNickname`]) when the service is actually launched.
184    #[must_use]
185    pub fn new(nickname: impl Into<String>) -> Self {
186        Self {
187            nickname: nickname.into(),
188            state_dir: None,
189            cache_dir: None,
190            #[cfg(feature = "cert-gen")]
191            tls: OnionTls::SelfSigned,
192            #[cfg(not(feature = "cert-gen"))]
193            tls: OnionTls::None,
194            redirect_http: false,
195            vanguards: true,
196            on_ready: None,
197        }
198    }
199
200    /// Overrides the directory Arti uses for persistent state — including this service's onion
201    /// keys. Reusing the same directory (and `nickname`) across restarts keeps the same `.onion`
202    /// address. Defaults to Arti's own platform-specific state directory.
203    #[must_use]
204    pub fn state_dir(mut self, dir: impl Into<PathBuf>) -> Self {
205        self.state_dir = Some(dir.into());
206        self
207    }
208
209    /// Overrides the directory Arti uses for cached network directory information. Defaults to
210    /// Arti's own platform-specific cache directory.
211    #[must_use]
212    pub fn cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
213        self.cache_dir = Some(dir.into());
214        self
215    }
216
217    /// Disables HTTPS entirely — only plaintext HTTP on virtual port 80 is served, matching
218    /// [`Server::serve_tor`].
219    // Only `const`-eligible when neither `Custom`/`SelfSigned` variant exists (their non-trivial
220    // `Drop` glue — an `Arc<rustls::ServerConfig>` — can't run in a `const fn`), i.e. only
221    // without `tls`/`cert-gen` — not worth splitting this method's signature across features
222    // for.
223    #[cfg_attr(not(feature = "tls"), allow(clippy::missing_const_for_fn))]
224    #[must_use]
225    pub fn no_tls(mut self) -> Self {
226        self.tls = OnionTls::None;
227        self
228    }
229
230    /// Re-enables HTTPS with a freshly generated self-signed certificate (the default when this
231    /// feature is available), after a prior [`no_tls`](Self::no_tls) or
232    /// [`tls_config`](Self::tls_config) call. Requires the `cert-gen` feature.
233    #[cfg(feature = "cert-gen")]
234    #[must_use]
235    pub fn self_signed_tls(mut self) -> Self {
236        self.tls = OnionTls::SelfSigned;
237        self
238    }
239
240    /// Enables HTTPS using a caller-supplied `rustls::ServerConfig` instead of the default
241    /// self-signed certificate — for example, the exact same config passed to
242    /// [`Server::serve_https_config`](crate::server::Server::serve_https_config) for the clearnet
243    /// listener, so a custom crypto provider, FIPS constraints, or a real cert chain carry over
244    /// unchanged. Requires the `tls` feature.
245    #[cfg(feature = "tls")]
246    #[must_use]
247    pub fn tls_config(mut self, config: rustls::ServerConfig) -> Self {
248        self.tls = OnionTls::Custom(Arc::new(config));
249        self
250    }
251
252    /// Controls what happens to plaintext HTTP (virtual port 80) requests when TLS is enabled.
253    ///
254    /// `false` (the default): plaintext and TLS are both served — a dual-stack onion service,
255    /// same as browsing a clearnet site over either `http://` or `https://`. `true`: port 80
256    /// instead issues a `308 Permanent Redirect` to the equivalent `https://` URL, forcing all
257    /// traffic onto TLS. Has no effect when TLS is disabled ([`no_tls`](Self::no_tls)) or the
258    /// `tls` feature isn't enabled.
259    #[must_use]
260    pub const fn redirect_http(mut self, enable: bool) -> Self {
261        self.redirect_http = enable;
262        self
263    }
264
265    /// Controls whether [vanguards](https://blog.torproject.org/vanguards-onion-services/) are
266    /// used for this service. Defaults to `true`.
267    #[must_use]
268    pub const fn vanguards(mut self, enabled: bool) -> Self {
269        self.vanguards = enabled;
270        self
271    }
272
273    /// Registers a callback invoked exactly once — with the published `.onion` address (no
274    /// scheme, e.g. `"abcd...xyz.onion"`) — as soon as the service is fully reachable, just
275    /// before requests start being served. This is the only way to observe the address
276    /// programmatically, since [`serve_onion`](Server::serve_onion) blocks for the lifetime of
277    /// the service; the address is also always logged via `tracing` at `info` level.
278    #[must_use]
279    pub fn on_ready(mut self, f: impl FnOnce(&str) + Send + 'static) -> Self {
280        self.on_ready = Some(Box::new(f));
281        self
282    }
283
284    /// The nickname this service will be published under.
285    #[must_use]
286    pub fn nickname(&self) -> &str {
287        &self.nickname
288    }
289
290    /// Whether HTTPS is enabled (via the default self-signed cert or a custom
291    /// [`tls_config`](Self::tls_config)) — `false` after [`no_tls`](Self::no_tls), and always
292    /// `false` when the `tls` feature isn't enabled.
293    #[must_use]
294    pub const fn tls_enabled(&self) -> bool {
295        !matches!(self.tls, OnionTls::None)
296    }
297
298    /// Whether plaintext HTTP is forced to redirect to HTTPS — see
299    /// [`redirect_http`](Self::redirect_http).
300    #[must_use]
301    pub const fn redirect_http_enabled(&self) -> bool {
302        self.redirect_http
303    }
304
305    /// Whether vanguards will be requested for this service — see
306    /// [`vanguards`](Self::vanguards).
307    #[must_use]
308    pub const fn vanguards_enabled(&self) -> bool {
309        self.vanguards
310    }
311}
312
313impl<S> Server<S>
314where
315    S: Clone + Send + Sync + 'static,
316{
317    /// Publishes this router as a Tor `.onion` hidden service and serves requests arriving
318    /// over it, blocking until the service stops.
319    ///
320    /// Bootstraps a fresh [`TorClient`] with [`TorClientConfig::default`] — this alone can
321    /// take from several seconds up to a minute or more, since it involves connecting to and
322    /// syncing with the live Tor network — then behaves like
323    /// [`serve_tor_with_client`](Server::serve_tor_with_client). Reuse a [`TorClient`] across
324    /// calls (via `serve_tor_with_client`) rather than bootstrapping one per service.
325    ///
326    /// This is the plaintext-only entry point (virtual port 80 only, no HTTPS, no
327    /// configuration) — available under the `tor` feature alone, no TLS stack required. For
328    /// native onion HTTPS (needs the `tls` feature too), custom state/cache directories, or the
329    /// other [`OnionConfig`] options, use [`serve_onion`](Server::serve_onion) instead.
330    ///
331    /// # Errors
332    /// Returns an error if the Tor client fails to bootstrap, `nickname` is not a valid
333    /// [`HsNickname`], or the onion service fails to launch.
334    pub async fn serve_tor(
335        self,
336        nickname: &str,
337    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
338        // Install this server's crypto/TLS policy as rustls's process-wide default *before*
339        // bootstrapping — arti reads this global default for its own relay/channel TLS
340        // connections (it has no API to accept a `ClientConfig` directly). See `TlsPolicy`'s
341        // docs. Idempotent: a no-op if something already installed a default. Only relevant
342        // (and only compiled) when this crate's own `tls` feature is enabled — without it,
343        // arti simply falls back to whatever crypto provider it installs on its own.
344        #[cfg(feature = "tls")]
345        self.effective_tls_policy().install_as_process_default();
346        let client = TorClient::create_bootstrapped(TorClientConfig::default()).await?;
347        self.serve_tor_with_client(&client, nickname).await
348    }
349
350    /// Publishes this router as a Tor `.onion` hidden service using an already-bootstrapped
351    /// [`TorClient`], and serves requests arriving over it, blocking until the service stops.
352    ///
353    /// Only rendezvous requests targeting virtual port 80 are accepted (the port every `.onion`
354    /// HTTP client expects); anything else has its circuit shut down immediately. Requests are
355    /// dispatched through the same handling pipeline as [`serve_http`](Server::serve_http) —
356    /// HTTP/1.1, plus h2c with the `http2` feature — one Tokio task per stream.
357    ///
358    /// Since `client` is already bootstrapped, arti has already constructed its internal
359    /// relay/channel TLS provider from whatever `rustls::crypto::CryptoProvider` was installed
360    /// process-wide *before this call* — install one yourself (e.g.
361    /// `server.effective_tls_policy()`, or simply `TlsPolicy::hardened().install_as_process_default()`,
362    /// both requiring this crate's `tls` feature) before bootstrapping `client` if that matters
363    /// to you; it's too late to affect `client` by the time this function runs.
364    /// [`serve_tor`](Server::serve_tor) does this for you because it owns the bootstrap.
365    ///
366    /// # Errors
367    /// Returns an error if `nickname` is not a valid [`HsNickname`], or the onion service
368    /// fails to launch (for example, if onion services are disabled in `client`'s config).
369    pub async fn serve_tor_with_client<R>(
370        self,
371        client: &TorClient<R>,
372        nickname: &str,
373    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
374    where
375        R: Runtime,
376    {
377        crate::server::enforce_fips_compliance()?;
378
379        let hs_nickname = parse_nickname(nickname)?;
380        let svc_cfg = OnionServiceConfigBuilder::default()
381            .nickname(hs_nickname)
382            .build()?;
383
384        let Some((service, request_stream)) = client.launch_onion_service(svc_cfg)? else {
385            return Err("onion services are disabled in this TorClient's config".into());
386        };
387
388        if let Some(addr) = service.onion_address() {
389            tracing::info!(
390                "[tor] onion service published at {}",
391                addr.display_unredacted()
392            );
393        }
394
395        wait_until_reachable(&service).await;
396
397        let state = Arc::new(self);
398        let stream_requests = tor_hsservice::handle_rend_requests(request_stream);
399        tokio::pin!(stream_requests);
400
401        while let Some(stream_request) = stream_requests.next().await {
402            let state = state.clone();
403            drop(tokio::spawn(async move {
404                if let Err(e) = handle_plaintext_only_stream(state, stream_request).await {
405                    tracing::debug!("[tor] connection error: {e}");
406                }
407            }));
408        }
409
410        drop(service);
411        Ok(())
412    }
413
414    /// Publishes this router as a Tor `.onion` hidden service according to `config`
415    /// (state/cache directories, vanguards) and serves requests arriving over it, blocking
416    /// until the service stops. Bootstraps a fresh [`TorClient`] — see the bootstrap-time note
417    /// on [`serve_tor`](Server::serve_tor); prefer [`serve_onion_with_client`](Server::serve_onion_with_client)
418    /// to reuse one across services.
419    ///
420    /// # Errors
421    /// Returns an error if the Tor client fails to bootstrap, `config.nickname` is not a valid
422    /// [`HsNickname`], or the onion service fails to launch.
423    pub async fn serve_onion(
424        self,
425        config: OnionConfig,
426    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
427        let mut builder = TorClientConfigBuilder::default();
428
429        if let Some(state_dir) = &config.state_dir {
430            builder
431                .storage()
432                .state_dir(CfgPath::new_literal(state_dir.clone()));
433        }
434        if let Some(cache_dir) = &config.cache_dir {
435            builder
436                .storage()
437                .cache_dir(CfgPath::new_literal(cache_dir.clone()));
438        }
439        if !config.vanguards {
440            builder
441                .vanguards()
442                .mode(ExplicitOrAuto::Explicit(VanguardMode::Disabled));
443        }
444
445        // See the equivalent comment in `serve_tor` — must happen before bootstrapping.
446        #[cfg(feature = "tls")]
447        self.effective_tls_policy().install_as_process_default();
448        let client_config = builder.build()?;
449        let client = TorClient::create_bootstrapped(client_config).await?;
450        self.serve_onion_with_client(&client, config).await
451    }
452
453    /// Publishes this router as a Tor `.onion` hidden service according to `config`, using an
454    /// already-bootstrapped [`TorClient`], and serves requests arriving over it, blocking until
455    /// the service stops.
456    ///
457    /// Dispatch depends on `config` and on which features are compiled in: virtual port 80
458    /// serves plaintext HTTP unless [`redirect_http`](OnionConfig::redirect_http) is enabled
459    /// (in which case it issues a `308` to the `https://` equivalent) — both only possible with
460    /// the `tls` feature enabled; virtual port 443 terminates TLS — self-signed by default with
461    /// `cert-gen`, or a caller-supplied config via [`tls_config`](OnionConfig::tls_config) with
462    /// just `tls` — and is only listened on if the `tls` feature is enabled and
463    /// [`no_tls`](OnionConfig::no_tls) wasn't called. Without the `tls` feature at all, this
464    /// behaves exactly like [`serve_tor_with_client`](Server::serve_tor_with_client): plaintext
465    /// on virtual port 80 only. Anything else has its circuit shut down immediately.
466    ///
467    /// Since `client` is already bootstrapped, install a `CryptoProvider` process-wide
468    /// yourself *before* bootstrapping it if you want arti's relay/channel TLS to share this
469    /// server's policy — see the equivalent note on
470    /// [`serve_tor_with_client`](Server::serve_tor_with_client). The self-signed certificate on
471    /// virtual port 443 always uses this server's [`TlsPolicy`](crate::tls::TlsPolicy)
472    /// (see [`Server::tls_policy`]), regardless of what's installed process-wide.
473    ///
474    /// # Errors
475    /// Returns an error if `config.nickname` is not a valid [`HsNickname`], the onion service
476    /// fails to launch, or (when TLS is enabled) the TLS configuration is invalid.
477    pub async fn serve_onion_with_client<R>(
478        self,
479        client: &TorClient<R>,
480        config: OnionConfig,
481    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
482    where
483        R: Runtime,
484    {
485        crate::server::enforce_fips_compliance()?;
486
487        let hs_nickname = parse_nickname(&config.nickname)?;
488        let svc_cfg = OnionServiceConfigBuilder::default()
489            .nickname(hs_nickname)
490            .build()?;
491
492        let Some((service, request_stream)) = client.launch_onion_service(svc_cfg)? else {
493            return Err("onion services are disabled in this TorClient's config".into());
494        };
495
496        let onion_host = service
497            .onion_address()
498            .map(|addr| addr.display_unredacted().to_string());
499        if let Some(host) = &onion_host {
500            tracing::info!("[tor] onion service published at {host}");
501        }
502        tracing::info!(
503            vanguards = config.vanguards,
504            tls = config.tls_enabled(),
505            "[tor] hardening posture: vanguards={}, tls={}",
506            if config.vanguards { "on" } else { "off" },
507            if config.tls_enabled() { "on" } else { "off" },
508        );
509
510        wait_until_reachable(&service).await;
511
512        if let Some(on_ready) = config.on_ready
513            && let Some(host) = &onion_host
514        {
515            on_ready(host);
516        }
517
518        #[cfg(feature = "tls")]
519        {
520            let tls_acceptor = match &config.tls {
521                OnionTls::None => None,
522                #[cfg(feature = "cert-gen")]
523                OnionTls::SelfSigned => {
524                    let domain = onion_host
525                        .clone()
526                        .unwrap_or_else(|| "onion-service.invalid".to_string());
527                    let cert = crate::tls::generate_self_signed_cert(vec![domain])?;
528                    // Shares this server's crypto/TLS policy (see `Server::tls_policy`) rather
529                    // than stock rustls defaults, so a hardened/FIPS/custom provider set for
530                    // clearnet applies here too.
531                    let server_config = self.effective_tls_policy().server_config_from_pem(
532                        cert.cert_pem.as_bytes(),
533                        cert.key_pem.as_bytes(),
534                    )?;
535                    Some(TlsAcceptor::from(Arc::new(server_config)))
536                }
537                OnionTls::Custom(server_config) => Some(TlsAcceptor::from(server_config.clone())),
538            };
539            let onion_host: Arc<str> = Arc::from(onion_host.unwrap_or_default());
540            let redirect_http = config.redirect_http;
541
542            let state = Arc::new(self);
543            let stream_requests = tor_hsservice::handle_rend_requests(request_stream);
544            tokio::pin!(stream_requests);
545
546            while let Some(stream_request) = stream_requests.next().await {
547                let state = state.clone();
548                let tls_acceptor = tls_acceptor.clone();
549                let onion_host = onion_host.clone();
550                drop(tokio::spawn(async move {
551                    if let Err(e) = handle_onion_stream(
552                        state,
553                        stream_request,
554                        tls_acceptor,
555                        redirect_http,
556                        onion_host,
557                    )
558                    .await
559                    {
560                        tracing::debug!("[tor] connection error: {e}");
561                    }
562                }));
563            }
564
565            drop(service);
566            Ok(())
567        }
568
569        // No `tls` feature compiled in at all: `config.tls` can only ever be `OnionTls::None`
570        // (the only variant that exists in this build), so this is functionally identical to
571        // `serve_tor_with_client`'s plaintext-only dispatch.
572        #[cfg(not(feature = "tls"))]
573        {
574            let state = Arc::new(self);
575            let stream_requests = tor_hsservice::handle_rend_requests(request_stream);
576            tokio::pin!(stream_requests);
577
578            while let Some(stream_request) = stream_requests.next().await {
579                let state = state.clone();
580                drop(tokio::spawn(async move {
581                    if let Err(e) = handle_plaintext_only_stream(state, stream_request).await {
582                        tracing::debug!("[tor] connection error: {e}");
583                    }
584                }));
585            }
586
587            drop(service);
588            Ok(())
589        }
590    }
591}
592
593/// Validates `nickname` as an [`HsNickname`], wrapping the error with the offending value —
594/// [`HsNickname::from_str`]'s own error doesn't otherwise echo it back.
595fn parse_nickname(nickname: &str) -> Result<HsNickname, Box<dyn std::error::Error + Send + Sync>> {
596    nickname
597        .parse()
598        .map_err(|e| format!("invalid onion service nickname {nickname:?}: {e}").into())
599}
600
601/// Awaits `service`'s status stream until it reports full reachability. This tracks real Tor
602/// network activity (introduction points built, descriptor accepted by `HsDirs`) with no built-in
603/// timeout, so it can legitimately take minutes on a slow or first-run bootstrap — every state
604/// transition is logged so that wait doesn't look hung.
605async fn wait_until_reachable(service: &tor_hsservice::RunningOnionService) {
606    let mut status_events = service.status_events();
607    let mut last_state = None;
608    loop {
609        let Some(status) = status_events.next().await else {
610            tracing::warn!(
611                "[tor] onion service status stream ended before reporting full reachability"
612            );
613            return;
614        };
615        let state = status.state();
616        if last_state != Some(state) {
617            tracing::info!("[tor] onion service status: {state:?}");
618            last_state = Some(state);
619        }
620        if state.is_fully_reachable() {
621            break;
622        }
623    }
624    tracing::info!("[tor] onion service is fully reachable");
625}
626
627/// What to do with an incoming onion-service rendezvous request, given the virtual port it
628/// targeted and the service's current TLS/redirect configuration. Kept as a pure function
629/// (see the `tests` module below) independent of arti's stream types so the dispatch rules can
630/// be unit-tested without a live Tor connection.
631///
632/// `Redirect`/`ServeTls` only exist when the `tls` feature is enabled — without it, an onion
633/// service can only ever be plaintext, so [`route_onion_request`] never has a reason to produce
634/// them.
635#[derive(Debug, Clone, Copy, PartialEq, Eq)]
636enum OnionAction {
637    /// Shut the circuit down — not a port this service answers on.
638    Reject,
639    /// Serve the app directly over plaintext HTTP.
640    ServePlaintext,
641    /// Issue a `308 Permanent Redirect` to the `https://` equivalent. Requires the `tls`
642    /// feature.
643    #[cfg(feature = "tls")]
644    Redirect,
645    /// Perform a TLS handshake, then serve the app over it. Requires the `tls` feature.
646    #[cfg(feature = "tls")]
647    ServeTls,
648}
649
650#[cfg_attr(not(feature = "tls"), allow(unused_variables))]
651const fn route_onion_request(port: u16, tls_enabled: bool, redirect_http: bool) -> OnionAction {
652    match port {
653        #[cfg(feature = "tls")]
654        ONION_HTTP_PORT if tls_enabled && redirect_http => OnionAction::Redirect,
655        ONION_HTTP_PORT => OnionAction::ServePlaintext,
656        #[cfg(feature = "tls")]
657        ONION_HTTPS_PORT if tls_enabled => OnionAction::ServeTls,
658        _ => OnionAction::Reject,
659    }
660}
661
662/// Builds the `Location` header value for a plaintext→TLS onion redirect. Requires the `tls`
663/// feature.
664#[cfg(feature = "tls")]
665fn redirect_location(onion_host: &str, path_and_query: &str) -> String {
666    format!("https://{onion_host}{path_and_query}")
667}
668
669/// Handles a single rendezvous stream for [`Server::serve_tor_with_client`] — plaintext HTTP on
670/// virtual port 80 only, everything else rejected.
671async fn handle_plaintext_only_stream<S>(
672    state: Arc<Server<S>>,
673    stream_request: StreamRequest,
674) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
675where
676    S: Clone + Send + Sync + 'static,
677{
678    let IncomingStreamRequest::Begin(begin) = stream_request.request() else {
679        stream_request.shutdown_circuit()?;
680        return Ok(());
681    };
682
683    if route_onion_request(begin.port(), false, false) != OnionAction::ServePlaintext {
684        stream_request.shutdown_circuit()?;
685        return Ok(());
686    }
687
688    let onion_stream = stream_request.accept(Connected::new_empty()).await?;
689    let svc =
690        hyper::service::service_fn(move |req| hyper_handler(state.clone(), req, ONION_PEER_ADDR));
691    serve_connection(onion_stream, svc).await
692}
693
694/// Handles a single rendezvous stream for [`Server::serve_onion_with_client`], dispatching per
695/// [`route_onion_request`]. Requires the `tls` feature (see [`OnionAction`]'s docs for why the
696/// non-TLS case never needs this — it reuses [`handle_plaintext_only_stream`] instead).
697#[cfg(feature = "tls")]
698async fn handle_onion_stream<S>(
699    state: Arc<Server<S>>,
700    stream_request: StreamRequest,
701    tls_acceptor: Option<TlsAcceptor>,
702    redirect_http: bool,
703    onion_host: Arc<str>,
704) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
705where
706    S: Clone + Send + Sync + 'static,
707{
708    let IncomingStreamRequest::Begin(begin) = stream_request.request() else {
709        stream_request.shutdown_circuit()?;
710        return Ok(());
711    };
712
713    match route_onion_request(begin.port(), tls_acceptor.is_some(), redirect_http) {
714        OnionAction::Reject => {
715            stream_request.shutdown_circuit()?;
716            Ok(())
717        }
718        OnionAction::ServePlaintext => {
719            let onion_stream = stream_request.accept(Connected::new_empty()).await?;
720            let svc = hyper::service::service_fn(move |req| {
721                hyper_handler(state.clone(), req, ONION_PEER_ADDR)
722            });
723            serve_connection(onion_stream, svc).await
724        }
725        OnionAction::Redirect => {
726            let onion_stream = stream_request.accept(Connected::new_empty()).await?;
727            let svc = hyper::service::service_fn(move |req: Request<hyper::body::Incoming>| {
728                let onion_host = onion_host.clone();
729                async move { Ok::<_, std::io::Error>(redirect_response(&req, &onion_host)) }
730            });
731            serve_connection(onion_stream, svc).await
732        }
733        OnionAction::ServeTls => {
734            let Some(acceptor) = tls_acceptor else {
735                stream_request.shutdown_circuit()?;
736                return Ok(());
737            };
738            let onion_stream = stream_request.accept(Connected::new_empty()).await?;
739            let tls_stream = tokio::time::timeout(
740                crate::server::TLS_HANDSHAKE_TIMEOUT,
741                acceptor.accept(onion_stream),
742            )
743            .await
744            .map_err(|_| "TLS handshake timed out")??;
745            let svc = hyper::service::service_fn(move |req| {
746                hyper_handler(state.clone(), req, ONION_PEER_ADDR)
747            });
748            serve_connection(tls_stream, svc).await
749        }
750    }
751}
752
753/// Builds the `308 Permanent Redirect` response for a plaintext request when
754/// [`OnionConfig::redirect_http`] is enabled. Requires the `tls` feature.
755#[cfg(feature = "tls")]
756fn redirect_response(req: &Request<hyper::body::Incoming>, onion_host: &str) -> Response<Body> {
757    let path_and_query = req
758        .uri()
759        .path_and_query()
760        .map_or("/", hyper::http::uri::PathAndQuery::as_str);
761    let location = redirect_location(onion_host, path_and_query);
762    Response::builder()
763        .status(308) // preserves the HTTP method, unlike 301/302
764        .header("location", location)
765        .body(Body::empty())
766        .unwrap_or_else(|_| Response::new(Body::empty()))
767}
768
769#[cfg(test)]
770mod tests {
771    #[cfg(feature = "tls")]
772    use super::redirect_location;
773    use super::{OnionAction, OnionConfig, parse_nickname, route_onion_request};
774
775    #[test]
776    fn plaintext_serves_port_80_and_rejects_everything_else() {
777        assert_eq!(
778            route_onion_request(80, false, false),
779            OnionAction::ServePlaintext
780        );
781        assert_eq!(route_onion_request(443, false, false), OnionAction::Reject);
782        assert_eq!(route_onion_request(22, false, false), OnionAction::Reject);
783    }
784
785    #[cfg(feature = "tls")]
786    #[test]
787    fn tls_dual_stack_serves_both_ports_without_redirect() {
788        assert_eq!(
789            route_onion_request(80, true, false),
790            OnionAction::ServePlaintext
791        );
792        assert_eq!(route_onion_request(443, true, false), OnionAction::ServeTls);
793    }
794
795    #[cfg(feature = "tls")]
796    #[test]
797    fn tls_with_redirect_forces_port_80_to_redirect() {
798        assert_eq!(route_onion_request(80, true, true), OnionAction::Redirect);
799        assert_eq!(route_onion_request(443, true, true), OnionAction::ServeTls);
800    }
801
802    #[test]
803    fn redirect_only_applies_when_tls_is_enabled() {
804        // Requesting a redirect without TLS enabled is meaningless — plaintext still wins.
805        assert_eq!(
806            route_onion_request(80, false, true),
807            OnionAction::ServePlaintext
808        );
809    }
810
811    #[test]
812    fn unknown_ports_are_always_rejected() {
813        assert_eq!(route_onion_request(8080, false, false), OnionAction::Reject);
814        assert_eq!(route_onion_request(8080, true, true), OnionAction::Reject);
815    }
816
817    #[cfg(feature = "tls")]
818    #[test]
819    fn redirect_location_builds_the_https_equivalent_url() {
820        assert_eq!(
821            redirect_location("abcd1234.onion", "/foo?x=1"),
822            "https://abcd1234.onion/foo?x=1"
823        );
824        assert_eq!(
825            redirect_location("abcd1234.onion", "/"),
826            "https://abcd1234.onion/"
827        );
828    }
829
830    #[cfg(feature = "cert-gen")]
831    #[test]
832    fn onion_config_defaults_to_self_signed_tls_when_cert_gen_is_enabled() {
833        let config = OnionConfig::new("test-nickname");
834        assert_eq!(config.nickname, "test-nickname");
835        assert!(config.vanguards);
836        assert!(!config.redirect_http);
837        assert!(matches!(config.tls, super::OnionTls::SelfSigned));
838    }
839
840    #[cfg(not(feature = "cert-gen"))]
841    #[test]
842    fn onion_config_defaults_to_no_tls_without_cert_gen() {
843        let config = OnionConfig::new("test-nickname");
844        assert_eq!(config.nickname, "test-nickname");
845        assert!(config.vanguards);
846        assert!(!config.redirect_http);
847        assert!(matches!(config.tls, super::OnionTls::None));
848    }
849
850    #[test]
851    fn onion_config_builder_methods_are_chainable() {
852        let config = OnionConfig::new("nick")
853            .state_dir("/tmp/state")
854            .cache_dir("/tmp/cache")
855            .redirect_http(true)
856            .vanguards(false)
857            .no_tls();
858        assert_eq!(
859            config.state_dir.as_deref(),
860            Some(std::path::Path::new("/tmp/state"))
861        );
862        assert_eq!(
863            config.cache_dir.as_deref(),
864            Some(std::path::Path::new("/tmp/cache"))
865        );
866        assert!(config.redirect_http);
867        assert!(!config.vanguards);
868        assert!(matches!(config.tls, super::OnionTls::None));
869    }
870
871    #[test]
872    fn parse_nickname_accepts_a_valid_name() {
873        assert!(parse_nickname("valid-nickname").is_ok());
874    }
875
876    #[test]
877    fn parse_nickname_rejects_an_invalid_name_and_echoes_it_back() {
878        // Onion service nicknames are restricted (e.g. no spaces) — `HsNickname::from_str`
879        // rejects this, and `parse_nickname` wraps that error with the offending value since
880        // the underlying error doesn't otherwise include it.
881        let err = parse_nickname("not a valid nickname!!").unwrap_err();
882        assert!(err.to_string().contains("not a valid nickname!!"));
883    }
884
885    #[test]
886    fn onion_config_debug_does_not_panic() {
887        let debug = format!("{:?}", OnionConfig::new("nick"));
888        assert!(debug.contains("OnionConfig"));
889        assert!(debug.contains("nick"));
890    }
891
892    #[cfg(all(feature = "tls", feature = "cert-gen"))]
893    #[test]
894    fn tls_config_switches_to_a_custom_server_config() {
895        let policy = crate::tls::TlsPolicy::hardened();
896        let cert = crate::tls::generate_self_signed_cert(vec!["nick.onion".to_string()])
897            .expect("generate self-signed cert");
898        let server_config = policy
899            .server_config_from_pem(cert.cert_pem.as_bytes(), cert.key_pem.as_bytes())
900            .expect("build server config");
901
902        let config = OnionConfig::new("nick").tls_config(server_config);
903        assert!(matches!(config.tls, super::OnionTls::Custom(_)));
904        // `OnionTls::Custom`'s `Debug` impl deliberately doesn't dump the whole
905        // `rustls::ServerConfig` — just proves the variant is reachable and formats.
906        assert!(format!("{config:?}").contains("nickname"));
907    }
908
909    #[test]
910    fn on_ready_stores_the_callback() {
911        let config = OnionConfig::new("nick").on_ready(|_addr| {});
912        assert!(config.on_ready.is_some());
913    }
914
915    #[cfg(all(feature = "tls", feature = "http1"))]
916    #[tokio::test]
917    async fn redirect_response_builds_a_308_to_the_https_equivalent() {
918        use hyper::Request;
919        use hyper::service::service_fn;
920        use tokio::io::{AsyncReadExt, AsyncWriteExt};
921
922        let (mut client_io, server_io) = tokio::io::duplex(8 * 1024);
923        let onion_host: std::sync::Arc<str> = std::sync::Arc::from("abcd1234.onion");
924
925        let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
926            let onion_host = onion_host.clone();
927            async move { Ok::<_, std::io::Error>(super::redirect_response(&req, &onion_host)) }
928        });
929        let server = tokio::spawn(async move {
930            hyper::server::conn::http1::Builder::new()
931                .serve_connection(hyper_util::rt::TokioIo::new(server_io), svc)
932                .await
933        });
934
935        client_io
936            .write_all(b"GET /foo?x=1 HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n")
937            .await
938            .expect("write request");
939
940        let mut buf = Vec::new();
941        client_io
942            .read_to_end(&mut buf)
943            .await
944            .expect("read response");
945        let response = String::from_utf8_lossy(&buf);
946
947        assert!(response.contains("308"), "unexpected response: {response}");
948        assert!(
949            response.contains("location: https://abcd1234.onion/foo?x=1"),
950            "unexpected response: {response}"
951        );
952
953        server
954            .await
955            .expect("server task join")
956            .expect("serve_connection ok");
957    }
958}