1use serde::{Deserialize, Serialize};
11
12const SERVICE_HOME_DIRNAME: &str = ".soma";
14
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17#[serde(default)]
18pub struct Config {
19 pub mcp: McpConfig,
20 pub soma: SomaConfig,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29#[serde(default)]
30pub struct SomaConfig {
31 pub api_url: String,
34 pub api_key: String,
36 pub runtime_mode: RuntimeMode,
38}
39
40#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
41#[serde(rename_all = "lowercase")]
42pub enum RuntimeMode {
43 #[default]
45 Auto,
46 Local,
48 Remote,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum EffectiveRuntimeMode {
54 Local,
55 Remote,
56}
57
58impl SomaConfig {
59 pub fn effective_runtime_mode(&self) -> EffectiveRuntimeMode {
60 match self.runtime_mode {
61 RuntimeMode::Auto if self.api_url.trim().is_empty() => EffectiveRuntimeMode::Local,
62 RuntimeMode::Auto => EffectiveRuntimeMode::Remote,
63 RuntimeMode::Local => EffectiveRuntimeMode::Local,
64 RuntimeMode::Remote => EffectiveRuntimeMode::Remote,
65 }
66 }
67
68 pub fn is_remote_adapter(&self) -> bool {
69 self.effective_runtime_mode() == EffectiveRuntimeMode::Remote
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(default)]
76pub struct McpConfig {
77 #[serde(default = "default_mcp_host")]
80 pub host: String,
81 #[serde(default = "default_mcp_port")]
83 pub port: u16,
84 #[serde(default = "default_server_name")]
86 pub server_name: String,
87 pub no_auth: bool,
89 pub trusted_gateway: bool,
93 pub conformance_fixtures: bool,
98 pub api_token: Option<String>,
100 pub static_token_write: bool,
106 pub allowed_hosts: Vec<String>,
108 pub allowed_origins: Vec<String>,
110 pub trace_headers: TraceHeaderMode,
115 pub auth: AuthConfig,
117}
118
119impl McpConfig {
120 pub fn bind_addr(&self) -> String {
121 format!("{}:{}", self.host, self.port)
122 }
123
124 pub fn is_loopback(&self) -> bool {
130 let host = &self.host;
131 host == "localhost"
134 || host
135 .trim_start_matches('[')
136 .trim_end_matches(']')
137 .parse::<std::net::IpAddr>()
138 .map(|ip| ip.is_loopback())
139 .unwrap_or(false)
140 }
141}
142
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
152#[serde(default)]
153pub struct AuthConfig {
154 pub mode: AuthMode,
155 pub public_url: Option<String>,
157 pub google_client_id: Option<String>,
159 pub google_client_secret: Option<String>,
161 pub google_callback_path: Option<String>,
163 pub google_scopes: Vec<String>,
165 pub authelia_issuer_url: Option<String>,
167 pub authelia_client_id: Option<String>,
169 pub authelia_client_secret: Option<String>,
171 pub authelia_callback_path: Option<String>,
173 pub authelia_scopes: Vec<String>,
175 pub github_client_id: Option<String>,
177 pub github_client_secret: Option<String>,
179 pub github_callback_path: Option<String>,
181 pub github_scopes: Vec<String>,
184 pub default_provider: Option<String>,
187 pub admin_email: String,
189 pub allowed_emails: Vec<String>,
190 pub bootstrap_secret: Option<String>,
192 pub sqlite_path: Option<String>,
194 pub key_path: Option<String>,
196 pub access_token_ttl_secs: Option<u64>,
198 pub refresh_token_ttl_secs: Option<u64>,
200 pub auth_code_ttl_secs: Option<u64>,
202 pub register_rpm: Option<u32>,
204 pub authorize_rpm: Option<u32>,
206 pub max_pending_oauth_states: Option<usize>,
208 pub allowed_client_redirect_uris: Vec<String>,
211 pub token_encryption_key: Option<String>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
217#[serde(rename_all = "lowercase")]
218pub enum AuthMode {
219 #[default]
220 Bearer,
221 OAuth,
222}
223
224#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
225#[serde(rename_all = "kebab-case")]
226pub enum TraceHeaderMode {
227 #[default]
229 Off,
230 Trusted,
233 TrustedWithBaggage,
236}
237
238impl TraceHeaderMode {
239 pub const fn as_str(self) -> &'static str {
240 match self {
241 Self::Off => "off",
242 Self::Trusted => "trusted",
243 Self::TrustedWithBaggage => "trusted-with-baggage",
244 }
245 }
246}
247
248impl std::fmt::Display for TraceHeaderMode {
249 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 formatter.write_str(self.as_str())
251 }
252}
253
254fn default_mcp_host() -> String {
257 "127.0.0.1".into()
260}
261fn default_mcp_port() -> u16 {
262 40060
263}
264fn default_server_name() -> String {
265 "soma".into()
266}
267
268impl Default for McpConfig {
269 fn default() -> Self {
270 Self {
271 host: default_mcp_host(),
272 port: default_mcp_port(),
273 server_name: default_server_name(),
274 no_auth: false,
275 trusted_gateway: false,
276 conformance_fixtures: false,
277 api_token: None,
278 static_token_write: false,
279 allowed_hosts: Vec::new(),
280 allowed_origins: Vec::new(),
281 trace_headers: TraceHeaderMode::default(),
282 auth: AuthConfig::default(),
283 }
284 }
285}
286
287pub fn default_data_dir() -> anyhow::Result<std::path::PathBuf> {
301 if let Some(path) = std::env::var_os("SOMA_HOME") {
302 return Ok(std::path::PathBuf::from(path));
303 }
304
305 if std::path::Path::new("/.dockerenv").exists()
309 || std::env::var("RUNNING_IN_CONTAINER").is_ok()
310 || std::env::var("container").is_ok()
311 {
312 return Ok(std::path::PathBuf::from("/data"));
313 }
314
315 let home = dirs::home_dir().ok_or_else(|| {
317 anyhow::anyhow!("cannot determine home directory — set HOME or RUNNING_IN_CONTAINER=1")
318 })?;
319 Ok(home.join(SERVICE_HOME_DIRNAME))
320}
321
322pub fn load_dotenv() {
331 let Ok(dir) = default_data_dir() else {
332 return;
333 };
334 let env_path = dir.join(".env");
335 match std::fs::symlink_metadata(&env_path) {
339 Ok(md) if md.file_type().is_symlink() => {
340 eprintln!(
341 "error: refusing to load symlinked .env at {} (potential symlink attack)",
342 env_path.display()
343 );
344 std::process::exit(1);
345 }
346 Ok(_) => {
347 let _ = dotenvy::from_path(&env_path);
348 }
349 Err(_) => {}
351 }
352}
353
354impl Config {
357 pub fn load() -> anyhow::Result<Self> {
358 let mut config = Config::default();
359
360 let candidate_paths = {
364 let mut paths = vec![];
365 if let Some(home) = std::env::var_os("HOME") {
366 paths.push(
367 std::path::PathBuf::from(home)
368 .join(SERVICE_HOME_DIRNAME)
369 .join("config.toml"),
370 );
371 }
372 paths.push(std::path::PathBuf::from("config.toml"));
373 paths
374 };
375
376 for path in &candidate_paths {
377 match std::fs::read_to_string(path) {
378 Ok(contents) => {
379 config = toml::from_str(&contents)
380 .map_err(|e| anyhow::anyhow!("Failed to parse {}: {e}", path.display()))?;
381 break;
382 }
383 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
384 Err(e) => return Err(anyhow::anyhow!("Failed to read {}: {e}", path.display())),
385 }
386 }
387
388 env_str("SOMA_MCP_HOST", &mut config.mcp.host);
390 env_parse("SOMA_MCP_PORT", &mut config.mcp.port)?;
391 env_str("SOMA_MCP_SERVER_NAME", &mut config.mcp.server_name);
392 env_bool("SOMA_MCP_NO_AUTH", &mut config.mcp.no_auth)?;
393 env_bool("SOMA_NOAUTH", &mut config.mcp.trusted_gateway)?;
394 env_bool(
395 "SOMA_MCP_CONFORMANCE_FIXTURES",
396 &mut config.mcp.conformance_fixtures,
397 )?;
398 env_opt_str("SOMA_MCP_TOKEN", &mut config.mcp.api_token);
399 env_bool(
400 "SOMA_MCP_STATIC_TOKEN_WRITE",
401 &mut config.mcp.static_token_write,
402 )?;
403 env_list("SOMA_MCP_ALLOWED_HOSTS", &mut config.mcp.allowed_hosts);
404 env_list("SOMA_MCP_ALLOWED_ORIGINS", &mut config.mcp.allowed_origins);
405 env_opt_str("SOMA_MCP_PUBLIC_URL", &mut config.mcp.auth.public_url);
406 env_str(
407 "SOMA_MCP_AUTH_ADMIN_EMAIL",
408 &mut config.mcp.auth.admin_email,
409 );
410 env_opt_str(
411 "SOMA_MCP_GOOGLE_CLIENT_ID",
412 &mut config.mcp.auth.google_client_id,
413 );
414 env_opt_str(
415 "SOMA_MCP_GOOGLE_CLIENT_SECRET",
416 &mut config.mcp.auth.google_client_secret,
417 );
418 env_opt_str(
419 "SOMA_MCP_GOOGLE_CALLBACK_PATH",
420 &mut config.mcp.auth.google_callback_path,
421 );
422 env_list("SOMA_MCP_GOOGLE_SCOPES", &mut config.mcp.auth.google_scopes);
423 env_opt_str(
424 "SOMA_MCP_AUTHELIA_ISSUER_URL",
425 &mut config.mcp.auth.authelia_issuer_url,
426 );
427 env_opt_str(
428 "SOMA_MCP_AUTHELIA_CLIENT_ID",
429 &mut config.mcp.auth.authelia_client_id,
430 );
431 env_opt_str(
432 "SOMA_MCP_AUTHELIA_CLIENT_SECRET",
433 &mut config.mcp.auth.authelia_client_secret,
434 );
435 env_opt_str(
436 "SOMA_MCP_AUTHELIA_CALLBACK_PATH",
437 &mut config.mcp.auth.authelia_callback_path,
438 );
439 env_list(
440 "SOMA_MCP_AUTHELIA_SCOPES",
441 &mut config.mcp.auth.authelia_scopes,
442 );
443 env_opt_str(
444 "SOMA_MCP_GITHUB_CLIENT_ID",
445 &mut config.mcp.auth.github_client_id,
446 );
447 env_opt_str(
448 "SOMA_MCP_GITHUB_CLIENT_SECRET",
449 &mut config.mcp.auth.github_client_secret,
450 );
451 env_opt_str(
452 "SOMA_MCP_GITHUB_CALLBACK_PATH",
453 &mut config.mcp.auth.github_callback_path,
454 );
455 env_list("SOMA_MCP_GITHUB_SCOPES", &mut config.mcp.auth.github_scopes);
456 env_opt_str(
457 "SOMA_MCP_AUTH_DEFAULT_PROVIDER",
458 &mut config.mcp.auth.default_provider,
459 );
460 env_opt_str(
461 "SOMA_MCP_AUTH_BOOTSTRAP_SECRET",
462 &mut config.mcp.auth.bootstrap_secret,
463 );
464 env_opt_str(
465 "SOMA_MCP_AUTH_SQLITE_PATH",
466 &mut config.mcp.auth.sqlite_path,
467 );
468 env_opt_str("SOMA_MCP_AUTH_KEY_PATH", &mut config.mcp.auth.key_path);
469 env_opt_parse(
470 "SOMA_MCP_AUTH_ACCESS_TOKEN_TTL_SECS",
471 &mut config.mcp.auth.access_token_ttl_secs,
472 )?;
473 env_opt_parse(
474 "SOMA_MCP_AUTH_REFRESH_TOKEN_TTL_SECS",
475 &mut config.mcp.auth.refresh_token_ttl_secs,
476 )?;
477 env_opt_parse(
478 "SOMA_MCP_AUTH_CODE_TTL_SECS",
479 &mut config.mcp.auth.auth_code_ttl_secs,
480 )?;
481 env_opt_parse(
482 "SOMA_MCP_AUTH_REGISTER_REQUESTS_PER_MINUTE",
483 &mut config.mcp.auth.register_rpm,
484 )?;
485 env_opt_parse(
486 "SOMA_MCP_AUTH_AUTHORIZE_REQUESTS_PER_MINUTE",
487 &mut config.mcp.auth.authorize_rpm,
488 )?;
489 env_opt_parse(
490 "SOMA_MCP_AUTH_MAX_PENDING_OAUTH_STATES",
491 &mut config.mcp.auth.max_pending_oauth_states,
492 )?;
493 env_list(
494 "SOMA_MCP_AUTH_ALLOWED_REDIRECT_URIS",
495 &mut config.mcp.auth.allowed_client_redirect_uris,
496 );
497 env_opt_str(
498 "SOMA_MCP_TOKEN_ENCRYPTION_KEY",
499 &mut config.mcp.auth.token_encryption_key,
500 );
501 if let Ok(v) = std::env::var("SOMA_MCP_AUTH_MODE") {
502 if !v.is_empty() {
503 config.mcp.auth.mode = match v.to_lowercase().as_str() {
504 "oauth" => AuthMode::OAuth,
505 "bearer" => AuthMode::Bearer,
506 other => {
507 return Err(anyhow::anyhow!(
508 "invalid SOMA_MCP_AUTH_MODE {:?}: must be \"bearer\" or \"oauth\"",
509 other
510 ));
511 }
512 };
513 }
514 }
515 if let Ok(v) = std::env::var("SOMA_MCP_TRACE_HEADERS") {
516 if !v.is_empty() {
517 config.mcp.trace_headers = match v.to_lowercase().as_str() {
518 "off" => TraceHeaderMode::Off,
519 "trusted" => TraceHeaderMode::Trusted,
520 "trusted-with-baggage" => TraceHeaderMode::TrustedWithBaggage,
521 other => {
522 return Err(anyhow::anyhow!(
523 "invalid SOMA_MCP_TRACE_HEADERS {:?}: must be \"off\", \"trusted\", \
524 or \"trusted-with-baggage\"",
525 other
526 ));
527 }
528 };
529 }
530 }
531
532 env_str("SOMA_API_URL", &mut config.soma.api_url);
534 env_str("SOMA_API_KEY", &mut config.soma.api_key);
535 if let Ok(v) = std::env::var("SOMA_RUNTIME_MODE") {
536 if !v.is_empty() {
537 config.soma.runtime_mode = match v.to_lowercase().as_str() {
538 "auto" => RuntimeMode::Auto,
539 "local" => RuntimeMode::Local,
540 "remote" | "api" => RuntimeMode::Remote,
541 other => {
542 return Err(anyhow::anyhow!(
543 "invalid SOMA_RUNTIME_MODE {:?}: must be \"auto\", \"local\", or \"remote\"",
544 other
545 ));
546 }
547 };
548 }
549 }
550
551 Ok(config)
552 }
553}
554
555fn env_str(key: &str, target: &mut String) {
558 if let Ok(v) = std::env::var(key) {
559 if !v.is_empty() {
560 *target = v;
561 }
562 }
563}
564
565fn env_opt_str(key: &str, target: &mut Option<String>) {
566 if let Ok(v) = std::env::var(key) {
567 if !v.is_empty() {
568 *target = Some(v);
569 }
570 }
571}
572
573fn env_parse<T: std::str::FromStr>(key: &str, target: &mut T) -> anyhow::Result<()> {
574 if let Ok(v) = std::env::var(key) {
575 if !v.is_empty() {
576 *target = v
577 .parse()
578 .map_err(|_| anyhow::anyhow!("{key}: invalid value {v:?}"))?;
579 }
580 }
581 Ok(())
582}
583
584fn env_opt_parse<T: std::str::FromStr>(key: &str, target: &mut Option<T>) -> anyhow::Result<()> {
585 if let Ok(v) = std::env::var(key) {
586 if !v.is_empty() {
587 *target = Some(
588 v.parse()
589 .map_err(|_| anyhow::anyhow!("{key}: invalid value {v:?}"))?,
590 );
591 }
592 }
593 Ok(())
594}
595
596fn env_bool(key: &str, target: &mut bool) -> anyhow::Result<()> {
597 if let Ok(v) = std::env::var(key) {
598 match v.to_lowercase().as_str() {
599 "1" | "true" | "yes" => *target = true,
600 "0" | "false" | "no" => *target = false,
601 other => anyhow::bail!("{key}: expected bool, got {other:?}"),
602 }
603 }
604 Ok(())
605}
606
607fn env_list(key: &str, target: &mut Vec<String>) {
608 if let Ok(v) = std::env::var(key) {
609 let items: Vec<String> = v
610 .split(',')
611 .map(|s| s.trim().to_string())
612 .filter(|s| !s.is_empty())
613 .collect();
614 if !items.is_empty() {
615 *target = items;
616 }
617 }
618}
619
620#[cfg(test)]
621#[path = "config_tests.rs"]
622mod tests;