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