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