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) => tokio::fs::read_to_string(path).await?,
135            Secret::Value(secret) => secret.clone(),
136        })
137    }
138
139    pub(crate) fn generate<R>(mut rng: R) -> Self
140    where
141        R: Rng + Send,
142    {
143        Self {
144            kind: HomeserverKind::default(),
145            homeserver: default_homeserver(),
146            secret: Secret::Value(Alphanumeric.sample_string(&mut rng, 32)),
147            endpoint: default_endpoint(),
148        }
149    }
150
151    pub(crate) fn test() -> Self {
152        Self {
153            kind: HomeserverKind::default(),
154            homeserver: default_homeserver(),
155            secret: Secret::Value("test".to_owned()),
156            endpoint: default_endpoint(),
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use figment::{
164        Figment, Jail,
165        providers::{Format, Yaml},
166    };
167    use tokio::{runtime::Handle, task};
168
169    use super::*;
170
171    #[tokio::test]
172    async fn load_config() {
173        task::spawn_blocking(|| {
174            Jail::expect_with(|jail| {
175                jail.create_file(
176                    "config.yaml",
177                    r"
178                        matrix:
179                          homeserver: matrix.org
180                          secret_file: secret
181                    ",
182                )?;
183                jail.create_file("secret", r"m472!x53c237")?;
184
185                let config = Figment::new()
186                    .merge(Yaml::file("config.yaml"))
187                    .extract_inner::<MatrixConfig>("matrix")?;
188
189                Handle::current().block_on(async move {
190                    assert_eq!(&config.homeserver, "matrix.org");
191                    assert!(matches!(config.secret, Secret::File(ref p) if p == "secret"));
192                    assert_eq!(config.secret().await.unwrap(), "m472!x53c237");
193                });
194
195                Ok(())
196            });
197        })
198        .await
199        .unwrap();
200    }
201
202    #[tokio::test]
203    async fn load_config_inline_secrets() {
204        task::spawn_blocking(|| {
205            Jail::expect_with(|jail| {
206                jail.create_file(
207                    "config.yaml",
208                    r"
209                        matrix:
210                          homeserver: matrix.org
211                          secret: m472!x53c237
212                    ",
213                )?;
214
215                let config = Figment::new()
216                    .merge(Yaml::file("config.yaml"))
217                    .extract_inner::<MatrixConfig>("matrix")?;
218
219                Handle::current().block_on(async move {
220                    assert_eq!(&config.homeserver, "matrix.org");
221                    assert!(matches!(config.secret, Secret::Value(ref v) if v == "m472!x53c237"));
222                    assert_eq!(config.secret().await.unwrap(), "m472!x53c237");
223                });
224
225                Ok(())
226            });
227        })
228        .await
229        .unwrap();
230    }
231}