use std::net::{IpAddr, Ipv4Addr};
use ipnetwork::IpNetwork;
use crate::config::profile::Transport;
use crate::keys::device_key::DeviceKey;
use crate::keys::preshared_key::PresharedKey;
use super::wireguard::client_config::WireGuardConfig;
pub const FORMATS: &[&dyn ConfigFormat] = &[&WireGuardConfig];
pub trait ConfigFormat: Sync {
fn name(&self) -> &'static str;
fn recognises(&self, text: &str) -> bool;
fn read(&self, text: &str) -> Result<ClientConfig, String>;
}
#[derive(Debug)]
pub struct ClientConfig {
pub transport: Transport,
pub key: Option<DeviceKey>,
pub preshared_key: Option<PresharedKey>,
pub routes: Vec<IpNetwork>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ImportError {
#[error("this isn't a VPN client config gitaway can read. It reads: {known}")]
Unrecognised {
known: String,
},
#[error("this {format} config can't be used: {reason}")]
Invalid {
format: &'static str,
reason: String,
},
}
pub fn read(text: &str) -> Result<ClientConfig, ImportError> {
let Some(format) = FORMATS.iter().find(|format| format.recognises(text)) else {
let known: Vec<_> = FORMATS
.iter()
.map(|format| format!("{} configs", format.name()))
.collect();
return Err(ImportError::Unrecognised {
known: known.join(", "),
});
};
format.read(text).map_err(|reason| ImportError::Invalid {
format: format.name(),
reason,
})
}
impl ClientConfig {
pub fn likely_addresses(&self, lan_host: &str) -> Vec<IpAddr> {
let routed = |ip: IpAddr| self.routes.iter().any(|route| route.contains(ip));
let device = self.device_address();
let single_hosts = self
.routes
.iter()
.filter(|route| route.prefix() == max_prefix(route.ip()))
.map(IpNetwork::ip);
let lan = lan_host.parse::<IpAddr>().ok().filter(|&ip| routed(ip));
let first_of_subnet = match device {
Some(IpAddr::V4(ip)) => {
let first = IpAddr::V4(Ipv4Addr::from(u32::from(ip) & 0xFFFF_FF00 | 1));
Some(first).filter(|&first| routed(first))
}
_ => None,
};
let mut likely = Vec::new();
for ip in single_hosts.chain(lan).chain(first_of_subnet) {
if Some(ip) != device && !ip.is_unspecified() && !likely.contains(&ip) {
likely.push(ip);
}
}
likely
}
fn device_address(&self) -> Option<IpAddr> {
match &self.transport {
Transport::WireGuard(wg) => wg.address.map(|address| address.ip),
}
}
}
fn max_prefix(ip: IpAddr) -> u8 {
if ip.is_ipv4() { 32 } else { 128 }
}
#[cfg(test)]
mod tests {
use super::*;
fn config(address: &str, allowed_ips: &str) -> ClientConfig {
read(&format!(
"[Interface]\nAddress = {address}\n[Peer]\n\
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n\
Endpoint = vpn.example.net:51820\nAllowedIPs = {allowed_ips}\n"
))
.unwrap()
}
fn likely(config: &ClientConfig, lan: &str) -> Vec<String> {
config
.likely_addresses(lan)
.iter()
.map(ToString::to_string)
.collect()
}
#[test]
fn a_single_routed_address_comes_first_and_alone_when_nothing_else_is_routed() {
let c = config("10.0.0.3/32", "10.0.0.1/32");
assert_eq!(likely(&c, "192.0.2.10"), ["10.0.0.1"]);
}
#[test]
fn a_routed_home_network_puts_the_lan_address_before_the_guess() {
let c = config("10.0.0.3/32", "10.0.0.0/24, 192.0.2.0/24");
assert_eq!(likely(&c, "192.0.2.10"), ["192.0.2.10", "10.0.0.1"]);
}
#[test]
fn names_and_unrouted_addresses_are_not_tried() {
let c = config("10.0.0.3/32", "10.0.0.0/24");
assert_eq!(likely(&c, "git.example.com"), ["10.0.0.1"]);
assert_eq!(likely(&c, "192.0.2.10"), ["10.0.0.1"]);
}
#[test]
fn the_devices_own_address_is_never_a_guess() {
let c = config("10.0.0.1/24", "10.0.0.0/24");
assert!(likely(&c, "192.0.2.10").is_empty());
}
#[test]
fn text_in_no_known_format_is_refused_naming_what_is_read() {
let error = read("client\ndev tun\nremote vpn.example.net 1194\n").unwrap_err();
assert!(matches!(error, ImportError::Unrecognised { .. }), "{error}");
assert!(error.to_string().contains("WireGuard"), "{error}");
}
}