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