mas_config/sections/
matrix.rs

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