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