pub mod ais;
pub mod bind;
pub mod config_store;
pub mod control;
pub mod registry;
pub mod resolver;
pub mod services;
pub mod signaling;
pub mod signer;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub mod turn;
pub use crate::config::ais::AisConfig;
pub use crate::config::bind::BindConfig;
pub use crate::config::control::{AdminUiConfig, ControlConfig, ControlHead};
pub use crate::config::services::ServicesConfig;
pub use crate::config::signaling::SignalingConfig;
pub use crate::config::turn::TurnConfig;
use ::signer::storage::StorageBackend;
use std::path::{Path, PathBuf};
use url::Url;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ActrixConfig {
#[serde(default = "default_enable")]
pub enable: u8,
pub name: String,
pub env: String,
pub user: Option<String>,
pub group: Option<String>,
pub pid: Option<String>,
pub bind: BindConfig,
pub turn: TurnConfig,
pub location_tag: String,
#[serde(default)]
pub control: ControlConfig,
#[serde(default)]
pub services: ServicesConfig,
#[serde(
serialize_with = "serialize_pathbuf",
deserialize_with = "deserialize_pathbuf"
)]
pub sqlite_path: PathBuf,
pub actrix_shared_key: String,
#[serde(default)]
pub recording: RecordingConfig,
#[serde(default)]
pub monitoring: MonitoringConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct RecordingConfig {
#[serde(default)]
pub sink: Option<String>,
#[serde(default = "default_recording_service_name")]
pub service_name: String,
#[serde(default = "default_observability_channel")]
pub observability: RecordingChannelConfig,
#[serde(default = "default_audit_channel")]
pub audit: RecordingChannelConfig,
#[serde(default = "default_security_channel")]
pub security: RecordingChannelConfig,
#[serde(default = "default_operations_channel")]
pub operations: RecordingChannelConfig,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
pub struct RecordingChannelConfig {
#[serde(default)]
pub filter: String,
#[serde(default)]
pub sink: Option<String>,
}
fn default_audit_channel() -> RecordingChannelConfig {
RecordingChannelConfig {
filter: "mutations".to_string(),
sink: None,
}
}
fn default_security_channel() -> RecordingChannelConfig {
RecordingChannelConfig {
filter: "all".to_string(),
sink: None,
}
}
fn default_operations_channel() -> RecordingChannelConfig {
RecordingChannelConfig {
filter: "lifecycle".to_string(),
sink: None,
}
}
fn default_observability_channel() -> RecordingChannelConfig {
RecordingChannelConfig {
filter: "digest".to_string(),
sink: None,
}
}
impl Default for RecordingConfig {
fn default() -> Self {
Self {
sink: None,
service_name: default_recording_service_name(),
observability: default_observability_channel(),
audit: default_audit_channel(),
security: default_security_channel(),
operations: default_operations_channel(),
}
}
}
fn normalized_optional_uri(uri: &Option<String>) -> Option<String> {
uri.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn any_recording_file_sink(recording: &RecordingConfig) -> bool {
[
&recording.sink,
&recording.observability.sink,
&recording.audit.sink,
&recording.security.sink,
&recording.operations.sink,
]
.into_iter()
.filter_map(normalized_optional_uri)
.any(|sink| sink.starts_with("file://"))
}
fn validate_recording_sink_field(field: &str, value: &Option<String>, errors: &mut Vec<String>) {
let Some(uri) = normalized_optional_uri(value) else {
return;
};
let parsed = match Url::parse(&uri) {
Ok(parsed) => parsed,
Err(error) => {
errors.push(format!(
"Invalid URI in {field}: {uri} (parse error: {error})"
));
return;
}
};
match parsed.scheme() {
"file" => {
if parsed.to_file_path().is_err() {
errors.push(format!(
"Invalid file URI in {field}: {uri} (cannot convert to local file path)"
));
}
}
"otlp+http" | "otlp+grpc" => {
if parsed.host_str().is_none() {
errors.push(format!(
"Invalid OTLP URI in {field}: {uri} (host is required)"
));
}
}
_ => {
errors.push(format!(
"Invalid URI scheme in {field}: expected file://, otlp+http:// or otlp+grpc://, got {}",
parsed.scheme()
));
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringConfig {
#[serde(default)]
pub allowed_ips: Vec<String>,
#[serde(default)]
pub htpasswd_file: String,
#[serde(default)]
pub services: std::collections::HashMap<String, MonitoringServiceOverride>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringServiceOverride {
#[serde(default)]
pub allowed_ips: Vec<String>,
#[serde(default)]
pub htpasswd_file: String,
}
impl MonitoringConfig {
pub fn is_open(&self) -> bool {
self.allowed_ips.is_empty() && self.htpasswd_file.is_empty()
}
pub fn effective_for(&self, service: &str) -> (&[String], &str) {
if let Some(ovr) = self.services.get(service) {
(ovr.allowed_ips.as_slice(), ovr.htpasswd_file.as_str())
} else {
(self.allowed_ips.as_slice(), self.htpasswd_file.as_str())
}
}
pub fn is_open_for(&self, service: &str) -> bool {
let (ips, htpasswd) = self.effective_for(service);
ips.is_empty() && htpasswd.is_empty()
}
}
fn default_enable() -> u8 {
31 }
fn default_recording_service_name() -> String {
"actrix".to_string()
}
fn serialize_pathbuf<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
path.display().to_string().serialize(serializer)
}
fn deserialize_pathbuf<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(PathBuf::from(s))
}
impl Default for ActrixConfig {
fn default() -> Self {
Self {
enable: default_enable(), name: "actrix-default".to_string(),
env: "dev".to_string(),
user: None,
group: None,
pid: Some("logs/actrix.pid".to_string()),
bind: BindConfig::default(),
turn: TurnConfig::default(),
location_tag: "default-location".to_string(),
control: ControlConfig::default(),
services: ServicesConfig::default(),
sqlite_path: PathBuf::from("database"),
actrix_shared_key: "XDDYE8d+yMfdXcdWMrXprcUk2uzjnmoX6nCfFw1gGIg=".to_string(),
recording: RecordingConfig::default(),
monitoring: MonitoringConfig::default(),
}
}
}
pub const ENABLE_SIGNALING: u8 = 0b00001;
pub const ENABLE_STUN: u8 = 0b00010;
pub const ENABLE_TURN: u8 = 0b00100;
pub const ENABLE_AIS: u8 = 0b01000;
pub const ENABLE_SIGNER: u8 = 0b10000;
impl ActrixConfig {
pub fn is_signaling_enabled(&self) -> bool {
self.enable & ENABLE_SIGNALING != 0
}
pub fn is_stun_enabled(&self) -> bool {
self.enable & ENABLE_STUN != 0
}
pub fn is_turn_enabled(&self) -> bool {
self.enable & ENABLE_TURN != 0
}
pub fn is_ais_enabled(&self) -> bool {
self.enable & ENABLE_AIS != 0
}
pub fn is_signer_enabled(&self) -> bool {
self.enable & ENABLE_SIGNER != 0
}
pub fn is_ice_enabled(&self) -> bool {
self.is_stun_enabled() || self.is_turn_enabled()
}
pub fn control_head(&self) -> ControlHead {
if self.control.grpc_api_enabled() && !self.control.admin_ui_enabled() {
ControlHead::GrpcApi
} else {
ControlHead::AdminUi
}
}
pub fn admin_ui_enabled(&self) -> bool {
self.control.admin_ui_enabled()
}
pub fn grpc_api_enabled(&self) -> bool {
self.control.grpc_api_enabled()
}
pub fn superv_managed(&self) -> bool {
self.control.superv_managed()
}
pub fn get_pid_path(&self) -> Option<String> {
self.pid.clone().or_else(|| {
Some("logs/actrix.pid".to_string())
})
}
pub fn get_actrix_shared_key(&self) -> &str {
&self.actrix_shared_key
}
pub fn recording_config(&self) -> &RecordingConfig {
&self.recording
}
pub fn recording_service_name(&self) -> &str {
&self.recording.service_name
}
pub fn get_filter_level(&self) -> String {
if let Ok(rust_log) = std::env::var("RUST_LOG") {
let trimmed = rust_log.trim().to_string();
if !trimmed.is_empty() {
return trimmed;
}
}
"info,actrix::observability=trace,actrix::audit=trace,actrix::security=trace,actrix::operations=trace".to_string()
}
pub fn from_file<P: AsRef<std::path::Path>>(
path: P,
) -> Result<Self, Box<dyn std::error::Error>> {
let path_ref = path.as_ref();
if !path_ref.exists() {
return Err(format!("Configuration file does not exist: {path_ref:?}").into());
}
if !path_ref.is_file() {
return Err(format!("Path is not a valid file: {path_ref:?}").into());
}
let content = std::fs::read_to_string(path_ref)?;
let config: ActrixConfig = toml::from_str(&content)?;
Ok(config)
}
pub fn from_toml(content: &str) -> Result<Self, toml::de::Error> {
toml::from_str(content)
}
pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
toml::to_string(self)
}
pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
if self.enable > 31 {
errors.push(format!(
"Invalid enable bitmask value: {}. Must be between 0 and 31 (5 bits)",
self.enable
));
}
if self.name.trim().is_empty() {
errors.push("Instance name cannot be empty".to_string());
}
if !["dev", "prod", "test"].contains(&self.env.as_str()) {
errors.push(format!(
"Invalid environment '{}', must be one of: dev, prod, test",
self.env
));
}
if self.bind.http.is_none() {
errors.push(
"Control plane requires bind.http because /admin is always served".to_string(),
);
}
{
let check = |name: &str, value: &str, valid: &[&str]| -> Option<String> {
if value.is_empty() || valid.contains(&value) {
None
} else {
Some(format!(
"Invalid recording.{name}.filter '{value}', must be one of: {}",
valid.join(", ")
))
}
};
if let Some(e) = check(
"observability",
&self.recording.observability.filter,
&["off", "digest", "detailed", "full"],
) {
errors.push(e);
}
if let Some(e) = check(
"audit",
&self.recording.audit.filter,
&["off", "mutations", "all"],
) {
errors.push(e);
}
if let Some(e) = check(
"security",
&self.recording.security.filter,
&["off", "critical", "high", "medium", "all"],
) {
errors.push(e);
}
if let Some(e) = check(
"operations",
&self.recording.operations.filter,
&["off", "lifecycle", "detailed"],
) {
errors.push(e);
}
}
if self.recording.service_name.trim().is_empty() {
errors.push("recording.service_name cannot be empty".to_string());
}
validate_recording_sink_field("recording.sink", &self.recording.sink, &mut errors);
validate_recording_sink_field(
"recording.observability.sink",
&self.recording.observability.sink,
&mut errors,
);
validate_recording_sink_field(
"recording.audit.sink",
&self.recording.audit.sink,
&mut errors,
);
validate_recording_sink_field(
"recording.security.sink",
&self.recording.security.sink,
&mut errors,
);
validate_recording_sink_field(
"recording.operations.sink",
&self.recording.operations.sink,
&mut errors,
);
if self.actrix_shared_key.contains("default") || self.actrix_shared_key.contains("change") {
errors.push("Security warning: actrix_shared_key appears to be a default value. Please change it!".to_string());
}
if self.actrix_shared_key.len() < 16 {
errors.push("Security warning: actrix_shared_key is too short, recommend at least 16 characters".to_string());
}
if self
.sqlite_path
.to_str()
.map(|s| s.trim().is_empty())
.unwrap_or(true)
{
errors.push("SQLite database path cannot be empty".to_string());
}
if self.is_ice_enabled() {
if self.bind.ice.advertised_ip.trim().is_empty() {
errors.push(
"bind.ice.advertised_ip is required when ICE services are enabled".to_string(),
);
}
if self
.bind
.ice
.advertised_ip
.parse::<std::net::IpAddr>()
.is_err()
{
errors.push(format!(
"Invalid bind.ice.advertised_ip '{}', must be a valid IP address",
self.bind.ice.advertised_ip
));
}
}
if self.is_turn_enabled() && self.turn.realm.trim().is_empty() {
errors.push("TURN realm is required when TURN is enabled".to_string());
}
if self.is_signer_enabled() {
if let Some(ref signer_cfg) = self.services.signer {
match signer_cfg.storage.backend {
StorageBackend::Sqlite => {}
StorageBackend::Postgres => {
if signer_cfg.storage.postgres.is_none() {
errors.push(
"Signer is configured to use PostgreSQL but postgres config is missing"
.to_string(),
);
}
}
}
} else {
errors.push(
"Signer service is enabled (ENABLE_SIGNER bit is set) but services.signer configuration is missing".to_string(),
);
}
}
if self.is_ais_enabled() {
if let Some(ref ais) = self.services.ais {
if ais.get_signer_client_config(self).is_none() {
errors.push(
"AIS service is enabled but no Signer available: \
either configure services.ais.dependencies.signer or enable local Signer service"
.to_string(),
);
}
if ais.server.renewal_token_secret.is_empty() {
errors.push("services.ais.server.renewal_token_secret is required".to_string());
} else {
match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&ais.server.renewal_token_secret,
) {
Ok(decoded) if decoded.len() >= 32 => {}
Ok(decoded) => {
errors.push(format!(
"services.ais.server.renewal_token_secret must decode to \
at least 32 bytes, got {} byte(s)",
decoded.len()
));
}
Err(e) => {
errors.push(format!(
"services.ais.server.renewal_token_secret is not valid \
base64: {e}"
));
}
}
}
} else {
errors.push(
"AIS service is enabled (ENABLE_AIS bit is set) but services.ais configuration is missing".to_string(),
);
}
}
if self.is_signaling_enabled() {
if let Some(ref signaling) = self.services.signaling {
if signaling.dependencies.signer.is_none()
&& !(self.is_signer_enabled() && self.services.signer.is_some())
{
errors.push(
"Signaling dependencies.signer is not configured; enable Signer (bitmask + services.signer) to use local defaults"
.to_string(),
);
}
if signaling.dependencies.ais.is_none()
&& !(self.is_ais_enabled() && self.services.ais.is_some())
{
errors.push(
"Signaling dependencies.ais is not configured; enable AIS (bitmask + services.ais) to use local defaults"
.to_string(),
);
}
} else {
errors.push(
"Signaling service is enabled (ENABLE_SIGNALING bit is set) but services.signaling configuration is missing"
.to_string(),
);
}
}
if self.env == "prod" {
if let Some(ref http) = self.bind.http {
if !http.is_tls() {
errors.push(
"Production environment should enable HTTPS (configure cert and key in bind.http)".to_string(),
);
}
} else {
errors.push("Production environment requires bind.http configuration".to_string());
}
if !any_recording_file_sink(&self.recording) {
errors.push("Warning: Production environment should configure recording.sink (file://...) or channel-specific recording.<channel>.sink".to_string());
}
}
if let Err(e) = self.control.validate() {
errors.push(format!("Control configuration error: {e}"));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ::signer::SignerServiceConfig;
fn valid_ais_server_config() -> ais::AisServerConfig {
ais::AisServerConfig {
renewal_token_secret: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string(),
..Default::default()
}
}
#[test]
fn example_config_loads_and_validates() {
let path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../config.example.toml");
let cfg = ActrixConfig::from_file(&path).expect("example config should parse");
cfg.validate().expect("example config should validate");
assert!(
cfg.admin_ui_enabled(),
"example should have admin_ui enabled"
);
assert!(
!cfg.grpc_api_enabled(),
"example should have grpc_api disabled by default"
);
}
#[test]
fn test_default_config() {
let config = ActrixConfig::default();
assert_eq!(config.enable, 31); assert_eq!(config.name, "actrix-default");
assert_eq!(config.env, "dev");
assert!(config.is_signaling_enabled());
assert!(config.is_stun_enabled());
assert!(config.is_turn_enabled());
assert!(config.is_ais_enabled());
assert!(config.is_signer_enabled());
assert_eq!(config.recording.service_name, "actrix");
assert!(config.recording.sink.is_none());
assert_eq!(config.recording.observability.filter, "digest");
assert_eq!(config.recording.audit.filter, "mutations");
assert_eq!(config.recording.security.filter, "all");
assert_eq!(config.recording.operations.filter, "lifecycle");
}
#[test]
fn test_recording_global_plus_spec_deserialize() {
let mut config = ActrixConfig::default();
config.recording.sink = Some("file:///tmp/actrix.log".to_string());
config.recording.audit.sink = Some("otlp+http://127.0.0.1:4318/v1/logs".to_string());
config.recording.security.sink = Some("otlp+grpc://127.0.0.1:4317".to_string());
let toml = config.to_toml().expect("config should serialize");
let parsed = ActrixConfig::from_toml(&toml).expect("config should deserialize");
assert_eq!(
parsed.recording.sink.as_deref(),
Some("file:///tmp/actrix.log")
);
assert_eq!(
parsed.recording.audit.sink.as_deref(),
Some("otlp+http://127.0.0.1:4318/v1/logs")
);
assert_eq!(
parsed.recording.security.sink.as_deref(),
Some("otlp+grpc://127.0.0.1:4317")
);
}
#[test]
fn test_recording_sink_validation_rejects_invalid_scheme() {
let mut config = ActrixConfig::default();
config.recording.sink = Some("http://127.0.0.1:4317".to_string());
let errors = config.validate().expect_err("validation should fail");
assert!(
errors
.iter()
.any(|error| error.contains("Invalid URI scheme in recording.sink")),
"expected invalid scheme error, got: {errors:?}"
);
}
#[test]
fn test_recording_sink_validation_accepts_supported_schemes() {
let mut config = ActrixConfig {
enable: 0,
..Default::default()
};
config.control.admin_ui.password = "testpassword123".to_string();
config.recording.sink = Some("file:///tmp/actrix.log".to_string());
config.recording.audit.sink = Some("otlp+http://127.0.0.1:4318/v1/logs".to_string());
config.recording.security.sink = Some("otlp+grpc://127.0.0.1:4317".to_string());
assert!(
config.validate().is_ok(),
"supported sink schemes should pass validation"
);
}
#[test]
fn test_recording_sink_validation_rejects_otlp_uri_without_host() {
let mut config = ActrixConfig::default();
config.recording.sink = Some("otlp+grpc:///v1/traces".to_string());
let errors = config.validate().expect_err("validation should fail");
assert!(
errors
.iter()
.any(|error| error.contains("Invalid OTLP URI in recording.sink")),
"expected missing-host error, got: {errors:?}"
);
}
#[test]
fn test_recording_sink_validation_rejects_invalid_file_uri() {
let mut config = ActrixConfig::default();
config.recording.sink = Some("file://relative-path.log".to_string());
let errors = config.validate().expect_err("validation should fail");
assert!(
errors
.iter()
.any(|error| error.contains("Invalid file URI in recording.sink")),
"expected invalid file-uri error, got: {errors:?}"
);
}
#[test]
fn test_toml_serialization() {
let config = ActrixConfig::default();
let toml_str = config.to_toml().unwrap();
assert!(toml_str.contains("enable = 31")); assert!(toml_str.contains("name = \"actrix-default\""));
assert!(
toml_str
.contains("actrix_shared_key = \"XDDYE8d+yMfdXcdWMrXprcUk2uzjnmoX6nCfFw1gGIg=\"")
);
let parsed_config = ActrixConfig::from_toml(&toml_str).unwrap();
assert_eq!(parsed_config.enable, config.enable);
assert_eq!(parsed_config.name, config.name);
assert_eq!(parsed_config.actrix_shared_key, config.actrix_shared_key);
}
#[test]
fn test_actrix_shared_key() {
let config = ActrixConfig::default();
assert_eq!(
config.get_actrix_shared_key(),
"XDDYE8d+yMfdXcdWMrXprcUk2uzjnmoX6nCfFw1gGIg="
);
let mut custom_config = config;
custom_config.actrix_shared_key = "custom-shared-key".to_string();
assert_eq!(custom_config.get_actrix_shared_key(), "custom-shared-key");
}
#[test]
fn test_service_flags() {
let mut config = ActrixConfig {
enable: 0,
..ActrixConfig::default()
};
config.enable = 0;
config.services.signaling = Some(SignalingConfig {
server: signaling::SignalingServerConfig::default(),
dependencies: signaling::SignalingDependencies::default(),
});
assert!(!config.is_signaling_enabled());
config.enable = ENABLE_SIGNALING;
assert!(config.is_signaling_enabled());
config.services.signaling = Some(SignalingConfig {
server: signaling::SignalingServerConfig::default(),
dependencies: signaling::SignalingDependencies::default(),
});
assert!(config.is_signaling_enabled());
config.services.signaling = None;
assert!(config.is_signaling_enabled());
config.enable = 0;
config.services.ais = Some(AisConfig {
server: valid_ais_server_config(),
dependencies: ais::AisDependencies::default(),
});
assert!(!config.is_ais_enabled());
config.enable = ENABLE_AIS;
assert!(config.is_ais_enabled());
config.services.ais = Some(AisConfig {
server: valid_ais_server_config(),
dependencies: ais::AisDependencies::default(),
});
assert!(config.is_ais_enabled());
config.services.ais = None;
assert!(config.is_ais_enabled());
config.enable = 0;
config.services.signer = Some(SignerServiceConfig {
..Default::default()
});
assert!(!config.is_signer_enabled());
config.enable = ENABLE_SIGNER;
assert!(config.is_signer_enabled());
config.services.signer = Some(SignerServiceConfig {
..Default::default()
});
assert!(config.is_signer_enabled());
config.services.signer = None;
assert!(config.is_signer_enabled());
config.enable = ENABLE_STUN;
assert!(config.is_stun_enabled());
assert!(config.is_ice_enabled());
config.enable = ENABLE_TURN;
assert!(config.is_turn_enabled());
assert!(config.is_ice_enabled());
}
#[test]
fn test_ais_auto_ks_config() {
let mut config = ActrixConfig {
enable: ENABLE_SIGNER | ENABLE_AIS,
..ActrixConfig::default()
};
config.enable = ENABLE_SIGNER | ENABLE_AIS; config.services.signer = Some(SignerServiceConfig {
..Default::default()
});
config.services.ais = Some(AisConfig {
server: valid_ais_server_config(),
dependencies: ais::AisDependencies { signer: None }, });
let ks_config = config
.services
.ais
.as_ref()
.unwrap()
.get_signer_client_config(&config);
assert!(ks_config.is_some());
let ks_config = ks_config.unwrap();
assert_eq!(ks_config.endpoint, "http://127.0.0.1:8080");
config.services.ais = Some(AisConfig {
server: valid_ais_server_config(),
dependencies: ais::AisDependencies {
signer: Some(crate::config::signer::SignerClientConfig {
endpoint: "http://remote-ks:8080".to_string(),
timeout_seconds: 10,
enable_tls: false,
tls_domain: None,
ca_cert: None,
client_cert: None,
client_key: None,
}),
},
});
let ks_config = config
.services
.ais
.as_ref()
.unwrap()
.get_signer_client_config(&config);
assert!(ks_config.is_some());
let ks_config = ks_config.unwrap();
assert_eq!(ks_config.endpoint, "http://remote-ks:8080");
config.services.signer = None;
config.services.ais = Some(AisConfig {
server: valid_ais_server_config(),
dependencies: ais::AisDependencies { signer: None },
});
config.enable = ENABLE_AIS;
let ks_config = config
.services
.ais
.as_ref()
.unwrap()
.get_signer_client_config(&config);
assert!(ks_config.is_none());
}
#[test]
fn test_signaling_auto_ks_config() {
let mut config = ActrixConfig {
enable: ENABLE_SIGNER | ENABLE_SIGNALING,
..ActrixConfig::default()
};
config.enable = ENABLE_SIGNER | ENABLE_SIGNALING; config.services.signer = Some(SignerServiceConfig {
..Default::default()
});
config.services.signaling = Some(SignalingConfig {
server: signaling::SignalingServerConfig::default(),
dependencies: signaling::SignalingDependencies {
signer: None,
ais: None,
},
});
let ks_config = config
.services
.signaling
.as_ref()
.unwrap()
.get_signer_client_config(&config);
assert!(ks_config.is_some());
let ks_config = ks_config.unwrap();
assert_eq!(ks_config.endpoint, "http://127.0.0.1:8080");
}
#[test]
fn test_validate_bitmask_consistency() {
let mut config = ActrixConfig {
enable: ENABLE_AIS,
..ActrixConfig::default()
};
config.enable = ENABLE_AIS;
config.services.ais = None;
let result = config.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.contains(
"AIS service is enabled (ENABLE_AIS bit is set) but services.ais configuration is missing"
)));
config.enable = ENABLE_SIGNER;
config.services.ais = None;
config.services.signer = None;
let result = config.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| {
e.contains("Signer service is enabled (ENABLE_SIGNER bit is set) but services.signer configuration is missing")
}));
config.enable = ENABLE_SIGNALING;
config.services.signaling = None;
config.services.signer = None;
let result = config.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| {
e.contains("Signaling service is enabled (ENABLE_SIGNALING bit is set) but services.signaling configuration is missing")
}));
config.enable = ENABLE_SIGNALING;
config.services.signaling = Some(SignalingConfig {
server: signaling::SignalingServerConfig::default(),
dependencies: signaling::SignalingDependencies {
signer: None,
ais: None,
},
});
config.services.signer = None;
let result = config.validate();
if let Err(errors) = result {
assert!(
!errors
.iter()
.any(|e| e.contains("Signaling service is enabled (ENABLE_SIGNALING bit is set) but services.signaling configuration is missing"))
);
assert!(
!errors
.iter()
.any(|e| e.contains("bit is not set in enable bitmask"))
);
}
config.enable = ENABLE_AIS | ENABLE_SIGNER;
config.services.ais = Some(AisConfig {
server: valid_ais_server_config(),
dependencies: ais::AisDependencies {
signer: Some(crate::config::signer::SignerClientConfig {
endpoint: "http://127.0.0.1:8080".to_string(),
timeout_seconds: 10,
enable_tls: false,
tls_domain: None,
ca_cert: None,
client_cert: None,
client_key: None,
}),
},
});
config.services.signer = Some(SignerServiceConfig {
storage: ::signer::storage::StorageConfig {
backend: ::signer::storage::StorageBackend::Sqlite,
key_ttl_seconds: 3600,
sqlite: Some(::signer::storage::SqliteConfig {}),
postgres: None,
},
kek: None,
kek_env: None,
kek_file: None,
tolerance_seconds: 3600,
});
let result = config.validate();
if let Err(errors) = result {
assert!(
!errors
.iter()
.any(|e| e.contains("bit is not set in enable bitmask"))
);
}
}
#[test]
fn test_signaling_dependencies_require_local_services_when_missing_clients() {
let mut config = ActrixConfig {
enable: ENABLE_SIGNALING,
..ActrixConfig::default()
};
config.services.signaling = Some(SignalingConfig {
server: signaling::SignalingServerConfig::default(),
dependencies: signaling::SignalingDependencies::default(),
});
config.services.signer = None;
config.services.ais = None;
let result = config.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(
errors
.iter()
.any(|e| e.contains("Signaling dependencies.signer is not configured"))
);
assert!(
errors
.iter()
.any(|e| e.contains("Signaling dependencies.ais is not configured"))
);
}
#[test]
fn test_signaling_dependencies_accept_local_services_when_enabled() {
let mut config = ActrixConfig {
enable: ENABLE_SIGNALING | ENABLE_SIGNER | ENABLE_AIS,
..ActrixConfig::default()
};
config.control.admin_ui.password = "testpassword123".to_string();
config.services.signaling = Some(SignalingConfig {
server: signaling::SignalingServerConfig::default(),
dependencies: signaling::SignalingDependencies::default(),
});
config.services.signer = Some(SignerServiceConfig {
..Default::default()
});
config.services.ais = Some(AisConfig {
server: valid_ais_server_config(),
dependencies: ais::AisDependencies::default(),
});
let result = config.validate();
assert!(result.is_ok());
}
}