Skip to main content

arti/rpc/
listener.rs

1//! Configure and activate RPC listeners from connect points.
2
3use anyhow::Context;
4use std::{
5    collections::{BTreeMap, HashMap},
6    str::FromStr as _,
7    sync::Arc,
8};
9use tracing::debug;
10
11use derive_deftly::Deftly;
12use fs_mistrust::{Mistrust, anon_home::PathExt as _};
13use tor_basic_utils::PathExt as _;
14use tor_config::derive::prelude::*;
15use tor_config::{
16    ConfigBuildError, define_map_builder,
17    extend_builder::{ExtendBuilder, ExtendStrategy},
18};
19use tor_config_path::{CfgPath, CfgPathResolver};
20use tor_error::internal;
21use tor_rpc_connect::{
22    ParsedConnectPoint, SuperuserPermission,
23    auth::RpcAuth,
24    load::{LoadError, LoadOptions, LoadOptionsBuilder},
25    server::ListenerGuard,
26};
27use tor_rtcompat::{Runtime, general};
28
29/// Return defaults for RpcListenerMapBuilder.
30pub(super) fn listener_map_defaults() -> BTreeMap<String, RpcListenerSetConfigBuilder> {
31    toml::from_str(
32        r#"
33        ["user-default"]
34        enable = true
35        dir = "${ARTI_LOCAL_DATA}/rpc/connect.d"
36
37        ["system-default"]
38        enable = false
39        dir = "/etc/arti-rpc/connect.d"
40        "#,
41    )
42    .expect("Could not parse defaults!")
43}
44
45/// Configuration for a single source of connect points
46/// to use when configuring Arti as an RPC server.
47///
48/// This can configure either a connect point from a single toml file,
49/// or a set of connect points from a directory of toml files.
50#[derive(Debug, Clone, Deftly, Eq, PartialEq)]
51#[derive_deftly(TorConfig)]
52#[deftly(tor_config(no_default_trait, no_flattenable_trait, pre_build = Self::validate))]
53#[cfg_attr(feature = "experimental-api", visibility::make(pub))]
54#[cfg_attr(feature = "experimental-api", deftly(tor_config(vis = pub)))]
55pub(crate) struct RpcListenerSetConfig {
56    /// An builder to determine default connect point options.
57    ///
58    /// If `file` is set, this builder is used directly
59    /// to determine the options for the connect points.
60    ///
61    /// If `dir` is set, this builder defines a set of defaults
62    /// that we can override for each connect point in `file_options`.
63    #[deftly(tor_config(
64        setter(skip),
65        attr = serde(flatten),
66        // This lets us hold a Builder in the Config too,
67        // so we can use `ExtendBuilder` on it.
68        field(ty = ConnectPointOptionsBuilder),
69        build = { |this: &Self| this.listener_options.clone() },
70        extend_with = ExtendBuilder::extend_from
71    ))]
72    listener_options: ConnectPointOptionsBuilder,
73
74    /// A path to a file on disk containing a connect string.
75    ///
76    /// Exactly one of `file` or `dir` may be set.
77    #[deftly(tor_config(setter(strip_option), default))]
78    file: Option<CfgPath>,
79
80    /// A path to a directory on disk containing one or more connect strings.
81    ///
82    /// Only files whose names end with ``.toml` are considered.
83    ///
84    #[deftly(tor_config(setter(strip_option), default))]
85    dir: Option<CfgPath>,
86
87    /// Map from file name within `dir` to builders for options on the individual files.
88    ///
89    /// We hold builders here so that we can use `ExtendBuilder` to derive settings
90    /// using `listener_options` as the defaults.
91    #[deftly(tor_config(
92        setter(skip),
93        field(ty = FileOptionsMapBuilder),
94        build = { |this: &Self| this.file_options.clone() },
95        extend_with = ExtendBuilder::extend_from
96    ))]
97    file_options: FileOptionsMapBuilder,
98}
99
100impl RpcListenerSetConfigBuilder {
101    /// Return an error if this builder isn't valid.
102    fn validate(&self) -> Result<(), ConfigBuildError> {
103        match (&self.file, &self.dir, self.file_options.is_empty()) {
104            // If "file" is present, dir and file_options must be absent.
105            (Some(_), None, true) => Ok(()),
106            // If "dir" is present, file must be absent and file_options can be whatever.
107            (None, Some(_), _) => Ok(()),
108            // Otherwise, there's an error.
109            (None, None, _) => Err(ConfigBuildError::MissingField {
110                field: "{file or dir}".into(),
111            }),
112            (_, _, _) => Err(ConfigBuildError::Inconsistent {
113                fields: vec!["file".into(), "dir".into(), "file_options".into()],
114                problem: "'file' is mutually exclusive with 'dir' and 'file_options'".into(),
115            }),
116        }
117    }
118
119    /// Return a mutable reference to the listener options.
120    ///
121    /// This field determines the default connect point options.
122    ///
123    /// If `file` is set, this builder is used directly
124    /// to determine the options for the connect points.
125    ///
126    /// If `dir` is set, this builder defines a set of defaults
127    /// that we can override for each connect point in `file_options`.
128    #[cfg(any(test, feature = "experimental-api"))]
129    pub fn listener_options(&mut self) -> &mut ConnectPointOptionsBuilder {
130        &mut self.listener_options
131    }
132
133    /// Return a mutable reference to the file options
134    ///
135    /// This field is a map from file name within `dir` to builders for options on the individual files.
136    ///
137    /// We hold builders here so that we can use `ExtendBuilder` to derive settings
138    /// using `listener_options` as the defaults.
139    #[cfg(any(test, feature = "experimental-api"))]
140    pub fn file_options(&mut self) -> &mut FileOptionsMapBuilder {
141        &mut self.file_options
142    }
143}
144
145define_map_builder! {
146    /// Builder for the `FileOptionsMap` within an `RpcListenerSetConfig`.
147    #[derive(Eq, PartialEq)]
148    #[cfg_attr(feature = "experimental-api", visibility::make(pub))]
149    pub(crate) struct FileOptionsMapBuilder =>
150    type FileOptionsMap = BTreeMap<String, ConnectPointOptions>;
151}
152
153/// Configuration for overriding a single item in a connect point directory.
154///
155/// This structure's corresponding builder appears at two points
156/// in our configuration tree:
157/// Once at the `RpcListenerSetConfig` level,
158/// and once (for directories only!) under the `file_options` map.
159///
160/// When loading a connect point from an explicitly specified file,
161/// we look at the `ConnectPointOptionsBuilder` under the `RpcListenerSetConfig` only.
162///
163/// When loading a connect point from a file within a specified directory,
164/// we use the `ConnectPointOptionsBuilder` under the `RpcListenerSetConfig`
165/// as a set of defaults,
166/// and we extend those defaults from any entry we find in the `file_options` map
167/// corresponding to the connect point's filename.
168#[derive(Debug, Clone, Eq, PartialEq, Deftly)]
169#[derive_deftly(TorConfig)]
170#[deftly(tor_config(attr = derive(PartialEq, Eq)))]
171#[cfg_attr(feature = "experimental-api", visibility::make(pub))]
172#[cfg_attr(feature = "experimental-api", deftly(tor_config(vis = pub)))]
173pub(crate) struct ConnectPointOptions {
174    /// Used to explicitly disable an entry in a connect point directory.
175    #[deftly(tor_config(default = true))]
176    enable: bool,
177}
178
179impl ConnectPointOptionsBuilder {
180    /// Return true if this builder represents an enabled connect point.
181    fn is_enabled(&self) -> bool {
182        self.enable != Some(false)
183    }
184
185    /// Return a [`LoadOptions`] corresponding to this OverrideConfig.
186    ///
187    /// The `LoadOptions` will contain a subset of our own options,
188    /// set in order to make [`ParsedConnectPoint::load_dir`] behaved as configured here.
189    fn load_options(&self) -> LoadOptions {
190        LoadOptionsBuilder::default()
191            .disable(!self.is_enabled())
192            .build()
193            .expect("Somehow constructed an invalid LoadOptions")
194    }
195}
196
197/// Configuration information used to initialize RPC connections.
198///
199/// This information is derived from the configuration on the connect point,
200/// and from the connect point itself.
201#[derive(Clone, Debug)]
202pub(super) struct RpcConnInfo {
203    /// A human-readable name for the source of this RPC connection.
204    ///
205    /// We try to make this unique, but it might not be, depending on filesystem UTF-8 issues.
206    pub(super) name: String,
207    /// The authentication we require for this RPC connection.
208    pub(super) auth: RpcAuth,
209    /// The options for this connect point.
210    #[allow(unused)] // TODO: Once there are more options than "enable", this will be used.
211    pub(super) options: ConnectPointOptions,
212    /// If true, we allow successful connections on this connect point
213    /// to get superuser capabilities.
214    pub(super) allow_superuser: SuperuserPermission,
215}
216
217impl RpcConnInfo {
218    /// Initialize a new `RpcConnInfo`.
219    ///
220    /// Uses `display_name`
221    /// to name the connect point for human-readable logs.
222    ///
223    /// Uses `auth`, `options`, and `allow_superuser` as settings to initialize new connections.
224    #[allow(clippy::unnecessary_wraps)]
225    fn new(
226        display_name: String,
227        auth: RpcAuth,
228        options: ConnectPointOptions,
229        allow_superuser: SuperuserPermission,
230    ) -> anyhow::Result<Self> {
231        Ok(Self {
232            name: display_name,
233            auth,
234            options,
235            allow_superuser,
236        })
237    }
238}
239
240impl RpcListenerSetConfig {
241    /// Load every enabled connect point from this file or directory,
242    /// and bind to them.
243    ///
244    /// On success, returns a list of bound sockets,
245    /// along with information about how to treat incoming connections on those sockets,
246    /// and a guard object that must not be dropped until we are no longer listening on the socket.
247    pub(super) async fn bind<R: Runtime>(
248        &self,
249        runtime: &R,
250        config_key: &str,
251        resolver: &CfgPathResolver,
252        mistrust: &Mistrust,
253    ) -> anyhow::Result<Vec<(general::Listener, Arc<RpcConnInfo>, ListenerGuard)>> {
254        if !self.listener_options.is_enabled() {
255            // We stop immediately if we're disabled at the RpcListenerSetConfig level,
256            // and load nothing.
257            return Ok(vec![]);
258        }
259
260        if let Some(file) = &self.file {
261            let file = file.path(resolver)?;
262            debug!(
263                "Binding to RPC connect point from {}",
264                file.anonymize_home()
265            );
266            let ctx = |action| {
267                format!(
268                    "Can't {} RPC connect point from {}",
269                    action,
270                    file.anonymize_home()
271                )
272            };
273            let options = self
274                .listener_options
275                .build()
276                .with_context(|| ctx("interpret options"))?;
277
278            let conn_pt = ParsedConnectPoint::load_file(file.as_ref(), mistrust)
279                .with_context(|| ctx("load"))?
280                .resolve(resolver)
281                .with_context(|| ctx("resolve"))?;
282            let tor_rpc_connect::server::Listener {
283                listener,
284                auth,
285                guard,
286                ..
287            } = conn_pt
288                .bind(runtime, mistrust)
289                .await
290                .with_context(|| ctx("bind to"))?;
291            return Ok(vec![(
292                listener,
293                Arc::new(RpcConnInfo::new(
294                    format!("rpc.listen.\"{}\"", config_key),
295                    auth,
296                    options,
297                    conn_pt.superuser_permission(),
298                )?),
299                guard,
300            )]);
301        }
302
303        if let Some(dir) = &self.dir {
304            let dir = dir.path(resolver)?;
305            debug!("Reading RPC connect directory at {}", dir.anonymize_home());
306            // Make a map of instructions from our `file_options` telling
307            // `ParsedConnectPoint::load_dir` about any filenames that might need special handling.
308            //
309            // (This is where we disable any connect point whose `file_options` ConnectPointOptions
310            // tells us it's disabled.)
311            let load_options: HashMap<std::path::PathBuf, LoadOptions> = self
312                .file_options
313                .iter()
314                .map(|(s, or)| (s.into(), or.load_options()))
315                .collect();
316            let mut listeners = Vec::new();
317            let dir_contents =
318                match ParsedConnectPoint::load_dir(dir.as_ref(), mistrust, &load_options) {
319                    Ok(contents) => contents,
320                    //  The spec says: "A nonexistent directory in `rpc.listen` is treated as if it
321                    //  were present but empty."
322                    Err(LoadError::Access(fs_mistrust::Error::NotFound(_))) => return Ok(vec![]),
323                    Err(e) => {
324                        return Err(e).with_context(|| {
325                            format!(
326                                "Can't read RPC connect point directory at {}",
327                                dir.anonymize_home()
328                            )
329                        });
330                    }
331                };
332            for (path, conn_pt_result) in dir_contents {
333                debug!("Binding to connect point from {}", path.display_lossy());
334                let ctx = |action| {
335                    format!(
336                        "Can't {} RPC connect point {} from dir {}",
337                        action,
338                        path.display_lossy(),
339                        dir.anonymize_home()
340                    )
341                };
342
343                let options = {
344                    let mut bld = self.listener_options.clone();
345
346                    if let Some(override_options) = path
347                        .to_str()
348                        .and_then(|fname_as_str| self.file_options.get(fname_as_str))
349                    {
350                        bld.extend_from(override_options.clone(), ExtendStrategy::ReplaceLists);
351                    }
352                    bld.build().with_context(|| ctx("interpret options"))?
353                };
354
355                let conn_pt = conn_pt_result
356                    .with_context(|| ctx("load"))?
357                    .resolve(resolver)
358                    .with_context(|| ctx("resolve"))?;
359
360                let tor_rpc_connect::server::Listener {
361                    listener,
362                    auth,
363                    guard,
364                    ..
365                } = conn_pt
366                    .bind(runtime, mistrust)
367                    .await
368                    .with_context(|| ctx("bind to"))?;
369                listeners.push((
370                    listener,
371                    Arc::new(RpcConnInfo::new(
372                        format!("rpc.listen.\"{}\" ({})", config_key, path.display_lossy()),
373                        auth,
374                        options,
375                        conn_pt.superuser_permission(),
376                    )?),
377                    guard,
378                ));
379            }
380
381            return Ok(listeners);
382        }
383
384        Err(internal!("Constructed RpcListenerSetConfig had neither 'dir' nor 'file' set.").into())
385    }
386}
387
388/// As [`RpcListenerSetConfig`], but bind directly to a verbatim connect point given as a string.
389///
390/// Uses `index` to describe which default entry this connect point came from;
391/// `index` should be a human-readable 1-based index.
392pub(super) async fn bind_string<R: Runtime>(
393    connpt: &str,
394    index: usize,
395    runtime: &R,
396    resolver: &CfgPathResolver,
397    mistrust: &Mistrust,
398) -> anyhow::Result<(general::Listener, Arc<RpcConnInfo>, ListenerGuard)> {
399    let ctx = |action| format!("Can't {action} RPC connect point from rpc.listen_default.#{index}");
400
401    let conn_pt = ParsedConnectPoint::from_str(connpt)
402        .with_context(|| ctx("parse"))?
403        .resolve(resolver)
404        .with_context(|| ctx("resolve"))?;
405    let tor_rpc_connect::server::Listener {
406        listener,
407        auth,
408        guard,
409        ..
410    } = conn_pt
411        .bind(runtime, mistrust)
412        .await
413        .with_context(|| ctx("bind to"))?;
414    Ok((
415        listener,
416        Arc::new(RpcConnInfo::new(
417            format!("rpc.listen_default[(#{})]", index),
418            auth,
419            ConnectPointOptions::default(),
420            conn_pt.superuser_permission(),
421        )?),
422        guard,
423    ))
424}
425
426#[cfg(test)]
427mod test {
428    // @@ begin test lint list maintained by maint/add_warning @@
429    #![allow(clippy::bool_assert_comparison)]
430    #![allow(clippy::clone_on_copy)]
431    #![allow(clippy::dbg_macro)]
432    #![allow(clippy::mixed_attributes_style)]
433    #![allow(clippy::print_stderr)]
434    #![allow(clippy::print_stdout)]
435    #![allow(clippy::single_char_pattern)]
436    #![allow(clippy::unwrap_used)]
437    #![allow(clippy::unchecked_time_subtraction)]
438    #![allow(clippy::useless_vec)]
439    #![allow(clippy::needless_pass_by_value)]
440    #![allow(clippy::string_slice)] // See arti#2571
441    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
442
443    use super::*;
444
445    #[test]
446    fn parse_defaults() {
447        // mainly we're concerned that this doesn't panic.
448        let m = listener_map_defaults();
449        assert_eq!(m.len(), 2);
450    }
451}