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