Skip to main content

cli/
serve_bolt.rs

1//! `serve`'s Bolt listener settings: the `--bolt-*` flags, their environment
2//! variables, and the rules that turn them into a listener config or refuse the
3//! start.
4//!
5//! The rules, all checked before the store is opened:
6//!
7//! - **Off by default.** No listener unless `--bolt-port` or
8//!   `MUSHROOMDB_BOLT_PORT` gives a port. Another `--bolt-*` flag without a
9//!   port is an error, not a silent no-op.
10//! - **The bind address** is `--addr`'s IP with the Bolt port, so the
11//!   non-loopback authentication rule `serve` applies to `--addr` covers the
12//!   Bolt listener the same way.
13//! - **TLS** follows HTTP: with `--tls-cert`/`--tls-key` given, Bolt is TLS
14//!   unless `--bolt-plaintext` (or `MUSHROOMDB_BOLT_TLS=off`) says otherwise;
15//!   without them it is plaintext, and `--bolt-tls` is refused.
16//! - **ROUTE's address** is `--bolt-advertised-host` (else the bind IP) with
17//!   `--bolt-advertised-port` (else the bind port). A wildcard bind
18//!   (`0.0.0.0`, `::`) with no advertised host is refused: a driver told to
19//!   dial `0.0.0.0` dials itself.
20
21use std::net::SocketAddr;
22use std::time::Duration;
23
24use bolt::messages::route::{RoutingConfig, DEFAULT_STORE_NAME};
25
26/// Environment variable for `--bolt-port`.
27pub const BOLT_PORT_ENV: &str = "MUSHROOMDB_BOLT_PORT";
28/// Environment variable for `--bolt-tls` / `--bolt-plaintext`: `on` or `off`.
29pub const BOLT_TLS_ENV: &str = "MUSHROOMDB_BOLT_TLS";
30/// Environment variable for `--bolt-advertised-host`.
31pub const BOLT_ADVERTISED_HOST_ENV: &str = "MUSHROOMDB_BOLT_ADVERTISED_HOST";
32/// Environment variable for `--bolt-advertised-port`.
33pub const BOLT_ADVERTISED_PORT_ENV: &str = "MUSHROOMDB_BOLT_ADVERTISED_PORT";
34/// Environment variable for `--bolt-max-connections`.
35pub const BOLT_MAX_CONNECTIONS_ENV: &str = "MUSHROOMDB_BOLT_MAX_CONNECTIONS";
36
37/// Default for `--bolt-tx-timeout-secs`.
38pub const DEFAULT_BOLT_TX_TIMEOUT_SECS: u64 = 30;
39/// Default for `--bookmark-timeout-secs`.
40pub const DEFAULT_BOOKMARK_TIMEOUT_SECS: u64 = 5;
41
42/// The `--bolt-*` flags as given; `None` is "not on the command line".
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct BoltFlags {
45    /// `--bolt-port <port>`.
46    pub port: Option<u16>,
47    /// `--bolt-tls` is `Some(true)`, `--bolt-plaintext` is `Some(false)`.
48    pub tls: Option<bool>,
49    /// `--bolt-advertised-host <host>`.
50    pub advertised_host: Option<String>,
51    /// `--bolt-advertised-port <port>`.
52    pub advertised_port: Option<u16>,
53    /// `--bolt-database <name>`.
54    pub database: Option<String>,
55    /// `--bolt-max-connections <n>`.
56    pub max_connections: Option<usize>,
57    /// `--bolt-tx-timeout-secs <secs>`.
58    pub tx_timeout: Option<Duration>,
59    /// `--bookmark-timeout-secs <secs>`.
60    pub bookmark_timeout: Option<Duration>,
61}
62
63impl BoltFlags {
64    /// Whether any flag other than the port was given.
65    fn any_besides_port(&self) -> bool {
66        self.tls.is_some()
67            || self.advertised_host.is_some()
68            || self.advertised_port.is_some()
69            || self.database.is_some()
70            || self.max_connections.is_some()
71            || self.tx_timeout.is_some()
72            || self.bookmark_timeout.is_some()
73    }
74}
75
76/// A Bolt listener to start.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct BoltSettings {
79    /// `--addr`'s IP with the Bolt port.
80    pub bind: SocketAddr,
81    pub tls: bool,
82    pub advertised_host: Option<String>,
83    pub advertised_port: Option<u16>,
84    /// The store's name for `ROUTE` and database selection.
85    pub database: String,
86    pub max_connections: usize,
87    pub tx_timeout: Duration,
88    pub bookmark_timeout: Duration,
89}
90
91impl BoltSettings {
92    /// The routing config for the address the listener actually bound (port 0
93    /// picks a free one).
94    pub fn routing(&self, bound: SocketAddr) -> Result<RoutingConfig, String> {
95        let routing = RoutingConfig::new(bound, self.advertised_host.as_deref(), &self.database)
96            .map_err(|e| advertise_error(bound, &e.to_string()))?;
97        Ok(match self.advertised_port {
98            Some(port) => routing.with_advertised_port(port),
99            None => routing,
100        })
101    }
102
103    /// `bolt` or `bolt+s`, the scheme a client uses to reach this listener.
104    pub fn scheme(&self) -> &'static str {
105        if self.tls {
106            "bolt+s"
107        } else {
108            "bolt"
109        }
110    }
111}
112
113fn advertise_error(bind: SocketAddr, why: &str) -> String {
114    format!(
115        "--bolt-port on {bind}: {why}; give --bolt-advertised-host (or \
116         {BOLT_ADVERTISED_HOST_ENV}), the host drivers reach this server by, \
117         which ROUTE hands them"
118    )
119}
120
121/// Reads a variable, treating an empty value as unset.
122fn env_value(env: &dyn Fn(&str) -> Option<String>, name: &str) -> Option<String> {
123    env(name)
124        .map(|v| v.trim().to_string())
125        .filter(|v| !v.is_empty())
126}
127
128fn parse_env<T: std::str::FromStr>(
129    env: &dyn Fn(&str) -> Option<String>,
130    name: &str,
131) -> Result<Option<T>, String> {
132    match env_value(env, name) {
133        None => Ok(None),
134        Some(text) => text
135            .parse()
136            .map(Some)
137            .map_err(|_| format!("{name}={text:?} is not valid")),
138    }
139}
140
141/// `on`/`off` and the usual spellings of each.
142fn parse_switch(name: &str, text: &str) -> Result<bool, String> {
143    match text.to_ascii_lowercase().as_str() {
144        "on" | "true" | "1" | "yes" => Ok(true),
145        "off" | "false" | "0" | "no" => Ok(false),
146        _ => Err(format!(
147            "{name}={text:?} is not recognised; use \"on\" or \"off\""
148        )),
149    }
150}
151
152/// Resolves the flags against the environment (`env`) and the HTTP settings:
153/// `addr` is `--addr`, `tls_configured` whether `--tls-cert`/`--tls-key` were
154/// given. `Ok(None)` is "no Bolt listener".
155pub fn resolve_bolt_settings(
156    flags: &BoltFlags,
157    env: &dyn Fn(&str) -> Option<String>,
158    addr: SocketAddr,
159    tls_configured: bool,
160) -> Result<Option<BoltSettings>, String> {
161    let port = match flags.port {
162        Some(port) => Some(port),
163        None => parse_env::<u16>(env, BOLT_PORT_ENV)?,
164    };
165    let Some(port) = port else {
166        if flags.any_besides_port() {
167            return Err(format!(
168                "the --bolt-* flags need --bolt-port (or {BOLT_PORT_ENV}); without a port no \
169                 Bolt listener is started"
170            ));
171        }
172        return Ok(None);
173    };
174    let tls = match flags.tls {
175        Some(tls) => Some(tls),
176        None => env_value(env, BOLT_TLS_ENV)
177            .map(|text| parse_switch(BOLT_TLS_ENV, &text))
178            .transpose()?,
179    };
180    let tls = match tls {
181        Some(true) if !tls_configured => {
182            return Err(
183                "--bolt-tls needs --tls-cert and --tls-key: Bolt serves the HTTP certificate"
184                    .into(),
185            )
186        }
187        Some(explicit) => explicit,
188        // TLS when HTTP has it: plaintext Bolt beside TLS HTTP takes asking.
189        None => tls_configured,
190    };
191    let advertised_host = match &flags.advertised_host {
192        Some(host) => Some(host.clone()),
193        None => env_value(env, BOLT_ADVERTISED_HOST_ENV),
194    };
195    let advertised_port = match flags.advertised_port {
196        Some(port) => Some(port),
197        None => parse_env::<u16>(env, BOLT_ADVERTISED_PORT_ENV)?,
198    };
199    if advertised_port == Some(0) {
200        return Err("--bolt-advertised-port must be a port from 1 to 65535".into());
201    }
202    let max_connections = match flags.max_connections {
203        Some(n) => n,
204        None => parse_env::<usize>(env, BOLT_MAX_CONNECTIONS_ENV)?
205            .unwrap_or(bolt::server::DEFAULT_MAX_CONNECTIONS),
206    };
207    if max_connections == 0 {
208        return Err("--bolt-max-connections must be at least 1".into());
209    }
210    let settings = BoltSettings {
211        bind: SocketAddr::new(addr.ip(), port),
212        tls,
213        advertised_host,
214        advertised_port,
215        database: flags
216            .database
217            .clone()
218            .unwrap_or_else(|| DEFAULT_STORE_NAME.to_string()),
219        max_connections,
220        tx_timeout: flags
221            .tx_timeout
222            .unwrap_or(Duration::from_secs(DEFAULT_BOLT_TX_TIMEOUT_SECS)),
223        bookmark_timeout: flags
224            .bookmark_timeout
225            .unwrap_or(Duration::from_secs(DEFAULT_BOOKMARK_TIMEOUT_SECS)),
226    };
227    // Refuse an address ROUTE cannot advertise now, before the store opens.
228    settings.routing(settings.bind)?;
229    Ok(Some(settings))
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::collections::HashMap;
236
237    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
238        let map: HashMap<String, String> = pairs
239            .iter()
240            .map(|(k, v)| (k.to_string(), v.to_string()))
241            .collect();
242        move |name| map.get(name).cloned()
243    }
244
245    fn loopback() -> SocketAddr {
246        "127.0.0.1:8080".parse().unwrap()
247    }
248
249    fn with_port(port: u16) -> BoltFlags {
250        BoltFlags {
251            port: Some(port),
252            ..BoltFlags::default()
253        }
254    }
255
256    #[test]
257    fn no_port_means_no_listener() {
258        let none = resolve_bolt_settings(&BoltFlags::default(), &env(&[]), loopback(), false);
259        assert_eq!(none, Ok(None));
260        // An empty variable is unset.
261        let empty = resolve_bolt_settings(
262            &BoltFlags::default(),
263            &env(&[(BOLT_PORT_ENV, "")]),
264            loopback(),
265            false,
266        );
267        assert_eq!(empty, Ok(None));
268    }
269
270    #[test]
271    fn another_bolt_flag_without_a_port_is_refused() {
272        let flags = BoltFlags {
273            tls: Some(false),
274            ..BoltFlags::default()
275        };
276        let err = resolve_bolt_settings(&flags, &env(&[]), loopback(), false).unwrap_err();
277        assert!(err.contains("--bolt-port"), "{err}");
278    }
279
280    #[test]
281    fn the_port_comes_from_the_flag_or_the_environment_and_binds_the_http_ip() {
282        let from_flag = resolve_bolt_settings(&with_port(7687), &env(&[]), loopback(), false)
283            .unwrap()
284            .unwrap();
285        assert_eq!(
286            from_flag.bind,
287            "127.0.0.1:7687".parse::<SocketAddr>().unwrap()
288        );
289        assert!(!from_flag.tls);
290        assert_eq!(from_flag.database, "mushroomdb");
291        assert_eq!(
292            from_flag.max_connections,
293            bolt::server::DEFAULT_MAX_CONNECTIONS
294        );
295        assert_eq!(from_flag.tx_timeout, Duration::from_secs(30));
296        assert_eq!(from_flag.bookmark_timeout, Duration::from_secs(5));
297        let from_env = resolve_bolt_settings(
298            &BoltFlags::default(),
299            &env(&[(BOLT_PORT_ENV, "7699")]),
300            loopback(),
301            false,
302        )
303        .unwrap()
304        .unwrap();
305        assert_eq!(from_env.bind.port(), 7699);
306        // The flag wins over the variable.
307        let both = resolve_bolt_settings(
308            &with_port(7687),
309            &env(&[(BOLT_PORT_ENV, "7699")]),
310            loopback(),
311            false,
312        )
313        .unwrap()
314        .unwrap();
315        assert_eq!(both.bind.port(), 7687);
316        let bad = resolve_bolt_settings(
317            &BoltFlags::default(),
318            &env(&[(BOLT_PORT_ENV, "seventy")]),
319            loopback(),
320            false,
321        );
322        assert!(bad.unwrap_err().contains(BOLT_PORT_ENV));
323    }
324
325    #[test]
326    fn tls_follows_http_unless_plaintext_is_asked_for() {
327        let resolve = |flags: &BoltFlags, vars: &[(&str, &str)], tls_configured: bool| {
328            resolve_bolt_settings(flags, &env(vars), loopback(), tls_configured)
329        };
330        // HTTP has TLS: Bolt does too, by default.
331        assert!(resolve(&with_port(7687), &[], true).unwrap().unwrap().tls);
332        // Explicit plaintext beside TLS HTTP, by flag or variable.
333        let plaintext = BoltFlags {
334            tls: Some(false),
335            ..with_port(7687)
336        };
337        assert!(!resolve(&plaintext, &[], true).unwrap().unwrap().tls);
338        assert!(
339            !resolve(&with_port(7687), &[(BOLT_TLS_ENV, "off")], true)
340                .unwrap()
341                .unwrap()
342                .tls
343        );
344        // No HTTP TLS: plaintext, and asking for TLS is refused.
345        assert!(!resolve(&with_port(7687), &[], false).unwrap().unwrap().tls);
346        let tls = BoltFlags {
347            tls: Some(true),
348            ..with_port(7687)
349        };
350        assert!(resolve(&tls, &[], false)
351            .unwrap_err()
352            .contains("--tls-cert"));
353        assert!(resolve(&with_port(7687), &[(BOLT_TLS_ENV, "on")], false).is_err());
354        assert!(resolve(&with_port(7687), &[(BOLT_TLS_ENV, "maybe")], true)
355            .unwrap_err()
356            .contains(BOLT_TLS_ENV));
357        // The flag wins over the variable.
358        assert!(
359            resolve(&tls, &[(BOLT_TLS_ENV, "off")], true)
360                .unwrap()
361                .unwrap()
362                .tls
363        );
364    }
365
366    #[test]
367    fn a_wildcard_bind_needs_an_advertised_host() {
368        let wildcard: SocketAddr = "0.0.0.0:8080".parse().unwrap();
369        let err = resolve_bolt_settings(&with_port(7687), &env(&[]), wildcard, false).unwrap_err();
370        assert!(err.contains("--bolt-advertised-host"), "{err}");
371        let v6: SocketAddr = "[::]:8080".parse().unwrap();
372        assert!(resolve_bolt_settings(&with_port(7687), &env(&[]), v6, false).is_err());
373        // Given by flag or variable, it is advertised with the bind port...
374        let flags = BoltFlags {
375            advertised_host: Some("db.example.test".into()),
376            ..with_port(7687)
377        };
378        let settings = resolve_bolt_settings(&flags, &env(&[]), wildcard, false)
379            .unwrap()
380            .unwrap();
381        assert_eq!(
382            settings
383                .routing(settings.bind)
384                .unwrap()
385                .advertised_address(),
386            "db.example.test:7687"
387        );
388        let from_env = resolve_bolt_settings(
389            &with_port(7687),
390            &env(&[
391                (BOLT_ADVERTISED_HOST_ENV, "db.example.test"),
392                (BOLT_ADVERTISED_PORT_ENV, "17687"),
393            ]),
394            wildcard,
395            false,
396        )
397        .unwrap()
398        .unwrap();
399        // ...or with the advertised port, for a port-mapped container.
400        assert_eq!(
401            from_env
402                .routing(from_env.bind)
403                .unwrap()
404                .advertised_address(),
405            "db.example.test:17687"
406        );
407        // A wildcard is not an advertised host either.
408        let wild_host = BoltFlags {
409            advertised_host: Some("0.0.0.0".into()),
410            ..with_port(7687)
411        };
412        assert!(resolve_bolt_settings(&wild_host, &env(&[]), wildcard, false).is_err());
413    }
414
415    #[test]
416    fn a_loopback_bind_advertises_itself_and_port_zero_takes_the_bound_port() {
417        let settings = resolve_bolt_settings(&with_port(0), &env(&[]), loopback(), false)
418            .unwrap()
419            .unwrap();
420        let bound: SocketAddr = "127.0.0.1:54321".parse().unwrap();
421        assert_eq!(
422            settings.routing(bound).unwrap().advertised_address(),
423            "127.0.0.1:54321"
424        );
425    }
426
427    #[test]
428    fn limits_and_names_are_checked() {
429        let zero = BoltFlags {
430            max_connections: Some(0),
431            ..with_port(7687)
432        };
433        assert!(resolve_bolt_settings(&zero, &env(&[]), loopback(), false).is_err());
434        let from_env = resolve_bolt_settings(
435            &with_port(7687),
436            &env(&[(BOLT_MAX_CONNECTIONS_ENV, "3")]),
437            loopback(),
438            false,
439        )
440        .unwrap()
441        .unwrap();
442        assert_eq!(from_env.max_connections, 3);
443        let blank_name = BoltFlags {
444            database: Some("  ".into()),
445            ..with_port(7687)
446        };
447        assert!(resolve_bolt_settings(&blank_name, &env(&[]), loopback(), false).is_err());
448        let zero_port = BoltFlags {
449            advertised_port: Some(0),
450            ..with_port(7687)
451        };
452        assert!(resolve_bolt_settings(&zero_port, &env(&[]), loopback(), false).is_err());
453    }
454
455    #[test]
456    fn the_scheme_names_tls() {
457        let mut settings = resolve_bolt_settings(&with_port(7687), &env(&[]), loopback(), false)
458            .unwrap()
459            .unwrap();
460        assert_eq!(settings.scheme(), "bolt");
461        settings.tls = true;
462        assert_eq!(settings.scheme(), "bolt+s");
463    }
464}