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