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