1use std::{
8 env::consts::OS,
9 fs::read_to_string,
10 net::{IpAddr, SocketAddr},
11 time::Duration,
12};
13
14use argon2::Error as Argon2Error;
15use either::Either;
16use http::HeaderValue;
17use itertools::Itertools;
18use regex::RegexSet;
19use url::Url;
20
21use super::{DEPRECATED_KEYS, IdentityProvider, IpSource, KNOWN_KEYS};
22use crate::{
23 Config, Err, Result, debug, debug_info, err, error,
24 utils::{
25 is_secret_set,
26 sys::storage::{Filesystem, filesystem_from_path},
27 time::timepoint_from_now,
28 },
29 warn,
30};
31
32const MAX_THUMBNAIL_CONCURRENCY: usize = 1024;
38
39pub fn reload(old: &Config, new: &Config) -> Result {
42 check(new)?;
43
44 if new.server_name != old.server_name {
45 return Err!(Config(
46 "server_name",
47 "You can't change the server's name from {:?}.",
48 old.server_name
49 ));
50 }
51
52 if new.ip_source != old.ip_source {
53 return Err!(Config(
54 "ip_source",
55 "ip_source cannot be changed at runtime; restart the server to apply this change."
56 ));
57 }
58
59 Ok(())
60}
61
62pub fn check(config: &Config) -> Result {
68 #[cfg(debug_assertions)]
69 warn!("Note: tuwunel was built without optimisations (i.e. debug build)");
70
71 warn_deprecated(config);
72 warn_legacy_state_local(config);
73 warn_literal_patterns(config);
74 warn_unknown_key(config)?;
75
76 check_observability(config)?;
77 check_network(config)?;
78 check_storage(config)?;
79 check_registration(config)?;
80 check_registration_terms(config)?;
81 check_profile_requests(config)?;
82 check_password_hashing(config)?;
83 check_jwt(config)?;
84 check_turn_and_media_misc(config)?;
85 check_url_previews(config)?;
86 check_room_version(config)?;
87 check_identity_providers(config)?;
88 warn_oidc_registration_token(config);
89 warn_oidc_client_approval(config);
90 check_media_providers(config)?;
91 check_well_known_support_contact_validity(config)?;
92 check_email(config)?;
93
94 Ok(())
95}
96
97fn warn_legacy_state_local(config: &Config) {
98 if config.resolve_state_locally_shadow {
99 warn!(
100 "resolve_state_locally_shadow is deprecated; use resolve_state_locally=false to \
101 disable local state derivation."
102 );
103 }
104}
105
106fn check_observability(config: &Config) -> Result {
107 if config.sentry && config.sentry_endpoint.is_none() {
108 return Err!(Config(
109 "sentry_endpoint",
110 "Sentry cannot be enabled without an endpoint set"
111 ));
112 }
113
114 if config.sentry && !(0.0..=1.0).contains(&config.sentry_traces_sample_rate) {
115 return Err!(Config(
116 "sentry_traces_sample_rate",
117 "Sentry traces sample rate must be between 0.0 and 1.0 inclusive"
118 ));
119 }
120
121 Ok(())
122}
123
124fn check_profile_requests(config: &Config) -> Result {
125 if config.limit_profile_requests_to_users_who_share_rooms
126 && !config.require_auth_for_profile_requests
127 {
128 return Err!(Config(
129 "limit_profile_requests_to_users_who_share_rooms",
130 "limit_profile_requests_to_users_who_share_rooms requires \
131 require_auth_for_profile_requests to be enabled"
132 ));
133 }
134
135 Ok(())
136}
137
138fn check_network(config: &Config) -> Result {
139 #[cfg(not(unix))]
140 if config.unix_socket_path.is_some() {
141 return Err!(Config(
142 "unix_socket_path",
143 "UNIX socket support is only available on *nix platforms. Please remove \
144 'unix_socket_path' from your config."
145 ));
146 }
147
148 let certs_set = config.tls.certs.is_some();
149 let key_set = config.tls.key.is_some();
150 if certs_set ^ key_set {
151 return Err!(Config("tls", "tls.certs and tls.key must either both be set or unset"));
152 }
153
154 let depth = config.conduit_media_directory_depth;
157 let length = config.conduit_media_directory_length;
158 if depth > 0 && length == 0 {
159 return Err!(Config(
160 "conduit_media_directory_length",
161 "must be non-zero when conduit_media_directory_depth is non-zero"
162 ));
163 }
164 if depth > 0 && usize::from(depth).saturating_mul(usize::from(length)) >= 64 {
165 return Err!(Config(
166 "conduit_media_directory_depth",
167 "conduit_media_directory_depth times conduit_media_directory_length must be less \
168 than 64, the length of a SHA-256 hex digest"
169 ));
170 }
171
172 if let Some(source) = config.ip_source
173 && !matches!(source, IpSource::ConnectInfo)
174 {
175 warn!(
176 "ip_source is set to {source:?}, a header-based source. Ensure a trusted reverse \
177 proxy populates this header for every request; otherwise clients can spoof their \
178 IP address."
179 );
180 }
181
182 if !config.listening {
183 warn!("Configuration item `listening` is set to `false`. Cannot hear anyone.");
184 }
185
186 if config.prev_events_concurrency == 0 {
187 return Err!(Config(
188 "prev_events_concurrency",
189 "Previous-event recovery concurrency must be at least one; zero stalls every \
190 incoming event with a backward extremity."
191 ));
192 }
193
194 if config.unix_socket_path.is_none() {
195 config
196 .get_bind_addrs()
197 .iter()
198 .for_each(warn_loopback_in_container);
199 }
200
201 for server in &config.dns_servers {
202 if server.parse::<SocketAddr>().is_err() && server.parse::<IpAddr>().is_err() {
203 return Err!(Config(
204 "dns_servers",
205 "{server:?} is not an IP address or socket address."
206 ));
207 }
208 }
209
210 for cidr in &config.ip_range_denylist {
212 if let Err(e) = ipaddress::IPAddress::parse(cidr) {
213 return Err!(Config(
214 "ip_range_denylist",
215 "Parsing specified IP CIDR range from string failed: {e}."
216 ));
217 }
218 }
219
220 Ok(())
221}
222
223fn warn_loopback_in_container(addr: &SocketAddr) {
224 use std::path::Path;
225
226 if !addr.ip().is_loopback() {
227 return;
228 }
229
230 debug_info!(
231 "Found loopback listening address {addr}, running checks if we're in a container."
232 );
233
234 if Path::new("/proc/vz").exists() && !Path::new("/proc/bz").exists()
235 {
237 error!(
238 "You are detected using OpenVZ with a loopback/localhost listening address of \
239 {addr}. If you are using OpenVZ for containers and you use NAT-based networking to \
240 communicate with the host and guest, this will NOT work. Please change this to \
241 \"0.0.0.0\". If this is expected, you can ignore.",
242 );
243 } else if Path::new("/.dockerenv").exists() {
244 error!(
245 "You are detected using Docker with a loopback/localhost listening address of \
246 {addr}. If you are using a reverse proxy on the host and require communication to \
247 tuwunel in the Docker container via NAT-based networking, this will NOT work. \
248 Please change this to \"0.0.0.0\". If this is expected, you can ignore.",
249 );
250 } else if Path::new("/run/.containerenv").exists() {
251 error!(
252 "You are detected using Podman with a loopback/localhost listening address of \
253 {addr}. If you are using a reverse proxy on the host and require communication to \
254 tuwunel in the Podman container via NAT-based networking, this will NOT work. \
255 Please change this to \"0.0.0.0\". If this is expected, you can ignore.",
256 );
257 }
258}
259
260fn check_storage(config: &Config) -> Result {
261 if config.rocksdb_max_log_files == 0 {
263 return Err!(Config(
264 "max_log_files",
265 "rocksdb_max_log_files cannot be 0. Please set a value at least 1."
266 ));
267 }
268
269 if config.rocksdb_allow_fallocate
270 && let Some(filesystem) = database_filesystem(config)
271 {
272 warn!(
273 %filesystem,
274 "database_path is on a Copy-on-Write filesystem, where preallocating write-ahead \
275 logs cannot reserve write space and can pin far more disk than the logs contain. \
276 Set rocksdb_allow_fallocate = false."
277 );
278 }
279
280 #[cfg(not(debug_assertions))]
282 if config.server_name == "your.server.name" {
283 return Err!(Config(
284 "server_name",
285 "You must specify a valid server name for production usage of tuwunel."
286 ));
287 }
288
289 Ok(())
290}
291
292fn database_filesystem(config: &Config) -> Option<Filesystem> {
297 config
298 .database_path
299 .ancestors()
300 .map(filesystem_from_path)
301 .find_map(Result::ok)
302 .flatten()
303}
304
305fn check_registration(config: &Config) -> Result {
306 if config
307 .emergency_password
308 .as_ref()
309 .is_some_and(|emergency_password| emergency_password == "F670$2CP@Hw8mG7RY1$%!#Ic7YA")
310 {
311 return Err!(Config(
312 "emergency_password",
313 "The public example emergency password is being used, this is insecure. Please \
314 change this."
315 ));
316 }
317
318 if config
319 .emergency_password
320 .as_ref()
321 .is_some_and(String::is_empty)
322 {
323 return Err!(Config(
324 "emergency_password",
325 "Emergency password was set to an empty string, this is not valid. Unset \
326 emergency_password to disable it or set it to a real password."
327 ));
328 }
329
330 if config
331 .registration_token
332 .as_ref()
333 .is_some_and(String::is_empty)
334 {
335 return Err!(Config(
336 "registration_token",
337 "Registration token was specified but is empty (\"\")"
338 ));
339 }
340
341 if config
343 .registration_token_file
344 .as_ref()
345 .is_some_and(|path| {
346 let Ok(token) = read_to_string(path).inspect_err(|e| {
347 error!("Failed to read the registration token file: {e}");
348 }) else {
349 return true;
350 };
351
352 token == String::new()
353 }) {
354 return Err!(Config(
355 "registration_token_file",
356 "Registration token file was specified but is empty or failed to be read"
357 ));
358 }
359
360 let no_token =
361 config.registration_token.is_none() && config.registration_token_file.is_none();
362
363 if config.allow_registration
364 && no_token
365 && !config.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
366 {
367 return Err!(Config(
368 "registration_token",
369 "!! You have `allow_registration` enabled without a token configured in your config \
370 which means you are allowing ANYONE to register on your tuwunel instance without \
371 any 2nd-step (e.g. registration token). If this is not the intended behaviour, \
372 please set a registration token. For security and safety reasons, tuwunel will \
373 shut down. If you are extra sure this is the desired behaviour you want, please \
374 set the following config option to true:
375`yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse`"
376 ));
377 }
378
379 if config.allow_registration
380 && no_token
381 && config.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
382 {
383 warn!(
384 "Open registration is enabled via setting \
385 `yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse` and \
386 `allow_registration` to true without a registration token configured. You are \
387 expected to be aware of the risks now. If this is not the desired behaviour, \
388 please set a registration token."
389 );
390 }
391
392 Ok(())
393}
394
395fn check_registration_terms(config: &Config) -> Result {
396 for (id, policy) in &config.registration_terms {
397 let opaque = !id.is_empty()
398 && id.len() <= 255
399 && id.bytes().all(
400 |b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'.' | b'_' | b'~' | b'-'),
401 );
402
403 if !opaque {
404 return Err!(Config(
405 "registration_terms",
406 "Policy id {id:?} must be a non-empty opaque identifier of at most 255 \
407 characters from [0-9a-zA-Z._~-]."
408 ));
409 }
410
411 for (lang, translation) in &policy.translations {
412 if !matches!(translation.url.scheme(), "http" | "https") {
413 return Err!(Config(
414 "registration_terms",
415 "Policy {id:?} translation {lang:?} url must use the http or https scheme."
416 ));
417 }
418 }
419 }
420
421 Ok(())
422}
423
424fn check_password_hashing(config: &Config) -> Result {
425 let cost = config.password_hash_cost();
426
427 cost.check().map_err(|e| match e {
428 | Argon2Error::TimeTooSmall => err!(Config("argon2_t_cost", "{e}")),
429 | Argon2Error::ThreadsTooFew | Argon2Error::ThreadsTooMany => {
430 err!(Config("argon2_p_cost", "{e}"))
431 },
432 | Argon2Error::MemoryTooLittle => err!(Config(
433 "argon2_m_cost",
434 "{e}; it needs at least eight blocks per lane of argon2_p_cost"
435 )),
436 | _ => err!(Config("argon2_m_cost", "{e}")),
437 })?;
438
439 if !cost.is_recommended() {
440 warn!(
441 ?cost,
442 "The Argon2id cost is below the weakest OWASP recommendation, so new password \
443 hashes are cheaper to crack than the default. See argon2_m_cost for the \
444 recommended pairs."
445 );
446 }
447
448 Ok(())
449}
450
451fn check_jwt(config: &Config) -> Result {
452 let jwt = &config.jwt;
453
454 if jwt.enable {
456 jwt.key_format()?;
457 }
458
459 Ok(())
460}
461
462fn check_turn_and_media_misc(config: &Config) -> Result {
463 if !config.turn_uris.is_empty()
465 && !is_secret_set(config.turn_secret_file.as_deref(), config.turn_secret.as_deref())
466 && config.turn_username.is_empty()
467 && config.turn_password.is_empty()
468 {
469 warn!(
470 "turn_uris is configured but no credential source is set; the endpoint \
471 /_matrix/client/v3/voip/turnServer will return empty username and password. Set \
472 turn_secret, turn_secret_file, or both turn_username and turn_password."
473 );
474 }
475
476 if config.max_request_size < 10_000_000 {
477 return Err!(Config(
478 "max_request_size",
479 "Max request size is less than 10MB. Please increase it as this is too low for \
480 operable federation."
481 ));
482 }
483
484 if config.allow_outgoing_presence && !config.allow_local_presence {
485 return Err!(Config(
486 "allow_local_presence",
487 "Outgoing presence requires allowing local presence. Please enable \
488 'allow_local_presence' or disable outgoing presence."
489 ));
490 }
491
492 if config.suppress_push_when_active {
493 warn!(
494 "Push suppression when active is enabled (EXPERIMENTAL): behavior may change or be \
495 unstable. Disable by removing or setting suppress_push_when_active to false."
496 );
497 }
498
499 check_thumbnails(config)?;
500 check_video_thumbnails(config)
501}
502
503fn check_thumbnails(config: &Config) -> Result {
504 if config.media_thumbnail_max_pixels == 0 {
505 return Err!(Config(
506 "media_thumbnail_max_pixels",
507 "A pixel budget of zero refuses every picture; remove the setting to take the \
508 default."
509 ));
510 }
511
512 if unusable_concurrency(config.media_thumbnail_animated_concurrency) {
513 return Err!(Config(
514 "media_thumbnail_animated_concurrency",
515 "Animated thumbnail source jobs permitted at once must be between 1 and \
516 {MAX_THUMBNAIL_CONCURRENCY}: zero leaves every source read waiting for a slot that \
517 never frees, and the ceiling is far past any useful degree of parallelism."
518 ));
519 }
520
521 Ok(())
522}
523
524fn check_video_thumbnails(config: &Config) -> Result {
525 if unusable_concurrency(config.media_video_thumbnail_concurrency) {
526 return Err!(Config(
527 "media_video_thumbnail_concurrency",
528 "Video thumbnail programs permitted at once must be between 1 and \
529 {MAX_THUMBNAIL_CONCURRENCY}: zero leaves every extraction waiting for a slot that \
530 never frees, and the ceiling is far past any useful degree of parallelism."
531 ));
532 }
533
534 if config.media_video_thumbnail_timeout == 0 {
535 return Err!(Config(
536 "media_video_thumbnail_timeout",
537 "A video thumbnail deadline of zero expires before the program can start."
538 ));
539 }
540
541 Ok(())
542}
543
544fn unusable_concurrency(slots: usize) -> bool {
549 !(1..=MAX_THUMBNAIL_CONCURRENCY).contains(&slots)
550}
551
552fn check_url_previews(config: &Config) -> Result {
553 let wildcard = "*".to_owned();
554 let url_preview_wildcards = [
555 (
556 "url_preview_domain_contains_allowlist",
557 &config.url_preview_domain_contains_allowlist,
558 ),
559 (
560 "url_preview_domain_explicit_allowlist",
561 &config.url_preview_domain_explicit_allowlist,
562 ),
563 ("url_preview_url_contains_allowlist", &config.url_preview_url_contains_allowlist),
564 ];
565
566 for (name, list) in url_preview_wildcards {
567 if list.contains(&wildcard) {
568 warn!(
569 "All URLs are allowed for URL previews via setting \"{name}\" to \"*\". This \
570 opens up significant attack surface to your server. You are expected to be \
571 aware of the risks by doing this."
572 );
573 }
574 }
575
576 if let Some(Either::Right(_)) = config.url_preview_bound_interface.as_ref()
577 && !matches!(OS, "android" | "fuchsia" | "linux")
578 {
579 return Err!(Config(
580 "url_preview_bound_interface",
581 "Not a valid IP address. Interface names not supported on {OS}."
582 ));
583 }
584
585 if let Some(user_agent) = config.url_preview_user_agent.as_deref()
586 && HeaderValue::from_str(user_agent).is_err()
587 {
588 return Err!(Config("url_preview_user_agent", "Not a valid HTTP header value."));
589 }
590
591 if let Some(user_agent) = config.url_preview_media_user_agent.as_deref()
592 && HeaderValue::from_str(user_agent).is_err()
593 {
594 return Err!(Config("url_preview_media_user_agent", "Not a valid HTTP header value."));
595 }
596
597 if let Some(accept_language) = config.url_preview_accept_language.as_deref()
598 && HeaderValue::from_str(accept_language).is_err()
599 {
600 return Err!(Config("url_preview_accept_language", "Not a valid HTTP header value."));
601 }
602
603 timepoint_from_now(Duration::from_secs(config.url_preview_cache_ttl)).map_err(|_| {
604 err!(Config(
605 "url_preview_cache_ttl",
606 "Value is too large to add to the current time."
607 ))
608 })?;
609
610 Ok(())
611}
612
613fn check_room_version(config: &Config) -> Result {
614 if !config.supported_room_version(&config.default_room_version) {
615 return Err!(Config(
616 "default_room_version",
617 "Room version {:?} is not available",
618 config.default_room_version
619 ));
620 }
621
622 if config
623 .default_power_level_content_override
624 .as_ref()
625 .is_some_and(|value| !value.is_object())
626 {
627 return Err!(Config(
628 "default_power_level_content_override",
629 "must be a table (a JSON object)"
630 ));
631 }
632
633 Ok(())
634}
635
636fn check_identity_providers(config: &Config) -> Result {
637 for a in config.identity_provider.values() {
638 let count = config
639 .identity_provider
640 .values()
641 .filter(|b| a.id().eq(b.id()))
642 .count();
643
644 debug_assert_ne!(count, 0, "expected at least one identity_provider");
645 if count > 1 {
646 return Err!(Config(
647 "client_id",
648 "Duplicate identity_provider with client_id {}",
649 a.client_id
650 ));
651 }
652 }
653
654 for (i, provider) in &config.identity_provider {
655 check_identity_provider_secret(i, provider)?;
656 }
657
658 if !config.sso_custom_providers_page
659 && config.identity_provider.len() > 1
660 && config
661 .identity_provider
662 .values()
663 .filter(|idp| idp.default)
664 .count()
665 .eq(&0)
666 {
667 let default = config
668 .identity_provider
669 .values()
670 .next()
671 .map(IdentityProvider::id)
672 .expect("Check at least one provider is configured to reach here");
673
674 warn!(
675 "More than one identity_provider has been configured without any default selected. \
676 To prevent this warning set `default = true` for one provider. Considering \
677 {default} the default for now..."
678 );
679 }
680
681 let mas_active = config
682 .mas_secret
683 .as_deref()
684 .is_some_and(|secret| !secret.is_empty());
685
686 if mas_active
687 && !config
688 .identity_provider
689 .values()
690 .any(|provider| provider.brand == "mas")
691 {
692 warn!(
693 "mas_secret is set but no identity_provider is configured with `brand = MAS`. \
694 Tuwunel is its own OpenID Connect issuer and does not delegate authentication to \
695 MAS; the secret only authorizes MAS provisioning calls on `/_synapse/mas/`. \
696 Logging in through MAS additionally requires an identity_provider entry with \
697 `brand = MAS`."
698 );
699 }
700
701 if mas_active {
702 config
703 .identity_provider
704 .values()
705 .filter(|provider| provider.brand == "mas" && !provider.trusted)
706 .for_each(|provider| {
707 warn!(
708 provider = provider.id(),
709 "`mas_secret` is set and this MAS identity provider is configured without \
710 `trusted = true`. Existing accounts provisioned by MAS will not be matched \
711 automatically during SSO login, so users may receive separate accounts. \
712 Set `trusted = true` only when this identity provider is the same \
713 self-hosted MAS instance that provisions this server and you fully control \
714 it; otherwise associate users explicitly."
715 );
716 });
717 }
718
719 Ok(())
720}
721
722fn check_identity_provider_secret(i: &str, provider: &IdentityProvider) -> Result {
723 if provider.client_secret.is_some() {
724 return Ok(());
725 }
726
727 let Some(secret_path) = &provider.client_secret_file else {
728 return Err!(Config(
729 "client_secret",
730 "Either client secret or a client secret file must be set on identity provider №{i}."
731 ));
732 };
733
734 let secret = read_to_string(secret_path).map_err(|e| {
735 err!(Config(
736 "client_secret_file",
737 "Failed to read client secret file {secret_path:?} on identity provider №{i}: {e}"
738 ))
739 })?;
740
741 if secret.trim().is_empty() {
742 return Err!(Config(
743 "client_secret_file",
744 "Client secret file {secret_path:?} is empty on identity provider №{i}"
745 ));
746 }
747
748 Ok(())
749}
750
751fn warn_oidc_client_approval(config: &Config) {
752 if !config.oidc_require_client_approval {
753 warn!(
754 "oidc_require_client_approval is disabled, so an authorization code is issued \
755 without asking the user, whichever client requested it. Anyone who can reach \
756 dynamic client registration can then register a client with a redirect target they \
757 control and phish a sign-in link. Prefer listing the clients you serve in \
758 oidc_registration_allowed_redirect_hosts, which waives the prompt for them alone."
759 );
760 }
761}
762
763fn warn_oidc_registration_token(config: &Config) {
764 if !config.oidc_registration_access_token.is_empty() {
765 warn!(
766 "oidc_registration_access_token is set, so dynamic client registration requires an \
767 RFC 7591 initial access token. No Matrix client sends one, so next-gen auth login \
768 will fail with `M_FORBIDDEN` for every ordinary client. Leave it empty unless \
769 every OAuth client on this server is registered out of band."
770 );
771 }
772}
773
774fn check_media_providers(config: &Config) -> Result {
775 for provider in &config.store_media_on_providers {
776 if !config.media_storage_providers.contains(provider) {
777 return Err!(Config(
778 "store_media_on_providers",
779 "Providers must be listed in 'media_storage_providers'"
780 ));
781 }
782 }
783
784 if config
785 .media_storage_providers
786 .iter()
787 .filter(|&provider| {
788 if config.storage_provider.contains_key(provider) || provider == "media" {
789 return false;
790 }
791
792 error!("`media_storage_providers` references non-existent provider {provider:?}");
793 true
794 })
795 .count()
796 .gt(&0)
797 {
798 return Err!(Config(
799 "media_storage_providers",
800 "Contains missing or unconfigured storage providers."
801 ));
802 }
803
804 if config.media_storage_providers.len() > 1 && config.store_media_on_providers.is_empty() {
805 warn!(
806 "Media will be duplicated to multiple providers {:?} until \
807 `store_media_on_providers` is configured. This warning can be suppressed by \
808 explicitly configuring `store_media_on_providers`",
809 config.media_storage_providers
810 );
811 }
812
813 Ok(())
814}
815
816fn check_well_known_support_contact_validity(config: &Config) -> Result {
817 let well_known = &config.well_known;
818
819 if well_known.support_role.is_some()
820 && well_known.support_email.is_none()
821 && well_known.support_mxid.is_none()
822 {
823 return Err!(
824 "well_known.support_role is set but neither support_email nor support_mxid is \
825 configured to accompany it"
826 );
827 }
828
829 if let Some(pgp_key) = well_known.support_pgp_key.as_deref() {
830 validate_pgp_key(pgp_key).map_err(|e| err!("well_known.support_pgp_key: {e}"))?;
831 }
832
833 for (id, contact) in &well_known.support_contact {
834 if contact.email_address.is_none() && contact.matrix_id.is_none() {
835 return Err!(
836 "well_known.support_contact.{id} has neither email_address nor matrix_id; at \
837 least one is required"
838 );
839 }
840
841 if let Some(pgp_key) = contact.pgp_key.as_deref() {
842 validate_pgp_key(pgp_key)
843 .map_err(|e| err!("well_known.support_contact.{id}.pgp_key: {e}"))?;
844 }
845 }
846
847 Ok(())
848}
849
850fn check_email(config: &Config) -> Result {
851 let smtp = &config.smtp;
852
853 if smtp.connection_uri.is_some() && config.well_known.client.is_none() {
854 return Err!(Config(
855 "well_known.client",
856 "global.smtp is configured but well_known.client is unset. Email verification links \
857 are built from the public client base URL, so set well_known.client to a valid \
858 HTTPS URL alongside global.smtp."
859 ));
860 }
861
862 if smtp.connection_uri.is_none()
863 && (smtp.require_email_for_registration || smtp.require_email_for_token_registration)
864 {
865 return Err!(Config(
866 "smtp.connection_uri",
867 "global.smtp requires a verified email at registration but smtp.connection_uri is \
868 unset. Set smtp.connection_uri so verification mail can be sent, or unset \
869 require_email_for_registration and require_email_for_token_registration."
870 ));
871 }
872
873 Ok(())
874}
875
876fn validate_pgp_key(value: &str) -> Result {
878 if value.contains("BEGIN PGP") {
879 return Err!(
880 "must be a URI, not inlined key material; publish the key and reference it by URI \
881 (for example https://example.com/key.asc or openpgp4fpr:<fingerprint>)"
882 );
883 }
884
885 let uri = Url::parse(value).map_err(|_| {
886 err!("must be a URI; a bare fingerprint must be prefixed with `openpgp4fpr:`")
887 })?;
888
889 if uri.scheme() == "openpgp4fpr" && !valid_openpgp4fpr(uri.path()) {
890 return Err!("`openpgp4fpr:` must be followed by a 40- or 64-character hex fingerprint");
891 }
892
893 Ok(())
894}
895
896fn valid_openpgp4fpr(fpr: &str) -> bool {
897 matches!(fpr.len(), 40 | 64) && fpr.bytes().all(|b| b.is_ascii_hexdigit())
898}
899
900fn warn_deprecated(config: &Config) {
903 debug!("Checking for deprecated config keys");
904 let found_deprecated_keys = config
905 .catchall
906 .keys()
907 .filter(|key| DEPRECATED_KEYS.iter().any(|s| s == key))
908 .inspect(|key| warn!("Config parameter \"{key}\" is deprecated, ignoring."))
909 .next()
910 .is_some();
911
912 if found_deprecated_keys {
913 warn!(
914 "Deprecated config keys were found. Read tuwunel config documentation at https://tuwunel.chat/configuration.html and \
915 check your configuration if any new configuration parameters should be adjusted"
916 );
917 }
918}
919
920fn warn_literal_patterns(config: &Config) {
928 let options = [
929 ("dns_passthru_domains", &config.dns_passthru_domains),
930 (
931 "allowed_remote_server_names_experimental",
932 &config.allowed_remote_server_names_experimental,
933 ),
934 ("prevent_media_downloads_from", &config.prevent_media_downloads_from),
935 ("forbidden_remote_server_names", &config.forbidden_remote_server_names),
936 (
937 "forbidden_remote_room_directory_server_names",
938 &config.forbidden_remote_room_directory_server_names,
939 ),
940 ("forbidden_alias_names", &config.forbidden_alias_names),
941 ("forbidden_usernames", &config.forbidden_usernames),
942 ("deprioritize_joins_through_servers", &config.deprioritize_joins_through_servers),
943 ];
944
945 options
946 .into_iter()
947 .flat_map(|(name, regexes)| {
948 regexes
949 .patterns()
950 .iter()
951 .map(move |pattern| (name, pattern.as_str()))
952 })
953 .filter(|(_, pattern)| is_literal_dotted(pattern))
954 .for_each(warn_literal_pattern);
955}
956
957fn is_literal_dotted(pattern: &str) -> bool {
965 let nameable =
966 |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '=' | '/');
967
968 pattern.contains('.')
969 && pattern.chars().any(|c| c.is_ascii_alphanumeric())
970 && pattern.chars().all(nameable)
971}
972
973fn warn_literal_pattern((name, pattern): (&str, &str)) {
974 let escaped = pattern.replace('.', "\\.");
975
976 warn!(
979 "Config option {name:?} holds the pattern {pattern:?}, which is a regular expression: \
980 its unescaped dots match any character, so it matches more than the name it reads as. \
981 To match the dots literally, write it as {escaped:?}, quoted exactly as shown."
982 );
983}
984
985fn warn_unknown_key(config: &Config) -> Result {
988 debug!("Checking for unknown config keys");
989 let known_keys =
990 RegexSet::new(KNOWN_KEYS).expect("Invalid regular expression set construction");
991
992 let unknown_keys = config
993 .catchall
994 .keys()
995 .filter(|key| !known_keys.is_match(key))
996 .inspect(|key| {
997 if config.error_on_unknown_config_opts {
998 error!("Config parameter \"{key}\" is unknown to tuwunel");
999 } else {
1000 warn!("Config parameter \"{key}\" is unknown to tuwunel, ignoring.");
1001 }
1002 })
1003 .collect_vec();
1004
1005 if !unknown_keys.is_empty() && config.error_on_unknown_config_opts {
1006 Err!("Unknown config options were found: {unknown_keys:?}")
1007 } else {
1008 Ok(())
1009 }
1010}