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