michal/tit
Browse tree · Show commit · Download archive
Blob: src/config.rs
use std::env;
use std::fs;
use std::net::{IpAddr, SocketAddr};
use std::path::{Component, Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
use url::{Host, Url};
use crate::cli::Cli;
const CONFIG_VERSION: u32 = 1;
const SYSTEM_CONFIG_PATH: &str = "/srv/tit/config.toml";
const MAX_CONFIG_REQUEST_BYTES: u64 = 256 * 1024 * 1024;
const MAX_CONFIG_CONNECTIONS: u32 = 100_000;
#[derive(Debug)]
pub(crate) struct Config {
pub(crate) config_path: PathBuf,
pub(crate) public_url: Url,
pub(crate) instance_dir: PathBuf,
pub(crate) http_listen: SocketAddr,
pub(crate) ssh_listen: SocketAddr,
pub(crate) ssh_public_host: Host<String>,
pub(crate) ssh_public_port: u16,
pub(crate) signup_policy: SignupPolicy,
pub(crate) max_request_bytes: u64,
pub(crate) max_connections: u32,
}
impl Config {
fn validate(&self, path: &Path) -> Result<(), ConfigError> {
validate_clean_absolute_path(&self.instance_dir, false)?;
if path.parent() != Some(self.instance_dir.as_path()) {
return Err(ConfigError::ConfigOutsideInstance);
}
validate_public_url(&self.public_url)?;
if self.http_listen.port() == 0 {
return Err(ConfigError::ZeroListenerPort("http.listen"));
}
if self.ssh_listen.port() == 0 {
return Err(ConfigError::ZeroListenerPort("ssh.listen"));
}
let advertised_address = match &self.ssh_public_host {
Host::Ipv4(address) => Some(IpAddr::V4(*address)),
Host::Ipv6(address) => Some(IpAddr::V6(*address)),
Host::Domain(_) => None,
};
if let Some(address) = advertised_address
&& (address.is_unspecified() || address.is_multicast())
{
return Err(ConfigError::InvalidSshPublicAddress(address));
}
if self.ssh_public_port == 0 {
return Err(ConfigError::ZeroSshPort);
}
if self.max_request_bytes == 0 {
return Err(ConfigError::ZeroLimit("max_request_bytes"));
}
if self.max_request_bytes > MAX_CONFIG_REQUEST_BYTES {
return Err(ConfigError::LimitTooLarge("max_request_bytes"));
}
if self.max_connections == 0 {
return Err(ConfigError::ZeroLimit("max_connections"));
}
if self.max_connections > MAX_CONFIG_CONNECTIONS {
return Err(ConfigError::LimitTooLarge("max_connections"));
}
if self.signup_policy != SignupPolicy::Invite {
return Err(ConfigError::UnsupportedSignupPolicy);
}
self.clone_urls("owner", "repository")?;
Ok(())
}
pub(crate) fn clone_urls(
&self,
owner: &str,
repository: &str,
) -> Result<(Url, Url), ConfigError> {
let mut http = self.public_url.clone();
http.path_segments_mut()
.map_err(|()| ConfigError::CloneUrlBase)?
.extend([owner, repository]);
let ssh_origin = if self.ssh_public_port == 22 {
format!("ssh://{}/", self.ssh_public_host)
} else {
format!("ssh://{}:{}/", self.ssh_public_host, self.ssh_public_port)
};
let mut ssh = Url::parse(&ssh_origin).map_err(ConfigError::CloneUrl)?;
ssh.path_segments_mut()
.map_err(|()| ConfigError::CloneUrlBase)?
.extend([owner, repository]);
Ok((http, ssh))
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum SignupPolicy {
Invite,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigFile {
version: u32,
public_url: Url,
#[serde(default)]
http: HttpConfig,
#[serde(default)]
ssh: SshConfig,
#[serde(default)]
signup: SignupConfig,
#[serde(default)]
limits: LimitsConfig,
}
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct HttpConfig {
listen: SocketAddr,
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
listen: "127.0.0.1:3000"
.parse()
.expect("the default HTTP address is valid"),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct SshConfig {
listen: SocketAddr,
public_host: Option<String>,
public_port: u16,
}
impl Default for SshConfig {
fn default() -> Self {
Self {
listen: "0.0.0.0:2222"
.parse()
.expect("the default SSH address is valid"),
public_host: None,
public_port: 2222,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct SignupConfig {
policy: SignupPolicy,
}
impl Default for SignupConfig {
fn default() -> Self {
Self {
policy: SignupPolicy::Invite,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct LimitsConfig {
max_request_bytes: u64,
max_connections: u32,
}
impl Default for LimitsConfig {
fn default() -> Self {
Self {
max_request_bytes: 1_048_576,
max_connections: 1_024,
}
}
}
#[derive(Debug, Error)]
pub(crate) enum ConfigError {
#[error("cannot determine the user data directory")]
UserDataDirectory,
#[error("configuration path must be absolute: {0}")]
RelativeConfigPath(PathBuf),
#[error("configuration path must not contain '.' or '..': {0}")]
UncleanConfigPath(PathBuf),
#[error("cannot read configuration {path}: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("cannot parse configuration {path}: {source}")]
Parse {
path: PathBuf,
source: toml::de::Error,
},
#[error("configuration version {0} is not supported")]
UnsupportedVersion(u32),
#[error("instance directory must be absolute: {0}")]
RelativeInstanceDirectory(PathBuf),
#[error("instance directory must not contain '.' or '..': {0}")]
UncleanInstanceDirectory(PathBuf),
#[error("configuration file must be directly inside the instance directory")]
ConfigOutsideInstance,
#[error("public URL must not contain credentials, a query, or a fragment")]
PublicUrlComponents,
#[error("public URL path must be '/'")]
PublicUrlPath,
#[error("public URL must contain a hostname")]
PublicUrlHost,
#[error("public URL must use HTTPS unless its host is loopback")]
InsecurePublicUrl,
#[error("public SSH port must not be zero")]
ZeroSshPort,
#[error("public SSH hostname is not valid: {value}")]
InvalidSshPublicHost {
value: String,
source: url::ParseError,
},
#[error("public SSH hostname must not be an unspecified or multicast address: {0}")]
InvalidSshPublicAddress(IpAddr),
#[error("listener port {0} must not be zero")]
ZeroListenerPort(&'static str),
#[error("configuration limit {0} must not be zero")]
ZeroLimit(&'static str),
#[error("configuration limit {0} is too large")]
LimitTooLarge(&'static str),
#[error("signup policy is not supported")]
UnsupportedSignupPolicy,
#[error("advertised endpoint cannot be a base URL")]
CloneUrlBase,
#[error("cannot generate an SSH clone URL: {0}")]
CloneUrl(url::ParseError),
}
pub(crate) fn load(cli: &Cli) -> Result<Config, ConfigError> {
let path = config_path(cli)?;
validate_clean_absolute_path(&path, true)?;
let contents = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
path: path.clone(),
source,
})?;
let file: ConfigFile = toml::from_str(&contents).map_err(|source| ConfigError::Parse {
path: path.clone(),
source,
})?;
if file.version != CONFIG_VERSION {
return Err(ConfigError::UnsupportedVersion(file.version));
}
let instance_dir = path
.parent()
.expect("an absolute file has a parent")
.to_owned();
let public_url = cli.public_url.clone().unwrap_or(file.public_url);
let http_listen = cli.http_listen.unwrap_or(file.http.listen);
let ssh_listen = cli.ssh_listen.unwrap_or(file.ssh.listen);
let ssh_public_host = cli
.ssh_public_host
.as_deref()
.or(file.ssh.public_host.as_deref())
.or_else(|| public_url.host_str())
.ok_or(ConfigError::PublicUrlHost)?;
let ssh_public_host =
Host::parse(ssh_public_host).map_err(|source| ConfigError::InvalidSshPublicHost {
value: ssh_public_host.to_owned(),
source,
})?;
let ssh_public_port = cli.ssh_public_port.unwrap_or(file.ssh.public_port);
let config = Config {
config_path: path.clone(),
public_url,
instance_dir,
http_listen,
ssh_listen,
ssh_public_host,
ssh_public_port,
signup_policy: file.signup.policy,
max_request_bytes: file.limits.max_request_bytes,
max_connections: file.limits.max_connections,
};
config.validate(&path)?;
Ok(config)
}
fn config_path(cli: &Cli) -> Result<PathBuf, ConfigError> {
if let Some(path) = &cli.config {
return Ok(path.clone());
}
if cli.user {
return user_config_path();
}
Ok(PathBuf::from(SYSTEM_CONFIG_PATH))
}
fn user_config_path() -> Result<PathBuf, ConfigError> {
if let Some(path) = env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
let path = PathBuf::from(path);
if path.is_absolute() {
return Ok(path.join("tit/config.toml"));
}
return Err(ConfigError::UserDataDirectory);
}
let home = env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.ok_or(ConfigError::UserDataDirectory)?;
Ok(home.join(".local/share/tit/config.toml"))
}
fn validate_clean_absolute_path(path: &Path, config: bool) -> Result<(), ConfigError> {
if !path.is_absolute() {
return if config {
Err(ConfigError::RelativeConfigPath(path.to_owned()))
} else {
Err(ConfigError::RelativeInstanceDirectory(path.to_owned()))
};
}
if path
.components()
.any(|component| matches!(component, Component::CurDir | Component::ParentDir))
{
return if config {
Err(ConfigError::UncleanConfigPath(path.to_owned()))
} else {
Err(ConfigError::UncleanInstanceDirectory(path.to_owned()))
};
}
Ok(())
}
fn validate_public_url(url: &Url) -> Result<(), ConfigError> {
if !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(ConfigError::PublicUrlComponents);
}
if url.path() != "/" {
return Err(ConfigError::PublicUrlPath);
}
let loopback = match url.host() {
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
None => false,
};
if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
return Err(ConfigError::InsecurePublicUrl);
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs;
use clap::Parser;
use tempfile::TempDir;
use super::*;
fn write_config(contents: &str) -> (TempDir, PathBuf) {
let directory = TempDir::new().expect("create a temporary directory");
let path = directory.path().join("config.toml");
fs::write(&path, contents).expect("write the configuration");
(directory, path)
}
fn cli(path: &Path) -> Cli {
Cli::try_parse_from(["tit", "--config", path.to_str().expect("a UTF-8 path")])
.expect("parse CLI options")
}
#[test]
fn loads_defaults_and_explicit_security_policy() {
let (_directory, path) = write_config(
r#"
version = 1
public_url = "https://tit.example/"
[signup]
policy = "invite"
"#,
);
let config = load(&cli(&path)).expect("load the configuration");
assert_eq!(config.public_url.as_str(), "https://tit.example/");
assert_eq!(config.instance_dir, path.parent().expect("a parent"));
assert_eq!(config.http_listen.to_string(), "127.0.0.1:3000");
assert_eq!(config.ssh_listen.to_string(), "0.0.0.0:2222");
assert_eq!(
config.ssh_public_host,
Host::Domain("tit.example".to_owned())
);
assert_eq!(config.ssh_public_port, 2222);
assert_eq!(config.signup_policy, SignupPolicy::Invite);
assert_eq!(config.max_request_bytes, 1_048_576);
assert_eq!(config.max_connections, 1_024);
}
#[test]
fn rejects_unknown_fields() {
let (_directory, path) =
write_config("version = 1\npublic_url = \"https://tit.example/\"\nunknown = true\n");
assert!(matches!(load(&cli(&path)), Err(ConfigError::Parse { .. })));
}
#[test]
fn rejects_unsupported_versions() {
let (_directory, path) =
write_config("version = 2\npublic_url = \"https://tit.example/\"\n");
assert!(matches!(
load(&cli(&path)),
Err(ConfigError::UnsupportedVersion(2))
));
}
#[test]
fn rejects_remote_plain_http() {
let (_directory, path) =
write_config("version = 1\npublic_url = \"http://tit.example/\"\n");
assert!(matches!(
load(&cli(&path)),
Err(ConfigError::InsecurePublicUrl)
));
}
#[test]
fn permits_loopback_plain_http() {
let (_directory, path) =
write_config("version = 1\npublic_url = \"http://127.0.0.1:3000/\"\n");
load(&cli(&path)).expect("permit a loopback development URL");
}
#[test]
fn applies_cli_overrides() {
let (_directory, path) =
write_config("version = 1\npublic_url = \"https://tit.example/\"\n");
let options = Cli::try_parse_from([
"tit",
"--config",
path.to_str().expect("a UTF-8 path"),
"--public-url",
"https://code.example/",
"--http-listen",
"127.0.0.1:4000",
"--ssh-listen",
"127.0.0.1:4001",
"--ssh-public-host",
"ssh.example",
"--ssh-public-port",
"4002",
])
.expect("parse CLI overrides");
let config = load(&options).expect("load the overridden configuration");
assert_eq!(config.public_url.as_str(), "https://code.example/");
assert_eq!(config.http_listen.to_string(), "127.0.0.1:4000");
assert_eq!(config.ssh_listen.to_string(), "127.0.0.1:4001");
assert_eq!(
config.ssh_public_host,
Host::Domain("ssh.example".to_owned())
);
assert_eq!(config.ssh_public_port, 4002);
let (http, ssh) = config
.clone_urls("alice", "project")
.expect("generate clone URLs");
assert_eq!(http.as_str(), "https://code.example/alice/project");
assert_eq!(ssh.as_str(), "ssh://ssh.example:4002/alice/project");
}
#[test]
fn accepts_an_onion_ssh_hostname() {
let hostname = format!("{}.onion", "a".repeat(56));
let (_directory, path) = write_config(&format!(
"version = 1\npublic_url = \"https://tit.example/\"\n[ssh]\npublic_host = \"{hostname}\"\n"
));
let config = load(&cli(&path)).expect("load the onion SSH hostname");
assert_eq!(config.ssh_public_host, Host::Domain(hostname.to_owned()));
}
#[test]
fn rejects_an_invalid_ssh_hostname() {
let (_directory, path) = write_config(
"version = 1\npublic_url = \"https://tit.example/\"\n[ssh]\npublic_host = \"bad host\"\n",
);
assert!(matches!(
load(&cli(&path)),
Err(ConfigError::InvalidSshPublicHost { .. })
));
}
#[test]
fn rejects_an_unspecified_ssh_address() {
let (_directory, path) = write_config(
"version = 1\npublic_url = \"https://tit.example/\"\n[ssh]\npublic_host = \"0.0.0.0\"\n",
);
assert!(matches!(
load(&cli(&path)),
Err(ConfigError::InvalidSshPublicAddress(address)) if address.is_unspecified()
));
}
#[test]
fn omits_the_standard_ssh_port_from_clone_urls() {
let (_directory, path) = write_config(
"version = 1\npublic_url = \"https://tit.example/\"\n[ssh]\npublic_port = 22\n",
);
let config = load(&cli(&path)).expect("load the configuration");
let (_http, ssh) = config
.clone_urls("alice", "project")
.expect("generate clone URLs");
assert_eq!(ssh.as_str(), "ssh://tit.example/alice/project");
}
#[test]
fn rejects_zero_limits() {
let (_directory, path) = write_config(
r#"
version = 1
public_url = "https://tit.example/"
[limits]
max_request_bytes = 0
max_connections = 1
"#,
);
assert!(matches!(
load(&cli(&path)),
Err(ConfigError::ZeroLimit("max_request_bytes"))
));
}
#[test]
fn rejects_limits_that_are_too_large() {
let (_directory, path) = write_config(
r#"
version = 1
public_url = "https://tit.example/"
[limits]
max_request_bytes = 268435457
max_connections = 1
"#,
);
assert!(matches!(
load(&cli(&path)),
Err(ConfigError::LimitTooLarge("max_request_bytes"))
));
let (_directory, path) = write_config(
r#"
version = 1
public_url = "https://tit.example/"
[limits]
max_request_bytes = 1
max_connections = 100001
"#,
);
assert!(matches!(
load(&cli(&path)),
Err(ConfigError::LimitTooLarge("max_connections"))
));
}
}