1mod runtime;
4mod store;
5mod validation;
6mod workspace;
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::env;
10use std::fs;
11use std::io::Read as _;
12use std::net::SocketAddr;
13#[cfg(unix)]
14use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
15use std::path::{Component, Path, PathBuf};
16use std::sync::Mutex;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use mobius::agent::DEFAULT_MAX_MODEL_STEPS;
20use mobius::backend::model::provider::{
21 ProviderAuth, ProviderDefinition, default_provider, provider,
22};
23use mobius::protocol::TokenUsage;
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26use sha2::Digest as _;
27
28#[cfg(test)]
29use crate::wire::RoutineInteractionPolicy;
30use crate::wire::{
31 AgentComposition, DailyUsage, ProfileSnapshot, ProviderConfig, ProviderEndpointAuth,
32 ProviderTint, VersionedAgentConfig, WorkspaceInfo,
33};
34use crate::{Error, Result};
35
36pub use self::runtime::RuntimeConfig;
37use self::store::*;
38pub use self::store::{
39 ConfigStore, CredentialStore, ResolvedCredential, load_secret_file, state_dir,
40};
41pub use self::validation::validate_agent_composition;
42use self::validation::*;
43pub(crate) use self::validation::{
44 configured_approval_policy, effective_reasoning_effort, model_route_id,
45 validate_bot_compatibility, validate_desktop_bot_policy,
46};
47pub(crate) use self::workspace::{
48 create_workspace_directory, local_user_name, validate_chat_workspace, workspace_id,
49};
50pub use crate::server::ConnectionPolicy;
51
52const CONFIG_VERSION: u32 = 26;
53pub(crate) const MAX_CAPACITY: usize = 4_096;
54
55pub(crate) fn bounded<T>(name: &str, value: T, range: std::ops::RangeInclusive<T>) -> Result<()>
56where
57 T: Copy + PartialOrd + std::fmt::Display,
58{
59 if !range.contains(&value) {
60 return Err(Error::Config(format!(
61 "{name} must be between {} and {}",
62 range.start(),
63 range.end()
64 )));
65 }
66 Ok(())
67}
68const CHAT_SPEC_VERSION: u32 = 15;
69pub(crate) const CHAT_SPEC_METADATA_KEY: &str = "mobius_gateway.chat";
70const CONFIG_FILE: &str = "gateway.toml";
71const CLOUDFLARE_TOKEN_FILE: &str = "cloudflare-token";
72const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
73const MAX_CREDENTIAL_STATE_BYTES: usize = 256 * 1024;
74const MAX_SYSTEM_PROMPT_BYTES: usize = 64 * 1024;
75pub const MAX_PROVIDER_API_KEY_BYTES: usize = 16 * 1024;
77const MAX_PROVIDER_CATALOG_ENTRIES: usize = 64;
78const MAX_PROVIDER_CATALOG_ENTRY_BYTES: usize = 1024;
79const MAX_PROVIDER_CATALOG_BYTES: usize = 16 * 1024;
80const MAX_CUSTOM_MODEL_ROUTES: usize = 64;
81pub const MAX_CLOUDFLARE_TOKEN_BYTES: usize = 16 * 1024;
83pub const MAX_PROVIDER_LABEL_BYTES: usize = 128;
85const MAX_WORKSPACE_DIRECTORY_NAME_BYTES: usize = 255;
86const MAX_ATTACHED_FOLDERS: usize = 8;
87const SECONDS_PER_DAY: u64 = 86_400;
88const USAGE_HISTORY_DAYS: u64 = 52 * 7;
89
90pub const DEFAULT_LISTEN: SocketAddr =
92 SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 8741);
93
94pub const DEFAULT_SYSTEM_PROMPT: &str = include_str!("config/default_prompt.md");
96
97pub const DEFAULT_CONTEXT_WINDOW: i64 = 272_000;
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct TlsConfig {
104 pub certificate: PathBuf,
106 pub private_key: PathBuf,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
113pub enum CloudflareConfig {
114 Quick,
116 Named {
118 hostname: String,
120 },
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct GatewayConfig {
127 version: u32,
128 #[serde(default)]
130 pub runtime: RuntimeConfig,
131 #[serde(default)]
133 pub connections: ConnectionPolicy,
134 #[serde(default)]
136 pub auth: crate::auth::AuthConfig,
137 #[serde(default)]
139 pub computer: crate::computer_runtime::ComputerConfig,
140 #[serde(default)]
142 pub model_transport: mobius::backend::model::ModelTransportSettings,
143 #[serde(default)]
145 pub execution: crate::sandbox::ExecutionConfig,
146 #[serde(default)]
148 pub telemetry: crate::telemetry::TelemetryConfig,
149 pub listen: SocketAddr,
151 pub tls: Option<TlsConfig>,
153 pub cloudflare: Option<CloudflareConfig>,
155 #[serde(default)]
157 pub desktop_enabled: bool,
158 pub bot_defaults: Option<VersionedAgentConfig>,
160 pub(crate) configured_providers: BTreeMap<String, ConfiguredProvider>,
161 pub(crate) installed_extensions: BTreeMap<String, crate::extensions::InstalledExtension>,
162 usage: UsageHistory,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub(crate) struct ConfiguredProvider {
169 pub(crate) selection: ProviderConfig,
170 pub(crate) label: String,
171 pub(crate) tint: ProviderTint,
172 pub(crate) model_ids: Vec<String>,
173 pub(crate) reasoning_efforts: Vec<String>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub(crate) struct ChatSpec {
179 version: u32,
180 pub(crate) workspace: Option<PathBuf>,
181 pub(crate) attached_folders: Vec<PathBuf>,
182 pub(crate) bot_id: String,
183 pub(crate) catalog_visible: bool,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(deny_unknown_fields)]
188struct StoredChatSpec {
189 version: u32,
190 workspace: Option<PathBuf>,
191 attached_folders: Vec<PathBuf>,
192 bot_id: String,
193}
194
195impl Default for AgentComposition {
196 fn default() -> Self {
197 let provider = default_provider();
198 let model = provider
199 .default_model()
200 .and_then(|id| provider.model(id))
201 .expect("default model manifest");
202 let mut middleware = crate::middleware_manifest::default_config();
203 middleware.set_setting(
204 "sandbox",
205 "approval_policy",
206 Some(mobius::protocol::FrontendSettingValue::String(
207 "full_access".into(),
208 )),
209 );
210 Self {
211 provider: ProviderConfig {
212 instance: provider.id().into(),
213 provider: provider.id().into(),
214 model: model.id.clone(),
215 base_url: provider.default_base_url().map(str::to_string),
216 endpoint_auth: ProviderEndpointAuth::ProviderDefault,
217 reasoning_effort: model.default_reasoning.clone(),
218 service_tier: None,
219 web_search: *provider
220 .web_search()
221 .first()
222 .expect("default provider web-search manifest"),
223 },
224 realtime_voice: None,
225 middleware,
226 extensions: BTreeSet::new(),
227 system_prompt: DEFAULT_SYSTEM_PROMPT.into(),
228 max_model_steps: DEFAULT_MAX_MODEL_STEPS as u64,
229 }
230 }
231}
232
233impl GatewayConfig {
234 pub fn new(listen: SocketAddr, tls: Option<TlsConfig>) -> Result<Self> {
239 let config = Self {
240 version: CONFIG_VERSION,
241 runtime: Default::default(),
242 connections: Default::default(),
243 auth: Default::default(),
244 computer: Default::default(),
245 model_transport: Default::default(),
246 execution: Default::default(),
247 telemetry: Default::default(),
248 listen,
249 tls,
250 cloudflare: None,
251 desktop_enabled: false,
252 bot_defaults: None,
253 configured_providers: BTreeMap::new(),
254 installed_extensions: BTreeMap::new(),
255 usage: UsageHistory::default(),
256 };
257 config.validate()?;
258 Ok(config)
259 }
260
261 pub fn new_cloudflare(listen: SocketAddr, cloudflare: CloudflareConfig) -> Result<Self> {
266 let mut config = Self::new(listen, None)?;
267 config.cloudflare = Some(cloudflare);
268 config.validate()?;
269 Ok(config)
270 }
271
272 pub(crate) fn registering_provider(
274 &self,
275 selection: ProviderConfig,
276 label: String,
277 tint: ProviderTint,
278 model_ids: Vec<String>,
279 reasoning_efforts: Vec<String>,
280 ) -> Result<Self> {
281 if let Some(configured) = self.configured_providers.get(&selection.instance)
282 && configured.selection.provider != selection.provider
283 {
284 return Err(Error::Config(format!(
285 "provider instance `{}` already belongs to `{}`",
286 selection.instance, configured.selection.provider
287 )));
288 }
289 let configured = ConfiguredProvider {
290 selection: selection.clone(),
291 label,
292 tint,
293 model_ids,
294 reasoning_efforts,
295 };
296 let mut next = self.clone();
297 next.configured_providers
298 .insert(selection.instance.clone(), configured);
299 if self.bot_defaults.is_none() {
300 let config = AgentComposition {
301 provider: selection,
302 ..AgentComposition::default()
303 };
304 next.bot_defaults = Some(VersionedAgentConfig {
305 revision: 1,
306 config,
307 });
308 }
309 next.validate()?;
310 Ok(next)
311 }
312
313 pub(crate) fn removing_provider(&self, instance: &str) -> Result<Self> {
315 if !self.configured_providers.contains_key(instance) {
316 return Err(Error::Config(format!(
317 "provider instance `{instance}` is not configured"
318 )));
319 }
320 if self
321 .bot_defaults
322 .as_ref()
323 .is_some_and(|default| default.config.provider.instance == instance)
324 {
325 return Err(Error::Config(
326 "choose another provider for Bot defaults before removing this provider".into(),
327 ));
328 }
329 let mut next = self.clone();
330 next.configured_providers.remove(instance);
331 let mut bot_defaults = next
332 .bot_defaults
333 .as_ref()
334 .expect("a removable provider cannot be the only configured provider")
335 .config
336 .clone();
337 if clear_missing_model_routes(&mut bot_defaults, &next)? {
338 let default = next
339 .bot_defaults
340 .as_mut()
341 .expect("a removable provider cannot be the only configured provider");
342 default.config = bot_defaults;
343 default.revision = default
344 .revision
345 .checked_add(1)
346 .ok_or_else(|| Error::Config("configuration revision overflow".into()))?;
347 }
348 next.validate()?;
349 Ok(next)
350 }
351
352 pub(crate) fn replacing_bot_defaults(
354 &self,
355 expected_revision: u64,
356 composition: AgentComposition,
357 ) -> Result<Self> {
358 let current = self
359 .bot_defaults
360 .as_ref()
361 .ok_or_else(|| Error::Config("configure a provider before saving defaults".into()))?;
362 if current.revision != expected_revision {
363 return Err(Error::Config(format!(
364 "configuration revision changed from {expected_revision} to {}",
365 current.revision
366 )));
367 }
368 let mut next = self.clone();
369 next.bot_defaults = Some(VersionedAgentConfig {
370 revision: current
371 .revision
372 .checked_add(1)
373 .ok_or_else(|| Error::Config("configuration revision overflow".into()))?,
374 config: composition,
375 });
376 next.validate()?;
377 Ok(next)
378 }
379
380 pub(crate) fn validate_provider_selection(&self, selection: &ProviderConfig) -> Result<()> {
381 validate_provider_config(selection)?;
382 let configured = self
383 .configured_providers
384 .get(&selection.instance)
385 .ok_or_else(|| {
386 Error::Config("provider selection must use a configured provider entry".into())
387 })?;
388 validate_configured_provider_selection(configured, selection)
389 }
390
391 pub fn observe_usage(&mut self, provider: &str, usage: &TokenUsage) -> Result<bool> {
396 self.usage.observe(provider, usage, SystemTime::now())
397 }
398
399 #[must_use]
401 pub fn profile(&self) -> ProfileSnapshot {
402 ProfileSnapshot {
403 user_name: local_user_name(),
404 daily_usage: self
405 .usage
406 .days
407 .iter()
408 .flat_map(|(unix_day, providers)| {
409 providers.iter().map(|(provider, usage)| DailyUsage {
410 unix_day: *unix_day,
411 provider: provider.clone(),
412 usage: usage.clone(),
413 })
414 })
415 .collect(),
416 provider_usage: Vec::new(),
417 run_stats: crate::wire::RunStats::default(),
418 recent_run_groups: Vec::new(),
419 }
420 }
421
422 pub fn validate(&self) -> Result<()> {
427 if self.version != CONFIG_VERSION {
428 return Err(Error::Config(format!(
429 "unsupported gateway config version {}",
430 self.version
431 )));
432 }
433 validate_telemetry(&self.telemetry)?;
434 self.runtime.validate()?;
435 self.connections.validate()?;
436 self.auth.validate()?;
437 self.computer.validate()?;
438 self.model_transport.validate()?;
439 self.execution.validate()?;
440 if let Some(ingress) = self.runtime.ingress
441 && (ingress == self.listen
442 || ingress.port() == 0
443 || self.tls.is_some()
444 || self.cloudflare.is_some())
445 {
446 return Err(Error::Config(
447 "ingress must differ from listen and cannot use TLS or Cloudflare".into(),
448 ));
449 }
450 if self.listen.port() == 0 {
451 return Err(Error::Config(
452 "gateway listen port must be greater than zero".into(),
453 ));
454 }
455 match (&self.tls, self.listen.ip().is_loopback()) {
456 (None, false) => {
457 return Err(Error::Config(
458 "non-loopback gateway listeners require a TLS certificate and private key"
459 .into(),
460 ));
461 }
462 (Some(tls), _) => tls.validate()?,
463 (None, true) => {}
464 }
465 if self.cloudflare.is_some() && (!self.listen.ip().is_loopback() || self.tls.is_some()) {
466 return Err(Error::Config(
467 "Cloudflare gateways require a plaintext loopback listener".into(),
468 ));
469 }
470 if let Some(cloudflare) = &self.cloudflare {
471 cloudflare.validate()?;
472 }
473 if self.configured_providers.is_empty() != self.bot_defaults.is_none() {
474 return Err(Error::Config(
475 "Bot defaults must exist exactly when a provider is configured".into(),
476 ));
477 }
478 for (instance, configured) in &self.configured_providers {
479 if instance != &configured.selection.instance {
480 return Err(Error::Config(format!(
481 "configured provider key `{instance}` does not match `{}`",
482 configured.selection.instance
483 )));
484 }
485 validate_configured_provider(configured)?;
486 }
487 crate::extensions::validate_installed(&self.installed_extensions)?;
488 validate_custom_model_route_count(&self.configured_providers)?;
489 if let Some(default) = &self.bot_defaults {
490 validate_desktop_bot_policy(self, &default.config)?;
491 if default.revision == 0 {
492 return Err(Error::Config(
493 "configuration revision must be positive".into(),
494 ));
495 }
496 validate_agent_composition_with_ceilings(
497 &default.config,
498 self.execution.subagent_ceilings()?,
499 )?;
500 self.validate_provider_selection(&default.config.provider)?;
501 for (middleware, setting, route) in
502 crate::middleware_manifest::configured_model_routes(&default.config.middleware)
503 {
504 if !crate::provider_catalog::configured_route_exists(self, route)? {
505 return Err(Error::Config(format!(
506 "Bot default middleware setting `{middleware}.{setting}` is not a configured model route"
507 )));
508 }
509 }
510 }
511 for providers in self.usage.days.values() {
512 for (provider, usage) in providers {
513 validate_usage_provider(provider)?;
514 validate_usage(usage)?;
515 }
516 }
517 Ok(())
518 }
519}
520
521impl ChatSpec {
522 pub(crate) fn for_bot(
523 workspace: &Path,
524 bot: &crate::wire::BotRecord,
525 state_dir: &Path,
526 tls: Option<&TlsConfig>,
527 ) -> Result<Self> {
528 let spec = Self {
529 version: CHAT_SPEC_VERSION,
530 workspace: Some(validate_chat_workspace(workspace, state_dir, tls)?),
531 attached_folders: Vec::new(),
532 bot_id: bot.id.clone(),
533 catalog_visible: true,
534 };
535 spec.validate(state_dir, tls)?;
536 Ok(spec)
537 }
538
539 pub(crate) fn from_metadata(
540 metadata: &BTreeMap<String, Value>,
541 bots: &crate::bots::BotStore,
542 state_dir: &Path,
543 tls: Option<&TlsConfig>,
544 ) -> Result<Self> {
545 Self::from_metadata_if_present(metadata, bots, state_dir, tls)?.ok_or_else(|| {
546 Error::Config("chat checkpoint has no gateway runtime configuration".into())
547 })
548 }
549
550 pub(crate) fn from_metadata_if_present(
551 metadata: &BTreeMap<String, Value>,
552 bots: &crate::bots::BotStore,
553 state_dir: &Path,
554 tls: Option<&TlsConfig>,
555 ) -> Result<Option<Self>> {
556 let Some(value) = metadata.get(CHAT_SPEC_METADATA_KEY) else {
557 return Ok(None);
558 };
559 let stored: StoredChatSpec = serde_json::from_value(value.clone())?;
560 let bot = bots.bot(&stored.bot_id)?;
561 let spec = Self {
562 version: stored.version,
563 workspace: stored.workspace,
564 attached_folders: stored
565 .attached_folders
566 .into_iter()
567 .filter(|folder| folder.is_dir())
568 .collect(),
569 bot_id: bot.id,
570 catalog_visible: true,
571 };
572 spec.validate(state_dir, tls)?;
573 Ok(Some(spec))
574 }
575
576 pub(crate) fn metadata(&self) -> Result<BTreeMap<String, Value>> {
577 Ok(BTreeMap::from([(
578 CHAT_SPEC_METADATA_KEY.into(),
579 serde_json::to_value(StoredChatSpec {
580 version: self.version,
581 workspace: self.workspace.clone(),
582 attached_folders: self.attached_folders.clone(),
583 bot_id: self.bot_id.clone(),
584 })?,
585 )]))
586 }
587
588 pub(crate) fn persistent(bot: &crate::wire::BotRecord) -> Self {
589 Self {
590 version: CHAT_SPEC_VERSION,
591 workspace: None,
592 attached_folders: Vec::new(),
593 bot_id: bot.id.clone(),
594 catalog_visible: true,
595 }
596 }
597
598 pub(crate) fn execution_root(
600 &self,
601 state_dir: &Path,
602 tls: Option<&TlsConfig>,
603 ) -> Result<PathBuf> {
604 if let Some(workspace) = &self.workspace {
605 return Ok(workspace.clone());
606 }
607 let root = state_dir.with_extension("workspaces").join(&self.bot_id);
608 fs::create_dir_all(&root)?;
609 #[cfg(unix)]
610 fs::set_permissions(&root, mobius::owner_only::dir())?;
611 validate_chat_workspace(&root, state_dir, tls)
612 }
613
614 #[must_use]
615 pub(crate) fn workspace_info(&self) -> Option<WorkspaceInfo> {
616 self.workspace.as_ref().map(|path| WorkspaceInfo {
617 id: workspace_id(path),
618 path: path.clone(),
619 })
620 }
621
622 pub(crate) fn with_attached_folder(
623 &self,
624 folder: &Path,
625 state_dir: &Path,
626 tls: Option<&TlsConfig>,
627 ) -> Result<Option<Self>> {
628 let workspace = self.workspace.as_ref().ok_or_else(|| {
629 Error::Config(
630 "Persistent Chat has no project; open a project chat to attach folders".into(),
631 )
632 })?;
633 let folder = validate_chat_workspace(folder, state_dir, tls)?;
634 if &folder == workspace || self.attached_folders.contains(&folder) {
635 return Ok(None);
636 }
637 if self.attached_folders.len() == MAX_ATTACHED_FOLDERS {
638 return Err(Error::Config(format!(
639 "a chat cannot attach more than {MAX_ATTACHED_FOLDERS} folders"
640 )));
641 }
642 let mut next = self.clone();
643 next.attached_folders.push(folder);
644 next.validate(state_dir, tls)?;
645 Ok(Some(next))
646 }
647
648 fn validate(&self, state_dir: &Path, tls: Option<&TlsConfig>) -> Result<()> {
649 if self.version != CHAT_SPEC_VERSION {
650 return Err(Error::Config(format!(
651 "unsupported chat configuration version {}",
652 self.version
653 )));
654 }
655 if self.bot_id.is_empty() {
656 return Err(Error::Config("chat Bot ownership is invalid".into()));
657 }
658 if let Some(path) = &self.workspace {
659 let workspace = validate_chat_workspace(path, state_dir, tls)?;
660 if workspace != *path {
661 return Err(Error::Config(
662 "chat workspace must use its canonical path".into(),
663 ));
664 }
665 } else if !self.attached_folders.is_empty() {
666 return Err(Error::Config(
667 "project-free chats cannot attach folders".into(),
668 ));
669 }
670 if self.attached_folders.len() > MAX_ATTACHED_FOLDERS {
671 return Err(Error::Config(format!(
672 "a chat cannot attach more than {MAX_ATTACHED_FOLDERS} folders"
673 )));
674 }
675 let mut folders = self.workspace.iter().collect::<BTreeSet<_>>();
676 for attached in &self.attached_folders {
677 let workspace = validate_chat_workspace(attached, state_dir, tls)?;
678 if workspace != *attached {
679 return Err(Error::Config(
680 "attached folders must use canonical paths".into(),
681 ));
682 }
683 if !folders.insert(attached) {
684 return Err(Error::Config("chat folders must be unique".into()));
685 }
686 }
687 Ok(())
688 }
689}
690
691fn clear_missing_model_routes(
692 composition: &mut AgentComposition,
693 gateway: &GatewayConfig,
694) -> Result<bool> {
695 let routes = crate::middleware_manifest::configured_model_routes(&composition.middleware)
696 .into_iter()
697 .map(|(middleware, setting, route)| {
698 (middleware.to_owned(), setting.to_owned(), route.to_owned())
699 })
700 .collect::<Vec<_>>();
701 let mut changed = false;
702 for (middleware, setting, route) in routes {
703 if !crate::provider_catalog::configured_route_exists(gateway, &route)? {
704 composition
705 .middleware
706 .set_setting(middleware, setting, None);
707 changed = true;
708 }
709 }
710 Ok(changed)
711}
712
713impl TlsConfig {
714 fn validate(&self) -> Result<()> {
715 for (name, path) in [
716 ("TLS certificate", &self.certificate),
717 ("TLS private key", &self.private_key),
718 ] {
719 if !path.is_absolute() || !path.is_file() {
720 return Err(Error::Config(format!(
721 "{name} must be an existing absolute file"
722 )));
723 }
724 }
725 Ok(())
726 }
727}
728
729impl CloudflareConfig {
730 pub fn named(hostname: &str) -> Result<Self> {
735 let hostname = hostname.trim().to_ascii_lowercase();
736 let config = Self::Named { hostname };
737 config.validate()?;
738 Ok(config)
739 }
740
741 #[must_use]
743 pub fn endpoint(&self) -> Option<String> {
744 self.hostname().map(|hostname| format!("wss://{hostname}"))
745 }
746
747 #[must_use]
749 pub fn hostname(&self) -> Option<&str> {
750 match self {
751 Self::Quick => None,
752 Self::Named { hostname } => Some(hostname),
753 }
754 }
755
756 pub fn validate_token(token: &str) -> Result<()> {
761 validate_cloudflare_token(token).map(|_| ())
762 }
763
764 fn validate(&self) -> Result<()> {
765 if let Self::Named { hostname } = self
766 && (hostname.len() > 253
767 || !hostname.is_ascii()
768 || hostname != &hostname.to_ascii_lowercase()
769 || !hostname.contains('.')
770 || !hostname.split('.').all(crate::hostnames::valid_label))
771 {
772 return Err(invalid_cloudflare_hostname());
773 }
774 Ok(())
775 }
776}
777
778#[cfg(test)]
779mod tests;