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