added IPv6 support; rewrote the igd implementation

This commit is contained in:
Lyn 2024-11-29 07:13:09 +01:00
parent 59d315b853
commit 75b34fc48a
7 changed files with 1976 additions and 748 deletions

811
Cargo.lock generated

File diff suppressed because it is too large Load diff

1604
Cargo.nix

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "wgautomesh" name = "wgautomesh"
version = "0.1.0" version = "0.1.1"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -11,11 +11,12 @@ anyhow = "1.0"
log = "0.4" log = "0.4"
pretty_env_logger = "0.5" pretty_env_logger = "0.5"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0.215", features = ["derive"] }
bincode = "1.3" bincode = "1.3"
toml = { version = "0.8", default-features = false, features = ["parse"] } toml = { version = "0.8", default-features = false, features = ["parse"] }
xsalsa20poly1305 = "0.9" xsalsa20poly1305 = "0.9"
blake3 = "1.5" blake3 = "1.5"
pnet = "0.35.0"
igd = { version = "0.12", default-features = false } rupnp = "2.0.0"
get_if_addrs = "0.5" tokio = { version = "1.41.1", features = ["rt", "rt-multi-thread", "macros"] }
futures = "0.3.31"

2
rust-toolchain.toml Normal file
View file

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

205
src/igd.rs Normal file
View file

@ -0,0 +1,205 @@
use log::*;
use std::net::IpAddr;
use futures::stream::{StreamExt};
use rupnp::{Device, Service};
use rupnp::ssdp::{SearchTarget, URN};
use std::str::FromStr;
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result, Error};
const IGD_LEASE_DURATION: Duration = Duration::from_secs(300);
const WAN_IPV6_FIREWALL_CONTROL: URN = URN::service("schemas-upnp-org", "WANIPv6FirewallControl", 1);
const WAN_IP_CONNECTION: URN = URN::service("schemas-upnp-org", "WANIPConnection", 1);
pub async fn igd_loop_iter(listen_port:u16, external_port: u16, use_ipv6: bool) -> Result<()> {
let lease_duration: u64 = IGD_LEASE_DURATION.as_secs();
//find gateway compatible with publishing the port for required IP version
let gateway = find_gateway(use_ipv6).await?;
let gateway_ip: IpAddr = IpAddr::from_str(gateway.url().host().unwrap())?;
//find corresponding interface local IP to forward in gateway
let local_ip = select_local_ip_for_gateway(gateway_ip,use_ipv6)?;
println!("local_ip: {}", local_ip);
debug!(
"Found gateway: {:?} at IP {}, making announce",
gateway.friendly_name(),
gateway.url().host().unwrap()
);
if use_ipv6 {
create_ipv6_firewall_pinhole(
gateway,
&local_ip,
listen_port,
external_port,
&lease_duration,
)
.await
} else {
create_ipv4_port_mapping(
gateway,
&local_ip,
listen_port,
external_port,
&lease_duration,
)
.await
}
}
/// Create a port mapping to forward a given Port for a given internal IPv4
async fn create_ipv4_port_mapping(
gateway: Device,
internal_ip: &IpAddr,
listen_port: u16,
external_port: u16,
lease_duration: &u64,
) -> Result<()> {
let wan_ip_con_service = gateway
.find_service(&WAN_IP_CONNECTION)
.expect("Gateway passed doesn't offer the required service to create IPv4 port mapping");
let arguments = format!(
"<NewRemoteHost/>
<NewExternalPort>{external_port}</NewExternalPort>
<NewProtocol>UDP</NewProtocol>
<NewInternalPort>{listen_port}</NewInternalPort>
<NewInternalClient>{internal_ip}</NewInternalClient>
<NewEnabled>1</NewEnabled>
<NewPortMappingDescription>Wireguard via wgautomesh</NewPortMappingDescription>
<NewLeaseDuration>{lease_duration}</NewLeaseDuration>"
);
debug!(
"Adding port mapping for internal IP {} on internal port {}, external port {}",
internal_ip, listen_port, external_port,
);
let result = wan_ip_con_service
.action(gateway.url(), "AddPortMapping", &arguments)
.await;
if result.is_ok() {
Ok(())
} else {
bail!(
"Error trying to add IPv4 port mapping: {}.\
Note: Have you checked whether your router allows this device to create (IPv4) port mappings?",
result.err().unwrap()
);
}
}
/// Create a pinhole for a given IPv6 address on a given port
async fn create_ipv6_firewall_pinhole(gateway: Device,
ip: &IpAddr,
listen_port: u16,
external_port: u16,
lease_duration: &u64,
) -> Result<()> {
let wan_ip6_fw_con = gateway
.find_service(&WAN_IPV6_FIREWALL_CONTROL)
.expect("Gateway passed doesn't offer the required service to create IPv6 pinholes");
let (firewall_enabled, can_create_inbound_pinhole) =
get_firewall_status(&gateway, &wan_ip6_fw_con).await;
if !firewall_enabled {
debug!("Gateway firewall is not enabled, incoming connections should be allowed as-is on all ports");
return Ok(());
} else if !can_create_inbound_pinhole {
bail!("Gateway said creating inbound IPv6 pinholes isn't allowed")
}
let arguments = format!(
"<RemoteHost/>
<RemotePort>{external_port}</RemotePort>
<Protocol>17</Protocol>
<InternalPort>{listen_port}</InternalPort>
<InternalClient>{ip}</InternalClient>
<LeaseTime>{lease_duration}</LeaseTime>"
);
debug!(
"Opening firewall pinhole for IP {} on internal port {}, external port {}",
ip, listen_port, external_port,
);
let result = wan_ip6_fw_con
.action(gateway.url(), "AddPinhole", &arguments)
.await;
if result.is_ok() {
Ok(())
} else {
bail!(
"Error trying to open IPv6 pinhole: {}\
Note: Have you checked whether your router allows this device to create (IPv6) pinholes?",
result.err().unwrap()
);
}
}
/// Asks the Gateway for the IPv6 Firewall status (whether the firewall is enabled AND whether devices in this network are allowed to create IPv6 pinholes per policy).
/// Note: This only works on IGDv2 supporting firewalls (-> any firewall that can do IPv6)
async fn get_firewall_status(gateway: &Device, igd_service: &Service) -> (bool, bool) {
let firewall_status_response = igd_service
.action(gateway.url(), "GetFirewallStatus", "")
.await
.unwrap();
let firewall_enabled: bool =
(u32::from_str(firewall_status_response.get("FirewallEnabled").unwrap()).unwrap()) != 0;
let can_create_inbound_pinhole: bool = u32::from_str(
firewall_status_response
.get("InboundPinholeAllowed")
.unwrap(),
)
.unwrap()
!= 0;
(firewall_enabled, can_create_inbound_pinhole)
}
/// Find a Gateway compatible with either IPv4 (supports WANIPConnection) or IPv6 (supports WANIPv6FirewallControl)
async fn find_gateway(ipv6_required: bool) -> Result<Device, Error> {
let search_urn: URN = if ipv6_required {
WAN_IPV6_FIREWALL_CONTROL
} else {
WAN_IP_CONNECTION
};
let discovered_devices = rupnp::discover(
&SearchTarget::URN(search_urn.clone()),
Duration::from_secs(3),
)
.await?
.filter_map(|result| async {
match result {
Ok(device) => Some(device),
Err(_) => None,
}
});
futures::pin_mut!(discovered_devices);
discovered_devices.next().await.ok_or_else(||anyhow!("Couldn't find any gateways supporting {}. Is port 1900 open for incoming connections from local networks?", search_urn.typ()))
}
/// Returns a list of IPs assigned to interfaces that are in the same subnet as a given IP
fn interface_ips_in_same_subnet_as(ip_to_match: IpAddr) -> Result<Vec<IpAddr>, Error> {
let interfaces = pnet::datalink::interfaces();
let ipnets = interfaces
.iter()
.filter_map(|interface| {
if interface
.ips
.iter()
.any(|ipnetwork| ipnetwork.contains(ip_to_match))
{
Some(interface.ips.clone())
} else {
None
}
})
.next()
.context("Couldn't find any local IPs within the same network as given IP")?;
let ips = ipnets.iter().map(|ip| ip.ip()).collect();
Ok(ips)
}
/// Selects the local IP we tell the Gateway to port forward to (/pinhole) later on
/// Note: As soon as this[https://github.com/jakobhellermann/ssdp-client/issues/11] is fixed and the dependency is upgraded in rupnp, we can simplify it
fn select_local_ip_for_gateway(gateway: IpAddr, output_ipv6: bool) -> Result<IpAddr, Error> {
//get IPs in same subnet as Gateway
let ips = interface_ips_in_same_subnet_as(gateway)?;
//removes IPv6 that are not globally routable as pinholing those would be pointless
let v6_cleaned_up = ips
.iter()
.filter(|ip| ip.is_ipv4() || (ip.is_global() && ip.is_ipv6()));
//filters IPs to match the ipv6 output criteria
let mut viable_ips = v6_cleaned_up.filter(|ip| ip.is_ipv6() == output_ipv6);
//return the first IP
viable_ips
.next()
.copied()
.ok_or_else(|| anyhow!("Couldn't find an IP address"))
}

View file

@ -1,5 +1,8 @@
#![feature(ip)]
mod igd;
use igd::*;
use std::collections::HashMap; use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr, SocketAddrV4, ToSocketAddrs, UdpSocket}; use std::net::{IpAddr, SocketAddr, ToSocketAddrs, UdpSocket};
use std::process::Command; use std::process::Command;
use std::sync::Mutex; use std::sync::Mutex;
use std::thread; use std::thread;
@ -26,8 +29,6 @@ const PERSIST_INTERVAL: Duration = Duration::from_secs(600);
const LAN_BROADCAST_INTERVAL: Duration = Duration::from_secs(60); const LAN_BROADCAST_INTERVAL: Duration = Duration::from_secs(60);
const IGD_INTERVAL: Duration = Duration::from_secs(60); const IGD_INTERVAL: Duration = Duration::from_secs(60);
const IGD_LEASE_DURATION: Duration = Duration::from_secs(300);
type Pubkey = String; type Pubkey = String;
#[derive(Deserialize)] #[derive(Deserialize)]
@ -42,6 +43,10 @@ struct Config {
/// The file where to persist known peer addresses /// The file where to persist known peer addresses
persist_file: Option<String>, persist_file: Option<String>,
/// Use IPv6 instead of IPv4
#[serde(default)]
ipv6: bool,
/// Enable LAN discovery /// Enable LAN discovery
#[serde(default)] #[serde(default)]
lan_discovery: bool, lan_discovery: bool,
@ -61,7 +66,7 @@ struct Peer {
/// The peer's Wireguard address /// The peer's Wireguard address
address: IpAddr, address: IpAddr,
/// An optionnal Wireguard endpoint used to initialize a connection to this peer /// An optionnal Wireguard endpoint used to initialize a connection to this peer
endpoint: Option<String>, endpoint: Option<SocketAddr>,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -189,9 +194,13 @@ enum Gossip {
impl Daemon { impl Daemon {
fn new(config: Config) -> Result<Self> { fn new(config: Config) -> Result<Self> {
let gossip_key = kdf(config.gossip_secret.as_deref().unwrap_or_default()); let gossip_key = kdf(config.gossip_secret.as_deref().unwrap_or_default());
let (our_pubkey, listen_port, _peers) = wg_dump(&config)?; let (our_pubkey, listen_port, _peers) = wg_dump(&config)?;
let socket = UdpSocket::bind(SocketAddr::new("0.0.0.0".parse()?, config.gossip_port))?; let bind_addr = if config.ipv6 {
SocketAddr::new("::".parse()?, config.gossip_port) // IPv6
} else {
SocketAddr::new("0.0.0.0".parse()?, config.gossip_port) // IPv4
};
let socket = UdpSocket::bind(bind_addr)?;
socket.set_broadcast(true)?; socket.set_broadcast(true)?;
Ok(Daemon { Ok(Daemon {
@ -396,59 +405,29 @@ impl Daemon {
pubkey: self.our_pubkey.clone(), pubkey: self.our_pubkey.clone(),
listen_port: self.listen_port, listen_port: self.listen_port,
})?; })?;
let addr = SocketAddr::new("255.255.255.255".parse().unwrap(), self.config.gossip_port); let addr = if self.config.ipv6 {
SocketAddr::new("ff05::1".parse().unwrap(), self.config.gossip_port)
} else {
SocketAddr::new("255.255.255.255".parse().unwrap(), self.config.gossip_port)
};
self.socket.send_to(&packet, addr)?; self.socket.send_to(&packet, addr)?;
Ok(()) Ok(())
} }
fn igd_loop(&self) { fn igd_loop(&self) {
if let Some(external_port) = self.config.upnp_forward_external_port { if let Some(external_port) = self.config.upnp_forward_external_port {
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
rt.block_on(async {
loop { loop {
if let Err(e) = self.igd_loop_iter(external_port) { if let Err(e) = igd_loop_iter(self.listen_port,external_port, self.config.ipv6).await {
error!("IGD loop error: {}", e); error!("IGD loop error: {}", e);
} }
std::thread::sleep(IGD_INTERVAL); tokio::time::sleep(IGD_INTERVAL).await;
} }
});
} }
} }
fn igd_loop_iter(&self, external_port: u16) -> Result<()> {
let gateway = igd::search_gateway(Default::default())?;
let gwa = gateway.addr.ip().octets();
let cmplen = match gwa {
[192, 168, _, _] => 3,
[10, _, _, _] => 2,
_ => bail!(
"Gateway IP does not appear to be in a local network ({})",
gateway.addr.ip()
),
};
let private_ip = get_if_addrs::get_if_addrs()?
.into_iter()
.map(|i| i.addr.ip())
.filter_map(|a| match a {
std::net::IpAddr::V4(a4) if a4.octets()[..cmplen] == gwa[..cmplen] => Some(a4),
_ => None,
})
.next()
.ok_or(anyhow!("No interface has an IP on same subnet as gateway"))?;
debug!(
"IGD: gateway is {}, private IP is {}, making announce",
gateway.addr, private_ip
);
gateway.add_port(
igd::PortMappingProtocol::UDP,
external_port,
SocketAddrV4::new(private_ip, self.listen_port),
IGD_LEASE_DURATION.as_secs() as u32,
"Wireguard via wgautomesh",
)?;
Ok(())
}
fn make_packet(&self, gossip: &Gossip) -> Result<Vec<u8>> { fn make_packet(&self, gossip: &Gossip) -> Result<Vec<u8>> {
use xsalsa20poly1305::{ use xsalsa20poly1305::{
aead::{Aead, KeyInit, OsRng}, aead::{Aead, KeyInit, OsRng},
@ -467,6 +446,7 @@ impl Daemon {
} }
} }
struct State { struct State {
peers: HashMap<Pubkey, PeerInfo>, peers: HashMap<Pubkey, PeerInfo>,
gossip: HashMap<Pubkey, Vec<(SocketAddr, u64)>>, gossip: HashMap<Pubkey, Vec<(SocketAddr, u64)>>,
@ -644,7 +624,11 @@ impl State {
endpoints endpoints
} }
}; };
let single_ip_cidr:u8 = if peer_cfg.address.is_ipv6(){
128
} else {
32
};
if !endpoints.is_empty() { if !endpoints.is_empty() {
let endpoint = endpoints[i % endpoints.len()].0; let endpoint = endpoints[i % endpoints.len()].0;
@ -657,7 +641,6 @@ impl State {
// Skip if we are already using that endpoint // Skip if we are already using that endpoint
continue; continue;
} }
info!("Configure {} with endpoint {}", peer_cfg.pubkey, endpoint); info!("Configure {} with endpoint {}", peer_cfg.pubkey, endpoint);
Command::new("wg") Command::new("wg")
.args([ .args([
@ -670,7 +653,7 @@ impl State {
"persistent-keepalive", "persistent-keepalive",
"10", "10",
"allowed-ips", "allowed-ips",
&format!("{}/32", peer_cfg.address), &format!("{}/{}", peer_cfg.address, single_ip_cidr),
]) ])
.output()?; .output()?;
let packet = daemon.make_packet(&Gossip::Ping)?; let packet = daemon.make_packet(&Gossip::Ping)?;
@ -687,7 +670,7 @@ impl State {
"peer", "peer",
&peer_cfg.pubkey, &peer_cfg.pubkey,
"allowed-ips", "allowed-ips",
&format!("{}/32", peer_cfg.address), &format!("{}/{}", peer_cfg.address, single_ip_cidr),
]) ])
.output()?; .output()?;
} }