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