1use std::{
8 env::consts::OS,
9 fs::read_to_string,
10 net::{IpAddr, SocketAddr},
11};
12
13use either::Either;
14use http::HeaderValue;
15use itertools::Itertools;
16use regex::RegexSet;
17use url::Url;
18
19use super::{DEPRECATED_KEYS, IdentityProvider, IpSource, KNOWN_KEYS};
20use crate::{Config, Err, Result, debug, debug_info, err, error, utils::is_secret_set, warn};
21
22pub fn reload(old: &Config, new: &Config) -> Result {
25 check(new)?;
26
27 if new.server_name != old.server_name {
28 return Err!(Config(
29 "server_name",
30 "You can't change the server's name from {:?}.",
31 old.server_name
32 ));
33 }
34
35 if new.ip_source != old.ip_source {
36 return Err!(Config(
37 "ip_source",
38 "ip_source cannot be changed at runtime; restart the server to apply this change."
39 ));
40 }
41
42 Ok(())
43}
44
45pub fn check(config: &Config) -> Result {
51 #[cfg(debug_assertions)]
52 warn!("Note: tuwunel was built without optimisations (i.e. debug build)");
53
54 warn_deprecated(config);
55 warn_unknown_key(config)?;
56
57 #[cfg(all(
58 feature = "hardened_malloc",
59 feature = "jemalloc",
60 not(target_env = "msvc")
61 ))]
62 debug_warn!(
63 "hardened_malloc and jemalloc compile-time features are both enabled, this causes \
64 jemalloc to be used."
65 );
66
67 check_observability(config)?;
68 check_network(config)?;
69 check_storage(config)?;
70 check_registration(config)?;
71 check_registration_terms(config)?;
72 check_turn_and_media_misc(config)?;
73 check_url_previews(config)?;
74 check_room_version(config)?;
75 check_identity_providers(config)?;
76 check_media_providers(config)?;
77 check_well_known_support_contact_validity(config)?;
78 check_email(config)?;
79
80 Ok(())
81}
82
83fn check_observability(config: &Config) -> Result {
84 if config.sentry && config.sentry_endpoint.is_none() {
85 return Err!(Config(
86 "sentry_endpoint",
87 "Sentry cannot be enabled without an endpoint set"
88 ));
89 }
90
91 Ok(())
92}
93
94fn check_network(config: &Config) -> Result {
95 #[cfg(not(unix))]
96 if config.unix_socket_path.is_some() {
97 return Err!(Config(
98 "unix_socket_path",
99 "UNIX socket support is only available on *nix platforms. Please remove \
100 'unix_socket_path' from your config."
101 ));
102 }
103
104 let certs_set = config.tls.certs.is_some();
105 let key_set = config.tls.key.is_some();
106 if certs_set ^ key_set {
107 return Err!(Config("tls", "tls.certs and tls.key must either both be set or unset"));
108 }
109
110 let depth = config.conduit_media_directory_depth;
113 let length = config.conduit_media_directory_length;
114 if depth > 0 && length == 0 {
115 return Err!(Config(
116 "conduit_media_directory_length",
117 "must be non-zero when conduit_media_directory_depth is non-zero"
118 ));
119 }
120 if depth > 0 && usize::from(depth).saturating_mul(usize::from(length)) >= 64 {
121 return Err!(Config(
122 "conduit_media_directory_depth",
123 "conduit_media_directory_depth times conduit_media_directory_length must be less \
124 than 64, the length of a SHA-256 hex digest"
125 ));
126 }
127
128 if let Some(source) = config.ip_source
129 && !matches!(source, IpSource::ConnectInfo)
130 {
131 warn!(
132 "ip_source is set to {source:?}, a header-based source. Ensure a trusted reverse \
133 proxy populates this header for every request; otherwise clients can spoof their \
134 IP address."
135 );
136 }
137
138 if !config.listening {
139 warn!("Configuration item `listening` is set to `false`. Cannot hear anyone.");
140 }
141
142 if config.unix_socket_path.is_none() {
143 config
144 .get_bind_addrs()
145 .iter()
146 .for_each(warn_loopback_in_container);
147 }
148
149 for server in &config.dns_servers {
150 if server.parse::<SocketAddr>().is_err() && server.parse::<IpAddr>().is_err() {
151 return Err!(Config(
152 "dns_servers",
153 "{server:?} is not an IP address or socket address."
154 ));
155 }
156 }
157
158 for cidr in &config.ip_range_denylist {
160 if let Err(e) = ipaddress::IPAddress::parse(cidr) {
161 return Err!(Config(
162 "ip_range_denylist",
163 "Parsing specified IP CIDR range from string failed: {e}."
164 ));
165 }
166 }
167
168 Ok(())
169}
170
171fn warn_loopback_in_container(addr: &SocketAddr) {
172 use std::path::Path;
173
174 if !addr.ip().is_loopback() {
175 return;
176 }
177
178 debug_info!(
179 "Found loopback listening address {addr}, running checks if we're in a container."
180 );
181
182 if Path::new("/proc/vz").exists() && !Path::new("/proc/bz").exists()
183 {
185 error!(
186 "You are detected using OpenVZ with a loopback/localhost listening address of \
187 {addr}. If you are using OpenVZ for containers and you use NAT-based networking to \
188 communicate with the host and guest, this will NOT work. Please change this to \
189 \"0.0.0.0\". If this is expected, you can ignore.",
190 );
191 } else if Path::new("/.dockerenv").exists() {
192 error!(
193 "You are detected using Docker with a loopback/localhost listening address of \
194 {addr}. If you are using a reverse proxy on the host and require communication to \
195 tuwunel in the Docker container via NAT-based networking, this will NOT work. \
196 Please change this to \"0.0.0.0\". If this is expected, you can ignore.",
197 );
198 } else if Path::new("/run/.containerenv").exists() {
199 error!(
200 "You are detected using Podman with a loopback/localhost listening address of \
201 {addr}. If you are using a reverse proxy on the host and require communication to \
202 tuwunel in the Podman container via NAT-based networking, this will NOT work. \
203 Please change this to \"0.0.0.0\". If this is expected, you can ignore.",
204 );
205 }
206}
207
208fn check_storage(config: &Config) -> Result {
209 if config.rocksdb_max_log_files == 0 {
211 return Err!(Config(
212 "max_log_files",
213 "rocksdb_max_log_files cannot be 0. Please set a value at least 1."
214 ));
215 }
216
217 #[cfg(not(debug_assertions))]
219 if config.server_name == "your.server.name" {
220 return Err!(Config(
221 "server_name",
222 "You must specify a valid server name for production usage of tuwunel."
223 ));
224 }
225
226 Ok(())
227}
228
229fn check_registration(config: &Config) -> Result {
230 if config
231 .emergency_password
232 .as_ref()
233 .is_some_and(|emergency_password| emergency_password == "F670$2CP@Hw8mG7RY1$%!#Ic7YA")
234 {
235 return Err!(Config(
236 "emergency_password",
237 "The public example emergency password is being used, this is insecure. Please \
238 change this."
239 ));
240 }
241
242 if config
243 .emergency_password
244 .as_ref()
245 .is_some_and(String::is_empty)
246 {
247 return Err!(Config(
248 "emergency_password",
249 "Emergency password was set to an empty string, this is not valid. Unset \
250 emergency_password to disable it or set it to a real password."
251 ));
252 }
253
254 if config
255 .registration_token
256 .as_ref()
257 .is_some_and(String::is_empty)
258 {
259 return Err!(Config(
260 "registration_token",
261 "Registration token was specified but is empty (\"\")"
262 ));
263 }
264
265 if config
267 .registration_token_file
268 .as_ref()
269 .is_some_and(|path| {
270 let Ok(token) = read_to_string(path).inspect_err(|e| {
271 error!("Failed to read the registration token file: {e}");
272 }) else {
273 return true;
274 };
275
276 token == String::new()
277 }) {
278 return Err!(Config(
279 "registration_token_file",
280 "Registration token file was specified but is empty or failed to be read"
281 ));
282 }
283
284 let no_token =
285 config.registration_token.is_none() && config.registration_token_file.is_none();
286
287 if config.allow_registration
288 && no_token
289 && !config.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
290 {
291 return Err!(Config(
292 "registration_token",
293 "!! You have `allow_registration` enabled without a token configured in your config \
294 which means you are allowing ANYONE to register on your tuwunel instance without \
295 any 2nd-step (e.g. registration token). If this is not the intended behaviour, \
296 please set a registration token. For security and safety reasons, tuwunel will \
297 shut down. If you are extra sure this is the desired behaviour you want, please \
298 set the following config option to true:
299`yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse`"
300 ));
301 }
302
303 if config.allow_registration
304 && no_token
305 && config.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
306 {
307 warn!(
308 "Open registration is enabled via setting \
309 `yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse` and \
310 `allow_registration` to true without a registration token configured. You are \
311 expected to be aware of the risks now. If this is not the desired behaviour, \
312 please set a registration token."
313 );
314 }
315
316 Ok(())
317}
318
319fn check_registration_terms(config: &Config) -> Result {
320 for (id, policy) in &config.registration_terms {
321 let opaque = !id.is_empty()
322 && id.len() <= 255
323 && id.bytes().all(
324 |b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'.' | b'_' | b'~' | b'-'),
325 );
326
327 if !opaque {
328 return Err!(Config(
329 "registration_terms",
330 "Policy id {id:?} must be a non-empty opaque identifier of at most 255 \
331 characters from [0-9a-zA-Z._~-]."
332 ));
333 }
334
335 for (lang, translation) in &policy.translations {
336 if !matches!(translation.url.scheme(), "http" | "https") {
337 return Err!(Config(
338 "registration_terms",
339 "Policy {id:?} translation {lang:?} url must use the http or https scheme."
340 ));
341 }
342 }
343 }
344
345 Ok(())
346}
347
348fn check_turn_and_media_misc(config: &Config) -> Result {
349 if !config.turn_uris.is_empty()
351 && !is_secret_set(config.turn_secret_file.as_deref(), config.turn_secret.as_deref())
352 && config.turn_username.is_empty()
353 && config.turn_password.is_empty()
354 {
355 warn!(
356 "turn_uris is configured but no credential source is set; the endpoint \
357 /_matrix/client/v3/voip/turnServer will return empty username and password. Set \
358 turn_secret, turn_secret_file, or both turn_username and turn_password."
359 );
360 }
361
362 if config.max_request_size < 10_000_000 {
363 return Err!(Config(
364 "max_request_size",
365 "Max request size is less than 10MB. Please increase it as this is too low for \
366 operable federation."
367 ));
368 }
369
370 if config.allow_outgoing_presence && !config.allow_local_presence {
371 return Err!(Config(
372 "allow_local_presence",
373 "Outgoing presence requires allowing local presence. Please enable \
374 'allow_local_presence' or disable outgoing presence."
375 ));
376 }
377
378 if config.suppress_push_when_active {
379 warn!(
380 "Push suppression when active is enabled (EXPERIMENTAL): behavior may change or be \
381 unstable. Disable by removing or setting suppress_push_when_active to false."
382 );
383 }
384
385 check_thumbnails(config)?;
386 check_video_thumbnails(config)
387}
388
389fn check_thumbnails(config: &Config) -> Result {
390 if config.media_thumbnail_max_pixels == 0 {
391 return Err!(Config(
392 "media_thumbnail_max_pixels",
393 "A pixel budget of zero refuses every picture; remove the setting to take the \
394 default."
395 ));
396 }
397
398 Ok(())
399}
400
401const MAX_VIDEO_THUMBNAIL_CONCURRENCY: usize = 1024;
404
405fn check_video_thumbnails(config: &Config) -> Result {
406 if !(1..=MAX_VIDEO_THUMBNAIL_CONCURRENCY).contains(&config.media_video_thumbnail_concurrency)
407 {
408 return Err!(Config(
409 "media_video_thumbnail_concurrency",
410 "Video thumbnail programs permitted at once must be between 1 and \
411 {MAX_VIDEO_THUMBNAIL_CONCURRENCY}: zero leaves every extraction waiting for a slot \
412 that never frees, and the ceiling is far past any useful degree of parallelism."
413 ));
414 }
415
416 if config.media_video_thumbnail_timeout == 0 {
417 return Err!(Config(
418 "media_video_thumbnail_timeout",
419 "A video thumbnail deadline of zero expires before the program can start."
420 ));
421 }
422
423 Ok(())
424}
425
426fn check_url_previews(config: &Config) -> Result {
427 let wildcard = "*".to_owned();
428 let url_preview_wildcards = [
429 (
430 "url_preview_domain_contains_allowlist",
431 &config.url_preview_domain_contains_allowlist,
432 ),
433 (
434 "url_preview_domain_explicit_allowlist",
435 &config.url_preview_domain_explicit_allowlist,
436 ),
437 ("url_preview_url_contains_allowlist", &config.url_preview_url_contains_allowlist),
438 ];
439
440 for (name, list) in url_preview_wildcards {
441 if list.contains(&wildcard) {
442 warn!(
443 "All URLs are allowed for URL previews via setting \"{name}\" to \"*\". This \
444 opens up significant attack surface to your server. You are expected to be \
445 aware of the risks by doing this."
446 );
447 }
448 }
449
450 if let Some(Either::Right(_)) = config.url_preview_bound_interface.as_ref()
451 && !matches!(OS, "android" | "fuchsia" | "linux")
452 {
453 return Err!(Config(
454 "url_preview_bound_interface",
455 "Not a valid IP address. Interface names not supported on {OS}."
456 ));
457 }
458
459 if let Some(user_agent) = config.url_preview_user_agent.as_deref()
460 && HeaderValue::from_str(user_agent).is_err()
461 {
462 return Err!(Config("url_preview_user_agent", "Not a valid HTTP header value."));
463 }
464
465 if let Some(user_agent) = config.url_preview_media_user_agent.as_deref()
466 && HeaderValue::from_str(user_agent).is_err()
467 {
468 return Err!(Config("url_preview_media_user_agent", "Not a valid HTTP header value."));
469 }
470
471 Ok(())
472}
473
474fn check_room_version(config: &Config) -> Result {
475 if !config.supported_room_version(&config.default_room_version) {
476 return Err!(Config(
477 "default_room_version",
478 "Room version {:?} is not available",
479 config.default_room_version
480 ));
481 }
482
483 if config
484 .default_power_level_content_override
485 .as_ref()
486 .is_some_and(|value| !value.is_object())
487 {
488 return Err!(Config(
489 "default_power_level_content_override",
490 "must be a table (a JSON object)"
491 ));
492 }
493
494 Ok(())
495}
496
497fn check_identity_providers(config: &Config) -> Result {
498 for a in config.identity_provider.values() {
499 let count = config
500 .identity_provider
501 .values()
502 .filter(|b| a.id().eq(b.id()))
503 .count();
504
505 debug_assert_ne!(count, 0, "expected at least one identity_provider");
506 if count > 1 {
507 return Err!(Config(
508 "client_id",
509 "Duplicate identity_provider with client_id {}",
510 a.client_id
511 ));
512 }
513 }
514
515 for (i, provider) in &config.identity_provider {
516 check_identity_provider_secret(i, provider)?;
517 }
518
519 if !config.sso_custom_providers_page
520 && config.identity_provider.len() > 1
521 && config
522 .identity_provider
523 .values()
524 .filter(|idp| idp.default)
525 .count()
526 .eq(&0)
527 {
528 let default = config
529 .identity_provider
530 .values()
531 .next()
532 .map(IdentityProvider::id)
533 .expect("Check at least one provider is configured to reach here");
534
535 warn!(
536 "More than one identity_provider has been configured without any default selected. \
537 To prevent this warning set `default = true` for one provider. Considering \
538 {default} the default for now..."
539 );
540 }
541
542 let mas_active = config
543 .mas_secret
544 .as_deref()
545 .is_some_and(|secret| !secret.is_empty());
546
547 if mas_active
548 && !config
549 .identity_provider
550 .values()
551 .any(|provider| provider.brand == "mas")
552 {
553 warn!(
554 "mas_secret is set but no identity_provider is configured with `brand = MAS`. \
555 Tuwunel is its own OpenID Connect issuer and does not delegate authentication to \
556 MAS; the secret only authorizes MAS provisioning calls on `/_synapse/mas/`. \
557 Logging in through MAS additionally requires an identity_provider entry with \
558 `brand = MAS`."
559 );
560 }
561
562 if mas_active {
563 config
564 .identity_provider
565 .values()
566 .filter(|provider| provider.brand == "mas" && !provider.trusted)
567 .for_each(|provider| {
568 warn!(
569 provider = provider.id(),
570 "`mas_secret` is set and this MAS identity provider is configured without \
571 `trusted = true`. Existing accounts provisioned by MAS will not be matched \
572 automatically during SSO login, so users may receive separate accounts. \
573 Set `trusted = true` only when this identity provider is the same \
574 self-hosted MAS instance that provisions this server and you fully control \
575 it; otherwise associate users explicitly."
576 );
577 });
578 }
579
580 Ok(())
581}
582
583fn check_identity_provider_secret(i: &str, provider: &IdentityProvider) -> Result {
584 if provider.client_secret.is_some() {
585 return Ok(());
586 }
587
588 let Some(secret_path) = &provider.client_secret_file else {
589 return Err!(Config(
590 "client_secret",
591 "Either client secret or a client secret file must be set on identity provider №{i}."
592 ));
593 };
594
595 let secret = read_to_string(secret_path).map_err(|e| {
596 err!(Config(
597 "client_secret_file",
598 "Failed to read client secret file {secret_path:?} on identity provider №{i}: {e}"
599 ))
600 })?;
601
602 if secret.trim().is_empty() {
603 return Err!(Config(
604 "client_secret_file",
605 "Client secret file {secret_path:?} is empty on identity provider №{i}"
606 ));
607 }
608
609 Ok(())
610}
611
612fn check_media_providers(config: &Config) -> Result {
613 for provider in &config.store_media_on_providers {
614 if !config.media_storage_providers.contains(provider) {
615 return Err!(Config(
616 "store_media_on_providers",
617 "Providers must be listed in 'media_storage_providers'"
618 ));
619 }
620 }
621
622 if config
623 .media_storage_providers
624 .iter()
625 .filter(|&provider| {
626 if config.storage_provider.contains_key(provider) || provider == "media" {
627 return false;
628 }
629
630 error!("`media_storage_providers` references non-existent provider {provider:?}");
631 true
632 })
633 .count()
634 .gt(&0)
635 {
636 return Err!(Config(
637 "media_storage_providers",
638 "Contains missing or unconfigured storage providers."
639 ));
640 }
641
642 if config.media_storage_providers.len() > 1 && config.store_media_on_providers.is_empty() {
643 warn!(
644 "Media will be duplicated to multiple providers {:?} until \
645 `store_media_on_providers` is configured. This warning can be suppressed by \
646 explicitly configuring `store_media_on_providers`",
647 config.media_storage_providers
648 );
649 }
650
651 Ok(())
652}
653
654fn check_well_known_support_contact_validity(config: &Config) -> Result {
655 let well_known = &config.well_known;
656
657 if well_known.support_role.is_some()
658 && well_known.support_email.is_none()
659 && well_known.support_mxid.is_none()
660 {
661 return Err!(
662 "well_known.support_role is set but neither support_email nor support_mxid is \
663 configured to accompany it"
664 );
665 }
666
667 if let Some(pgp_key) = well_known.support_pgp_key.as_deref() {
668 validate_pgp_key(pgp_key).map_err(|e| err!("well_known.support_pgp_key: {e}"))?;
669 }
670
671 for (id, contact) in &well_known.support_contact {
672 if contact.email_address.is_none() && contact.matrix_id.is_none() {
673 return Err!(
674 "well_known.support_contact.{id} has neither email_address nor matrix_id; at \
675 least one is required"
676 );
677 }
678
679 if let Some(pgp_key) = contact.pgp_key.as_deref() {
680 validate_pgp_key(pgp_key)
681 .map_err(|e| err!("well_known.support_contact.{id}.pgp_key: {e}"))?;
682 }
683 }
684
685 Ok(())
686}
687
688fn check_email(config: &Config) -> Result {
689 let smtp = &config.smtp;
690
691 if smtp.connection_uri.is_some() && config.well_known.client.is_none() {
692 return Err!(Config(
693 "well_known.client",
694 "global.smtp is configured but well_known.client is unset. Email verification links \
695 are built from the public client base URL, so set well_known.client to a valid \
696 HTTPS URL alongside global.smtp."
697 ));
698 }
699
700 if smtp.connection_uri.is_none()
701 && (smtp.require_email_for_registration || smtp.require_email_for_token_registration)
702 {
703 return Err!(Config(
704 "smtp.connection_uri",
705 "global.smtp requires a verified email at registration but smtp.connection_uri is \
706 unset. Set smtp.connection_uri so verification mail can be sent, or unset \
707 require_email_for_registration and require_email_for_token_registration."
708 ));
709 }
710
711 Ok(())
712}
713
714fn validate_pgp_key(value: &str) -> Result {
716 if value.contains("BEGIN PGP") {
717 return Err!(
718 "must be a URI, not inlined key material; publish the key and reference it by URI \
719 (for example https://example.com/key.asc or openpgp4fpr:<fingerprint>)"
720 );
721 }
722
723 let uri = Url::parse(value).map_err(|_| {
724 err!("must be a URI; a bare fingerprint must be prefixed with `openpgp4fpr:`")
725 })?;
726
727 if uri.scheme() == "openpgp4fpr" && !valid_openpgp4fpr(uri.path()) {
728 return Err!("`openpgp4fpr:` must be followed by a 40- or 64-character hex fingerprint");
729 }
730
731 Ok(())
732}
733
734fn valid_openpgp4fpr(fpr: &str) -> bool {
735 matches!(fpr.len(), 40 | 64) && fpr.bytes().all(|b| b.is_ascii_hexdigit())
736}
737
738fn warn_deprecated(config: &Config) {
741 debug!("Checking for deprecated config keys");
742 let found_deprecated_keys = config
743 .catchall
744 .keys()
745 .filter(|key| DEPRECATED_KEYS.iter().any(|s| s == key))
746 .inspect(|key| warn!("Config parameter \"{key}\" is deprecated, ignoring."))
747 .next()
748 .is_some();
749
750 if found_deprecated_keys {
751 warn!(
752 "Deprecated config keys were found. Read tuwunel config documentation at https://tuwunel.chat/configuration.html and \
753 check your configuration if any new configuration parameters should be adjusted"
754 );
755 }
756}
757
758fn warn_unknown_key(config: &Config) -> Result {
761 debug!("Checking for unknown config keys");
762 let known_keys =
763 RegexSet::new(KNOWN_KEYS).expect("Invalid regular expression set construction");
764
765 let unknown_keys = config
766 .catchall
767 .keys()
768 .filter(|key| !known_keys.is_match(key))
769 .inspect(|key| {
770 if config.error_on_unknown_config_opts {
771 error!("Config parameter \"{key}\" is unknown to tuwunel");
772 } else {
773 warn!("Config parameter \"{key}\" is unknown to tuwunel, ignoring.");
774 }
775 })
776 .collect_vec();
777
778 if !unknown_keys.is_empty() && config.error_on_unknown_config_opts {
779 Err!("Unknown config options were found: {unknown_keys:?}")
780 } else {
781 Ok(())
782 }
783}