use base64::Engine;
use rand::RngCore;
use x25519_dalek::{PublicKey, StaticSecret};
use crate::error::AppError;
use crate::models::peer::Peer;
pub fn generate_keypair() -> (String, String) {
let mut rng = rand::thread_rng();
let mut private_bytes = [0u8; 32];
rng.fill_bytes(&mut private_bytes);
let secret = StaticSecret::from(private_bytes);
let public = PublicKey::from(&secret);
let private_b64 = base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
let public_b64 = base64::engine::general_purpose::STANDARD.encode(public.as_bytes());
(private_b64, public_b64)
}
pub async fn apply_syncconf(interface: &str, config_path: &str) -> Result<(), AppError> {
let output = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new("wg")
.args(["syncconf", interface, config_path])
.output(),
)
.await
.map_err(|_| AppError::Internal("wg syncconf timed out after 30s".into()))?
.map_err(|e| AppError::Internal(format!("wg syncconf failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AppError::Internal(format!("wg syncconf error: {stderr}")));
}
Ok(())
}
pub async fn restart_interface(iface: &str) -> Result<(), AppError> {
crate::services::validation::validate_wg_interface_name(iface)?;
let _ = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new("wg-quick")
.args(["down", iface])
.output(),
)
.await;
let output = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new("wg-quick")
.args(["up", iface])
.output(),
)
.await
.map_err(|_| AppError::Internal("wg-quick up timed out after 30s".into()))?
.map_err(|e| AppError::Internal(format!("wg-quick up failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AppError::Internal(format!(
"wg-quick up {iface} failed: {stderr}"
)));
}
Ok(())
}
pub async fn get_wg_status(
interface: &str,
) -> Result<Vec<crate::grpc::generated::WgInterface>, AppError> {
let output = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new("wg")
.args(["show", interface, "dump"])
.output(),
)
.await
.map_err(|_| AppError::Internal("wg show timed out after 30s".into()))?
.map_err(|e| AppError::Internal(format!("wg show failed: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut interfaces: Vec<crate::grpc::generated::WgInterface> = Vec::new();
let mut current_iface: Option<crate::grpc::generated::WgInterface> = None;
for line in stdout.lines() {
let fields: Vec<&str> = line.split('\t').collect();
if fields.is_empty() {
continue;
}
match fields[0] {
key if fields.len() >= 4 && !key.is_empty() => {
if let Some(iface) = current_iface.take() {
interfaces.push(iface);
}
current_iface = Some(crate::grpc::generated::WgInterface {
name: interface.to_string(),
public_key: fields[1].to_string(),
listen_port: fields[2].parse().unwrap_or(0),
peers: Vec::new(),
});
}
_peer_key if fields.len() >= 8 => {
if let Some(ref mut iface) = current_iface {
iface.peers.push(crate::grpc::generated::WgPeerStatus {
public_key: fields[0].to_string(),
endpoint: fields.get(2).map(|s| s.to_string()).unwrap_or_default(),
allowed_ips: fields.get(3).map(|s| s.to_string()).unwrap_or_default(),
latest_handshake: fields.get(4).map(|s| s.to_string()).unwrap_or_default(),
transfer_rx: fields.get(5).map(|s| s.to_string()).unwrap_or_default(),
transfer_tx: fields.get(6).map(|s| s.to_string()).unwrap_or_default(),
});
}
}
_ => {}
}
}
if let Some(iface) = current_iface {
interfaces.push(iface);
}
Ok(interfaces)
}
pub fn generate_cluster_wg_config(
nodes: &[crate::models::node::Node],
private_key: &str,
listen_port: u16,
) -> String {
let mut config = String::new();
config.push_str("[Interface]\n");
config.push_str(&format!("PrivateKey = {private_key}\n"));
config.push_str(&format!("ListenPort = {listen_port}\n"));
config.push_str("Table = off\n\n");
for node in nodes {
if node.wg_pubkey.is_empty() {
continue;
}
config.push_str("[Peer]\n");
config.push_str(&format!("PublicKey = {}\n", node.wg_pubkey));
let host = node
.listen_addr
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(&node.listen_addr);
config.push_str(&format!("Endpoint = {host}:{listen_port}\n"));
let mut allowed = format!("{}/32", node.tunnel_ip);
if !node.tunnel_ipv6.is_empty() {
allowed.push_str(&format!(", {}/128", node.tunnel_ipv6));
}
config.push_str(&format!("AllowedIPs = {allowed}\n"));
config.push_str("PersistentKeepalive = 25\n\n");
}
config
}
pub fn generate_config(peer: &Peer, settings: &crate::models::settings::Settings) -> String {
let mut config = String::new();
config.push_str("[Interface]\n");
if let Some(ref key) = peer.wg_private_key {
config.push_str(&format!("PrivateKey = {key}\n"));
}
config.push_str(&format!("ListenPort = {}\n", peer.wg_listen_port));
config.push_str(&format!("Table = {}\n", settings.wg_table));
if settings.wg_mtu > 0 {
config.push_str(&format!("MTU = {}\n", settings.wg_mtu));
}
if settings.wg_fwmark > 0 {
config.push_str(&format!("FwMark = {}\n", settings.wg_fwmark));
}
let mut addresses = Vec::new();
if let Some(ref ipv4) = peer.ipv4_tunnel_local {
addresses.push(format!("{ipv4}/32"));
}
if let Some(ref ipv6) = peer.ipv6_tunnel_local {
addresses.push(format!("{ipv6}/128"));
}
if !addresses.is_empty() {
config.push_str(&format!("Address = {}\n", addresses.join(", ")));
}
let mut post_up =
String::from("PostUp = ip link set %i up; sysctl -w net.ipv6.conf.%i.autoconf=0");
if let Some(ref ipv4) = peer.ipv4_tunnel_local {
post_up.push_str(&format!("; ip addr add {ipv4}/32 dev %i"));
}
if let Some(ref ipv6) = peer.ipv6_tunnel_local {
post_up.push_str(&format!("; ip addr add {ipv6}/128 dev %i"));
}
if !settings.wg_post_up.is_empty() {
if let Err(e) = crate::services::input_sanitizer::validate_post_script(&settings.wg_post_up)
{
tracing::warn!("Skipping invalid wg_post_up: {e}");
} else {
post_up.push_str(&format!("; {}", settings.wg_post_up));
}
}
config.push_str(&format!("{post_up}\n"));
let mut post_down = String::from("PostDown = ip link set %i down");
if !settings.wg_post_down.is_empty() {
if let Err(e) =
crate::services::input_sanitizer::validate_post_script(&settings.wg_post_down)
{
tracing::warn!("Skipping invalid wg_post_down: {e}");
} else {
post_down.push_str(&format!("; {}", settings.wg_post_down));
}
}
config.push_str(&format!("{post_down}\n"));
config.push('\n');
config.push_str("[Peer]\n");
if let Some(ref key) = peer.wg_public_key {
config.push_str(&format!("PublicKey = {key}\n"));
}
config.push_str(&format!(
"Endpoint = {}:{}\n",
peer.wg_remote_address, peer.wg_remote_port
));
let allowed_ips = format!(
"{}, {}, fe80::/10",
settings.dn42_ipv4_prefix, settings.dn42_ipv6_prefix
);
config.push_str(&format!("AllowedIPs = {allowed_ips}\n"));
config.push_str("PersistentKeepalive = 25\n");
config
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::settings::Settings;
fn test_settings() -> Settings {
Settings {
local_asn: 4242420000,
bird_template_name: "test".into(),
bird_router_id: "1.2.3.4".into(),
wg_default_listen_port: 42420,
dn42_ipv4_prefix: "172.20.0.0/14".into(),
dn42_ipv6_prefix: "fd00::/8".into(),
wg_table: "off".into(),
wg_mtu: 1420,
wg_fwmark: 0,
wg_post_up: String::new(),
wg_post_down: String::new(),
roa_mode: "none".into(),
roa_static_v4_url: String::new(),
roa_static_v6_url: String::new(),
roa_rtr_address: String::new(),
roa_rtr_port: 323,
bird_import_limit: 9000,
bird_export_filter: String::new(),
bird_import_filter: String::new(),
enable_community_filters: false,
enable_bfd: false,
bfd_interval_ms: 300,
bfd_multiplier: 3,
cluster_tunnel_ipv6_range: String::new(),
enable_confederation: false,
confederation_local_asn: 0,
}
}
fn test_peer() -> Peer {
Peer {
id: "test-id".into(),
name: "test-peer".into(),
description: None,
asn: 4242420001,
local_asn: 4242420000,
wg_private_key: Some("privkey".into()),
wg_public_key: Some("pubkey".into()),
wg_remote_address: "10.0.0.1".into(),
wg_remote_port: 42420,
wg_listen_port: 42420,
wg_interface_name: "wg0".into(),
ipv4_tunnel_local: Some("172.20.1.1".into()),
ipv4_tunnel_remote: Some("172.20.1.2".into()),
ipv6_tunnel_local: Some("fd00::1".into()),
ipv6_tunnel_remote: Some("fd00::2".into()),
multiprotocol: true,
extended_nexthop: true,
sessions: 0,
passive: false,
import_max_prefix: None,
export_max_prefix: None,
enabled: true,
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T00:00:00Z".into(),
origin_node_id: None,
}
}
#[test]
fn test_generate_keypair_format() {
let (priv_key, pub_key) = generate_keypair();
assert_eq!(priv_key.len(), 44);
assert_eq!(pub_key.len(), 44);
use base64::Engine;
assert!(base64::engine::general_purpose::STANDARD
.decode(&priv_key)
.is_ok());
assert!(base64::engine::general_purpose::STANDARD
.decode(&pub_key)
.is_ok());
}
#[test]
fn test_generate_config_contains_sections() {
let config = generate_config(&test_peer(), &test_settings());
assert!(config.contains("[Interface]"));
assert!(config.contains("[Peer]"));
assert!(config.contains("PrivateKey = privkey"));
assert!(config.contains("PublicKey = pubkey"));
assert!(config.contains("Table = off"));
assert!(config.contains("MTU = 1420"));
assert!(config.contains("PostUp ="));
assert!(config.contains("PostDown ="));
assert!(config.contains("PersistentKeepalive = 25"));
assert!(config.contains("fe80::/10"));
}
#[test]
fn test_generate_config_fwmark_omitted_when_zero() {
let config = generate_config(&test_peer(), &test_settings());
assert!(!config.contains("FwMark"));
}
#[test]
fn test_generate_config_fwmark_included_when_set() {
let mut s = test_settings();
s.wg_fwmark = 51820;
let config = generate_config(&test_peer(), &s);
assert!(config.contains("FwMark = 51820"));
}
#[test]
fn test_generate_config_post_up_has_tunnel_ips() {
let config = generate_config(&test_peer(), &test_settings());
assert!(config.contains("172.20.1.1/32"));
assert!(config.contains("fd00::1/128"));
}
#[test]
fn test_generate_config_custom_post_up_appended() {
let mut s = test_settings();
s.wg_post_up = "ip route add 10.0.0.0/8 via 172.20.1.2".into();
let config = generate_config(&test_peer(), &s);
assert!(config.contains("ip route add 10.0.0.0/8 via 172.20.1.2"));
}
#[test]
fn test_generate_config_without_keys() {
let mut peer = test_peer();
peer.wg_private_key = None;
peer.wg_public_key = None;
let config = generate_config(&peer, &test_settings());
assert!(!config.contains("PrivateKey"));
assert!(!config.contains("PublicKey"));
}
#[test]
fn test_generate_cluster_wg_config_has_interface_and_peers() {
let nodes = vec![
crate::models::node::Node {
id: "n1".into(),
name: "node-a".into(),
listen_addr: "1.2.3.4:3000".into(),
local_asn: 4242420000,
description: None,
online: true,
last_seen_at: String::new(),
created_at: String::new(),
updated_at: String::new(),
wg_pubkey: "pubkey-a".into(),
tunnel_ip: "10.255.0.1".into(),
tunnel_ipv6: String::new(),
wg_private_key: String::new(),
},
crate::models::node::Node {
id: "n2".into(),
name: "node-b".into(),
listen_addr: "5.6.7.8:3000".into(),
local_asn: 4242420001,
description: None,
online: true,
last_seen_at: String::new(),
created_at: String::new(),
updated_at: String::new(),
wg_pubkey: "pubkey-b".into(),
tunnel_ip: "10.255.0.2".into(),
tunnel_ipv6: String::new(),
wg_private_key: String::new(),
},
];
let config = generate_cluster_wg_config(&nodes, "key-a", 51821);
assert!(config.contains("[Interface]"));
assert!(config.contains("PrivateKey = key-a"));
assert!(config.contains("ListenPort = 51821"));
assert!(config.contains("[Peer]"));
assert!(config.contains("PublicKey = pubkey-b"));
assert!(config.contains("Endpoint = 5.6.7.8:51821"));
}
#[test]
fn test_generate_cluster_wg_config_empty_nodes() {
let nodes: Vec<crate::models::node::Node> = vec![];
let config = generate_cluster_wg_config(&nodes, "key-a", 51821);
assert!(config.contains("[Interface]"));
assert!(!config.contains("[Peer]"));
}
}