syn2mas/synapse_reader/config/
mod.rs1mod oidc;
7
8use std::collections::BTreeMap;
9
10use camino::Utf8PathBuf;
11use chrono::{DateTime, Utc};
12use figment::providers::{Format, Yaml};
13use mas_config::{PasswordAlgorithm, PasswordHashingScheme};
14use rand::Rng;
15use serde::Deserialize;
16use sqlx::postgres::PgConnectOptions;
17use tracing::warn;
18use url::Url;
19
20pub use self::oidc::OidcProvider;
21
22#[derive(Deserialize)]
28#[expect(clippy::struct_excessive_bools)]
29pub struct Config {
30 pub database: DatabaseSection,
31
32 #[serde(default)]
33 pub password_config: PasswordSection,
34
35 pub bcrypt_rounds: Option<u32>,
36
37 #[serde(default)]
38 pub allow_guest_access: bool,
39
40 #[serde(default)]
41 pub enable_registration: bool,
42
43 #[serde(default)]
44 pub enable_registration_captcha: bool,
45 pub recaptcha_public_key: Option<String>,
46 pub recaptcha_private_key: Option<String>,
47
48 #[serde(default)]
51 pub enable_3pid_changes: Option<bool>,
52
53 #[serde(default = "default_true")]
54 enable_set_display_name: bool,
55
56 #[serde(default)]
57 pub user_consent: Option<UserConsentSection>,
58
59 #[serde(default)]
60 pub registrations_require_3pid: Vec<String>,
61
62 #[serde(default)]
63 pub registration_requires_token: bool,
64
65 pub registration_shared_secret: Option<String>,
66
67 #[serde(default)]
68 pub login_via_existing_session: EnableableSection,
69
70 #[serde(default)]
71 pub cas_config: EnableableSection,
72
73 #[serde(default)]
74 pub saml2_config: EnableableSection,
75
76 #[serde(default)]
77 pub jwt_config: EnableableSection,
78
79 #[serde(default)]
80 pub oidc_config: Option<OidcProvider>,
81
82 #[serde(default)]
83 pub oidc_providers: Vec<OidcProvider>,
84
85 pub server_name: String,
86
87 pub public_baseurl: Option<Url>,
88}
89
90impl Config {
91 pub fn load(files: &[Utf8PathBuf]) -> Result<Config, figment::Error> {
98 let mut figment = figment::Figment::new();
99 for file in files {
100 figment = figment.merge(Yaml::file(file));
105 }
106 figment.extract::<Config>()
107 }
108
109 #[must_use]
117 pub fn all_oidc_providers(&self) -> BTreeMap<String, OidcProvider> {
118 let mut out = BTreeMap::new();
119
120 if let Some(provider) = &self.oidc_config {
121 if provider.has_required_fields() {
122 let mut provider = provider.clone();
123 let idp_id = provider.idp_id.take().unwrap_or("oidc".to_owned());
125 provider.idp_id = Some(idp_id.clone());
126 out.insert(idp_id, provider);
127 }
128 }
129
130 for provider in &self.oidc_providers {
131 let mut provider = provider.clone();
132 let idp_id = match provider.idp_id.take() {
133 None => "oidc".to_owned(),
134 Some(idp_id) if idp_id == "oidc" => idp_id,
135 Some(idp_id) => format!("oidc-{idp_id}"),
137 };
138 provider.idp_id = Some(idp_id.clone());
139 out.insert(idp_id, provider);
140 }
141
142 out
143 }
144
145 #[must_use]
147 pub fn adjust_mas_config(
148 self,
149 mut mas_config: mas_config::RootConfig,
150 rng: &mut impl Rng,
151 now: DateTime<Utc>,
152 ) -> mas_config::RootConfig {
153 let providers = self.all_oidc_providers();
154 for provider in providers.into_values() {
155 let Some(mas_provider_config) = provider.into_mas_config(rng, now) else {
156 warn!("Could not convert OIDC provider to MAS config");
158 continue;
159 };
160
161 mas_config
162 .upstream_oauth2
163 .providers
164 .push(mas_provider_config);
165 }
166
167 if let Some(enable_3pid_changes) = self.enable_3pid_changes {
169 mas_config.account.email_change_allowed = enable_3pid_changes;
170 }
171 mas_config.account.displayname_change_allowed = self.enable_set_display_name;
172 if self.password_config.enabled {
173 mas_config.passwords.enabled = true;
174 mas_config.passwords.schemes = vec![
175 PasswordHashingScheme {
177 version: 1,
178 algorithm: PasswordAlgorithm::Bcrypt,
179 cost: self.bcrypt_rounds,
180 secret: self.password_config.pepper,
181 secret_file: None,
182 unicode_normalization: true,
183 },
184 PasswordHashingScheme {
187 version: 2,
188 algorithm: PasswordAlgorithm::default(),
189 cost: None,
190 secret: None,
191 secret_file: None,
192 unicode_normalization: false,
193 },
194 ];
195
196 mas_config.account.password_registration_enabled = self.enable_registration;
197 } else {
198 mas_config.passwords.enabled = false;
199 }
200
201 if self.enable_registration_captcha {
202 mas_config.captcha.service = Some(mas_config::CaptchaServiceKind::RecaptchaV2);
203 mas_config.captcha.site_key = self.recaptcha_public_key;
204 mas_config.captcha.secret_key = self.recaptcha_private_key;
205 }
206
207 mas_config.matrix.homeserver = self.server_name;
208 if let Some(public_baseurl) = self.public_baseurl {
209 mas_config.matrix.endpoint = public_baseurl;
210 }
211
212 mas_config
213 }
214}
215
216#[derive(Deserialize)]
220pub struct DatabaseSection {
221 pub name: String,
225 #[serde(default)]
226 pub args: DatabaseArgsSuboption,
227}
228
229pub const SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2: &str = "psycopg2";
231pub const SYNAPSE_DATABASE_DRIVER_NAME_SQLITE3: &str = "sqlite3";
233
234impl DatabaseSection {
235 pub fn to_sqlx_postgres(&self) -> Result<PgConnectOptions, anyhow::Error> {
248 if self.name != SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2 {
249 anyhow::bail!("syn2mas does not support the {} database driver", self.name);
250 }
251
252 if self.args.database.is_some() && self.args.dbname.is_some() {
253 anyhow::bail!(
254 "Only one of `database` and `dbname` may be specified in the Synapse database configuration, not both."
255 );
256 }
257
258 let mut opts = PgConnectOptions::new().application_name("syn2mas-synapse");
259
260 if let Some(host) = &self.args.host {
261 opts = opts.host(host);
262 }
263 if let Some(port) = self.args.port {
264 opts = opts.port(port);
265 }
266 if let Some(dbname) = &self.args.dbname {
267 opts = opts.database(dbname);
268 }
269 if let Some(database) = &self.args.database {
270 opts = opts.database(database);
271 }
272 if let Some(user) = &self.args.user {
273 opts = opts.username(user);
274 }
275 if let Some(password) = &self.args.password {
276 opts = opts.password(password);
277 }
278
279 Ok(opts)
280 }
281}
282
283#[derive(Deserialize, Default)]
287pub struct DatabaseArgsSuboption {
288 pub user: Option<String>,
289 pub password: Option<String>,
290 pub dbname: Option<String>,
291 pub database: Option<String>,
293 pub host: Option<String>,
294 pub port: Option<u16>,
295}
296
297#[derive(Deserialize)]
301pub struct PasswordSection {
302 #[serde(default = "default_true")]
303 pub enabled: bool,
304 #[serde(default = "default_true")]
305 pub localdb_enabled: bool,
306 pub pepper: Option<String>,
307}
308
309impl Default for PasswordSection {
310 fn default() -> Self {
311 PasswordSection {
312 enabled: true,
313 localdb_enabled: true,
314 pepper: None,
315 }
316 }
317}
318
319#[derive(Default, Deserialize)]
322pub struct EnableableSection {
323 #[serde(default)]
324 pub enabled: bool,
325}
326
327fn default_true() -> bool {
328 true
329}
330
331#[cfg(test)]
332mod test {
333 use sqlx::postgres::PgConnectOptions;
334
335 use super::{DatabaseArgsSuboption, DatabaseSection};
336
337 #[test]
338 fn test_to_sqlx_postgres() {
339 #[track_caller]
340 #[expect(clippy::needless_pass_by_value)]
341 fn assert_eq_options(config: DatabaseSection, uri: &str) {
342 let config_connect_options = config
343 .to_sqlx_postgres()
344 .expect("no connection options generated by config");
345 let uri_connect_options: PgConnectOptions = uri
346 .parse()
347 .expect("example URI did not parse as PgConnectionOptions");
348
349 assert_eq!(
350 config_connect_options.get_host(),
351 uri_connect_options.get_host()
352 );
353 assert_eq!(
354 config_connect_options.get_port(),
355 uri_connect_options.get_port()
356 );
357 assert_eq!(
358 config_connect_options.get_username(),
359 uri_connect_options.get_username()
360 );
361 assert_eq!(
363 config_connect_options.get_database(),
364 uri_connect_options.get_database()
365 );
366 }
367
368 assert!(
370 DatabaseSection {
371 name: "sqlite3".to_owned(),
372 args: DatabaseArgsSuboption::default(),
373 }
374 .to_sqlx_postgres()
375 .is_err()
376 );
377
378 assert!(
380 DatabaseSection {
381 name: "psycopg2".to_owned(),
382 args: DatabaseArgsSuboption {
383 user: Some("synapse_user".to_owned()),
384 password: Some("verysecret".to_owned()),
385 dbname: Some("synapse_db".to_owned()),
386 database: Some("synapse_db".to_owned()),
387 host: Some("synapse-db.example.com".to_owned()),
388 port: Some(42),
389 },
390 }
391 .to_sqlx_postgres()
392 .is_err()
393 );
394
395 assert_eq_options(
396 DatabaseSection {
397 name: "psycopg2".to_owned(),
398 args: DatabaseArgsSuboption::default(),
399 },
400 "postgresql:///",
401 );
402 assert_eq_options(
403 DatabaseSection {
404 name: "psycopg2".to_owned(),
405 args: DatabaseArgsSuboption {
406 user: Some("synapse_user".to_owned()),
407 password: Some("verysecret".to_owned()),
408 dbname: Some("synapse_db".to_owned()),
409 database: None,
410 host: Some("synapse-db.example.com".to_owned()),
411 port: Some(42),
412 },
413 },
414 "postgresql://synapse_user:verysecret@synapse-db.example.com:42/synapse_db",
415 );
416 }
417}
418
419#[derive(Deserialize)]
422pub struct UserConsentSection {}