Skip to main content

mobius_gateway/
config.rs

1//! Validated gateway configuration and owner-only persistence.
2
3mod 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
63/// Default loopback listener used by a local gateway.
64pub const DEFAULT_LISTEN: SocketAddr =
65    SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 8741);
66
67/// Default system prompt installed by `mobius-gateway init`.
68pub const DEFAULT_SYSTEM_PROMPT: &str = include_str!("config/default_prompt.md");
69
70/// Context window used for custom models without an advertised preset.
71pub const DEFAULT_CONTEXT_WINDOW: i64 = 272_000;
72
73/// Certificate paths required by a TLS listener.
74#[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/// Cloudflare Tunnel exposure selected for this gateway.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
84pub enum CloudflareConfig {
85    /// Account-free tunnel with an address assigned at process startup.
86    Quick,
87    /// User-owned tunnel with a stable published hostname.
88    Named { hostname: String },
89}
90
91/// Durable machine-wide settings and defaults for one gateway process.
92#[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/// One durable provider selection and its gateway model and reasoning catalogs.
106#[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/// Chat ownership and workspace, independent of Bot configuration.
117#[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    /// Builds validated machine-wide settings and Bot-creation defaults.
166    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    /// Builds a loopback gateway exposed through Cloudflare Tunnel.
182    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    /// Registers one configured provider and establishes the first Bot defaults.
190    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    /// Removes one non-default provider and lets middleware inherit the primary model.
231    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    /// Replaces only the defaults copied into future chats.
270    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    /// Records one live token-usage increment and reports whether daily usage changed.
309    pub fn observe_usage(&mut self, provider: &str, usage: &TokenUsage) -> Result<bool> {
310        self.usage.observe(provider, usage, SystemTime::now())
311    }
312
313    /// Returns frontend-safe local identity and daily aggregate usage.
314    #[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    /// Validates every persisted trust-boundary field.
337    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    /// Validates and normalizes a stable public hostname.
584    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    /// Returns the stable endpoint when one exists before startup.
592    #[must_use]
593    pub fn endpoint(&self) -> Option<String> {
594        self.hostname().map(|hostname| format!("wss://{hostname}"))
595    }
596
597    /// Returns the stable hostname when this is a named tunnel.
598    #[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    /// Validates one tunnel-scoped connector token without retaining it.
607    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;