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