Skip to main content

mas_config/sections/
http.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2021-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
8#![allow(deprecated)]
9
10use std::borrow::Cow;
11
12use anyhow::bail;
13use camino::Utf8PathBuf;
14use ipnetwork::IpNetwork;
15use mas_keystore::PrivateKey;
16use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, pem::PemObject};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use url::Url;
20
21use super::ConfigurationSection;
22
23fn default_public_base() -> Url {
24    "http://[::]:8080".parse().unwrap()
25}
26
27#[cfg(not(any(feature = "docker", feature = "dist")))]
28fn http_listener_assets_path_default() -> Utf8PathBuf {
29    "./frontend/dist/".into()
30}
31
32#[cfg(feature = "docker")]
33fn http_listener_assets_path_default() -> Utf8PathBuf {
34    "/usr/local/share/mas-cli/assets/".into()
35}
36
37#[cfg(feature = "dist")]
38fn http_listener_assets_path_default() -> Utf8PathBuf {
39    "./share/assets/".into()
40}
41
42fn is_default_http_listener_assets_path(value: &Utf8PathBuf) -> bool {
43    *value == http_listener_assets_path_default()
44}
45
46fn default_trusted_proxies() -> Vec<IpNetwork> {
47    vec![
48        IpNetwork::new([192, 168, 0, 0].into(), 16).unwrap(),
49        IpNetwork::new([172, 16, 0, 0].into(), 12).unwrap(),
50        IpNetwork::new([10, 0, 0, 0].into(), 10).unwrap(),
51        IpNetwork::new(std::net::Ipv4Addr::LOCALHOST.into(), 8).unwrap(),
52        IpNetwork::new([0xfd00, 0, 0, 0, 0, 0, 0, 0].into(), 8).unwrap(),
53        IpNetwork::new(std::net::Ipv6Addr::LOCALHOST.into(), 128).unwrap(),
54    ]
55}
56
57/// Kind of socket
58#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Copy)]
59#[serde(rename_all = "lowercase")]
60pub enum UnixOrTcp {
61    /// UNIX domain socket
62    Unix,
63
64    /// TCP socket
65    Tcp,
66}
67
68impl UnixOrTcp {
69    /// UNIX domain socket
70    #[must_use]
71    pub const fn unix() -> Self {
72        Self::Unix
73    }
74
75    /// TCP socket
76    #[must_use]
77    pub const fn tcp() -> Self {
78        Self::Tcp
79    }
80}
81
82/// Configuration of a single listener
83#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
84#[serde(untagged)]
85pub enum BindConfig {
86    /// Listen on the specified host and port
87    Listen {
88        /// Host on which to listen.
89        ///
90        /// Defaults to listening on all addresses
91        #[serde(skip_serializing_if = "Option::is_none")]
92        host: Option<String>,
93
94        /// Port on which to listen.
95        port: u16,
96    },
97
98    /// Listen on the specified address
99    Address {
100        /// Host and port on which to listen
101        #[schemars(
102            example = &"[::1]:8080",
103            example = &"[::]:8080",
104            example = &"127.0.0.1:8080",
105            example = &"0.0.0.0:8080",
106        )]
107        address: String,
108    },
109
110    /// Listen on a UNIX domain socket
111    Unix {
112        /// Path to the socket
113        #[schemars(with = "String")]
114        socket: Utf8PathBuf,
115
116        /// Permissions to use for the socket. Defaults to the process's umask.
117        #[serde(skip_serializing_if = "Option::is_none")]
118        #[schemars(example = &"600")]
119        mode: Option<String>,
120    },
121
122    /// Accept connections on file descriptors passed by the parent process.
123    ///
124    /// This is useful for grabbing sockets passed by systemd.
125    ///
126    /// See <https://www.freedesktop.org/software/systemd/man/sd_listen_fds.html>
127    FileDescriptor {
128        /// Index of the file descriptor. Note that this is offseted by 3
129        /// because of the standard input/output sockets, so setting
130        /// here a value of `0` will grab the file descriptor `3`
131        #[serde(default)]
132        fd: usize,
133
134        /// Whether the socket is a TCP socket or a UNIX domain socket. Defaults
135        /// to TCP.
136        #[serde(default = "UnixOrTcp::tcp")]
137        kind: UnixOrTcp,
138    },
139}
140
141/// Configuration related to TLS on a listener
142#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
143pub struct TlsConfig {
144    /// PEM-encoded X509 certificate chain
145    ///
146    /// Exactly one of `certificate` or `certificate_file` must be set.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub certificate: Option<String>,
149
150    /// File containing the PEM-encoded X509 certificate chain
151    ///
152    /// Exactly one of `certificate` or `certificate_file` must be set.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    #[schemars(with = "Option<String>")]
155    pub certificate_file: Option<Utf8PathBuf>,
156
157    /// PEM-encoded private key
158    ///
159    /// Exactly one of `key` or `key_file` must be set.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub key: Option<String>,
162
163    /// File containing a PEM or DER-encoded private key
164    ///
165    /// Exactly one of `key` or `key_file` must be set.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    #[schemars(with = "Option<String>")]
168    pub key_file: Option<Utf8PathBuf>,
169
170    /// Password used to decode the private key
171    ///
172    /// One of `password` or `password_file` must be set if the key is
173    /// encrypted.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub password: Option<String>,
176
177    /// Password file used to decode the private key
178    ///
179    /// One of `password` or `password_file` must be set if the key is
180    /// encrypted.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    #[schemars(with = "Option<String>")]
183    pub password_file: Option<Utf8PathBuf>,
184}
185
186impl TlsConfig {
187    /// Load the TLS certificate chain and key file from disk
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if an error was encountered either while:
192    ///   - reading the certificate, key or password files
193    ///   - decoding the key as PEM or DER
194    ///   - decrypting the key if encrypted
195    ///   - a password was provided but the key was not encrypted
196    ///   - decoding the certificate chain as PEM
197    ///   - the certificate chain is empty
198    pub fn load(
199        &self,
200    ) -> Result<(PrivateKeyDer<'static>, Vec<CertificateDer<'static>>), anyhow::Error> {
201        let password = match (&self.password, &self.password_file) {
202            (None, None) => None,
203            (Some(_), Some(_)) => {
204                bail!("Only one of `password` or `password_file` can be set at a time")
205            }
206            (Some(password), None) => Some(Cow::Borrowed(password)),
207            (None, Some(path)) => Some(Cow::Owned(std::fs::read_to_string(path)?)),
208        };
209
210        // Read the key either embedded in the config file or on disk
211        let key = match (&self.key, &self.key_file) {
212            (None, None) => bail!("Either `key` or `key_file` must be set"),
213            (Some(_), Some(_)) => bail!("Only one of `key` or `key_file` can be set at a time"),
214            (Some(key), None) => {
215                // If the key was embedded in the config file, assume it is formatted as PEM
216                if let Some(password) = password {
217                    PrivateKey::load_encrypted_pem(key, password.as_bytes())?
218                } else {
219                    PrivateKey::load_pem(key)?
220                }
221            }
222            (None, Some(path)) => {
223                // When reading from disk, it might be either PEM or DER. `PrivateKey::load*`
224                // will try both.
225                let key = std::fs::read(path)?;
226                if let Some(password) = password {
227                    PrivateKey::load_encrypted(&key, password.as_bytes())?
228                } else {
229                    PrivateKey::load(&key)?
230                }
231            }
232        };
233
234        // Re-serialize the key to PKCS#8 DER, so rustls can consume it
235        let key = key.to_pkcs8_der()?;
236        let key = PrivatePkcs8KeyDer::from(key.to_vec()).into();
237
238        let certificate_chain_pem = match (&self.certificate, &self.certificate_file) {
239            (None, None) => bail!("Either `certificate` or `certificate_file` must be set"),
240            (Some(_), Some(_)) => {
241                bail!("Only one of `certificate` or `certificate_file` can be set at a time")
242            }
243            (Some(certificate), None) => Cow::Borrowed(certificate),
244            (None, Some(path)) => Cow::Owned(std::fs::read_to_string(path)?),
245        };
246
247        let certificate_chain = CertificateDer::pem_slice_iter(certificate_chain_pem.as_bytes())
248            .collect::<Result<Vec<_>, _>>()?;
249
250        if certificate_chain.is_empty() {
251            bail!("TLS certificate chain is empty (or invalid)")
252        }
253
254        Ok((key, certificate_chain))
255    }
256}
257
258/// HTTP resources to mount
259#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
260#[serde(tag = "name", rename_all = "lowercase")]
261pub enum Resource {
262    /// Healthcheck endpoint (/health)
263    Health,
264
265    /// Prometheus metrics endpoint (/metrics)
266    Prometheus,
267
268    /// OIDC discovery endpoints
269    Discovery,
270
271    /// Pages destined to be viewed by humans
272    Human,
273
274    /// GraphQL endpoint
275    GraphQL {
276        /// Allow access for OAuth 2.0 clients (undocumented)
277        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
278        undocumented_oauth2_access: bool,
279    },
280
281    /// OAuth-related APIs
282    OAuth,
283
284    /// Matrix compatibility API
285    Compat,
286
287    /// Static files
288    Assets {
289        /// Path to the directory to serve.
290        #[serde(
291            default = "http_listener_assets_path_default",
292            skip_serializing_if = "is_default_http_listener_assets_path"
293        )]
294        #[schemars(with = "String")]
295        path: Utf8PathBuf,
296    },
297
298    /// Admin API, served at `/api/admin/v1`
299    AdminApi,
300
301    /// Mount a "/connection-info" handler which helps debugging informations on
302    /// the upstream connection
303    #[serde(rename = "connection-info")]
304    ConnectionInfo,
305}
306
307/// Configuration of a listener
308#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
309pub struct ListenerConfig {
310    /// A unique name for this listener which will be shown in traces and in
311    /// metrics labels
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub name: Option<String>,
314
315    /// List of resources to mount
316    pub resources: Vec<Resource>,
317
318    /// HTTP prefix to mount the resources on
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub prefix: Option<String>,
321
322    /// List of sockets to bind
323    pub binds: Vec<BindConfig>,
324
325    /// Accept `HAProxy`'s Proxy Protocol V1
326    #[serde(default)]
327    pub proxy_protocol: bool,
328
329    /// If set, makes the listener use TLS with the provided certificate and key
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub tls: Option<TlsConfig>,
332}
333
334/// Configuration related to the web server
335#[derive(Debug, Serialize, Deserialize, JsonSchema)]
336pub struct HttpConfig {
337    /// List of listeners to run
338    #[serde(default)]
339    pub listeners: Vec<ListenerConfig>,
340
341    /// List of trusted reverse proxies that can set the `X-Forwarded-For`
342    /// header
343    #[serde(default = "default_trusted_proxies")]
344    #[schemars(with = "Vec<String>", inner(ip))]
345    pub trusted_proxies: Vec<IpNetwork>,
346
347    /// Public URL base from where the authentication service is reachable
348    pub public_base: Url,
349
350    /// OIDC issuer URL. Defaults to `public_base` if not set.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub issuer: Option<Url>,
353}
354
355impl Default for HttpConfig {
356    fn default() -> Self {
357        Self {
358            listeners: vec![
359                ListenerConfig {
360                    name: Some("web".to_owned()),
361                    resources: vec![
362                        Resource::Discovery,
363                        Resource::Human,
364                        Resource::OAuth,
365                        Resource::Compat,
366                        Resource::GraphQL {
367                            undocumented_oauth2_access: false,
368                        },
369                        Resource::Assets {
370                            path: http_listener_assets_path_default(),
371                        },
372                    ],
373                    prefix: None,
374                    tls: None,
375                    proxy_protocol: false,
376                    binds: vec![BindConfig::Address {
377                        address: "[::]:8080".into(),
378                    }],
379                },
380                ListenerConfig {
381                    name: Some("internal".to_owned()),
382                    resources: vec![Resource::Health],
383                    prefix: None,
384                    tls: None,
385                    proxy_protocol: false,
386                    binds: vec![BindConfig::Listen {
387                        host: Some("localhost".to_owned()),
388                        port: 8081,
389                    }],
390                },
391            ],
392            trusted_proxies: default_trusted_proxies(),
393            issuer: Some(default_public_base()),
394            public_base: default_public_base(),
395        }
396    }
397}
398
399impl ConfigurationSection for HttpConfig {
400    const PATH: Option<&'static str> = Some("http");
401
402    fn validate(
403        &self,
404        figment: &figment::Figment,
405    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
406        for (index, listener) in self.listeners.iter().enumerate() {
407            let annotate = |mut error: figment::Error| {
408                error.metadata = figment
409                    .find_metadata(&format!("{root}.listeners", root = Self::PATH.unwrap()))
410                    .cloned();
411                error.profile = Some(figment::Profile::Default);
412                error.path = vec![
413                    Self::PATH.unwrap().to_owned(),
414                    "listeners".to_owned(),
415                    index.to_string(),
416                ];
417                error
418            };
419
420            if listener.resources.is_empty() {
421                return Err(
422                    annotate(figment::Error::from("listener has no resources".to_owned())).into(),
423                );
424            }
425
426            if listener.binds.is_empty() {
427                return Err(annotate(figment::Error::from(
428                    "listener does not bind to any address".to_owned(),
429                ))
430                .into());
431            }
432
433            if let Some(tls_config) = &listener.tls {
434                if tls_config.certificate.is_some() && tls_config.certificate_file.is_some() {
435                    return Err(annotate(figment::Error::from(
436                        "Only one of `certificate` or `certificate_file` can be set at a time"
437                            .to_owned(),
438                    ))
439                    .into());
440                }
441
442                if tls_config.certificate.is_none() && tls_config.certificate_file.is_none() {
443                    return Err(annotate(figment::Error::from(
444                        "TLS configuration is missing a certificate".to_owned(),
445                    ))
446                    .into());
447                }
448
449                if tls_config.key.is_some() && tls_config.key_file.is_some() {
450                    return Err(annotate(figment::Error::from(
451                        "Only one of `key` or `key_file` can be set at a time".to_owned(),
452                    ))
453                    .into());
454                }
455
456                if tls_config.key.is_none() && tls_config.key_file.is_none() {
457                    return Err(annotate(figment::Error::from(
458                        "TLS configuration is missing a private key".to_owned(),
459                    ))
460                    .into());
461                }
462
463                if tls_config.password.is_some() && tls_config.password_file.is_some() {
464                    return Err(annotate(figment::Error::from(
465                        "Only one of `password` or `password_file` can be set at a time".to_owned(),
466                    ))
467                    .into());
468                }
469            }
470        }
471
472        Ok(())
473    }
474}