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 ®ion.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 ®ion.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}