Skip to main content

arti/
onion_proxy.rs

1//! Configure and implement onion service reverse-proxy feature.
2
3use std::{
4    collections::{BTreeMap, HashSet, btree_map::Entry},
5    sync::{Arc, Mutex},
6};
7
8use arti_client::config::onion_service::{OnionServiceConfig, OnionServiceConfigBuilder};
9use futures::StreamExt as _;
10use tor_config::{
11    ConfigBuildError, Flatten, Reconfigure, ReconfigureError, define_list_builder_helper,
12    impl_standard_builder,
13};
14use tor_error::warn_report;
15use tor_hsrproxy::{OnionServiceReverseProxy, ProxyConfig, config::ProxyConfigBuilder};
16use tor_hsservice::{HsNickname, RunningOnionService};
17use tor_rtcompat::{Runtime, SpawnExt};
18use tracing::debug;
19
20use crate::reload_cfg::ReconfigurableModule;
21
22/// Configuration for running an onion service from `arti`.
23///
24/// This onion service will forward incoming connections to one or more local
25/// ports, depending on its configuration.  If you need it to do something else
26/// with incoming connections, or if you need finer-grained control over its
27/// behavior, consider using
28/// [`TorClient::launch_onion_service`](arti_client::TorClient::launch_onion_service).
29#[derive(Clone, Debug, Eq, PartialEq)]
30#[cfg_attr(feature = "experimental-api", visibility::make(pub))]
31pub(crate) struct OnionServiceProxyConfig {
32    /// Configuration for the onion service itself.
33    pub(crate) svc_cfg: OnionServiceConfig,
34    /// Configuration for the reverse proxy that handles incoming connections
35    /// from the onion service.
36    pub(crate) proxy_cfg: ProxyConfig,
37}
38
39/// Builder object to construct an [`OnionServiceProxyConfig`].
40//
41// We cannot easily use derive_builder on this builder type, since we want it to be a
42// "Flatten<>" internally.  Fortunately, it's easy enough to implement the
43// pieces that we need.
44#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
45#[serde(transparent)]
46#[cfg_attr(feature = "experimental-api", visibility::make(pub))]
47pub(crate) struct OnionServiceProxyConfigBuilder(
48    Flatten<OnionServiceConfigBuilder, ProxyConfigBuilder>,
49);
50
51impl OnionServiceProxyConfigBuilder {
52    /// Try to construct an [`OnionServiceProxyConfig`].
53    ///
54    /// Returns an error if any part of this builder is invalid.
55    #[cfg_attr(feature = "experimental-api", visibility::make(pub))]
56    pub(crate) fn build(&self) -> Result<OnionServiceProxyConfig, ConfigBuildError> {
57        let svc_cfg = self.0.0.build()?;
58        let proxy_cfg = self.0.1.build()?;
59        Ok(OnionServiceProxyConfig { svc_cfg, proxy_cfg })
60    }
61
62    /// Return a mutable reference to an onion-service configuration sub-builder.
63    #[cfg_attr(feature = "experimental-api", visibility::make(pub))]
64    pub(crate) fn service(&mut self) -> &mut OnionServiceConfigBuilder {
65        &mut self.0.0
66    }
67
68    /// Return a mutable reference to a proxy configuration sub-builder.
69    #[cfg_attr(feature = "experimental-api", visibility::make(pub))]
70    pub(crate) fn proxy(&mut self) -> &mut ProxyConfigBuilder {
71        &mut self.0.1
72    }
73}
74
75impl_standard_builder! { OnionServiceProxyConfig: !Default }
76
77/// Alias for a `BTreeMap` of [`OnionServiceProxyConfig`]; used to make [`derive_builder`] happy.
78#[cfg(feature = "onion-service-service")]
79pub(crate) type OnionServiceProxyConfigMap = BTreeMap<HsNickname, OnionServiceProxyConfig>;
80
81/// The serialized format of an [`OnionServiceProxyConfigMapBuilder`]:
82/// a map from [`HsNickname`] to [`OnionServiceConfigBuilder`].
83type ProxyBuilderMap = BTreeMap<HsNickname, OnionServiceProxyConfigBuilder>;
84
85// TODO: Someday we might want to have an API for a MapBuilder that is distinct
86// from that of a ListBuilder.  It would have to enforce that everything has a
87// key, and that keys are distinct.
88#[cfg(feature = "onion-service-service")]
89define_list_builder_helper! {
90#[cfg_attr(feature = "experimental-api", visibility::make(pub))]
91    pub(crate) struct OnionServiceProxyConfigMapBuilder {
92        services: [OnionServiceProxyConfigBuilder],
93    }
94    built: OnionServiceProxyConfigMap = build_list(services)?;
95    default = vec![];
96    #[serde(try_from="ProxyBuilderMap", into="ProxyBuilderMap")]
97}
98
99/// Construct a [`OnionServiceProxyConfigMap`] from a `Vec` of [`OnionServiceProxyConfig`];
100/// enforce that [`HsNickname`]s are unique.
101fn build_list(
102    services: Vec<OnionServiceProxyConfig>,
103) -> Result<OnionServiceProxyConfigMap, ConfigBuildError> {
104    // It *is* reachable from OnionServiceProxyConfigMapBuilder::build(), since
105    // that builder's API uses push() to add OnionServiceProxyConfigBuilders to
106    // an internal _list_.  Alternatively, we might want to have a distinct
107    // MapBuilder type.
108
109    let mut map = BTreeMap::new();
110    for service in services {
111        if let Some(previous_value) = map.insert(service.svc_cfg.nickname().clone(), service) {
112            return Err(ConfigBuildError::Inconsistent {
113                fields: vec!["nickname".into()],
114                problem: format!(
115                    "Multiple onion services with the nickname {}",
116                    previous_value.svc_cfg.nickname()
117                ),
118            });
119        };
120    }
121    Ok(map)
122}
123
124impl TryFrom<ProxyBuilderMap> for OnionServiceProxyConfigMapBuilder {
125    type Error = ConfigBuildError;
126
127    fn try_from(value: ProxyBuilderMap) -> Result<Self, Self::Error> {
128        let mut list_builder = OnionServiceProxyConfigMapBuilder::default();
129        for (nickname, mut cfg) in value {
130            match cfg.0.0.peek_nickname() {
131                Some(n) if n == &nickname => (),
132                None => (),
133                Some(other) => {
134                    return Err(ConfigBuildError::Inconsistent {
135                        fields: vec![nickname.to_string(), format!("{nickname}.{other}")],
136                        problem: "mismatched nicknames on onion service.".into(),
137                    });
138                }
139            }
140            cfg.0.0.nickname(nickname);
141            list_builder.access().push(cfg);
142        }
143        Ok(list_builder)
144    }
145}
146
147impl From<OnionServiceProxyConfigMapBuilder> for ProxyBuilderMap {
148    /// Convert our Builder representation of a set of onion services into the
149    /// format that serde will serialize.
150    ///
151    /// Note: This is a potentially lossy conversion, since the serialized format
152    /// can't represent partially-built services without a nickname, or
153    /// a collection of services with duplicate nicknames.
154    fn from(value: OnionServiceProxyConfigMapBuilder) -> Self {
155        let mut map = BTreeMap::new();
156        for cfg in value.services.into_iter().flatten() {
157            let nickname = cfg.0.0.peek_nickname().cloned().unwrap_or_else(|| {
158                "Unnamed"
159                    .to_string()
160                    .try_into()
161                    .expect("'Unnamed' was not a valid nickname")
162            });
163            map.insert(nickname, cfg);
164        }
165        map
166    }
167}
168
169/// A running onion service and an associated reverse proxy.
170///
171/// This is what a user configures when they add an onion service to their
172/// configuration.
173#[must_use = "a hidden service Proxy object will terminate the service when dropped"]
174struct Proxy {
175    /// The onion service.
176    ///
177    /// This is launched and running.
178    svc: Arc<RunningOnionService>,
179    /// The reverse proxy that accepts connections from the onion service.
180    ///
181    /// This is also launched and running.
182    proxy: Arc<OnionServiceReverseProxy>,
183}
184
185impl Proxy {
186    /// Create and launch a new onion service proxy, using a given `client`,
187    /// to handle connections according to `config`.
188    ///
189    /// Returns `Ok(None)` if the service specified is disabled in the config.
190    pub(crate) fn launch_new<R: Runtime>(
191        client: &arti_client::TorClient<R>,
192        config: OnionServiceProxyConfig,
193    ) -> anyhow::Result<Option<Self>> {
194        let OnionServiceProxyConfig { svc_cfg, proxy_cfg } = config;
195        let nickname = svc_cfg.nickname().clone();
196
197        let (svc, request_stream) = match client.launch_onion_service(svc_cfg)? {
198            Some(running_service) => running_service,
199            None => {
200                debug!(
201                    "Onion service {} didn't start (disabled in config)",
202                    nickname
203                );
204                return Ok(None);
205            }
206        };
207        let proxy = OnionServiceReverseProxy::new(proxy_cfg);
208
209        {
210            let proxy = proxy.clone();
211            let runtime_clone = client.runtime().clone();
212            let nickname_clone = nickname.clone();
213            client.runtime().spawn(async move {
214                match proxy
215                    .handle_requests(runtime_clone, nickname.clone(), request_stream)
216                    .await
217                {
218                    Ok(()) => {
219                        debug!("Onion service {} exited cleanly.", nickname);
220                    }
221                    Err(e) => {
222                        warn_report!(e, "Onion service {} exited with an error", nickname);
223                    }
224                }
225            })?;
226
227            let mut status_stream = svc.status_events();
228            client.runtime().spawn(async move {
229                while let Some(status) = status_stream.next().await {
230                    debug!(
231                        nickname=%nickname_clone,
232                        status=?status.state(),
233                        problem=?status.current_problem(),
234                        "Onion service status change",
235                    );
236                }
237            })?;
238        }
239
240        Ok(Some(Proxy { svc, proxy }))
241    }
242
243    /// Reconfigure this proxy, using the new configuration `config` and the
244    /// rules in `how`.
245    fn reconfigure(
246        &mut self,
247        config: OnionServiceProxyConfig,
248        how: Reconfigure,
249    ) -> Result<(), ReconfigureError> {
250        if matches!(how, Reconfigure::AllOrNothing) {
251            self.reconfigure_inner(config.clone(), Reconfigure::CheckAllOrNothing)?;
252        }
253
254        self.reconfigure_inner(config, how)
255    }
256
257    /// Helper for `reconfigure`: Run `reconfigure` on each part of this `Proxy`.
258    fn reconfigure_inner(
259        &mut self,
260        config: OnionServiceProxyConfig,
261        how: Reconfigure,
262    ) -> Result<(), ReconfigureError> {
263        let OnionServiceProxyConfig { svc_cfg, proxy_cfg } = config;
264
265        self.svc.reconfigure(svc_cfg, how)?;
266        self.proxy.reconfigure(proxy_cfg, how)?;
267
268        Ok(())
269    }
270}
271
272/// A set of configured onion service proxies.
273#[must_use = "a hidden service ProxySet object will terminate the services when dropped"]
274pub(crate) struct ProxySet<R: Runtime> {
275    /// The arti_client that we use to launch proxies.
276    client: Arc<arti_client::TorClient<R>>,
277    /// The proxies themselves, indexed by nickname.
278    proxies: Mutex<BTreeMap<HsNickname, Proxy>>,
279}
280
281impl<R: Runtime> ProxySet<R> {
282    /// Create a new empty onion service proxy set.
283    ///
284    /// We do this when we are running with deferred bootstrapping,
285    /// since we can't launch an onion service on an unbootstrapped client.
286    pub(crate) fn new_deferred(client: Arc<arti_client::TorClient<R>>) -> Self {
287        Self {
288            client,
289            proxies: Mutex::new(BTreeMap::new()),
290        }
291    }
292
293    /// Create and launch a set of onion service proxies.
294    pub(crate) fn launch_new(
295        client: Arc<arti_client::TorClient<R>>,
296        config_list: OnionServiceProxyConfigMap,
297    ) -> anyhow::Result<Self> {
298        let proxies: BTreeMap<_, _> = config_list
299            .into_iter()
300            .filter_map(|(nickname, cfg)| {
301                // Filter out services which are disabled in the config
302                match Proxy::launch_new(&client, cfg) {
303                    Ok(Some(running_service)) => Some(Ok((nickname, running_service))),
304                    Err(error) => Some(Err(error)),
305                    Ok(None) => None,
306                }
307            })
308            .collect::<anyhow::Result<BTreeMap<_, _>>>()?;
309
310        Ok(Self {
311            client,
312            proxies: Mutex::new(proxies),
313        })
314    }
315
316    /// Try to reconfigure the set of onion proxies according to the
317    /// configuration in `new_config`.
318    ///
319    /// Launches or closes proxies as necessary.  Does not close existing
320    /// connections.
321    pub(crate) fn reconfigure(
322        &self,
323        new_config: OnionServiceProxyConfigMap,
324        how: Reconfigure,
325    ) -> Result<(), ReconfigureError> {
326        if how == Reconfigure::AllOrNothing {
327            self.reconfigure(new_config.clone(), Reconfigure::CheckAllOrNothing)?;
328        }
329        let dry_run = how == Reconfigure::CheckAllOrNothing;
330
331        let mut proxy_map = self.proxies.lock().expect("lock poisoned");
332
333        // Set of the nicknames of defunct proxies.
334        let mut defunct_nicknames: HashSet<_> = proxy_map.keys().map(Clone::clone).collect();
335
336        for cfg in new_config.into_values() {
337            let nickname = cfg.svc_cfg.nickname().clone();
338            // This proxy is still configured, so remove it from the list of
339            // defunct proxies.
340            defunct_nicknames.remove(&nickname);
341
342            match proxy_map.entry(nickname) {
343                Entry::Occupied(mut existing_proxy) => {
344                    // We already have a proxy by this name, so we try to
345                    // reconfigure it.
346                    existing_proxy.get_mut().reconfigure(cfg, how)?;
347                }
348                Entry::Vacant(ent) => {
349                    // We do not have a proxy by this name, so we try to launch
350                    // one.
351                    if !dry_run {
352                        match Proxy::launch_new(&self.client, cfg) {
353                            Ok(Some(new_proxy)) => {
354                                ent.insert(new_proxy);
355                            }
356                            Ok(None) => {
357                                debug!(
358                                    "Onion service {} didn't start (disabled in config)",
359                                    ent.key()
360                                );
361                            }
362                            Err(err) => {
363                                warn_report!(err, "Unable to launch onion service {}", ent.key());
364                            }
365                        }
366                    }
367                }
368            }
369        }
370
371        if !dry_run {
372            for nickname in defunct_nicknames {
373                // We no longer have any configuration for this proxy, so we remove
374                // it from our map.
375                let defunct_proxy = proxy_map
376                    .remove(&nickname)
377                    .expect("Somehow a proxy disappeared from the map");
378                // This "drop" should shut down the proxy.
379                drop(defunct_proxy);
380            }
381        }
382
383        Ok(())
384    }
385
386    /// Whether this `ProxySet` is empty.
387    pub(crate) fn is_empty(&self) -> bool {
388        self.proxies.lock().expect("lock poisoned").is_empty()
389    }
390}
391
392impl<R: Runtime> ReconfigurableModule for ProxySet<R> {
393    fn reconfigure(
394        &self,
395        new: &crate::ArtiCombinedConfig,
396        how: Reconfigure,
397    ) -> Result<(), ReconfigureError> {
398        if new.0.application().defer_bootstrap {
399            // Do not actually launch any onion services unless we are trying
400            // to bootstrap the client.
401            return Ok(());
402        }
403
404        ProxySet::reconfigure(self, new.0.onion_services.clone(), how)?;
405        Ok(())
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    // @@ begin test lint list maintained by maint/add_warning @@
412    #![allow(clippy::bool_assert_comparison)]
413    #![allow(clippy::clone_on_copy)]
414    #![allow(clippy::dbg_macro)]
415    #![allow(clippy::mixed_attributes_style)]
416    #![allow(clippy::print_stderr)]
417    #![allow(clippy::print_stdout)]
418    #![allow(clippy::single_char_pattern)]
419    #![allow(clippy::unwrap_used)]
420    #![allow(clippy::unchecked_time_subtraction)]
421    #![allow(clippy::useless_vec)]
422    #![allow(clippy::needless_pass_by_value)]
423    #![allow(clippy::string_slice)] // See arti#2571
424    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
425    use super::*;
426
427    use tor_config::ConfigBuildError;
428    use tor_hsservice::HsNickname;
429
430    /// Get an [`OnionServiceProxyConfig`] with its `svc_cfg` field having the nickname `nick`.
431    fn get_onion_service_proxy_config(nick: &HsNickname) -> OnionServiceProxyConfig {
432        let mut builder = OnionServiceProxyConfigBuilder::default();
433        builder.service().nickname(nick.clone());
434        builder.build().unwrap()
435    }
436
437    /// Test `super::build_list` with unique and duplicate [`HsNickname`]s.
438    #[test]
439    fn fn_build_list() {
440        let nick_1 = HsNickname::new("nick_1".to_string()).unwrap();
441        let nick_2 = HsNickname::new("nick_2".to_string()).unwrap();
442
443        let proxy_configs: Vec<OnionServiceProxyConfig> = [&nick_1, &nick_2]
444            .into_iter()
445            .map(get_onion_service_proxy_config)
446            .collect();
447        let actual = build_list(proxy_configs.clone()).unwrap();
448
449        let expected =
450            OnionServiceProxyConfigMap::from_iter([nick_1, nick_2].into_iter().zip(proxy_configs));
451
452        assert_eq!(actual, expected);
453
454        let nick = HsNickname::new("nick".to_string()).unwrap();
455        let proxy_configs_dup: Vec<OnionServiceProxyConfig> = [&nick, &nick]
456            .into_iter()
457            .map(get_onion_service_proxy_config)
458            .collect();
459        let actual = build_list(proxy_configs_dup).unwrap_err();
460        let ConfigBuildError::Inconsistent { fields, problem } = actual else {
461            panic!("Unexpected error from `build_list`: {actual:?}");
462        };
463
464        assert_eq!(fields, vec!["nickname".to_string()]);
465        assert_eq!(
466            problem,
467            format!("Multiple onion services with the nickname {nick}")
468        );
469    }
470}