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::{
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
65/// Default loopback listener used by a local gateway.
66pub const DEFAULT_LISTEN: SocketAddr =
67    SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 8741);
68
69/// Default system prompt installed by `mobius-gateway init`.
70pub const DEFAULT_SYSTEM_PROMPT: &str = defaults::DEFAULT_SYSTEM_PROMPT;
71
72/// Context window used for custom models without an advertised preset.
73pub const DEFAULT_CONTEXT_WINDOW: i64 = defaults::DEFAULT_CONTEXT_WINDOW;
74
75/// Certificate paths required by a TLS listener.
76#[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/// Cloudflare Tunnel exposure selected for this gateway.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
86pub enum CloudflareConfig {
87    /// Account-free tunnel with an address assigned at process startup.
88    Quick,
89    /// User-owned tunnel with a stable published hostname.
90    Named { hostname: String },
91}
92
93/// Durable machine-wide settings and defaults for one gateway process.
94#[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/// One durable provider selection and its gateway model and reasoning catalogs.
108#[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/// Runtime recipe resolved from one durable Bot profile and chat workspace.
119#[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    /// Builds validated machine-wide settings and Bot-creation defaults.
168    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    /// Builds a loopback gateway exposed through Cloudflare Tunnel.
184    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    /// Registers one configured provider and establishes the first Bot defaults.
192    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    /// Removes one non-default provider and lets middleware inherit the primary model.
233    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    /// Replaces only the defaults copied into future chats.
272    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    /// Records one live token-usage increment and reports whether daily usage changed.
311    pub fn observe_usage(&mut self, provider: &str, usage: &TokenUsage) -> Result<bool> {
312        self.usage.observe(provider, usage, SystemTime::now())
313    }
314
315    /// Returns frontend-safe local identity and daily aggregate usage.
316    #[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    /// Validates every persisted trust-boundary field.
338    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    /// Validates and normalizes a stable public hostname.
549    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    /// Returns the stable endpoint when one exists before startup.
557    #[must_use]
558    pub fn endpoint(&self) -> Option<String> {
559        self.hostname().map(|hostname| format!("wss://{hostname}"))
560    }
561
562    /// Returns the stable hostname when this is a named tunnel.
563    #[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    /// Validates one tunnel-scoped connector token without retaining it.
572    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;