1use super::*;
2use mobius::backend::model::ModelCredentialLifetime;
3
4#[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
13pub struct CredentialStore {
15 path: PathBuf,
16 values: Mutex<BTreeMap<String, StoredCredential>>,
17}
18
19#[derive(Clone)]
21pub struct ResolvedCredential {
22 pub api_key: String,
24 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 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 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 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 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 pub fn save(&self, config: &GatewayConfig) -> Result<()> {
146 self.save_with_mode(config, false)
147 }
148
149 #[must_use]
151 pub fn state_dir(&self) -> &Path {
152 &self.state_dir
153 }
154
155 #[must_use]
157 pub(crate) fn extensions_path(&self) -> PathBuf {
158 crate::extensions::extensions_path(&self.state_dir)
159 }
160
161 #[must_use]
163 pub fn credentials_path(&self) -> PathBuf {
164 self.state_dir.join("credentials.json")
165 }
166
167 #[must_use]
169 pub fn provider_auth_path(&self) -> PathBuf {
170 self.state_dir.join("provider-auth.json")
171 }
172
173 #[must_use]
175 pub fn checkpoints_path(&self) -> PathBuf {
176 self.state_dir.join("checkpoints.sqlite3")
177 }
178
179 #[must_use]
181 pub fn auth_path(&self) -> PathBuf {
182 self.state_dir.join("auth.json")
183 }
184
185 #[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 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 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 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 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 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
381pub 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
402pub 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}