1use std::{net::SocketAddr, path::PathBuf, str::FromStr};
8
9use ruma::OwnedServerName;
10use url::Url;
11
12#[derive(Clone, Debug)]
14pub struct ArminiusConfig {
15 pub server_name: OwnedServerName,
18 pub addr: SocketAddr,
20 pub client_base_url: Url,
22 pub oidc_well_known: Option<Url>,
24 pub scim_base_url: Option<Url>,
26 pub scim_token_endpoint: Option<String>,
28 pub scim_client_id: Option<String>,
30 pub scim_client_secret: Option<String>,
32 pub proxy_url: Option<Url>,
34 pub extra_ca_certs: Vec<PathBuf>,
36}
37
38impl ArminiusConfig {
39 #[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 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 #[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 #[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 #[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#[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#[derive(Debug)]
178pub enum IpAddrClass {
179 Ipv4,
181 Ipv6,
183}
184
185#[derive(thiserror::Error, Debug)]
187pub enum ArminiusConfigurationError {
188 #[error("Missing configuration value: {key}")]
190 MissingConfigValue {
191 key: String,
193 },
194 #[error("Malformed configuration value: {key}, details: {details:?}")]
196 MalformedConfigValue {
197 key: String,
199 details: Option<String>,
201 },
202}
203
204impl ArminiusConfigurationError {
205 pub fn missing(key: impl Into<String>) -> Self {
207 Self::MissingConfigValue { key: key.into() }
208 }
209
210 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
216pub 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
223pub 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}