cat_gateway/settings/
mod.rs

1//! Command line and environment variable settings for the service
2use std::{
3    net::{IpAddr, Ipv4Addr, SocketAddr},
4    str::FromStr,
5    sync::{LazyLock, OnceLock},
6    time::Duration,
7};
8
9use anyhow::anyhow;
10use cardano_chain_follower::{Network, Slot};
11use clap::Args;
12use dotenvy::dotenv;
13use str_env_var::StringEnvVar;
14use tracing::error;
15use url::Url;
16
17use crate::{
18    build_info::{log_build_info, BUILD_INFO},
19    logger::{self, LogLevel, LOG_LEVEL_DEFAULT},
20    service::utilities::net::{get_public_ipv4, get_public_ipv6},
21    utils::blake2b_hash::generate_uuid_string_from_data,
22};
23
24pub(crate) mod cardano_assets_cache;
25pub(crate) mod cassandra_db;
26pub(crate) mod chain_follower;
27pub(crate) mod event_db;
28pub(crate) mod rbac;
29pub(crate) mod signed_doc;
30mod str_env_var;
31
32/// Default address to start service on, '0.0.0.0:3030'.
33const ADDRESS_DEFAULT: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 3030);
34
35/// Default Github repo owner
36const GITHUB_REPO_OWNER_DEFAULT: &str = "input-output-hk";
37
38/// Default Github repo name
39const GITHUB_REPO_NAME_DEFAULT: &str = "catalyst-voices";
40
41/// Default Github issue template to use
42const GITHUB_ISSUE_TEMPLATE_DEFAULT: &str = "bug_report.yml";
43
44/// Default `CLIENT_ID_KEY` used in development.
45const CLIENT_ID_KEY_DEFAULT: &str = "3db5301e-40f2-47ed-ab11-55b37674631a";
46
47/// Default `API_URL_PREFIX` used in development.
48const API_URL_PREFIX_DEFAULT: &str = "/api";
49
50/// Default `CHECK_CONFIG_TICK` used in development, 5 seconds.
51const CHECK_CONFIG_TICK_DEFAULT: Duration = Duration::from_secs(5);
52
53/// Default number of slots used as overlap when purging Live Index data.
54const PURGE_BACKWARD_SLOT_BUFFER_DEFAULT: u64 = 100;
55
56/// Default `SERVICE_LIVE_TIMEOUT_INTERVAL`, that is used to determine if the service is
57/// live, 30 seconds.
58const SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT: Duration = Duration::from_secs(30);
59
60/// Default `SERVICE_LIVE_COUNTER_THRESHOLD`, that is used to determine if the service is
61/// live.
62const SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT: u64 = 100;
63
64/// Hash the Public IPv4 and IPv6 address of the machine, and convert to a 128 bit V4
65/// UUID.
66fn calculate_service_uuid() -> String {
67    let ip_addr: Vec<String> = vec![get_public_ipv4().to_string(), get_public_ipv6().to_string()];
68
69    generate_uuid_string_from_data("Catalyst-Gateway-Machine-UID", &ip_addr)
70}
71
72/// Settings for the application.
73///
74/// This struct represents the configuration settings for the application.
75/// It is used to specify the server binding address,
76/// the URL to the `PostgreSQL` event database,
77/// and the logging level.
78#[derive(Args, Clone)]
79#[clap(version = BUILD_INFO)]
80pub(crate) struct ServiceSettings {
81    /// Logging level
82    #[clap(long, default_value = LOG_LEVEL_DEFAULT)]
83    pub(crate) log_level: LogLevel,
84}
85
86/// All the `EnvVars` used by the service.
87struct EnvVars {
88    /// The github repo owner
89    github_repo_owner: StringEnvVar,
90
91    /// The github repo name
92    github_repo_name: StringEnvVar,
93
94    /// The github issue template to use
95    github_issue_template: StringEnvVar,
96
97    /// Server binding address
98    address: SocketAddr,
99
100    /// Server name
101    server_name: Option<StringEnvVar>,
102
103    /// Id of the Service.
104    service_id: StringEnvVar,
105
106    /// The client id key used to anonymize client connections.
107    client_id_key: StringEnvVar,
108
109    /// A List of servers to provide
110    api_host_names: Vec<String>,
111
112    /// The base path the API is served at.
113    api_url_prefix: StringEnvVar,
114
115    /// Flag by enabling `/panic` endpoint if its set
116    /// Enabled is `YES_I_REALLY_WANT_TO_PANIC` env var is set
117    /// and equals to `"panic attack"`
118    is_panic_endpoint_enabled: bool,
119
120    /// The Config of the Persistent Cassandra DB.
121    cassandra_persistent_db: cassandra_db::EnvVars,
122
123    /// The Config of the Volatile Cassandra DB.
124    cassandra_volatile_db: cassandra_db::EnvVars,
125
126    /// The Chain Follower configuration
127    chain_follower: chain_follower::EnvVars,
128
129    /// The event db configuration
130    event_db: event_db::EnvVars,
131
132    /// The Catalyst Signed Documents configuration
133    signed_doc: signed_doc::EnvVars,
134
135    /// RBAC configuration.
136    rbac: rbac::EnvVars,
137
138    /// The Cardano assets caches configuration
139    cardano_assets_cache: cardano_assets_cache::EnvVars,
140
141    /// Internal API Access API Key
142    internal_api_key: Option<StringEnvVar>,
143
144    /// Tick every N seconds until config exists in db
145    #[allow(unused)]
146    check_config_tick: Duration,
147
148    /// Slot buffer used as overlap when purging Live Index data.
149    purge_backward_slot_buffer: u64,
150
151    /// Interval for determining if the service is live.
152    service_live_timeout_interval: Duration,
153
154    /// Threshold for determining if the service is live.
155    service_live_counter_threshold: u64,
156
157    /// Set to Log 404 not found.
158    log_not_found: Option<StringEnvVar>,
159}
160
161// Lazy initialization of all env vars which are not command line parameters.
162// All env vars used by the application should be listed here and all should have a
163// default. The default for all NON Secret values should be suitable for Production, and
164// NOT development. Secrets however should only be used with the default value in
165// development
166
167/// Handle to the mithril sync thread. One for each Network ONLY.
168static ENV_VARS: LazyLock<EnvVars> = LazyLock::new(|| {
169    // Support env vars in a `.env` file,  doesn't need to exist.
170    dotenv().ok();
171
172    let address = StringEnvVar::new("ADDRESS", ADDRESS_DEFAULT.to_string().into());
173    let address = SocketAddr::from_str(address.as_str())
174        .inspect_err(|err| {
175            error!(
176                error = ?err,
177                default_addr = ?ADDRESS_DEFAULT,
178                invalid_addr = ?address,
179                "Invalid binding address. Using default binding address value.",
180            );
181        })
182        .unwrap_or(ADDRESS_DEFAULT);
183
184    let purge_backward_slot_buffer = StringEnvVar::new_as_int(
185        "PURGE_BACKWARD_SLOT_BUFFER",
186        PURGE_BACKWARD_SLOT_BUFFER_DEFAULT,
187        0,
188        u64::MAX,
189    );
190
191    EnvVars {
192        github_repo_owner: StringEnvVar::new("GITHUB_REPO_OWNER", GITHUB_REPO_OWNER_DEFAULT.into()),
193        github_repo_name: StringEnvVar::new("GITHUB_REPO_NAME", GITHUB_REPO_NAME_DEFAULT.into()),
194        github_issue_template: StringEnvVar::new(
195            "GITHUB_ISSUE_TEMPLATE",
196            GITHUB_ISSUE_TEMPLATE_DEFAULT.into(),
197        ),
198        address,
199        server_name: StringEnvVar::new_optional("SERVER_NAME", false),
200        service_id: StringEnvVar::new("SERVICE_ID", calculate_service_uuid().into()),
201        client_id_key: StringEnvVar::new("CLIENT_ID_KEY", CLIENT_ID_KEY_DEFAULT.into()),
202        api_host_names: string_to_api_host_names(
203            &StringEnvVar::new_optional("c", false)
204                .map(|v| v.as_string())
205                .unwrap_or_default(),
206        ),
207        api_url_prefix: StringEnvVar::new("API_URL_PREFIX", API_URL_PREFIX_DEFAULT.into()),
208        is_panic_endpoint_enabled: StringEnvVar::new_optional("YES_I_REALLY_WANT_TO_PANIC", false)
209            .is_some_and(|v| v.as_str() == "panic attack"),
210
211        cassandra_persistent_db: cassandra_db::EnvVars::new(
212            cassandra_db::PERSISTENT_URL_DEFAULT,
213            cassandra_db::PERSISTENT_NAMESPACE_DEFAULT,
214        ),
215        cassandra_volatile_db: cassandra_db::EnvVars::new(
216            cassandra_db::VOLATILE_URL_DEFAULT,
217            cassandra_db::VOLATILE_NAMESPACE_DEFAULT,
218        ),
219        chain_follower: chain_follower::EnvVars::new(),
220        event_db: event_db::EnvVars::new(),
221        signed_doc: signed_doc::EnvVars::new(),
222        rbac: rbac::EnvVars::new(),
223        cardano_assets_cache: cardano_assets_cache::EnvVars::new(),
224        internal_api_key: StringEnvVar::new_optional("INTERNAL_API_KEY", true),
225        check_config_tick: StringEnvVar::new_as_duration(
226            "CHECK_CONFIG_TICK",
227            CHECK_CONFIG_TICK_DEFAULT,
228        ),
229        purge_backward_slot_buffer,
230        service_live_timeout_interval: StringEnvVar::new_as_duration(
231            "SERVICE_LIVE_TIMEOUT_INTERVAL",
232            SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT,
233        ),
234        service_live_counter_threshold: StringEnvVar::new_as_int(
235            "SERVICE_LIVE_COUNTER_THRESHOLD",
236            SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT,
237            0,
238            u64::MAX,
239        ),
240        log_not_found: StringEnvVar::new_optional("LOG_NOT_FOUND", false),
241    }
242});
243
244impl EnvVars {
245    /// Validate env vars in ways we couldn't when they were first loaded.
246    pub(crate) fn validate() -> anyhow::Result<()> {
247        let mut status = Ok(());
248
249        let url = ENV_VARS.event_db.url.as_str();
250        if let Err(error) = tokio_postgres::config::Config::from_str(url) {
251            error!(error=%error, url=url, "Invalid Postgres DB URL.");
252            status = Err(anyhow!("Environment Variable Validation Error."));
253        }
254
255        status
256    }
257}
258
259/// All Settings/Options for the Service.
260static SERVICE_SETTINGS: OnceLock<ServiceSettings> = OnceLock::new();
261
262/// Our Global Settings for this running service.
263pub(crate) struct Settings();
264
265impl Settings {
266    /// Initialize the settings data.
267    pub(crate) fn init(settings: ServiceSettings) -> anyhow::Result<()> {
268        let log_level = settings.log_level;
269
270        if SERVICE_SETTINGS.set(settings).is_err() {
271            // We use println here, because logger not yet configured.
272            println!("Failed to initialize service settings. Called multiple times?");
273        }
274
275        // Init the logger.
276        logger::init(log_level);
277
278        log_build_info();
279
280        // Validate any settings we couldn't validate when loaded.
281        EnvVars::validate()
282    }
283
284    /// Get the current Event DB settings for this service.
285    pub(crate) fn event_db_settings() -> (
286        &'static str,
287        Option<&'static str>,
288        Option<&'static str>,
289        u32,
290        u32,
291        u32,
292        u32,
293    ) {
294        let url = ENV_VARS.event_db.url.as_str();
295        let user = ENV_VARS
296            .event_db
297            .username
298            .as_ref()
299            .map(StringEnvVar::as_str);
300        let pass = ENV_VARS
301            .event_db
302            .password
303            .as_ref()
304            .map(StringEnvVar::as_str);
305
306        let max_connections = ENV_VARS.event_db.max_connections;
307
308        let max_lifetime = ENV_VARS.event_db.max_lifetime;
309
310        let min_idle = ENV_VARS.event_db.min_idle;
311
312        let connection_timeout = ENV_VARS.event_db.connection_timeout;
313
314        (
315            url,
316            user,
317            pass,
318            max_connections,
319            max_lifetime,
320            min_idle,
321            connection_timeout,
322        )
323    }
324
325    /// Get the Persistent & Volatile Cassandra DB config for this service.
326    pub(crate) fn cassandra_db_cfg() -> (cassandra_db::EnvVars, cassandra_db::EnvVars) {
327        (
328            ENV_VARS.cassandra_persistent_db.clone(),
329            ENV_VARS.cassandra_volatile_db.clone(),
330        )
331    }
332
333    /// Get the configuration of the chain follower.
334    pub(crate) fn follower_cfg() -> chain_follower::EnvVars {
335        ENV_VARS.chain_follower.clone()
336    }
337
338    /// Get the configuration of the Catalyst Signed Documents.
339    pub(crate) fn signed_doc_cfg() -> signed_doc::EnvVars {
340        ENV_VARS.signed_doc.clone()
341    }
342
343    /// Returns the RBAC configuration.
344    pub fn rbac_cfg() -> &'static rbac::EnvVars {
345        &ENV_VARS.rbac
346    }
347
348    /// Get the configuration of the Cardano assets cache.
349    pub(crate) fn cardano_assets_cache() -> cardano_assets_cache::EnvVars {
350        ENV_VARS.cardano_assets_cache.clone()
351    }
352
353    /// Chain Follower network (The Blockchain network we are configured to use).
354    /// Note: Catalyst Gateway can ONLY follow one network at a time.
355    pub(crate) fn cardano_network() -> Network {
356        ENV_VARS.chain_follower.chain
357    }
358
359    /// The API Url prefix
360    pub(crate) fn api_url_prefix() -> &'static str {
361        ENV_VARS.api_url_prefix.as_str()
362    }
363
364    /// The Key used to anonymize client connections in the logs.
365    pub(crate) fn client_id_key() -> &'static str {
366        ENV_VARS.client_id_key.as_str()
367    }
368
369    /// The Service UUID
370    pub(crate) fn service_id() -> &'static str {
371        ENV_VARS.service_id.as_str()
372    }
373
374    /// Get a list of all host names to serve the API on.
375    ///
376    /// Used by the `OpenAPI` Documentation to point to the correct backend.
377    /// Take a list of [scheme://] + host names from the env var and turns it into
378    /// a lits of strings.
379    ///
380    /// Host names are taken from the `API_HOST_NAMES` environment variable.
381    /// If that is not set, returns an empty list.
382    pub(crate) fn api_host_names() -> &'static [String] {
383        &ENV_VARS.api_host_names
384    }
385
386    /// The socket address we are bound to.
387    pub(crate) fn bound_address() -> SocketAddr {
388        ENV_VARS.address
389    }
390
391    /// Get the server name to be used in the `Server` object of the `OpenAPI` Document.
392    pub(crate) fn server_name() -> Option<&'static str> {
393        ENV_VARS.server_name.as_ref().map(StringEnvVar::as_str)
394    }
395
396    /// Get the flag is the `/panic` should be enabled or not
397    pub(crate) fn is_panic_endpoint_enabled() -> bool {
398        ENV_VARS.is_panic_endpoint_enabled
399    }
400
401    /// Generate a github issue url with a given title
402    ///
403    /// ## Arguments
404    ///
405    /// * `title`: &str - the title to give the issue
406    ///
407    /// ## Returns
408    ///
409    /// * String - the url
410    ///
411    /// ## Example
412    ///
413    /// ```rust,no_run
414    /// # use cat_data_service::settings::generate_github_issue_url;
415    /// assert_eq!(
416    ///     generate_github_issue_url("Hello, World! How are you?"),
417    ///     "https://github.com/input-output-hk/catalyst-voices/issues/new?template=bug_report.yml&title=Hello%2C%20World%21%20How%20are%20you%3F"
418    /// );
419    /// ```
420    pub(crate) fn generate_github_issue_url(title: &str) -> Option<Url> {
421        let path = format!(
422            "https://github.com/{}/{}/issues/new",
423            ENV_VARS.github_repo_owner.as_str(),
424            ENV_VARS.github_repo_name.as_str()
425        );
426
427        match Url::parse_with_params(&path, &[
428            ("template", ENV_VARS.github_issue_template.as_str()),
429            ("title", title),
430        ]) {
431            Ok(url) => Some(url),
432            Err(e) => {
433                error!("Failed to generate github issue url {:?}", e.to_string());
434                None
435            },
436        }
437    }
438
439    /// Check a given key matches the internal API Key
440    pub(crate) fn check_internal_api_key(value: &str) -> bool {
441        if let Some(required_key) = ENV_VARS.internal_api_key.as_ref().map(StringEnvVar::as_str) {
442            value == required_key
443        } else {
444            false
445        }
446    }
447
448    /// Slot buffer used as overlap when purging Live Index data.
449    pub(crate) fn purge_backward_slot_buffer() -> Slot {
450        ENV_VARS.purge_backward_slot_buffer.into()
451    }
452
453    /// Duration in seconds used to determine if the system is live during checks.
454    pub(crate) fn service_live_timeout_interval() -> Duration {
455        ENV_VARS.service_live_timeout_interval
456    }
457
458    /// Value after which the service is considered NOT live.
459    pub(crate) fn service_live_counter_threshold() -> u64 {
460        ENV_VARS.service_live_counter_threshold
461    }
462
463    /// If set log the 404 not found, else do not log.
464    pub(crate) fn log_not_found() -> bool {
465        ENV_VARS.log_not_found.is_some()
466    }
467}
468
469/// Transform a string list of host names into a vec of host names.
470fn string_to_api_host_names(hosts: &str) -> Vec<String> {
471    /// Log an invalid hostname.
472    fn invalid_hostname(hostname: &str) -> String {
473        error!(hostname = hostname, "Invalid host name for API");
474        String::new()
475    }
476
477    let configured_hosts: Vec<String> = hosts
478        .split(',')
479        // filters out at the beginning all empty entries, because they all would be invalid and
480        // filtered out anyway
481        .filter(|s| !s.is_empty())
482        .map(|s| {
483            let url = Url::parse(s.trim());
484            match url {
485                Ok(url) => {
486                    // Get the scheme, and if its empty, use http
487                    let scheme = url.scheme();
488
489                    let port = url.port();
490
491                    // Rebuild the scheme + hostname
492                    match url.host() {
493                        Some(host) => {
494                            let host = host.to_string();
495                            if host.is_empty() {
496                                invalid_hostname(s)
497                            } else {
498                                match port {
499                                    Some(port) => {
500                                        format! {"{scheme}://{host}:{port}"}
501                                        // scheme.to_owned() + "://" + &host + ":" +
502                                        // &port.to_string()
503                                    },
504                                    None => {
505                                        format! {"{scheme}://{host}"}
506                                    },
507                                }
508                            }
509                        },
510                        None => invalid_hostname(s),
511                    }
512                },
513                Err(_) => invalid_hostname(s),
514            }
515        })
516        .filter(|s| !s.is_empty())
517        .collect();
518
519    configured_hosts
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn generate_github_issue_url_test() {
528        let title = "Hello, World! How are you?";
529        assert_eq!(
530            Settings::generate_github_issue_url(title).expect("Failed to generate url").as_str(),
531            "https://github.com/input-output-hk/catalyst-voices/issues/new?template=bug_report.yml&title=Hello%2C+World%21+How+are+you%3F"
532        );
533    }
534
535    #[test]
536    fn configured_hosts_default() {
537        let configured_hosts = Settings::api_host_names();
538        assert!(configured_hosts.is_empty());
539    }
540
541    #[test]
542    fn configured_hosts_set_multiple() {
543        let configured_hosts = string_to_api_host_names(
544            "http://api.prod.projectcatalyst.io , https://api.dev.projectcatalyst.io:1234",
545        );
546        assert_eq!(configured_hosts, vec![
547            "http://api.prod.projectcatalyst.io",
548            "https://api.dev.projectcatalyst.io:1234"
549        ]);
550    }
551
552    #[test]
553    fn configured_hosts_set_multiple_one_invalid() {
554        let configured_hosts =
555            string_to_api_host_names("not a hostname , https://api.dev.projectcatalyst.io:1234");
556        assert_eq!(configured_hosts, vec![
557            "https://api.dev.projectcatalyst.io:1234"
558        ]);
559    }
560
561    #[test]
562    fn configured_hosts_set_empty() {
563        let configured_hosts = string_to_api_host_names("");
564        assert!(configured_hosts.is_empty());
565    }
566
567    #[test]
568    fn configured_service_live_timeout_interval_default() {
569        let timeout_secs = Settings::service_live_timeout_interval();
570        assert_eq!(timeout_secs, SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT);
571    }
572
573    #[test]
574    fn configured_service_live_counter_threshold_default() {
575        let threshold = Settings::service_live_counter_threshold();
576        assert_eq!(threshold, SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT);
577    }
578}