Skip to main content

tuwunel_core/config/
check.rs

1//! Validates configuration before startup and reload.
2//!
3//! Checks reject invalid combinations while emitting warnings for deprecated or
4//! risky values. Reload validation also prevents runtime changes to fixed
5//! identity and network fields.
6
7use 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
32/// Slots either thumbnail concurrency knob may ask for.
33///
34/// The ceiling is an operator sanity bound rather than a limit of the semaphore
35/// beneath it, which accepts far more: a figure this large is a typo more often
36/// than an intention, and no host has the cores to spend it.
37const MAX_THUMBNAIL_CONCURRENCY: usize = 1024;
38
39/// Performs check() with additional checks specific to reloading old config
40/// with new config.
41pub 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
62/// Validates a complete server configuration.
63///
64/// The checks reject incompatible settings and emit warnings for risky or
65/// deprecated choices. Successful validation leaves the configuration
66/// unchanged.
67pub 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	// A non-zero depth shards the 64-char SHA-256 hex digest into `depth`
155	// segments of `length` plus a remainder, so the product must stay below 64.
156	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	// check if user specified valid IP CIDR ranges on startup
211	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() /* Guest */ && !Path::new("/proc/bz").exists()
235	/* Host */
236	{
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	// rocksdb does not allow max_log_files to be 0
262	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	// yeah, unless the user built a debug build hopefully for local testing only
281	#[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
292/// Identify the filesystem that will host the database.
293///
294/// A first boot has no `database_path` yet, since rocksdb creates it well
295/// after this check. The nearest existing ancestor stands in for it there.
296fn 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	// check if we can read the token file path, and check if the file is empty
342	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	// Without [global.jwt], the derived Default's empty format would fail this.
455	if jwt.enable {
456		jwt.key_format()?;
457	}
458
459	Ok(())
460}
461
462fn check_turn_and_media_misc(config: &Config) -> Result {
463	// A blank secret resolves to none at all, so it is checked the same way here.
464	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
544/// Whether a concurrency knob names a number of slots that cannot serve.
545///
546/// Zero is the load-bearing half: it leaves every request waiting on a slot
547/// that never frees, which reads as a hang rather than as a refusal.
548fn 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
876/// Validates an MSC4439 `pgp_key`: a URI, never inline key material.
877fn 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
900/// Iterates over all the keys in the config file and warns if there is a
901/// deprecated key specified
902fn 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
920/// Warns where a pattern option holds text that reads as a plain name.
921///
922/// These options are regular expressions, so an unescaped dot matches any
923/// character and an entry typed as a plain name therefore matches more than it
924/// names. Only escaping is suggested: whether the pattern should also be
925/// anchored depends on what the operator meant it to match, and a substring
926/// match is sometimes the intent.
927fn 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
957/// Whether a pattern is a plain dotted name rather than an expression.
958///
959/// True only for a pattern holding a dot, holding something more than dots, and
960/// built from characters a server name or a localpart may contain, none of which
961/// carry regex meaning. Anything using regex syntax of its own is left alone,
962/// `+` included, as is a pattern of bare dots, which is the any-character idiom
963/// rather than a name.
964fn 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	// Interpolated rather than carried as fields: the admin-room capture surface
977	// renders only the message, and this one is unactionable without all three.
978	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
985/// iterates over all the catchall keys (unknown config options) and warns or
986/// errors if there are any.
987fn 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}