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 _, 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
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 = defaults::DEFAULT_SYSTEM_PROMPT;
69
70/// Context window used for custom models without an advertised preset.
71pub const DEFAULT_CONTEXT_WINDOW: i64 = defaults::DEFAULT_CONTEXT_WINDOW;
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 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/// 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/// Durable runtime recipe copied into one chat checkpoint.
117#[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    /// Builds validated machine-wide settings and new-chat defaults.
155    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    /// Builds a loopback gateway exposed through Cloudflare Tunnel.
171    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    /// Registers one configured provider and establishes the first as the new-chat default.
179    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    /// Removes one non-default provider and lets middleware inherit the primary model.
220    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    /// Replaces a same-provider new-chat default during an explicit fleet cutover.
259    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    /// Replaces only the defaults copied into future chats.
285    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    /// Records one live token-usage increment and reports whether daily usage changed.
324    pub fn observe_usage(&mut self, provider: &str, usage: &TokenUsage) -> Result<bool> {
325        self.usage.observe(provider, usage, SystemTime::now())
326    }
327
328    /// Returns frontend-safe local identity and daily aggregate usage.
329    #[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    /// Validates every persisted trust-boundary field.
351    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    /// Falls back from provider entries removed since this chat last ran.
526    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    /// Validates and normalizes a stable public hostname.
619    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    /// Returns the stable endpoint when one exists before startup.
627    #[must_use]
628    pub fn endpoint(&self) -> Option<String> {
629        self.hostname().map(|hostname| format!("wss://{hostname}"))
630    }
631
632    /// Returns the stable hostname when this is a named tunnel.
633    #[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    /// Validates one tunnel-scoped connector token without retaining it.
642    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;