diff options
Diffstat (limited to 'src/config/mod.rs')
| -rw-r--r-- | src/config/mod.rs | 562 |
1 files changed, 435 insertions, 127 deletions
diff --git a/src/config/mod.rs b/src/config/mod.rs index 9210922..c7f96a3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -110,6 +110,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "HTTP API users (from htpasswd) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake. Such writes act on the whole swarm and are logged. Epoch metadata and the swarm ID are never writable.", ), option( + "log-format", + Kind::Text, + "auto|text|journal", + Some("auto"), + "How log lines look: `text` (timestamped), `journal` (for systemd's journal: no timestamp or colours, the level as each line's priority, for journalctl -p), or `auto`: journal when stdout goes to the journal, else text.", + ), + option( "log-raw-ids", Kind::Flag, "BOOL", @@ -128,7 +135,7 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ Kind::List, "STRING,[STRING...]", None, - "How to reach a node of the swarm to join: its address (`mycelium:<public key>`, as GET /admin/stats lists it), a Mycelium public key, a Mycelium IPv6 address, or a host name with an AAAA record for one. A node keeps trying until it has joined.", + "How to reach a node of the swarm to join: its address, as GET /admin/stats lists it (`mycelium:<public key>`, `libp2p:<multiaddr>`, `libp2p:/p2p/<peer ID>` (where it is known, or through a relay), `tor:<name>.onion:<port>`); or, for Mycelium, a public key, an IPv6 address, or a host name with an AAAA record for one. A node keeps trying until it has joined.", ), option( "genesis", @@ -194,7 +201,7 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "This node's key (Ed25519, 32 raw bytes): who the node is, whatever transport reaches it. Keep it with the node: a new key is a new node. Required, but with relay-only (then not allowed).", ), option( - "create-node-key", + "node-key-create", Kind::Flag, "BOOL", Some("false"), @@ -208,6 +215,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "Pre-shared swarm key: only nodes that know it may join (raw bytes, at least 16; e.g. `head -c 32 /dev/urandom`). Without it, any node that can reach a member may join.", ), option( + "swarm-key-create", + Kind::Flag, + "BOOL", + Some("false"), + "Create a new swarm key at swarm-key-file if there is no file there: on the swarm's first node, as the others need a copy of it (a key of their own keeps them out). Without it, a missing key file is an error.", + ), + option( "alias", Kind::Text, "NAME", @@ -222,6 +236,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "Keepalive interval; dormancy, resync and probe timings derive from it.", ), option( + "dormant-probe-max", + Kind::Duration, + "DURATION", + None, + "Longest wait between HELLOs to a member that doesn't answer (dormant, or unanswered HELLOs): they start at 10 keepalives and double up to this. It bounds how long a returning member, or the other side of a partition, takes to be noticed. Default: 60 keepalives (1h at 1m). Shorter than 10 keepalives, it is every wait.", + ), + option( "neighbours", Kind::Integer, "COUNT", @@ -233,7 +254,21 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ Kind::Duration, "DURATION", Some("7d"), - "Members that renewed their alive stamp within this time vote on remakes (a majority of them must agree). Matters on the node that coordinates a remake.", + "Members that renewed their alive stamp within this time vote on remakes (a majority of them must agree). At least 7h. Members renew their stamps every seventh of the shortest window any of them publishes, hourly at most, so nodes may differ. Matters on the node that coordinates a remake.", + ), + option( + "superseded-writes", + Kind::Text, + "drop|quarantine", + Some("drop"), + "What becomes of a write a remake or rejoin would carry over though a newer one touched its path (last writer wins, by the writers' clocks): drop leaves it out (logged), quarantine keeps it for an operator (/admin/quarantine).", + ), + option( + "clock-skew-warn", + Kind::Duration, + "DURATION", + Some("2s"), + "Warn in the log when this node's clock differs from most of its peers' by more than this, for sure (beyond half the round trip it was measured over, from their PONGs); 0: never.", ), option( "blob-chunk", @@ -243,11 +278,32 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "Chunk size when serving large transfers to other nodes (e.g. an epoch's snapshot to a rejoining node), 1KiB to 4MiB.", ), option( - "transports", + "mycelium-enable", + Kind::Flag, + "BOOL", + Some("false"), + "Use the Mycelium transport (in builds with the mycelium feature). A node uses every transport enabled, and needs one at least (none is on by default); nodes on several carry the traffic between nodes that share none.", + ), + option( + "libp2p-enable", + Kind::Flag, + "BOOL", + Some("false"), + "Use the libp2p transport (in builds with the libp2p feature).", + ), + option( + "tor-enable", + Kind::Flag, + "BOOL", + Some("false"), + "Use the Tor transport (in builds with the tor feature).", + ), + option( + "transport-order", Kind::List, "NAME,[NAME...]", - Some("mycelium"), - "The transports this node uses, in order of preference: mycelium, libp2p and tor (in builds with the libp2p and tor features). A peer is reached on the first one both have, until the paths are measured (then on the fastest); nodes on several carry the traffic between nodes that share none.", + Some("mycelium,libp2p,tor"), + "The transports' order of preference, whether enabled or not: a peer is reached on the first enabled one both have, until the paths are measured (then on the fastest). Transports left out come after, in the default order.", ), option( "libp2p-listen", @@ -264,11 +320,32 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "With the libp2p transport: the addresses to publish in the member list instead of those listened on, e.g. a public address forwarded to this node's listen port. Without, the listen addresses are published, loopback and link-local ones left out.", ), option( + "libp2p-ignore", + Kind::List, + "RANGE,[RANGE...]", + None, + "With the libp2p transport: address ranges libp2p leaves out, e.g. 10.244.0.0/16 and 42::/16 (VPNs): addresses there are neither published (but announced ones) nor offered for hole punching nor dialled, and connections there are refused, whoever dials.", + ), + option( + "libp2p-prefer", + Kind::List, + "RANGE,[RANGE...]", + None, + "With the libp2p transport: address ranges that matter most, best first, e.g. the LAN's 10.23.0.0/16: of the many addresses a node listening everywhere has, those there are published first, and of a peer's, kept first and dialled with the others. Without, public addresses come first.", + ), + option( + "libp2p-internet-hosts", + Kind::List, + "HOST,[HOST...]", + None, + "With the libp2p transport: hosts on the internet (names or addresses, e.g. one.one.one.one), only to tell whether this node has a route to the internet, per IPv4 and IPv6 (asked of the routing table every 30 s; nothing is sent to them). Where it has none, its own public addresses of that family aren't published. Without, or for a family none of them has, it isn't asked.", + ), + option( "libp2p-relay", Kind::Flag, "BOOL", Some("false"), - "With the libp2p transport: relay for other nodes of the swarm that can't be dialled (bounded: 64 circuits, 8 per peer, each up to an hour and 1 GiB), and say so in the member list, where nodes that need a relay find it. Needs a listen address others can dial.", + "With the libp2p transport: relay for other nodes of the swarm that can't be dialled (bounded: 1024 circuits, 64 per peer, each up to an hour and 1 GiB), and say so in the member list, where nodes that need a relay find it. Needs a listen address others can dial.", ), option( "libp2p-relays", @@ -285,6 +362,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "With the libp2p transport: find nodes on the LAN (multicast DNS, IPv4). Those that speak continuum are seeds while joining, so nodes on one LAN find each other without configured seeds; a swarm key keeps a new node from joining another swarm on the same LAN.", ), option( + "libp2p-upnp", + Kind::Flag, + "BOOL", + Some("false"), + "With the libp2p transport: ask the router to forward this node's listen ports (UPnP), and publish where (the router's public address), so that nodes elsewhere can dial it: behind a home router, no relay or hole punching is needed then. Opens those ports on the router for as long as the node runs.", + ), + option( "libp2p-public", Kind::Flag, "BOOL", @@ -310,7 +394,7 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ Kind::Flag, "BOOL", Some("false"), - "Only relay for nodes of swarms (libp2p circuit relay), without being a node: no document, no swarm, no node key. Needs transports = libp2p, libp2p-listen and libp2p-key-file; options about a swarm or a document are errors with it. List it for a swarm in the document (_continuum/relays), or configure it (libp2p-relays). The HTTP API (with htpasswd) has /health, /admin/stats and /admin/log only.", + "Only relay for nodes of swarms (libp2p circuit relay), without being a node: no document, no swarm, no node key. Needs libp2p alone (libp2p-enable, no other transport), libp2p-listen and libp2p-key-file; options about a swarm or a document are errors with it. List it for a swarm in the document (_continuum/relays), or configure it (libp2p-relays). The HTTP API (with htpasswd) has /health, /admin/stats and /admin/log only.", ), option( "libp2p-key-file", @@ -320,6 +404,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "With the libp2p transport: its key (Ed25519, 32 raw bytes), which makes its peer ID; a new one each start if absent (the node ID stays the same either way).", ), option( + "libp2p-key-create", + Kind::Flag, + "BOOL", + Some("false"), + "With the libp2p transport: create a new libp2p key at libp2p-key-file if there is no file there (a new peer ID). Without it, a missing key file is an error.", + ), + option( "mycelium-rest-url", Kind::Text, "SCHEME://HOSTNAME:PORT", @@ -336,6 +427,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "Private key of the supervised Mycelium daemon (its identity); a new one each start if absent.", ), option( + "mycelium-key-create", + Kind::Flag, + "BOOL", + Some("false"), + "Create a new key for the supervised Mycelium daemon at mycelium-key-file if there is no file there (a new Mycelium address). Without it, a missing key file is an error.", + ), + option( "mycelium-network-key-file", Kind::Text, "PATH", @@ -390,6 +488,8 @@ pub(crate) struct Settings { pub(crate) protocol_writers: Vec<String>, #[serde(default)] pub(crate) debug_api: bool, + #[serde(default = "default_log_format")] + pub(crate) log_format: String, #[serde(default)] pub(crate) log_raw_ids: bool, #[serde(default)] @@ -415,11 +515,19 @@ pub(crate) struct Settings { /// Required (`check`), but not allowed with `relay_only`. pub(crate) node_key_file: Option<PathBuf>, #[serde(default)] - pub(crate) create_node_key: bool, + pub(crate) node_key_create: bool, pub(crate) swarm_key_file: Option<PathBuf>, + #[serde(default)] + pub(crate) swarm_key_create: bool, pub(crate) alias: Option<String>, #[serde(default = "default_keepalive", with = "crate::units::duration_option")] pub(crate) keepalive: Duration, + #[serde( + default, + deserialize_with = "crate::units::optional_duration", + serialize_with = "crate::units::serialize_optional_duration" + )] + pub(crate) dormant_probe_max: Option<Duration>, #[serde(default = "default_neighbours")] pub(crate) neighbours: usize, #[serde( @@ -427,10 +535,27 @@ pub(crate) struct Settings { with = "crate::units::duration_option" )] pub(crate) electorate_window: Duration, + #[serde(default)] + pub(crate) superseded_writes: crate::remake::Superseded, + #[serde( + default = "default_clock_skew_warn", + with = "crate::units::duration_option" + )] + pub(crate) clock_skew_warn: Duration, /// In bytes. #[serde(default = "default_blob_chunk", with = "crate::units::size_option")] pub(crate) blob_chunk: u64, - #[serde(default = "default_transports")] + #[serde(default)] + pub(crate) mycelium_enable: bool, + #[serde(default)] + pub(crate) libp2p_enable: bool, + #[serde(default)] + pub(crate) tor_enable: bool, + #[serde(default = "default_transport_order")] + pub(crate) transport_order: Vec<String>, + /// The transports this node uses, in order of preference: those enabled, in + /// `transport_order` (`resolve`). + #[serde(skip)] pub(crate) transports: Vec<String>, #[serde(default)] #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] @@ -449,9 +574,24 @@ pub(crate) struct Settings { pub(crate) libp2p_mdns: bool, #[serde(default)] #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] + pub(crate) libp2p_upnp: bool, + #[serde(default)] + #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] + pub(crate) libp2p_ignore: Vec<String>, + #[serde(default)] + #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] + pub(crate) libp2p_prefer: Vec<String>, + #[serde(default)] + #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] + pub(crate) libp2p_internet_hosts: Vec<String>, + #[serde(default)] + #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] pub(crate) libp2p_public: bool, #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] pub(crate) libp2p_key_file: Option<PathBuf>, + #[serde(default)] + #[cfg_attr(not(feature = "libp2p"), allow(dead_code))] + pub(crate) libp2p_key_create: bool, #[cfg_attr(not(feature = "tor"), allow(dead_code))] pub(crate) tor_state_dir: Option<PathBuf>, #[serde(default = "default_tor_port")] @@ -459,12 +599,21 @@ pub(crate) struct Settings { pub(crate) tor_port: u16, #[serde(default)] pub(crate) relay_only: bool, + #[cfg_attr(not(feature = "mycelium"), allow(dead_code))] pub(crate) mycelium_rest_url: Option<String>, + #[cfg_attr(not(feature = "mycelium"), allow(dead_code))] pub(crate) mycelium_key_file: Option<PathBuf>, + #[serde(default)] + #[cfg_attr(not(feature = "mycelium"), allow(dead_code))] + pub(crate) mycelium_key_create: bool, + #[cfg_attr(not(feature = "mycelium"), allow(dead_code))] pub(crate) mycelium_network_key_file: Option<PathBuf>, + #[cfg_attr(not(feature = "mycelium"), allow(dead_code))] pub(crate) mycelium_network_name: Option<String>, + #[cfg_attr(not(feature = "mycelium"), allow(dead_code))] pub(crate) mycelium_tcp_listen_port: Option<u16>, #[serde(default = "default_mycelium_peers")] + #[cfg_attr(not(feature = "mycelium"), allow(dead_code))] pub(crate) mycelium_peers: Vec<String>, pub(crate) debug_epoch: Option<u64>, } @@ -487,18 +636,27 @@ fn default_neighbours() -> usize { fn default_electorate_window() -> Duration { Duration::from_secs(7 * 24 * 3600) } +fn default_clock_skew_warn() -> Duration { + Duration::from_secs(2) +} fn default_blob_chunk() -> u64 { 64 * 1024 } -fn default_transports() -> Vec<String> { - vec!["mycelium".to_owned()] +fn default_transport_order() -> Vec<String> { + TRANSPORTS + .iter() + .map(|(name, _)| (*name).to_owned()) + .collect() } -/// The transports `transports` may name, and whether this build has each. +/// The transports there are, in the default order, and whether this build has each. pub(crate) const TRANSPORTS: &[(&str, bool)] = &[ - ("mycelium", true), + ("mycelium", cfg!(feature = "mycelium")), ("libp2p", cfg!(feature = "libp2p")), ("tor", cfg!(feature = "tor")), ]; +fn default_log_format() -> String { + "auto".to_owned() +} fn default_tor_port() -> u16 { 9662 } @@ -507,48 +665,14 @@ fn default_mycelium_peers() -> Vec<String> { } impl Settings { - /// `mycelium-rest-url`, parsed (checked when loading). - pub(crate) fn mycelium_rest_url(&self) -> Option<url::Url> { - self.mycelium_rest_url - .as_deref() - .and_then(|url| url::Url::parse(url).ok()) - } - - /// The options only a supervised Mycelium daemon takes, and whether each is set: the - /// other side of `CONFLICTS`' `mycelium-rest-url` (a test keeps them the same). - fn supervised_only(&self) -> [(&'static str, bool); 4] { - [ - ("mycelium-key-file", self.mycelium_key_file.is_some()), - ( - "mycelium-network-key-file", - self.mycelium_network_key_file.is_some(), - ), - ( - "mycelium-network-name", - self.mycelium_network_name.is_some(), - ), - ( - "mycelium-tcp-listen-port", - self.mycelium_tcp_listen_port.is_some(), - ), - ] - } - fn check(&self) -> Result<()> { - if let Some(url) = &self.mycelium_rest_url { - url::Url::parse(url).with_context(|| format!("mycelium-rest-url {url:?}"))?; - } - if self.mycelium_rest_url.is_some() - && let Some((name, _)) = self.supervised_only().iter().find(|(_, set)| *set) - { + if crate::logging::LogFormat::parse(&self.log_format).is_none() { bail!( - "{name} is for a supervised Mycelium daemon and can't be combined with mycelium-rest-url" + "log-format {:?}: one of {}", + self.log_format, + crate::logging::LogFormat::NAMES.join(", ") ); } - // A private network has both; one alone would be ignored or invented. - if self.mycelium_network_key_file.is_some() != self.mycelium_network_name.is_some() { - bail!("mycelium-network-key-file and mycelium-network-name go together"); - } crate::force::Force::parse(self.force.iter().map(String::as_str), FORCE) .map_err(|reason| anyhow!("force: {reason}"))?; // Contradictions, not overrides: an error wherever the options come from. @@ -563,6 +687,12 @@ impl Settings { if self.genesis && self.recover_from.is_some() { bail!("recover-from starts the swarm from a backup; it can't be combined with genesis"); } + if self.node_key_create && self.node_key_file.is_none() { + bail!("node-key-create creates the key at node-key-file; set that too"); + } + if self.swarm_key_create && self.swarm_key_file.is_none() { + bail!("swarm-key-create creates the key at swarm-key-file; set that too"); + } if self.relay_only { self.check_relay_only()?; } else { @@ -571,37 +701,47 @@ impl Settings { } if self.node_key_file.is_none() { bail!( - "node-key-file is required: who this node is (create one with create-node-key)" + "node-key-file is required: who this node is (create one with node-key-create)" ); } } - if self.transports.is_empty() { - bail!("transports: a node needs at least one"); - } - if self.transports.iter().any(|name| name == "tor") && self.tor_state_dir.is_none() { - bail!( - "tor needs tor-state-dir: where arti keeps its state, the onion service's key among it" - ); - } - for (index, name) in self.transports.iter().enumerate() { - match TRANSPORTS.iter().find(|(known, _)| known == name) { - None => bail!( - "transports: unknown transport {name:?} (known: {})", + for (index, name) in self.transport_order.iter().enumerate() { + if !TRANSPORTS.iter().any(|(known, _)| known == name) { + bail!( + "transport-order: unknown transport {name:?} (known: {})", TRANSPORTS .iter() .map(|(known, _)| *known) .collect::<Vec<_>>() .join(", ") - ), - Some((_, false)) => { - bail!("transports: this build has no {name} (built without the {name} feature)") - } - Some(_) if self.transports[..index].contains(name) => { - bail!("transports: {name} is named twice") - } - Some(_) => {} + ); + } + if self.transport_order[..index].contains(name) { + bail!("transport-order: {name} is named twice"); + } + } + for (name, built) in TRANSPORTS { + if self.enabled(name) && !built { + bail!("{name}-enable: this build has no {name} (built without the {name} feature)"); } } + if self.transports.is_empty() { + bail!( + "no transport enabled: a node needs at least one ({}-enable; this build has: {})", + TRANSPORTS + .iter() + .find(|(_, built)| *built) + .map_or("libp2p", |(name, _)| *name), + TRANSPORTS + .iter() + .filter(|(_, built)| *built) + .map(|(name, _)| *name) + .collect::<Vec<_>>() + .join(", ") + ); + } + // Each transport's own options, now that the names are good. + crate::transport::check(self)?; if self.debug_epoch.is_some() && !self.debug_api { bail!("debug-epoch needs debug-api"); } @@ -617,8 +757,11 @@ impl Settings { if !(1024..=4 << 20).contains(&self.blob_chunk) { bail!("blob-chunk must be between 1KiB and 4MiB"); } - if self.electorate_window.is_zero() { - bail!("electorate-window must be longer than 0"); + // Members renew their alive stamp hourly at most (`members::alive_renewal`): in + // a shorter window, live members would drop out between renewals, down to an + // electorate of the coordinator alone. + if self.electorate_window < crate::swarm::members::MIN_ELECTORATE_WINDOW { + bail!("electorate-window must be at least 7h (seven hourly renewals)"); } if let Some(alias) = &self.alias { check_alias(alias)?; @@ -628,6 +771,32 @@ impl Settings { } impl Settings { + /// Whether transport `name` is enabled (`<name>-enable`). + fn enabled(&self, name: &str) -> bool { + match name { + "mycelium" => self.mycelium_enable, + "libp2p" => self.libp2p_enable, + "tor" => self.tor_enable, + _ => false, + } + } + + /// The transports used, in order: the enabled ones, as `transport_order` puts + /// them, then those it leaves out, in the default order. + fn ordered_transports(&self) -> Vec<String> { + self.transport_order + .iter() + .map(String::as_str) + .chain(TRANSPORTS.iter().map(|(name, _)| *name)) + .filter(|name| self.enabled(name)) + .fold(Vec::new(), |mut used, name| { + if !used.iter().any(|known: &String| known == name) { + used.push(name.to_owned()); + } + used + }) + } + /// A relay that is no node: libp2p alone, dialable, with a key kept, and nothing /// about a swarm or a document. fn check_relay_only(&self) -> Result<()> { @@ -635,19 +804,12 @@ impl Settings { bail!("relay-only relays over libp2p; this build has no libp2p"); } if self.transports != ["libp2p"] { - bail!("relay-only relays over libp2p alone: set transports to libp2p"); - } - if self.libp2p_listen.is_empty() { - bail!("relay-only needs libp2p-listen: where nodes reach the relay"); - } - if self.libp2p_key_file.is_none() { - bail!( - "relay-only needs libp2p-key-file: without it, the relay's peer ID changes with every start and nodes lose it" - ); + bail!("relay-only relays over libp2p alone: set libp2p-enable, and no other transport"); } + // Its listen address and key: libp2p's checks (`transport::libp2p::check`). let swarm_options = [ ("node-key-file", self.node_key_file.is_some()), - ("create-node-key", self.create_node_key), + ("node-key-create", self.node_key_create), ("seeds", !self.seeds.is_empty()), ("genesis", self.genesis), ("state-file", self.state_file.is_some()), @@ -655,6 +817,7 @@ impl Settings { ("recover-from", self.recover_from.is_some()), ("remake", self.remake.is_some()), ("swarm-key-file", self.swarm_key_file.is_some()), + ("swarm-key-create", self.swarm_key_create), ("protocol-writers", !self.protocol_writers.is_empty()), ("alias", self.alias.is_some()), ("debug-api", self.debug_api), @@ -776,11 +939,12 @@ fn unset_entry(provenance: Provenance) -> Entry { /// Options that can't be combined. A source that sets one side drops the other side's /// values from earlier sources (lower precedence), so e.g. `--mycelium-rest-url` on the /// command line overrides `mycelium-key-file` from a file. Both set in one source is an -/// error (`Settings::check`). +/// error (`transport::mycelium::check`, where Mycelium is built). const CONFLICTS: &[(&[&str], &[&str])] = &[( &["mycelium-rest-url"], &[ "mycelium-key-file", + "mycelium-key-create", "mycelium-network-key-file", "mycelium-network-name", "mycelium-tcp-listen-port", @@ -1028,12 +1192,13 @@ fn culprit(merged: &Layer) -> Option<(&String, &Entry)> { const CONFIG: &str = "config"; const PRINT_CONFIG: &str = "print-config"; -const UNSET: &str = "unset"; +/// Undoing a value set before: the argument `--unset`, and the key `unset` in a file. +pub(super) const UNSET: &str = "unset"; fn command() -> Command { let mut command = Command::new("continuum") .version(env!("CARGO_PKG_VERSION")) - .about("continuum: a replicated key-value store over Mycelium") + .about("continuum: a replicated Automerge document over Mycelium, libp2p or Tor") .after_help( "Every option can also be set in a configuration file (--config); arguments win.\n\ A value can be imported instead of written out: env:NAME, file:PATH or cmd:COMMAND \ @@ -1146,6 +1311,34 @@ pub(crate) struct Loaded { pub(crate) print_only: bool, } +impl Loaded { + /// The options given for transports this node doesn't use (not enabled), which it + /// ignores: per transport, whether this build has it, and the options (but its + /// `-enable`). A shared configuration may well carry them; still worth a warning, + /// as it may as well be a mistake (a build without that transport, or a transport + /// not enabled). + pub(crate) fn unused_transport_options(&self) -> Vec<(&'static str, bool, Vec<String>)> { + TRANSPORTS + .iter() + .filter(|(name, _)| !self.settings.transports.iter().any(|used| used == name)) + .filter_map(|(name, built)| { + let prefix = format!("{name}-"); + let options: Vec<String> = self + .origins + .iter() + .filter(|(option, origin)| { + option.starts_with(&prefix) + && *option != &format!("{name}-enable") + && !origin.unset + }) + .map(|(option, _)| option.clone()) + .collect(); + (!options.is_empty()).then_some((*name, *built, options)) + }) + .collect() + } +} + /// How `GET /admin/config` shows text with placeholders (`?values=`). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1301,6 +1494,7 @@ fn resolve(merged: Layer, files: Vec<PathBuf>) -> Result<Loaded> { } })?; settings.alias = settings.alias.map(|alias| alias.trim().to_owned()); + settings.transports = settings.ordered_transports(); settings.check().context("invalid configuration")?; Ok(Loaded { settings, @@ -1318,27 +1512,54 @@ mod tests { use super::*; use serde_json::json; + /// The first transport this build has: what the tests run on when they don't name + /// one, so they pass with any of the transport features. + fn built_transport() -> &'static str { + TRANSPORTS + .iter() + .find(|(_, built)| *built) + .map(|(name, _)| *name) + .expect("a build has a transport") + } + + fn entry(value: JsonValue) -> Entry { + Entry { + value, + origin: Origin { + provenance: Provenance::Argument, + unset: false, + imports: Vec::new(), + templates: Vec::new(), + written: None, + }, + } + } + + /// `values` as arguments, with this build's first transport unless they name some + /// (`built_transport`). fn layer(values: JsonValue) -> Layer { - values + let mut layer: Layer = values .as_object() .unwrap() .iter() - .map(|(name, value)| { - ( - name.clone(), - Entry { - value: value.clone(), - origin: Origin { - provenance: Provenance::Argument, - unset: false, - imports: Vec::new(), - templates: Vec::new(), - written: None, - }, - }, - ) - }) - .collect() + .map(|(name, value)| (name.clone(), entry(value.clone()))) + .collect(); + with_built_transport(&mut layer); + layer + } + + /// Unless `layer` enables transports: this build's first, with what it needs to start + /// (Tor its state directory). + fn with_built_transport(layer: &mut Layer) { + if layer.keys().any(|name| name.ends_with("-enable")) { + return; + } + layer.insert(format!("{}-enable", built_transport()), entry(json!(true))); + if built_transport() == "tor" { + layer + .entry("tor-state-dir".to_owned()) + .or_insert_with(|| entry(json!("tor"))); + } } #[test] @@ -1364,6 +1585,22 @@ mod tests { } #[test] + fn electorate_window_of_seven_renewals_at_least() { + let with = |window: &str| { + resolve( + layer(json!({ + "htpasswd": "u", + "node-key-file": "node.key", + "electorate-window": window, + })), + Vec::new(), + ) + }; + assert!(with("6h59m").is_err()); + assert!(with("7h").is_ok()); + } + + #[test] fn options_and_settings_agree() { // Every option is a Settings field, and every field an option. let loaded = resolve( @@ -1380,8 +1617,10 @@ mod tests { options.sort_unstable(); assert_eq!(names, options); - // Help defaults match the real ones. - for spec in OPTIONS { + // Help defaults match the real ones (but for what `layer` sets to start a + // transport: `with_built_transport`). + let set = format!("{}-enable", built_transport()); + for spec in OPTIONS.iter().filter(|spec| spec.name != set) { if let Some(default) = spec.default { assert_eq!( from_text(spec.kind, default).unwrap(), @@ -1435,6 +1674,7 @@ mod tests { file(json!("a")), Overlay::FileOverFile, ); + with_built_transport(&mut merged); // Both in one source: an error. let mut same = merged.clone(); @@ -1444,7 +1684,11 @@ mod tests { file(url.clone()), Overlay::FileOverFile, ); - assert!(resolve(same, Vec::new()).is_err()); + // (Mycelium's check, where it's built.) + assert_eq!( + resolve(same, Vec::new()).is_err(), + cfg!(feature = "mycelium") + ); // The command line wins over the conflicting file value, and unsets. overlay( @@ -1505,7 +1749,54 @@ mod tests { assert_eq!(from_text(Kind::Flag, "yes").unwrap(), json!(true)); } - /// `Settings::check` and the override rule name the same supervised-only options. + /// Options of transports not in `transports` are named, per transport; those of + /// the transports used aren't, nor unset ones. + #[test] + fn unused_transport_options_are_named() { + let mut values = layer(json!({ + "htpasswd": "u", + "node-key-file": "node.key", + "libp2p-listen": ["/ip4/0.0.0.0/udp/0/quic-v1"], + "libp2p-mdns": true, + "tor-port": 9000, + "mycelium-peers": ["tcp://a:9651"], + })); + values.get_mut("tor-port").unwrap().origin.unset = true; + let loaded = resolve(values, Vec::new()).unwrap(); + let unused = loaded.unused_transport_options(); + for (name, built, options) in &unused { + assert_ne!(*name, built_transport()); + assert_eq!( + *built, + TRANSPORTS.iter().any(|(known, b)| known == name && *b) + ); + assert!( + options + .iter() + .all(|option| option.starts_with(&format!("{name}-"))) + ); + } + let named = |name: &str| { + unused + .iter() + .find(|(n, _, _)| *n == name) + .map(|(_, _, o)| o.clone()) + }; + if built_transport() != "libp2p" { + assert_eq!( + named("libp2p"), + Some(vec!["libp2p-listen".to_owned(), "libp2p-mdns".to_owned()]) + ); + } + if built_transport() != "mycelium" { + assert_eq!(named("mycelium"), Some(vec!["mycelium-peers".to_owned()])); + } + // Unset: not given. + assert_eq!(named("tor"), None); + } + + /// Mycelium's check and the override rule name the same supervised-only options. + #[cfg(feature = "mycelium")] #[test] fn supervised_only_options_are_the_conflicts() { let settings = resolve( @@ -1514,8 +1805,7 @@ mod tests { ) .unwrap() .settings; - let names: Vec<&str> = settings - .supervised_only() + let names: Vec<&str> = crate::transport::mycelium::supervised_only(&settings) .iter() .map(|(name, _)| *name) .collect(); @@ -1541,7 +1831,7 @@ mod tests { ) .is_err() ); - assert!( + assert_eq!( resolve( layer(json!({ "htpasswd": "u", @@ -1551,7 +1841,8 @@ mod tests { })), Vec::new() ) - .is_err() + .is_err(), + cfg!(feature = "mycelium") ); assert!( resolve( @@ -1567,19 +1858,24 @@ mod tests { ) .is_err() ); - let transports = |names: JsonValue| { - resolve( - layer(json!({ "htpasswd": "u", "node-key-file": "node.key", "transports": names })), - Vec::new(), - ) + // The transports enabled (`names`), the others not, in `order` if given. + let transports = |names: &[&str], order: Option<JsonValue>| { + let mut settings = json!({ "htpasswd": "u", "node-key-file": "node.key" }); + for (name, _) in TRANSPORTS { + settings[format!("{name}-enable")] = json!(names.contains(name)); + } + if let Some(order) = order { + settings["transport-order"] = order; + } + resolve(layer(settings), Vec::new()) }; - assert!(transports(json!([])).is_err()); + assert!(transports(&[], None).is_err()); // Without the node key: an error, but for a relay. assert!(resolve(layer(json!({ "htpasswd": "u" })), Vec::new()).is_err()); let relay = |extra: JsonValue| { let mut settings = json!({ "relay-only": true, - "transports": ["libp2p"], + "libp2p-enable": true, "libp2p-listen": ["/ip6/::/tcp/9661"], "libp2p-key-file": "libp2p.key", }); @@ -1596,15 +1892,26 @@ mod tests { ); assert!(relay(json!({ "node-key-file": "node.key" })).is_err()); assert!(relay(json!({ "seeds": ["a"] })).is_err()); - assert!(relay(json!({ "transports": ["mycelium"] })).is_err()); + assert!(relay(json!({ "mycelium-enable": true })).is_err()); + assert!(relay(json!({ "tor-enable": true })).is_err()); assert!(relay(json!({ "libp2p-listen": [] })).is_err()); - assert!(transports(json!(["pigeon"])).is_err()); - assert!(transports(json!(["mycelium", "mycelium"])).is_err()); - assert!(transports(json!(["mycelium"])).is_ok()); + assert!(transports(&["mycelium"], Some(json!(["pigeon"]))).is_err()); + assert!(transports(&["mycelium"], Some(json!(["mycelium", "mycelium"]))).is_err()); assert_eq!( - transports(json!(["libp2p", "mycelium"])).is_ok(), - cfg!(feature = "libp2p") + transports(&["mycelium"], None).is_ok(), + cfg!(feature = "mycelium") ); + if cfg!(feature = "libp2p") && cfg!(feature = "mycelium") { + // In the order given, the ones left out after; whether enabled or not. + let used = |order| { + transports(&["libp2p", "mycelium"], order) + .unwrap() + .settings + .transports + }; + assert_eq!(used(None), ["mycelium", "libp2p"]); + assert_eq!(used(Some(json!(["tor", "libp2p"]))), ["libp2p", "mycelium"]); + } } #[test] @@ -1653,9 +1960,10 @@ mod tests { "c-${env:CONTINUUM_TEST_SITE}", ]) .unwrap(); - let layer = arguments(&matches).unwrap(); + let mut layer = arguments(&matches).unwrap(); assert!(!layer["alias"].origin.is_imported()); assert_eq!(layer["alias"].shown("alias"), "\"node-berlin\""); + with_built_transport(&mut layer); let loaded = resolve(layer, Vec::new()).unwrap(); let report = loaded.report(Values::Interpolated); |
