Skip to main content

mas_config/sections/
matrix.rs

1// Copyright 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use anyhow::bail;
9use camino::Utf8PathBuf;
10use rand::{
11    Rng,
12    distributions::{Alphanumeric, DistString},
13};
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16use serde_with::serde_as;
17use url::Url;
18
19use super::ConfigurationSection;
20
21fn default_homeserver() -> String {
22    "localhost:8008".to_owned()
23}
24
25fn default_endpoint() -> Url {
26    Url::parse("http://localhost:8008/").unwrap()
27}
28
29/// The kind of homeserver it is.
30#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum HomeserverKind {
33    /// Homeserver is Synapse, version 1.135.0 or newer
34    #[default]
35    Synapse,
36
37    /// Homeserver is Synapse, version 1.135.0 or newer, in read-only mode
38    ///
39    /// This is meant for testing rolling out Matrix Authentication Service with
40    /// no risk of writing data to the homeserver.
41    SynapseReadOnly,
42
43    /// Homeserver is Synapse, using the legacy API
44    SynapseLegacy,
45
46    /// Homeserver is Synapse, with the modern API available (>= 1.135.0)
47    SynapseModern,
48}
49
50/// Shared secret between MAS and the homeserver.
51///
52/// It either holds the secret value directly or references a file where the
53/// secret is stored.
54#[derive(Clone, Debug)]
55pub enum Secret {
56    File(Utf8PathBuf),
57    Value(String),
58}
59
60/// Secret fields as serialized in JSON.
61#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
62struct SecretRaw {
63    #[schemars(with = "Option<String>")]
64    #[serde(skip_serializing_if = "Option::is_none")]
65    secret_file: Option<Utf8PathBuf>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    secret: Option<String>,
68}
69
70impl TryFrom<SecretRaw> for Secret {
71    type Error = anyhow::Error;
72
73    fn try_from(value: SecretRaw) -> Result<Self, Self::Error> {
74        match (value.secret, value.secret_file) {
75            (None, None) => bail!("Missing `secret` or `secret_file`"),
76            (None, Some(path)) => Ok(Secret::File(path)),
77            (Some(secret), None) => Ok(Secret::Value(secret)),
78            (Some(_), Some(_)) => bail!("Cannot specify both `secret` and `secret_file`"),
79        }
80    }
81}
82
83impl From<Secret> for SecretRaw {
84    fn from(value: Secret) -> Self {
85        match value {
86            Secret::File(path) => SecretRaw {
87                secret_file: Some(path),
88                secret: None,
89            },
90            Secret::Value(secret) => SecretRaw {
91                secret_file: None,
92                secret: Some(secret),
93            },
94        }
95    }
96}
97
98/// Configuration related to the Matrix homeserver
99#[serde_as]
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101pub struct MatrixConfig {
102    /// The kind of homeserver it is.
103    #[serde(default)]
104    pub kind: HomeserverKind,
105
106    /// The server name of the homeserver.
107    #[serde(default = "default_homeserver")]
108    pub homeserver: String,
109
110    /// Shared secret to use for calls to the admin API
111    #[schemars(with = "SecretRaw")]
112    #[serde_as(as = "serde_with::TryFromInto<SecretRaw>")]
113    #[serde(flatten)]
114    pub secret: Secret,
115
116    /// The base URL of the homeserver's client API
117    #[serde(default = "default_endpoint")]
118    pub endpoint: Url,
119}
120
121impl ConfigurationSection for MatrixConfig {
122    const PATH: Option<&'static str> = Some("matrix");
123}
124
125impl MatrixConfig {
126    /// Returns the shared secret.
127    ///
128    /// If `secret_file` was given, the secret is read from that file.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error when the shared secret could not be read from file.
133    pub async fn secret(&self) -> anyhow::Result<String> {
134        Ok(match &self.secret {
135            Secret::File(path) => {
136                let raw = tokio::fs::read_to_string(path).await?;
137                // Trim the secret when read from file to match Synapse's behaviour
138                raw.trim().to_string()
139            }
140            Secret::Value(secret) => secret.clone(),
141        })
142    }
143
144    pub(crate) fn generate<R>(mut rng: R) -> Self
145    where
146        R: Rng + Send,
147    {
148        Self {
149            kind: HomeserverKind::default(),
150            homeserver: default_homeserver(),
151            secret: Secret::Value(Alphanumeric.sample_string(&mut rng, 32)),
152            endpoint: default_endpoint(),
153        }
154    }
155
156    pub(crate) fn test() -> Self {
157        Self {
158            kind: HomeserverKind::default(),
159            homeserver: default_homeserver(),
160            secret: Secret::Value("test".to_owned()),
161            endpoint: default_endpoint(),
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    // The closures passed to `Jail::expect_with` return `figment::Error`, which is
169    // large, and we can't change figment's API.
170    #![expect(clippy::result_large_err)]
171
172    use figment::{
173        Figment, Jail,
174        providers::{Format, Yaml},
175    };
176    use tokio::{runtime::Handle, task};
177
178    use super::*;
179
180    #[tokio::test]
181    async fn load_config() {
182        task::spawn_blocking(|| {
183            Jail::expect_with(|jail| {
184                jail.create_file(
185                    "config.yaml",
186                    r"
187                        matrix:
188                          homeserver: matrix.org
189                          secret_file: secret
190                    ",
191                )?;
192                jail.create_file("secret", r"m472!x53c237")?;
193
194                let config = Figment::new()
195                    .merge(Yaml::file("config.yaml"))
196                    .extract_inner::<MatrixConfig>("matrix")?;
197
198                Handle::current().block_on(async move {
199                    assert_eq!(&config.homeserver, "matrix.org");
200                    assert!(matches!(config.secret, Secret::File(ref p) if p == "secret"));
201                    assert_eq!(config.secret().await.unwrap(), "m472!x53c237");
202                });
203
204                Ok(())
205            });
206        })
207        .await
208        .unwrap();
209    }
210
211    #[tokio::test]
212    async fn load_config_inline_secrets() {
213        task::spawn_blocking(|| {
214            Jail::expect_with(|jail| {
215                jail.create_file(
216                    "config.yaml",
217                    r"
218                        matrix:
219                          homeserver: matrix.org
220                          secret: m472!x53c237
221                    ",
222                )?;
223
224                let config = Figment::new()
225                    .merge(Yaml::file("config.yaml"))
226                    .extract_inner::<MatrixConfig>("matrix")?;
227
228                Handle::current().block_on(async move {
229                    assert_eq!(&config.homeserver, "matrix.org");
230                    assert!(matches!(config.secret, Secret::Value(ref v) if v == "m472!x53c237"));
231                    assert_eq!(config.secret().await.unwrap(), "m472!x53c237");
232                });
233
234                Ok(())
235            });
236        })
237        .await
238        .unwrap();
239    }
240}