Skip to main content

tuwunel_core/config/
check.rs

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