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