Skip to main content

moonlight_core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::{env, net::SocketAddr, path::PathBuf, str::FromStr};
3
4pub const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 10 * 1024 * 1024;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct AppConfig {
8    pub bind_addr: SocketAddr,
9    pub primary_url: String,
10    pub candidate_url: String,
11    pub secondary_url: String,
12    pub enable_secondary: bool,
13    pub return_target: ReturnTarget,
14    pub return_fallback: ReturnFallback,
15    pub response_timing: ResponseTiming,
16    pub max_body_capture_bytes: usize,
17    pub max_request_body_bytes: usize,
18    pub redact_headers: Vec<String>,
19    pub redact_json_paths: Vec<String>,
20    pub redact_query_params: Vec<String>,
21    pub ignored_json_paths: Vec<String>,
22    pub ignored_headers: Vec<String>,
23    pub ignore_stderr: bool,
24    pub storage_path: PathBuf,
25    pub cors_origins: Vec<String>,
26    #[serde(skip_serializing, skip_deserializing)]
27    pub admin_token: Option<String>,
28    pub retention_max_runs: Option<usize>,
29    pub retention_max_bytes: Option<u64>,
30}
31
32#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "snake_case")]
34pub enum ReturnTarget {
35    Primary,
36    Candidate,
37}
38
39impl FromStr for ReturnTarget {
40    type Err = anyhow::Error;
41
42    fn from_str(value: &str) -> Result<Self, Self::Err> {
43        match value {
44            "primary" => Ok(Self::Primary),
45            "candidate" => Ok(Self::Candidate),
46            other => {
47                anyhow::bail!("invalid MOONLIGHT_RETURN_TARGET {other:?}; use primary or candidate")
48            }
49        }
50    }
51}
52
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "snake_case")]
55pub enum ReturnFallback {
56    None,
57    Primary,
58}
59
60impl FromStr for ReturnFallback {
61    type Err = anyhow::Error;
62
63    fn from_str(value: &str) -> Result<Self, Self::Err> {
64        match value {
65            "none" => Ok(Self::None),
66            "primary" => Ok(Self::Primary),
67            other => {
68                anyhow::bail!("invalid MOONLIGHT_RETURN_FALLBACK {other:?}; use none or primary")
69            }
70        }
71    }
72}
73
74#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "snake_case")]
76pub enum ResponseTiming {
77    WaitAll,
78    ReturnSelected,
79}
80
81impl FromStr for ResponseTiming {
82    type Err = anyhow::Error;
83
84    fn from_str(value: &str) -> Result<Self, Self::Err> {
85        match value {
86            "wait_all" => Ok(Self::WaitAll),
87            "return_selected" => Ok(Self::ReturnSelected),
88            other => anyhow::bail!(
89                "invalid MOONLIGHT_RESPONSE_TIMING {other:?}; use wait_all or return_selected"
90            ),
91        }
92    }
93}
94
95impl AppConfig {
96    pub fn from_env() -> anyhow::Result<Self> {
97        Self::from_lookup(|key| env::var(key).ok())
98    }
99
100    fn from_lookup(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
101        Ok(Self {
102            bind_addr: env_or(&get, "MOONLIGHT_BIND_ADDR", "127.0.0.1:8080").parse()?,
103            primary_url: normalize_base_url(env_or(
104                &get,
105                "MOONLIGHT_PRIMARY_URL",
106                "http://127.0.0.1:3001",
107            )),
108            candidate_url: normalize_base_url(env_or(
109                &get,
110                "MOONLIGHT_CANDIDATE_URL",
111                "http://127.0.0.1:3002",
112            )),
113            secondary_url: normalize_base_url(env_or(
114                &get,
115                "MOONLIGHT_SECONDARY_URL",
116                "http://127.0.0.1:3003",
117            )),
118            enable_secondary: env_bool(&get, "MOONLIGHT_ENABLE_SECONDARY", true),
119            return_target: env_or(&get, "MOONLIGHT_RETURN_TARGET", "primary").parse()?,
120            return_fallback: env_or(&get, "MOONLIGHT_RETURN_FALLBACK", "none").parse()?,
121            response_timing: env_or(&get, "MOONLIGHT_RESPONSE_TIMING", "wait_all").parse()?,
122            max_body_capture_bytes: env_or(&get, "MOONLIGHT_MAX_BODY_CAPTURE_BYTES", "8192")
123                .parse()?,
124            max_request_body_bytes: env_or(
125                &get,
126                "MOONLIGHT_MAX_REQUEST_BODY_BYTES",
127                &DEFAULT_MAX_REQUEST_BODY_BYTES.to_string(),
128            )
129            .parse()?,
130            redact_headers: env_list(
131                &get,
132                "MOONLIGHT_REDACT_HEADERS",
133                &[
134                    "authorization",
135                    "cookie",
136                    "set-cookie",
137                    "x-api-key",
138                    "proxy-authorization",
139                    "x-auth-token",
140                    "x-csrf-token",
141                ],
142            ),
143            redact_json_paths: env_list(&get, "MOONLIGHT_REDACT_JSON_PATHS", &[]),
144            redact_query_params: env_list(
145                &get,
146                "MOONLIGHT_REDACT_QUERY_PARAMS",
147                &[
148                    "token",
149                    "access_token",
150                    "id_token",
151                    "api_key",
152                    "key",
153                    "secret",
154                    "password",
155                ],
156            ),
157            ignored_json_paths: env_list(
158                &get,
159                "MOONLIGHT_IGNORED_JSON_PATHS",
160                &["$.timestamp", "$.requestId", "$.traceId", "$.id"],
161            ),
162            ignored_headers: env_list(
163                &get,
164                "MOONLIGHT_IGNORED_HEADERS",
165                &[
166                    "date",
167                    "server",
168                    "set-cookie",
169                    "x-request-id",
170                    "traceparent",
171                ],
172            ),
173            ignore_stderr: env_bool(&get, "MOONLIGHT_IGNORE_STDERR", false),
174            storage_path: PathBuf::from(env_or(
175                &get,
176                "MOONLIGHT_STORAGE_PATH",
177                "data/moonlight/http-runs.jsonl",
178            )),
179            cors_origins: env_list(
180                &get,
181                "MOONLIGHT_CORS_ORIGINS",
182                &["http://127.0.0.1:5173", "http://localhost:5173"],
183            ),
184            admin_token: get("MOONLIGHT_ADMIN_TOKEN").filter(|value| !value.trim().is_empty()),
185            retention_max_runs: env_optional(&get, "MOONLIGHT_RETENTION_MAX_RUNS")?,
186            retention_max_bytes: env_optional(&get, "MOONLIGHT_RETENTION_MAX_BYTES")?,
187        })
188    }
189}
190
191fn env_or(get: &impl Fn(&str) -> Option<String>, key: &str, default: &str) -> String {
192    get(key).unwrap_or_else(|| default.to_string())
193}
194
195fn env_bool(get: &impl Fn(&str) -> Option<String>, key: &str, default: bool) -> bool {
196    get(key)
197        .and_then(|value| match value.to_ascii_lowercase().as_str() {
198            "1" | "true" | "yes" | "on" => Some(true),
199            "0" | "false" | "no" | "off" => Some(false),
200            _ => None,
201        })
202        .unwrap_or(default)
203}
204
205fn env_optional<T>(get: &impl Fn(&str) -> Option<String>, key: &str) -> anyhow::Result<Option<T>>
206where
207    T: FromStr,
208    T::Err: std::error::Error + Send + Sync + 'static,
209{
210    get(key)
211        .filter(|value| !value.trim().is_empty())
212        .map(|value| value.parse())
213        .transpose()
214        .map_err(Into::into)
215}
216
217fn env_list(get: &impl Fn(&str) -> Option<String>, key: &str, defaults: &[&str]) -> Vec<String> {
218    get(key)
219        .map(|value| {
220            value
221                .split(',')
222                .map(|item| item.trim().to_ascii_lowercase())
223                .filter(|item| !item.is_empty())
224                .collect()
225        })
226        .unwrap_or_else(|| defaults.iter().map(|item| item.to_string()).collect())
227}
228
229fn normalize_base_url(value: String) -> String {
230    value.trim_end_matches('/').to_string()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::collections::HashMap;
237
238    fn config_from(values: &[(&str, &str)]) -> anyhow::Result<AppConfig> {
239        let values: HashMap<String, String> = values
240            .iter()
241            .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
242            .collect();
243        AppConfig::from_lookup(|key| values.get(key).cloned())
244    }
245
246    #[test]
247    fn response_timing_defaults_to_wait_all() {
248        let config = config_from(&[]).unwrap();
249        assert_eq!(config.response_timing, ResponseTiming::WaitAll);
250    }
251
252    #[test]
253    fn response_timing_parses_return_selected() {
254        let config = config_from(&[("MOONLIGHT_RESPONSE_TIMING", "return_selected")]).unwrap();
255        assert_eq!(config.response_timing, ResponseTiming::ReturnSelected);
256    }
257
258    #[test]
259    fn invalid_response_timing_returns_error() {
260        let error = config_from(&[("MOONLIGHT_RESPONSE_TIMING", "fast")]).unwrap_err();
261        assert!(error
262            .to_string()
263            .contains("invalid MOONLIGHT_RESPONSE_TIMING"));
264    }
265
266    #[test]
267    fn return_target_defaults_to_primary() {
268        let config = config_from(&[]).unwrap();
269        assert_eq!(config.return_target, ReturnTarget::Primary);
270    }
271
272    #[test]
273    fn return_fallback_defaults_to_none() {
274        let config = config_from(&[]).unwrap();
275        assert_eq!(config.return_fallback, ReturnFallback::None);
276    }
277
278    #[test]
279    fn parses_local_first_hardening_env_vars() {
280        let config = config_from(&[
281            ("MOONLIGHT_CORS_ORIGINS", "http://example.test,*"),
282            ("MOONLIGHT_ADMIN_TOKEN", "secret"),
283            ("MOONLIGHT_MAX_REQUEST_BODY_BYTES", "128"),
284            ("MOONLIGHT_REDACT_JSON_PATHS", "$.token,$.items[0].secret"),
285            ("MOONLIGHT_REDACT_QUERY_PARAMS", "token,key"),
286            ("MOONLIGHT_RETENTION_MAX_RUNS", "50"),
287            ("MOONLIGHT_RETENTION_MAX_BYTES", "2048"),
288        ])
289        .unwrap();
290
291        assert_eq!(
292            config.cors_origins,
293            vec!["http://example.test".to_string(), "*".to_string()]
294        );
295        assert_eq!(config.admin_token, Some("secret".to_string()));
296        assert_eq!(config.max_request_body_bytes, 128);
297        assert_eq!(
298            config.redact_json_paths,
299            vec!["$.token".to_string(), "$.items[0].secret".to_string()]
300        );
301        assert_eq!(
302            config.redact_query_params,
303            vec!["token".to_string(), "key".to_string()]
304        );
305        assert_eq!(config.retention_max_runs, Some(50));
306        assert_eq!(config.retention_max_bytes, Some(2048));
307    }
308
309    #[test]
310    fn serialized_config_omits_admin_token() {
311        let config = config_from(&[("MOONLIGHT_ADMIN_TOKEN", "secret")]).unwrap();
312        let json = serde_json::to_value(config).unwrap();
313
314        assert!(json.get("admin_token").is_none());
315    }
316}