Skip to main content

mobius_gateway/config/
store.rs

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