This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-config / src / lib.rs
58 kB 1658 lines
1use std::fmt; 2use std::net::SocketAddr; 3use std::path::{Path, PathBuf}; 4use std::sync::OnceLock; 5use std::time::Duration; 6 7use base64::Engine; 8use confique::Config; 9use knot_runtime::HttpLimits; 10use knot_types::{AccountDid, AdmissionPolicy, AppviewEndpoint}; 11use url::Url; 12 13#[derive(Debug, Config)] 14pub struct KnotConfig { 15 #[config(nested)] 16 pub server: ServerConfig, 17 #[config(nested)] 18 pub tls: TlsConfig, 19 #[config(nested)] 20 pub acl: AclConfig, 21 #[config(nested)] 22 pub repo: RepoConfig, 23 #[config(nested)] 24 pub git: GitConfig, 25 #[config(nested)] 26 pub secrets: SecretsConfig, 27 #[config(nested)] 28 pub http: HttpConfig, 29 #[config(nested)] 30 pub atproto: AtprotoConfig, 31 #[config(nested)] 32 pub xrpc: XrpcConfig, 33 #[config(nested)] 34 pub maintenance: MaintenanceConfig, 35 #[config(nested)] 36 pub pack_cache: PackCacheConfig, 37 #[config(nested)] 38 pub pack: PackConfig, 39 #[config(nested)] 40 pub lfs: LfsConfig, 41 #[config(nested)] 42 pub resources: ResourcesConfig, 43 #[config(nested)] 44 pub homepage: HomepageConfig, 45 46 #[config(nested)] 47 pub ci: CiConfig, 48 #[config(nested)] 49 pub messages: knot_messages::MessagesConfig, 50} 51 52#[derive(Debug, Config)] 53pub struct AclConfig { 54 #[config(env = "KNOT_ADMISSION", default = "closed")] 55 pub admission: AdmissionPolicy, 56} 57 58#[derive(Debug, Config)] 59pub struct TlsConfig { 60 #[config(env = "KNOT_TLS_CERT_PATH")] 61 pub cert_path: Option<PathBuf>, 62 63 #[config(env = "KNOT_TLS_KEY_PATH")] 64 pub key_path: Option<PathBuf>, 65 66 #[config(env = "KNOT_TLS_HTTP3", default = true)] 67 pub http3: bool, 68 69 #[config(env = "KNOT_TLS_ACME_ENABLED", default = false)] 70 pub acme_enabled: bool, 71 72 #[config(env = "KNOT_TLS_ACME_CACHE_DIR")] 73 pub acme_cache_dir: Option<PathBuf>, 74 75 #[config(env = "KNOT_TLS_ACME_CONTACT")] 76 pub acme_contact: Option<String>, 77 78 #[config(env = "KNOT_TLS_ACME_STAGING", default = false)] 79 pub acme_staging: bool, 80 81 #[config(env = "KNOT_TLS_MTLS_ENABLED", default = false)] 82 pub mtls_enabled: bool, 83 84 #[config(env = "KNOT_TLS_MTLS_CLIENT_CA_PATH")] 85 pub mtls_client_ca_path: Option<PathBuf>, 86 87 #[config(env = "KNOT_TLS_MTLS_ADMIN_SPKI_PIN")] 88 pub mtls_admin_spki_pin: Option<String>, 89} 90 91#[derive(Debug, Config)] 92pub struct ServerConfig { 93 #[config(env = "KNOT_HOSTNAME")] 94 pub hostname: String, 95 96 #[config(env = "KNOT_ADMINS", parse_env = parse_admins)] 97 pub admins: Vec<AccountDid>, 98 99 #[config(env = "KNOT_LISTEN_ADDR", default = "[::]:5555")] 100 pub listen_addr: SocketAddr, 101 102 #[config(env = "KNOT_LISTEN_HEADER_TIMEOUT_MS", default = 10_000)] 103 pub listen_header_timeout_ms: u64, 104 105 #[config(env = "KNOT_LISTEN_IDLE_TIMEOUT_MS", default = 60_000)] 106 pub listen_idle_timeout_ms: u64, 107 108 #[config(env = "KNOT_LISTEN_MAX_CONNECTIONS", default = 1_024)] 109 pub listen_max_connections: u32, 110 111 #[config(env = "KNOT_LISTEN_RATE_LIMIT_PER_SECOND", default = 50)] 112 pub listen_rate_limit_per_second: u32, 113 114 #[config(env = "KNOT_LISTEN_RATE_LIMIT_BURST", default = 200)] 115 pub listen_rate_limit_burst: u32, 116 117 #[config(env = "KNOT_LISTEN_MAX_INFLIGHT_REQUESTS", default = 1_024)] 118 pub listen_max_inflight_requests: u32, 119 120 #[config(env = "KNOT_LISTEN_REQUEST_TIMEOUT_MS", default = 60_000)] 121 pub listen_request_timeout_ms: u64, 122 123 #[config(env = "KNOT_LISTEN_BODY_TIMEOUT_MS", default = 30_000)] 124 pub listen_body_timeout_ms: u64, 125 126 #[config(env = "KNOT_LISTEN_WRITE_REQUEST_TIMEOUT_MS", default = 1_800_000)] 127 pub listen_write_request_timeout_ms: u64, 128 129 #[config(env = "KNOT_INTERNAL_LISTEN_ADDR", default = "[::1]:5444")] 130 pub internal_listen_addr: SocketAddr, 131 132 #[config(env = "KNOT_SSH_LISTEN_ADDR", default = "[::]:2222")] 133 pub ssh_listen_addr: SocketAddr, 134 135 #[config(env = "KNOT_SSH_HOST_KEY_FILE")] 136 pub ssh_host_key_file: PathBuf, 137 138 #[config(env = "KNOT_SSH_MAX_PACK_BYTES", default = 8_589_934_592u64)] 139 pub ssh_max_pack_bytes: u64, 140 141 #[config(env = "KNOT_APPVIEW_ENDPOINT", default = "https://tangled.org")] 142 pub appview_endpoint: AppviewEndpoint, 143} 144 145#[derive(Debug, Config)] 146pub struct RepoConfig { 147 #[config(env = "KNOT_SCAN_PATH")] 148 pub scan_path: PathBuf, 149 150 #[config(env = "KNOT_DEFAULT_BRANCH", default = "main")] 151 pub default_branch: String, 152} 153 154#[derive(Debug, Config)] 155pub struct CiConfig { 156 #[config(env = "KNOT_CI_LOGS_ADDR")] 157 pub logs_addr: Option<String>, 158} 159 160#[derive(Debug, Config)] 161pub struct HomepageConfig { 162 #[config(env = "KNOT_HOMEPAGE_ENABLED", default = true)] 163 pub enabled: bool, 164 165 #[config(env = "KNOT_HOMEPAGE_PATH")] 166 pub path: Option<PathBuf>, 167} 168 169#[derive(Debug)] 170pub enum HomepageSource { 171 Disabled, 172 Default, 173 File(PathBuf), 174} 175 176impl HomepageConfig { 177 pub fn source(&self) -> HomepageSource { 178 match (self.enabled, self.path.as_ref()) { 179 (false, _) => HomepageSource::Disabled, 180 (true, None) => HomepageSource::Default, 181 (true, Some(path)) => HomepageSource::File(path.clone()), 182 } 183 } 184} 185 186#[derive(Debug, Config)] 187pub struct GitConfig { 188 /// Committer identity stamped on merge commits the knot creates. 189 #[config(env = "KNOT_GIT_USER_NAME", default = "Tangled")] 190 pub user_name: String, 191 192 #[config(env = "KNOT_GIT_USER_EMAIL", default = "noreply@tangled.sh")] 193 pub user_email: String, 194 195 #[config(env = "KNOT_GIT_OBJECT_FORMAT", default = "sha256")] 196 pub object_format: String, 197} 198 199#[derive(Debug, Config)] 200pub struct SecretsConfig { 201 #[config(env = "KNOT_SEALED_KEY_FILE")] 202 pub sealed_key_file: PathBuf, 203 204 #[config(env = "KNOT_MASTER_KEY_ENV")] 205 pub master_key_env: String, 206} 207 208#[derive(Debug, Config)] 209pub struct HttpConfig { 210 #[config(env = "KNOT_HTTP_CONNECT_TIMEOUT_MS", default = 5_000)] 211 pub connect_timeout_ms: u64, 212 213 #[config(env = "KNOT_HTTP_READ_TIMEOUT_MS", default = 30_000)] 214 pub read_timeout_ms: u64, 215 216 #[config(env = "KNOT_HTTP_REQUEST_TIMEOUT_MS", default = 60_000)] 217 pub request_timeout_ms: u64, 218 219 #[config(env = "KNOT_HTTP_MAX_RESPONSE_BYTES", default = 16_777_216)] 220 pub max_response_bytes: u64, 221} 222 223#[derive(Debug, Config)] 224pub struct AtprotoConfig { 225 #[config(env = "KNOT_PLC_DIRECTORY")] 226 pub plc_directory: Url, 227} 228 229#[derive(Debug, Config)] 230pub struct XrpcConfig { 231 #[config(env = "KNOT_XRPC_MAX_BODY_BYTES", default = 65_536)] 232 pub max_body_bytes: u64, 233 234 #[config(env = "KNOT_XRPC_MAX_RESPONSE_BYTES", default = 5_242_880)] 235 pub max_response_bytes: u64, 236 237 #[config(env = "KNOT_XRPC_MAX_ARCHIVE_BYTES", default = 1_073_741_824)] 238 pub max_archive_bytes: u64, 239 240 #[config(env = "KNOT_XRPC_TREE_LAST_COMMIT_BUDGET_MS", default = 300)] 241 pub tree_last_commit_budget_ms: u64, 242 243 #[config(env = "KNOT_XRPC_BLOB_LAST_COMMIT_BUDGET_MS", default = 2_000)] 244 pub blob_last_commit_budget_ms: u64, 245 246 #[config(env = "KNOT_XRPC_LANGUAGES_BUDGET_MS", default = 1_000)] 247 pub languages_budget_ms: u64, 248 249 #[config(env = "KNOT_XRPC_LANGUAGES_PUSH_BUDGET_MS", default = 2_000)] 250 pub languages_push_budget_ms: u64, 251 252 /// Body limit for the merge and mergeCheck procedures, whose patch payloads 253 /// routinely exceed the general XRPC body limit. 254 #[config(env = "KNOT_XRPC_MAX_PATCH_BYTES", default = 16_777_216)] 255 pub max_patch_bytes: u64, 256 257 /// Limit on the total decompressed size of a patch the merge procedures parse, 258 /// bounding binary-delta inflation and hunk expansion apart from the 259 /// compressed body limit above. 260 #[config(env = "KNOT_XRPC_MAX_PATCH_DECOMPRESSED_BYTES", default = 134_217_728)] 261 pub max_patch_decompressed_bytes: u64, 262 263 #[config(env = "KNOT_XRPC_PREAUTH_BURST", default = 20)] 264 pub preauth_burst: u32, 265 266 #[config(env = "KNOT_XRPC_PREAUTH_REFILL_MS", default = 100)] 267 pub preauth_refill_ms: u64, 268 269 #[config(env = "KNOT_XRPC_PER_PEER_INFLIGHT", default = 8)] 270 pub per_peer_inflight: u32, 271 272 #[config(env = "KNOT_XRPC_GLOBAL_INFLIGHT", default = 64)] 273 pub global_inflight: u32, 274 275 #[config(env = "KNOT_XRPC_MAX_PENDING_RESERVATIONS", default = 256)] 276 pub max_pending_reservations: u32, 277 278 /// Per-account limit on reserved repository keys awaiting creation, so one 279 /// account cannot consume the whole pending-reservation budget. 280 #[config(env = "KNOT_XRPC_PER_ACTOR_RESERVATIONS", default = 32)] 281 pub per_actor_reservations: u32, 282 283 /// How long a reserved repository key is held before it lapses and its 284 /// sealed key is reclaimed, in seconds. 285 #[config(env = "KNOT_XRPC_RESERVATION_TTL_SECS", default = 3600)] 286 pub reservation_ttl_secs: u64, 287 288 #[config(env = "KNOT_XRPC_FORK_MAX_PACK_BYTES", default = 1_073_741_824)] 289 pub fork_max_pack_bytes: u64, 290 291 #[config(env = "KNOT_XRPC_FORK_FETCH_TIMEOUT_MS", default = 600_000)] 292 pub fork_fetch_timeout_ms: u64, 293 294 /// When the knot runs behind a trusted reverse proxy that terminates TLS, 295 /// set this to the header the proxy appends the client address to, for 296 /// example x-forwarded-for. The rightmost entry is used. Leave unset when 297 /// the knot is directly exposed so the socket peer address is used. Only set 298 /// this when a trusted proxy overwrites or appends the header, since a client 299 /// can forge it otherwise. 300 #[config(env = "KNOT_XRPC_TRUSTED_PROXY_HEADER")] 301 pub trusted_proxy_header: Option<String>, 302 303 #[config(env = "KNOT_XRPC_EVENTS_REPLAY_BUFFER", default = 4096)] 304 pub events_replay_buffer: u32, 305 306 #[config(env = "KNOT_XRPC_EVENTS_REPLAY_BYTES", default = 67_108_864)] 307 pub events_replay_bytes: u64, 308 309 #[config(env = "KNOT_XRPC_EVENTS_MAX_SUBSCRIBERS", default = 256)] 310 pub events_max_subscribers: u32, 311 312 #[config(env = "KNOT_XRPC_EVENTS_MAX_PER_PEER", default = 8)] 313 pub events_max_per_peer: u32, 314} 315 316#[derive(Debug, Config)] 317pub struct MaintenanceConfig { 318 #[config(env = "KNOT_MAINTENANCE_ENABLED", default = true)] 319 pub enabled: bool, 320 321 #[config(env = "KNOT_MAINTENANCE_COMMIT_GRAPH", default = true)] 322 pub commit_graph: bool, 323 324 #[config(env = "KNOT_MAINTENANCE_MULTI_PACK_INDEX", default = true)] 325 pub multi_pack_index: bool, 326 327 #[config(env = "KNOT_MAINTENANCE_BITMAP", default = true)] 328 pub bitmap: bool, 329 330 #[config(env = "KNOT_MAINTENANCE_INTERVAL_SECS", default = 21_600)] 331 pub interval_secs: u64, 332 333 #[config(env = "KNOT_MAINTENANCE_REPACK_MAX_OBJECTS", default = 16_000_000)] 334 pub repack_max_objects: u64, 335 336 #[config(env = "KNOT_MAINTENANCE_REPACK_GEOMETRIC_FACTOR", default = 2)] 337 pub repack_geometric_factor: u64, 338 339 #[config(env = "KNOT_MAINTENANCE_PRUNE_GRACE_SECS", default = 1_209_600)] 340 pub prune_grace_secs: u64, 341 342 #[config(env = "KNOT_MAINTENANCE_REFLOG_EXPIRE_SECS", default = 7_776_000)] 343 pub reflog_expire_secs: u64, 344 345 #[config(env = "KNOT_MAINTENANCE_LARGE_PUSH_BYTES", default = 52_428_800)] 346 pub large_push_bytes: u64, 347} 348 349#[derive(Debug, Config)] 350pub struct PackCacheConfig { 351 #[config(env = "KNOT_PACK_CACHE_ENABLED", default = true)] 352 pub enabled: bool, 353 354 #[config(env = "KNOT_PACK_CACHE_TTL_SECS", default = 60)] 355 pub ttl_secs: u64, 356 357 #[config(env = "KNOT_PACK_CACHE_MAX_ENTRY_BYTES", default = 67_108_864)] 358 pub max_entry_bytes: u64, 359 360 #[config(env = "KNOT_PACK_CACHE_MAX_TOTAL_BYTES", default = 2_147_483_648u64)] 361 pub max_total_bytes: u64, 362} 363 364#[derive(Debug, Config)] 365pub struct PackConfig { 366 #[config(env = "KNOT_PACK_MAX_OBJECTS", default = 16_000_000)] 367 pub max_objects: u32, 368 369 #[config(env = "KNOT_PACK_MAX_TOTAL_BYTES", default = 68_719_476_736u64)] 370 pub max_total_bytes: u64, 371 372 #[config(env = "KNOT_PACK_SELECTION_MAX_OBJECTS", default = 16_000_000)] 373 pub selection_max_objects: u32, 374 375 #[config(env = "KNOT_PACK_SELECTION_TIME_BUDGET_SECS", default = 600)] 376 pub selection_time_budget_secs: u64, 377} 378 379#[derive(Debug, Config)] 380pub struct LfsConfig { 381 #[config(env = "KNOT_LFS_STORE_PATH")] 382 pub store_path: Option<PathBuf>, 383 384 #[config(env = "KNOT_LFS_MAX_OBJECT_BYTES", default = 5_368_709_120u64)] 385 pub max_object_bytes: u64, 386 387 #[config(env = "KNOT_LFS_FREE_SPACE_FLOOR_BYTES", default = 1_073_741_824u64)] 388 pub free_space_floor_bytes: u64, 389 390 #[config(env = "KNOT_LFS_GC_GRACE_SECS", default = 1_209_600)] 391 pub gc_grace_secs: u64, 392 393 #[config(env = "KNOT_LFS_GC_INTERVAL_SECS", default = 21_600)] 394 pub gc_interval_secs: u64, 395 396 #[config(env = "KNOT_LFS_MAX_SSH_TRANSFERS", default = 16)] 397 pub max_ssh_transfers: u32, 398 399 #[config(env = "KNOT_LFS_MAX_HTTP_DOWNLOADS", default = 64)] 400 pub max_http_downloads: u32, 401} 402 403#[derive(Debug, Config)] 404pub struct ResourcesConfig { 405 #[config(env = "KNOT_MAX_THREADS", default = 0)] 406 pub max_threads: u32, 407 408 #[config(env = "KNOT_MAX_MEMORY_BYTES", default = 0)] 409 pub max_memory_bytes: u64, 410} 411 412fn parse_admins(raw: &str) -> Result<Vec<AccountDid>, knot_types::ParseError> { 413 raw.split(',') 414 .map(str::trim) 415 .filter(|item| !item.is_empty()) 416 .map(AccountDid::new) 417 .collect() 418} 419 420impl KnotConfig { 421 pub fn object_format(&self) -> Option<knot_types::ObjectFormat> { 422 knot_types::ObjectFormat::from_capability(&self.git.object_format) 423 } 424 425 pub fn tls_enabled(&self) -> bool { 426 self.static_cert_enabled() || self.tls.acme_enabled 427 } 428 429 pub fn static_cert_enabled(&self) -> bool { 430 self.tls.cert_path.is_some() && self.tls.key_path.is_some() 431 } 432 433 pub fn http_limits(&self) -> HttpLimits { 434 HttpLimits { 435 connect_timeout: Duration::from_millis(self.http.connect_timeout_ms), 436 read_timeout: Duration::from_millis(self.http.read_timeout_ms), 437 request_timeout: Duration::from_millis(self.http.request_timeout_ms), 438 max_response_bytes: self.http.max_response_bytes, 439 block_private_addresses: true, 440 } 441 } 442 443 pub fn fork_http_limits(&self) -> HttpLimits { 444 HttpLimits { 445 connect_timeout: Duration::from_millis(self.http.connect_timeout_ms), 446 read_timeout: Duration::from_millis(self.http.read_timeout_ms), 447 request_timeout: Duration::from_millis(self.xrpc.fork_fetch_timeout_ms), 448 max_response_bytes: self 449 .xrpc 450 .fork_max_pack_bytes 451 .saturating_add(self.xrpc.fork_max_pack_bytes / 64) 452 .saturating_add(1_048_576), 453 block_private_addresses: true, 454 } 455 } 456 457 pub fn validate(&self) -> Result<(), ConfigError> { 458 let errors: Vec<String> = [ 459 check( 460 !self.server.hostname.is_empty(), 461 "server.hostname mustn't be empty", 462 ), 463 knot_messages::Catalog::parse(&self.messages) 464 .err() 465 .map(|error| error.to_string()), 466 knot_types::KnotHostname::new(self.server.hostname.clone()) 467 .err() 468 .map(|_| "server.hostname isn't a valid bare hostname".to_string()), 469 check( 470 !self.server.admins.is_empty(), 471 "server.admins must list at least one DID", 472 ), 473 check( 474 self.repo.scan_path.is_absolute(), 475 "repo.scan_path must be absolute path", 476 ), 477 check( 478 self.secrets.sealed_key_file.is_absolute(), 479 "secrets.sealed_key_file must be absolute path", 480 ), 481 check( 482 self.server.ssh_host_key_file.is_absolute(), 483 "server.ssh_host_key_file must be absolute path", 484 ), 485 check( 486 self.tls.cert_path.is_some() == self.tls.key_path.is_some(), 487 "tls.cert_path and tls.key_path must both be set or both unset", 488 ), 489 self.tls 490 .cert_path 491 .as_ref() 492 .filter(|path| !path.is_absolute()) 493 .map(|_| "tls.cert_path must be absolute path".to_string()), 494 self.tls 495 .key_path 496 .as_ref() 497 .filter(|path| !path.is_absolute()) 498 .map(|_| "tls.key_path must be absolute path".to_string()), 499 check( 500 !(self.tls.acme_enabled && self.static_cert_enabled()), 501 "tls.acme_enabled cannot combine with a static tls.cert_path and tls.key_path", 502 ), 503 check( 504 !self.tls.acme_enabled || self.tls.acme_cache_dir.is_some(), 505 "tls.acme_cache_dir is required when tls.acme_enabled is set", 506 ), 507 self.tls 508 .acme_cache_dir 509 .as_ref() 510 .filter(|path| !path.is_absolute()) 511 .map(|_| "tls.acme_cache_dir must be absolute path".to_string()), 512 check( 513 !self.tls.acme_enabled 514 || self 515 .tls 516 .acme_contact 517 .as_deref() 518 .is_some_and(is_contact_email), 519 "tls.acme_contact must be a contact email when tls.acme_enabled is set", 520 ), 521 check( 522 !self.tls.mtls_enabled || self.tls_enabled(), 523 "tls.mtls_enabled requires a server certificate via static paths or ACME", 524 ), 525 check( 526 !self.tls.mtls_enabled || self.tls.mtls_client_ca_path.is_some(), 527 "tls.mtls_client_ca_path is required when tls.mtls_enabled is set", 528 ), 529 self.tls 530 .mtls_client_ca_path 531 .as_ref() 532 .filter(|path| !path.is_absolute()) 533 .map(|_| "tls.mtls_client_ca_path must be absolute path".to_string()), 534 check( 535 !self.tls.mtls_enabled 536 || self 537 .tls 538 .mtls_admin_spki_pin 539 .as_deref() 540 .is_some_and(is_spki_pin), 541 "tls.mtls_admin_spki_pin must be a base64 SHA-256 pin when tls.mtls_enabled is set", 542 ), 543 check( 544 is_env_var_name(&self.secrets.master_key_env), 545 "secrets.master_key_env must be valid environment variable name", 546 ), 547 check( 548 self.server.ssh_max_pack_bytes > 0, 549 "server.ssh_max_pack_bytes must be greater than zero", 550 ), 551 check( 552 self.server.listen_header_timeout_ms > 0, 553 "server.listen_header_timeout_ms must be greater than zero", 554 ), 555 check( 556 self.server.listen_idle_timeout_ms > 0, 557 "server.listen_idle_timeout_ms must be greater than zero", 558 ), 559 check( 560 self.server.listen_idle_timeout_ms >= self.server.listen_header_timeout_ms, 561 "server.listen_idle_timeout_ms must be at least server.listen_header_timeout_ms", 562 ), 563 check( 564 self.server.listen_max_connections > 0, 565 "server.listen_max_connections must be greater than zero", 566 ), 567 check( 568 self.server.listen_rate_limit_per_second > 0, 569 "server.listen_rate_limit_per_second must be greater than zero", 570 ), 571 check( 572 self.server.listen_rate_limit_burst > 0, 573 "server.listen_rate_limit_burst must be greater than zero", 574 ), 575 check( 576 self.server.listen_max_inflight_requests > 0, 577 "server.listen_max_inflight_requests must be greater than zero", 578 ), 579 check( 580 self.server.listen_request_timeout_ms > 0, 581 "server.listen_request_timeout_ms must be greater than zero", 582 ), 583 check( 584 self.server.listen_body_timeout_ms > 0, 585 "server.listen_body_timeout_ms must be greater than zero", 586 ), 587 check( 588 self.server.listen_write_request_timeout_ms > 0, 589 "server.listen_write_request_timeout_ms must be greater than zero", 590 ), 591 knot_types::RefName::new(format!("refs/heads/{}", self.repo.default_branch)) 592 .err() 593 .map(|_| "repo.default_branch isn't valid branch name".to_string()), 594 check( 595 self.http.connect_timeout_ms > 0, 596 "http.connect_timeout_ms must be greater than zero", 597 ), 598 check( 599 self.http.read_timeout_ms > 0, 600 "http.read_timeout_ms must be greater than zero", 601 ), 602 check( 603 self.http.request_timeout_ms > 0, 604 "http.request_timeout_ms must be greater than zero", 605 ), 606 check( 607 self.http.max_response_bytes > 0, 608 "http.max_response_bytes must be greater than zero", 609 ), 610 check( 611 self.atproto.plc_directory.scheme() == "https", 612 "atproto.plc_directory must be https URL", 613 ), 614 check( 615 self.atproto.plc_directory.host().is_some(), 616 "atproto.plc_directory must have host", 617 ), 618 check( 619 self.xrpc.max_body_bytes > 0, 620 "xrpc.max_body_bytes must be greater than zero", 621 ), 622 check( 623 self.xrpc.max_response_bytes > 0, 624 "xrpc.max_response_bytes must be greater than zero", 625 ), 626 check( 627 self.xrpc.max_archive_bytes > 0, 628 "xrpc.max_archive_bytes must be greater than zero", 629 ), 630 check( 631 self.xrpc.tree_last_commit_budget_ms > 0, 632 "xrpc.tree_last_commit_budget_ms must be greater than zero", 633 ), 634 check( 635 self.xrpc.blob_last_commit_budget_ms > 0, 636 "xrpc.blob_last_commit_budget_ms must be greater than zero", 637 ), 638 check( 639 self.xrpc.languages_budget_ms > 0, 640 "xrpc.languages_budget_ms must be greater than zero", 641 ), 642 check( 643 self.xrpc.languages_push_budget_ms > 0, 644 "xrpc.languages_push_budget_ms must be greater than zero", 645 ), 646 check( 647 self.xrpc.max_patch_bytes > 0, 648 "xrpc.max_patch_bytes must be greater than zero", 649 ), 650 check( 651 self.xrpc.max_patch_decompressed_bytes > 0, 652 "xrpc.max_patch_decompressed_bytes must be greater than zero", 653 ), 654 check( 655 self.xrpc.fork_max_pack_bytes > 0, 656 "xrpc.fork_max_pack_bytes must be greater than zero", 657 ), 658 check( 659 self.xrpc.fork_fetch_timeout_ms > 0, 660 "xrpc.fork_fetch_timeout_ms must be greater than zero", 661 ), 662 check( 663 !self.git.user_name.trim().is_empty(), 664 "git.user_name mustn't be empty", 665 ), 666 check( 667 !self.git.user_email.trim().is_empty(), 668 "git.user_email mustn't be empty", 669 ), 670 check( 671 self.object_format().is_some(), 672 "git.object_format must be \"sha1\" or \"sha256\"", 673 ), 674 check( 675 self.xrpc.preauth_burst > 0, 676 "xrpc.preauth_burst must be greater than zero", 677 ), 678 check( 679 self.xrpc.preauth_refill_ms > 0, 680 "xrpc.preauth_refill_ms must be greater than zero", 681 ), 682 check( 683 self.xrpc.per_peer_inflight > 0, 684 "xrpc.per_peer_inflight must be greater than zero", 685 ), 686 check( 687 self.xrpc.global_inflight >= self.xrpc.per_peer_inflight, 688 "xrpc.global_inflight must be at least xrpc.per_peer_inflight", 689 ), 690 check( 691 self.xrpc.per_actor_reservations > 0, 692 "xrpc.per_actor_reservations must be greater than zero", 693 ), 694 check( 695 self.xrpc.max_pending_reservations >= self.xrpc.per_actor_reservations, 696 "xrpc.max_pending_reservations must be at least xrpc.per_actor_reservations", 697 ), 698 check( 699 self.xrpc.reservation_ttl_secs > 0, 700 "xrpc.reservation_ttl_secs must be greater than zero", 701 ), 702 check( 703 self.xrpc.events_replay_buffer > 0, 704 "xrpc.events_replay_buffer must be greater than zero", 705 ), 706 check( 707 self.xrpc.events_replay_bytes > 0, 708 "xrpc.events_replay_bytes must be greater than zero", 709 ), 710 check( 711 self.xrpc.events_max_subscribers > 0, 712 "xrpc.events_max_subscribers must be greater than zero", 713 ), 714 check( 715 self.xrpc.events_max_per_peer > 0, 716 "xrpc.events_max_per_peer must be greater than zero", 717 ), 718 check( 719 self.xrpc.events_max_subscribers >= self.xrpc.events_max_per_peer, 720 "xrpc.events_max_subscribers must be at least xrpc.events_max_per_peer", 721 ), 722 check( 723 self.maintenance.interval_secs > 0, 724 "maintenance.interval_secs must be greater than zero", 725 ), 726 check( 727 self.maintenance.repack_max_objects > 0, 728 "maintenance.repack_max_objects must be greater than zero", 729 ), 730 check( 731 self.maintenance.repack_geometric_factor >= 2, 732 "maintenance.repack_geometric_factor must be at least 2", 733 ), 734 check( 735 self.maintenance.large_push_bytes > 0, 736 "maintenance.large_push_bytes must be greater than zero", 737 ), 738 check( 739 self.pack_cache.ttl_secs > 0, 740 "pack_cache.ttl_secs must be greater than zero", 741 ), 742 check( 743 self.pack_cache.max_entry_bytes > 0, 744 "pack_cache.max_entry_bytes must be greater than zero", 745 ), 746 check( 747 self.pack_cache.max_total_bytes > 0, 748 "pack_cache.max_total_bytes must be greater than zero", 749 ), 750 check( 751 self.pack_cache.max_total_bytes >= self.pack_cache.max_entry_bytes, 752 "pack_cache.max_total_bytes must be at least pack_cache.max_entry_bytes", 753 ), 754 check( 755 self.pack.max_objects > 0, 756 "pack.max_objects must be greater than zero", 757 ), 758 check( 759 self.pack.max_total_bytes > 0, 760 "pack.max_total_bytes must be greater than zero", 761 ), 762 check( 763 self.pack.selection_max_objects > 0, 764 "pack.selection_max_objects must be greater than zero", 765 ), 766 check( 767 self.pack.selection_time_budget_secs > 0, 768 "pack.selection_time_budget_secs must be greater than zero", 769 ), 770 self.lfs 771 .store_path 772 .as_ref() 773 .filter(|path| !path.is_absolute()) 774 .map(|_| "lfs.store_path must be absolute path".to_string()), 775 self.lfs 776 .store_path 777 .as_ref() 778 .filter(|path| { 779 path.starts_with(&self.repo.scan_path) || self.repo.scan_path.starts_with(path) 780 }) 781 .map(|_| "lfs.store_path mustn't overlap repo.scan_path".to_string()), 782 check( 783 self.lfs.max_object_bytes > 0, 784 "lfs.max_object_bytes must be greater than zero", 785 ), 786 check( 787 self.lfs.gc_interval_secs > 0, 788 "lfs.gc_interval_secs must be greater than zero", 789 ), 790 check( 791 self.lfs.max_ssh_transfers > 0, 792 "lfs.max_ssh_transfers must be greater than zero", 793 ), 794 check( 795 self.lfs.max_http_downloads > 0, 796 "lfs.max_http_downloads must be greater than zero", 797 ), 798 self.xrpc 799 .trusted_proxy_header 800 .as_ref() 801 .filter(|header| !is_http_token(header)) 802 .map(|_| "xrpc.trusted_proxy_header isn't valid HTTP header name".to_string()), 803 match self.homepage.source() { 804 HomepageSource::File(path) if !path.is_absolute() => { 805 Some("homepage.path must be absolute path".to_string()) 806 } 807 _ => None, 808 }, 809 ] 810 .into_iter() 811 .flatten() 812 .chain(self.port_collisions()) 813 .collect(); 814 815 if errors.is_empty() { 816 Ok(()) 817 } else { 818 Err(ConfigError { errors }) 819 } 820 } 821 822 fn port_collisions(&self) -> Vec<String> { 823 let binds = [ 824 ("server.listen_addr", self.server.listen_addr), 825 ( 826 "server.internal_listen_addr", 827 self.server.internal_listen_addr, 828 ), 829 ("server.ssh_listen_addr", self.server.ssh_listen_addr), 830 ]; 831 [(0, 1), (0, 2), (1, 2)] 832 .into_iter() 833 .filter(|&(a, b)| binds[a].1.port() == binds[b].1.port()) 834 .map(|(a, b)| { 835 format!( 836 "{} and {} cannot bind same port {}", 837 binds[a].0, 838 binds[b].0, 839 binds[a].1.port() 840 ) 841 }) 842 .collect() 843 } 844} 845 846fn check(ok: bool, message: &str) -> Option<String> { 847 (!ok).then(|| message.to_string()) 848} 849 850fn is_http_token(value: &str) -> bool { 851 !value.is_empty() 852 && value 853 .bytes() 854 .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)) 855} 856 857fn is_contact_email(value: &str) -> bool { 858 let mut parts = value.splitn(2, '@'); 859 matches!( 860 (parts.next(), parts.next()), 861 (Some(local), Some(domain)) 862 if !local.is_empty() 863 && domain.contains('.') 864 && !domain.starts_with('.') 865 && !domain.ends_with('.') 866 && !value.chars().any(char::is_whitespace) 867 ) 868} 869 870fn is_spki_pin(value: &str) -> bool { 871 base64::engine::general_purpose::STANDARD 872 .decode(value) 873 .is_ok_and(|bytes| bytes.len() == 32) 874} 875 876fn is_env_var_name(value: &str) -> bool { 877 !value.is_empty() 878 && !value.starts_with(|c: char| c.is_ascii_digit()) 879 && value 880 .chars() 881 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') 882} 883 884#[derive(Debug, thiserror::Error)] 885pub struct ConfigError { 886 pub errors: Vec<String>, 887} 888 889impl fmt::Display for ConfigError { 890 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 891 writeln!(f, "{} configuration problem(s):", self.errors.len())?; 892 self.errors 893 .iter() 894 .try_for_each(|error| writeln!(f, " - {error}")) 895 } 896} 897 898pub struct Validated(KnotConfig); 899 900impl Validated { 901 pub fn verify_environment(&self) -> Result<(), EnvError> { 902 verify_writable_dir("repo.scan_path", &self.0.repo.scan_path)?; 903 self.0 904 .lfs 905 .store_path 906 .as_deref() 907 .map_or(Ok(()), |path| verify_writable_dir("lfs.store_path", path))?; 908 verify_homepage(self.0.homepage.source())?; 909 verify_master_key(&self.0.secrets.master_key_env) 910 } 911 912 pub fn into_inner(self) -> KnotConfig { 913 self.0 914 } 915} 916 917impl std::ops::Deref for Validated { 918 type Target = KnotConfig; 919 920 fn deref(&self) -> &Self::Target { 921 &self.0 922 } 923} 924 925#[derive(Debug, thiserror::Error)] 926pub enum EnvError { 927 #[error("{field} {path} isn't accessible")] 928 DirInaccessible { 929 field: &'static str, 930 path: PathBuf, 931 #[source] 932 source: std::io::Error, 933 }, 934 #[error("{field} {path} isn't directory")] 935 DirNotDir { field: &'static str, path: PathBuf }, 936 #[error("{field} {path} isn't writable")] 937 DirNotWritable { 938 field: &'static str, 939 path: PathBuf, 940 #[source] 941 source: std::io::Error, 942 }, 943 #[error("homepage.path {path} isn't accessible")] 944 HomepageInaccessible { 945 path: PathBuf, 946 #[source] 947 source: std::io::Error, 948 }, 949 #[error("homepage.path {path} isn't a regular file")] 950 HomepageNotFile { path: PathBuf }, 951 #[error("master key env var {name} isn't set")] 952 MasterKeyUnset { name: String }, 953 #[error("master key env var {name} is empty")] 954 MasterKeyEmpty { name: String }, 955 #[error("master key env var {name} isn't valid base64")] 956 MasterKeyNotBase64 { 957 name: String, 958 #[source] 959 source: base64::DecodeError, 960 }, 961 #[error( 962 "master key env var {name} decodes to only {len} of minimum {MASTER_KEY_MIN_BYTES} bytes" 963 )] 964 MasterKeyTooShort { name: String, len: usize }, 965} 966 967const MASTER_KEY_MIN_BYTES: usize = 32; 968 969fn verify_writable_dir(field: &'static str, path: &Path) -> Result<(), EnvError> { 970 let metadata = std::fs::metadata(path).map_err(|source| EnvError::DirInaccessible { 971 field, 972 path: path.to_path_buf(), 973 source, 974 })?; 975 if !metadata.is_dir() { 976 return Err(EnvError::DirNotDir { 977 field, 978 path: path.to_path_buf(), 979 }); 980 } 981 tempfile::Builder::new() 982 .prefix(".knot-write-probe") 983 .tempfile_in(path) 984 .map(drop) 985 .map_err(|source| EnvError::DirNotWritable { 986 field, 987 path: path.to_path_buf(), 988 source, 989 }) 990} 991 992fn verify_homepage(source: HomepageSource) -> Result<(), EnvError> { 993 let HomepageSource::File(path) = source else { 994 return Ok(()); 995 }; 996 let file = std::fs::File::open(&path).map_err(|source| EnvError::HomepageInaccessible { 997 path: path.clone(), 998 source, 999 })?; 1000 let is_file = file 1001 .metadata() 1002 .map_err(|source| EnvError::HomepageInaccessible { 1003 path: path.clone(), 1004 source, 1005 })? 1006 .is_file(); 1007 is_file 1008 .then_some(()) 1009 .ok_or(EnvError::HomepageNotFile { path }) 1010} 1011 1012fn verify_master_key(name: &str) -> Result<(), EnvError> { 1013 let value = std::env::var(name).ok(); 1014 validate_master_key(name, value.as_deref()) 1015} 1016 1017fn validate_master_key(name: &str, value: Option<&str>) -> Result<(), EnvError> { 1018 let raw = value.ok_or_else(|| EnvError::MasterKeyUnset { 1019 name: name.to_string(), 1020 })?; 1021 let trimmed = raw.trim(); 1022 if trimmed.is_empty() { 1023 return Err(EnvError::MasterKeyEmpty { 1024 name: name.to_string(), 1025 }); 1026 } 1027 let decoded = base64::engine::general_purpose::STANDARD 1028 .decode(trimmed) 1029 .map_err(|source| EnvError::MasterKeyNotBase64 { 1030 name: name.to_string(), 1031 source, 1032 })?; 1033 (decoded.len() >= MASTER_KEY_MIN_BYTES) 1034 .then_some(()) 1035 .ok_or(EnvError::MasterKeyTooShort { 1036 name: name.to_string(), 1037 len: decoded.len(), 1038 }) 1039} 1040 1041static CONFIG: OnceLock<KnotConfig> = OnceLock::new(); 1042 1043#[derive(Debug, thiserror::Error)] 1044pub enum LoadError { 1045 #[error("config file not found: {0}")] 1046 Missing(PathBuf), 1047 #[error(transparent)] 1048 Confique(#[from] confique::Error), 1049 #[error(transparent)] 1050 Invalid(#[from] ConfigError), 1051} 1052 1053pub fn load(path: Option<&Path>) -> Result<Validated, LoadError> { 1054 if let Some(path) = path 1055 && !path.exists() 1056 { 1057 return Err(LoadError::Missing(path.to_path_buf())); 1058 } 1059 let mut builder = KnotConfig::builder().env(); 1060 if let Some(path) = path { 1061 builder = builder.file(path); 1062 } 1063 let config = builder.file("/etc/knot/config.toml").load()?; 1064 config.validate()?; 1065 Ok(Validated(config)) 1066} 1067 1068pub fn template() -> String { 1069 confique::toml::template::<KnotConfig>(confique::toml::FormatOptions::default()) 1070} 1071 1072pub fn init(config: Validated) { 1073 CONFIG 1074 .set(config.into_inner()) 1075 .expect("knot-config: configuration already initialized"); 1076} 1077 1078pub fn get() -> &'static KnotConfig { 1079 CONFIG 1080 .get() 1081 .expect("knot-config: not initialized, call knot_config::init first") 1082} 1083 1084pub fn try_get() -> Option<&'static KnotConfig> { 1085 CONFIG.get() 1086} 1087 1088#[cfg(test)] 1089mod tests { 1090 use super::*; 1091 1092 #[test] 1093 fn example_toml_is_the_generated_template() { 1094 assert_eq!( 1095 template(), 1096 include_str!("../../../example.toml"), 1097 "regenerate example.toml from knot_config::template() after changing config" 1098 ); 1099 } 1100 1101 fn sample() -> KnotConfig { 1102 KnotConfig { 1103 server: ServerConfig { 1104 hostname: "oyster.cafe".to_string(), 1105 admins: vec![AccountDid::new("did:plc:nel").unwrap()], 1106 listen_addr: "[::]:5555".parse().unwrap(), 1107 listen_header_timeout_ms: 10_000, 1108 listen_idle_timeout_ms: 60_000, 1109 listen_max_connections: 1_024, 1110 listen_rate_limit_per_second: 50, 1111 listen_rate_limit_burst: 200, 1112 listen_max_inflight_requests: 1_024, 1113 listen_request_timeout_ms: 60_000, 1114 listen_body_timeout_ms: 30_000, 1115 listen_write_request_timeout_ms: 1_800_000, 1116 internal_listen_addr: "[::1]:5444".parse().unwrap(), 1117 ssh_listen_addr: "[::]:2222".parse().unwrap(), 1118 ssh_host_key_file: PathBuf::from("/var/lib/knot/ssh_host_key"), 1119 ssh_max_pack_bytes: 8_589_934_592, 1120 appview_endpoint: AppviewEndpoint::new("https://tangled.org").unwrap(), 1121 }, 1122 tls: TlsConfig { 1123 cert_path: None, 1124 key_path: None, 1125 http3: true, 1126 acme_enabled: false, 1127 acme_cache_dir: None, 1128 acme_contact: None, 1129 acme_staging: false, 1130 mtls_enabled: false, 1131 mtls_client_ca_path: None, 1132 mtls_admin_spki_pin: None, 1133 }, 1134 acl: AclConfig { 1135 admission: AdmissionPolicy::Closed, 1136 }, 1137 repo: RepoConfig { 1138 scan_path: PathBuf::from("/srv/git"), 1139 default_branch: "main".to_string(), 1140 }, 1141 git: GitConfig { 1142 user_name: "Tangled".to_string(), 1143 user_email: "noreply@tangled.sh".to_string(), 1144 object_format: "sha1".to_string(), 1145 }, 1146 secrets: SecretsConfig { 1147 sealed_key_file: PathBuf::from("/var/lib/knot/keys.sealed"), 1148 master_key_env: "KNOT_MASTER_KEY".to_string(), 1149 }, 1150 http: HttpConfig { 1151 connect_timeout_ms: 5_000, 1152 read_timeout_ms: 30_000, 1153 request_timeout_ms: 60_000, 1154 max_response_bytes: 16_777_216, 1155 }, 1156 atproto: AtprotoConfig { 1157 plc_directory: Url::parse("https://plc.nel.pet/").unwrap(), 1158 }, 1159 xrpc: XrpcConfig { 1160 max_body_bytes: 65_536, 1161 max_response_bytes: 5_242_880, 1162 max_archive_bytes: 1_073_741_824, 1163 tree_last_commit_budget_ms: 300, 1164 blob_last_commit_budget_ms: 2_000, 1165 languages_budget_ms: 1_000, 1166 languages_push_budget_ms: 2_000, 1167 max_patch_bytes: 16_777_216, 1168 max_patch_decompressed_bytes: 134_217_728, 1169 preauth_burst: 20, 1170 preauth_refill_ms: 100, 1171 per_peer_inflight: 8, 1172 global_inflight: 64, 1173 max_pending_reservations: 256, 1174 per_actor_reservations: 32, 1175 reservation_ttl_secs: 3_600, 1176 fork_max_pack_bytes: 1_073_741_824, 1177 fork_fetch_timeout_ms: 600_000, 1178 trusted_proxy_header: None, 1179 events_replay_buffer: 4_096, 1180 events_replay_bytes: 67_108_864, 1181 events_max_subscribers: 256, 1182 events_max_per_peer: 8, 1183 }, 1184 maintenance: MaintenanceConfig { 1185 enabled: true, 1186 commit_graph: true, 1187 multi_pack_index: true, 1188 bitmap: true, 1189 interval_secs: 21_600, 1190 repack_max_objects: 16_000_000, 1191 repack_geometric_factor: 2, 1192 prune_grace_secs: 1_209_600, 1193 reflog_expire_secs: 7_776_000, 1194 large_push_bytes: 52_428_800, 1195 }, 1196 pack_cache: PackCacheConfig { 1197 enabled: true, 1198 ttl_secs: 60, 1199 max_entry_bytes: 67_108_864, 1200 max_total_bytes: 536_870_912, 1201 }, 1202 pack: PackConfig { 1203 max_objects: 16_000_000, 1204 max_total_bytes: 68_719_476_736, 1205 selection_max_objects: 16_000_000, 1206 selection_time_budget_secs: 600, 1207 }, 1208 lfs: LfsConfig { 1209 store_path: None, 1210 max_object_bytes: 5_368_709_120, 1211 free_space_floor_bytes: 1_073_741_824, 1212 gc_grace_secs: 1_209_600, 1213 gc_interval_secs: 21_600, 1214 max_ssh_transfers: 16, 1215 max_http_downloads: 64, 1216 }, 1217 resources: ResourcesConfig { 1218 max_threads: 0, 1219 max_memory_bytes: 0, 1220 }, 1221 messages: knot_messages::MessagesConfig::defaults(), 1222 homepage: HomepageConfig { 1223 enabled: true, 1224 path: None, 1225 }, 1226 ci: CiConfig { 1227 logs_addr: Some("logs.oyster.cafe:3333".to_string()), 1228 }, 1229 } 1230 } 1231 1232 fn apply_acme(config: &mut KnotConfig) { 1233 config.tls.acme_enabled = true; 1234 config.tls.acme_cache_dir = Some(PathBuf::from("/var/lib/knot/acme")); 1235 config.tls.acme_contact = Some("nel@oyster.cafe".to_string()); 1236 } 1237 1238 fn apply_mtls(config: &mut KnotConfig) { 1239 config.tls.cert_path = Some(PathBuf::from("/etc/knot/tls/cert.pem")); 1240 config.tls.key_path = Some(PathBuf::from("/etc/knot/tls/key.pem")); 1241 config.tls.mtls_enabled = true; 1242 config.tls.mtls_client_ca_path = Some(PathBuf::from("/etc/knot/tls/admin-ca.pem")); 1243 config.tls.mtls_admin_spki_pin = 1244 Some(base64::engine::general_purpose::STANDARD.encode([7u8; 32])); 1245 } 1246 1247 #[test] 1248 fn accepts_valid_config() { 1249 type Case = (&'static str, fn(&mut KnotConfig), bool, bool); 1250 let cases: &[Case] = &[ 1251 ("valid_config_passes", |_| {}, false, false), 1252 ( 1253 "matched_absolute_tls_paths", 1254 |config| { 1255 config.tls.cert_path = Some(PathBuf::from("/etc/knot/tls/cert.pem")); 1256 config.tls.key_path = Some(PathBuf::from("/etc/knot/tls/key.pem")); 1257 }, 1258 true, 1259 true, 1260 ), 1261 ("acme_enables_tls", apply_acme, true, false), 1262 ("mtls_with_server_cert_and_pin", apply_mtls, true, true), 1263 ( 1264 "an_immediate_prune_grace", 1265 |config| config.maintenance.prune_grace_secs = 0, 1266 false, 1267 false, 1268 ), 1269 ]; 1270 cases 1271 .iter() 1272 .for_each(|(label, mutate, tls_enabled, static_cert)| { 1273 let mut config = sample(); 1274 mutate(&mut config); 1275 assert!(config.validate().is_ok(), "{label}"); 1276 assert_eq!(config.tls_enabled(), *tls_enabled, "{label} tls_enabled"); 1277 assert_eq!( 1278 config.static_cert_enabled(), 1279 *static_cert, 1280 "{label} static_cert_enabled" 1281 ); 1282 }); 1283 } 1284 1285 #[test] 1286 fn rejects_invalid_config() { 1287 type Case = (&'static str, fn(&mut KnotConfig), &'static str); 1288 let cases: &[Case] = &[ 1289 ( 1290 "a_cert_without_a_key", 1291 |config| config.tls.cert_path = Some(PathBuf::from("/etc/knot/tls/cert.pem")), 1292 "both be set or both unset", 1293 ), 1294 ( 1295 "a_relative_cert_path", 1296 |config| { 1297 config.tls.cert_path = Some(PathBuf::from("tls/cert.pem")); 1298 config.tls.key_path = Some(PathBuf::from("tls/key.pem")); 1299 }, 1300 "tls.cert_path", 1301 ), 1302 ( 1303 "acme_cannot_combine_with_static", 1304 |config| { 1305 apply_acme(config); 1306 config.tls.cert_path = Some(PathBuf::from("/etc/knot/tls/cert.pem")); 1307 config.tls.key_path = Some(PathBuf::from("/etc/knot/tls/key.pem")); 1308 }, 1309 "cannot combine", 1310 ), 1311 ( 1312 "acme_without_a_cache_dir", 1313 |config| { 1314 apply_acme(config); 1315 config.tls.acme_cache_dir = None; 1316 }, 1317 "tls.acme_cache_dir is required", 1318 ), 1319 ( 1320 "acme_without_a_valid_contact", 1321 |config| { 1322 apply_acme(config); 1323 config.tls.acme_contact = Some("not-an-email".to_string()); 1324 }, 1325 "tls.acme_contact", 1326 ), 1327 ( 1328 "mtls_without_a_server_certificate", 1329 |config| { 1330 apply_mtls(config); 1331 config.tls.cert_path = None; 1332 config.tls.key_path = None; 1333 }, 1334 "tls.mtls_enabled requires a server certificate", 1335 ), 1336 ( 1337 "mtls_with_a_malformed_pin", 1338 |config| { 1339 apply_mtls(config); 1340 config.tls.mtls_admin_spki_pin = 1341 Some(base64::engine::general_purpose::STANDARD.encode([0u8; 16])); 1342 }, 1343 "tls.mtls_admin_spki_pin", 1344 ), 1345 ( 1346 "empty_admin_list", 1347 |config| config.server.admins = Vec::new(), 1348 "admins", 1349 ), 1350 ( 1351 "a_zero_maintenance_interval", 1352 |config| config.maintenance.interval_secs = 0, 1353 "maintenance.interval_secs", 1354 ), 1355 ( 1356 "a_zero_repack_object_limit", 1357 |config| config.maintenance.repack_max_objects = 0, 1358 "maintenance.repack_max_objects", 1359 ), 1360 ( 1361 "a_geometric_factor_below_two", 1362 |config| config.maintenance.repack_geometric_factor = 1, 1363 "maintenance.repack_geometric_factor", 1364 ), 1365 ( 1366 "a_zero_large_push_threshold", 1367 |config| config.maintenance.large_push_bytes = 0, 1368 "maintenance.large_push_bytes", 1369 ), 1370 ( 1371 "a_zero_pack_cache_ttl", 1372 |config| config.pack_cache.ttl_secs = 0, 1373 "pack_cache.ttl_secs", 1374 ), 1375 ( 1376 "a_zero_pack_cache_entry_limit", 1377 |config| config.pack_cache.max_entry_bytes = 0, 1378 "pack_cache.max_entry_bytes", 1379 ), 1380 ( 1381 "a_zero_pack_cache_total_limit", 1382 |config| config.pack_cache.max_total_bytes = 0, 1383 "pack_cache.max_total_bytes", 1384 ), 1385 ( 1386 "a_pack_cache_total_below_one_entry", 1387 |config| { 1388 config.pack_cache.max_entry_bytes = 1_000; 1389 config.pack_cache.max_total_bytes = 500; 1390 }, 1391 "at least pack_cache.max_entry_bytes", 1392 ), 1393 ( 1394 "relative_scan_path", 1395 |config| config.repo.scan_path = PathBuf::from("relative/git"), 1396 "scan_path", 1397 ), 1398 ( 1399 "a_relative_lfs_store_path", 1400 |config| config.lfs.store_path = Some(PathBuf::from("relative/lfs")), 1401 "lfs.store_path must be absolute path", 1402 ), 1403 ( 1404 "an_lfs_store_inside_the_scan_path", 1405 |config| config.lfs.store_path = Some(config.repo.scan_path.join("lfs")), 1406 "lfs.store_path mustn't overlap repo.scan_path", 1407 ), 1408 ( 1409 "a_scan_path_inside_the_lfs_store", 1410 |config| { 1411 config.lfs.store_path = Some(PathBuf::from("/srv/media")); 1412 config.repo.scan_path = PathBuf::from("/srv/media/git"); 1413 }, 1414 "lfs.store_path mustn't overlap repo.scan_path", 1415 ), 1416 ( 1417 "a_zero_lfs_object_limit", 1418 |config| config.lfs.max_object_bytes = 0, 1419 "lfs.max_object_bytes", 1420 ), 1421 ( 1422 "a_zero_lfs_gc_interval", 1423 |config| config.lfs.gc_interval_secs = 0, 1424 "lfs.gc_interval_secs", 1425 ), 1426 ( 1427 "a_zero_lfs_ssh_transfer_limit", 1428 |config| config.lfs.max_ssh_transfers = 0, 1429 "lfs.max_ssh_transfers", 1430 ), 1431 ( 1432 "a_zero_lfs_http_download_limit", 1433 |config| config.lfs.max_http_downloads = 0, 1434 "lfs.max_http_downloads", 1435 ), 1436 ( 1437 "bad_master_key_env_name", 1438 |config| config.secrets.master_key_env = "9 bad name".to_string(), 1439 "master_key_env", 1440 ), 1441 ( 1442 "zero_http_timeout", 1443 |config| config.http.request_timeout_ms = 0, 1444 "request_timeout_ms", 1445 ), 1446 ( 1447 "zero_tree_last_commit_budget", 1448 |config| config.xrpc.tree_last_commit_budget_ms = 0, 1449 "tree_last_commit_budget_ms", 1450 ), 1451 ( 1452 "zero_blob_last_commit_budget", 1453 |config| config.xrpc.blob_last_commit_budget_ms = 0, 1454 "blob_last_commit_budget_ms", 1455 ), 1456 ( 1457 "zero_languages_budget", 1458 |config| config.xrpc.languages_budget_ms = 0, 1459 "languages_budget_ms", 1460 ), 1461 ( 1462 "zero_languages_push_budget", 1463 |config| config.xrpc.languages_push_budget_ms = 0, 1464 "languages_push_budget_ms", 1465 ), 1466 ( 1467 "zero_events_replay_buffer", 1468 |config| config.xrpc.events_replay_buffer = 0, 1469 "events_replay_buffer", 1470 ), 1471 ( 1472 "zero_events_replay_bytes", 1473 |config| config.xrpc.events_replay_bytes = 0, 1474 "events_replay_bytes", 1475 ), 1476 ( 1477 "zero_events_max_subscribers", 1478 |config| config.xrpc.events_max_subscribers = 0, 1479 "events_max_subscribers", 1480 ), 1481 ( 1482 "zero_events_max_per_peer", 1483 |config| config.xrpc.events_max_per_peer = 0, 1484 "events_max_per_peer", 1485 ), 1486 ( 1487 "a_per_peer_limit_above_the_global_limit", 1488 |config| { 1489 config.xrpc.events_max_subscribers = 4; 1490 config.xrpc.events_max_per_peer = 8; 1491 }, 1492 "events_max_subscribers must be at least", 1493 ), 1494 ( 1495 "a_non_https_plc_directory", 1496 |config| { 1497 config.atproto.plc_directory = Url::parse("http://plc.nel.pet/").unwrap(); 1498 }, 1499 "plc_directory", 1500 ), 1501 ( 1502 "an_idle_timeout_below_the_header_timeout", 1503 |config| { 1504 config.server.listen_header_timeout_ms = 10_000; 1505 config.server.listen_idle_timeout_ms = 5_000; 1506 }, 1507 "listen_idle_timeout_ms must be at least", 1508 ), 1509 ( 1510 "colliding_bind_ports", 1511 |config| config.server.internal_listen_addr = config.server.listen_addr, 1512 "same port", 1513 ), 1514 ( 1515 "a_relative_homepage_path", 1516 |config| config.homepage.path = Some(PathBuf::from("homepage.html")), 1517 "homepage.path must be absolute path", 1518 ), 1519 ]; 1520 cases.iter().for_each(|(label, mutate, expected)| { 1521 let mut config = sample(); 1522 mutate(&mut config); 1523 let errors = config.validate().unwrap_err().errors; 1524 assert!( 1525 errors.iter().any(|error| error.contains(expected)), 1526 "{label}: expected an error containing {expected}, got {errors:?}" 1527 ); 1528 }); 1529 } 1530 1531 #[test] 1532 fn homepage_source_resolves_states() { 1533 let disabled = HomepageConfig { 1534 enabled: false, 1535 path: Some(PathBuf::from("/etc/knot/home.html")), 1536 }; 1537 assert!(matches!(disabled.source(), HomepageSource::Disabled)); 1538 1539 let default = HomepageConfig { 1540 enabled: true, 1541 path: None, 1542 }; 1543 assert!(matches!(default.source(), HomepageSource::Default)); 1544 1545 let file = HomepageConfig { 1546 enabled: true, 1547 path: Some(PathBuf::from("/etc/knot/home.html")), 1548 }; 1549 match file.source() { 1550 HomepageSource::File(path) => assert_eq!(path, PathBuf::from("/etc/knot/home.html")), 1551 other => panic!("expected File, got {other:?}"), 1552 } 1553 } 1554 1555 #[test] 1556 fn validates_master_key() { 1557 let short = base64::engine::general_purpose::STANDARD.encode([0u8; 16]); 1558 let key = base64::engine::general_purpose::STANDARD.encode([7u8; 32]); 1559 type Case<'a> = (Option<&'a str>, fn(&Result<(), EnvError>) -> bool); 1560 let cases: Vec<Case<'_>> = vec![ 1561 (None, |result| { 1562 matches!(result, Err(EnvError::MasterKeyUnset { .. })) 1563 }), 1564 (Some(" "), |result| { 1565 matches!(result, Err(EnvError::MasterKeyEmpty { .. })) 1566 }), 1567 (Some("not base64 *** value"), |result| { 1568 matches!(result, Err(EnvError::MasterKeyNotBase64 { .. })) 1569 }), 1570 (Some(short.as_str()), |result| { 1571 matches!(result, Err(EnvError::MasterKeyTooShort { .. })) 1572 }), 1573 (Some(key.as_str()), |result| result.is_ok()), 1574 ]; 1575 cases.iter().for_each(|(input, expect)| { 1576 assert!(expect(&validate_master_key("KNOT_MASTER_KEY", *input))); 1577 }); 1578 } 1579 1580 #[test] 1581 fn admins_parse_from_comma_separated_env() { 1582 let parsed = parse_admins("did:plc:nel, did:plc:olaren").unwrap(); 1583 assert_eq!(parsed.len(), 2); 1584 assert!(parse_admins("not-a-did").is_err()); 1585 } 1586 1587 #[test] 1588 fn http_limits_map_from_config() { 1589 let limits = sample().http_limits(); 1590 assert_eq!(limits.connect_timeout, Duration::from_millis(5_000)); 1591 assert_eq!(limits.request_timeout, Duration::from_millis(60_000)); 1592 assert_eq!(limits.max_response_bytes, 16_777_216); 1593 } 1594 1595 #[test] 1596 fn missing_dir_is_rejected() { 1597 let dir = tempfile::tempdir().unwrap(); 1598 let absent = dir.path().join("no-such-dir"); 1599 assert!(matches!( 1600 verify_writable_dir("repo.scan_path", &absent), 1601 Err(EnvError::DirInaccessible { .. }) 1602 )); 1603 } 1604 1605 #[test] 1606 fn file_in_place_of_dir_is_rejected() { 1607 let file = tempfile::NamedTempFile::new().unwrap(); 1608 assert!(matches!( 1609 verify_writable_dir("lfs.store_path", file.path()), 1610 Err(EnvError::DirNotDir { .. }) 1611 )); 1612 } 1613 1614 #[test] 1615 fn writable_dir_passes() { 1616 let dir = tempfile::tempdir().unwrap(); 1617 assert!(verify_writable_dir("repo.scan_path", dir.path()).is_ok()); 1618 } 1619 1620 #[test] 1621 fn verify_homepage_accepts_absent_and_default_sources() { 1622 assert!(verify_homepage(HomepageSource::Disabled).is_ok()); 1623 assert!(verify_homepage(HomepageSource::Default).is_ok()); 1624 } 1625 1626 #[test] 1627 fn verify_homepage_rejects_missing_file() { 1628 let dir = tempfile::tempdir().unwrap(); 1629 let absent = dir.path().join("no-such-page.html"); 1630 assert!(matches!( 1631 verify_homepage(HomepageSource::File(absent)), 1632 Err(EnvError::HomepageInaccessible { .. }) 1633 )); 1634 } 1635 1636 #[test] 1637 fn verify_homepage_rejects_directory() { 1638 let dir = tempfile::tempdir().unwrap(); 1639 assert!(matches!( 1640 verify_homepage(HomepageSource::File(dir.path().to_path_buf())), 1641 Err(EnvError::HomepageNotFile { .. }) 1642 )); 1643 } 1644 1645 #[test] 1646 fn verify_homepage_accepts_readable_file() { 1647 let file = tempfile::NamedTempFile::new().unwrap(); 1648 assert!(verify_homepage(HomepageSource::File(file.path().to_path_buf())).is_ok()); 1649 } 1650 1651 #[test] 1652 fn disabled_homepage_ignores_relative_path() { 1653 let mut config = sample(); 1654 config.homepage.enabled = false; 1655 config.homepage.path = Some(PathBuf::from("homepage.html")); 1656 assert!(config.validate().is_ok()); 1657 } 1658}