Skip to main content

rscale/
config.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6
7use serde::{Deserialize, Serialize};
8use tier::{ConfigLoader, EnvSource, LoadedConfig, TierConfig, ValidationErrors};
9
10use crate::error::{AppError, AppResult};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, TierConfig)]
13#[serde(default)]
14pub struct AppConfig {
15    pub server: ServerConfig,
16    pub network: NetworkConfig,
17    pub database: DatabaseConfig,
18    pub auth: AuthConfig,
19    pub control: ControlConfig,
20    pub derp: DerpConfig,
21    pub telemetry: TelemetryConfig,
22}
23
24impl AppConfig {
25    pub fn load(config_path: Option<&Path>) -> AppResult<Self> {
26        Ok(Self::load_with_report(config_path)?.into_inner())
27    }
28
29    pub fn load_with_report(config_path: Option<&Path>) -> AppResult<LoadedConfig<Self>> {
30        let loader = Self::loader(config_path)?;
31        Ok(loader.load()?)
32    }
33
34    pub fn validate(&self) -> AppResult<()> {
35        Self::validate_bind_addr(self).map_err(|err| AppError::InvalidConfig(err.to_string()))?;
36        Self::validate_server(self).map_err(|err| AppError::InvalidConfig(err.to_string()))?;
37        Self::validate_network(self).map_err(|err| AppError::InvalidConfig(err.to_string()))?;
38        Self::validate_database(self).map_err(|err| AppError::InvalidConfig(err.to_string()))?;
39        Self::validate_auth(self).map_err(|err| AppError::InvalidConfig(err.to_string()))?;
40        Self::validate_oidc(self).map_err(|err| AppError::InvalidConfig(err.to_string()))?;
41        Self::validate_control(self).map_err(|err| AppError::InvalidConfig(err.to_string()))?;
42        Self::validate_derp(self).map_err(|err| AppError::InvalidConfig(err.to_string()))?;
43        Ok(())
44    }
45
46    pub fn bind_addr(&self) -> AppResult<SocketAddr> {
47        self.server
48            .bind_addr
49            .parse::<SocketAddr>()
50            .map_err(|err| AppError::InvalidConfig(format!("server.bind_addr is invalid: {err}")))
51    }
52
53    pub fn summary(&self) -> ConfigSummary {
54        ConfigSummary {
55            bind_addr: self.server.bind_addr.clone(),
56            web_root_configured: self
57                .server
58                .web_root
59                .as_deref()
60                .is_some_and(|value| !value.trim().is_empty()),
61            control_protocol_enabled: !self.server.control_private_key.is_empty(),
62            tailnet_ipv4_range: self.network.tailnet_ipv4_range.clone(),
63            tailnet_ipv6_range: self.network.tailnet_ipv6_range.clone(),
64            database_configured: self.database.url.is_some(),
65            derp_region_count: self.derp.regions.len() as u32,
66            derp_url_count: self.derp.urls.len() as u32,
67            derp_path_count: self.derp.paths.len() as u32,
68            derp_omit_default_regions: self.derp.omit_default_regions,
69            derp_refresh_interval_secs: self.derp.refresh_interval_secs,
70            derp_embedded_relay_enabled: self.derp.server.enabled,
71            derp_stun_bind_addr: self.derp.server.stun_bind_addr.clone(),
72            derp_verify_clients: self.derp.server.verify_clients,
73            admin_auth_configured: self.auth.break_glass_token.is_some(),
74            oidc_enabled: self.auth.oidc.enabled,
75            oidc_discovery_validation: self.auth.oidc.validate_discovery_on_startup,
76            control_display_message_count: self.control.display_messages.len() as u32,
77            control_dial_candidate_count: self.control.dial_plan.candidates.len() as u32,
78            control_client_version_configured: self.control.client_version.latest_version.is_some(),
79            control_collect_services_configured: self.control.collect_services.is_some(),
80            control_node_attr_count: self.control.node_attrs.enabled_count(),
81            control_pop_browser_url_configured: self.control.pop_browser_url.is_some(),
82            log_filter: self.telemetry.filter.clone(),
83            log_format: self.telemetry.format.as_str().to_string(),
84        }
85    }
86
87    fn loader(config_path: Option<&Path>) -> AppResult<ConfigLoader<Self>> {
88        let mut loader = ConfigLoader::new(Self::default())
89            .derive_metadata()
90            .secret_path("server.control_private_key")
91            .secret_path("auth.break_glass_token")
92            .secret_path("auth.oidc.client_secret")
93            .secret_path("derp.server.private_key")
94            .secret_path("derp.server.mesh_key")
95            .env(Self::env_source())
96            .validator("rscale.server.bind_addr", Self::validate_bind_addr)
97            .validator("rscale.server", Self::validate_server)
98            .validator("rscale.network", Self::validate_network)
99            .validator("rscale.database", Self::validate_database)
100            .validator("rscale.auth", Self::validate_auth)
101            .validator("rscale.auth.oidc", Self::validate_oidc)
102            .validator("rscale.control", Self::validate_control)
103            .validator("rscale.derp", Self::validate_derp);
104
105        if let Some(path) = Self::resolve_path(config_path)? {
106            loader = loader.file(path);
107        } else {
108            loader = loader
109                .optional_file("config.toml")
110                .optional_file("config/config.toml")
111                .optional_file("rscale.toml")
112                .optional_file("config/rscale.toml");
113        }
114
115        Ok(loader)
116    }
117
118    fn env_source() -> EnvSource {
119        EnvSource::prefixed("RSCALE")
120            .with_alias("RSCALE_BIND_ADDR", "server.bind_addr")
121            .with_alias("RSCALE_WEB_ROOT", "server.web_root")
122            .with_alias("RSCALE_CONTROL_PRIVATE_KEY", "server.control_private_key")
123            .with_alias("RSCALE_TAILNET_IPV4_RANGE", "network.tailnet_ipv4_range")
124            .with_alias("RSCALE_TAILNET_IPV6_RANGE", "network.tailnet_ipv6_range")
125            .with_alias("RSCALE_DATABASE_URL", "database.url")
126            .with_alias("RSCALE_BREAK_GLASS_TOKEN", "auth.break_glass_token")
127            .with_alias("RSCALE_OIDC_ENABLED", "auth.oidc.enabled")
128            .with_alias("RSCALE_OIDC_ISSUER_URL", "auth.oidc.issuer_url")
129            .with_alias("RSCALE_OIDC_CLIENT_ID", "auth.oidc.client_id")
130            .with_alias("RSCALE_OIDC_CLIENT_SECRET", "auth.oidc.client_secret")
131            .with_alias(
132                "RSCALE_CONTROL_DIAL_CANDIDATE_IP",
133                "control.dial_plan.candidates[0].ip",
134            )
135            .with_alias(
136                "RSCALE_CONTROL_TAILNET_DISPLAY_NAME",
137                "control.node_attrs.tailnet_display_name",
138            )
139            .with_alias(
140                "RSCALE_CONTROL_CLIENT_LATEST_VERSION",
141                "control.client_version.latest_version",
142            )
143            .with_alias(
144                "RSCALE_CONTROL_COLLECT_SERVICES",
145                "control.collect_services",
146            )
147            .with_alias("RSCALE_CONTROL_POP_BROWSER_URL", "control.pop_browser_url")
148            .with_alias("RSCALE_DERP_SERVER_ENABLED", "derp.server.enabled")
149            .with_alias("RSCALE_DERP_SERVER_PRIVATE_KEY", "derp.server.private_key")
150            .with_alias("RSCALE_DERP_SERVER_MESH_KEY", "derp.server.mesh_key")
151            .with_alias("RSCALE_DERP_SERVER_NODE_NAME", "derp.server.node_name")
152            .with_alias("RSCALE_DERP_STUN_BIND_ADDR", "derp.server.stun_bind_addr")
153            .with_alias("RSCALE_LOG_FILTER", "telemetry.filter")
154            .with_alias("RSCALE_LOG_FORMAT", "telemetry.format")
155    }
156
157    fn resolve_path(config_path: Option<&Path>) -> AppResult<Option<PathBuf>> {
158        if let Some(path) = config_path {
159            return Ok(Some(path.to_path_buf()));
160        }
161
162        match env::var_os("RSCALE_CONFIG") {
163            Some(path) if !path.is_empty() => Ok(Some(PathBuf::from(path))),
164            Some(_) => Err(AppError::InvalidConfig(
165                "RSCALE_CONFIG must not be empty".to_string(),
166            )),
167            None => Ok(None),
168        }
169    }
170
171    fn validate_bind_addr(config: &Self) -> Result<(), ValidationErrors> {
172        match config.server.bind_addr.parse::<SocketAddr>() {
173            Ok(_) => Ok(()),
174            Err(err) => Err(ValidationErrors::from_message(
175                "server.bind_addr",
176                format!("must be a valid socket address: {err}"),
177            )),
178        }
179    }
180
181    fn validate_server(config: &Self) -> Result<(), ValidationErrors> {
182        let control_private_key = config.server.control_private_key.trim();
183        if control_private_key.is_empty() {
184            return Err(ValidationErrors::from_message(
185                "server.control_private_key",
186                "is required for TS2021 control-plane compatibility",
187            ));
188        }
189
190        validate_machine_private_key(control_private_key)
191            .map_err(|err| ValidationErrors::from_message("server.control_private_key", err))?;
192
193        if config.server.map_poll_interval_secs == 0 {
194            return Err(ValidationErrors::from_message(
195                "server.map_poll_interval_secs",
196                "must be greater than zero",
197            ));
198        }
199
200        if config.server.map_keepalive_interval_secs == 0 {
201            return Err(ValidationErrors::from_message(
202                "server.map_keepalive_interval_secs",
203                "must be greater than zero",
204            ));
205        }
206
207        Ok(())
208    }
209
210    fn validate_database(config: &Self) -> Result<(), ValidationErrors> {
211        if config.database.max_connections == 0 {
212            return Err(ValidationErrors::from_message(
213                "database.max_connections",
214                "must be greater than zero",
215            ));
216        }
217
218        if config.database.url.as_deref().is_none_or(str::is_empty) {
219            return Err(ValidationErrors::from_message(
220                "database.url",
221                "is required for production startup",
222            ));
223        }
224
225        Ok(())
226    }
227
228    fn validate_network(config: &Self) -> Result<(), ValidationErrors> {
229        validate_ipv4_cidr(&config.network.tailnet_ipv4_range)
230            .map_err(|err| ValidationErrors::from_message("network.tailnet_ipv4_range", err))?;
231
232        validate_ipv6_cidr(&config.network.tailnet_ipv6_range)
233            .map_err(|err| ValidationErrors::from_message("network.tailnet_ipv6_range", err))?;
234
235        if config.network.node_online_window_secs == 0 {
236            return Err(ValidationErrors::from_message(
237                "network.node_online_window_secs",
238                "must be greater than zero",
239            ));
240        }
241
242        if config.network.node_session_ttl_secs == 0 {
243            return Err(ValidationErrors::from_message(
244                "network.node_session_ttl_secs",
245                "must be greater than zero",
246            ));
247        }
248
249        Ok(())
250    }
251
252    fn validate_auth(config: &Self) -> Result<(), ValidationErrors> {
253        if config.auth.break_glass_username.trim().is_empty() {
254            return Err(ValidationErrors::from_message(
255                "auth.break_glass_username",
256                "must not be empty",
257            ));
258        }
259
260        let Some(token) = config.auth.break_glass_token.as_deref() else {
261            return Err(ValidationErrors::from_message(
262                "auth.break_glass_token",
263                "is required for authenticated administration",
264            ));
265        };
266
267        if token.trim().is_empty() {
268            return Err(ValidationErrors::from_message(
269                "auth.break_glass_token",
270                "must not be empty",
271            ));
272        }
273
274        if token.len() < 24 {
275            return Err(ValidationErrors::from_message(
276                "auth.break_glass_token",
277                "must be at least 24 characters long",
278            ));
279        }
280
281        Ok(())
282    }
283
284    fn validate_oidc(config: &Self) -> Result<(), ValidationErrors> {
285        let oidc = &config.auth.oidc;
286
287        if !oidc.enabled {
288            return Ok(());
289        }
290
291        if oidc.issuer_url.as_deref().is_none_or(str::is_empty) {
292            return Err(ValidationErrors::from_message(
293                "auth.oidc.issuer_url",
294                "is required when OIDC is enabled",
295            ));
296        }
297
298        if oidc.client_id.as_deref().is_none_or(str::is_empty) {
299            return Err(ValidationErrors::from_message(
300                "auth.oidc.client_id",
301                "is required when OIDC is enabled",
302            ));
303        }
304
305        if oidc.client_secret.as_deref().is_none_or(str::is_empty) {
306            return Err(ValidationErrors::from_message(
307                "auth.oidc.client_secret",
308                "is required when OIDC is enabled",
309            ));
310        }
311
312        if oidc.request_timeout_secs == 0 {
313            return Err(ValidationErrors::from_message(
314                "auth.oidc.request_timeout_secs",
315                "must be greater than zero",
316            ));
317        }
318
319        if oidc.total_timeout_secs == 0 {
320            return Err(ValidationErrors::from_message(
321                "auth.oidc.total_timeout_secs",
322                "must be greater than zero",
323            ));
324        }
325
326        if oidc.total_timeout_secs < oidc.request_timeout_secs {
327            return Err(ValidationErrors::from_message(
328                "auth.oidc.total_timeout_secs",
329                "must be greater than or equal to auth.oidc.request_timeout_secs",
330            ));
331        }
332
333        if oidc.auth_flow_ttl_secs == 0 {
334            return Err(ValidationErrors::from_message(
335                "auth.oidc.auth_flow_ttl_secs",
336                "must be greater than zero",
337            ));
338        }
339
340        if oidc.scopes.is_empty() {
341            return Err(ValidationErrors::from_message(
342                "auth.oidc.scopes",
343                "must contain at least one scope when OIDC is enabled",
344            ));
345        }
346
347        if oidc.scopes.iter().any(|scope| scope.trim().is_empty()) {
348            return Err(ValidationErrors::from_message(
349                "auth.oidc.scopes",
350                "must not contain empty scope values",
351            ));
352        }
353
354        if oidc
355            .allowed_domains
356            .iter()
357            .any(|value| value.trim().is_empty())
358        {
359            return Err(ValidationErrors::from_message(
360                "auth.oidc.allowed_domains",
361                "must not contain empty values",
362            ));
363        }
364
365        if oidc
366            .allowed_users
367            .iter()
368            .any(|value| value.trim().is_empty())
369        {
370            return Err(ValidationErrors::from_message(
371                "auth.oidc.allowed_users",
372                "must not contain empty values",
373            ));
374        }
375
376        if oidc
377            .allowed_groups
378            .iter()
379            .any(|value| value.trim().is_empty())
380        {
381            return Err(ValidationErrors::from_message(
382                "auth.oidc.allowed_groups",
383                "must not contain empty values",
384            ));
385        }
386
387        if oidc
388            .extra_params
389            .keys()
390            .any(|value| value.trim().is_empty())
391        {
392            return Err(ValidationErrors::from_message(
393                "auth.oidc.extra_params",
394                "must not contain empty parameter names",
395            ));
396        }
397
398        let Some(public_base_url) = config.server.public_base_url.as_deref() else {
399            return Err(ValidationErrors::from_message(
400                "server.public_base_url",
401                "is required when OIDC is enabled",
402            ));
403        };
404
405        if public_base_url.trim().is_empty() {
406            return Err(ValidationErrors::from_message(
407                "server.public_base_url",
408                "must not be empty when OIDC is enabled",
409            ));
410        }
411
412        if !is_secure_or_local_http_url(public_base_url) {
413            return Err(ValidationErrors::from_message(
414                "server.public_base_url",
415                "must use https unless it points to a local development endpoint",
416            ));
417        }
418
419        Ok(())
420    }
421
422    fn validate_control(config: &Self) -> Result<(), ValidationErrors> {
423        for candidate in &config.control.dial_plan.candidates {
424            let ip = candidate.ip.as_deref().map(str::trim).unwrap_or_default();
425            let ace_host = candidate
426                .ace_host
427                .as_deref()
428                .map(str::trim)
429                .unwrap_or_default();
430
431            if ip.is_empty() && ace_host.is_empty() {
432                return Err(ValidationErrors::from_message(
433                    "control.dial_plan.candidates[]",
434                    "must define at least one of ip or ace_host",
435                ));
436            }
437
438            if !ip.is_empty() {
439                ip.parse::<std::net::IpAddr>().map_err(|err| {
440                    ValidationErrors::from_message(
441                        "control.dial_plan.candidates[].ip",
442                        format!("must be a valid IP address: {err}"),
443                    )
444                })?;
445            }
446
447            if candidate.ip.as_deref().is_some() && ip.is_empty() {
448                return Err(ValidationErrors::from_message(
449                    "control.dial_plan.candidates[].ip",
450                    "must not be empty when configured",
451                ));
452            }
453
454            if candidate.ace_host.as_deref().is_some() && ace_host.is_empty() {
455                return Err(ValidationErrors::from_message(
456                    "control.dial_plan.candidates[].ace_host",
457                    "must not be empty when configured",
458                ));
459            }
460
461            if let Some(delay) = candidate.dial_start_delay_secs
462                && (!delay.is_finite() || delay < 0.0)
463            {
464                return Err(ValidationErrors::from_message(
465                    "control.dial_plan.candidates[].dial_start_delay_secs",
466                    "must be a finite number greater than or equal to zero",
467                ));
468            }
469
470            if let Some(timeout) = candidate.dial_timeout_secs
471                && (!timeout.is_finite() || timeout <= 0.0)
472            {
473                return Err(ValidationErrors::from_message(
474                    "control.dial_plan.candidates[].dial_timeout_secs",
475                    "must be a finite number greater than zero",
476                ));
477            }
478        }
479
480        for (id, message) in &config.control.display_messages {
481            if id.trim().is_empty() {
482                return Err(ValidationErrors::from_message(
483                    "control.display_messages",
484                    "message ids must not be empty",
485                ));
486            }
487
488            if id == "*" {
489                return Err(ValidationErrors::from_message(
490                    "control.display_messages",
491                    "message id '*' is reserved for control-plane clear-all patches",
492                ));
493            }
494
495            if message.title.trim().is_empty() {
496                return Err(ValidationErrors::from_message(
497                    "control.display_messages[].title",
498                    format!("display message {id} must define a non-empty title"),
499                ));
500            }
501
502            if message.text.trim().is_empty() {
503                return Err(ValidationErrors::from_message(
504                    "control.display_messages[].text",
505                    format!("display message {id} must define a non-empty text"),
506                ));
507            }
508
509            if let Some(action) = &message.primary_action {
510                if action.url.trim().is_empty() {
511                    return Err(ValidationErrors::from_message(
512                        "control.display_messages[].primary_action.url",
513                        format!("display message {id} primary action URL must not be empty"),
514                    ));
515                }
516
517                if !is_secure_or_local_http_url(&action.url) {
518                    return Err(ValidationErrors::from_message(
519                        "control.display_messages[].primary_action.url",
520                        format!(
521                            "display message {id} primary action URL must use https unless it points to a local endpoint"
522                        ),
523                    ));
524                }
525
526                if action.label.trim().is_empty() {
527                    return Err(ValidationErrors::from_message(
528                        "control.display_messages[].primary_action.label",
529                        format!("display message {id} primary action label must not be empty"),
530                    ));
531                }
532            }
533        }
534
535        let attrs = &config.control.node_attrs;
536        if attrs
537            .tailnet_display_name
538            .as_deref()
539            .is_some_and(|value| value.trim().is_empty())
540        {
541            return Err(ValidationErrors::from_message(
542                "control.node_attrs.tailnet_display_name",
543                "must not be empty when configured",
544            ));
545        }
546
547        if attrs.max_key_duration_secs == Some(0) {
548            return Err(ValidationErrors::from_message(
549                "control.node_attrs.max_key_duration_secs",
550                "must be greater than zero when configured",
551            ));
552        }
553
554        let client_version = &config.control.client_version;
555        if let Some(version) = client_version.latest_version.as_deref() {
556            if version.trim().is_empty() {
557                return Err(ValidationErrors::from_message(
558                    "control.client_version.latest_version",
559                    "must not be empty when configured",
560                ));
561            }
562            validate_release_version(version).map_err(|err| {
563                ValidationErrors::from_message("control.client_version.latest_version", err)
564            })?;
565        }
566
567        if let Some(url) = client_version.notify_url.as_deref() {
568            if url.trim().is_empty() {
569                return Err(ValidationErrors::from_message(
570                    "control.client_version.notify_url",
571                    "must not be empty when configured",
572                ));
573            }
574
575            if !is_secure_or_local_http_url(url) {
576                return Err(ValidationErrors::from_message(
577                    "control.client_version.notify_url",
578                    "must use https unless it points to a local endpoint",
579                ));
580            }
581        }
582
583        if client_version
584            .notify_text
585            .as_deref()
586            .is_some_and(|value| value.trim().is_empty())
587        {
588            return Err(ValidationErrors::from_message(
589                "control.client_version.notify_text",
590                "must not be empty when configured",
591            ));
592        }
593
594        if (client_version.notify
595            || client_version.notify_url.is_some()
596            || client_version.notify_text.is_some()
597            || client_version.urgent_security_update)
598            && client_version.latest_version.is_none()
599        {
600            return Err(ValidationErrors::from_message(
601                "control.client_version.latest_version",
602                "is required when client version notification settings are configured",
603            ));
604        }
605
606        if let Some(url) = config.control.pop_browser_url.as_deref() {
607            if url.trim().is_empty() {
608                return Err(ValidationErrors::from_message(
609                    "control.pop_browser_url",
610                    "must not be empty when configured",
611                ));
612            }
613
614            if !is_secure_or_local_http_url(url) {
615                return Err(ValidationErrors::from_message(
616                    "control.pop_browser_url",
617                    "must use https unless it points to a local endpoint",
618                ));
619            }
620        }
621
622        Ok(())
623    }
624
625    fn validate_derp(config: &Self) -> Result<(), ValidationErrors> {
626        let has_external_sources = !config.derp.urls.is_empty() || !config.derp.paths.is_empty();
627        if config.derp.regions.is_empty() && !has_external_sources {
628            return Err(ValidationErrors::from_message(
629                "derp",
630                "must configure at least one inline DERP region or one external DERP source",
631            ));
632        }
633
634        let mut region_ids = std::collections::BTreeSet::new();
635        let mut node_names = std::collections::BTreeSet::new();
636
637        for region in &config.derp.regions {
638            if region.region_id == 0 {
639                return Err(ValidationErrors::from_message(
640                    "derp.regions[].region_id",
641                    "must be greater than zero",
642                ));
643            }
644
645            if !region_ids.insert(region.region_id) {
646                return Err(ValidationErrors::from_message(
647                    "derp.regions[].region_id",
648                    format!("duplicate DERP region id {}", region.region_id),
649                ));
650            }
651
652            if region.region_code.trim().is_empty() {
653                return Err(ValidationErrors::from_message(
654                    "derp.regions[].region_code",
655                    "must not be empty",
656                ));
657            }
658
659            if region.region_name.trim().is_empty() {
660                return Err(ValidationErrors::from_message(
661                    "derp.regions[].region_name",
662                    "must not be empty",
663                ));
664            }
665
666            if region.nodes.is_empty() {
667                return Err(ValidationErrors::from_message(
668                    "derp.regions[].nodes",
669                    format!(
670                        "DERP region {} must contain at least one node",
671                        region.region_id
672                    ),
673                ));
674            }
675
676            if let Some(latitude) = region.latitude
677                && (!latitude.is_finite() || !(-90.0..=90.0).contains(&latitude))
678            {
679                return Err(ValidationErrors::from_message(
680                    "derp.regions[].latitude",
681                    format!(
682                        "latitude for DERP region {} must be within [-90, 90]",
683                        region.region_id
684                    ),
685                ));
686            }
687
688            if let Some(longitude) = region.longitude
689                && (!longitude.is_finite() || !(-180.0..=180.0).contains(&longitude))
690            {
691                return Err(ValidationErrors::from_message(
692                    "derp.regions[].longitude",
693                    format!(
694                        "longitude for DERP region {} must be within [-180, 180]",
695                        region.region_id
696                    ),
697                ));
698            }
699
700            for node in &region.nodes {
701                if node.name.trim().is_empty() {
702                    return Err(ValidationErrors::from_message(
703                        "derp.regions[].nodes[].name",
704                        "must not be empty",
705                    ));
706                }
707
708                if !node_names.insert(node.name.clone()) {
709                    return Err(ValidationErrors::from_message(
710                        "derp.regions[].nodes[].name",
711                        format!("duplicate DERP node name {}", node.name),
712                    ));
713                }
714
715                if node.host_name.trim().is_empty() {
716                    return Err(ValidationErrors::from_message(
717                        "derp.regions[].nodes[].host_name",
718                        "must not be empty",
719                    ));
720                }
721
722                if let Some(ipv4) = node.ipv4.as_deref().filter(|value| *value != "none") {
723                    ipv4.parse::<std::net::Ipv4Addr>().map_err(|err| {
724                        ValidationErrors::from_message(
725                            "derp.regions[].nodes[].ipv4",
726                            format!("invalid IPv4 address {ipv4}: {err}"),
727                        )
728                    })?;
729                }
730
731                if let Some(ipv6) = node.ipv6.as_deref().filter(|value| *value != "none") {
732                    ipv6.parse::<std::net::Ipv6Addr>().map_err(|err| {
733                        ValidationErrors::from_message(
734                            "derp.regions[].nodes[].ipv6",
735                            format!("invalid IPv6 address {ipv6}: {err}"),
736                        )
737                    })?;
738                }
739
740                if let Some(stun_test_ip) = node
741                    .stun_test_ip
742                    .as_deref()
743                    .filter(|value| !value.is_empty())
744                {
745                    stun_test_ip.parse::<std::net::IpAddr>().map_err(|err| {
746                        ValidationErrors::from_message(
747                            "derp.regions[].nodes[].stun_test_ip",
748                            format!("invalid STUN test IP {stun_test_ip}: {err}"),
749                        )
750                    })?;
751                }
752
753                if node.stun_port < -1 {
754                    return Err(ValidationErrors::from_message(
755                        "derp.regions[].nodes[].stun_port",
756                        "must be -1 or greater",
757                    ));
758                }
759            }
760        }
761
762        for url in &config.derp.urls {
763            if url.trim().is_empty() {
764                return Err(ValidationErrors::from_message(
765                    "derp.urls[]",
766                    "must not be empty",
767                ));
768            }
769
770            if !is_secure_or_local_url(url) {
771                return Err(ValidationErrors::from_message(
772                    "derp.urls[]",
773                    format!(
774                        "DERP source URL must use https unless it points to a local endpoint: {url}"
775                    ),
776                ));
777            }
778        }
779
780        for path in &config.derp.paths {
781            if path.trim().is_empty() {
782                return Err(ValidationErrors::from_message(
783                    "derp.paths[]",
784                    "must not be empty",
785                ));
786            }
787        }
788
789        if has_external_sources && config.derp.refresh_interval_secs == 0 {
790            return Err(ValidationErrors::from_message(
791                "derp.refresh_interval_secs",
792                "must be greater than zero when DERP URLs or paths are configured",
793            ));
794        }
795
796        if !config.derp.urls.is_empty() && config.derp.request_timeout_secs == 0 {
797            return Err(ValidationErrors::from_message(
798                "derp.request_timeout_secs",
799                "must be greater than zero when DERP URLs are configured",
800            ));
801        }
802
803        if !config.derp.urls.is_empty() && config.derp.total_timeout_secs == 0 {
804            return Err(ValidationErrors::from_message(
805                "derp.total_timeout_secs",
806                "must be greater than zero when DERP URLs are configured",
807            ));
808        }
809
810        if !config.derp.urls.is_empty()
811            && config.derp.total_timeout_secs < config.derp.request_timeout_secs
812        {
813            return Err(ValidationErrors::from_message(
814                "derp.total_timeout_secs",
815                "must be greater than or equal to derp.request_timeout_secs",
816            ));
817        }
818
819        if config.derp.server.enabled {
820            let private_key = config.derp.server.private_key.trim();
821            if private_key.is_empty() {
822                return Err(ValidationErrors::from_message(
823                    "derp.server.private_key",
824                    "is required when the embedded DERP relay is enabled",
825                ));
826            }
827
828            validate_machine_private_key(private_key)
829                .map_err(|err| ValidationErrors::from_message("derp.server.private_key", err))?;
830
831            if let Some(bind_addr) = config.derp.server.stun_bind_addr.as_deref()
832                && !bind_addr.trim().is_empty()
833            {
834                bind_addr.parse::<SocketAddr>().map_err(|err| {
835                    ValidationErrors::from_message(
836                        "derp.server.stun_bind_addr",
837                        format!("must be a valid socket address: {err}"),
838                    )
839                })?;
840            }
841
842            if config.derp.server.keepalive_interval_secs == 0 {
843                return Err(ValidationErrors::from_message(
844                    "derp.server.keepalive_interval_secs",
845                    "must be greater than zero when the embedded DERP relay is enabled",
846                ));
847            }
848        }
849
850        if let Some(mesh_key) = config.derp.server.mesh_key.as_deref()
851            && !mesh_key.trim().is_empty()
852        {
853            validate_derp_mesh_key(mesh_key)
854                .map_err(|err| ValidationErrors::from_message("derp.server.mesh_key", err))?;
855
856            let node_name = config
857                .derp
858                .server
859                .node_name
860                .as_deref()
861                .map(str::trim)
862                .unwrap_or_default();
863            if node_name.is_empty() {
864                return Err(ValidationErrors::from_message(
865                    "derp.server.node_name",
866                    "is required when derp.server.mesh_key is configured",
867                ));
868            }
869
870            if config.derp.server.mesh_retry_interval_secs == 0 {
871                return Err(ValidationErrors::from_message(
872                    "derp.server.mesh_retry_interval_secs",
873                    "must be greater than zero when derp.server.mesh_key is configured",
874                ));
875            }
876
877            let node_matches = config
878                .derp
879                .regions
880                .iter()
881                .flat_map(|region| region.nodes.iter())
882                .filter(|node| node.name == node_name)
883                .count();
884            if !config.derp.regions.is_empty() && node_matches == 0 {
885                return Err(ValidationErrors::from_message(
886                    "derp.server.node_name",
887                    format!("references unknown DERP node {node_name}"),
888                ));
889            }
890            if node_matches > 1 {
891                return Err(ValidationErrors::from_message(
892                    "derp.server.node_name",
893                    format!("DERP node name {node_name} must be unique"),
894                ));
895            }
896        }
897
898        for region in &config.derp.regions {
899            for node in &region.nodes {
900                if let Some(mesh_url) = node.mesh_url.as_deref()
901                    && !mesh_url.trim().is_empty()
902                {
903                    validate_derp_mesh_url(mesh_url).map_err(|err| {
904                        ValidationErrors::from_message("derp.regions[].nodes[].mesh_url", err)
905                    })?;
906                }
907            }
908        }
909
910        for (region_id, score) in &config.derp.home_params.region_score {
911            if *region_id == 0 {
912                return Err(ValidationErrors::from_message(
913                    "derp.home_params.region_score",
914                    "region score keys must be greater than zero",
915                ));
916            }
917
918            if !has_external_sources && !region_ids.contains(region_id) {
919                return Err(ValidationErrors::from_message(
920                    "derp.home_params.region_score",
921                    format!("region score references unknown DERP region {region_id}"),
922                ));
923            }
924
925            if *score <= 0.0 || !score.is_finite() {
926                return Err(ValidationErrors::from_message(
927                    "derp.home_params.region_score",
928                    format!("region score for {region_id} must be a positive finite number"),
929                ));
930            }
931        }
932
933        Ok(())
934    }
935}
936
937#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TierConfig)]
938#[serde(default)]
939pub struct ServerConfig {
940    pub bind_addr: String,
941    pub web_root: Option<String>,
942    pub public_base_url: Option<String>,
943    pub control_private_key: String,
944    pub map_poll_interval_secs: u64,
945    pub map_keepalive_interval_secs: u64,
946}
947
948impl Default for ServerConfig {
949    fn default() -> Self {
950        Self {
951            bind_addr: "127.0.0.1:8080".to_string(),
952            web_root: None,
953            public_base_url: None,
954            control_private_key: String::new(),
955            map_poll_interval_secs: 5,
956            map_keepalive_interval_secs: 50,
957        }
958    }
959}
960
961#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TierConfig)]
962#[serde(default)]
963pub struct NetworkConfig {
964    pub tailnet_ipv4_range: String,
965    pub tailnet_ipv6_range: String,
966    pub node_online_window_secs: u64,
967    pub node_session_ttl_secs: u64,
968}
969
970impl Default for NetworkConfig {
971    fn default() -> Self {
972        Self {
973            tailnet_ipv4_range: "100.64.0.0/10".to_string(),
974            tailnet_ipv6_range: "fd7a:115c:a1e0::/48".to_string(),
975            node_online_window_secs: 120,
976            node_session_ttl_secs: 604800,
977        }
978    }
979}
980
981#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TierConfig)]
982#[serde(default)]
983pub struct DatabaseConfig {
984    pub url: Option<String>,
985    pub max_connections: u32,
986}
987
988impl Default for DatabaseConfig {
989    fn default() -> Self {
990        Self {
991            url: None,
992            max_connections: 20,
993        }
994    }
995}
996
997#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, TierConfig)]
998#[serde(default)]
999pub struct ControlConfig {
1000    pub dial_plan: ControlDialPlanConfig,
1001    pub display_messages: BTreeMap<String, ControlDisplayMessageConfig>,
1002    pub client_version: ControlClientVersionConfig,
1003    pub collect_services: Option<bool>,
1004    pub node_attrs: ControlNodeAttrsConfig,
1005    pub pop_browser_url: Option<String>,
1006}
1007
1008#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, TierConfig)]
1009#[serde(default)]
1010pub struct ControlDialPlanConfig {
1011    pub candidates: Vec<ControlDialCandidateConfig>,
1012}
1013
1014#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, TierConfig)]
1015#[serde(default)]
1016pub struct ControlDialCandidateConfig {
1017    pub ip: Option<String>,
1018    pub ace_host: Option<String>,
1019    pub dial_start_delay_secs: Option<f64>,
1020    pub dial_timeout_secs: Option<f64>,
1021    pub priority: i32,
1022}
1023
1024#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TierConfig)]
1025#[serde(default)]
1026pub struct ControlDisplayMessageConfig {
1027    pub title: String,
1028    pub text: String,
1029    pub severity: ControlDisplayMessageSeverityConfig,
1030    pub impacts_connectivity: bool,
1031    pub primary_action: Option<ControlDisplayMessageActionConfig>,
1032}
1033
1034#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TierConfig)]
1035#[serde(default)]
1036pub struct ControlDisplayMessageActionConfig {
1037    pub url: String,
1038    pub label: String,
1039}
1040
1041#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TierConfig)]
1042#[serde(default)]
1043pub struct ControlClientVersionConfig {
1044    pub latest_version: Option<String>,
1045    pub urgent_security_update: bool,
1046    pub notify: bool,
1047    pub notify_url: Option<String>,
1048    pub notify_text: Option<String>,
1049}
1050
1051#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TierConfig)]
1052#[serde(default)]
1053pub struct ControlNodeAttrsConfig {
1054    pub tailnet_display_name: Option<String>,
1055    pub default_auto_update: Option<bool>,
1056    pub max_key_duration_secs: Option<u64>,
1057    pub cache_network_maps: bool,
1058    pub disable_hosts_file_updates: bool,
1059    pub force_register_magicdns_ipv4_only: bool,
1060    pub magicdns_peer_aaaa: bool,
1061    pub user_dial_use_routes: bool,
1062    pub disable_captive_portal_detection: bool,
1063    pub client_side_reachability: bool,
1064}
1065
1066impl ControlNodeAttrsConfig {
1067    pub fn enabled_count(&self) -> u32 {
1068        let mut count = 0;
1069        count += u32::from(self.tailnet_display_name.is_some());
1070        count += u32::from(self.default_auto_update.is_some());
1071        count += u32::from(self.max_key_duration_secs.is_some());
1072        count += u32::from(self.cache_network_maps);
1073        count += u32::from(self.disable_hosts_file_updates);
1074        count += u32::from(self.force_register_magicdns_ipv4_only);
1075        count += u32::from(self.magicdns_peer_aaaa);
1076        count += u32::from(self.user_dial_use_routes);
1077        count += u32::from(self.disable_captive_portal_detection);
1078        count += u32::from(self.client_side_reachability);
1079        count
1080    }
1081}
1082
1083#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TierConfig)]
1084#[serde(rename_all = "snake_case")]
1085pub enum ControlDisplayMessageSeverityConfig {
1086    High,
1087    #[default]
1088    Medium,
1089    Low,
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TierConfig)]
1093#[serde(default)]
1094pub struct AuthConfig {
1095    pub break_glass_username: String,
1096    pub break_glass_token: Option<String>,
1097    pub oidc: OidcConfig,
1098}
1099
1100impl Default for AuthConfig {
1101    fn default() -> Self {
1102        Self {
1103            break_glass_username: "admin".to_string(),
1104            break_glass_token: None,
1105            oidc: OidcConfig::default(),
1106        }
1107    }
1108}
1109
1110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TierConfig)]
1111#[serde(default)]
1112pub struct OidcConfig {
1113    pub enabled: bool,
1114    pub issuer_url: Option<String>,
1115    pub client_id: Option<String>,
1116    pub client_secret: Option<String>,
1117    pub scopes: Vec<String>,
1118    pub allowed_domains: Vec<String>,
1119    pub allowed_users: Vec<String>,
1120    pub allowed_groups: Vec<String>,
1121    pub extra_params: BTreeMap<String, String>,
1122    pub request_timeout_secs: u64,
1123    pub total_timeout_secs: u64,
1124    pub auth_flow_ttl_secs: u64,
1125    pub validate_discovery_on_startup: bool,
1126}
1127
1128impl Default for OidcConfig {
1129    fn default() -> Self {
1130        Self {
1131            enabled: false,
1132            issuer_url: None,
1133            client_id: None,
1134            client_secret: None,
1135            scopes: vec![
1136                "openid".to_string(),
1137                "profile".to_string(),
1138                "email".to_string(),
1139            ],
1140            allowed_domains: Vec::new(),
1141            allowed_users: Vec::new(),
1142            allowed_groups: Vec::new(),
1143            extra_params: BTreeMap::new(),
1144            request_timeout_secs: 5,
1145            total_timeout_secs: 15,
1146            auth_flow_ttl_secs: 600,
1147            validate_discovery_on_startup: true,
1148        }
1149    }
1150}
1151
1152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TierConfig)]
1153#[serde(default)]
1154pub struct TelemetryConfig {
1155    pub filter: String,
1156    pub format: LogFormat,
1157}
1158
1159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TierConfig)]
1160#[serde(default)]
1161pub struct DerpConfig {
1162    pub omit_default_regions: bool,
1163    pub urls: Vec<String>,
1164    pub paths: Vec<String>,
1165    pub refresh_interval_secs: u64,
1166    pub request_timeout_secs: u64,
1167    pub total_timeout_secs: u64,
1168    pub server: DerpServerConfig,
1169    pub home_params: DerpHomeParamsConfig,
1170    pub regions: Vec<DerpRegionConfig>,
1171}
1172
1173impl Default for DerpConfig {
1174    fn default() -> Self {
1175        Self {
1176            omit_default_regions: false,
1177            urls: Vec::new(),
1178            paths: Vec::new(),
1179            refresh_interval_secs: 300,
1180            request_timeout_secs: 5,
1181            total_timeout_secs: 15,
1182            server: DerpServerConfig::default(),
1183            home_params: DerpHomeParamsConfig::default(),
1184            regions: Vec::new(),
1185        }
1186    }
1187}
1188
1189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TierConfig)]
1190#[serde(default)]
1191pub struct DerpServerConfig {
1192    pub enabled: bool,
1193    pub private_key: String,
1194    pub mesh_key: Option<String>,
1195    pub node_name: Option<String>,
1196    pub stun_bind_addr: Option<String>,
1197    pub verify_clients: bool,
1198    pub keepalive_interval_secs: u64,
1199    pub mesh_retry_interval_secs: u64,
1200}
1201
1202impl Default for DerpServerConfig {
1203    fn default() -> Self {
1204        Self {
1205            enabled: false,
1206            private_key: String::new(),
1207            mesh_key: None,
1208            node_name: None,
1209            stun_bind_addr: Some("0.0.0.0:3478".to_string()),
1210            verify_clients: true,
1211            keepalive_interval_secs: 60,
1212            mesh_retry_interval_secs: 5,
1213        }
1214    }
1215}
1216
1217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, TierConfig)]
1218#[serde(default)]
1219pub struct DerpHomeParamsConfig {
1220    pub region_score: BTreeMap<u32, f64>,
1221}
1222
1223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, TierConfig)]
1224#[serde(default)]
1225pub struct DerpRegionConfig {
1226    pub region_id: u32,
1227    pub region_code: String,
1228    pub region_name: String,
1229    pub latitude: Option<f64>,
1230    pub longitude: Option<f64>,
1231    pub avoid: bool,
1232    pub no_measure_no_home: bool,
1233    pub nodes: Vec<DerpNodeConfig>,
1234}
1235
1236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TierConfig)]
1237#[serde(default)]
1238pub struct DerpNodeConfig {
1239    pub name: String,
1240    pub host_name: String,
1241    pub cert_name: Option<String>,
1242    pub ipv4: Option<String>,
1243    pub ipv6: Option<String>,
1244    pub stun_port: i32,
1245    pub stun_only: bool,
1246    pub derp_port: u16,
1247    pub insecure_for_tests: bool,
1248    pub stun_test_ip: Option<String>,
1249    pub can_port80: bool,
1250    pub mesh_url: Option<String>,
1251}
1252
1253impl Default for TelemetryConfig {
1254    fn default() -> Self {
1255        Self {
1256            filter: "info".to_string(),
1257            format: LogFormat::Pretty,
1258        }
1259    }
1260}
1261
1262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TierConfig)]
1263#[serde(rename_all = "snake_case")]
1264pub enum LogFormat {
1265    Json,
1266    #[default]
1267    Pretty,
1268    Compact,
1269}
1270
1271impl LogFormat {
1272    pub fn as_str(&self) -> &'static str {
1273        match self {
1274            Self::Json => "json",
1275            Self::Pretty => "pretty",
1276            Self::Compact => "compact",
1277        }
1278    }
1279}
1280
1281impl FromStr for LogFormat {
1282    type Err = AppError;
1283
1284    fn from_str(value: &str) -> Result<Self, Self::Err> {
1285        match value {
1286            "json" => Ok(Self::Json),
1287            "pretty" => Ok(Self::Pretty),
1288            "compact" => Ok(Self::Compact),
1289            _ => Err(AppError::InvalidConfig(format!(
1290                "unsupported log format: {value}"
1291            ))),
1292        }
1293    }
1294}
1295
1296#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1297pub struct ConfigSummary {
1298    pub bind_addr: String,
1299    pub web_root_configured: bool,
1300    pub control_protocol_enabled: bool,
1301    pub tailnet_ipv4_range: String,
1302    pub tailnet_ipv6_range: String,
1303    pub database_configured: bool,
1304    pub derp_region_count: u32,
1305    pub derp_url_count: u32,
1306    pub derp_path_count: u32,
1307    pub derp_omit_default_regions: bool,
1308    pub derp_refresh_interval_secs: u64,
1309    pub derp_embedded_relay_enabled: bool,
1310    pub derp_stun_bind_addr: Option<String>,
1311    pub derp_verify_clients: bool,
1312    pub admin_auth_configured: bool,
1313    pub oidc_enabled: bool,
1314    pub oidc_discovery_validation: bool,
1315    pub control_display_message_count: u32,
1316    pub control_dial_candidate_count: u32,
1317    pub control_client_version_configured: bool,
1318    pub control_collect_services_configured: bool,
1319    pub control_node_attr_count: u32,
1320    pub control_pop_browser_url_configured: bool,
1321    pub log_filter: String,
1322    pub log_format: String,
1323}
1324
1325fn validate_machine_private_key(value: &str) -> Result<(), String> {
1326    const PREFIX: &str = "privkey:";
1327
1328    let encoded = value
1329        .strip_prefix(PREFIX)
1330        .ok_or_else(|| format!("must start with {PREFIX}"))?;
1331
1332    if encoded.len() != 64 {
1333        return Err("must contain exactly 32 bytes encoded as 64 hex characters".to_string());
1334    }
1335
1336    if !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1337        return Err("must be hexadecimal".to_string());
1338    }
1339
1340    Ok(())
1341}
1342
1343fn validate_derp_mesh_key(value: &str) -> Result<(), String> {
1344    if value.len() != 64 {
1345        return Err("must contain exactly 64 hex characters".to_string());
1346    }
1347
1348    if !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1349        return Err("must be hex-encoded".to_string());
1350    }
1351
1352    Ok(())
1353}
1354
1355fn validate_derp_mesh_url(value: &str) -> Result<(), String> {
1356    let trimmed = value.trim();
1357    let Some(scheme_end) = trimmed.find("://") else {
1358        return Err("must include a scheme".to_string());
1359    };
1360
1361    let scheme = &trimmed[..scheme_end];
1362    if !matches!(scheme, "http" | "https" | "ws" | "wss") {
1363        return Err("scheme must be one of http, https, ws, or wss".to_string());
1364    }
1365
1366    if trimmed[scheme_end + 3..].trim().is_empty() {
1367        return Err("must include a host".to_string());
1368    }
1369
1370    Ok(())
1371}
1372
1373fn is_secure_or_local_http_url(value: &str) -> bool {
1374    value.starts_with("https://")
1375        || value.starts_with("http://127.0.0.1")
1376        || value.starts_with("http://localhost")
1377        || value.starts_with("http://[::1]")
1378}
1379
1380fn validate_ipv4_cidr(value: &str) -> Result<(), String> {
1381    let (address, prefix_len) = value
1382        .split_once('/')
1383        .ok_or_else(|| "must be in CIDR notation".to_string())?;
1384
1385    let _: std::net::Ipv4Addr = address
1386        .parse()
1387        .map_err(|err| format!("invalid IPv4 address: {err}"))?;
1388    let prefix_len: u8 = prefix_len
1389        .parse()
1390        .map_err(|err| format!("invalid IPv4 prefix length: {err}"))?;
1391
1392    if prefix_len > 30 {
1393        return Err("must allow at least two usable IPv4 host addresses".to_string());
1394    }
1395
1396    Ok(())
1397}
1398
1399fn validate_ipv6_cidr(value: &str) -> Result<(), String> {
1400    let (address, prefix_len) = value
1401        .split_once('/')
1402        .ok_or_else(|| "must be in CIDR notation".to_string())?;
1403
1404    let _: std::net::Ipv6Addr = address
1405        .parse()
1406        .map_err(|err| format!("invalid IPv6 address: {err}"))?;
1407    let prefix_len: u8 = prefix_len
1408        .parse()
1409        .map_err(|err| format!("invalid IPv6 prefix length: {err}"))?;
1410
1411    if prefix_len > 127 {
1412        return Err("must allow at least one allocatable IPv6 address".to_string());
1413    }
1414
1415    Ok(())
1416}
1417
1418fn validate_release_version(value: &str) -> Result<(), String> {
1419    let trimmed = value.trim();
1420    let core = trimmed
1421        .split_once('-')
1422        .map_or(trimmed, |(prefix, _)| prefix);
1423    let core = core.split_once('+').map_or(core, |(prefix, _)| prefix);
1424    let mut seen = 0_u8;
1425
1426    for part in core.split('.') {
1427        if part.is_empty() {
1428            return Err("must use dot-separated numeric segments".to_string());
1429        }
1430        part.parse::<u64>()
1431            .map_err(|err| format!("contains an invalid numeric segment: {err}"))?;
1432        seen += 1;
1433    }
1434
1435    if seen < 2 {
1436        return Err("must contain at least major.minor segments".to_string());
1437    }
1438
1439    Ok(())
1440}
1441
1442fn is_secure_or_local_url(value: &str) -> bool {
1443    value.starts_with("https://")
1444        || value.starts_with("http://127.0.0.1")
1445        || value.starts_with("http://localhost")
1446        || value.starts_with("http://[::1]")
1447}
1448
1449#[cfg(test)]
1450mod tests {
1451    use std::error::Error;
1452    use std::time::{SystemTime, UNIX_EPOCH};
1453
1454    use super::*;
1455
1456    fn write_temp_config(contents: &str) -> Result<PathBuf, Box<dyn Error>> {
1457        let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
1458        let path = env::temp_dir().join(format!("rscale-config-{unique}.toml"));
1459        std::fs::write(&path, contents)?;
1460        Ok(path)
1461    }
1462
1463    #[test]
1464    fn default_config_requires_explicit_runtime_secrets() {
1465        let config = AppConfig::default();
1466        assert!(config.validate().is_err());
1467    }
1468
1469    #[test]
1470    fn config_loads_via_tier_from_file() -> Result<(), Box<dyn Error>> {
1471        let path = write_temp_config(
1472            r#"
1473[server]
1474bind_addr = "0.0.0.0:9090"
1475public_base_url = "https://rscale.example.com"
1476control_private_key = "privkey:1111111111111111111111111111111111111111111111111111111111111111"
1477
1478[network]
1479tailnet_ipv4_range = "100.64.0.0/10"
1480tailnet_ipv6_range = "fd7a:115c:a1e0::/48"
1481
1482[database]
1483url = "postgres://localhost/rscale"
1484max_connections = 32
1485
1486[auth]
1487break_glass_username = "bootstrap-admin"
1488break_glass_token = "0123456789abcdef01234567"
1489
1490[auth.oidc]
1491enabled = true
1492issuer_url = "https://issuer.example.com"
1493client_id = "rscale"
1494client_secret = "secret"
1495request_timeout_secs = 3
1496total_timeout_secs = 10
1497
1498[control]
1499
1500[control.display_messages.maintenance]
1501title = "Scheduled maintenance"
1502text = "Control plane maintenance is in progress."
1503severity = "medium"
1504impacts_connectivity = false
1505
1506[control.display_messages.maintenance.primary_action]
1507url = "https://status.example.com"
1508label = "Status page"
1509
1510[derp]
1511omit_default_regions = true
1512
1513[[derp.regions]]
1514region_id = 900
1515region_code = "test"
1516region_name = "Test Region"
1517
1518[[derp.regions.nodes]]
1519name = "900a"
1520host_name = "derp.example.com"
1521stun_port = 3478
1522derp_port = 443
1523
1524[telemetry]
1525filter = "debug"
1526format = "json"
1527"#,
1528        )?;
1529
1530        let loaded = AppConfig::load_with_report(Some(&path))?;
1531
1532        assert_eq!(loaded.server.bind_addr, "0.0.0.0:9090");
1533        assert_eq!(loaded.network.tailnet_ipv4_range, "100.64.0.0/10");
1534        assert_eq!(loaded.database.max_connections, 32);
1535        assert_eq!(loaded.telemetry.format, LogFormat::Json);
1536        assert!(loaded.config().validate().is_ok());
1537        assert!(!loaded.report().has_warnings());
1538
1539        std::fs::remove_file(path)?;
1540        Ok(())
1541    }
1542
1543    #[test]
1544    fn invalid_bind_addr_is_rejected() {
1545        let config = AppConfig {
1546            server: ServerConfig {
1547                bind_addr: "not-an-address".to_string(),
1548                web_root: None,
1549                public_base_url: None,
1550                control_private_key:
1551                    "privkey:1111111111111111111111111111111111111111111111111111111111111111"
1552                        .to_string(),
1553                map_poll_interval_secs: 5,
1554                map_keepalive_interval_secs: 50,
1555            },
1556            ..AppConfig::default()
1557        };
1558
1559        assert!(config.validate().is_err());
1560    }
1561
1562    #[test]
1563    fn derp_accepts_external_sources_without_inline_regions() {
1564        let mut config = AppConfig::default();
1565        config.server.control_private_key =
1566            "privkey:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1567        config.database.url = Some("postgres://localhost/rscale".to_string());
1568        config.auth.break_glass_token = Some("0123456789abcdef01234567".to_string());
1569        config.derp.urls = vec!["https://controlplane.tailscale.com/derpmap/default".to_string()];
1570        config.derp.regions.clear();
1571
1572        assert!(config.validate().is_ok());
1573    }
1574
1575    #[test]
1576    fn embedded_derp_requires_private_key() {
1577        let mut config = AppConfig::default();
1578        config.server.control_private_key =
1579            "privkey:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1580        config.database.url = Some("postgres://localhost/rscale".to_string());
1581        config.auth.break_glass_token = Some("0123456789abcdef01234567".to_string());
1582        config.derp.server.enabled = true;
1583        config.derp.regions = vec![DerpRegionConfig {
1584            region_id: 900,
1585            region_code: "sha".to_string(),
1586            region_name: "Shanghai".to_string(),
1587            nodes: vec![DerpNodeConfig {
1588                name: "900a".to_string(),
1589                host_name: "derp.example.com".to_string(),
1590                stun_port: 3478,
1591                derp_port: 443,
1592                ..DerpNodeConfig::default()
1593            }],
1594            ..DerpRegionConfig::default()
1595        }];
1596
1597        assert!(config.validate().is_err());
1598    }
1599
1600    #[test]
1601    fn embedded_derp_accepts_valid_stun_bind_addr() {
1602        let mut config = AppConfig::default();
1603        config.server.control_private_key =
1604            "privkey:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1605        config.database.url = Some("postgres://localhost/rscale".to_string());
1606        config.auth.break_glass_token = Some("0123456789abcdef01234567".to_string());
1607        config.derp.server.enabled = true;
1608        config.derp.server.private_key =
1609            "privkey:2222222222222222222222222222222222222222222222222222222222222222".to_string();
1610        config.derp.server.stun_bind_addr = Some("0.0.0.0:3478".to_string());
1611        config.derp.regions = vec![DerpRegionConfig {
1612            region_id: 900,
1613            region_code: "sha".to_string(),
1614            region_name: "Shanghai".to_string(),
1615            nodes: vec![DerpNodeConfig {
1616                name: "900a".to_string(),
1617                host_name: "derp.example.com".to_string(),
1618                stun_port: 3478,
1619                derp_port: 443,
1620                ..DerpNodeConfig::default()
1621            }],
1622            ..DerpRegionConfig::default()
1623        }];
1624
1625        assert!(config.validate().is_ok());
1626    }
1627
1628    #[test]
1629    fn embedded_derp_mesh_requires_node_name() {
1630        let mut config = AppConfig::default();
1631        config.server.control_private_key =
1632            "privkey:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1633        config.database.url = Some("postgres://localhost/rscale".to_string());
1634        config.auth.break_glass_token = Some("0123456789abcdef01234567".to_string());
1635        config.derp.server.enabled = true;
1636        config.derp.server.private_key =
1637            "privkey:2222222222222222222222222222222222222222222222222222222222222222".to_string();
1638        config.derp.server.mesh_key =
1639            Some("3333333333333333333333333333333333333333333333333333333333333333".to_string());
1640        config.derp.regions = vec![DerpRegionConfig {
1641            region_id: 900,
1642            region_code: "sha".to_string(),
1643            region_name: "Shanghai".to_string(),
1644            nodes: vec![DerpNodeConfig {
1645                name: "900a".to_string(),
1646                host_name: "derp.example.com".to_string(),
1647                stun_port: 3478,
1648                derp_port: 443,
1649                ..DerpNodeConfig::default()
1650            }],
1651            ..DerpRegionConfig::default()
1652        }];
1653
1654        assert!(config.validate().is_err());
1655    }
1656
1657    #[test]
1658    fn control_display_message_requires_https_action_url() {
1659        let mut config = AppConfig::default();
1660        config.server.control_private_key =
1661            "privkey:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1662        config.database.url = Some("postgres://localhost/rscale".to_string());
1663        config.auth.break_glass_token = Some("0123456789abcdef01234567".to_string());
1664        config.derp.urls = vec!["https://controlplane.tailscale.com/derpmap/default".to_string()];
1665        config.derp.regions.clear();
1666        config.control.display_messages.insert(
1667            "maintenance".to_string(),
1668            ControlDisplayMessageConfig {
1669                title: "Maintenance".to_string(),
1670                text: "Scheduled work".to_string(),
1671                severity: ControlDisplayMessageSeverityConfig::Medium,
1672                impacts_connectivity: false,
1673                primary_action: Some(ControlDisplayMessageActionConfig {
1674                    url: "http://example.com".to_string(),
1675                    label: "View status".to_string(),
1676                }),
1677            },
1678        );
1679
1680        assert!(config.validate().is_err());
1681    }
1682
1683    #[test]
1684    fn control_dial_candidate_requires_endpoint() {
1685        let mut config = AppConfig::default();
1686        config.server.control_private_key =
1687            "privkey:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1688        config.database.url = Some("postgres://localhost/rscale".to_string());
1689        config.auth.break_glass_token = Some("0123456789abcdef01234567".to_string());
1690        config.derp.urls = vec!["https://controlplane.tailscale.com/derpmap/default".to_string()];
1691        config.derp.regions.clear();
1692        config
1693            .control
1694            .dial_plan
1695            .candidates
1696            .push(ControlDialCandidateConfig::default());
1697
1698        assert!(config.validate().is_err());
1699    }
1700
1701    #[test]
1702    fn control_node_attrs_validate_non_empty_and_positive_values() {
1703        let mut config = AppConfig::default();
1704        config.server.control_private_key =
1705            "privkey:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1706        config.database.url = Some("postgres://localhost/rscale".to_string());
1707        config.auth.break_glass_token = Some("0123456789abcdef01234567".to_string());
1708        config.derp.urls = vec!["https://controlplane.tailscale.com/derpmap/default".to_string()];
1709        config.derp.regions.clear();
1710        config.control.node_attrs.tailnet_display_name = Some("   ".to_string());
1711        config.control.node_attrs.max_key_duration_secs = Some(0);
1712
1713        assert!(config.validate().is_err());
1714    }
1715
1716    #[test]
1717    fn control_client_version_requires_latest_version_when_enabled() {
1718        let mut config = AppConfig::default();
1719        config.server.control_private_key =
1720            "privkey:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1721        config.database.url = Some("postgres://localhost/rscale".to_string());
1722        config.auth.break_glass_token = Some("0123456789abcdef01234567".to_string());
1723        config.derp.urls = vec!["https://controlplane.tailscale.com/derpmap/default".to_string()];
1724        config.derp.regions.clear();
1725        config.control.client_version.notify = true;
1726
1727        assert!(config.validate().is_err());
1728    }
1729
1730    #[test]
1731    fn control_client_version_rejects_insecure_notify_url() {
1732        let mut config = AppConfig::default();
1733        config.server.control_private_key =
1734            "privkey:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1735        config.database.url = Some("postgres://localhost/rscale".to_string());
1736        config.auth.break_glass_token = Some("0123456789abcdef01234567".to_string());
1737        config.derp.urls = vec!["https://controlplane.tailscale.com/derpmap/default".to_string()];
1738        config.derp.regions.clear();
1739        config.control.client_version.latest_version = Some("1.82.0".to_string());
1740        config.control.client_version.notify = true;
1741        config.control.client_version.notify_url = Some("http://example.com".to_string());
1742
1743        assert!(config.validate().is_err());
1744    }
1745
1746    #[test]
1747    fn control_pop_browser_url_requires_secure_or_local_http() {
1748        let mut config = AppConfig::default();
1749        config.server.control_private_key =
1750            "privkey:1111111111111111111111111111111111111111111111111111111111111111".to_string();
1751        config.database.url = Some("postgres://localhost/rscale".to_string());
1752        config.auth.break_glass_token = Some("0123456789abcdef01234567".to_string());
1753        config.derp.urls = vec!["https://controlplane.tailscale.com/derpmap/default".to_string()];
1754        config.derp.regions.clear();
1755        config.control.pop_browser_url = Some("http://example.com".to_string());
1756
1757        assert!(config.validate().is_err());
1758    }
1759}