1use std::env;
2use std::fs;
3use std::net::{IpAddr, SocketAddr};
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use hydracache_client_transport_axum::ClientSurfaceLimits;
8use hydracache_redis_compat::{RedisAuthConfig, RedisListenerConfig};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum ServerRole {
16 #[default]
18 Local,
19 Member,
21 Client,
23}
24
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ClusterStartMode {
32 #[default]
34 Bootstrap,
35 Join,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct TlsConfig {
42 pub enabled: bool,
44 pub cert_path: Option<PathBuf>,
46 pub key_path: Option<PathBuf>,
48 pub ca_path: Option<PathBuf>,
50 pub acknowledge_insecure: bool,
52}
53
54impl TlsConfig {
55 pub fn has_complete_material(&self) -> bool {
57 !self.enabled
58 || (self.cert_path.is_some() && self.key_path.is_some() && self.ca_path.is_some())
59 }
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ClusterAuthConfig {
65 pub key_id: Option<String>,
67 pub token_file: Option<PathBuf>,
69 pub previous_key_id: Option<String>,
71 pub previous_token_file: Option<PathBuf>,
73}
74
75impl ClusterAuthConfig {
76 pub fn is_configured(&self) -> bool {
78 self.key_id.as_deref().is_some_and(non_empty)
79 || self.token_file.as_deref().is_some_and(non_empty_path)
80 }
81
82 fn validate(&self) -> Result<(), ServerConfigError> {
83 validate_cluster_auth_pair(
84 self.key_id.as_deref(),
85 self.token_file.as_deref(),
86 "cluster_auth",
87 )?;
88 validate_cluster_auth_pair(
89 self.previous_key_id.as_deref(),
90 self.previous_token_file.as_deref(),
91 "cluster_auth.previous",
92 )
93 }
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
98pub struct BackupConfig {
99 pub enabled: bool,
101 pub location: Option<String>,
103}
104
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
107pub struct ClientApiConfig {
108 pub enabled: bool,
110 pub limits: ClientSurfaceLimits,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub struct AdminApiConfig {
117 pub enabled: bool,
119 pub listen_addr: SocketAddr,
121}
122
123impl Default for AdminApiConfig {
124 fn default() -> Self {
125 Self {
126 enabled: true,
127 listen_addr: "127.0.0.1:9091"
128 .parse()
129 .expect("default admin listen address is valid"),
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct RedisApiConfig {
137 pub enabled: bool,
139 pub listen_addr: SocketAddr,
141 pub auth_required: bool,
143 pub auth_username: Option<String>,
145 pub auth_token_file: Option<PathBuf>,
147 pub rediss_enabled: bool,
149}
150
151impl Default for RedisApiConfig {
152 fn default() -> Self {
153 Self {
154 enabled: false,
155 listen_addr: "127.0.0.1:6379"
156 .parse()
157 .expect("default Redis RESP listen address is valid"),
158 auth_required: false,
159 auth_username: None,
160 auth_token_file: None,
161 rediss_enabled: false,
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(default)]
169pub struct ServerConfig {
170 pub role: ServerRole,
172 pub listen_addr: SocketAddr,
174 pub cluster_addr: SocketAddr,
176 pub cluster_start: ClusterStartMode,
178 pub cluster_advertise_addr: Option<String>,
180 pub node_id: Option<String>,
182 pub seeds: Vec<String>,
184 pub storage_dir: Option<PathBuf>,
186 pub drain_timeout_ms: u64,
188 pub join_timeout_ms: u64,
190 pub tls: TlsConfig,
192 pub cluster_auth: ClusterAuthConfig,
194 pub backup: BackupConfig,
196 pub client_api: ClientApiConfig,
198 pub admin_api: AdminApiConfig,
200 pub redis_api: RedisApiConfig,
202}
203
204impl Default for ServerConfig {
205 fn default() -> Self {
206 Self {
207 role: ServerRole::Local,
208 listen_addr: "127.0.0.1:8080"
209 .parse()
210 .expect("default listen address is valid"),
211 cluster_addr: "127.0.0.1:7000"
212 .parse()
213 .expect("default cluster address is valid"),
214 cluster_start: ClusterStartMode::Bootstrap,
215 cluster_advertise_addr: None,
216 node_id: None,
217 seeds: Vec::new(),
218 storage_dir: None,
219 drain_timeout_ms: 30_000,
220 join_timeout_ms: 15_000,
221 tls: TlsConfig::default(),
222 cluster_auth: ClusterAuthConfig::default(),
223 backup: BackupConfig::default(),
224 client_api: ClientApiConfig::default(),
225 admin_api: AdminApiConfig::default(),
226 redis_api: RedisApiConfig::default(),
227 }
228 }
229}
230
231impl ServerConfig {
232 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ServerConfigError> {
234 let path = path.as_ref();
235 let text = fs::read_to_string(path).map_err(|source| ServerConfigError::ConfigRead {
236 path: path.to_path_buf(),
237 source,
238 })?;
239 Self::from_toml_str(&text)
240 }
241
242 pub fn from_toml_str(text: &str) -> Result<Self, ServerConfigError> {
244 let config = toml::from_str::<Self>(text).map_err(ServerConfigError::ConfigParse)?;
245 config.validate()?;
246 Ok(config)
247 }
248
249 pub fn from_env() -> Result<Self, ServerConfigError> {
251 let mut config = Self::default();
252 if let Ok(role) = env::var("HYDRACACHE_ROLE") {
253 config.role = parse_role(&role)?;
254 }
255 if let Ok(listen) = env::var("HYDRACACHE_LISTEN_ADDR") {
256 config.listen_addr = listen
257 .parse()
258 .map_err(|_| ServerConfigError::InvalidAddress(listen))?;
259 }
260 if let Ok(cluster) = env::var("HYDRACACHE_CLUSTER_ADDR") {
261 config.cluster_addr = cluster
262 .parse()
263 .map_err(|_| ServerConfigError::InvalidAddress(cluster))?;
264 }
265 let cluster_start_explicit = if let Ok(cluster_start) = env::var("HYDRACACHE_CLUSTER_START")
266 {
267 config.cluster_start = parse_cluster_start(&cluster_start)?;
268 true
269 } else {
270 false
271 };
272 if let Ok(advertise_addr) = env::var("HYDRACACHE_CLUSTER_ADVERTISE_ADDR") {
273 config.cluster_advertise_addr = Some(advertise_addr);
274 }
275 if let Ok(node_id) = env::var("HYDRACACHE_NODE_ID") {
276 config.node_id = Some(node_id);
277 }
278 if let Ok(storage_dir) = env::var("HYDRACACHE_STORAGE_DIR") {
279 config.storage_dir = Some(PathBuf::from(storage_dir));
280 }
281 if let Ok(seeds) = env::var("HYDRACACHE_SEEDS") {
282 config.seeds = seeds
283 .split(',')
284 .map(str::trim)
285 .filter(|seed| !seed.is_empty())
286 .map(ToOwned::to_owned)
287 .collect();
288 }
289 apply_statefulset_env(&mut config, cluster_start_explicit)?;
290 if let Ok(join_timeout) = env::var("HYDRACACHE_JOIN_TIMEOUT_MS") {
291 config.join_timeout_ms = join_timeout
292 .parse()
293 .map_err(|_| ServerConfigError::InvalidJoinTimeoutMs(join_timeout))?;
294 }
295 if env::var("HYDRACACHE_TLS_ACK_INSECURE").as_deref() == Ok("true") {
296 config.tls.acknowledge_insecure = true;
297 }
298 if env::var("HYDRACACHE_TLS_ENABLED").as_deref() == Ok("true") {
299 config.tls.enabled = true;
300 }
301 if let Ok(path) = env::var("HYDRACACHE_TLS_CERT_PATH") {
302 config.tls.cert_path = Some(PathBuf::from(path));
303 }
304 if let Ok(path) = env::var("HYDRACACHE_TLS_KEY_PATH") {
305 config.tls.key_path = Some(PathBuf::from(path));
306 }
307 if let Ok(path) = env::var("HYDRACACHE_TLS_CA_PATH") {
308 config.tls.ca_path = Some(PathBuf::from(path));
309 }
310 if let Ok(key_id) = env::var("HYDRACACHE_CLUSTER_AUTH_KEY_ID") {
311 config.cluster_auth.key_id = Some(key_id);
312 }
313 if let Ok(path) = env::var("HYDRACACHE_CLUSTER_AUTH_TOKEN_FILE") {
314 config.cluster_auth.token_file = Some(PathBuf::from(path));
315 }
316 if let Ok(key_id) = env::var("HYDRACACHE_CLUSTER_AUTH_PREVIOUS_KEY_ID") {
317 config.cluster_auth.previous_key_id = Some(key_id);
318 }
319 if let Ok(path) = env::var("HYDRACACHE_CLUSTER_AUTH_PREVIOUS_TOKEN_FILE") {
320 config.cluster_auth.previous_token_file = Some(PathBuf::from(path));
321 }
322 if env::var("HYDRACACHE_BACKUP_ENABLED").as_deref() == Ok("true") {
323 config.backup.enabled = true;
324 }
325 if let Ok(location) = env::var("HYDRACACHE_BACKUP_LOCATION") {
326 config.backup.location = Some(location);
327 }
328 if env::var("HYDRACACHE_CLIENT_API_ENABLED").as_deref() == Ok("true") {
329 config.client_api.enabled = true;
330 }
331 if let Ok(enabled) = env::var("HYDRACACHE_ADMIN_API_ENABLED") {
332 config.admin_api.enabled = enabled != "false";
333 }
334 if let Ok(listen) = env::var("HYDRACACHE_ADMIN_ADDR") {
335 config.admin_api.listen_addr = listen
336 .parse()
337 .map_err(|_| ServerConfigError::InvalidAddress(listen))?;
338 }
339 if env::var("HYDRACACHE_REDIS_API_ENABLED").as_deref() == Ok("true") {
340 config.redis_api.enabled = true;
341 }
342 if let Ok(listen) = env::var("HYDRACACHE_REDIS_ADDR") {
343 config.redis_api.listen_addr = listen
344 .parse()
345 .map_err(|_| ServerConfigError::InvalidAddress(listen))?;
346 }
347 if env::var("HYDRACACHE_REDIS_AUTH_REQUIRED").as_deref() == Ok("true") {
348 config.redis_api.auth_required = true;
349 }
350 if let Ok(username) = env::var("HYDRACACHE_REDIS_AUTH_USERNAME") {
351 config.redis_api.auth_username = Some(username);
352 }
353 if let Ok(path) = env::var("HYDRACACHE_REDIS_AUTH_TOKEN_FILE") {
354 config.redis_api.auth_token_file = Some(PathBuf::from(path));
355 }
356 if env::var("HYDRACACHE_REDIS_REDISS_ENABLED").as_deref() == Ok("true") {
357 config.redis_api.rediss_enabled = true;
358 }
359 config.validate()?;
360 Ok(config)
361 }
362
363 pub fn validate(&self) -> Result<(), ServerConfigError> {
365 if self.drain_timeout_ms == 0 {
366 return Err(ServerConfigError::DrainTimeoutZero);
367 }
368 if self.join_timeout_ms == 0 {
369 return Err(ServerConfigError::JoinTimeoutZero);
370 }
371 if matches!(self.cluster_start, ClusterStartMode::Join) {
372 if !matches!(self.role, ServerRole::Member) {
373 return Err(ServerConfigError::JoinRequiresMemberRole);
374 }
375 if self.seeds.is_empty() {
376 return Err(ServerConfigError::JoinRequiresSeeds);
377 }
378 }
379 if matches!(self.role, ServerRole::Member) && self.storage_dir.is_none() {
380 return Err(ServerConfigError::MissingStorageDir);
381 }
382 if matches!(self.role, ServerRole::Member | ServerRole::Client) && self.seeds.is_empty() {
383 return Err(ServerConfigError::MissingSeeds);
384 }
385 if self
386 .node_id
387 .as_deref()
388 .is_some_and(|node_id| node_id.trim().is_empty())
389 {
390 return Err(ServerConfigError::InvalidNodeId);
391 }
392 if self
393 .cluster_advertise_addr
394 .as_deref()
395 .is_some_and(invalid_cluster_advertise_addr)
396 {
397 return Err(ServerConfigError::InvalidClusterAdvertiseAddr);
398 }
399 if self.backup.enabled
400 && self
401 .backup
402 .location
403 .as_deref()
404 .unwrap_or("")
405 .trim()
406 .is_empty()
407 {
408 return Err(ServerConfigError::MissingBackupLocation);
409 }
410 if !self.tls.has_complete_material() {
411 return Err(ServerConfigError::IncompleteTlsMaterial);
412 }
413 self.cluster_auth.validate()?;
414 if self.exposes_non_loopback() && !self.tls.enabled && !self.tls.acknowledge_insecure {
415 return Err(ServerConfigError::NonLoopbackWithoutTls);
416 }
417 if self.client_api.enabled {
418 self.client_api
419 .limits
420 .validate()
421 .map_err(|error| ServerConfigError::InvalidClientApi(error.to_string()))?;
422 }
423 if self.admin_api.enabled && self.admin_api.listen_addr == self.listen_addr {
424 return Err(ServerConfigError::AdminAddressConflicts);
425 }
426 if self.redis_api.enabled {
427 if self.redis_api.rediss_enabled && !self.tls.enabled {
428 return Err(ServerConfigError::RedisRedissRequiresTls);
429 }
430 validate_redis_auth(&self.redis_api)?;
431 if self.redis_api.listen_addr == self.listen_addr {
432 return Err(ServerConfigError::RedisAddressConflicts {
433 surface: "listen_addr",
434 });
435 }
436 if self.redis_api.listen_addr == self.cluster_addr {
437 return Err(ServerConfigError::RedisAddressConflicts {
438 surface: "cluster_addr",
439 });
440 }
441 if self.admin_api.enabled && self.redis_api.listen_addr == self.admin_api.listen_addr {
442 return Err(ServerConfigError::RedisAddressConflicts {
443 surface: "admin_api.listen_addr",
444 });
445 }
446 }
447 Ok(())
448 }
449
450 pub fn redis_listener_config(&self) -> Result<RedisListenerConfig, ServerConfigError> {
452 let mut config = RedisListenerConfig::default();
453 if self.redis_api.auth_required {
454 let path = self
455 .redis_api
456 .auth_token_file
457 .as_deref()
458 .ok_or(ServerConfigError::IncompleteRedisAuth)?;
459 config.auth = RedisAuthConfig {
460 required: true,
461 username: self.redis_api.auth_username.clone(),
462 password: Some(read_redis_auth_token(path)?),
463 };
464 }
465 Ok(config)
466 }
467
468 pub fn drain_timeout(&self) -> Duration {
470 Duration::from_millis(self.drain_timeout_ms)
471 }
472
473 pub fn join_timeout(&self) -> Duration {
475 Duration::from_millis(self.join_timeout_ms)
476 }
477
478 pub fn cluster_advertise_endpoint(&self) -> String {
480 self.cluster_advertise_addr
481 .clone()
482 .unwrap_or_else(|| self.cluster_addr.to_string())
483 }
484
485 pub fn exposes_non_loopback(&self) -> bool {
487 !is_loopback(self.listen_addr.ip())
488 || !is_loopback(self.cluster_addr.ip())
489 || (self.admin_api.enabled && !is_loopback(self.admin_api.listen_addr.ip()))
490 || (self.redis_api.enabled && !is_loopback(self.redis_api.listen_addr.ip()))
491 }
492}
493
494#[derive(Debug, Error)]
496pub enum ServerConfigError {
497 #[error("failed to read config {path}: {source}")]
499 ConfigRead {
500 path: PathBuf,
502 source: std::io::Error,
504 },
505 #[error("failed to parse config: {0}")]
507 ConfigParse(toml::de::Error),
508 #[error("invalid server role: {0}")]
510 InvalidRole(String),
511 #[error("invalid cluster start mode: {0}")]
513 InvalidClusterStart(String),
514 #[error("invalid listen address: {0}")]
516 InvalidAddress(String),
517 #[error("invalid bootstrap_replicas: {0}")]
519 InvalidBootstrapReplicas(String),
520 #[error("invalid StatefulSet HOSTNAME: {0}")]
522 InvalidStatefulSetHostname(String),
523 #[error("invalid join_timeout_ms: {0}")]
525 InvalidJoinTimeoutMs(String),
526 #[error("drain_timeout_ms must be greater than zero")]
528 DrainTimeoutZero,
529 #[error("join_timeout_ms must be greater than zero")]
531 JoinTimeoutZero,
532 #[error("cluster_start=join requires role=member")]
534 JoinRequiresMemberRole,
535 #[error("cluster_start=join requires at least one seed")]
537 JoinRequiresSeeds,
538 #[error("member role requires storage_dir")]
540 MissingStorageDir,
541 #[error("member/client role requires at least one seed")]
543 MissingSeeds,
544 #[error("node_id must not be empty")]
546 InvalidNodeId,
547 #[error("cluster_advertise_addr must be non-empty and routable when set")]
549 InvalidClusterAdvertiseAddr,
550 #[error("backup.enabled requires backup.location")]
552 MissingBackupLocation,
553 #[error("tls.enabled requires cert_path, key_path, and ca_path")]
555 IncompleteTlsMaterial,
556 #[error("{section} requires key_id and readable token_file")]
558 IncompleteClusterAuth {
559 section: &'static str,
561 },
562 #[error("failed to read {section}.token_file {path}: {source}")]
564 ClusterAuthTokenRead {
565 section: &'static str,
567 path: PathBuf,
569 source: std::io::Error,
571 },
572 #[error("{section}.token_file {path} is empty")]
574 EmptyClusterAuthToken {
575 section: &'static str,
577 path: PathBuf,
579 },
580 #[error("non-loopback listeners require TLS or acknowledge_insecure=true")]
582 NonLoopbackWithoutTls,
583 #[error("invalid client_api config: {0}")]
585 InvalidClientApi(String),
586 #[error("failed to start member grid host: {0}")]
588 GridHostStart(String),
589 #[error("admin_api.listen_addr must differ from listen_addr")]
591 AdminAddressConflicts,
592 #[error("redis_api.listen_addr must differ from {surface}")]
594 RedisAddressConflicts {
595 surface: &'static str,
597 },
598 #[error("redis_api auth requires auth_token_file and a non-empty token")]
600 IncompleteRedisAuth,
601 #[error("failed to read redis_api.auth_token_file {path}: {source}")]
603 RedisAuthTokenRead {
604 path: PathBuf,
606 source: std::io::Error,
608 },
609 #[error("redis_api.auth_token_file {path} is empty")]
611 EmptyRedisAuthToken {
612 path: PathBuf,
614 },
615 #[error("redis_api.rediss_enabled requires tls.enabled with certificate/key material")]
617 RedisRedissRequiresTls,
618}
619
620fn parse_role(value: &str) -> Result<ServerRole, ServerConfigError> {
621 match value.trim().to_ascii_lowercase().as_str() {
622 "local" => Ok(ServerRole::Local),
623 "member" => Ok(ServerRole::Member),
624 "client" => Ok(ServerRole::Client),
625 _ => Err(ServerConfigError::InvalidRole(value.to_owned())),
626 }
627}
628
629fn parse_cluster_start(value: &str) -> Result<ClusterStartMode, ServerConfigError> {
630 match value.trim().to_ascii_lowercase().as_str() {
631 "bootstrap" => Ok(ClusterStartMode::Bootstrap),
632 "join" => Ok(ClusterStartMode::Join),
633 _ => Err(ServerConfigError::InvalidClusterStart(value.to_owned())),
634 }
635}
636
637fn apply_statefulset_env(
638 config: &mut ServerConfig,
639 cluster_start_explicit: bool,
640) -> Result<(), ServerConfigError> {
641 let bootstrap_replicas = match env::var("HYDRACACHE_BOOTSTRAP_REPLICAS") {
642 Ok(value) => {
643 let replicas = value
644 .parse::<u32>()
645 .map_err(|_| ServerConfigError::InvalidBootstrapReplicas(value.clone()))?;
646 if replicas == 0 {
647 return Err(ServerConfigError::InvalidBootstrapReplicas(value));
648 }
649 Some(replicas)
650 }
651 Err(_) => None,
652 };
653 let headless_service = env::var("HYDRACACHE_CLUSTER_HEADLESS_SERVICE")
654 .ok()
655 .filter(|value| non_empty(value));
656 if bootstrap_replicas.is_none() && headless_service.is_none() {
657 return Ok(());
658 }
659
660 let hostname = env::var("HOSTNAME")
661 .map_err(|_| ServerConfigError::InvalidStatefulSetHostname(String::new()))
662 .and_then(|value| {
663 if non_empty(&value) {
664 Ok(value)
665 } else {
666 Err(ServerConfigError::InvalidStatefulSetHostname(value))
667 }
668 })?;
669
670 if config.node_id.is_none() {
671 config.node_id = Some(hostname.clone());
672 }
673
674 if let Some(headless) = headless_service {
675 if config.cluster_advertise_addr.is_none() {
676 config.cluster_advertise_addr = Some(format!(
677 "{hostname}.{headless}:{}",
678 config.cluster_addr.port()
679 ));
680 }
681 }
682
683 if let Some(replicas) = bootstrap_replicas {
684 if !cluster_start_explicit {
685 let ordinal = statefulset_ordinal(&hostname)
686 .ok_or_else(|| ServerConfigError::InvalidStatefulSetHostname(hostname.clone()))?;
687 config.cluster_start = if ordinal < replicas {
688 ClusterStartMode::Bootstrap
689 } else {
690 ClusterStartMode::Join
691 };
692 }
693 }
694
695 Ok(())
696}
697
698fn statefulset_ordinal(hostname: &str) -> Option<u32> {
699 let (_, ordinal) = hostname.rsplit_once('-')?;
700 ordinal.parse().ok()
701}
702
703fn validate_cluster_auth_pair(
704 key_id: Option<&str>,
705 token_file: Option<&Path>,
706 section: &'static str,
707) -> Result<(), ServerConfigError> {
708 let has_key = key_id.is_some_and(non_empty);
709 let has_file = token_file.is_some_and(non_empty_path);
710 if has_key != has_file {
711 return Err(ServerConfigError::IncompleteClusterAuth { section });
712 }
713 let Some(path) = token_file else {
714 return Ok(());
715 };
716 let token =
717 fs::read_to_string(path).map_err(|source| ServerConfigError::ClusterAuthTokenRead {
718 section,
719 path: path.to_path_buf(),
720 source,
721 })?;
722 if token.trim().is_empty() {
723 return Err(ServerConfigError::EmptyClusterAuthToken {
724 section,
725 path: path.to_path_buf(),
726 });
727 }
728 Ok(())
729}
730
731fn validate_redis_auth(config: &RedisApiConfig) -> Result<(), ServerConfigError> {
732 if config
733 .auth_username
734 .as_deref()
735 .is_some_and(|username| username.trim().is_empty())
736 {
737 return Err(ServerConfigError::IncompleteRedisAuth);
738 }
739 if !config.auth_required {
740 return Ok(());
741 }
742 let Some(path) = config
743 .auth_token_file
744 .as_deref()
745 .filter(|path| non_empty_path(path))
746 else {
747 return Err(ServerConfigError::IncompleteRedisAuth);
748 };
749 read_redis_auth_token(path).map(|_| ())
750}
751
752fn read_redis_auth_token(path: &Path) -> Result<String, ServerConfigError> {
753 let token =
754 fs::read_to_string(path).map_err(|source| ServerConfigError::RedisAuthTokenRead {
755 path: path.to_path_buf(),
756 source,
757 })?;
758 let token = token.trim().to_owned();
759 if token.is_empty() {
760 return Err(ServerConfigError::EmptyRedisAuthToken {
761 path: path.to_path_buf(),
762 });
763 }
764 Ok(token)
765}
766
767fn non_empty(value: &str) -> bool {
768 !value.trim().is_empty()
769}
770
771fn non_empty_path(path: &Path) -> bool {
772 !path.as_os_str().is_empty()
773}
774
775fn invalid_cluster_advertise_addr(value: &str) -> bool {
776 let value = value.trim();
777 if value.is_empty() {
778 return true;
779 }
780 value
781 .parse::<SocketAddr>()
782 .is_ok_and(|addr| addr.ip().is_unspecified())
783}
784
785fn is_loopback(ip: IpAddr) -> bool {
786 match ip {
787 IpAddr::V4(ip) => ip.is_loopback(),
788 IpAddr::V6(ip) => ip.is_loopback(),
789 }
790}