links/config/
partial.rs

1//! Links server configuration as seen by the user
2
3use std::{
4	collections::HashMap, env, ffi::OsStr, fs, io::Error as IoError, path::Path, str::FromStr,
5};
6
7use basic_toml::Error as TomlError;
8use pico_args::Arguments;
9use serde::{Deserialize, Serialize};
10use serde_json::Error as JsonError;
11use serde_yaml::Error as YamlError;
12use strum::{Display as EnumDisplay, EnumString};
13use thiserror::Error;
14use tracing::{instrument, warn};
15
16use crate::{
17	config::{global::Hsts, CertificateSource, DefaultCertificateSource, ListenAddress, LogLevel},
18	stats::StatisticCategories,
19	store::BackendType,
20};
21
22/// The error returned by fallible conversions into a [`Partial`]
23#[derive(Debug, Error)]
24pub enum IntoPartialError {
25	/// Failed to parse from toml
26	#[error("failed to parse from toml")]
27	Toml(#[from] TomlError),
28	/// Failed to parse from yaml
29	#[error("failed to parse from yaml")]
30	Yaml(#[from] YamlError),
31	/// Failed to parse from json
32	#[error("failed to parse from json")]
33	Json(#[from] JsonError),
34	/// Failed to read config file
35	#[error("failed to read config file")]
36	Io(#[from] IoError),
37	/// File extension unknown, could not determine format
38	#[error("file extension unknown, could not determine format")]
39	UnknownExtension,
40}
41
42/// JSON-deserialize the provided command-line argument, returning `Some(...)`
43/// if it is present, has a value, and was successfully parsed, and `None`
44/// otherwise
45fn deserialize_arg<T: for<'a> Deserialize<'a>>(
46	args: &mut Arguments,
47	key: &'static str,
48) -> Option<T> {
49	args.opt_value_from_fn(key, |s| serde_json::from_str(s))
50		.map_err(|err| {
51			warn!(
52				%err,
53				"Error parsing configuration from command-line argument '{key}'"
54			);
55		})
56		.ok()
57		.flatten()
58}
59
60/// Parse the provided environment variable, returning `Some(...)` if it is
61/// present, has a value, and was successfully parsed, and `None` otherwise
62fn parse_env_var<T: FromStr>(key: &str) -> Option<T> {
63	env::var(key).map_or(None, |s| {
64		s.parse()
65			.map_err(|_| {
66				warn!("Error parsing configuration from environment variable '{key}'");
67			})
68			.ok()
69	})
70}
71
72/// JSON-deserialize the provided environment variable, returning `Some(...)` if
73/// it is present, has a value, and was successfully parsed, and `None`
74/// otherwise
75fn deserialize_env_var<T: for<'a> Deserialize<'a>>(key: &str) -> Option<T> {
76	env::var(key)
77		.map_or(None, |s| {
78			serde_json::from_str(&s)
79				.map_err(|err| {
80					warn!(
81						%err,
82						"Error parsing configuration from environment variable '{key}'"
83					);
84				})
85				.ok()
86		})
87		.flatten()
88}
89
90/// Links redirector configuration as seen from the user's perspective.
91///
92/// This is easier to parse, but less idiomatic and not as easy to use as
93/// [`Config`][super::Config]. As this is a representation of links'
94/// configuration from one source only, all fields are optional, which allows
95/// incremental updates to the actual [`Config`][super::Config] struct.
96#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
97pub struct Partial {
98	/// Minimum level of logs to be collected/displayed. Debug and trace levels
99	/// may expose secret information, so are not recommended for production
100	/// deployments.
101	pub log_level: Option<LogLevel>,
102	/// API token, used for authentication of gRPC clients
103	pub token: Option<String>,
104	/// Listener addresses, see [`ListenAddress`] for details
105	pub listeners: Option<Vec<ListenAddress>>,
106	/// What types of statistics should be collected
107	pub statistics: Option<StatisticCategories>,
108	/// Default TLS certificate and key source
109	pub default_certificate: Option<DefaultCertificateSource>,
110	/// TLS certificate and key sources
111	pub certificates: Option<Vec<CertificateSource>>,
112	/// HTTP Strict Transport Security setting on redirect
113	pub hsts: Option<PartialHsts>,
114	/// HTTP Strict Transport Security `max_age` header attribute (retention
115	/// time in seconds)
116	pub hsts_max_age: Option<u32>,
117	/// Redirect from HTTP to HTTPS before the external redirect
118	pub https_redirect: Option<bool>,
119	/// Send the `Alt-Svc` header advertising `h2` (HTTP/2.0 with TLS) support
120	/// on port 443
121	pub send_alt_svc: Option<bool>,
122	/// Send the `Server` header
123	pub send_server: Option<bool>,
124	/// Send the `Content-Security-Policy` header
125	pub send_csp: Option<bool>,
126	/// The store backend type
127	pub store: Option<BackendType>,
128	/// The store backend configuration. All of these options are
129	/// backend-specific, and have ASCII alphanumeric string keys in
130	/// `snake_case` (lower case, words seperated by underscores), without any
131	/// hyphens (`-`), i.e. only lowercase `a-z`, `0-9`, and `_` are
132	/// allowed. The values are UTF-8 strings in any format.
133	pub store_config: Option<HashMap<String, String>>,
134}
135
136impl Partial {
137	/// Parse a [`Partial`] from a [toml](https://toml.io/en/) string
138	///
139	/// # Errors
140	/// Returns a `FromFileError::Toml` if deserialization fails.
141	pub fn from_toml(toml: &str) -> Result<Self, IntoPartialError> {
142		Ok(basic_toml::from_str(toml)?)
143	}
144
145	/// Parse a [`Partial`] from a [yaml](https://yaml.org/) string
146	///
147	/// # Errors
148	/// Returns a `FromFileError::Yaml` if deserialization fails.
149	pub fn from_yaml(yaml: &str) -> Result<Self, IntoPartialError> {
150		Ok(serde_yaml::from_str(yaml)?)
151	}
152
153	/// Parse a [`Partial`] from a [json](https://json.org/) string
154	///
155	/// # Errors
156	/// Returns a `FromFileError::Json` if deserialization fails.
157	pub fn from_json(json: &str) -> Result<Self, IntoPartialError> {
158		Ok(serde_json::from_str(json)?)
159	}
160
161	/// Read and parse a configuration file into a [`Partial`]. The format of
162	/// the file is determined from its extension:
163	/// - `*.toml` files are parsed as [toml](https://toml.io/en/)
164	/// - `*.yaml` and `*.yml` files are parsed as [yaml](https://yaml.org/)
165	/// - `*.json` files are parsed as [json](https://json.org/)
166	///
167	/// # IO
168	/// This function performs synchronous file IO, and should not be used in an
169	/// asynchronous context.
170	///
171	/// # Errors
172	/// Returns an error when reading of parsing the file fails.
173	#[instrument(level = "debug", ret, err)]
174	pub fn from_file(path: &Path) -> Result<Self, IntoPartialError> {
175		let parse = match path.extension().map(OsStr::to_str) {
176			Some(Some("toml")) => Self::from_toml,
177			Some(Some("yaml" | "yml")) => Self::from_yaml,
178			Some(Some("json")) => Self::from_json,
179			_ => return Err(IntoPartialError::UnknownExtension),
180		};
181
182		parse(&fs::read_to_string(path)?)
183	}
184
185	/// Parse command-line arguments into a [`Partial`]. Listeners and store
186	/// configuration are parsed from json strings.
187	#[must_use]
188	#[instrument(level = "debug", ret)]
189	pub fn from_args() -> Self {
190		let mut args = Arguments::from_env();
191		Self {
192			log_level: args.opt_value_from_str("--log-level").unwrap_or(None),
193			token: args.opt_value_from_str("--token").unwrap_or(None),
194			listeners: deserialize_arg(&mut args, "--listeners"),
195			statistics: deserialize_arg(&mut args, "--statistics"),
196			default_certificate: deserialize_arg(&mut args, "--default-certificate"),
197			certificates: deserialize_arg(&mut args, "--certificates"),
198			hsts: args.opt_value_from_str("--hsts").unwrap_or(None),
199			hsts_max_age: args.opt_value_from_str("--hsts-max-age").unwrap_or(None),
200			https_redirect: args.opt_value_from_str("--https-redirect").unwrap_or(None),
201			send_alt_svc: args.opt_value_from_str("--send-alt-svc").unwrap_or(None),
202			send_server: args.opt_value_from_str("--send-server").unwrap_or(None),
203			send_csp: args.opt_value_from_str("--send-csp").unwrap_or(None),
204			store: args.opt_value_from_str("--store").unwrap_or(None),
205			store_config: deserialize_arg(&mut args, "--store-config"),
206		}
207	}
208
209	/// Parse environment variables with the prefix `LINKS_` into a [`Partial`].
210	/// Listeners and store configuration are parsed from json strings.
211	#[must_use]
212	#[instrument(level = "debug", ret)]
213	pub fn from_env_vars() -> Self {
214		Self {
215			log_level: parse_env_var("LINKS_LOG_LEVEL"),
216			token: parse_env_var("LINKS_TOKEN"),
217			listeners: deserialize_env_var("LINKS_LISTENERS"),
218			statistics: deserialize_env_var("LINKS_STATISTICS"),
219			default_certificate: deserialize_env_var("LINKS_DEFAULT_CERTIFICATE"),
220			certificates: deserialize_env_var("LINKS_CERTIFICATES"),
221			hsts: parse_env_var("LINKS_HSTS"),
222			hsts_max_age: parse_env_var("LINKS_HSTS_MAX_AGE"),
223			https_redirect: parse_env_var("LINKS_HTTPS_REDIRECT"),
224			send_alt_svc: parse_env_var("LINKS_SEND_ALT_SVC"),
225			send_server: parse_env_var("LINKS_SEND_SERVER"),
226			send_csp: parse_env_var("LINKS_SEND_CSP"),
227			store: parse_env_var("LINKS_STORE"),
228			store_config: deserialize_env_var("LINKS_STORE_CONFIG"),
229		}
230	}
231
232	/// Get HSTS configuration information from this partial config, if present
233	#[must_use]
234	pub fn hsts(&self) -> Option<Hsts> {
235		match self.hsts? {
236			PartialHsts::Disable => Some(Hsts::Disable),
237			PartialHsts::Enable => Some(Hsts::Enable(self.hsts_max_age?)),
238			PartialHsts::IncludeSubDomains => Some(Hsts::IncludeSubDomains(self.hsts_max_age?)),
239			PartialHsts::Preload => Some(Hsts::Preload(self.hsts_max_age?)),
240		}
241	}
242}
243
244/// HSTS enabling options as seen from the user's perspective.
245///
246/// # Caution:
247/// The `IncludeSubDomains` and `Preload` settings may have lasting unintended
248/// effects on unrelated HTTP servers (current and future) running on subdomains
249/// of the links host, and may even render those websites unusable for months or
250/// years by requiring browsers to use HTTPS (with TLS) *exclusively* when doing
251/// HTTP requests to those domains. The `Enable` setting, however, only impacts
252/// the exact domain it is used from, so should only impact the links redirector
253/// server itself. It is recommended to start testing HSTS (especially
254/// `IncludeSubDomains` and `Preload`) with a short `max-age` initially, and to
255/// test any possible impact on other websites hosted on the same domain and on
256/// its subdomains.
257///
258/// See also:
259/// - <https://hstspreload.org/>
260/// - <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security>
261/// - <https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security>
262#[derive(
263	Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, EnumString, EnumDisplay,
264)]
265#[serde(rename_all = "snake_case")]
266#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
267pub enum PartialHsts {
268	/// Don't send the HTTP Strict Transport Security header
269	#[strum(serialize = "disable", serialize = "off")]
270	Disable,
271	/// Send the HSTS header without the `preload` or `includeSubDomains`
272	/// attributes.
273	#[default]
274	#[strum(serialize = "enable", serialize = "on")]
275	Enable,
276	/// Send the HSTS header with the `includeSubDomains` attribute, but without
277	/// `preload`
278	///
279	/// # Caution:
280	/// This may have temporary unintended effects on unrelated HTTP servers
281	/// running on subdomains of the links host. Make sure that this won't cause
282	/// any problems before enabling it and try a short max-age first.
283	/// More info on <https://hstspreload.org/>,
284	/// <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security>,
285	/// and <https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security>.
286	#[strum(
287		serialize = "includeSubDomains",
288		serialize = "include",
289		to_string = "include"
290	)]
291	IncludeSubDomains,
292	/// Send the HSTS header with the `preload` and `includeSubDomains`
293	/// attributes
294	///
295	/// # Caution:
296	/// This may have lasting unintended effects on unrelated HTTP servers
297	/// (current and future) running on subdomains of the links host, and may
298	/// even render those websites unusable for months or years.
299	///
300	/// Read <https://hstspreload.org/>,
301	/// <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security>,
302	/// and <https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security> first,
303	/// and make sure that this won't cause any problems before enabling it.
304	Preload,
305}
306
307#[cfg(test)]
308mod tests {
309	use super::*;
310
311	#[test]
312	fn test_deserialize_arg() {
313		let mut args = Arguments::from_vec(vec![
314			"--certificates".into(),
315			r#"[{"source": "files", "domains": ["example.com"], "cert": "./cert.pem", "key": "./key.pem"}]"#.into(),
316			"--listeners".into(),
317			"yes, please".into(),
318		]);
319
320		assert!(deserialize_arg::<Vec<ListenAddress>>(&mut args, "--listeners").is_none());
321		assert!(deserialize_arg::<Vec<CertificateSource>>(&mut args, "--certificates").is_some());
322	}
323
324	#[test]
325	fn test_parse_env_var() {
326		env::set_var("LINKS_LOG_LEVEL", "no logging, thanks");
327		env::set_var("LINKS_HSTS", "include");
328
329		assert!(parse_env_var::<LogLevel>("LINKS_LOG_LEVEL").is_none());
330		assert!(parse_env_var::<PartialHsts>("LINKS_HSTS").is_some());
331	}
332
333	#[test]
334	fn test_deserialize_env_var() {
335		env::set_var(
336			"LINKS_CERTIFICATES",
337			r#"[{"source": "files", "domains": ["example.com"], "cert": "./cert.pem", "key": "./key.pem"}]"#,
338		);
339		env::set_var("LINKS_LISTENERS", "yes, please");
340
341		assert!(deserialize_env_var::<Vec<ListenAddress>>("LINKS_LISTENERS").is_none());
342		assert!(deserialize_env_var::<Vec<CertificateSource>>("LINKS_CERTIFICATES").is_some());
343	}
344}