Skip to main content

arti/
proxy.rs

1//! Implement a simple proxy that relays connections over Tor.
2//!
3//! A proxy is launched with [`bind_proxy()`], which opens listener ports.
4//! `StreamProxy::run_proxy` then listens for new
5//! connections, handles an appropriate handshake,
6//! and then relays traffic as appropriate.
7
8semipublic_mod! {
9    #[cfg(feature="http-connect")]
10    mod http_connect;
11    mod socks;
12    pub(crate) mod port_info;
13}
14
15use derive_more::Display;
16use extend::ext;
17use futures::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, Error as IoError};
18use futures::stream::StreamExt;
19use std::net::IpAddr;
20use std::sync::Arc;
21use tor_basic_utils::error_sources::ErrorSources;
22use tor_log_ratelim::log_ratelim;
23use tor_rtcompat::{NetStreamProvider, SpawnExt, TcpListenOptions};
24use tracing::{debug, error, info, instrument, warn};
25
26#[allow(unused)]
27use arti_client::HasKind;
28use arti_client::TorClient;
29#[cfg(feature = "rpc")]
30use arti_rpcserver::RpcMgr;
31use tor_config::Listen;
32use tor_error::{debug_report, warn_report};
33use tor_rtcompat::{NetStreamListener, Runtime};
34use tor_socksproto::SocksAuth;
35
36use anyhow::{Context, Result, anyhow};
37
38/// Placeholder type when RPC is disabled at compile time.
39#[cfg(not(feature = "rpc"))]
40#[cfg_attr(feature = "experimental-api", visibility::make(pub))]
41pub(crate) enum RpcMgr {}
42
43/// A set of proxy protocols to support on a listener.
44#[cfg_attr(feature = "experimental-api", visibility::make(pub))]
45#[derive(Copy, Clone, Debug)]
46#[non_exhaustive]
47pub(crate) enum ListenProtocols {
48    /// Only the socks protocol.
49    SocksOnly,
50    /// Socks _and_ HTTP CONNECT.
51    SocksAndHttpConnect,
52}
53
54impl ListenProtocols {
55    /// Return true if http connect is included in this set of protocols.
56    fn http_connect_supported(self) -> bool {
57        matches!(self, Self::SocksAndHttpConnect)
58    }
59}
60
61impl std::fmt::Display for ListenProtocols {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            ListenProtocols::SocksOnly => write!(f, "SOCKS"),
65            ListenProtocols::SocksAndHttpConnect => write!(f, "SOCKS+HTTP"),
66        }
67    }
68}
69
70/// A Key used to isolate connections.
71///
72/// Composed of an usize (representing which listener socket accepted
73/// the connection, the source IpAddr of the client, and the
74/// authentication string provided by the client).
75#[derive(Debug, Clone, PartialEq, Eq)]
76struct StreamIsolationKey(ListenerIsolation, ProvidedIsolation);
77
78/// Isolation information provided through the proxy connection
79#[derive(Debug, Clone, PartialEq, Eq)]
80enum ProvidedIsolation {
81    /// The socks isolation itself.
82    LegacySocks(SocksAuth),
83    /// A bytestring provided as isolation with the extended Socks5 username/password protocol.
84    ExtendedSocks {
85        /// Which format was negotiated?
86        ///
87        /// (At present, different format codes can't share a circuit.)
88        format_code: u8,
89        /// What's the isolation string?
90        isolation: Box<[u8]>,
91    },
92    #[cfg(feature = "http-connect")]
93    /// An HTTP token, taken from headers.
94    Http(http_connect::Isolation),
95}
96
97impl arti_client::isolation::IsolationHelper for StreamIsolationKey {
98    fn compatible_same_type(&self, other: &Self) -> bool {
99        self == other
100    }
101
102    fn join_same_type(&self, other: &Self) -> Option<Self> {
103        if self == other {
104            Some(self.clone())
105        } else {
106            None
107        }
108    }
109
110    fn enables_long_lived_circuits(&self) -> bool {
111        use ProvidedIsolation as PI;
112        use SocksAuth as SA;
113        match &self.1 {
114            PI::LegacySocks(SA::Socks4(auth)) => !auth.is_empty(),
115            PI::LegacySocks(SA::Username(uname, pass)) => !(uname.is_empty() && pass.is_empty()),
116            PI::LegacySocks(_) => false,
117            PI::ExtendedSocks { isolation, .. } => !isolation.is_empty(),
118            #[cfg(feature = "http-connect")]
119            PI::Http(isolation) => !isolation.is_empty(),
120        }
121    }
122}
123
124/// Size of read buffer to apply to application data streams
125/// and Tor data streams when copying.
126//
127// This particular value is chosen more or less arbitrarily.
128// Larger values let us do fewer reads from the application,
129// but consume more memory.
130//
131// (The default value for BufReader is 8k as of this writing.)
132const APP_STREAM_BUF_LEN: usize = 4096;
133
134const _: () = {
135    assert!(APP_STREAM_BUF_LEN >= tor_socksproto::SOCKS_BUF_LEN);
136};
137
138/// NOTE: The following documentation belongs in a spec.
139/// But for now, it's our best attempt to document the design and protocol
140/// implemented here
141/// for integrating proxies with our RPC system. --nickm
142///
143/// Roughly speaking:
144///
145/// ## Key concepts
146///
147/// A data stream is "RPC-visible" if, when it is created via a proxy connection,
148/// the RPC system is told about it.
149///
150/// Every RPC-visible stream is associated with a given RPC object when it is created.
151/// (Since the RPC object is being specified in the proxy protocol,
152/// it must be one with an externally visible Object ID.
153/// Such Object IDs are cryptographically unguessable and unforgeable,
154/// and are qualified with a unique identifier for their associated RPC session.)
155/// Call this RPC Object the "target" object for now.
156/// This target RPC object must implement
157/// the [`ConnectWithPrefs`](arti_client::rpc::ConnectWithPrefs) special method.
158///
159/// Right now, there are two general kinds of objects that implement this method:
160/// client-like objects, and one-shot clients.
161///
162/// A client-like object is either a `TorClient` or an RPC `Session`.
163/// It knows about and it is capable of opening multiple data streams.
164/// Using it as the target object for a proxy connection tells Arti
165/// that the resulting data stream (if any)
166/// should be built by it, and associated with its RPC session.
167///
168/// An application gets a TorClient by asking the session for one,
169/// or for asking a TorClient to give you a new variant clone of itself.
170///
171/// A one-shot client is an `arti_rpcserver::stream::OneshotClient`.
172/// It is created from a client-like object, but can only be used for a single data stream.
173/// When created, it it not yet connected or trying to connect to anywhere:
174/// the act of using it as the target Object for a proxy connection causes
175/// it to begin connecting.
176///
177/// An application gets a `OneShotClient` by calling `arti:new_oneshot_client`
178/// on any client-like object.
179///
180/// ## The Proxy protocol
181///
182/// See the specification for
183/// [SOCKS extended authentication](https://spec.torproject.org/socks-extensions.html#extended-auth)
184/// for full details on integrating RPC with SOCKS.
185/// For HTTP integration, see
186/// [the relevant section of prop365](https://spec.torproject.org/proposals/365-http-connect-ext.html#x-tor-rpc-target-arti-rpc-support).
187///
188/// ### Further restrictions on Object IDs and isolation
189///
190/// In some cases,
191/// the RPC Object ID may denote an object
192/// that already includes information about its intended stream isolation.
193/// In such cases, the stream isolation MUST be blank.
194/// Implementations MUST reject non-blank stream isolation in such cases.
195///
196/// In some cases, the RPC object ID may denote an object
197/// that already includes information
198/// about its intended destination address and port.
199/// In such cases, the destination address MUST be `0.0.0.0` or `::`
200/// (encoded either as an IPv4 address, an IPv6 address, or a hostname)
201/// and the destination port MUST be 0.
202/// Implementations MUST reject other addresses in such cases.
203///
204/// ### Another proposed change
205///
206/// We could add a new method to clients, with a name like
207/// "open_stream" or "connect_stream".
208/// This method would include all target and isolation information in its parameters.
209/// It would actually create a DataStream immediately, tell it to begin connecting,
210/// and return an externally visible object ID.
211/// The RPC protocol could be used to watch the DataStream object,
212/// to see when it was connected.
213///
214/// The resulting DataStream object could also be used as the target of a proxy connection.
215/// We would require in such a case that no isolation be provided in the proxy handshake,
216/// and that the target address was (e.g.) INADDR_ANY.
217///
218/// ## Intended use cases (examples)
219///
220/// (These examples assume that the application
221/// already knows the proxy port it should use.
222/// I'm leaving out the isolation strings as orthogonal.)
223///
224/// These are **NOT** the only possible use cases;
225/// they're just the two that help understand this system best (I hope).
226///
227/// ### Case 1: Using a client-like object directly.
228///
229/// Here the application has authenticated to RPC
230/// and gotten the session ID `SESSION-1`.
231/// (In reality, this would be a longer ID, and full of crypto).
232///
233/// The application wants to open a new stream to www.example.com.
234/// They don't particularly care about isolation,
235/// but they do want their stream to use their RPC session.
236/// They don't want an Object ID for the stream.
237///
238/// To do this, they make a SOCKS connection to arti,
239/// with target address www.example.com.
240/// They set the username to `<torS0X>0SESSION-1`,
241/// and the password to the empty string.
242///
243/// (Alternatively, it could use HTTP CONNECT, setting
244/// Tor-Rpc-Target to SESSION-1.)
245///
246/// Arti looks up the Session object via the `SESSION-1` object ID
247/// and tells it (via the ConnectWithPrefs special method)
248/// to connect to www.example.com.
249/// The session creates a new DataStream using its internal TorClient,
250/// but does not register the stream with an RPC Object ID.
251/// Arti proxies the application's connection through this DataStream.
252///
253///
254/// ### Case 2: Creating an identifiable stream.
255///
256/// Here the application wants to be able to refer to its DataStream
257/// after the stream is created.
258/// As before, we assume that it's on an RPC session
259/// where the Session ID is `SESSION-1`.
260///
261/// The application sends an RPC request of the form:
262/// `{"id": 123, "obj": "SESSION-1", "method": "arti:new_oneshot_client", "params": {}}`
263///
264/// It receives a reply like:
265/// `{"id": 123, "result": {"id": "STREAM-1"} }`
266///
267/// (In reality, `STREAM-1` would also be longer and full of crypto.)
268///
269/// Now the application has an object called `STREAM-1` that is not yet a connected
270/// stream, but which may become one.
271///
272/// This time, it wants to set its isolation string to "xyzzy".
273///
274/// The application opens a socks connection as before.
275/// For the username it sends `<torS0X>0STREAM-1`,
276/// and for the password it sends `xyzzy`.
277///
278/// (Alternatively, it could use HTTP CONNECT, setting Tor-Isolation to xyzzy,
279/// and Tor-Rpc-Target to STREAM-1.)
280///
281/// Now Arti looks up the `RpcDataStream` object via `STREAM-1`,
282/// and tells it (via the ConnectWithPrefs special method)
283/// to connect to www.example.com.
284/// This causes the `RpcDataStream` internally to create a new `DataStream`,
285/// and to store that `DataStream` in itself.
286/// The `RpcDataStream` with Object ID `STREAM-1`
287/// is now an alias for the newly created `DataStream`.
288/// Arti proxies the application's connection through that `DataStream`.
289///
290#[cfg(feature = "rpc")]
291#[allow(dead_code)]
292mod socks_and_rpc {}
293
294/// Information used to implement a proxy listener.
295struct ProxyContext<R: Runtime> {
296    /// A TorClient to use (by default) to anonymize requests.
297    tor_client: Arc<TorClient<R>>,
298    /// If present, an RpcMgr to use when for attaching requests to RPC
299    /// sessions.
300    #[cfg(feature = "rpc")]
301    rpc_mgr: Option<Arc<arti_rpcserver::RpcMgr>>,
302    /// The protocols that we support.
303    protocols: ListenProtocols,
304}
305
306/// Type alias for the isolation information associated with a given proxy
307/// connection _before_ any negotiation occurs.
308///
309/// Currently this is an index for which listener accepted the connection, plus
310/// the address of the client that connected to the proxy port.
311type ListenerIsolation = (usize, IpAddr);
312
313/// write_all the data to the writer & flush the writer if write_all is successful.
314async fn write_all_and_flush<W>(writer: &mut W, buf: &[u8]) -> Result<()>
315where
316    W: AsyncWrite + Unpin,
317{
318    writer
319        .write_all(buf)
320        .await
321        .context("Error while writing proxy reply")?;
322    writer
323        .flush()
324        .await
325        .context("Error while flushing proxy stream")
326}
327
328/// write_all the data to the writer & close the writer if write_all is successful.
329async fn write_all_and_close<W>(writer: &mut W, buf: &[u8]) -> Result<()>
330where
331    W: AsyncWrite + Unpin,
332{
333    writer
334        .write_all(buf)
335        .await
336        .context("Error while writing proxy reply")?;
337    writer
338        .close()
339        .await
340        .context("Error while closing proxy stream")
341}
342
343/// Return true if a given IoError, when received from accept, is a fatal
344/// error.
345fn accept_err_is_fatal(err: &IoError) -> bool {
346    #![allow(clippy::match_like_matches_macro)]
347
348    /// Re-declaration of WSAEMFILE with the right type to match
349    /// `raw_os_error()`.
350    #[cfg(windows)]
351    const WSAEMFILE: i32 = winapi::shared::winerror::WSAEMFILE as i32;
352
353    // Currently, EMFILE and ENFILE aren't distinguished by ErrorKind;
354    // we need to use OS-specific errors. :P
355    match err.raw_os_error() {
356        #[cfg(unix)]
357        Some(libc::EMFILE) | Some(libc::ENFILE) => false,
358        #[cfg(windows)]
359        Some(WSAEMFILE) => false,
360        _ => true,
361    }
362}
363
364/// A stream proxy listening on one or more local ports, ready to relay traffic.
365#[cfg_attr(feature = "experimental-api", visibility::make(pub))]
366#[must_use]
367pub(crate) struct StreamProxy<R: Runtime> {
368    /// A tor client to use when relaying traffic.
369    tor_client: Arc<TorClient<R>>,
370    /// The listeners that we've actually bound to.
371    listeners: Vec<<R as NetStreamProvider>::Listener>,
372    /// The protocols we respond to.
373    protocols: ListenProtocols,
374    /// An RPC manager to use when incoming requests are tied to streams.
375    rpc_mgr: Option<Arc<RpcMgr>>,
376}
377
378/// Launch a proxy to listen on a given set of ports.
379///
380/// Requires a `runtime` to use for launching tasks and handling
381/// timeouts, and a `tor_client` to use in connecting over the Tor
382/// network.
383///
384/// Returns the proxy, and a list of the ports that we have
385/// bound to.
386#[cfg_attr(feature = "experimental-api", visibility::make(pub))]
387#[instrument(skip_all, level = "trace")]
388pub(crate) async fn bind_proxy<R: Runtime>(
389    runtime: R,
390    tor_client: Arc<TorClient<R>>,
391    listen: Listen,
392    listen_options: TcpListenOptions,
393    protocols: ListenProtocols,
394    rpc_mgr: Option<Arc<RpcMgr>>,
395) -> Result<StreamProxy<R>> {
396    if !listen.is_loopback_only() {
397        warn!(
398            "Configured to listen for proxy connections on non-local addresses. \
399            This is usually insecure! We recommend listening on localhost only."
400        );
401    }
402
403    let mut listeners = Vec::new();
404
405    // Try to bind to the listener ports.
406    match listen.ip_addrs() {
407        Ok(addrgroups) => {
408            for addrgroup in addrgroups {
409                for addr in addrgroup {
410                    match runtime.listen(&addr, &listen_options).await {
411                        Ok(listener) => {
412                            let bound_addr = listener.local_addr()?;
413                            info!("Listening on {:?}", bound_addr);
414                            listeners.push(listener);
415                        }
416                        #[cfg(unix)]
417                        Err(ref e) if e.raw_os_error() == Some(libc::EAFNOSUPPORT) => {
418                            warn_report!(e, "Address family not supported {}", addr);
419                        }
420                        Err(ref e) => {
421                            return Err(anyhow!("Can't listen on {}: {e}", addr));
422                        }
423                    }
424                }
425                // TODO: We are supposed to fail if every address in the group failed!
426            }
427        }
428        Err(e) => warn_report!(e, "Invalid listen spec"),
429    }
430
431    // We weren't able to bind any ports: There's nothing to do.
432    if listeners.is_empty() {
433        error!("Couldn't open any listeners.");
434        return Err(anyhow!("Couldn't open listeners"));
435    }
436
437    Ok(StreamProxy {
438        tor_client,
439        listeners,
440        protocols,
441        rpc_mgr,
442    })
443}
444
445impl<R: Runtime> StreamProxy<R> {
446    /// Run indefinitely, processing incoming connections and relaying traffic.
447    pub(crate) async fn run_proxy(self) -> Result<()> {
448        let StreamProxy {
449            tor_client,
450            listeners,
451            protocols,
452            rpc_mgr,
453        } = self;
454        run_proxy_with_listeners(tor_client, listeners, protocols, rpc_mgr).await
455    }
456
457    /// Return a list of the ports that we've bound to.
458    pub(crate) fn port_info(&self) -> Result<Vec<port_info::Port>> {
459        let mut ports = Vec::new();
460        for listener in &self.listeners {
461            let address = listener.local_addr()?;
462            ports.push(port_info::Port {
463                protocol: port_info::SupportedProtocol::Socks,
464                address: address.into(),
465            });
466            #[cfg(feature = "http-connect")]
467            if self.protocols.http_connect_supported() {
468                ports.push(port_info::Port {
469                    protocol: port_info::SupportedProtocol::Http,
470                    address: address.into(),
471                });
472            }
473        }
474
475        Ok(ports)
476    }
477}
478
479/// Launch a proxy from a given set of already bound listeners.
480#[cfg_attr(feature = "experimental-api", visibility::make(pub))]
481#[instrument(skip_all, level = "trace")]
482pub(crate) async fn run_proxy_with_listeners<R: Runtime>(
483    tor_client: Arc<TorClient<R>>,
484    listeners: Vec<<R as tor_rtcompat::NetStreamProvider>::Listener>,
485    protocols: ListenProtocols,
486    rpc_mgr: Option<Arc<RpcMgr>>,
487) -> Result<()> {
488    // Create a stream of (incoming socket, listener_id) pairs, selected
489    // across all the listeners.
490    let mut incoming = futures::stream::select_all(
491        listeners
492            .into_iter()
493            .map(NetStreamListener::incoming)
494            .enumerate()
495            .map(|(listener_id, incoming_conns)| {
496                incoming_conns.map(move |socket| (socket, listener_id))
497            }),
498    );
499
500    // Loop over all incoming connections.  For each one, call
501    // handle_proxy_conn() in a new task.
502    while let Some((stream, sock_id)) = incoming.next().await {
503        let (stream, addr) = match stream {
504            Ok((s, a)) => (s, a),
505            Err(err) => {
506                if accept_err_is_fatal(&err) {
507                    return Err(err).context("Failed to receive incoming stream on proxy port");
508                } else {
509                    warn_report!(err, "Incoming stream failed");
510                    continue;
511                }
512            }
513        };
514        let proxy_context = ProxyContext {
515            tor_client: tor_client.clone(),
516            #[cfg(feature = "rpc")]
517            rpc_mgr: rpc_mgr.clone(),
518            protocols,
519        };
520        tor_client.runtime().spawn(async move {
521            let res = handle_proxy_conn(proxy_context, stream, (sock_id, addr.ip())).await;
522            if let Err(e) = res {
523                report_proxy_error(e);
524            }
525        })?;
526    }
527
528    Ok(())
529}
530
531/// A (possibly) supported proxy protocol.
532enum ProxyProtocols {
533    /// Some HTTP/1 command or other.
534    ///
535    /// (We only support CONNECT and OPTIONS, but we reject other commands in [`http_connect`].)
536    Http1,
537    /// SOCKS4 or SOCKS5.
538    Socks,
539}
540
541/// Look at the first byte of a proxy connection, and guess what protocol
542/// what protocol it is trying to speak.
543fn classify_protocol_from_first_byte(byte: u8) -> Option<ProxyProtocols> {
544    match byte {
545        b'a'..=b'z' | b'A'..=b'Z' => Some(ProxyProtocols::Http1),
546        4 | 5 => Some(ProxyProtocols::Socks),
547        _ => None,
548    }
549}
550
551/// Handle a single connection `stream` from an application.
552///
553/// Depending on what protocol the application is speaking
554/// (and what protocols we support!), negotiate an appropriate set of options,
555/// and relay traffic to and from the application.
556async fn handle_proxy_conn<R, S>(
557    context: ProxyContext<R>,
558    stream: S,
559    isolation_info: ListenerIsolation,
560) -> Result<()>
561where
562    R: Runtime,
563    S: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static,
564{
565    let mut stream = BufReader::with_capacity(APP_STREAM_BUF_LEN, stream);
566    use futures::AsyncBufReadExt as _;
567
568    let buf: &[u8] = stream.fill_buf().await?;
569    if buf.is_empty() {
570        // connection closed
571        return Ok(());
572    }
573    match classify_protocol_from_first_byte(buf[0]) {
574        Some(ProxyProtocols::Http1) => {
575            #[cfg(feature = "http-connect")]
576            if context.protocols.http_connect_supported() {
577                return http_connect::handle_http_conn(context, stream, isolation_info).await;
578            }
579
580            write_all_and_close(&mut stream, socks::WRONG_PROTOCOL_PAYLOAD).await?;
581            Ok(())
582        }
583        Some(ProxyProtocols::Socks) => {
584            socks::handle_socks_conn(context, stream, isolation_info).await
585        }
586        None => {
587            // We have no idea what protocol the client expects,
588            // so we have no idea how to tell it so.
589            warn!(
590                "Unrecognized protocol on proxy listener (first byte {:x})",
591                buf[0]
592            );
593            Ok(())
594        }
595    }
596}
597
598/// If any source of the provided `error` is a [`tor_proto::Error`], return a reference to that
599/// [`tor_proto::Error`].
600fn extract_proto_err<'a>(
601    error: &'a (dyn std::error::Error + 'static),
602) -> Option<&'a tor_proto::Error> {
603    for error in ErrorSources::new(error) {
604        if let Some(downcast) = error.downcast_ref::<tor_proto::Error>() {
605            return Some(downcast);
606        }
607    }
608
609    None
610}
611
612/// A wrapper that makes anyhow::Error Sized and Cloneable
613/// by wrapping it in an Arc.
614// TODO Add better handling for anyhow errors in tor-log-ratelim
615// create tor-log-ratelim/src/anyhow.rs and put them there
616#[derive(Debug, Clone, Display)]
617#[display("{}", _0)]
618pub(crate) struct RateLimitError(Arc<anyhow::Error>);
619
620impl RateLimitError {
621    /// Creates a new `RateLimitError` from an `anyhow::Error`.
622    pub(crate) fn new(e: anyhow::Error) -> Self {
623        Self(Arc::new(e))
624    }
625}
626
627impl std::error::Error for RateLimitError {}
628
629/// Wraps a `Result` containing an `anyhow::Error` into a `Result`
630/// containing a `Sized` `RateLimitError` to allow usage with `log_ratelim!`.
631#[ext]
632impl<T> Result<T, anyhow::Error> {
633    /// Wraps an `anyhow::Error` into a `Sized` `RateLimitError`
634    fn wrap_for_ratelimit(self) -> Result<T, RateLimitError> {
635        self.map_err(RateLimitError::new)
636    }
637}
638
639/// Report an error that occurred within a single proxy task.
640fn report_proxy_error(e: anyhow::Error) {
641    use tor_proto::Error as PE;
642    // TODO: In the long run it might be a good idea to use an ErrorKind here if we can get one.
643    // This is a bit of a kludge based on the fact that we're using anyhow.
644    //
645    // TODO: It might be handy to have a way to collapse CircuitClosed into EOF earlier.
646    // But that loses information, so it should be optional.
647    //
648    // TODO: Maybe we should look at io::ErrorKind as well, if it's there.  That's another reason
649    // to discard or restrict our anyhow usage.
650    match extract_proto_err(e.as_ref()) {
651        Some(e @ PE::CircuitClosed) => debug_report!(e, "Connection exited"),
652        // We can't use `debug_report!` here because `NotConnected`s error kind is `BadApiUsage`,
653        // which upgrades this to a warning.
654        // https://gitlab.torproject.org/tpo/core/arti/-/issues/2439
655        Some(e @ PE::NotConnected) => debug!(error = (e as &dyn std::error::Error), "Connection exited"),
656        _ => {
657            // TODO: See https://gitlab.torproject.org/tpo/core/arti/-/work_items/2632
658            // We use the root cause of the error as the activity key for rate-limiting
659            // because it provides only the original OS error without any wrappers on the text.
660            // This avoids a manual if-else chain while maintaining separate buckets
661            // for different errors.
662            let bucket = e.root_cause().to_string();
663            let r: Result<(), RateLimitError> = Err(e).wrap_for_ratelimit();
664
665            log_ratelim!(
666                "Connection exited (cause: {})", bucket;
667                r;
668            );
669        }
670    }
671}