1mod store;
4mod validation;
5mod workspace;
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::env;
9use std::fs;
10use std::io::{Read as _, Write as _};
11use std::net::SocketAddr;
12#[cfg(unix)]
13use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
14use std::path::{Component, Path, PathBuf};
15use std::sync::Mutex;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use mobius::agent::DEFAULT_MAX_MODEL_STEPS;
19use mobius::backend::model::provider::{
20 ProviderAuth, ProviderDefinition, default_provider, provider,
21};
22use mobius::protocol::TokenUsage;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use sha2::Digest as _;
26
27use crate::wire::{
28 AgentComposition, DailyUsage, ProfileSnapshot, ProviderConfig, ProviderEndpointAuth,
29 ProviderTint, VersionedAgentConfig, WorkspaceInfo,
30};
31use crate::{Error, Result};
32
33use self::store::*;
34pub use self::store::{ConfigStore, CredentialStore, load_cloudflare_token, state_dir};
35pub use self::validation::validate_agent_composition;
36use self::validation::*;
37pub(crate) use self::validation::{effective_reasoning_effort, model_route_id};
38use self::workspace::*;
39pub(crate) use self::workspace::{create_workspace_directory, local_user_name};
40
41const CONFIG_VERSION: u32 = 20;
42const CHAT_SPEC_VERSION: u32 = 9;
43pub(crate) const CHAT_SPEC_METADATA_KEY: &str = "mobius_gateway.chat";
44const CONFIG_FILE: &str = "gateway.toml";
45const CLOUDFLARE_TOKEN_FILE: &str = "cloudflare-token";
46const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
47const MAX_CREDENTIAL_STATE_BYTES: usize = 256 * 1024;
48const MAX_SYSTEM_PROMPT_BYTES: usize = 64 * 1024;
49pub(crate) const MAX_API_KEY_BYTES: usize = 16 * 1024;
50const MAX_PROVIDER_CATALOG_ENTRIES: usize = 64;
51const MAX_PROVIDER_CATALOG_ENTRY_BYTES: usize = 1024;
52const MAX_PROVIDER_CATALOG_BYTES: usize = 16 * 1024;
53const MAX_CUSTOM_MODEL_ROUTES: usize = 64;
54const MAX_CLOUDFLARE_TOKEN_BYTES: usize = 16 * 1024;
55const MAX_WORKSPACE_DIRECTORY_NAME_BYTES: usize = 255;
56const SECONDS_PER_DAY: u64 = 86_400;
57const USAGE_HISTORY_DAYS: u64 = 52 * 7;
58
59mod defaults {
60 include!(concat!(env!("OUT_DIR"), "/defaults.rs"));
61}
62
63pub const DEFAULT_LISTEN: SocketAddr =
65 SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 8741);
66
67pub const DEFAULT_SYSTEM_PROMPT: &str = defaults::DEFAULT_SYSTEM_PROMPT;
69
70pub const DEFAULT_CONTEXT_WINDOW: i64 = defaults::DEFAULT_CONTEXT_WINDOW;
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct TlsConfig {
77 pub certificate: PathBuf,
78 pub private_key: PathBuf,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
84pub enum CloudflareConfig {
85 Quick,
87 Named { hostname: String },
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct GatewayConfig {
95 version: u32,
96 pub listen: SocketAddr,
97 pub tls: Option<TlsConfig>,
98 pub cloudflare: Option<CloudflareConfig>,
99 pub default_agent: Option<VersionedAgentConfig>,
100 pub(crate) configured_providers: BTreeMap<String, ConfiguredProvider>,
101 pub(crate) installed_extensions: BTreeMap<String, crate::extensions::InstalledExtension>,
102 usage: UsageHistory,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub(crate) struct ConfiguredProvider {
109 pub(crate) selection: ProviderConfig,
110 pub(crate) label: String,
111 pub(crate) tint: ProviderTint,
112 pub(crate) model_ids: Vec<String>,
113 pub(crate) reasoning_efforts: Vec<String>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub(crate) struct ChatSpec {
120 version: u32,
121 pub(crate) workspace: PathBuf,
122 pub(crate) agent: VersionedAgentConfig,
123}
124
125impl Default for AgentComposition {
126 fn default() -> Self {
127 let provider = default_provider();
128 let model = provider
129 .default_model()
130 .and_then(|id| provider.model(id))
131 .expect("default model manifest");
132 Self {
133 provider: ProviderConfig {
134 instance: provider.id().into(),
135 provider: provider.id().into(),
136 model: model.id.into(),
137 base_url: provider.default_base_url().map(str::to_string),
138 endpoint_auth: ProviderEndpointAuth::ProviderDefault,
139 reasoning_effort: model.default_reasoning.map(str::to_string),
140 web_search: *provider
141 .web_search()
142 .first()
143 .expect("default provider web-search manifest"),
144 },
145 middleware: crate::middleware_manifest::default_config(),
146 extensions: BTreeSet::new(),
147 system_prompt: DEFAULT_SYSTEM_PROMPT.into(),
148 max_model_steps: DEFAULT_MAX_MODEL_STEPS as u64,
149 }
150 }
151}
152
153impl GatewayConfig {
154 pub fn new(listen: SocketAddr, tls: Option<TlsConfig>) -> Result<Self> {
156 let config = Self {
157 version: CONFIG_VERSION,
158 listen,
159 tls,
160 cloudflare: None,
161 default_agent: None,
162 configured_providers: BTreeMap::new(),
163 installed_extensions: BTreeMap::new(),
164 usage: UsageHistory::default(),
165 };
166 config.validate()?;
167 Ok(config)
168 }
169
170 pub fn new_cloudflare(listen: SocketAddr, cloudflare: CloudflareConfig) -> Result<Self> {
172 let mut config = Self::new(listen, None)?;
173 config.cloudflare = Some(cloudflare);
174 config.validate()?;
175 Ok(config)
176 }
177
178 pub(crate) fn registering_provider(
180 &self,
181 selection: ProviderConfig,
182 label: String,
183 tint: ProviderTint,
184 model_ids: Vec<String>,
185 reasoning_efforts: Vec<String>,
186 ) -> Result<Self> {
187 if let Some(configured) = self.configured_providers.get(&selection.instance)
188 && configured.selection.provider != selection.provider
189 {
190 return Err(Error::Config(format!(
191 "provider instance `{}` already belongs to `{}`",
192 selection.instance, configured.selection.provider
193 )));
194 }
195 let configured = ConfiguredProvider {
196 selection: selection.clone(),
197 label,
198 tint,
199 model_ids,
200 reasoning_efforts,
201 };
202 let mut next = self.clone();
203 next.configured_providers
204 .insert(selection.instance.clone(), configured);
205 if self.default_agent.is_none() {
206 let config = AgentComposition {
207 provider: selection,
208 ..AgentComposition::default()
209 };
210 next.default_agent = Some(VersionedAgentConfig {
211 revision: 1,
212 config,
213 });
214 }
215 next.validate()?;
216 Ok(next)
217 }
218
219 pub(crate) fn removing_provider(&self, instance: &str) -> Result<Self> {
221 if !self.configured_providers.contains_key(instance) {
222 return Err(Error::Config(format!(
223 "provider instance `{instance}` is not configured"
224 )));
225 }
226 if self
227 .default_agent
228 .as_ref()
229 .is_some_and(|default| default.config.provider.instance == instance)
230 {
231 return Err(Error::Config(
232 "choose another gateway default before removing this provider".into(),
233 ));
234 }
235 let mut next = self.clone();
236 next.configured_providers.remove(instance);
237 let mut default_config = next
238 .default_agent
239 .as_ref()
240 .expect("a removable provider cannot be the only configured provider")
241 .config
242 .clone();
243 if clear_missing_model_routes(&mut default_config, &next)? {
244 let default = next
245 .default_agent
246 .as_mut()
247 .expect("a removable provider cannot be the only configured provider");
248 default.config = default_config;
249 default.revision = default
250 .revision
251 .checked_add(1)
252 .ok_or_else(|| Error::Config("configuration revision overflow".into()))?;
253 }
254 next.validate()?;
255 Ok(next)
256 }
257
258 pub(crate) fn replacing_provider_default(&self, selection: &ProviderConfig) -> Result<Self> {
260 let Some(current) = self.default_agent.as_ref() else {
261 return Err(Error::Config(
262 "register a provider before replacing its default".into(),
263 ));
264 };
265 if current.config.provider.instance != selection.instance
266 || current.config.provider == *selection
267 {
268 return Ok(self.clone());
269 }
270 let mut next = self.clone();
271 let mut config = current.config.clone();
272 config.provider = selection.clone();
273 next.default_agent = Some(VersionedAgentConfig {
274 revision: current
275 .revision
276 .checked_add(1)
277 .ok_or_else(|| Error::Config("configuration revision overflow".into()))?,
278 config,
279 });
280 next.validate()?;
281 Ok(next)
282 }
283
284 pub(crate) fn replacing_default_agent(
286 &self,
287 expected_revision: u64,
288 composition: AgentComposition,
289 ) -> Result<Self> {
290 let current = self
291 .default_agent
292 .as_ref()
293 .ok_or_else(|| Error::Config("configure a provider before saving defaults".into()))?;
294 if current.revision != expected_revision {
295 return Err(Error::Config(format!(
296 "configuration revision changed from {expected_revision} to {}",
297 current.revision
298 )));
299 }
300 let mut next = self.clone();
301 next.default_agent = Some(VersionedAgentConfig {
302 revision: current
303 .revision
304 .checked_add(1)
305 .ok_or_else(|| Error::Config("configuration revision overflow".into()))?,
306 config: composition,
307 });
308 next.validate()?;
309 Ok(next)
310 }
311
312 pub(crate) fn validate_provider_selection(&self, selection: &ProviderConfig) -> Result<()> {
313 validate_provider_config(selection)?;
314 let configured = self
315 .configured_providers
316 .get(&selection.instance)
317 .ok_or_else(|| {
318 Error::Config("provider selection must use a configured provider entry".into())
319 })?;
320 validate_configured_provider_selection(configured, selection)
321 }
322
323 pub fn observe_usage(&mut self, provider: &str, usage: &TokenUsage) -> Result<bool> {
325 self.usage.observe(provider, usage, SystemTime::now())
326 }
327
328 #[must_use]
330 pub fn profile(&self) -> ProfileSnapshot {
331 ProfileSnapshot {
332 user_name: local_user_name(),
333 daily_usage: self
334 .usage
335 .days
336 .iter()
337 .flat_map(|(unix_day, providers)| {
338 providers.iter().map(|(provider, usage)| DailyUsage {
339 unix_day: *unix_day,
340 provider: provider.clone(),
341 usage: usage.clone(),
342 })
343 })
344 .collect(),
345 run_stats: crate::wire::RunStats::default(),
346 recent_run_groups: Vec::new(),
347 }
348 }
349
350 pub fn validate(&self) -> Result<()> {
352 if self.version != CONFIG_VERSION {
353 return Err(Error::Config(format!(
354 "unsupported gateway config version {}",
355 self.version
356 )));
357 }
358 if self.listen.port() == 0 {
359 return Err(Error::Config(
360 "gateway listen port must be greater than zero".into(),
361 ));
362 }
363 match (&self.tls, self.listen.ip().is_loopback()) {
364 (None, false) => {
365 return Err(Error::Config(
366 "non-loopback gateway listeners require a TLS certificate and private key"
367 .into(),
368 ));
369 }
370 (Some(tls), _) => tls.validate()?,
371 (None, true) => {}
372 }
373 if self.cloudflare.is_some() && (!self.listen.ip().is_loopback() || self.tls.is_some()) {
374 return Err(Error::Config(
375 "Cloudflare gateways require a plaintext loopback listener".into(),
376 ));
377 }
378 if let Some(cloudflare) = &self.cloudflare {
379 cloudflare.validate()?;
380 }
381 if self.configured_providers.is_empty() != self.default_agent.is_none() {
382 return Err(Error::Config(
383 "the gateway default must exist exactly when a provider is configured".into(),
384 ));
385 }
386 for (instance, configured) in &self.configured_providers {
387 if instance != &configured.selection.instance {
388 return Err(Error::Config(format!(
389 "configured provider key `{instance}` does not match `{}`",
390 configured.selection.instance
391 )));
392 }
393 validate_configured_provider(configured)?;
394 }
395 crate::extensions::validate_installed(&self.installed_extensions)?;
396 validate_custom_model_route_count(&self.configured_providers)?;
397 if let Some(default) = &self.default_agent {
398 if default.revision == 0 {
399 return Err(Error::Config(
400 "configuration revision must be positive".into(),
401 ));
402 }
403 validate_agent_composition(&default.config)?;
404 self.validate_provider_selection(&default.config.provider)?;
405 for (middleware, setting, route) in
406 crate::middleware_manifest::configured_model_routes(&default.config.middleware)
407 {
408 if !crate::provider_catalog::configured_route_exists(self, route)? {
409 return Err(Error::Config(format!(
410 "gateway default middleware setting `{middleware}.{setting}` is not a configured model route"
411 )));
412 }
413 }
414 }
415 for providers in self.usage.days.values() {
416 for (provider, usage) in providers {
417 validate_usage_provider(provider)?;
418 validate_usage(usage)?;
419 }
420 }
421 Ok(())
422 }
423}
424
425impl ChatSpec {
426 pub(crate) fn new(
427 workspace: &Path,
428 agent: VersionedAgentConfig,
429 state_dir: &Path,
430 tls: Option<&TlsConfig>,
431 ) -> Result<Self> {
432 let spec = Self {
433 version: CHAT_SPEC_VERSION,
434 workspace: validate_chat_workspace(workspace, state_dir, tls)?,
435 agent,
436 };
437 spec.validate(state_dir, tls)?;
438 Ok(spec)
439 }
440
441 pub(crate) fn from_metadata(
442 metadata: &BTreeMap<String, Value>,
443 state_dir: &Path,
444 tls: Option<&TlsConfig>,
445 ) -> Result<Self> {
446 Self::from_metadata_if_present(metadata, state_dir, tls)?.ok_or_else(|| {
447 Error::Config("chat checkpoint has no gateway runtime configuration".into())
448 })
449 }
450
451 pub(crate) fn from_metadata_if_present(
452 metadata: &BTreeMap<String, Value>,
453 state_dir: &Path,
454 tls: Option<&TlsConfig>,
455 ) -> Result<Option<Self>> {
456 let Some(value) = metadata.get(CHAT_SPEC_METADATA_KEY) else {
457 return Ok(None);
458 };
459 let spec: Self = serde_json::from_value(value.clone())?;
460 spec.validate(state_dir, tls)?;
461 Ok(Some(spec))
462 }
463
464 pub(crate) fn metadata(&self) -> Result<BTreeMap<String, Value>> {
465 Ok(BTreeMap::from([(
466 CHAT_SPEC_METADATA_KEY.into(),
467 serde_json::to_value(self)?,
468 )]))
469 }
470
471 #[must_use]
472 pub(crate) fn workspace_info(&self) -> WorkspaceInfo {
473 WorkspaceInfo {
474 id: workspace_id(&self.workspace),
475 path: self.workspace.clone(),
476 }
477 }
478
479 pub(crate) fn replacing_agent(
480 &self,
481 expected_revision: u64,
482 composition: AgentComposition,
483 gateway: &GatewayConfig,
484 state_dir: &Path,
485 tls: Option<&TlsConfig>,
486 ) -> Result<Self> {
487 if expected_revision != self.agent.revision {
488 return Err(Error::Config(format!(
489 "configuration revision changed from {expected_revision} to {}",
490 self.agent.revision
491 )));
492 }
493 let mut next = self.clone();
494 next.agent = VersionedAgentConfig {
495 revision: self
496 .agent
497 .revision
498 .checked_add(1)
499 .ok_or_else(|| Error::Config("configuration revision overflow".into()))?,
500 config: composition,
501 };
502 next.validate(state_dir, tls)?;
503 gateway.validate_provider_selection(&next.agent.config.provider)?;
504 Ok(next)
505 }
506
507 pub(crate) fn replacing_provider_selection(
508 &self,
509 selection: &ProviderConfig,
510 gateway: &GatewayConfig,
511 state_dir: &Path,
512 tls: Option<&TlsConfig>,
513 ) -> Result<Option<Self>> {
514 if self.agent.config.provider.instance != selection.instance
515 || self.agent.config.provider == *selection
516 {
517 return Ok(None);
518 }
519 let mut composition = self.agent.config.clone();
520 composition.provider = selection.clone();
521 self.replacing_agent(self.agent.revision, composition, gateway, state_dir, tls)
522 .map(Some)
523 }
524
525 pub(crate) fn normalizing_provider_catalog(
527 &self,
528 gateway: &GatewayConfig,
529 state_dir: &Path,
530 tls: Option<&TlsConfig>,
531 ) -> Result<Self> {
532 if gateway.configured_providers.is_empty() {
533 return Ok(self.clone());
534 }
535 let mut composition = self.agent.config.clone();
536 let mut changed = false;
537 if !gateway
538 .configured_providers
539 .contains_key(&composition.provider.instance)
540 {
541 composition.provider = gateway
542 .default_agent
543 .as_ref()
544 .ok_or_else(|| Error::Config("gateway has no default provider".into()))?
545 .config
546 .provider
547 .clone();
548 changed = true;
549 }
550 changed |= clear_missing_model_routes(&mut composition, gateway)?;
551 if !changed {
552 return Ok(self.clone());
553 }
554 self.replacing_agent(self.agent.revision, composition, gateway, state_dir, tls)
555 }
556
557 fn validate(&self, state_dir: &Path, tls: Option<&TlsConfig>) -> Result<()> {
558 if self.version != CHAT_SPEC_VERSION {
559 return Err(Error::Config(format!(
560 "unsupported chat configuration version {}",
561 self.version
562 )));
563 }
564 if self.agent.revision == 0 {
565 return Err(Error::Config(
566 "chat configuration revision must be positive".into(),
567 ));
568 }
569 let workspace = validate_chat_workspace(&self.workspace, state_dir, tls)?;
570 if workspace != self.workspace {
571 return Err(Error::Config(
572 "chat workspace must use its canonical path".into(),
573 ));
574 }
575 validate_agent_composition(&self.agent.config)
576 }
577}
578
579fn clear_missing_model_routes(
580 composition: &mut AgentComposition,
581 gateway: &GatewayConfig,
582) -> Result<bool> {
583 let routes = crate::middleware_manifest::configured_model_routes(&composition.middleware)
584 .into_iter()
585 .map(|(middleware, setting, route)| {
586 (middleware.to_owned(), setting.to_owned(), route.to_owned())
587 })
588 .collect::<Vec<_>>();
589 let mut changed = false;
590 for (middleware, setting, route) in routes {
591 if !crate::provider_catalog::configured_route_exists(gateway, &route)? {
592 composition
593 .middleware
594 .set_setting(middleware, setting, None);
595 changed = true;
596 }
597 }
598 Ok(changed)
599}
600
601impl TlsConfig {
602 fn validate(&self) -> Result<()> {
603 for (name, path) in [
604 ("TLS certificate", &self.certificate),
605 ("TLS private key", &self.private_key),
606 ] {
607 if !path.is_absolute() || !path.is_file() {
608 return Err(Error::Config(format!(
609 "{name} must be an existing absolute file"
610 )));
611 }
612 }
613 Ok(())
614 }
615}
616
617impl CloudflareConfig {
618 pub fn named(hostname: &str) -> Result<Self> {
620 let hostname = hostname.trim().to_ascii_lowercase();
621 let config = Self::Named { hostname };
622 config.validate()?;
623 Ok(config)
624 }
625
626 #[must_use]
628 pub fn endpoint(&self) -> Option<String> {
629 self.hostname().map(|hostname| format!("wss://{hostname}"))
630 }
631
632 #[must_use]
634 pub fn hostname(&self) -> Option<&str> {
635 match self {
636 Self::Quick => None,
637 Self::Named { hostname } => Some(hostname),
638 }
639 }
640
641 pub fn validate_token(token: &str) -> Result<()> {
643 validate_cloudflare_token(token).map(|_| ())
644 }
645
646 fn validate(&self) -> Result<()> {
647 if let Self::Named { hostname } = self
648 && (hostname.len() > 253
649 || !hostname.is_ascii()
650 || hostname != &hostname.to_ascii_lowercase()
651 || !hostname.contains('.')
652 || !hostname.split('.').all(valid_hostname_label))
653 {
654 return Err(invalid_cloudflare_hostname());
655 }
656 Ok(())
657 }
658}
659
660#[cfg(test)]
661mod tests;