Skip to main content

mas_config/sections/
http.rs

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