Skip to main content

pyth_lazer_agent/
config.rs

1use config::{Environment, File};
2use derivative::Derivative;
3use serde::Deserialize;
4use std::cmp::min;
5use std::fmt::{Debug, Display, Formatter};
6use std::net::SocketAddr;
7use std::path::PathBuf;
8use std::time::Duration;
9use url::Url;
10
11#[derive(Deserialize, Derivative, Clone, PartialEq)]
12#[derivative(Debug)]
13pub struct Config {
14    pub listen_address: SocketAddr,
15    pub relayer_urls: Vec<Url>,
16    #[serde(default)]
17    pub relayer_connections: Option<usize>,
18    pub authorization_token: Option<AuthorizationToken>,
19    #[derivative(Debug = "ignore")]
20    pub publish_keypair_path: PathBuf,
21    #[serde(with = "humantime_serde", default = "default_publish_interval")]
22    pub publish_interval_duration: Duration,
23    #[serde(default = "default_history_service_url")]
24    pub history_service_url: Url,
25    #[serde(default)]
26    pub enable_update_deduplication: bool,
27    #[serde(with = "humantime_serde", default = "default_update_deduplication_ttl")]
28    pub update_deduplication_ttl: Duration,
29    pub proxy_url: Option<RedactedUrl>,
30    #[serde(with = "humantime_serde", default = "default_legacy_sched_interval")]
31    pub legacy_sched_interval_duration: Duration,
32}
33
34/// A `Url` that never renders embedded credentials. The proxy URL may carry
35/// Basic-auth userinfo (`user:pass@host`), and `url::Url`'s own `Display`/`Debug`
36/// both emit the password in plaintext. This wrapper redacts userinfo (and
37/// path/query) on every `Display`/`Debug`, so it cannot leak into logs or spans.
38/// Code that genuinely needs the credentials reaches them through [`RedactedUrl::expose`].
39#[derive(Deserialize, Clone, PartialEq)]
40#[serde(transparent)]
41pub struct RedactedUrl(Url);
42
43impl RedactedUrl {
44    /// Access the underlying `Url`, including any embedded credentials. Named to
45    /// keep credential exposure explicit and greppable at every call site.
46    pub fn expose(&self) -> &Url {
47        &self.0
48    }
49}
50
51/// `scheme://host[:port]` with userinfo, path, and query stripped.
52fn redact_url(url: &Url) -> String {
53    let scheme = url.scheme();
54    match (url.host_str(), url.port()) {
55        (Some(host), Some(port)) => format!("{scheme}://{host}:{port}"),
56        (Some(host), None) => format!("{scheme}://{host}"),
57        (None, _) => scheme.to_string(),
58    }
59}
60
61impl Display for RedactedUrl {
62    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
63        f.write_str(&redact_url(&self.0))
64    }
65}
66
67impl Debug for RedactedUrl {
68    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
69        write!(f, "{:?}", redact_url(&self.0))
70    }
71}
72
73#[derive(Deserialize, Derivative, Clone, PartialEq)]
74pub struct AuthorizationToken(pub String);
75
76impl Debug for AuthorizationToken {
77    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
78        let token_string = self.0.to_ascii_lowercase();
79        #[allow(clippy::string_slice, reason = "false positive")]
80        let last_chars = &token_string[token_string.len() - min(4, token_string.len())..];
81        write!(f, "\"...{last_chars}\"")
82    }
83}
84
85fn default_publish_interval() -> Duration {
86    Duration::from_millis(25)
87}
88
89fn default_update_deduplication_ttl() -> Duration {
90    Duration::from_millis(500)
91}
92
93fn default_legacy_sched_interval() -> Duration {
94    Duration::from_millis(500)
95}
96
97fn default_history_service_url() -> Url {
98    #[allow(clippy::expect_used, reason = "hardcoded URL is always valid")]
99    "https://pyth.dourolabs.app/v1/symbols"
100        .parse()
101        .expect("hardcoded URL is valid")
102}
103
104pub fn load_config(config_path: String) -> anyhow::Result<Config> {
105    let config = config::Config::builder()
106        .add_source(File::with_name(&config_path))
107        .add_source(Environment::with_prefix("LAZER_AGENT").separator("__"))
108        .build()?
109        .try_deserialize()?;
110    Ok(config)
111}
112
113// Default capacity for all tokio mpsc channels that communicate between tasks.
114pub const CHANNEL_CAPACITY: usize = 1000;
115
116#[cfg(test)]
117mod tests {
118    use super::RedactedUrl;
119    use url::Url;
120
121    fn redacted(s: &str) -> RedactedUrl {
122        RedactedUrl(Url::parse(s).unwrap())
123    }
124
125    #[test]
126    fn redacted_url_hides_credentials() {
127        let url = redacted("http://alice:s3cr3t@proxy.example.com:8080/path?q=1");
128
129        let display = format!("{url}");
130        let debug = format!("{url:?}");
131        assert_eq!(display, "http://proxy.example.com:8080");
132        assert_eq!(debug, "\"http://proxy.example.com:8080\"");
133        for rendered in [&display, &debug] {
134            assert!(!rendered.contains("alice"));
135            assert!(!rendered.contains("s3cr3t"));
136        }
137
138        // The credentials remain reachable through the explicit accessor.
139        assert_eq!(url.expose().password(), Some("s3cr3t"));
140
141        // No explicit port still renders cleanly without userinfo.
142        let no_port = redacted("https://bob:hunter2@proxy.example.com");
143        assert_eq!(format!("{no_port}"), "https://proxy.example.com");
144    }
145}