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