use std::path::PathBuf;
use std::time::SystemTime;
use anyhow::{Context, Result, bail};
use opentunnel::Routes;
use opentunnel::protocol::names;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub routes: Routes,
}
pub fn path(profile: &str) -> PathBuf {
opentunnel::paths::config_dir().join(format!("{profile}.toml"))
}
pub fn load(profile: &str) -> Result<Config> {
let path = path(profile);
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
};
let config: Config =
toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
for (name, target) in &config.routes {
validate(name, target).with_context(|| format!("in {}", path.display()))?;
}
Ok(config)
}
pub fn save(profile: &str, config: &Config) -> Result<()> {
let path = path(profile);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let content = toml::to_string(config).context("serializing config")?;
std::fs::write(&path, content).with_context(|| format!("writing {}", path.display()))
}
pub fn modified(profile: &str) -> Option<SystemTime> {
std::fs::metadata(path(profile))
.and_then(|meta| meta.modified())
.ok()
}
pub fn normalize_target(target: &str) -> String {
if !target.is_empty() && target.bytes().all(|byte| byte.is_ascii_digit()) {
format!("127.0.0.1:{target}")
} else {
target.to_owned()
}
}
pub fn validate(name: &str, target: &str) -> Result<()> {
if !names::is_valid_route(name) {
bail!("invalid route name '{name}': use '@' or one lowercase DNS label");
}
if names::parse_target(target).is_none() {
bail!(
"invalid target '{target}': use a port or host:port, for example 3000 or 127.0.0.1:3000"
);
}
Ok(())
}
pub const RANDOM_ROUTE_LENGTH: usize = 16;
pub fn random_route_name(routes: &Routes) -> Result<String> {
use ring::rand::SecureRandom;
let random = ring::rand::SystemRandom::new();
for _ in 0..8 {
let mut bytes = [0u8; RANDOM_ROUTE_LENGTH / 2];
random
.fill(&mut bytes)
.map_err(|_| anyhow::anyhow!("the system random number generator failed"))?;
let name: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
if !routes.contains_key(&name) {
return Ok(name);
}
}
bail!("could not generate an unused route name")
}
pub fn choose_route(routes: &Routes, target: &str, name: Option<&str>) -> Result<(String, bool)> {
if let Some(name) = name {
validate(name, target)?;
return Ok((
name.to_owned(),
routes.get(name).is_some_and(|existing| existing == target),
));
}
validate(names::ROOT_ROUTE, target)?;
if let Some((existing, _)) = routes
.iter()
.find(|(_, existing)| existing.as_str() == target)
{
return Ok((existing.clone(), true));
}
Ok((random_route_name(routes)?, false))
}
pub fn public_hostname(route: &str, hostname: &str) -> String {
if route == names::ROOT_ROUTE {
hostname.to_owned()
} else {
format!("{route}.{hostname}")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn routes(entries: &[(&str, &str)]) -> Routes {
entries
.iter()
.map(|(name, target)| (name.to_string(), target.to_string()))
.collect()
}
#[test]
fn random_names_are_valid_hex_routes() {
let mut seen = std::collections::BTreeSet::new();
for _ in 0..1000 {
let name = random_route_name(&Routes::new()).unwrap();
assert_eq!(name.len(), RANDOM_ROUTE_LENGTH);
assert!(
name.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
);
assert!(names::is_valid_route(&name));
assert!(seen.insert(name), "random route names repeated");
}
}
#[test]
fn a_new_target_gets_a_random_name() {
let existing = routes(&[("api", "127.0.0.1:3000")]);
let (name, existed) = choose_route(&existing, "127.0.0.1:4000", None).unwrap();
assert_eq!(name.len(), RANDOM_ROUTE_LENGTH);
assert!(!existed);
}
#[test]
fn repeating_a_target_keeps_its_route() {
let existing = routes(&[("0123456789abcdef", "127.0.0.1:3000")]);
assert_eq!(
choose_route(&existing, "127.0.0.1:3000", None).unwrap(),
("0123456789abcdef".to_owned(), true)
);
}
#[test]
fn an_explicit_name_is_validated_and_used() {
let existing = routes(&[("api", "127.0.0.1:3000")]);
assert_eq!(
choose_route(&existing, "127.0.0.1:3000", Some("api")).unwrap(),
("api".to_owned(), true)
);
assert_eq!(
choose_route(&existing, "127.0.0.1:4000", Some("api")).unwrap(),
("api".to_owned(), false)
);
assert_eq!(
choose_route(&existing, "127.0.0.1:4000", Some("@")).unwrap(),
("@".to_owned(), false)
);
assert!(choose_route(&existing, "127.0.0.1:4000", Some("Not_Valid")).is_err());
assert!(choose_route(&existing, "not a target", None).is_err());
}
}