An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
at main 918 lines 32 kB view raw
1use serde::Deserialize; 2use std::collections::HashMap; 3use std::path::PathBuf; 4use zeroize::Zeroizing; 5 6/// A wrapper that suppresses [`Debug`] output for sensitive values, printing `***` instead. 7/// 8/// `T` is `pub` to allow deliberate access via `.0` at call sites. This is an explicit choice: 9/// any read of the raw value is visible in source, making accidental logging harder to miss in 10/// code review. 11#[derive(Clone)] 12pub struct Sensitive<T>(pub T); 13 14impl<T> std::fmt::Debug for Sensitive<T> { 15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 f.write_str("***") 17 } 18} 19 20/// Validated, fully-resolved relay configuration. 21#[derive(Debug, Clone)] 22pub struct Config { 23 pub bind_address: String, 24 pub port: u16, 25 pub data_dir: PathBuf, 26 pub database_url: String, 27 pub public_url: String, 28 pub server_did: Option<String>, 29 pub available_user_domains: Vec<String>, 30 pub invite_code_required: bool, 31 pub links: ServerLinksConfig, 32 pub contact: ContactConfig, 33 pub blobs: BlobsConfig, 34 pub oauth: OAuthConfig, 35 pub iroh: IrohConfig, 36 pub telemetry: TelemetryConfig, 37 // Operator authentication for management endpoints (e.g., POST /v1/relay/keys). 38 pub admin_token: Option<String>, 39 // AES-256-GCM master key for encrypting signing key private keys at rest. 40 pub signing_key_master_key: Option<Sensitive<Zeroizing<[u8; 32]>>>, 41 // URL of the PLC directory service (default: https://plc.directory) 42 pub plc_directory_url: String, 43} 44 45/// Optional privacy/ToS links surfaced by `com.atproto.server.describeServer`. 46#[derive(Debug, Clone, Deserialize, Default)] 47pub struct ServerLinksConfig { 48 pub privacy_policy: Option<String>, 49 pub terms_of_service: Option<String>, 50} 51 52/// Optional admin contact surfaced by `com.atproto.server.describeServer`. 53#[derive(Debug, Clone, Deserialize, Default)] 54pub struct ContactConfig { 55 pub email: Option<String>, 56} 57 58/// Stub for future blob storage configuration. 59#[derive(Debug, Clone, Deserialize, Default)] 60pub struct BlobsConfig {} 61 62/// Stub for future OAuth configuration. 63#[derive(Debug, Clone, Deserialize, Default)] 64pub struct OAuthConfig {} 65 66/// Iroh networking configuration. 67#[derive(Debug, Clone, Deserialize, Default)] 68pub struct IrohConfig { 69 /// Iroh node endpoint for NAT traversal. `None` when not configured. 70 pub endpoint: Option<String>, 71} 72 73/// OpenTelemetry telemetry configuration. 74#[derive(Debug, Clone)] 75pub struct TelemetryConfig { 76 /// Whether to export traces via OTLP. Off by default — zero overhead when disabled. 77 pub enabled: bool, 78 /// OTLP gRPC endpoint for the trace exporter. 79 pub otlp_endpoint: String, 80 /// `service.name` resource attribute reported to the trace backend. 81 pub service_name: String, 82} 83 84impl Default for TelemetryConfig { 85 fn default() -> Self { 86 Self { 87 enabled: false, 88 otlp_endpoint: "http://localhost:4317".to_string(), 89 service_name: "ezpds-relay".to_string(), 90 } 91 } 92} 93 94#[derive(Debug, Deserialize, Default)] 95pub(crate) struct RawTelemetryConfig { 96 pub(crate) enabled: Option<bool>, 97 pub(crate) otlp_endpoint: Option<String>, 98 pub(crate) service_name: Option<String>, 99} 100 101/// Raw TOML-deserialized config with all fields optional to support env-var overlays. 102#[derive(Debug, Deserialize, Default)] 103pub(crate) struct RawConfig { 104 pub(crate) bind_address: Option<String>, 105 pub(crate) port: Option<u16>, 106 pub(crate) data_dir: Option<String>, 107 pub(crate) database_url: Option<String>, 108 pub(crate) public_url: Option<String>, 109 pub(crate) server_did: Option<String>, 110 pub(crate) available_user_domains: Option<Vec<String>>, 111 pub(crate) invite_code_required: Option<bool>, 112 #[serde(default)] 113 pub(crate) links: ServerLinksConfig, 114 #[serde(default)] 115 pub(crate) contact: ContactConfig, 116 #[serde(default)] 117 pub(crate) blobs: BlobsConfig, 118 #[serde(default)] 119 pub(crate) oauth: OAuthConfig, 120 #[serde(default)] 121 pub(crate) iroh: IrohConfig, 122 #[serde(default)] 123 pub(crate) telemetry: RawTelemetryConfig, 124 pub(crate) admin_token: Option<String>, 125 pub(crate) plc_directory_url: Option<String>, 126 #[serde(skip)] 127 pub(crate) signing_key_master_key: Option<[u8; 32]>, 128 /// Sentinel field — only present to detect misconfiguration. 129 /// signing_key_master_key must be set via env var EZPDS_SIGNING_KEY_MASTER_KEY, not TOML. 130 #[serde(rename = "signing_key_master_key")] 131 pub(crate) signing_key_master_key_toml_sentinel: Option<String>, 132} 133 134#[derive(Debug, thiserror::Error)] 135pub enum ConfigError { 136 #[error("failed to read config file {path}: {source}")] 137 Io { 138 path: PathBuf, 139 #[source] 140 source: std::io::Error, 141 }, 142 #[error("failed to parse config file: {0}")] 143 Parse(#[from] toml::de::Error), 144 #[error("invalid configuration: missing required field '{field}'")] 145 MissingField { field: &'static str }, 146 #[error("invalid configuration: {0}")] 147 Invalid(String), 148} 149 150/// Parse a 64-character hex string into a 32-byte array. 151/// Returns a human-readable error string on failure. 152fn parse_hex_32(var_name: &str, value: &str) -> Result<[u8; 32], ConfigError> { 153 if value.len() != 64 { 154 return Err(ConfigError::Invalid(format!( 155 "{var_name} must be exactly 64 hex characters (32 bytes), got {} characters", 156 value.len() 157 ))); 158 } 159 let mut bytes = [0u8; 32]; 160 for (i, pair) in value.as_bytes().chunks(2).enumerate() { 161 let hi = hex_nibble(var_name, pair[0])?; 162 let lo = hex_nibble(var_name, pair[1])?; 163 bytes[i] = (hi << 4) | lo; 164 } 165 Ok(bytes) 166} 167 168fn hex_nibble(var_name: &str, b: u8) -> Result<u8, ConfigError> { 169 match b { 170 b'0'..=b'9' => Ok(b - b'0'), 171 b'a'..=b'f' => Ok(b - b'a' + 10), 172 b'A'..=b'F' => Ok(b - b'A' + 10), 173 _ => Err(ConfigError::Invalid(format!( 174 "{var_name} contains invalid hex character: {:?}", 175 char::from(b) 176 ))), 177 } 178} 179 180/// Apply `EZPDS_*` and selected OTel standard environment variable overrides to a [`RawConfig`], 181/// returning the updated config. 182/// 183/// Also reads `OTEL_SERVICE_NAME` (without the `EZPDS_` prefix) as a standard OpenTelemetry 184/// convention for overriding the telemetry service name. 185/// 186/// Receives the environment as a map so this function stays isolated from I/O (no `std::env` 187/// access). Takes `raw` by value and returns it so callers can chain calls without mutation. 188pub(crate) fn apply_env_overrides( 189 mut raw: RawConfig, 190 env: &HashMap<String, String>, 191) -> Result<RawConfig, ConfigError> { 192 if let Some(v) = env.get("EZPDS_BIND_ADDRESS") { 193 raw.bind_address = Some(v.clone()); 194 } 195 if let Some(v) = env.get("EZPDS_PORT") { 196 raw.port = Some(v.parse::<u16>().map_err(|e| { 197 ConfigError::Invalid(format!("EZPDS_PORT is not a valid port number: '{v}': {e}")) 198 })?); 199 } 200 if let Some(v) = env.get("EZPDS_DATA_DIR") { 201 raw.data_dir = Some(v.clone()); 202 } 203 if let Some(v) = env.get("EZPDS_DATABASE_URL") { 204 raw.database_url = Some(v.clone()); 205 } 206 if let Some(v) = env.get("EZPDS_PUBLIC_URL") { 207 raw.public_url = Some(v.clone()); 208 } 209 if let Some(v) = env.get("EZPDS_SERVER_DID") { 210 raw.server_did = Some(v.clone()); 211 } 212 if let Some(v) = env.get("EZPDS_INVITE_CODE_REQUIRED") { 213 raw.invite_code_required = Some(v.parse::<bool>().map_err(|e| { 214 ConfigError::Invalid(format!( 215 "EZPDS_INVITE_CODE_REQUIRED is not a valid boolean: '{v}': {e}" 216 )) 217 })?); 218 } 219 if let Some(v) = env.get("EZPDS_AVAILABLE_USER_DOMAINS") { 220 raw.available_user_domains = Some( 221 v.split(',') 222 .map(str::trim) 223 .filter(|s| !s.is_empty()) 224 .map(str::to_string) 225 .collect(), 226 ); 227 } 228 if let Some(v) = env.get("EZPDS_TELEMETRY_ENABLED") { 229 raw.telemetry.enabled = Some(v.parse::<bool>().map_err(|e| { 230 ConfigError::Invalid(format!( 231 "EZPDS_TELEMETRY_ENABLED is not a valid boolean: '{v}': {e}" 232 )) 233 })?); 234 } 235 if let Some(v) = env.get("EZPDS_OTLP_ENDPOINT") { 236 raw.telemetry.otlp_endpoint = Some(v.clone()); 237 } 238 if let Some(v) = env.get("OTEL_SERVICE_NAME") { 239 raw.telemetry.service_name = Some(v.clone()); 240 } 241 if let Some(v) = env.get("EZPDS_IROH_ENDPOINT") { 242 raw.iroh.endpoint = Some(v.clone()); 243 } 244 if let Some(v) = env.get("EZPDS_ADMIN_TOKEN") { 245 raw.admin_token = Some(v.clone()); 246 } 247 if let Some(v) = env.get("EZPDS_PLC_DIRECTORY_URL") { 248 raw.plc_directory_url = Some(v.clone()); 249 } 250 if let Some(v) = env.get("EZPDS_SIGNING_KEY_MASTER_KEY") { 251 raw.signing_key_master_key = Some(parse_hex_32("EZPDS_SIGNING_KEY_MASTER_KEY", v)?); 252 } 253 Ok(raw) 254} 255 256/// Validate a [`RawConfig`] and build a [`Config`], applying defaults for optional fields. 257/// 258/// Required fields: `data_dir`, `public_url`, `available_user_domains` (non-empty). 259/// Defaults: `bind_address = "0.0.0.0"`, `port = 8080`, `invite_code_required = true`, 260/// `database_url = "{data_dir}/relay.db"` (derived; fails if `data_dir` is non-UTF-8), 261/// `telemetry.enabled = false`, `telemetry.otlp_endpoint = "http://localhost:4317"`, 262/// `telemetry.service_name = "ezpds-relay"`. 263/// When provided, `telemetry.otlp_endpoint` must be non-empty and start with `http://` or 264/// `https://`. 265pub(crate) fn validate_and_build(raw: RawConfig) -> Result<Config, ConfigError> { 266 // Reject signing_key_master_key if it appears in TOML (must be env var only). 267 if raw.signing_key_master_key_toml_sentinel.is_some() { 268 return Err(ConfigError::Invalid( 269 "signing_key_master_key must be set via env var EZPDS_SIGNING_KEY_MASTER_KEY, not relay.toml (security-sensitive field)".to_string() 270 )); 271 } 272 273 let bind_address = raw.bind_address.unwrap_or_else(|| "0.0.0.0".to_string()); 274 let port = raw.port.unwrap_or(8080); 275 let data_dir: PathBuf = raw 276 .data_dir 277 .ok_or(ConfigError::MissingField { field: "data_dir" })? 278 .into(); 279 let database_url = match raw.database_url { 280 Some(url) => url, 281 None => data_dir 282 .join("relay.db") 283 .to_str() 284 .ok_or_else(|| { 285 ConfigError::Invalid( 286 "data_dir contains non-UTF-8 characters, cannot derive database_url" 287 .to_string(), 288 ) 289 })? 290 .to_owned(), 291 }; 292 let public_url = raw.public_url.ok_or(ConfigError::MissingField { 293 field: "public_url", 294 })?; 295 if !public_url.starts_with("https://") { 296 return Err(ConfigError::Invalid(format!( 297 "public_url must start with https:// (RFC 8414 requires HTTPS for the OAuth issuer), got: {public_url:?}" 298 ))); 299 } 300 let available_user_domains = raw 301 .available_user_domains 302 .ok_or(ConfigError::MissingField { 303 field: "available_user_domains", 304 })?; 305 if available_user_domains.is_empty() { 306 return Err(ConfigError::Invalid( 307 "available_user_domains must contain at least one domain".to_string(), 308 )); 309 } 310 let invite_code_required = raw.invite_code_required.unwrap_or(true); 311 let plc_directory_url = raw 312 .plc_directory_url 313 .unwrap_or_else(|| "https://plc.directory".to_string()); 314 315 let telemetry_defaults = TelemetryConfig::default(); 316 let otlp_endpoint = raw 317 .telemetry 318 .otlp_endpoint 319 .unwrap_or(telemetry_defaults.otlp_endpoint); 320 if otlp_endpoint.is_empty() { 321 return Err(ConfigError::Invalid( 322 "telemetry.otlp_endpoint must not be empty".to_string(), 323 )); 324 } 325 if !otlp_endpoint.starts_with("http://") && !otlp_endpoint.starts_with("https://") { 326 return Err(ConfigError::Invalid(format!( 327 "telemetry.otlp_endpoint must start with http:// or https://, got: {otlp_endpoint:?}" 328 ))); 329 } 330 let telemetry = TelemetryConfig { 331 enabled: raw.telemetry.enabled.unwrap_or(telemetry_defaults.enabled), 332 otlp_endpoint, 333 service_name: raw 334 .telemetry 335 .service_name 336 .unwrap_or(telemetry_defaults.service_name), 337 }; 338 339 if raw.iroh.endpoint.as_deref() == Some("") { 340 return Err(ConfigError::Invalid( 341 "iroh.endpoint must not be empty".to_string(), 342 )); 343 } 344 345 Ok(Config { 346 bind_address, 347 port, 348 data_dir, 349 database_url, 350 public_url, 351 server_did: raw.server_did, 352 available_user_domains, 353 invite_code_required, 354 links: raw.links, 355 contact: raw.contact, 356 blobs: raw.blobs, 357 oauth: raw.oauth, 358 iroh: raw.iroh, 359 telemetry, 360 admin_token: raw.admin_token, 361 signing_key_master_key: raw 362 .signing_key_master_key 363 .map(|k| Sensitive(Zeroizing::new(k))), 364 plc_directory_url, 365 }) 366} 367 368#[cfg(test)] 369mod tests { 370 use super::*; 371 372 fn minimal_raw() -> RawConfig { 373 RawConfig { 374 data_dir: Some("/var/pds".to_string()), 375 public_url: Some("https://pds.example.com".to_string()), 376 available_user_domains: Some(vec!["example.com".to_string()]), 377 ..Default::default() 378 } 379 } 380 381 #[test] 382 fn parses_minimal_toml() { 383 let toml = r#" 384 data_dir = "/var/pds" 385 public_url = "https://pds.example.com" 386 available_user_domains = ["example.com"] 387 "#; 388 let raw: RawConfig = toml::from_str(toml).unwrap(); 389 let config = validate_and_build(raw).unwrap(); 390 391 assert_eq!(config.bind_address, "0.0.0.0"); 392 assert_eq!(config.port, 8080); 393 assert_eq!(config.data_dir, PathBuf::from("/var/pds")); 394 assert_eq!(config.database_url, "/var/pds/relay.db"); 395 assert_eq!(config.public_url, "https://pds.example.com"); 396 } 397 398 #[test] 399 fn parses_full_toml() { 400 let toml = r#" 401 bind_address = "127.0.0.1" 402 port = 3000 403 data_dir = "/data" 404 database_url = "sqlite:///data/custom.db" 405 public_url = "https://pds.example.com" 406 available_user_domains = ["example.com"] 407 "#; 408 let raw: RawConfig = toml::from_str(toml).unwrap(); 409 let config = validate_and_build(raw).unwrap(); 410 411 assert_eq!(config.bind_address, "127.0.0.1"); 412 assert_eq!(config.port, 3000); 413 assert_eq!(config.data_dir, PathBuf::from("/data")); 414 assert_eq!(config.database_url, "sqlite:///data/custom.db"); 415 } 416 417 #[test] 418 fn parses_stub_sections() { 419 let toml = r#" 420 data_dir = "/var/pds" 421 public_url = "https://pds.example.com" 422 available_user_domains = ["example.com"] 423 424 [blobs] 425 426 [oauth] 427 428 [iroh] 429 "#; 430 let raw: RawConfig = toml::from_str(toml).unwrap(); 431 let config = validate_and_build(raw).unwrap(); 432 433 assert_eq!(config.public_url, "https://pds.example.com"); 434 } 435 436 #[test] 437 fn database_url_defaults_to_data_dir() { 438 let config = validate_and_build(minimal_raw()).unwrap(); 439 assert_eq!(config.database_url, "/var/pds/relay.db"); 440 } 441 442 #[test] 443 fn env_override_port() { 444 let env = HashMap::from([("EZPDS_PORT".to_string(), "9090".to_string())]); 445 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 446 let config = validate_and_build(raw).unwrap(); 447 448 assert_eq!(config.port, 9090); 449 } 450 451 #[test] 452 fn env_override_wins_over_toml_value() { 453 // env always takes precedence over explicit TOML values 454 let toml = r#" 455 data_dir = "/var/pds" 456 port = 3000 457 public_url = "https://pds.example.com" 458 available_user_domains = ["example.com"] 459 "#; 460 let raw: RawConfig = toml::from_str(toml).unwrap(); 461 let env = HashMap::from([("EZPDS_PORT".to_string(), "9999".to_string())]); 462 let raw = apply_env_overrides(raw, &env).unwrap(); 463 let config = validate_and_build(raw).unwrap(); 464 465 assert_eq!(config.port, 9999); 466 } 467 468 #[test] 469 fn env_override_all_fields() { 470 let env = HashMap::from([ 471 ("EZPDS_BIND_ADDRESS".to_string(), "127.0.0.1".to_string()), 472 ("EZPDS_PORT".to_string(), "4000".to_string()), 473 ("EZPDS_DATA_DIR".to_string(), "/tmp/pds".to_string()), 474 ( 475 "EZPDS_DATABASE_URL".to_string(), 476 "sqlite:///tmp/relay.db".to_string(), 477 ), 478 ( 479 "EZPDS_PUBLIC_URL".to_string(), 480 "https://pds.test".to_string(), 481 ), 482 ( 483 "EZPDS_AVAILABLE_USER_DOMAINS".to_string(), 484 "pds.test".to_string(), 485 ), 486 ]); 487 let raw = apply_env_overrides(RawConfig::default(), &env).unwrap(); 488 let config = validate_and_build(raw).unwrap(); 489 490 assert_eq!(config.bind_address, "127.0.0.1"); 491 assert_eq!(config.port, 4000); 492 assert_eq!(config.data_dir, PathBuf::from("/tmp/pds")); 493 assert_eq!(config.database_url, "sqlite:///tmp/relay.db"); 494 assert_eq!(config.public_url, "https://pds.test"); 495 } 496 497 #[test] 498 fn env_override_invalid_port_returns_error() { 499 let env = HashMap::from([("EZPDS_PORT".to_string(), "not_a_port".to_string())]); 500 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 501 502 assert!(matches!(err, ConfigError::Invalid(_))); 503 assert!(err.to_string().contains("EZPDS_PORT")); 504 assert!(err.to_string().contains("not_a_port")); 505 } 506 507 #[test] 508 fn missing_data_dir_returns_error() { 509 let raw = RawConfig { 510 public_url: Some("https://pds.example.com".to_string()), 511 ..Default::default() 512 }; 513 let err = validate_and_build(raw).unwrap_err(); 514 515 assert!(matches!( 516 err, 517 ConfigError::MissingField { field: "data_dir" } 518 )); 519 } 520 521 #[test] 522 fn missing_public_url_returns_error() { 523 let raw = RawConfig { 524 data_dir: Some("/var/pds".to_string()), 525 ..Default::default() 526 }; 527 let err = validate_and_build(raw).unwrap_err(); 528 529 assert!(matches!( 530 err, 531 ConfigError::MissingField { 532 field: "public_url" 533 } 534 )); 535 } 536 537 // --- describeServer config fields --- 538 539 #[test] 540 fn parses_describe_server_fields_from_toml() { 541 let toml = r#" 542 data_dir = "/var/pds" 543 public_url = "https://pds.example.com" 544 server_did = "did:plc:abc123" 545 available_user_domains = ["pds.example.com", "alt.example.com"] 546 invite_code_required = false 547 548 [links] 549 privacy_policy = "https://example.com/privacy" 550 terms_of_service = "https://example.com/tos" 551 552 [contact] 553 email = "admin@example.com" 554 "#; 555 let raw: RawConfig = toml::from_str(toml).unwrap(); 556 let config = validate_and_build(raw).unwrap(); 557 558 assert_eq!(config.server_did.as_deref(), Some("did:plc:abc123")); 559 assert_eq!( 560 config.available_user_domains, 561 vec!["pds.example.com", "alt.example.com"] 562 ); 563 assert!(!config.invite_code_required); 564 assert_eq!( 565 config.links.privacy_policy.as_deref(), 566 Some("https://example.com/privacy") 567 ); 568 assert_eq!( 569 config.links.terms_of_service.as_deref(), 570 Some("https://example.com/tos") 571 ); 572 assert_eq!(config.contact.email.as_deref(), Some("admin@example.com")); 573 } 574 575 #[test] 576 fn public_url_without_https_scheme_returns_error() { 577 for bad_url in &[ 578 "pds.example.com", 579 "http://pds.example.com", 580 "ftp://pds.example.com", 581 "", 582 ] { 583 let raw = RawConfig { 584 data_dir: Some("/var/pds".to_string()), 585 public_url: Some(bad_url.to_string()), 586 available_user_domains: Some(vec!["example.com".to_string()]), 587 ..Default::default() 588 }; 589 let err = validate_and_build(raw).unwrap_err(); 590 assert!( 591 matches!(err, ConfigError::Invalid(_)), 592 "expected Invalid error for public_url={bad_url:?}, got: {err}" 593 ); 594 assert!( 595 err.to_string().contains("https://"), 596 "error message should mention https:// for public_url={bad_url:?}" 597 ); 598 } 599 } 600 601 #[test] 602 fn available_user_domains_missing_returns_error() { 603 let raw = RawConfig { 604 data_dir: Some("/var/pds".to_string()), 605 public_url: Some("https://pds.example.com".to_string()), 606 ..Default::default() 607 }; 608 let err = validate_and_build(raw).unwrap_err(); 609 610 assert!(matches!( 611 err, 612 ConfigError::MissingField { 613 field: "available_user_domains" 614 } 615 )); 616 } 617 618 #[test] 619 fn available_user_domains_empty_returns_invalid_error() { 620 let raw = RawConfig { 621 data_dir: Some("/var/pds".to_string()), 622 public_url: Some("https://pds.example.com".to_string()), 623 available_user_domains: Some(vec![]), 624 ..Default::default() 625 }; 626 let err = validate_and_build(raw).unwrap_err(); 627 628 assert!(matches!(err, ConfigError::Invalid(_))); 629 assert!(err 630 .to_string() 631 .contains("available_user_domains must contain at least one domain")); 632 } 633 634 #[test] 635 fn invite_code_required_defaults_to_true() { 636 let config = validate_and_build(minimal_raw()).unwrap(); 637 assert!(config.invite_code_required); 638 } 639 640 #[test] 641 fn server_did_is_optional() { 642 let config = validate_and_build(minimal_raw()).unwrap(); 643 assert!(config.server_did.is_none()); 644 } 645 646 #[test] 647 fn links_section_optional() { 648 let config = validate_and_build(minimal_raw()).unwrap(); 649 assert!(config.links.privacy_policy.is_none()); 650 assert!(config.links.terms_of_service.is_none()); 651 } 652 653 #[test] 654 fn contact_section_optional() { 655 let config = validate_and_build(minimal_raw()).unwrap(); 656 assert!(config.contact.email.is_none()); 657 } 658 659 #[test] 660 fn env_override_server_did() { 661 let env = HashMap::from([("EZPDS_SERVER_DID".to_string(), "did:plc:xyz".to_string())]); 662 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 663 let config = validate_and_build(raw).unwrap(); 664 665 assert_eq!(config.server_did.as_deref(), Some("did:plc:xyz")); 666 } 667 668 #[test] 669 fn env_override_invite_code_required_false() { 670 let env = HashMap::from([( 671 "EZPDS_INVITE_CODE_REQUIRED".to_string(), 672 "false".to_string(), 673 )]); 674 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 675 let config = validate_and_build(raw).unwrap(); 676 677 assert!(!config.invite_code_required); 678 } 679 680 #[test] 681 fn env_override_invite_code_required_invalid_returns_error() { 682 let env = HashMap::from([( 683 "EZPDS_INVITE_CODE_REQUIRED".to_string(), 684 "maybe".to_string(), 685 )]); 686 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 687 688 assert!(matches!(err, ConfigError::Invalid(_))); 689 assert!(err.to_string().contains("EZPDS_INVITE_CODE_REQUIRED")); 690 } 691 692 #[test] 693 fn env_override_available_user_domains_comma_separated() { 694 let env = HashMap::from([( 695 "EZPDS_AVAILABLE_USER_DOMAINS".to_string(), 696 "foo.com, bar.com".to_string(), 697 )]); 698 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 699 let config = validate_and_build(raw).unwrap(); 700 701 assert_eq!(config.available_user_domains, vec!["foo.com", "bar.com"]); 702 } 703 704 // --- telemetry config tests --- 705 706 #[test] 707 fn telemetry_defaults_to_disabled() { 708 let config = validate_and_build(minimal_raw()).unwrap(); 709 assert!(!config.telemetry.enabled); 710 assert_eq!(config.telemetry.otlp_endpoint, "http://localhost:4317"); 711 assert_eq!(config.telemetry.service_name, "ezpds-relay"); 712 } 713 714 #[test] 715 fn parses_telemetry_section_from_toml() { 716 let toml = r#" 717 data_dir = "/var/pds" 718 public_url = "https://pds.example.com" 719 available_user_domains = ["example.com"] 720 721 [telemetry] 722 enabled = true 723 otlp_endpoint = "http://otel-collector:4317" 724 service_name = "my-pds" 725 "#; 726 let raw: RawConfig = toml::from_str(toml).unwrap(); 727 let config = validate_and_build(raw).unwrap(); 728 729 assert!(config.telemetry.enabled); 730 assert_eq!(config.telemetry.otlp_endpoint, "http://otel-collector:4317"); 731 assert_eq!(config.telemetry.service_name, "my-pds"); 732 } 733 734 #[test] 735 fn env_override_telemetry_enabled() { 736 let env = HashMap::from([("EZPDS_TELEMETRY_ENABLED".to_string(), "true".to_string())]); 737 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 738 let config = validate_and_build(raw).unwrap(); 739 740 assert!(config.telemetry.enabled); 741 } 742 743 #[test] 744 fn env_override_otlp_endpoint() { 745 let env = HashMap::from([( 746 "EZPDS_OTLP_ENDPOINT".to_string(), 747 "http://custom:4317".to_string(), 748 )]); 749 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 750 let config = validate_and_build(raw).unwrap(); 751 752 assert_eq!(config.telemetry.otlp_endpoint, "http://custom:4317"); 753 } 754 755 #[test] 756 fn env_override_otel_service_name() { 757 let env = HashMap::from([("OTEL_SERVICE_NAME".to_string(), "my-service".to_string())]); 758 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 759 let config = validate_and_build(raw).unwrap(); 760 761 assert_eq!(config.telemetry.service_name, "my-service"); 762 } 763 764 #[test] 765 fn otel_service_name_env_overrides_toml() { 766 let toml = r#" 767 data_dir = "/var/pds" 768 public_url = "https://pds.example.com" 769 available_user_domains = ["example.com"] 770 771 [telemetry] 772 service_name = "from-toml" 773 "#; 774 let raw: RawConfig = toml::from_str(toml).unwrap(); 775 let env = HashMap::from([("OTEL_SERVICE_NAME".to_string(), "from-env".to_string())]); 776 let raw = apply_env_overrides(raw, &env).unwrap(); 777 let config = validate_and_build(raw).unwrap(); 778 779 assert_eq!(config.telemetry.service_name, "from-env"); 780 } 781 782 #[test] 783 fn env_override_telemetry_enabled_invalid_returns_error() { 784 let env = HashMap::from([("EZPDS_TELEMETRY_ENABLED".to_string(), "maybe".to_string())]); 785 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 786 787 assert!(matches!(err, ConfigError::Invalid(_))); 788 assert!(err.to_string().contains("EZPDS_TELEMETRY_ENABLED")); 789 } 790 791 // --- admin_token and signing_key_master_key config fields --- 792 793 #[test] 794 fn admin_token_is_optional() { 795 let config = validate_and_build(minimal_raw()).unwrap(); 796 assert!(config.admin_token.is_none()); 797 } 798 799 #[test] 800 fn signing_key_master_key_is_optional() { 801 let config = validate_and_build(minimal_raw()).unwrap(); 802 assert!(config.signing_key_master_key.is_none()); 803 } 804 805 #[test] 806 fn env_override_admin_token() { 807 let env = HashMap::from([("EZPDS_ADMIN_TOKEN".to_string(), "secret-token".to_string())]); 808 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 809 let config = validate_and_build(raw).unwrap(); 810 assert_eq!(config.admin_token.as_deref(), Some("secret-token")); 811 } 812 813 #[test] 814 fn env_override_signing_key_master_key_valid_hex() { 815 // 64 valid hex chars → [u8; 32] 816 let hex_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; 817 let env = HashMap::from([( 818 "EZPDS_SIGNING_KEY_MASTER_KEY".to_string(), 819 hex_key.to_string(), 820 )]); 821 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 822 let config = validate_and_build(raw).unwrap(); 823 824 let expected: [u8; 32] = [ 825 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 826 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 827 0x1d, 0x1e, 0x1f, 0x20, 828 ]; 829 assert_eq!( 830 config.signing_key_master_key.as_ref().map(|s| &*s.0), 831 Some(&expected) 832 ); 833 } 834 835 #[test] 836 fn env_override_signing_key_master_key_wrong_length_returns_error() { 837 // 62 hex chars (31 bytes) — wrong length 838 let short_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; 839 let env = HashMap::from([( 840 "EZPDS_SIGNING_KEY_MASTER_KEY".to_string(), 841 short_key.to_string(), 842 )]); 843 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 844 assert!(matches!(err, ConfigError::Invalid(_))); 845 assert!(err.to_string().contains("EZPDS_SIGNING_KEY_MASTER_KEY")); 846 } 847 848 #[test] 849 fn env_override_signing_key_master_key_non_hex_returns_error() { 850 // contains 'g' which is not a valid hex character 851 let invalid_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fgg"; 852 let env = HashMap::from([( 853 "EZPDS_SIGNING_KEY_MASTER_KEY".to_string(), 854 invalid_key.to_string(), 855 )]); 856 let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 857 assert!(matches!(err, ConfigError::Invalid(_))); 858 assert!(err.to_string().contains("EZPDS_SIGNING_KEY_MASTER_KEY")); 859 } 860 861 #[test] 862 fn iroh_endpoint_parses_from_toml() { 863 let toml = r#" 864 data_dir = "/var/pds" 865 public_url = "https://pds.example.com" 866 available_user_domains = ["example.com"] 867 868 [iroh] 869 endpoint = "abc123nodeid" 870 "#; 871 let raw: RawConfig = toml::from_str(toml).unwrap(); 872 let config = validate_and_build(raw).unwrap(); 873 assert_eq!(config.iroh.endpoint, Some("abc123nodeid".to_string())); 874 } 875 876 #[test] 877 fn iroh_endpoint_defaults_to_none() { 878 let config = validate_and_build(minimal_raw()).unwrap(); 879 assert_eq!(config.iroh.endpoint, None); 880 } 881 882 #[test] 883 fn env_override_iroh_endpoint() { 884 let env = HashMap::from([("EZPDS_IROH_ENDPOINT".to_string(), "nodeabc123".to_string())]); 885 let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 886 let config = validate_and_build(raw).unwrap(); 887 assert_eq!(config.iroh.endpoint, Some("nodeabc123".to_string())); 888 } 889 890 #[test] 891 fn iroh_endpoint_empty_string_returns_error() { 892 let mut raw = minimal_raw(); 893 raw.iroh.endpoint = Some(String::new()); 894 let err = validate_and_build(raw).unwrap_err(); 895 assert!(matches!(err, ConfigError::Invalid(_))); 896 assert!( 897 err.to_string().contains("iroh.endpoint"), 898 "error message must mention iroh.endpoint" 899 ); 900 } 901 902 #[test] 903 fn signing_key_master_key_in_toml_returns_error() { 904 // Operator mistakenly puts signing_key_master_key in relay.toml instead of env var. 905 // The sentinel field must catch this and reject the configuration. 906 let toml = r#" 907 data_dir = "/var/pds" 908 public_url = "https://pds.example.com" 909 available_user_domains = ["example.com"] 910 signing_key_master_key = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" 911 "#; 912 let raw: RawConfig = toml::from_str(toml).unwrap(); 913 let err = validate_and_build(raw).unwrap_err(); 914 915 assert!(matches!(err, ConfigError::Invalid(_))); 916 assert!(err.to_string().contains("EZPDS_SIGNING_KEY_MASTER_KEY")); 917 } 918}