This repository has no description
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}