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};
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
22/// Performs check() with additional checks specific to reloading old config
23/// with new config.
24pub 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
45/// Validates a complete server configuration.
46///
47/// The checks reject incompatible settings and emit warnings for risky or
48/// deprecated choices. Successful validation leaves the configuration
49/// unchanged.
50pub 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	// A non-zero depth shards the 64-char SHA-256 hex digest into `depth`
111	// segments of `length` plus a remainder, so the product must stay below 64.
112	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	// check if user specified valid IP CIDR ranges on startup
159	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() /* Guest */ && !Path::new("/proc/bz").exists()
183	/* Host */
184	{
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	// rocksdb does not allow max_log_files to be 0
210	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	// yeah, unless the user built a debug build hopefully for local testing only
218	#[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	// check if we can read the token file path, and check if the file is empty
266	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	// A blank secret resolves to none at all, so it is checked the same way here.
350	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
401/// Beyond this the semaphore sizing the extractions would itself be rejected,
402/// and no host has a use for that much video decoding at once.
403const 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
714/// Validates an MSC4439 `pgp_key`: a URI, never inline key material.
715fn 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
738/// Iterates over all the keys in the config file and warns if there is a
739/// deprecated key specified
740fn 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
758/// iterates over all the catchall keys (unknown config options) and warns or
759/// errors if there are any.
760fn 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}