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}