1use serde::Serialize;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "lowercase")]
10pub enum ConfigValueType {
11 String,
12 U8,
13 U16,
14 U32,
15 U64,
16 Bool,
17 Enum,
18 Range16,
20 Ip,
22 Fpath,
24 Domain,
26 #[serde(rename = "uri_path")]
28 UriPath,
29}
30
31impl ConfigValueType {
32 pub fn validate_str(&self, value: &str) -> bool {
35 match self {
36 ConfigValueType::String | ConfigValueType::Enum | ConfigValueType::Fpath => true,
37 ConfigValueType::U8 => value.parse::<u8>().is_ok(),
38 ConfigValueType::U16 => value.parse::<u16>().is_ok(),
39 ConfigValueType::U32 => value.parse::<u32>().is_ok(),
40 ConfigValueType::U64 => value.parse::<u64>().is_ok(),
41 ConfigValueType::Bool => value == "true" || value == "false",
42 ConfigValueType::Range16 => {
43 let Some((a, b)) = value.split_once('-') else {
44 return false;
45 };
46 let (Ok(start), Ok(end)) = (a.parse::<u16>(), b.parse::<u16>()) else {
47 return false;
48 };
49 start <= end
50 }
51 ConfigValueType::Ip => value.parse::<std::net::IpAddr>().is_ok(),
52 ConfigValueType::Domain => {
53 !value.is_empty()
54 && value.len() <= 253
55 && value.split('.').all(|label| {
56 !label.is_empty()
57 && label.len() <= 63
58 && label
59 .bytes()
60 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
61 })
62 }
63 ConfigValueType::UriPath => value.starts_with('/'),
64 }
65 }
66}
67
68impl std::fmt::Display for ConfigValueType {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 ConfigValueType::String => write!(f, "string"),
72 ConfigValueType::U8 => write!(f, "u8"),
73 ConfigValueType::U16 => write!(f, "u16"),
74 ConfigValueType::U32 => write!(f, "u32"),
75 ConfigValueType::U64 => write!(f, "u64"),
76 ConfigValueType::Bool => write!(f, "bool"),
77 ConfigValueType::Enum => write!(f, "enum"),
78 ConfigValueType::Range16 => write!(f, "range16"),
79 ConfigValueType::Ip => write!(f, "ip"),
80 ConfigValueType::Fpath => write!(f, "fpath"),
81 ConfigValueType::Domain => write!(f, "domain"),
82 ConfigValueType::UriPath => write!(f, "uri_path"),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize)]
89pub struct ConfigFieldDef {
90 pub key: &'static str,
92 pub toml_path: &'static str,
94 pub value_type: ConfigValueType,
96 pub default_value: &'static str,
98 pub dynamic: bool,
100 pub reloadable: bool,
102 pub description: &'static str,
104 pub service: &'static str,
106 pub choices: &'static [&'static str],
108}
109
110impl ConfigFieldDef {
111 pub fn validate(&self, value: &str) -> bool {
113 if !self.value_type.validate_str(value) {
114 return false;
115 }
116 if self.value_type == ConfigValueType::Enum && !self.choices.is_empty() {
117 return self.choices.contains(&value);
118 }
119 true
120 }
121}
122
123static REGISTRY: &[ConfigFieldDef] = &[
125 ConfigFieldDef {
127 key: "enable",
128 toml_path: "enable",
129 value_type: ConfigValueType::U8,
130 default_value: "31",
131 dynamic: false,
132 reloadable: false,
133 description: "Service enable bitmask",
134 service: "platform",
135 choices: &[],
136 },
137 ConfigFieldDef {
138 key: "name",
139 toml_path: "name",
140 value_type: ConfigValueType::String,
141 default_value: "actrix-default",
142 dynamic: false,
143 reloadable: false,
144 description: "Server instance name",
145 service: "platform",
146 choices: &[],
147 },
148 ConfigFieldDef {
149 key: "env",
150 toml_path: "env",
151 value_type: ConfigValueType::Enum,
152 default_value: "dev",
153 dynamic: false,
154 reloadable: false,
155 description: "Environment",
156 service: "platform",
157 choices: &["dev", "prod", "test"],
158 },
159 ConfigFieldDef {
160 key: "location_tag",
161 toml_path: "location_tag",
162 value_type: ConfigValueType::String,
163 default_value: "default-location",
164 dynamic: false,
165 reloadable: false,
166 description: "Geographic location tag",
167 service: "platform",
168 choices: &[],
169 },
170 ConfigFieldDef {
171 key: "sqlite_path",
172 toml_path: "sqlite_path",
173 value_type: ConfigValueType::Fpath,
174 default_value: "database",
175 dynamic: false,
176 reloadable: false,
177 description: "SQLite database directory",
178 service: "platform",
179 choices: &[],
180 },
181 ConfigFieldDef {
182 key: "bind.http.ip",
183 toml_path: "bind.http.ip",
184 value_type: ConfigValueType::Ip,
185 default_value: "::",
186 dynamic: false,
187 reloadable: false,
188 description: "HTTP listen address",
189 service: "platform",
190 choices: &[],
191 },
192 ConfigFieldDef {
193 key: "bind.http.port",
194 toml_path: "bind.http.port",
195 value_type: ConfigValueType::U16,
196 default_value: "8080",
197 dynamic: false,
198 reloadable: false,
199 description: "HTTP port",
200 service: "platform",
201 choices: &[],
202 },
203 ConfigFieldDef {
204 key: "bind.http.domain_name",
205 toml_path: "bind.http.domain_name",
206 value_type: ConfigValueType::Domain,
207 default_value: "localhost",
208 dynamic: false,
209 reloadable: false,
210 description: "HTTP domain name",
211 service: "platform",
212 choices: &[],
213 },
214 ConfigFieldDef {
215 key: "bind.http.advertised_ip",
216 toml_path: "bind.http.advertised_ip",
217 value_type: ConfigValueType::Ip,
218 default_value: "127.0.0.1",
219 dynamic: false,
220 reloadable: false,
221 description: "HTTP advertised IP",
222 service: "platform",
223 choices: &[],
224 },
225 ConfigFieldDef {
226 key: "bind.http.advertised_port",
227 toml_path: "bind.http.advertised_port",
228 value_type: ConfigValueType::U16,
229 default_value: "0",
230 dynamic: false,
231 reloadable: false,
232 description: "HTTP advertised port (0 = same as port)",
233 service: "platform",
234 choices: &[],
235 },
236 ConfigFieldDef {
237 key: "bind.http.cert",
238 toml_path: "bind.http.cert",
239 value_type: ConfigValueType::Fpath,
240 default_value: "",
241 dynamic: false,
242 reloadable: false,
243 description: "TLS certificate path (enables HTTPS)",
244 service: "platform",
245 choices: &[],
246 },
247 ConfigFieldDef {
248 key: "bind.http.key",
249 toml_path: "bind.http.key",
250 value_type: ConfigValueType::Fpath,
251 default_value: "",
252 dynamic: false,
253 reloadable: false,
254 description: "TLS private key path (enables HTTPS)",
255 service: "platform",
256 choices: &[],
257 },
258 ConfigFieldDef {
259 key: "recording.sink",
260 toml_path: "recording.sink",
261 value_type: ConfigValueType::String,
262 default_value: "",
263 dynamic: false,
264 reloadable: false,
265 description: "Global sink URI (file:// | otlp+http:// | otlp+grpc://)",
266 service: "platform",
267 choices: &[],
268 },
269 ConfigFieldDef {
270 key: "recording.service_name",
271 toml_path: "recording.service_name",
272 value_type: ConfigValueType::String,
273 default_value: "actrix",
274 dynamic: false,
275 reloadable: false,
276 description: "OTLP service name",
277 service: "platform",
278 choices: &[],
279 },
280 ConfigFieldDef {
282 key: "recording.observability.filter",
283 toml_path: "recording.observability.filter",
284 value_type: ConfigValueType::Enum,
285 default_value: "digest",
286 dynamic: true,
287 reloadable: true,
288 description: "Observability resolution: off / digest / detailed / full",
289 service: "platform",
290 choices: &["off", "digest", "detailed", "full"],
291 },
292 ConfigFieldDef {
293 key: "recording.observability.sink",
294 toml_path: "recording.observability.sink",
295 value_type: ConfigValueType::String,
296 default_value: "",
297 dynamic: false,
298 reloadable: false,
299 description: "Observability channel sink override",
300 service: "platform",
301 choices: &[],
302 },
303 ConfigFieldDef {
305 key: "recording.audit.filter",
306 toml_path: "recording.audit.filter",
307 value_type: ConfigValueType::Enum,
308 default_value: "mutations",
309 dynamic: true,
310 reloadable: true,
311 description: "Audit scope: off / mutations / all",
312 service: "platform",
313 choices: &["off", "mutations", "all"],
314 },
315 ConfigFieldDef {
316 key: "recording.audit.sink",
317 toml_path: "recording.audit.sink",
318 value_type: ConfigValueType::String,
319 default_value: "",
320 dynamic: false,
321 reloadable: false,
322 description: "Audit channel sink override",
323 service: "platform",
324 choices: &[],
325 },
326 ConfigFieldDef {
328 key: "recording.security.filter",
329 toml_path: "recording.security.filter",
330 value_type: ConfigValueType::Enum,
331 default_value: "all",
332 dynamic: true,
333 reloadable: true,
334 description: "Security severity threshold: off / critical / high / medium / all",
335 service: "platform",
336 choices: &["off", "critical", "high", "medium", "all"],
337 },
338 ConfigFieldDef {
339 key: "recording.security.sink",
340 toml_path: "recording.security.sink",
341 value_type: ConfigValueType::String,
342 default_value: "",
343 dynamic: false,
344 reloadable: false,
345 description: "Security channel sink override",
346 service: "platform",
347 choices: &[],
348 },
349 ConfigFieldDef {
351 key: "recording.operations.filter",
352 toml_path: "recording.operations.filter",
353 value_type: ConfigValueType::Enum,
354 default_value: "lifecycle",
355 dynamic: true,
356 reloadable: true,
357 description: "Operations detail: off / lifecycle / detailed",
358 service: "platform",
359 choices: &["off", "lifecycle", "detailed"],
360 },
361 ConfigFieldDef {
362 key: "recording.operations.sink",
363 toml_path: "recording.operations.sink",
364 value_type: ConfigValueType::String,
365 default_value: "",
366 dynamic: false,
367 reloadable: false,
368 description: "Operations channel sink override",
369 service: "platform",
370 choices: &[],
371 },
372 ConfigFieldDef {
373 key: "control.head",
374 toml_path: "control.head",
375 value_type: ConfigValueType::Enum,
376 default_value: "admin_ui",
377 dynamic: false,
378 reloadable: false,
379 description: "Legacy control plane mode override (prefer admin_ui.enabled / grpc_api.enabled)",
380 service: "platform",
381 choices: &["admin_ui", "grpc_api"],
382 },
383 ConfigFieldDef {
384 key: "control.admin_ui.enabled",
385 toml_path: "control.admin_ui.enabled",
386 value_type: ConfigValueType::Bool,
387 default_value: "true",
388 dynamic: false,
389 reloadable: false,
390 description: "Enable local Admin UI",
391 service: "platform",
392 choices: &[],
393 },
394 ConfigFieldDef {
395 key: "control.grpc_api.enabled",
396 toml_path: "control.grpc_api.enabled",
397 value_type: ConfigValueType::Bool,
398 default_value: "false",
399 dynamic: false,
400 reloadable: false,
401 description: "Enable NodeAdminService gRPC control API",
402 service: "platform",
403 choices: &[],
404 },
405 ConfigFieldDef {
406 key: "control.admin_ui.session_expiry_secs",
407 toml_path: "control.admin_ui.session_expiry_secs",
408 value_type: ConfigValueType::U64,
409 default_value: "86400",
410 dynamic: true,
411 reloadable: true,
412 description: "Admin session TTL",
413 service: "platform",
414 choices: &[],
415 },
416 ConfigFieldDef {
418 key: "bind.ice.ip",
419 toml_path: "bind.ice.ip",
420 value_type: ConfigValueType::Ip,
421 default_value: "0.0.0.0",
422 dynamic: false,
423 reloadable: false,
424 description: "Local IP address the ICE server listens on",
425 service: "platform",
426 choices: &[],
427 },
428 ConfigFieldDef {
429 key: "bind.ice.port",
430 toml_path: "bind.ice.port",
431 value_type: ConfigValueType::U16,
432 default_value: "3478",
433 dynamic: false,
434 reloadable: false,
435 description: "UDP port the ICE server binds to",
436 service: "platform",
437 choices: &[],
438 },
439 ConfigFieldDef {
440 key: "bind.ice.advertised_ip",
441 toml_path: "bind.ice.advertised_ip",
442 value_type: ConfigValueType::Ip,
443 default_value: "127.0.0.1",
444 dynamic: false,
445 reloadable: false,
446 description: "Public IP advertised to clients",
447 service: "platform",
448 choices: &[],
449 },
450 ConfigFieldDef {
451 key: "bind.ice.advertised_port",
452 toml_path: "bind.ice.advertised_port",
453 value_type: ConfigValueType::U16,
454 default_value: "3478",
455 dynamic: false,
456 reloadable: false,
457 description: "Public-facing port advertised to clients",
458 service: "platform",
459 choices: &[],
460 },
461 ConfigFieldDef {
462 key: "turn.relay_port_range",
463 toml_path: "turn.relay_port_range",
464 value_type: ConfigValueType::Range16,
465 default_value: "49152-65535",
466 dynamic: false,
467 reloadable: false,
468 description: "UDP port range for relay allocations",
469 service: "platform",
470 choices: &[],
471 },
472 ConfigFieldDef {
473 key: "turn.realm",
474 toml_path: "turn.realm",
475 value_type: ConfigValueType::Domain,
476 default_value: "actrix.local",
477 dynamic: true,
478 reloadable: true,
479 description: "Credential scope for long-term authentication (RFC 8489)",
480 service: "turn",
481 choices: &[],
482 },
483 ConfigFieldDef {
485 key: "services.signaling.server.ws_path",
486 toml_path: "services.signaling.server.ws_path",
487 value_type: ConfigValueType::UriPath,
488 default_value: "/signaling",
489 dynamic: false,
490 reloadable: true,
491 description: "WebSocket endpoint path for client connections",
492 service: "signaling",
493 choices: &[],
494 },
495 ConfigFieldDef {
496 key: "services.signaling.server.rate_limit.connection.enabled",
497 toml_path: "services.signaling.server.rate_limit.connection.enabled",
498 value_type: ConfigValueType::Bool,
499 default_value: "true",
500 dynamic: true,
501 reloadable: true,
502 description: "Enable connection-level rate limiting",
503 service: "signaling",
504 choices: &[],
505 },
506 ConfigFieldDef {
507 key: "services.signaling.server.rate_limit.connection.per_minute",
508 toml_path: "services.signaling.server.rate_limit.connection.per_minute",
509 value_type: ConfigValueType::U32,
510 default_value: "5",
511 dynamic: true,
512 reloadable: true,
513 description: "Max new connections per IP per minute",
514 service: "signaling",
515 choices: &[],
516 },
517 ConfigFieldDef {
518 key: "services.signaling.server.rate_limit.connection.burst_size",
519 toml_path: "services.signaling.server.rate_limit.connection.burst_size",
520 value_type: ConfigValueType::U32,
521 default_value: "10",
522 dynamic: true,
523 reloadable: true,
524 description: "Burst allowance for connection rate",
525 service: "signaling",
526 choices: &[],
527 },
528 ConfigFieldDef {
529 key: "services.signaling.server.rate_limit.connection.max_concurrent_per_ip",
530 toml_path: "services.signaling.server.rate_limit.connection.max_concurrent_per_ip",
531 value_type: ConfigValueType::U32,
532 default_value: "100",
533 dynamic: true,
534 reloadable: true,
535 description: "Max concurrent connections per IP",
536 service: "signaling",
537 choices: &[],
538 },
539 ConfigFieldDef {
540 key: "services.signaling.server.rate_limit.message.enabled",
541 toml_path: "services.signaling.server.rate_limit.message.enabled",
542 value_type: ConfigValueType::Bool,
543 default_value: "true",
544 dynamic: true,
545 reloadable: true,
546 description: "Enable message-level rate limiting",
547 service: "signaling",
548 choices: &[],
549 },
550 ConfigFieldDef {
551 key: "services.signaling.server.rate_limit.message.per_second",
552 toml_path: "services.signaling.server.rate_limit.message.per_second",
553 value_type: ConfigValueType::U32,
554 default_value: "10",
555 dynamic: true,
556 reloadable: true,
557 description: "Max messages per connection per second",
558 service: "signaling",
559 choices: &[],
560 },
561 ConfigFieldDef {
562 key: "services.signaling.server.rate_limit.message.burst_size",
563 toml_path: "services.signaling.server.rate_limit.message.burst_size",
564 value_type: ConfigValueType::U32,
565 default_value: "50",
566 dynamic: true,
567 reloadable: true,
568 description: "Burst allowance for message rate",
569 service: "signaling",
570 choices: &[],
571 },
572 ConfigFieldDef {
574 key: "services.ais.server.token_ttl_secs",
575 toml_path: "services.ais.server.token_ttl_secs",
576 value_type: ConfigValueType::U64,
577 default_value: "3600",
578 dynamic: true,
579 reloadable: true,
580 description: "Token time-to-live in seconds",
581 service: "ais",
582 choices: &[],
583 },
584 ConfigFieldDef {
585 key: "services.ais.server.signaling_heartbeat_interval_secs",
586 toml_path: "services.ais.server.signaling_heartbeat_interval_secs",
587 value_type: ConfigValueType::U32,
588 default_value: "30",
589 dynamic: true,
590 reloadable: true,
591 description: "Heartbeat interval for signaling connections",
592 service: "ais",
593 choices: &[],
594 },
595 ConfigFieldDef {
597 key: "services.signer.storage.key_ttl_seconds",
598 toml_path: "services.signer.storage.key_ttl_seconds",
599 value_type: ConfigValueType::U64,
600 default_value: "3600",
601 dynamic: true,
602 reloadable: true,
603 description: "Key time-to-live in seconds",
604 service: "signer",
605 choices: &[],
606 },
607 ConfigFieldDef {
608 key: "services.signer.tolerance_seconds",
609 toml_path: "services.signer.tolerance_seconds",
610 value_type: ConfigValueType::U64,
611 default_value: "300",
612 dynamic: true,
613 reloadable: true,
614 description: "Clock skew tolerance for key validation",
615 service: "signer",
616 choices: &[],
617 },
618 ConfigFieldDef {
620 key: "monitoring.htpasswd_file",
621 toml_path: "monitoring.htpasswd_file",
622 value_type: ConfigValueType::Fpath,
623 default_value: "",
624 dynamic: true,
625 reloadable: true,
626 description: "Path to htpasswd file for monitoring endpoint Basic Auth",
627 service: "platform",
628 choices: &[],
629 },
630];
631
632pub fn get_field(key: &str) -> Option<&'static ConfigFieldDef> {
634 REGISTRY.iter().find(|f| f.key == key)
635}
636
637pub fn fields_for_service(service: &str) -> Vec<&'static ConfigFieldDef> {
639 REGISTRY.iter().filter(|f| f.service == service).collect()
640}
641
642pub fn all_fields() -> &'static [ConfigFieldDef] {
644 REGISTRY
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
652 fn test_get_field() {
653 let field = get_field("turn.realm").unwrap();
654 assert_eq!(field.service, "turn");
655 assert!(field.dynamic);
656 assert!(field.reloadable);
657 assert_eq!(field.default_value, "actrix.local");
658 }
659
660 #[test]
661 fn test_get_field_not_found() {
662 assert!(get_field("nonexistent.field").is_none());
663 }
664
665 #[test]
666 fn test_fields_for_service() {
667 let platform_fields = fields_for_service("platform");
668 assert_eq!(platform_fields.len(), 32);
669 assert!(platform_fields.iter().all(|f| f.service == "platform"));
670
671 let stun_fields = fields_for_service("stun");
672 assert_eq!(stun_fields.len(), 0);
673
674 let signaling_fields = fields_for_service("signaling");
675 assert_eq!(signaling_fields.len(), 8);
676 }
677
678 #[test]
679 fn test_validate_type() {
680 assert!(ConfigValueType::U8.validate_str("31"));
681 assert!(!ConfigValueType::U8.validate_str("256"));
682 assert!(ConfigValueType::U16.validate_str("3478"));
683 assert!(!ConfigValueType::U16.validate_str("99999"));
684 assert!(ConfigValueType::U64.validate_str("99999"));
685 assert!(ConfigValueType::Bool.validate_str("true"));
686 assert!(!ConfigValueType::Bool.validate_str("yes"));
687 assert!(ConfigValueType::String.validate_str("anything"));
688
689 assert!(ConfigValueType::U32.validate_str("100000"));
690 assert!(!ConfigValueType::U32.validate_str("-1"));
691
692 assert!(ConfigValueType::Ip.validate_str("127.0.0.1"));
694 assert!(ConfigValueType::Ip.validate_str("0.0.0.0"));
695 assert!(ConfigValueType::Ip.validate_str("::"));
696 assert!(ConfigValueType::Ip.validate_str("::1"));
697 assert!(ConfigValueType::Ip.validate_str("2001:db8::1"));
698 assert!(!ConfigValueType::Ip.validate_str("not-an-ip"));
699 assert!(!ConfigValueType::Ip.validate_str("999.999.999.999"));
700
701 assert!(ConfigValueType::Fpath.validate_str("/etc/actrix/config.toml"));
703 assert!(ConfigValueType::Fpath.validate_str("relative/path"));
704
705 assert!(ConfigValueType::Domain.validate_str("localhost"));
707 assert!(ConfigValueType::Domain.validate_str("actrix.example.com"));
708 assert!(ConfigValueType::Domain.validate_str("actrix.local"));
709 assert!(!ConfigValueType::Domain.validate_str(""));
710 assert!(!ConfigValueType::Domain.validate_str("bad domain.com")); assert!(ConfigValueType::UriPath.validate_str("/signaling"));
714 assert!(ConfigValueType::UriPath.validate_str("/"));
715 assert!(!ConfigValueType::UriPath.validate_str("signaling")); assert!(ConfigValueType::Range16.validate_str("49152-65535"));
719 assert!(ConfigValueType::Range16.validate_str("1024-1024"));
720 assert!(!ConfigValueType::Range16.validate_str("65535-1024")); assert!(!ConfigValueType::Range16.validate_str("abc-def"));
722 assert!(!ConfigValueType::Range16.validate_str("1024")); }
724
725 #[test]
726 fn test_validate_enum() {
727 let field = get_field("env").unwrap();
728 assert_eq!(field.value_type, ConfigValueType::Enum);
729 assert!(field.validate("dev"));
730 assert!(field.validate("prod"));
731 assert!(!field.validate("staging"));
732
733 let level = get_field("recording.observability.filter").unwrap();
734 assert!(level.validate("digest"));
735 assert!(level.validate("full"));
736 assert!(!level.validate("verbose"));
737 }
738
739 #[test]
740 fn test_all_fields_have_valid_defaults() {
741 for field in all_fields() {
742 assert!(
743 field.validate(field.default_value),
744 "Field '{}' has invalid default '{}' for type {:?}",
745 field.key,
746 field.default_value,
747 field.value_type
748 );
749 }
750 }
751}