use crate::vpn::NetError;
use serde::Deserialize;
use std::time::Duration;
#[derive(Clone, Deserialize)]
pub struct WgInterface {
pub private_key: String,
pub address: String,
}
impl std::fmt::Debug for WgInterface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WgInterface")
.field("private_key", &"<redacted>")
.field("address", &self.address)
.finish()
}
}
#[derive(Clone, Deserialize)]
pub struct WgPeer {
pub public_key: String,
#[serde(default)]
pub preshared_key: Option<String>,
pub endpoint: String,
pub allowed_ips: String,
#[serde(default)]
pub persistent_keepalive: Option<u32>,
}
impl std::fmt::Debug for WgPeer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WgPeer")
.field("public_key", &self.public_key)
.field(
"preshared_key",
&self.preshared_key.as_ref().map(|_| "<redacted>"),
)
.field("endpoint", &self.endpoint)
.field("allowed_ips", &self.allowed_ips)
.field("persistent_keepalive", &self.persistent_keepalive)
.finish()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct WgProfile {
pub interface: WgInterface,
pub peer: WgPeer,
#[serde(default)]
pub mesh_subnet: Option<String>,
}
impl WgProfile {
pub fn to_conf(&self) -> Result<String, NetError> {
validate_key("PrivateKey", &self.interface.private_key)?;
validate_cidr("Address", &self.interface.address)?;
validate_key("PublicKey", &self.peer.public_key)?;
if let Some(psk) = &self.peer.preshared_key {
validate_key("PresharedKey", psk)?;
}
validate_cidr("AllowedIPs", &self.peer.allowed_ips)?;
validate_endpoint(&self.peer.endpoint)?;
let mut s = String::new();
s.push_str("[Interface]\n");
s.push_str(&format!("PrivateKey = {}\n", self.interface.private_key));
s.push_str(&format!("Address = {}\n", self.interface.address));
s.push_str(&format!("MTU = {}\n", mtu()));
s.push('\n');
s.push_str("[Peer]\n");
s.push_str(&format!("PublicKey = {}\n", self.peer.public_key));
if let Some(psk) = &self.peer.preshared_key {
s.push_str(&format!("PresharedKey = {}\n", psk));
}
s.push_str(&format!("AllowedIPs = {}\n", self.peer.allowed_ips));
if let Some(k) = self.peer.persistent_keepalive {
s.push_str(&format!("PersistentKeepalive = {}\n", k));
}
s.push_str(&format!("Endpoint = {}\n", self.peer.endpoint));
Ok(s)
}
pub fn mesh_subnet(&self) -> &str {
self.mesh_subnet
.as_deref()
.unwrap_or(&self.peer.allowed_ips)
}
}
fn validate_no_ctrl(label: &str, v: &str) -> Result<(), NetError> {
if v.contains('\n') || v.contains('\r') {
return Err(NetError::Profile(format!(
"{}: value contains a line break",
label
)));
}
if v.trim() != v {
return Err(NetError::Profile(format!(
"{}: value has leading/trailing whitespace",
label
)));
}
if v.is_empty() {
return Err(NetError::Profile(format!("{}: empty value", label)));
}
Ok(())
}
fn validate_key(label: &str, v: &str) -> Result<(), NetError> {
validate_no_ctrl(label, v)?;
let ok = v.len() == 44
&& v.ends_with('=')
&& v[..43]
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/');
if !ok {
return Err(NetError::Profile(format!(
"{}: not a 44-char base64 WireGuard key",
label
)));
}
Ok(())
}
fn mtu() -> u32 {
std::env::var("ZAKURO_WG_MTU")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.filter(|m| (576..=1500).contains(m))
.unwrap_or(1280)
}
fn validate_cidr(label: &str, v: &str) -> Result<(), NetError> {
validate_no_ctrl(label, v)?;
let (ip, prefix) = v
.split_once('/')
.ok_or_else(|| NetError::Profile(format!("{}: missing /prefix in CIDR", label)))?;
let addr: std::net::IpAddr = ip
.parse()
.map_err(|_| NetError::Profile(format!("{}: invalid IP in CIDR", label)))?;
let bits: u8 = prefix
.parse()
.map_err(|_| NetError::Profile(format!("{}: invalid prefix length", label)))?;
let max = if addr.is_ipv4() { 32 } else { 128 };
if bits > max {
return Err(NetError::Profile(format!(
"{}: prefix length out of range",
label
)));
}
Ok(())
}
fn validate_endpoint(v: &str) -> Result<(), NetError> {
validate_no_ctrl("Endpoint", v)?;
let (host, port) = v
.rsplit_once(':')
.ok_or_else(|| NetError::Profile("Endpoint: missing :port".to_string()))?;
if host.is_empty() {
return Err(NetError::Profile("Endpoint: empty host".to_string()));
}
let p: u16 = port
.parse()
.map_err(|_| NetError::Profile("Endpoint: invalid port".to_string()))?;
if p == 0 {
return Err(NetError::Profile(
"Endpoint: port must be 1..=65535".to_string(),
));
}
let host_inner = host.trim_start_matches('[').trim_end_matches(']');
let valid_host = host_inner.parse::<std::net::IpAddr>().is_ok()
|| host_inner
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-');
if !valid_host || host_inner.is_empty() {
return Err(NetError::Profile("Endpoint: invalid host".to_string()));
}
Ok(())
}
pub fn fetch_wg_profile() -> Result<WgProfile, NetError> {
let allow_file = std::env::var("ZAKURO_ALLOW_FILE_PROFILE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if allow_file {
if let Ok(path) = std::env::var("ZAKURO_WG_PROFILE_FILE") {
if !path.trim().is_empty() {
let body = std::fs::read_to_string(&path)
.map_err(|e| NetError::Fetch(format!("reading {}: {}", path, e)))?;
return serde_json::from_str(&body).map_err(|e| NetError::Profile(e.to_string()));
}
}
}
let api_key = match std::env::var("ZAKURO_API_KEY") {
Ok(k) if !k.trim().is_empty() => k,
_ => return Err(NetError::NoApiKey),
};
let api_url = crate::credentials::default_api_url();
let endpoint = format!(
"{}/api/broker/config/wireguard",
api_url.trim_end_matches('/')
);
let agent = ureq::Agent::new_with_config(
ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(10)))
.timeout_recv_response(Some(Duration::from_secs(10)))
.http_status_as_error(false)
.build(),
);
let resp = agent
.get(&endpoint)
.header("X-Broker-Api-Key", &api_key)
.call()
.map_err(|e| NetError::Fetch(e.to_string()))?;
if resp.status().as_u16() != 200 {
let status = resp.status();
let body = resp
.into_body()
.read_to_string()
.unwrap_or_else(|_| "<no body>".into());
return Err(NetError::Fetch(format!("status {}: {}", status, body)));
}
let body = resp
.into_body()
.read_to_string()
.map_err(|e| NetError::Fetch(e.to_string()))?;
serde_json::from_str(&body).map_err(|e| NetError::Profile(e.to_string()))
}
#[cfg(test)]
mod tests {
#[test]
fn conf_pins_a_safe_mtu() {
let p: WgProfile = serde_json::from_str(SAMPLE).expect("parse");
let conf = p.to_conf().expect("valid profile");
assert!(
conf.contains("MTU = 1280"),
"conf must pin an MTU that no path can black-hole:\n{conf}"
);
let iface = conf.split("[Peer]").next().unwrap();
assert!(
iface.contains("MTU ="),
"MTU must be in [Interface]:\n{conf}"
);
}
#[test]
fn mtu_is_overridable_within_sane_bounds() {
for bad in ["0", "99", "9000", "abc", ""] {
std::env::set_var("ZAKURO_WG_MTU", bad);
assert_eq!(
super::mtu(),
1280,
"{bad:?} should fall back to the default"
);
}
std::env::set_var("ZAKURO_WG_MTU", "1400");
assert_eq!(super::mtu(), 1400);
std::env::remove_var("ZAKURO_WG_MTU");
}
use super::*;
const SAMPLE: &str = r#"{
"interface": { "private_key": "MKCl4DM7FbX1sFUiFd8VqKzdIGUMwJ28rTgFYezsclc=", "address": "10.13.13.6/24" },
"peer": {
"public_key": "/Lzp+YIUBrNgdABzyR221uhfx2uOWi4m5ZAdgxXzhFs=",
"preshared_key": "dZBQTKaxfPxmAidvugeQ0yNUfG25k8aqJ/q2xFNSPC0=",
"endpoint": "144.202.121.242:51822",
"allowed_ips": "10.13.13.0/24",
"persistent_keepalive": 25
},
"mesh_subnet": "10.99.0.0/16"
}"#;
#[test]
fn parses_and_renders_conf() {
let p: WgProfile = serde_json::from_str(SAMPLE).expect("parse");
let conf = p.to_conf().expect("render");
assert!(conf.contains("[Interface]"));
assert!(conf.contains("PrivateKey = MKCl4DM7FbX1sFUiFd8VqKzdIGUMwJ28rTgFYezsclc="));
assert!(conf.contains("Address = 10.13.13.6/24"));
assert!(conf.contains("[Peer]"));
assert!(conf.contains("PublicKey = /Lzp+YIUBrNgdABzyR221uhfx2uOWi4m5ZAdgxXzhFs="));
assert!(conf.contains("PresharedKey = dZBQTKaxfPxmAidvugeQ0yNUfG25k8aqJ/q2xFNSPC0="));
assert!(conf.contains("AllowedIPs = 10.13.13.0/24"));
assert!(conf.contains("PersistentKeepalive = 25"));
assert!(conf.contains("Endpoint = 144.202.121.242:51822"));
assert_eq!(p.mesh_subnet(), "10.99.0.0/16");
}
#[test]
fn mesh_subnet_falls_back_to_allowed_ips() {
let json = r#"{"interface":{"private_key":"MKCl4DM7FbX1sFUiFd8VqKzdIGUMwJ28rTgFYezsclc=","address":"10.13.13.6/24"},
"peer":{"public_key":"/Lzp+YIUBrNgdABzyR221uhfx2uOWi4m5ZAdgxXzhFs=","endpoint":"144.202.121.242:51822","allowed_ips":"10.13.13.0/24"}}"#;
let p: WgProfile = serde_json::from_str(json).unwrap();
assert_eq!(p.mesh_subnet(), "10.13.13.0/24");
let conf = p.to_conf().expect("render");
assert!(!conf.contains("PresharedKey")); assert!(!conf.contains("PersistentKeepalive")); }
fn base_profile() -> WgProfile {
serde_json::from_str(SAMPLE).expect("parse sample")
}
#[test]
fn valid_profile_renders_ok() {
let conf = base_profile()
.to_conf()
.expect("valid profile should render");
assert!(conf.contains("Endpoint = 144.202.121.242:51822"));
}
#[test]
fn rejects_newline_injection_in_private_key() {
let mut p = base_profile();
p.interface.private_key =
"MKCl4DM7FbX1sFUiFd8VqKzdIGUMwJ28rTgFYezsclc=\nPostUp = curl evil".into();
match p.to_conf() {
Err(NetError::Profile(_)) => {}
other => panic!("expected Profile error, got {:?}", other),
}
}
#[test]
fn rejects_cr_injection_in_endpoint() {
let mut p = base_profile();
p.peer.endpoint = "144.202.121.242:51822\rPostUp = id".into();
assert!(matches!(p.to_conf(), Err(NetError::Profile(_))));
}
#[test]
fn rejects_leading_whitespace_in_address() {
let mut p = base_profile();
p.interface.address = " 10.13.13.6/24".into();
assert!(matches!(p.to_conf(), Err(NetError::Profile(_))));
}
#[test]
fn rejects_non_base64_key() {
let mut p = base_profile();
p.peer.public_key = "not-a-valid-44-char-base64-key".into();
assert!(matches!(p.to_conf(), Err(NetError::Profile(_))));
}
#[test]
fn rejects_address_without_cidr() {
let mut p = base_profile();
p.interface.address = "10.13.13.6".into(); assert!(matches!(p.to_conf(), Err(NetError::Profile(_))));
}
#[test]
fn rejects_endpoint_without_port() {
let mut p = base_profile();
p.peer.endpoint = "144.202.121.242".into();
assert!(matches!(p.to_conf(), Err(NetError::Profile(_))));
}
#[test]
fn rejects_bad_preshared_key() {
let mut p = base_profile();
p.peer.preshared_key = Some("short".into());
assert!(matches!(p.to_conf(), Err(NetError::Profile(_))));
}
#[test]
fn fetch_profile_sources() {
let dir = std::env::temp_dir().join(format!("zc-vpn-prof-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("wg.json");
std::fs::write(&path, SAMPLE).unwrap();
std::env::set_var("ZAKURO_ALLOW_FILE_PROFILE", "1");
std::env::set_var("ZAKURO_WG_PROFILE_FILE", &path);
let p = super::fetch_wg_profile().expect("fetch from file");
assert_eq!(p.interface.address, "10.13.13.6/24");
std::env::remove_var("ZAKURO_ALLOW_FILE_PROFILE");
std::env::remove_var("ZAKURO_API_KEY");
match super::fetch_wg_profile() {
Err(crate::vpn::NetError::NoApiKey) => {}
other => panic!(
"file profile must be ignored without opt-in, got {:?}",
other
),
}
std::env::remove_var("ZAKURO_WG_PROFILE_FILE");
std::env::remove_var("ZAKURO_API_KEY");
match super::fetch_wg_profile() {
Err(crate::vpn::NetError::NoApiKey) => {}
other => panic!("expected NoApiKey, got {:?}", other),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn debug_redacts_secrets() {
let p: WgProfile = serde_json::from_str(SAMPLE).unwrap();
let dbg = format!("{:?}", p);
assert!(
!dbg.contains("MKCl4DM7FbX1sFUiFd8VqKzdIGUMwJ28rTgFYezsclc="),
"private key leaked: {}",
dbg
);
assert!(
!dbg.contains("dZBQTKaxfPxmAidvugeQ0yNUfG25k8aqJ/q2xFNSPC0="),
"preshared key leaked: {}",
dbg
);
assert!(dbg.contains("<redacted>"));
}
}