Skip to main content

autoendpoint/
settings.rs

1//! Application settings
2use std::time::Duration;
3
4use actix_http::header::HeaderMap;
5use config::{Config, ConfigError, Environment, File};
6use fernet::{Fernet, MultiFernet};
7use serde::Deserialize;
8use serde_with::serde_as;
9use url::Url;
10
11use autopush_common::{MAX_NOTIFICATION_TTL_SECS, util};
12
13use crate::headers::vapid::VapidHeaderWithKey;
14use crate::routers::apns::settings::ApnsSettings;
15use crate::routers::fcm::settings::FcmSettings;
16#[cfg(feature = "stub")]
17use crate::routers::stub::settings::StubSettings;
18
19pub const ENV_PREFIX: &str = "autoend";
20
21#[serde_as]
22#[derive(Clone, Debug, Deserialize)]
23#[serde(default)]
24pub struct Settings {
25    /// Endpoint URL scheme
26    pub scheme: String,
27    /// Endpoint URL host
28    pub host: String,
29    /// Endpoint URL port
30    pub port: u16,
31    /// Endpoint URL. If this is set, it will override the `scheme`, `host`, and `port` settings.
32    pub endpoint_url: String,
33
34    /// The DSN to connect to the storage engine (Used to select between storage systems)
35    pub db_dsn: Option<String>,
36    /// JSON set of specific database settings (See data storage engines)
37    pub db_settings: String,
38
39    /// The router table name to use in the database (legacy, will be removed in the future)
40    pub router_table_name: String,
41    /// The message table name to use in the database (legacy, will be removed in the future)
42    pub message_table_name: String,
43
44    /// A stringified JSON list of VAPID public keys which should be tracked internally.
45    /// This should ONLY include Mozilla generated and consumed messages (e.g. "SendToTab", etc.)
46    /// These keys should be specified in stripped, b64encoded, X962 format (e.g. a single line of
47    /// base64 encoded data without padding).
48    /// You can use `scripts/convert_pem_to_x962.py` to easily convert EC Public keys stored in
49    /// PEM format into appropriate x962 format.
50    pub tracking_keys: String,
51
52    /// The max size of notification data in bytes.
53    pub max_data_bytes: usize,
54    /// The cryptographic keys to use to encode the endpoint URL. NOTE: this _must_ match the keys
55    /// specified for autoconnect.
56    pub crypto_keys: String,
57    /// The key to use to generate the client Auth token for channel management endpoints.
58    pub auth_keys: String,
59    /// Whether to include human readable logs in the output.
60    pub human_logs: bool,
61    /// Number of log records to buffer before dropping them. Records are dropped
62    /// when the buffer is full, and each drop emits an ERROR level overflow
63    /// report, so this should comfortably exceed the peak logging rate. 0 uses
64    /// the default.
65    pub log_chan_size: usize,
66
67    /// Bridge connection timeout in milliseconds.
68    pub connection_timeout_millis: u64,
69    /// Bridge request timeout in milliseconds.
70    pub request_timeout_millis: u64,
71    /// Maximum idle connections per host in the HTTP connection pool.
72    pub pool_max_idle_per_host: usize,
73    /// Idle connection timeout in seconds.
74    pub pool_idle_timeout_secs: u64,
75
76    /// The host for the statsd server to send metrics to. If None, metrics will not be sent.
77    pub statsd_host: Option<String>,
78    /// The port for the statsd server to send metrics to.
79    pub statsd_port: u16,
80    /// The label to use for statsd metrics.
81    pub statsd_label: String,
82
83    /// Do not report errors to sentry, instead log them to STDERR.
84    pub disable_sentry: bool,
85
86    /// FCM bridge settings
87    pub fcm: FcmSettings,
88    /// APNS bridge settings
89    pub apns: ApnsSettings,
90    #[cfg(feature = "stub")]
91    /// "Stub" is a predictable Mock bridge that allows us to "send" data and return an expected
92    /// result.
93    pub stub: StubSettings,
94    #[cfg(feature = "reliable_report")]
95    /// The DNS for the reliability data store. This is normally a Redis compatible
96    /// storage system. See [Connection Parameters](https://docs.rs/redis/latest/redis/#connection-parameters)
97    /// for details.
98    pub reliability_dsn: Option<String>,
99    #[cfg(feature = "reliable_report")]
100    /// Max number of retries reliability transactions into Redis
101    pub reliability_retry_count: usize,
102    /// Max Notification Lifespan
103    #[serde_as(as = "serde_with::DurationSeconds<u64>")]
104    pub max_notification_ttl: Duration,
105    /// Path to read kubernetes internal memory information.
106    pub kubernetes_memory_path: Option<String>,
107}
108// Did you update the documentation in `docs/src/config_options.md`?
109
110impl Default for Settings {
111    fn default() -> Settings {
112        Settings {
113            scheme: "http".to_string(),
114            host: "127.0.0.1".to_string(),
115            endpoint_url: "".to_string(),
116            port: 8000,
117            db_dsn: None,
118            db_settings: "".to_owned(),
119            router_table_name: "router".to_string(),
120            message_table_name: "message".to_string(),
121            // max data is a bit hard to figure out, due to encryption. Using something
122            // like pywebpush, if you encode a block of 4096 bytes, you'll get a
123            // 4216 byte data block. Since we're going to be receiving this, we have to
124            // presume base64 encoding, so we can bump things up to 5630 bytes max.
125            max_data_bytes: 5630,
126            crypto_keys: format!("[{}]", Fernet::generate_key()),
127            auth_keys: r#"[]"#.to_string(),
128            tracking_keys: r#"[]"#.to_string(),
129            human_logs: false,
130            log_chan_size: autopush_common::logging::DEFAULT_LOG_CHAN_SIZE,
131            connection_timeout_millis: 1000,
132            request_timeout_millis: 3000,
133            pool_max_idle_per_host: 10,
134            pool_idle_timeout_secs: 30,
135            statsd_host: None,
136            statsd_port: 8125,
137            statsd_label: "autoendpoint".to_string(),
138            fcm: FcmSettings::default(),
139            apns: ApnsSettings::default(),
140            #[cfg(feature = "stub")]
141            stub: StubSettings::default(),
142            #[cfg(feature = "reliable_report")]
143            reliability_dsn: None,
144            #[cfg(feature = "reliable_report")]
145            reliability_retry_count: autopush_common::redis_util::MAX_TRANSACTION_LOOP,
146            max_notification_ttl: Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
147            disable_sentry: false,
148            // From empirical observation, kubernetes stores this in the main
149            // cgroup. Other docs say that this should be in the "memory" subdir.
150            // Going with what I can see for now.
151            kubernetes_memory_path: None,
152        }
153    }
154}
155
156impl Settings {
157    /// Load the settings from the config file if supplied, then the environment.
158    pub fn with_env_and_config_file(filename: &Option<String>) -> Result<Self, ConfigError> {
159        let mut config = Config::builder();
160
161        // Merge the config file if supplied
162        if let Some(config_filename) = filename {
163            config = config.add_source(File::with_name(config_filename));
164        }
165
166        // Merge the environment overrides
167        // Note: Specify the separator here so that the shell can properly pass args
168        // down to the sub structures.
169        config = config.add_source(Environment::with_prefix(ENV_PREFIX).separator("__"));
170
171        let built: Self = config.build()?.try_deserialize::<Self>().map_err(|error| {
172            match error {
173                // Configuration errors are not very sysop friendly, Try to make them
174                // a bit more 3AM useful.
175                ConfigError::Message(error_msg) => {
176                    println!("Bad configuration: {:?}", &error_msg);
177                    println!("Please set in config file or use environment variable.");
178                    println!(
179                        "For example to set `database_url` use env var `{}_DATABASE_URL`\n",
180                        ENV_PREFIX.to_uppercase()
181                    );
182                    error!("Configuration error: Value undefined {:?}", &error_msg);
183                    ConfigError::NotFound(error_msg)
184                }
185                _ => {
186                    error!("Configuration error: Other: {:?}", &error);
187                    error
188                }
189            }
190        })?;
191        // Reject empty or missing auth_keys; may pass deserialization
192        // so have to check here
193        built.validate_auth_keys()?;
194        Ok(built)
195    }
196
197    /// Convert a string like `[item1,item2]` into a iterator over `item1` and `item2`.
198    /// Panics with a custom message if the string is not in the expected form.
199    fn read_list_from_str<'list>(
200        list_str: &'list str,
201        panic_msg: &'static str,
202    ) -> impl Iterator<Item = &'list str> {
203        if !(list_str.starts_with('[') && list_str.ends_with(']')) {
204            panic!("{}", panic_msg);
205        }
206
207        let items = &list_str[1..list_str.len() - 1];
208        items.split(',')
209    }
210
211    /// Initialize the fernet encryption instance
212    pub fn make_fernet(&self) -> MultiFernet {
213        let keys = &self.crypto_keys.replace(['"', ' '], "");
214        let fernets = Self::read_list_from_str(keys, "Invalid AUTOEND_CRYPTO_KEYS")
215            .map(|key| {
216                debug!("🔐 Fernet keys: {:?}", &key);
217                Fernet::new(key).expect("Invalid AUTOEND_CRYPTO_KEYS")
218            })
219            .collect();
220        MultiFernet::new(fernets)
221    }
222
223    /// Get the list of auth hash keys
224    pub fn auth_keys(&self) -> Vec<String> {
225        let keys = &self.auth_keys.replace(['"', ' '], "");
226        Self::read_list_from_str(keys, "Invalid AUTOEND_AUTH_KEYS")
227            .map(|v| v.to_owned())
228            .collect()
229    }
230    /// Validate at least one usable (non-empty) auth key is configured.
231    fn validate_auth_keys(&self) -> Result<(), ConfigError> {
232        if self.auth_keys().iter().all(|key| key.is_empty()) {
233            return Err(ConfigError::Message(
234                "AUTOEND__AUTH_KEYS must contain at least one non-empty key".to_owned(),
235            ));
236        }
237        Ok(())
238    }
239
240    /// Get the list of tracking public keys converted to raw, x962 format byte arrays.
241    /// (This avoids problems with formatting, padding, and other concerns. x962 precedes the
242    /// EC key pair with a `\04` byte. We'll keep that value in place for now, since the value we
243    /// are comparing against will also have the same prefix.)
244    pub fn tracking_keys(&self) -> Result<Vec<Vec<u8>>, ConfigError> {
245        let keys = &self.tracking_keys.replace(['"', ' '], "");
246        // I'm sure there's a more clever way to do this. I don't care. I want simple.
247        let mut result = Vec::new();
248        for v in Self::read_list_from_str(keys, "Invalid AUTOEND_TRACKING_KEYS") {
249            result.push(
250                util::b64_decode(v)
251                    .map_err(|e| ConfigError::Message(format!("Invalid tracking key: {e:?}")))?,
252            );
253        }
254        trace!("🔍 tracking_keys: {result:?}");
255        Ok(result)
256    }
257
258    /// Get the URL for this endpoint server
259    pub fn endpoint_url(&self) -> Url {
260        let endpoint = if self.endpoint_url.is_empty() {
261            format!("{}://{}:{}", self.scheme, self.host, self.port)
262        } else {
263            self.endpoint_url.clone()
264        };
265        Url::parse(&endpoint).expect("Invalid endpoint URL")
266    }
267}
268
269#[derive(Clone, Debug)]
270pub struct VapidTracker(pub Vec<Vec<u8>>);
271impl VapidTracker {
272    /// Very simple string check to see if the Public Key specified in the Vapid header
273    /// matches the set of trackable keys.
274    pub fn is_trackable(&self, vapid: &VapidHeaderWithKey) -> bool {
275        // ideally, [Settings.with_env_and_config_file()] does the work of pre-populating
276        // the Settings.tracking_vapid_pubs cache, but we can't rely on that.
277
278        let key = match util::b64_decode(&vapid.public_key) {
279            Ok(v) => v,
280            Err(e) => {
281                // This error is not fatal, and should not happen often. During preliminary
282                // runs, however, we do want to try and spot them.
283                warn!("🔍 VAPID: tracker failure {e}");
284                return false;
285            }
286        };
287        let result = self.0.contains(&key);
288
289        debug!("🔍 Checking {:?} {}", &vapid.public_key, {
290            if result { "Match!" } else { "no match" }
291        });
292        result
293    }
294
295    /// Extract the message Id from the headers (if present), otherwise just make one up.
296    pub fn get_id(&self, headers: &HeaderMap) -> String {
297        headers
298            .get("X-MessageId")
299            .and_then(|v|
300                // TODO: we should convert the public key string to a bitarray
301                // this would prevent any formatting errors from falsely rejecting
302                // the key. We're ok with comparing strings because we currently
303                // have access to the same public key value string that is being
304                // used, but that may not always be the case.
305                v.to_str().ok())
306            .map(|v| v.to_owned())
307            .unwrap_or_else(|| uuid::Uuid::new_v4().as_simple().to_string())
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use actix_http::header::{HeaderMap, HeaderName, HeaderValue};
314
315    use super::{Settings, VapidTracker};
316    use crate::{
317        error::ApiResult,
318        headers::vapid::{VapidHeader, VapidHeaderWithKey},
319    };
320
321    #[test]
322    fn test_auth_keys() -> ApiResult<()> {
323        let success: Vec<String> = vec![
324            "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB=".to_owned(),
325            "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC=".to_owned(),
326        ];
327        // Try with quoted strings
328        let settings = Settings{
329            auth_keys: r#"["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB=", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC="]"#.to_owned(),
330            ..Default::default()
331        };
332        let result = settings.auth_keys();
333        assert_eq!(result, success);
334
335        // try with unquoted, non-JSON compliant strings.
336        let settings = Settings{
337            auth_keys: r#"[AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB=,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC=]"#.to_owned(),
338            ..Default::default()
339        };
340        let result = settings.auth_keys();
341        assert_eq!(result, success);
342        Ok(())
343    }
344    #[test]
345    fn test_auth_keys_rejects() -> ApiResult<()> {
346        // Unset *(rejects default)
347        assert!(Settings::default().validate_auth_keys().is_err());
348        // Empty array
349        let settings = Settings {
350            auth_keys: r#"[]"#.to_owned(),
351            ..Default::default()
352        };
353        assert!(settings.validate_auth_keys().is_err());
354        // Only empty strings
355        let settings = Settings {
356            auth_keys: r#"["", ""]"#.to_owned(),
357            ..Default::default()
358        };
359        assert!(settings.validate_auth_keys().is_err());
360        // A non-empty key passes
361        let settings = Settings {
362            auth_keys: r#"["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB="]"#.to_owned(),
363            ..Default::default()
364        };
365        assert!(settings.validate_auth_keys().is_ok());
366        Ok(())
367    }
368
369    #[test]
370    fn test_endpoint_url() -> ApiResult<()> {
371        let example = "https://example.org/";
372        let settings = Settings {
373            endpoint_url: example.to_owned(),
374            ..Default::default()
375        };
376
377        assert_eq!(settings.endpoint_url(), url::Url::parse(example).unwrap());
378        let settings = Settings {
379            ..Default::default()
380        };
381
382        assert_eq!(
383            settings.endpoint_url(),
384            url::Url::parse(&format!(
385                "{}://{}:{}",
386                settings.scheme, settings.host, settings.port
387            ))
388            .unwrap()
389        );
390        Ok(())
391    }
392
393    /*
394    // The following test is commented out due to the recent change in rust that makes `env::set_var` unsafe
395    #cfg[all(test, feature="unsafe")]
396    #[test]
397    fn test_default_settings() {
398        // Test that the Config works the way we expect it to.
399        let port = format!("{}__PORT", super::ENV_PREFIX).to_uppercase();
400        let timeout = format!("{}__FCM__TIMEOUT", super::ENV_PREFIX).to_uppercase();
401
402        use std::env;
403        let v1 = env::var(&port);
404        let v2 = env::var(&timeout);
405        // TODO: Audit that the environment access only happens in single-threaded code.
406        unsafe { env::set_var(&port, "9123") };
407        // TODO: Audit that the environment access only happens in single-threaded code.
408        unsafe { env::set_var(&timeout, "123") };
409
410        let settings = Settings::with_env_and_config_file(&None).unwrap();
411        assert_eq!(&settings.port, &9123);
412        assert_eq!(&settings.fcm.timeout, &123);
413        assert_eq!(settings.host, "127.0.0.1".to_owned());
414        // reset (just in case)
415        if let Ok(p) = v1 {
416            trace!("Resetting {}", &port);
417            // TODO: Audit that the environment access only happens in single-threaded code.
418            unsafe { env::set_var(&port, p) };
419        } else {
420            // TODO: Audit that the environment access only happens in single-threaded code.
421            unsafe { env::remove_var(&port) };
422        }
423        if let Ok(p) = v2 {
424            trace!("Resetting {}", &timeout);
425            // TODO: Audit that the environment access only happens in single-threaded code.
426            unsafe { env::set_var(&timeout, p) };
427        } else {
428            // TODO: Audit that the environment access only happens in single-threaded code.
429            unsafe { env::remove_var(&timeout) };
430        }
431    }
432    // */
433
434    #[test]
435    fn test_tracking_keys() -> ApiResult<()> {
436        // Handle the case where the settings may use Standard encoding instead of Base64 encoding.
437        let settings = Settings{
438            tracking_keys: r#"["BLMymkOqvT6OZ1o9etCqV4jGPkvOXNz5FdBjsAR9zR5oeCV1x5CBKuSLTlHon+H/boHTzMtMoNHsAGDlDB6X"]"#.to_owned(),
439            ..Default::default()
440        };
441
442        let test_header = VapidHeaderWithKey {
443            vapid: VapidHeader {
444                scheme: "".to_owned(),
445                token: "".to_owned(),
446                version_data: crate::headers::vapid::VapidVersionData::Version1,
447            },
448            public_key: "BLMymkOqvT6OZ1o9etCqV4jGPkvOXNz5FdBjsAR9zR5oeCV1x5CBKuSLTlHon-H_boHTzMtMoNHsAGDlDB6X==".to_owned()
449        };
450
451        let key_set = settings.tracking_keys().unwrap();
452        assert!(!key_set.is_empty());
453
454        let reliability = VapidTracker(key_set);
455        assert!(reliability.is_trackable(&test_header));
456
457        Ok(())
458    }
459
460    #[test]
461    fn test_multi_tracking_keys() -> ApiResult<()> {
462        // Handle the case where the settings may use Standard encoding instead of Base64 encoding.
463        let settings = Settings{
464            tracking_keys: r#"[BLbZTvXsQr0rdvLQr73ETRcseSpoof5xV83NiPK9U-Qi00DjNJct1N6EZtTBMD0uh-nNjtLAxik1XP9CZXrKtTg,BHDgfiL1hz4oIBFaxxS9jkzyAVing-W9jjt_7WUeFjWS5Invalid5EjC8TQKddJNP3iow7UW6u8JE3t7u_y3Plc]"#.to_owned(),
465            ..Default::default()
466        };
467
468        let test_header = VapidHeaderWithKey {
469            vapid: VapidHeader {
470                scheme: "".to_owned(),
471                token: "".to_owned(),
472                version_data: crate::headers::vapid::VapidVersionData::Version1,
473            },
474            public_key: "BLbZTvXsQr0rdvLQr73ETRcseSpoof5xV83NiPK9U-Qi00DjNJct1N6EZtTBMD0uh-nNjtLAxik1XP9CZXrKtTg".to_owned()
475        };
476
477        let key_set = settings.tracking_keys().unwrap();
478        assert!(!key_set.is_empty());
479
480        let reliability = VapidTracker(key_set);
481        assert!(reliability.is_trackable(&test_header));
482
483        Ok(())
484    }
485
486    #[test]
487    fn test_reliability_id() -> ApiResult<()> {
488        let mut headers = HeaderMap::new();
489        let keys = Vec::new();
490        let reliability = VapidTracker(keys);
491
492        let key = reliability.get_id(&headers);
493        assert!(!key.is_empty());
494
495        headers.insert(
496            HeaderName::from_lowercase(b"x-messageid").unwrap(),
497            HeaderValue::from_static("123foobar456"),
498        );
499
500        let key = reliability.get_id(&headers);
501        assert_eq!(key, "123foobar456".to_owned());
502
503        Ok(())
504    }
505}