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