mas_config/sections/
http.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7#![allow(deprecated)]
8
9use std::{borrow::Cow, io::Cursor};
10
11use anyhow::bail;
12use camino::Utf8PathBuf;
13use ipnetwork::IpNetwork;
14use mas_keystore::PrivateKey;
15use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
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
116    /// Accept connections on file descriptors passed by the parent process.
117    ///
118    /// This is useful for grabbing sockets passed by systemd.
119    ///
120    /// See <https://www.freedesktop.org/software/systemd/man/sd_listen_fds.html>
121    FileDescriptor {
122        /// Index of the file descriptor. Note that this is offseted by 3
123        /// because of the standard input/output sockets, so setting
124        /// here a value of `0` will grab the file descriptor `3`
125        #[serde(default)]
126        fd: usize,
127
128        /// Whether the socket is a TCP socket or a UNIX domain socket. Defaults
129        /// to TCP.
130        #[serde(default = "UnixOrTcp::tcp")]
131        kind: UnixOrTcp,
132    },
133}
134
135/// Configuration related to TLS on a listener
136#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
137pub struct TlsConfig {
138    /// PEM-encoded X509 certificate chain
139    ///
140    /// Exactly one of `certificate` or `certificate_file` must be set.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub certificate: Option<String>,
143
144    /// File containing the 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    #[schemars(with = "Option<String>")]
149    pub certificate_file: Option<Utf8PathBuf>,
150
151    /// PEM-encoded private key
152    ///
153    /// Exactly one of `key` or `key_file` must be set.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub key: Option<String>,
156
157    /// File containing a PEM or DER-encoded private key
158    ///
159    /// Exactly one of `key` or `key_file` must be set.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    #[schemars(with = "Option<String>")]
162    pub key_file: Option<Utf8PathBuf>,
163
164    /// Password used to decode the private key
165    ///
166    /// One of `password` or `password_file` must be set if the key is
167    /// encrypted.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub password: Option<String>,
170
171    /// Password file used to decode the private key
172    ///
173    /// One of `password` or `password_file` must be set if the key is
174    /// encrypted.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    #[schemars(with = "Option<String>")]
177    pub password_file: Option<Utf8PathBuf>,
178}
179
180impl TlsConfig {
181    /// Load the TLS certificate chain and key file from disk
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if an error was encountered either while:
186    ///   - reading the certificate, key or password files
187    ///   - decoding the key as PEM or DER
188    ///   - decrypting the key if encrypted
189    ///   - a password was provided but the key was not encrypted
190    ///   - decoding the certificate chain as PEM
191    ///   - the certificate chain is empty
192    pub fn load(
193        &self,
194    ) -> Result<(PrivateKeyDer<'static>, Vec<CertificateDer<'static>>), anyhow::Error> {
195        let password = match (&self.password, &self.password_file) {
196            (None, None) => None,
197            (Some(_), Some(_)) => {
198                bail!("Only one of `password` or `password_file` can be set at a time")
199            }
200            (Some(password), None) => Some(Cow::Borrowed(password)),
201            (None, Some(path)) => Some(Cow::Owned(std::fs::read_to_string(path)?)),
202        };
203
204        // Read the key either embedded in the config file or on disk
205        let key = match (&self.key, &self.key_file) {
206            (None, None) => bail!("Either `key` or `key_file` must be set"),
207            (Some(_), Some(_)) => bail!("Only one of `key` or `key_file` can be set at a time"),
208            (Some(key), None) => {
209                // If the key was embedded in the config file, assume it is formatted as PEM
210                if let Some(password) = password {
211                    PrivateKey::load_encrypted_pem(key, password.as_bytes())?
212                } else {
213                    PrivateKey::load_pem(key)?
214                }
215            }
216            (None, Some(path)) => {
217                // When reading from disk, it might be either PEM or DER. `PrivateKey::load*`
218                // will try both.
219                let key = std::fs::read(path)?;
220                if let Some(password) = password {
221                    PrivateKey::load_encrypted(&key, password.as_bytes())?
222                } else {
223                    PrivateKey::load(&key)?
224                }
225            }
226        };
227
228        // Re-serialize the key to PKCS#8 DER, so rustls can consume it
229        let key = key.to_pkcs8_der()?;
230        let key = PrivatePkcs8KeyDer::from(key.to_vec()).into();
231
232        let certificate_chain_pem = match (&self.certificate, &self.certificate_file) {
233            (None, None) => bail!("Either `certificate` or `certificate_file` must be set"),
234            (Some(_), Some(_)) => {
235                bail!("Only one of `certificate` or `certificate_file` can be set at a time")
236            }
237            (Some(certificate), None) => Cow::Borrowed(certificate),
238            (None, Some(path)) => Cow::Owned(std::fs::read_to_string(path)?),
239        };
240
241        let mut certificate_chain_reader = Cursor::new(certificate_chain_pem.as_bytes());
242        let certificate_chain: Result<Vec<_>, _> =
243            rustls_pemfile::certs(&mut certificate_chain_reader).collect();
244        let certificate_chain = certificate_chain?;
245
246        if certificate_chain.is_empty() {
247            bail!("TLS certificate chain is empty (or invalid)")
248        }
249
250        Ok((key, certificate_chain))
251    }
252}
253
254/// HTTP resources to mount
255#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
256#[serde(tag = "name", rename_all = "lowercase")]
257pub enum Resource {
258    /// Healthcheck endpoint (/health)
259    Health,
260
261    /// Prometheus metrics endpoint (/metrics)
262    Prometheus,
263
264    /// OIDC discovery endpoints
265    Discovery,
266
267    /// Pages destined to be viewed by humans
268    Human,
269
270    /// GraphQL endpoint
271    GraphQL {
272        /// Enabled the GraphQL playground
273        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
274        playground: bool,
275
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                            playground: false,
368                            undocumented_oauth2_access: false,
369                        },
370                        Resource::Assets {
371                            path: http_listener_assets_path_default(),
372                        },
373                    ],
374                    prefix: None,
375                    tls: None,
376                    proxy_protocol: false,
377                    binds: vec![BindConfig::Address {
378                        address: "[::]:8080".into(),
379                    }],
380                },
381                ListenerConfig {
382                    name: Some("internal".to_owned()),
383                    resources: vec![Resource::Health],
384                    prefix: None,
385                    tls: None,
386                    proxy_protocol: false,
387                    binds: vec![BindConfig::Listen {
388                        host: Some("localhost".to_owned()),
389                        port: 8081,
390                    }],
391                },
392            ],
393            trusted_proxies: default_trusted_proxies(),
394            issuer: Some(default_public_base()),
395            public_base: default_public_base(),
396        }
397    }
398}
399
400impl ConfigurationSection for HttpConfig {
401    const PATH: Option<&'static str> = Some("http");
402
403    fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
404        for (index, listener) in self.listeners.iter().enumerate() {
405            let annotate = |mut error: figment::Error| {
406                error.metadata = figment
407                    .find_metadata(&format!("{root}.listeners", root = Self::PATH.unwrap()))
408                    .cloned();
409                error.profile = Some(figment::Profile::Default);
410                error.path = vec![
411                    Self::PATH.unwrap().to_owned(),
412                    "listeners".to_owned(),
413                    index.to_string(),
414                ];
415                Err(error)
416            };
417
418            if listener.resources.is_empty() {
419                return annotate(figment::Error::from("listener has no resources".to_owned()));
420            }
421
422            if listener.binds.is_empty() {
423                return annotate(figment::Error::from(
424                    "listener does not bind to any address".to_owned(),
425                ));
426            }
427
428            if let Some(tls_config) = &listener.tls {
429                if tls_config.certificate.is_some() && tls_config.certificate_file.is_some() {
430                    return annotate(figment::Error::from(
431                        "Only one of `certificate` or `certificate_file` can be set at a time"
432                            .to_owned(),
433                    ));
434                }
435
436                if tls_config.certificate.is_none() && tls_config.certificate_file.is_none() {
437                    return annotate(figment::Error::from(
438                        "TLS configuration is missing a certificate".to_owned(),
439                    ));
440                }
441
442                if tls_config.key.is_some() && tls_config.key_file.is_some() {
443                    return annotate(figment::Error::from(
444                        "Only one of `key` or `key_file` can be set at a time".to_owned(),
445                    ));
446                }
447
448                if tls_config.key.is_none() && tls_config.key_file.is_none() {
449                    return annotate(figment::Error::from(
450                        "TLS configuration is missing a private key".to_owned(),
451                    ));
452                }
453
454                if tls_config.password.is_some() && tls_config.password_file.is_some() {
455                    return annotate(figment::Error::from(
456                        "Only one of `password` or `password_file` can be set at a time".to_owned(),
457                    ));
458                }
459            }
460        }
461
462        Ok(())
463    }
464}