use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
pub const CONFIG_FILE: &str = "config.toml";
#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileConfig {
pub listen: Option<Vec<String>>,
pub public_url: Option<String>,
#[serde(default)]
pub proxy_client_ip_peers: Vec<std::net::IpAddr>,
}
impl FileConfig {
pub fn parse(text: &str) -> Result<Self> {
let config: Self = toml::from_str(text)?;
config.proxy_policy()?;
Ok(config)
}
pub fn proxy_policy(&self) -> Result<hennery_kernel::client_address::ProxyPolicy> {
hennery_kernel::client_address::ProxyPolicy::new(self.proxy_client_ip_peers.clone())
}
pub fn load(dir: &Path) -> Result<Self> {
let path = dir.join(CONFIG_FILE);
let mut file = match std::fs::File::open(&path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(err) => return Err(err).with_context(|| format!("read {}", path.display())),
};
let mode = file.metadata()?.permissions().mode() & 0o777;
if mode & 0o022 != 0 {
bail!(
"{} is writable by other users (mode {mode:o}); run `chmod go-w {}`",
path.display(),
path.display()
);
}
let mut text = String::new();
file.read_to_string(&mut text)
.with_context(|| format!("read {}", path.display()))?;
Self::parse(&text).with_context(|| format!("{}", path.display()))
}
pub fn listen(&self, given: &[String]) -> Result<Vec<String>> {
if !given.is_empty() {
return Ok(given.to_vec());
}
match &self.listen {
None => Ok(Vec::new()),
Some(addresses) if addresses.is_empty() => {
bail!("listen in config.toml is empty: give at least one address, or remove the key")
}
Some(addresses) => Ok(addresses.clone()),
}
}
pub fn public_url(&self, given: Option<&str>) -> Option<String> {
given.map(str::to_string).or_else(|| self.public_url.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn proxy_peers_are_machine_only_exact_bounded_and_not_mistyped() {
for text in [
"",
"proxy_client_ip_peers = []",
"proxy_client_ip_peers = ['127.0.0.1', '::1']",
] {
assert!(FileConfig::parse(text).is_ok(), "{text}");
}
for text in [
"proxy_client_ip_peers = ['localhost']",
"proxy_client_ip_peers = ['127.0.0.0/8']",
"proxy_client_ip_peers = ['127.0.0.1:7117']",
"proxy_client_ip_peers = ['0.0.0.0']",
"proxy_client_ip_peers = ['::']",
"proxy_client_ip_peers = ['224.0.0.1']",
"proxy_client_ip_peers = ['127.0.0.1', '::ffff:127.0.0.1']",
"proxy_client_ip_peers = ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4', '127.0.0.5', '127.0.0.6', '127.0.0.7', '127.0.0.8', '127.0.0.9']",
"proxy_client_ip_peer = ['127.0.0.1']",
] {
assert!(FileConfig::parse(text).is_err(), "{text}");
}
}
#[test]
fn the_file_holds_listen_and_public_url_and_nothing_else() {
let config = FileConfig::parse(
"listen = [\"127.0.0.1:7117\", \"[::1]:7117\"]\npublic_url = \"https://hennery.example\"\n",
)
.unwrap();
assert_eq!(
config,
FileConfig {
listen: Some(vec!["127.0.0.1:7117".into(), "[::1]:7117".into()]),
public_url: Some("https://hennery.example".into()),
..Default::default()
}
);
assert_eq!(FileConfig::parse("").unwrap(), FileConfig::default());
let typo = FileConfig::parse("listens = [\"127.0.0.1:7117\"]\n").unwrap_err();
assert!(format!("{typo:#}").contains("unknown field"), "{typo:#}");
assert!(FileConfig::parse("listen = \"127.0.0.1:7117\"\n").is_err());
}
#[test]
fn a_missing_file_is_the_defaults() {
let dir = std::env::temp_dir().join(format!("hennery-config-missing-{}", std::process::id()));
assert_eq!(FileConfig::load(&dir).unwrap(), FileConfig::default());
assert!(!dir.exists(), "loading made the directory");
}
#[test]
fn a_file_others_can_write_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE);
std::fs::write(&path, "listen = [\"127.0.0.1:7117\"]\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(FileConfig::load(dir.path()).unwrap().listen.is_some());
for mode in [0o664, 0o646] {
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
let err = FileConfig::load(dir.path()).unwrap_err();
assert!(format!("{err:#}").contains("chmod go-w"), "{mode:o}: {err:#}");
}
}
#[test]
fn flags_and_the_environment_win_over_the_file() {
let file = FileConfig {
listen: Some(vec!["127.0.0.1:1".into()]),
public_url: Some("https://file.example".into()),
..Default::default()
};
assert_eq!(file.listen(&[]).unwrap(), ["127.0.0.1:1"]);
assert_eq!(file.listen(&["127.0.0.1:2".into()]).unwrap(), ["127.0.0.1:2"]);
assert_eq!(file.public_url(None).as_deref(), Some("https://file.example"));
assert_eq!(
file.public_url(Some("https://flag.example")).as_deref(),
Some("https://flag.example")
);
let empty = FileConfig::default();
assert!(empty.listen(&[]).unwrap().is_empty());
assert_eq!(empty.public_url(None), None);
}
#[test]
fn an_explicit_empty_listen_in_the_file_is_refused() {
let empty_list = FileConfig {
listen: Some(vec![]),
public_url: None,
..Default::default()
};
let err = empty_list.listen(&[]).unwrap_err();
assert!(format!("{err:#}").contains("listen in config.toml is empty"), "{err:#}");
assert_eq!(empty_list.listen(&["127.0.0.1:1".into()]).unwrap(), ["127.0.0.1:1"]);
assert!(FileConfig::default().listen(&[]).unwrap().is_empty());
}
}