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(
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 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 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 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 pub fn save(&self, config: &GatewayConfig) -> Result<()> {
131 self.save_with_mode(config, false)
132 }
133
134 #[must_use]
136 pub fn state_dir(&self) -> &Path {
137 &self.state_dir
138 }
139
140 #[must_use]
142 pub(crate) fn extensions_path(&self) -> PathBuf {
143 crate::extensions::extensions_path(&self.state_dir)
144 }
145
146 #[must_use]
148 pub fn credentials_path(&self) -> PathBuf {
149 self.state_dir.join("credentials.json")
150 }
151
152 #[must_use]
154 pub fn provider_auth_path(&self) -> PathBuf {
155 self.state_dir.join("provider-auth.json")
156 }
157
158 #[must_use]
160 pub fn checkpoints_path(&self) -> PathBuf {
161 self.state_dir.join("checkpoints.sqlite3")
162 }
163
164 #[must_use]
166 pub fn auth_path(&self) -> PathBuf {
167 self.state_dir.join("auth.json")
168 }
169
170 #[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 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 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 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 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 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
354pub 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
374pub 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}