1use super::*;
2
3#[derive(Debug, Clone)]
5pub struct ConfigStore {
6 state_dir: PathBuf,
7 path: PathBuf,
8}
9
10pub 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 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 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 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 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 pub fn save(&self, config: &GatewayConfig) -> Result<()> {
116 self.save_with_mode(config, false)
117 }
118
119 #[must_use]
121 pub fn state_dir(&self) -> &Path {
122 &self.state_dir
123 }
124
125 #[must_use]
127 pub(crate) fn extensions_path(&self) -> PathBuf {
128 crate::extensions::extensions_path(&self.state_dir)
129 }
130
131 #[must_use]
133 pub fn credentials_path(&self) -> PathBuf {
134 self.state_dir.join("credentials.json")
135 }
136
137 #[must_use]
139 pub fn provider_auth_path(&self) -> PathBuf {
140 self.state_dir.join("provider-auth.json")
141 }
142
143 #[must_use]
145 pub fn checkpoints_path(&self) -> PathBuf {
146 self.state_dir.join("checkpoints.sqlite3")
147 }
148
149 #[must_use]
151 pub fn auth_path(&self) -> PathBuf {
152 self.state_dir.join("auth.json")
153 }
154
155 #[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 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 ) -> 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 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 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 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
331pub 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
351pub 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}