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