Skip to main content

arminius_lib_config/
lib.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Arminius Configuration
6
7use std::{net::SocketAddr, path::PathBuf, str::FromStr};
8
9use ruma::OwnedServerName;
10use url::Url;
11
12/// The configuration for an instance of Arminius
13#[derive(Clone, Debug)]
14pub struct ArminiusConfig {
15	/// The name of the homeserver we are hosting
16	/// <https://spec.matrix.org/v1.14/appendices/#server-name>
17	pub server_name: OwnedServerName,
18	/// The address on which the component will be listening
19	pub addr: SocketAddr,
20	/// The URL that clients should connect to
21	pub client_base_url: Url,
22	/// The OIDC base URL
23	pub oidc_well_known: Option<Url>,
24	/// SCIM base URL used for user and session sync
25	pub scim_base_url: Option<Url>,
26	/// OAuth token endpoint URL used to obtain the access token for SCIM
27	pub scim_token_endpoint: Option<String>,
28	/// OAuth client ID used to obtain the access token for SCIM
29	pub scim_client_id: Option<String>,
30	/// OAuth client secret used to obtain the access token for SCIM
31	pub scim_client_secret: Option<String>,
32	/// URL for the outgoing proxy
33	pub proxy_url: Option<Url>,
34	/// Custom CA certificates
35	pub extra_ca_certs: Vec<PathBuf>,
36}
37
38impl ArminiusConfig {
39	/// Create a new ArminiusConfig
40	#[expect(
41		clippy::too_many_arguments,
42		reason = "TODO(#2003): This exception predates us enabling `clippy::allow_attributes_without_reason`, so we don't know why it was enabled"
43	)]
44	pub fn new(
45		server_name: OwnedServerName,
46		addr: impl Into<SocketAddr>,
47		client_base_url: Url,
48		oidc_well_known: impl Into<Option<Url>>,
49		scim_base_url: impl Into<Option<Url>>,
50		scim_token_endpoint: impl Into<Option<String>>,
51		scim_client_id: impl Into<Option<String>>,
52		scim_client_secret: impl Into<Option<String>>,
53		proxy_url: impl Into<Option<Url>>,
54		extra_ca_certs: &[PathBuf],
55	) -> Self {
56		Self {
57			server_name,
58			addr: addr.into(),
59			client_base_url,
60			oidc_well_known: oidc_well_known.into(),
61			scim_base_url: scim_base_url.into(),
62			scim_token_endpoint: scim_token_endpoint.into(),
63			scim_client_id: scim_client_id.into(),
64			scim_client_secret: scim_client_secret.into(),
65			proxy_url: proxy_url.into(),
66			extra_ca_certs: extra_ca_certs.to_vec(),
67		}
68	}
69
70	/// Reads the configuration from the environment
71	pub fn from_env() -> Result<Self, ArminiusConfigurationError> {
72		let host = try_read_env::<String>("ARMINIUS_HOST")?.parse().map_err(|_| {
73			ArminiusConfigurationError::malformed(
74				"ARMINIUS_HOST",
75				"cannot parse as SocketAddr".to_owned(),
76			)
77		})?;
78		let oidc_base_url: Option<Url> = try_read_opt_env("OIDC_BASE_URL")?;
79		let extra_ca_certs: Vec<PathBuf> =
80			try_read_opt_env("ARMINIUS_EXTRA_CA_CERT")?.map(|cert| vec![cert]).unwrap_or_default();
81
82		let server_name: OwnedServerName =
83			try_read_env::<String>("ARMINIUS_NAME")?.parse().map_err(|_| {
84				ArminiusConfigurationError::malformed(
85					"ARMINIUS_NAME",
86					"not a legal Matrix Server Name".to_owned(),
87				)
88			})?;
89
90		Ok(Self {
91			server_name,
92			addr: host,
93			client_base_url: Url::parse(&format!("http://{host}")).map_err(|err| {
94				ArminiusConfigurationError::malformed("ARMINIUS_HOST", err.to_string())
95			})?,
96			oidc_well_known: oidc_base_url
97				.map(|url| url.join("/.well-known/openid-configuration"))
98				.transpose()
99				.map_err(|err| {
100					ArminiusConfigurationError::malformed("OIDC_BASE_URL", err.to_string())
101				})?,
102			scim_base_url: try_read_opt_env("SCIM_BASE_URL")?,
103			scim_token_endpoint: try_read_opt_env("SCIM_TOKEN_ENDPOINT")?,
104			scim_client_id: try_read_opt_env("SCIM_CLIENT_ID")?,
105			scim_client_secret: try_read_opt_env("SCIM_CLIENT_SECRET")?,
106			proxy_url: try_read_opt_env("ARMINIUS_PROXY")?,
107			extra_ca_certs,
108		})
109	}
110
111	/// Generate a config for testing purposes,
112	/// assigning a random local port
113	/// Server name and address will be IPv4 based.
114	#[cfg(any(feature = "test_utils", test))]
115	#[must_use]
116	pub fn for_test_using_ipv4() -> Self {
117		Self::test_config_for(false)
118	}
119
120	/// Generate a config for testing purposes,
121	/// assigning a random local port.
122	/// Server name and address will be IPv6 based.
123	#[cfg(any(feature = "test_utils", test))]
124	#[must_use]
125	pub fn for_test_using_ipv6() -> Self {
126		Self::test_config_for(true)
127	}
128
129	/// Generate a config for testing purposes,
130	/// assigning a random local port.
131	#[cfg(any(feature = "test_utils", test))]
132	#[expect(
133		clippy::expect_used,
134		reason = "TODO(#2003): This exception predates us enabling `clippy::allow_attributes_without_reason`, so we don't know why it was enabled"
135	)]
136	#[must_use]
137	pub fn test_config_for(bind_to_ipv6: bool) -> Self {
138		let port = get_random_port().expect("Failed to get port for test config");
139		let loopback_addr =
140			if bind_to_ipv6 { format!("[::1]:{port}") } else { format!("127.0.0.1:{port}") };
141		Self::new(
142			loopback_addr.parse().expect("Invalid server name generated by test"),
143			SocketAddr::V6(std::net::SocketAddrV6::new(std::net::Ipv6Addr::LOCALHOST, port, 0, 0)),
144			Url::parse(&format!("http://{loopback_addr}"))
145				.expect("error parsing generated client base URL"),
146			None,
147			None,
148			None,
149			None,
150			None,
151			None,
152			&[],
153		)
154	}
155}
156
157/// Gets an open port.
158/// Can theoretically lead to a data race. There is no guarantee that the
159/// port will stay free between calling this function, and assigning it to
160/// some process. In practice, the Unix bind call assigns these randomly
161/// allocated ports semi-sequentially from the ephemeral range, in which
162/// there are 28000 to choose between. So this race condition probably
163/// definitely won't be happening.
164///
165/// Bonus: If you were assigning them randomly, had the perfect storm of
166/// timing issues, and were looking for 100 ports, there'd only be 16.1%
167/// chance of two of your tests having the same birthday.
168#[cfg(any(feature = "test_utils", test))]
169fn get_random_port() -> std::io::Result<u16> {
170	let listener = std::net::TcpListener::bind("[::1]:0")?;
171	let addr = listener.local_addr()?;
172	let port = addr.port();
173	Ok(port)
174}
175
176/// Classifies an IP address as IPv4 or IPv6
177#[derive(Debug)]
178pub enum IpAddrClass {
179	/// IPv4 address
180	Ipv4,
181	/// IPv6 address
182	Ipv6,
183}
184
185/// Errors that can occur when reading Arminius configuration
186#[derive(thiserror::Error, Debug)]
187pub enum ArminiusConfigurationError {
188	/// A required configuration value is missing
189	#[error("Missing configuration value: {key}")]
190	MissingConfigValue {
191		/// The configuration key that is missing
192		key: String,
193	},
194	/// A configuration value is malformed
195	#[error("Malformed configuration value: {key}, details: {details:?}")]
196	MalformedConfigValue {
197		/// The configuration key that is malformed
198		key: String,
199		/// Additional details about the malformation
200		details: Option<String>,
201	},
202}
203
204impl ArminiusConfigurationError {
205	/// Create a MissingConfigValue error
206	pub fn missing(key: impl Into<String>) -> Self {
207		Self::MissingConfigValue { key: key.into() }
208	}
209
210	/// Create a MalformedConfigValue error
211	pub fn malformed(key: impl Into<String>, details: impl Into<Option<String>>) -> Self {
212		Self::MalformedConfigValue { key: key.into(), details: details.into() }
213	}
214}
215
216/// Try to read a required environment variable, parsing it to the given type.
217pub fn try_read_env<T: FromStr>(key: &str) -> Result<String, ArminiusConfigurationError> {
218	std::env::var(key).map_err(|_| ArminiusConfigurationError::missing(key)).and_then(|value| {
219		value.parse().map_err(|_| ArminiusConfigurationError::malformed(key, value))
220	})
221}
222
223/// Try to read an optional environment variable.
224pub fn try_read_opt_env<T: FromStr>(key: &str) -> Result<Option<T>, ArminiusConfigurationError> {
225	match std::env::var(key) {
226		Ok(value) => {
227			value.parse().map(Some).map_err(|_| ArminiusConfigurationError::malformed(key, value))
228		}
229		Err(std::env::VarError::NotPresent) => Ok(None),
230		Err(_) => Err(ArminiusConfigurationError::missing(key)),
231	}
232}