diff --git a/Cargo.lock b/Cargo.lock index 4e71c84cb..ab8ad119c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3553,6 +3553,7 @@ dependencies = [ "directories", "dirs", "educe", + "either", "fs-mistrust", "itertools", "once_cell", diff --git a/crates/tor-config/Cargo.toml b/crates/tor-config/Cargo.toml index f6684f3d4..2301e285b 100644 --- a/crates/tor-config/Cargo.toml +++ b/crates/tor-config/Cargo.toml @@ -21,6 +21,7 @@ config = { version = "0.13", default-features = false, features = ["toml"] } derive_builder = { version = "0.11.2", package = "derive_builder_fork_arti" } directories = { version = "4", optional = true } educe = "0.4.6" +either = "1" fs-mistrust = { path = "../fs-mistrust", version = "0.4.0" } itertools = "0.10.1" once_cell = "1" diff --git a/crates/tor-config/src/misc.rs b/crates/tor-config/src/misc.rs index a6791c657..703d32ee5 100644 --- a/crates/tor-config/src/misc.rs +++ b/crates/tor-config/src/misc.rs @@ -4,8 +4,13 @@ //! and layers, but which don't depend on specific elements of the Tor system. use std::borrow::Cow; +use std::iter; +use std::net; +use std::num::NonZeroU16; use educe::Educe; +use either::Either; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; @@ -74,6 +79,215 @@ impl TryFrom for PaddingLevel { } } +/// Specification of (possibly) something to listen on (eg, a port, or some addresses/ports) +/// +/// Can represent, at least: +/// * "do not listen" +/// * Listen on the following port on localhost (IPv6 and IPv4) +/// * Listen on precisely the following address and port +/// * Listen on several addresses/ports +/// +/// Currently only IP (v6 and v4) is supported. +#[derive(Clone, Hash, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "ListenSerde", into = "ListenSerde")] +#[derive(Educe)] +#[educe(Default)] +pub struct Listen(Vec); + +impl Listen { + /// Create a new `Listen` specifying no addresses (no listening) + pub fn new_none() -> Listen { + Listen(vec![]) + } + + /// Create a new `Listen` specifying listening on a port on localhost + /// + /// Special case: if `port` is zero, specifies no listening. + pub fn new_localhost(port: u16) -> Listen { + Listen(port.try_into().ok().map(ListenItem::Localhost).into_iter().collect_vec()) + } + + /// Create a new `Listen`, possibly specifying listening on a port on localhost + /// + /// Special case: if `port` is `Some(0)`, also specifies no listening. + pub fn new_localhost_optional(port: Option) -> Listen { + Self::new_localhost(port.unwrap_or_default()) + } + + /// List the network socket addresses to listen on + /// + /// Fails if the listen spec involves listening on things other than IP addresses. + /// (Currently that is not possible.) + pub fn ip_addrs( + &self, + ) -> Result + '_, ListenUnsupported> { + Ok(self.0.iter().flat_map(|i| i.iter())) + } + + /// Get the localhost port to listen on + /// + /// Returns `None` if listening is configured to be disabled. + /// + /// Fails, giving an unsupported error, if the configuration + /// isn't just "listen on a single localhost port". + pub fn localhost_port_deprecated(&self) -> Result, ListenUnsupported> { + use ListenItem as LI; + Ok(match &*self.0 { + [] => None, + [LI::Localhost(port)] => Some((*port).into()), + _ => return Err(ListenUnsupported {}), + }) + } +} + +/// [`Listen`] configuration specified something not supported by application code +#[derive(thiserror::Error, Debug, Clone)] +#[non_exhaustive] +#[error("Unsupported listening configration")] +pub struct ListenUnsupported {} + +/// One item in the `Listen` +/// +/// We distinguish `Localhost`, +/// rather than just storing two `net:SocketAddr`, +/// so that we can handle localhost (which means to address families) specially +/// in order to implement `localhost_port_deprecated()`. +#[derive(Clone, Hash, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +enum ListenItem { + /// One port, both IPv6 and IPv4 + Localhost(NonZeroU16), + + /// Any other single socket address + General(net::SocketAddr), +} + +impl ListenItem { + /// Return the `SocketAddr`s implied by this item + fn iter(&self) -> impl Iterator + '_ { + use net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use ListenItem as LI; + match self { + &LI::Localhost(port) => Either::Left({ + let port = port.into(); + let addrs: [IpAddr; 2] = [Ipv6Addr::LOCALHOST.into(), Ipv4Addr::LOCALHOST.into()]; + addrs + .into_iter() + .map(move |ip| net::SocketAddr::new(ip, port)) + }), + LI::General(addr) => Either::Right(iter::once(addr).cloned()), + } + } +} + +/// How we (de) serialize a [`Listen`] +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +enum ListenSerde { + /// for `listen = false` (in TOML syntax) + Bool(bool), + + /// A bare item + One(ListenItemSerde), + + /// An item in a list + List(Vec), +} + +/// One item in the list of a list-ish `Listen`, or the plain value +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +enum ListenItemSerde { + /// An integer. + /// + /// When appearing "loose" (in ListenSerde::One), `0` is parsed as none. + Port(u16), + + /// An string which will be parsed as an address and port + /// + /// When appearing "loose" (in ListenSerde::One), `""` is parsed as none. + String(String), +} + +// This implementation isn't fallible, but clippy thinks it is because of the unwrap. +// The unwrap is just there because we can't pattern-match on a Vec +#[allow(clippy::fallible_impl_from)] +impl From for ListenSerde { + fn from(l: Listen) -> ListenSerde { + let l = l.0; + match l.len() { + 0 => ListenSerde::Bool(false), + 1 => ListenSerde::One(l.into_iter().next().expect("len=1 but no next").into()), + _ => ListenSerde::List(l.into_iter().map(Into::into).collect()), + } + } +} +impl From for ListenItemSerde { + fn from(i: ListenItem) -> ListenItemSerde { + use ListenItem as LI; + use ListenItemSerde as LIS; + match i { + LI::Localhost(port) => LIS::Port(port.into()), + LI::General(addr) => LIS::String(addr.to_string()), + } + } +} + +/// Listen configuration is invalid +#[derive(thiserror::Error, Debug, Clone)] +#[non_exhaustive] +pub enum InvalidListen { + /// Bool was `true` but that's not an address. + #[error("Invalid listen specification: need actual addr/port, or `false`; not `true`")] + InvalidBool, + + /// Specified listen was a string but couldn't parse to a [`net::SocketAddr`]. + #[error("Invalid listen specification: failed to parse string: {0}")] + InvalidString(#[from] net::AddrParseError), + + /// Specified listen was a list containing a zero integer + #[error("Invalid listen specification: zero (for no port) not permitted in list")] + ZeroPortInList, +} +impl TryFrom for Listen { + type Error = InvalidListen; + + fn try_from(l: ListenSerde) -> Result { + use ListenSerde as LS; + Ok(Listen(match l { + LS::Bool(false) => vec![], + LS::Bool(true) => return Err(InvalidListen::InvalidBool), + LS::One(i) if i.means_none() => vec![], + LS::One(i) => vec![i.try_into()?], + LS::List(l) => l.into_iter().map(|i| i.try_into()).try_collect()?, + })) + } +} +impl ListenItemSerde { + /// Is this item actually a sentinel, meaning "don't listen, disable this thing"? + /// + /// Allowed only bare, not in a list. + fn means_none(&self) -> bool { + use ListenItemSerde as LIS; + match self { + &LIS::Port(port) => port == 0, + LIS::String(s) => s.is_empty(), + } + } +} +impl TryFrom for ListenItem { + type Error = InvalidListen; + + fn try_from(i: ListenItemSerde) -> Result { + use ListenItem as LI; + use ListenItemSerde as LIS; + Ok(match i { + LIS::String(s) => LI::General(s.parse()?), + LIS::Port(p) => LI::Localhost(p.try_into().map_err(|_| InvalidListen::ZeroPortInList)?), + }) + } +} + #[cfg(test)] mod test { // @@ begin test lint list maintained by maint/add_warning @@ @@ -90,6 +304,9 @@ mod test { struct TestConfigFile { #[serde(default)] padding: PaddingLevel, + + #[serde(default)] + listen: Option, } #[test] @@ -117,4 +334,97 @@ mod test { chk_e(r#"padding = "unknown""#); chk_e(r#"padding = "Normal""#); } + + #[test] + fn listen_parse() { + use net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + use ListenItem as LI; + + let localhost6 = |p| SocketAddr::new(Ipv6Addr::LOCALHOST.into(), p); + let localhost4 = |p| SocketAddr::new(Ipv4Addr::LOCALHOST.into(), p); + let unspec6 = |p| SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), p); + + #[allow(clippy::needless_pass_by_value)] // we do this for consistency + fn chk( + exp_i: Vec, + exp_addrs: Result, ()>, + exp_lpd: Result, ()>, + s: &str, + ) { + let tc: TestConfigFile = toml::from_str(s).expect(s); + let ll = tc.listen.unwrap(); + eprintln!("s={:?} ll={:?}", &s, &ll); + assert_eq!(ll, Listen(exp_i)); + assert_eq!( + ll.ip_addrs().map(|a| a.collect_vec()).map_err(|_| ()), + exp_addrs + ); + assert_eq!(ll.localhost_port_deprecated().map_err(|_| ()), exp_lpd); + } + + let chk_err = |exp, s: &str| { + let got: Result = toml::from_str(s); + let got = got.expect_err(s).to_string(); + assert!(got.contains(exp), "s={:?} got={:?} exp={:?}", s, got, exp); + }; + + let chk_none = |s: &str| { + chk(vec![], Ok(vec![]), Ok(None), &format!("listen = {}", s)); + chk_err( + "", /* any error will do */ + &format!("listen = [ {} ]", s), + ); + }; + + let chk_1 = |v: ListenItem, addrs: Vec, port, s| { + chk( + vec![v.clone()], + Ok(addrs.clone()), + port, + &format!("listen = {}", s), + ); + chk( + vec![v.clone()], + Ok(addrs.clone()), + port, + &format!("listen = [ {} ]", s), + ); + chk( + vec![v, LI::Localhost(23.try_into().unwrap())], + Ok([addrs, vec![localhost6(23), localhost4(23)]] + .into_iter() + .flatten() + .collect()), + Err(()), + &format!("listen = [ {}, 23 ]", s), + ); + }; + + chk_none(r#""""#); + chk_none(r#"0"#); + chk_none(r#"false"#); + chk(vec![], Ok(vec![]), Ok(None), r#"listen = []"#); + + chk_1( + LI::Localhost(42.try_into().unwrap()), + vec![localhost6(42), localhost4(42)], + Ok(Some(42)), + "42", + ); + chk_1( + LI::General(unspec6(56)), + vec![unspec6(56)], + Err(()), + r#""[::]:56""#, + ); + + let chk_err_1 = |e, el, s| { + chk_err(e, &format!("listen = {}", s)); + chk_err(el, &format!("listen = [ {} ]", s)); + chk_err(el, &format!("listen = [ 23, {}, 77 ]", s)); + }; + + chk_err_1("need actual addr/port", "did not match any variant", "true"); + chk_err("did not match any variant", r#"listen = [ [] ]"#); + } }