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