Skip to main content

mobius_gateway/config/
store.rs

1use super::*;
2use mobius::backend::model::ModelCredentialLifetime;
3
4/// File owner for gateway configuration and aggregate usage.
5#[derive(Debug, Clone)]
6pub struct ConfigStore {
7    #[cfg(test)]
8    pub(crate) runtime_operations: std::sync::Arc<RuntimeOperations>,
9    state_dir: PathBuf,
10    path: PathBuf,
11}
12
13/// Owner-only API-key storage kept outside frontend-readable configuration.
14pub struct CredentialStore {
15    path: PathBuf,
16    values: Mutex<BTreeMap<String, StoredCredential>>,
17}
18
19/// A credential resolved atomically with the lifetime of the same stored secret.
20#[derive(Clone)]
21pub struct ResolvedCredential {
22    /// Secret used only for provider assembly.
23    pub api_key: String,
24    /// Expiry and revocation shared by all routes using this secret.
25    pub lifetime: ModelCredentialLifetime,
26}
27
28#[derive(Clone, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30struct StoredCredential {
31    provider: String,
32    api_key: String,
33    base_url: Option<String>,
34    expires_at: Option<u64>,
35    #[serde(skip)]
36    revocation: Option<tokio::sync::watch::Sender<()>>,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub(super) struct UsageHistory {
42    pub(super) days: BTreeMap<u64, BTreeMap<String, TokenUsage>>,
43}
44
45impl ConfigStore {
46    /// Initializes an owner-only state directory and new config file.
47    /// # Errors
48    ///
49    /// Returns an error if configuration is invalid or a required resource cannot be initialized.
50    pub fn initialize(
51        state_dir: PathBuf,
52        listen: SocketAddr,
53        tls: Option<TlsConfig>,
54    ) -> Result<(Self, GatewayConfig)> {
55        let config = GatewayConfig::new(listen, tls)?;
56        let state_dir = prepare_state_dir(state_dir)?;
57        let store = Self::at(state_dir);
58        store.save_with_mode(&config, true)?;
59        Ok((store, config))
60    }
61
62    /// Initializes state for an account-free Cloudflare Quick Tunnel.
63    /// # Errors
64    ///
65    /// Returns an error if configuration is invalid or a required resource cannot be initialized.
66    pub fn initialize_quick_cloudflare(
67        state_dir: PathBuf,
68        listen: SocketAddr,
69    ) -> Result<(Self, GatewayConfig)> {
70        Self::initialize_cloudflare(state_dir, listen, CloudflareConfig::Quick, None)
71    }
72
73    /// Initializes state for one user-owned Cloudflare Tunnel.
74    /// # Errors
75    ///
76    /// Returns an error if configuration is invalid or a required resource cannot be initialized.
77    pub fn initialize_named_cloudflare(
78        state_dir: PathBuf,
79        listen: SocketAddr,
80        hostname: &str,
81        token: &str,
82    ) -> Result<(Self, GatewayConfig)> {
83        Self::initialize_cloudflare(
84            state_dir,
85            listen,
86            CloudflareConfig::named(hostname)?,
87            Some(validate_cloudflare_token(token)?),
88        )
89    }
90
91    fn initialize_cloudflare(
92        state_dir: PathBuf,
93        listen: SocketAddr,
94        cloudflare: CloudflareConfig,
95        token: Option<&str>,
96    ) -> Result<(Self, GatewayConfig)> {
97        let config = GatewayConfig::new_cloudflare(listen, cloudflare)?;
98        let state_dir = prepare_state_dir(state_dir)?;
99        let store = Self::at(state_dir);
100        let result = token
101            .map_or(Ok(()), |token| store.save_cloudflare_token(token))
102            .and_then(|()| store.save_with_mode(&config, true));
103        if let Err(error) = result {
104            fs::remove_dir_all(&store.state_dir).map_err(|cleanup| {
105                Error::Config(format!(
106                    "{error}; failed to remove incomplete gateway state at {}: {cleanup}",
107                    store.state_dir.display()
108                ))
109            })?;
110            return Err(error);
111        }
112        Ok((store, config))
113    }
114
115    /// Opens and validates persisted gateway configuration.
116    /// # Errors
117    ///
118    /// Returns an error if the resource cannot be read, decoded, or validated.
119    pub fn open(state_dir: PathBuf) -> Result<(Self, GatewayConfig)> {
120        let state_dir = fs::canonicalize(state_dir)?;
121        validate_private_state_dir(&state_dir)?;
122        let store = Self::at(state_dir);
123        let mut file = fs::File::open(&store.path)?;
124        let mut contents = Vec::new();
125        std::io::Read::by_ref(&mut file)
126            .take(MAX_CONFIG_BYTES + 1)
127            .read_to_end(&mut contents)?;
128        if u64::try_from(contents.len()).unwrap_or(u64::MAX) > MAX_CONFIG_BYTES {
129            return Err(Error::Config("gateway configuration is too large".into()));
130        }
131        let config = toml::from_slice(&contents).map_err(|error| {
132            Error::Config(format!(
133                "gateway state at {} is incompatible with this release; remove that directory and run `mobius` again: {error}",
134                store.state_dir.display()
135            ))
136        })?;
137        store.validate_config(&config)?;
138        Ok((store, config))
139    }
140
141    /// Atomically replaces validated persistent configuration.
142    /// # Errors
143    ///
144    /// Returns an error if the value cannot be encoded or persisted.
145    pub fn save(&self, config: &GatewayConfig) -> Result<()> {
146        self.save_with_mode(config, false)
147    }
148
149    /// Returns the protected state directory.
150    #[must_use]
151    pub fn state_dir(&self) -> &Path {
152        &self.state_dir
153    }
154
155    /// Returns the owner-managed extension store outside sandbox-masked gateway state.
156    #[must_use]
157    pub(crate) fn extensions_path(&self) -> PathBuf {
158        crate::extensions::extensions_path(&self.state_dir)
159    }
160
161    /// Returns the provider credential file path.
162    #[must_use]
163    pub fn credentials_path(&self) -> PathBuf {
164        self.state_dir.join("credentials.json")
165    }
166
167    /// Returns the provider browser-auth file path.
168    #[must_use]
169    pub fn provider_auth_path(&self) -> PathBuf {
170        self.state_dir.join("provider-auth.json")
171    }
172
173    /// Returns the checkpoint database path.
174    #[must_use]
175    pub fn checkpoints_path(&self) -> PathBuf {
176        self.state_dir.join("checkpoints.sqlite3")
177    }
178
179    /// Returns the authentication state path.
180    #[must_use]
181    pub fn auth_path(&self) -> PathBuf {
182        self.state_dir.join("auth.json")
183    }
184
185    /// Returns the owner-only Cloudflare connector-token path.
186    #[must_use]
187    pub fn cloudflare_token_path(&self) -> PathBuf {
188        self.state_dir.join(CLOUDFLARE_TOKEN_FILE)
189    }
190
191    fn at(state_dir: PathBuf) -> Self {
192        let path = state_dir.join(CONFIG_FILE);
193        Self {
194            state_dir,
195            path,
196            #[cfg(test)]
197            runtime_operations: Default::default(),
198        }
199    }
200
201    fn save_with_mode(&self, config: &GatewayConfig, create_new: bool) -> Result<()> {
202        self.validate_config(config)?;
203        let config = toml::to_string_pretty(config).map_err(|error| {
204            Error::Config(format!("cannot encode gateway configuration: {error}"))
205        })?;
206        let contents = config;
207        if u64::try_from(contents.len()).unwrap_or(u64::MAX) > MAX_CONFIG_BYTES {
208            return Err(Error::Config("gateway configuration is too large".into()));
209        }
210        crate::publication::publish(&self.path, contents.as_bytes(), create_new)
211    }
212
213    fn validate_config(&self, config: &GatewayConfig) -> Result<()> {
214        config.validate()?;
215        if matches!(
216            config.cloudflare.as_ref(),
217            Some(CloudflareConfig::Named { .. })
218        ) {
219            load_secret_file(&self.cloudflare_token_path())?;
220        }
221        Ok(())
222    }
223
224    fn save_cloudflare_token(&self, token: &str) -> Result<()> {
225        let token = validate_cloudflare_token(token)?;
226        crate::publication::publish(&self.cloudflare_token_path(), token.as_bytes(), true)
227    }
228}
229
230impl CredentialStore {
231    /// Opens credential state, treating a missing file as an empty store.
232    /// # Errors
233    ///
234    /// Returns an error if the resource cannot be read, decoded, or validated.
235    pub fn open(path: PathBuf) -> Result<Self> {
236        let values = match fs::read(&path) {
237            Ok(contents) => {
238                if contents.len() > MAX_CREDENTIAL_STATE_BYTES {
239                    return Err(Error::Config(
240                        "provider credential state is too large".into(),
241                    ));
242                }
243                serde_json::from_slice(&contents)?
244            }
245            Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
246            Err(error) => return Err(error.into()),
247        };
248        validate_credential_state(&values)?;
249        Ok(Self {
250            path,
251            values: Mutex::new(values),
252        })
253    }
254
255    /// Atomically replaces one instance's API key after provider and size validation.
256    /// # Errors
257    ///
258    /// Returns an error if validation or an operation required by this function fails.
259    pub fn set(
260        &self,
261        instance: &str,
262        provider_id: &str,
263        api_key: &str,
264        base_url: Option<&str>,
265        expires_at: Option<u64>,
266    ) -> Result<()> {
267        let api_key = api_key.trim();
268        validate_new_api_key(api_key)?;
269        let credential = StoredCredential {
270            provider: provider_id.into(),
271            api_key: api_key.into(),
272            base_url: base_url.map(str::to_owned),
273            expires_at,
274            revocation: None,
275        };
276        validate_stored_credential(instance, &credential)?;
277        let mut values = self
278            .values
279            .lock()
280            .map_err(|_| Error::Config("provider credential lock is poisoned".into()))?;
281        if let Some(credential) = values.get(instance)
282            && credential.provider != provider_id
283        {
284            return Err(Error::Config(format!(
285                "provider instance `{instance}` already belongs to `{}`",
286                credential.provider
287            )));
288        }
289        if values.get(instance).is_some_and(|current| {
290            current.provider == credential.provider
291                && current.api_key == credential.api_key
292                && current.base_url == credential.base_url
293                && current.expires_at == credential.expires_at
294        }) {
295            return Ok(());
296        }
297        let mut next = values.clone();
298        next.insert(instance.into(), credential);
299        save_private_map(&self.path, &next)?;
300        *values = next;
301        Ok(())
302    }
303
304    /// Resolves a secret and its lifetime under the same lock.
305    /// # Errors
306    ///
307    /// Returns an error if validation or an operation required by this function fails.
308    pub fn get(
309        &self,
310        instance: &str,
311        provider_id: &str,
312        base_url: Option<&str>,
313    ) -> Result<Option<ResolvedCredential>> {
314        let mut values = self
315            .values
316            .lock()
317            .map_err(|_| Error::Config("provider credential lock is poisoned".into()))?;
318        let Some(credential) = values.get_mut(instance).filter(|credential| {
319            credential.provider == provider_id && credential.base_url.as_deref() == base_url
320        }) else {
321            return Ok(None);
322        };
323        let revoked = credential
324            .revocation
325            .get_or_insert_with(|| tokio::sync::watch::channel(()).0)
326            .subscribe();
327        Ok(Some(ResolvedCredential {
328            api_key: credential.api_key.clone(),
329            lifetime: ModelCredentialLifetime {
330                expires_at: credential
331                    .expires_at
332                    .map(|seconds| UNIX_EPOCH + std::time::Duration::from_secs(seconds)),
333                revoked: Some(revoked),
334            },
335        }))
336    }
337
338    /// Returns a non-secret suffix for identifying one stored credential.
339    pub(crate) fn hint(
340        &self,
341        instance: &str,
342        provider_id: &str,
343        base_url: Option<&str>,
344    ) -> Result<Option<String>> {
345        let values = self
346            .values
347            .lock()
348            .map_err(|_| Error::Config("provider credential lock is poisoned".into()))?;
349        Ok(values
350            .get(instance)
351            .filter(|credential| {
352                credential.provider == provider_id && credential.base_url.as_deref() == base_url
353            })
354            .and_then(|credential| {
355                let suffix = credential.api_key.chars().rev().take(4).collect::<String>();
356                (suffix.chars().count() == 4).then(|| suffix.chars().rev().collect())
357            }))
358    }
359
360    /// Atomically removes one instance-scoped API-key credential.
361    /// # Errors
362    ///
363    /// Returns an error if validation or an operation required by this function fails.
364    pub fn remove(&self, instance: &str) -> Result<bool> {
365        super::validation::validate_instance_id(instance)?;
366        let mut values = self
367            .values
368            .lock()
369            .map_err(|_| Error::Config("provider credential lock is poisoned".into()))?;
370        if !values.contains_key(instance) {
371            return Ok(false);
372        }
373        let mut next = values.clone();
374        next.remove(instance);
375        save_private_map(&self.path, &next)?;
376        *values = next;
377        Ok(true)
378    }
379}
380
381/// Resolves the gateway state directory from the environment or home directory.
382/// # Errors
383///
384/// Returns an error if validation or an operation required by this function fails.
385pub fn state_dir() -> Result<PathBuf> {
386    if let Some(path) = env::var_os("MOBIUS_GATEWAY_STATE_DIR") {
387        if path.is_empty() {
388            return Err(Error::Config("MOBIUS_GATEWAY_STATE_DIR is empty".into()));
389        }
390        return Ok(path.into());
391    }
392    env::home_dir()
393        .filter(|path| !path.as_os_str().is_empty())
394        .map(|path| path.join(".mobius").join("gateway"))
395        .ok_or_else(|| {
396            Error::Config(
397                "cannot determine the home directory; set MOBIUS_GATEWAY_STATE_DIR".into(),
398            )
399        })
400}
401
402/// Loads a connector token from an owner-only regular file without exposing its contents.
403/// # Errors
404///
405/// Returns an error if the resource cannot be read, decoded, or validated.
406pub fn load_secret_file(path: &Path) -> Result<String> {
407    #[cfg(unix)]
408    let file = fs::OpenOptions::new()
409        .read(true)
410        .custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK)
411        .open(path)
412        .map_err(|error| -> Error {
413            if error.raw_os_error() == Some(nix::libc::ELOOP) {
414                Error::Config("secret must be stored in a regular file".into())
415            } else {
416                error.into()
417            }
418        })?;
419    #[cfg(not(unix))]
420    let file = {
421        let metadata = fs::symlink_metadata(path)?;
422        if !metadata.file_type().is_file() {
423            return Err(Error::Config(
424                "secret must be stored in a regular file".into(),
425            ));
426        }
427        fs::File::open(path)?
428    };
429    let metadata = file.metadata()?;
430    if !metadata.file_type().is_file() {
431        return Err(Error::Config(
432            "secret must be stored in a regular file".into(),
433        ));
434    }
435    #[cfg(unix)]
436    if metadata.permissions().mode() & 0o077 != 0 {
437        return Err(Error::Config(
438            "secret file must not be accessible by group or others (use mode 0600)".into(),
439        ));
440    }
441    if metadata.len() > MAX_CLOUDFLARE_TOKEN_BYTES as u64 {
442        return Err(Error::Config("secret file token is too large".into()));
443    }
444    let mut contents = String::new();
445    file.take(MAX_CLOUDFLARE_TOKEN_BYTES as u64 + 1)
446        .read_to_string(&mut contents)?;
447    let token = validate_cloudflare_token(&contents)
448        .map_err(|_| Error::Config("secret file contains an invalid token".into()))?;
449    Ok(token.to_owned())
450}
451
452fn prepare_state_dir(path: PathBuf) -> Result<PathBuf> {
453    let name = path
454        .file_name()
455        .ok_or_else(|| Error::Config("gateway state directory must have a name".into()))?
456        .to_owned();
457    let parent = path
458        .parent()
459        .filter(|parent| !parent.as_os_str().is_empty())
460        .unwrap_or(Path::new("."));
461    fs::create_dir_all(parent)?;
462    let path = fs::canonicalize(parent)?.join(name);
463    match fs::create_dir(&path) {
464        Ok(()) => {}
465        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
466            return Err(Error::Config(
467                "gateway state directory already exists".into(),
468            ));
469        }
470        Err(error) => return Err(error.into()),
471    }
472    #[cfg(unix)]
473    fs::set_permissions(&path, mobius::owner_only::dir())?;
474    Ok(path)
475}
476
477fn validate_private_state_dir(path: &Path) -> Result<()> {
478    let metadata = fs::metadata(path)?;
479    if !metadata.is_dir() {
480        return Err(Error::Config(
481            "gateway state path must be a directory".into(),
482        ));
483    }
484    #[cfg(unix)]
485    if metadata.permissions().mode() & 0o077 != 0 {
486        return Err(Error::Config(
487            "gateway state directory must not be accessible by group or others (use mode 0700)"
488                .into(),
489        ));
490    }
491    Ok(())
492}
493
494fn validate_stored_credential(instance: &str, credential: &StoredCredential) -> Result<()> {
495    if credential
496        .expires_at
497        .is_some_and(|seconds| seconds == 0 || seconds > 253_402_300_799)
498    {
499        return Err(Error::Config(
500            "credential expiry must be a valid Unix timestamp".into(),
501        ));
502    }
503    super::validation::validate_instance_id(instance)?;
504    let definition = provider(&credential.provider)?;
505    if !matches!(definition.auth(), ProviderAuth::ApiKey(_)) {
506        return Err(Error::Config(format!(
507            "provider `{}` does not accept an API key",
508            credential.provider
509        )));
510    }
511    if credential.api_key.trim().is_empty() || credential.api_key.len() > MAX_PROVIDER_API_KEY_BYTES
512    {
513        return Err(Error::Config(format!(
514            "API key must be 1–{MAX_PROVIDER_API_KEY_BYTES} bytes"
515        )));
516    }
517    definition.validate_base_url(credential.base_url.as_deref())?;
518    Ok(())
519}
520
521fn validate_new_api_key(api_key: &str) -> Result<()> {
522    if api_key.is_empty() || api_key.len() > MAX_PROVIDER_API_KEY_BYTES {
523        return Err(Error::Config(format!(
524            "API key must be 1–{MAX_PROVIDER_API_KEY_BYTES} bytes"
525        )));
526    }
527    if !api_key.bytes().all(|byte| byte.is_ascii_graphic()) {
528        return Err(Error::Config(
529            "API key must contain only visible ASCII characters without whitespace".into(),
530        ));
531    }
532    Ok(())
533}
534
535fn validate_credential_state(values: &BTreeMap<String, StoredCredential>) -> Result<()> {
536    for (instance, credential) in values {
537        validate_stored_credential(instance, credential)?;
538    }
539    Ok(())
540}
541
542fn save_private_map(path: &Path, values: &BTreeMap<String, StoredCredential>) -> Result<()> {
543    validate_credential_state(values)?;
544    let contents = serde_json::to_vec(values)?;
545    if contents.len() > MAX_CREDENTIAL_STATE_BYTES {
546        return Err(Error::Config(
547            "provider credential state is too large".into(),
548        ));
549    }
550    crate::publication::publish(path, &contents, false)
551}
552
553impl UsageHistory {
554    pub(super) fn observe(
555        &mut self,
556        provider: &str,
557        usage: &TokenUsage,
558        now: SystemTime,
559    ) -> Result<bool> {
560        validate_usage_provider(provider)?;
561        validate_usage(usage)?;
562        if usage == &TokenUsage::default() {
563            return Ok(false);
564        }
565        let day = unix_day(now)?;
566        let mut bucket = self
567            .days
568            .get(&day)
569            .and_then(|providers| providers.get(provider))
570            .cloned()
571            .unwrap_or_default();
572        bucket
573            .checked_add(usage)
574            .ok_or_else(|| Error::Config("daily token usage overflow".into()))?;
575        self.days
576            .entry(day)
577            .or_default()
578            .insert(provider.into(), bucket);
579        let first_day = day.saturating_sub(USAGE_HISTORY_DAYS - 1);
580        self.days.retain(|stored, _| *stored >= first_day);
581        Ok(true)
582    }
583}
584
585pub(super) fn unix_day(now: SystemTime) -> Result<u64> {
586    Ok(now
587        .duration_since(UNIX_EPOCH)
588        .map_err(|_| Error::Config("system clock is before the Unix epoch".into()))?
589        .as_secs()
590        / SECONDS_PER_DAY)
591}
592
593pub(super) fn validate_usage(usage: &TokenUsage) -> Result<()> {
594    if !usage_nonnegative(usage) {
595        return Err(Error::Config("token usage cannot be negative".into()));
596    }
597    Ok(())
598}
599
600pub(super) fn validate_usage_provider(provider: &str) -> Result<()> {
601    if provider.trim().is_empty()
602        || provider != provider.trim()
603        || provider.len() > 256
604        || provider.chars().any(char::is_control)
605    {
606        return Err(Error::Config(
607            "usage provider ID must be canonical and 1–256 bytes".into(),
608        ));
609    }
610    Ok(())
611}
612
613fn usage_nonnegative(usage: &TokenUsage) -> bool {
614    usage.input_tokens >= 0
615        && usage.cached_input_tokens >= 0
616        && usage.cache_write_input_tokens >= 0
617        && usage.output_tokens >= 0
618        && usage.reasoning_output_tokens >= 0
619        && usage.total_tokens >= 0
620}
621
622#[cfg(test)]
623#[derive(Debug, Default)]
624pub(crate) struct RuntimeOperations {
625    pub(crate) preparations: std::sync::atomic::AtomicUsize,
626    pub(crate) assemblies: std::sync::atomic::AtomicUsize,
627}
628
629#[cfg(test)]
630impl RuntimeOperations {
631    pub(crate) fn counts(&self) -> (usize, usize) {
632        use std::sync::atomic::Ordering::Relaxed;
633        (
634            self.preparations.load(Relaxed),
635            self.assemblies.load(Relaxed),
636        )
637    }
638}