diff --git a/Makefile b/Makefile index 972ff3eb..ff3afb75 100644 --- a/Makefile +++ b/Makefile @@ -4,3 +4,6 @@ docs: @cargo doc -p clash_doc --no-deps @echo "" > target/doc/index.html @cp -r target/doc ./docs + +test-no-docker: + CLASH_RS_CI=true cargo test --all --all-features diff --git a/clash/tests/data/config/rules.yaml b/clash/tests/data/config/rules.yaml index b71679e2..1ba339ca 100644 --- a/clash/tests/data/config/rules.yaml +++ b/clash/tests/data/config/rules.yaml @@ -4,8 +4,12 @@ socks-port: 8889 mixed-port: 8899 tun: - enable: false + enable: true device-id: "dev://utun1989" + route-all: false + routes: + - 0.0.0.0/1 + - 128.0.0.0/1 ipv6: true @@ -42,7 +46,7 @@ dns: nameserver: # - 114.114.114.114 # default value # - 1.1.1.1#auto # default value - - tls://1.1.1.1:853#en0 # DNS over TLS + - tls://1.1.1.1:853#auto # DNS over TLS # - dhcp://en0 # dns from dhcp allow-lan: true diff --git a/clash_lib/Cargo.toml b/clash_lib/Cargo.toml index bb6cbf66..4c4b11a0 100644 --- a/clash_lib/Cargo.toml +++ b/clash_lib/Cargo.toml @@ -143,4 +143,5 @@ security-framework = "2.11.1" windows = { version = "0.58", features = [ "Win32_Networking_WinSock", "Win32_Foundation", + "Win32_NetworkManagement_Rras", ]} \ No newline at end of file diff --git a/clash_lib/src/app/logging.rs b/clash_lib/src/app/logging.rs index 6a40564b..a255748f 100644 --- a/clash_lib/src/app/logging.rs +++ b/clash_lib/src/app/logging.rs @@ -26,6 +26,7 @@ impl From for filter::LevelFilter { LogLevel::Warning => filter::LevelFilter::WARN, LogLevel::Info => filter::LevelFilter::INFO, LogLevel::Debug => filter::LevelFilter::DEBUG, + LogLevel::Trace => filter::LevelFilter::TRACE, LogLevel::Silent => filter::LevelFilter::OFF, } } @@ -65,7 +66,7 @@ where tracing::Level::WARN => LogLevel::Warning, tracing::Level::INFO => LogLevel::Info, tracing::Level::DEBUG => LogLevel::Debug, - tracing::Level::TRACE => LogLevel::Debug, + tracing::Level::TRACE => LogLevel::Trace, }, msg: strs.join(" "), }; diff --git a/clash_lib/src/common/defer.rs b/clash_lib/src/common/defer.rs new file mode 100644 index 00000000..7aac16ea --- /dev/null +++ b/clash_lib/src/common/defer.rs @@ -0,0 +1,25 @@ +// https://stackoverflow.com/a/29963675/1109167 +pub struct ScopeCall { + pub c: Option, +} +impl Drop for ScopeCall { + fn drop(&mut self) { + self.c.take().unwrap()() + } +} + +#[macro_export] +macro_rules! expr { + ($e:expr) => { + $e + }; +} // tt hack + +#[macro_export] +macro_rules! defer { + ($($data: tt)*) => ( + let _scope_call = $crate::common::defer::ScopeCall { + c: Some(|| -> () { $crate::expr!({ $($data)* }) }) + }; + ) +} diff --git a/clash_lib/src/common/mod.rs b/clash_lib/src/common/mod.rs index 7aca6469..661a0ce9 100644 --- a/clash_lib/src/common/mod.rs +++ b/clash_lib/src/common/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod crypto; +pub mod defer; pub mod errors; pub mod geodata; pub mod http; diff --git a/clash_lib/src/config/def.rs b/clash_lib/src/config/def.rs index 31ed1dbb..b4a0d59a 100644 --- a/clash_lib/src/config/def.rs +++ b/clash_lib/src/config/def.rs @@ -4,6 +4,23 @@ use std::{collections::HashMap, fmt::Display, path::PathBuf, str::FromStr}; use serde::{Deserialize, Serialize}; use serde_yaml::Value; +fn default_tun_address() -> String { + "198.18.0.1/32".to_string() +} + +#[derive(Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct TunConfig { + pub enable: bool, + pub device_id: String, + /// tun interface address + #[serde(default = "default_tun_address")] + pub gateway: String, + pub routes: Option>, + #[serde(default)] + pub route_all: bool, +} + #[derive(Serialize, Deserialize, Default, Copy, Clone)] #[serde(rename_all = "lowercase")] pub enum RunMode { @@ -29,6 +46,7 @@ impl Display for RunMode { #[derive(PartialEq, Serialize, Deserialize, Default, Copy, Clone, Debug)] #[serde(rename_all = "lowercase")] pub enum LogLevel { + Trace, Debug, #[default] Info, @@ -41,6 +59,7 @@ pub enum LogLevel { impl Display for LogLevel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + LogLevel::Trace => write!(f, "trace"), LogLevel::Debug => write!(f, "debug"), LogLevel::Info => write!(f, "info"), LogLevel::Warning => write!(f, "warn"), @@ -313,7 +332,7 @@ pub struct Config { /// enable: true /// device-id: "dev://utun1989" /// ``` - pub tun: Option>, + pub tun: Option, } impl TryFrom for Config { @@ -553,9 +572,7 @@ allow-lan: false tun: enable: true stack: system - device-url: dev://clash0 - dns-hijack: - - 10.0.0.5 + device-id: dev://clash0 # This is only applicable when `allow-lan` is `true` # '*': bind all IP addresses diff --git a/clash_lib/src/config/internal/config.rs b/clash_lib/src/config/internal/config.rs index b8044fc4..1f930c79 100644 --- a/clash_lib/src/config/internal/config.rs +++ b/clash_lib/src/config/internal/config.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::{fmt::Display, net::IpAddr, str::FromStr}; +use ipnet::IpNet; use serde::{de::value::MapDeserializer, Deserialize, Serialize}; use serde_yaml::Value; @@ -97,15 +98,26 @@ impl TryFrom for Config { dns: (&c).try_into()?, experimental: c.experimental, tun: match c.tun { - Some(mapping) => { - TunConfig::deserialize(MapDeserializer::new(mapping.into_iter())) - .map_err(|e| { - Error::InvalidConfig(format!( - "invalid tun config: {}", - e - )) + Some(t) => TunConfig { + enable: t.enable, + device_id: t.device_id, + route_all: t.route_all, + routes: t + .routes + .map(|r| { + r.into_iter() + .map(|x| x.parse()) + .collect::, _>>() + }) + .transpose() + .map_err(|x| { + Error::InvalidConfig(format!("parse tun routes: {}", x)) })? - } + .unwrap_or_default(), + gateway: t.gateway.parse().map_err(|x| { + Error::InvalidConfig(format!("parse tun gateway: {}", x)) + })?, + }, None => TunConfig::default(), }, profile: Profile { @@ -279,19 +291,13 @@ pub struct Profile { // store_fake_ip: bool, } -#[derive(Deserialize, Default)] -#[serde(rename_all = "kebab-case")] +#[derive(Default)] pub struct TunConfig { pub enable: bool, - /// tun device id, could be - /// dev://utun886 # Linux - /// fd://3 # file descriptor - #[serde(alias = "device-url")] pub device_id: String, - /// tun device address - /// default: 198.18.0.0/16 - pub network: Option, - pub gateway: Option, + pub route_all: bool, + pub routes: Vec, + pub gateway: IpNet, } #[derive(Clone, Default)] diff --git a/clash_lib/src/proxy/tun/inbound.rs b/clash_lib/src/proxy/tun/inbound.rs index a2b27b6b..6ad5cb7f 100644 --- a/clash_lib/src/proxy/tun/inbound.rs +++ b/clash_lib/src/proxy/tun/inbound.rs @@ -2,6 +2,7 @@ use super::{datagram::TunDatagram, netstack}; use std::{net::SocketAddr, sync::Arc}; use futures::{SinkExt, StreamExt}; + use tracing::{debug, error, info, trace, warn}; use tun::{Device, TunPacket}; use url::Url; @@ -10,7 +11,10 @@ use crate::{ app::{dispatcher::Dispatcher, dns::ThreadSafeDNSResolver}, common::errors::{map_io_error, new_io_error}, config::internal::config::TunConfig, - proxy::{datagram::UdpPacket, utils::get_outbound_interface}, + proxy::{ + datagram::UdpPacket, tun::routes::maybe_add_routes, + utils::get_outbound_interface, + }, session::{Network, Session, SocksAddr, Type}, Error, Runner, }; @@ -148,9 +152,9 @@ pub fn get_runner( return Ok(None); } - let device_id = cfg.device_id; + let device_id = &cfg.device_id; - let u = Url::parse(&device_id) + let u = Url::parse(device_id) .map_err(|x| Error::InvalidConfig(format!("tun device {}", x)))?; let mut tun_cfg = tun::Configuration::default(); @@ -177,7 +181,8 @@ pub fn get_runner( } } - tun_cfg.up(); + let gw = cfg.gateway; + tun_cfg.address(gw.addr()).netmask(gw.netmask()).up(); let tun = tun::create_as_async(&tun_cfg) .map_err(|x| new_io_error(format!("failed to create tun device: {}", x)))?; @@ -185,6 +190,8 @@ pub fn get_runner( let tun_name = tun.get_ref().name().map_err(map_io_error)?; info!("tun started at {}", tun_name); + maybe_add_routes(&cfg, &tun_name)?; + let (stack, mut tcp_listener, udp_socket) = netstack::NetStack::with_buffer_size(512, 256).map_err(map_io_error)?; diff --git a/clash_lib/src/proxy/tun/mod.rs b/clash_lib/src/proxy/tun/mod.rs index cde10543..19bc5b90 100644 --- a/clash_lib/src/proxy/tun/mod.rs +++ b/clash_lib/src/proxy/tun/mod.rs @@ -2,3 +2,4 @@ pub mod inbound; pub use netstack_lwip as netstack; mod datagram; pub use inbound::get_runner as get_tun_runner; +mod routes; diff --git a/clash_lib/src/proxy/tun/routes/linux.rs b/clash_lib/src/proxy/tun/routes/linux.rs new file mode 100644 index 00000000..0ee6e018 --- /dev/null +++ b/clash_lib/src/proxy/tun/routes/linux.rs @@ -0,0 +1,9 @@ +use ipnet::IpNet; +use tracing::warn; + +use crate::proxy::utils::OutboundInterface; + +pub fn add_route(_: &OutboundInterface, _: &IpNet) -> std::io::Result<()> { + warn!("add_route is not implemented on Linux"); + Ok(()) +} diff --git a/clash_lib/src/proxy/tun/routes/macos.rs b/clash_lib/src/proxy/tun/routes/macos.rs new file mode 100644 index 00000000..45cdacff --- /dev/null +++ b/clash_lib/src/proxy/tun/routes/macos.rs @@ -0,0 +1,9 @@ +use ipnet::IpNet; +use tracing::warn; + +use crate::proxy::utils::OutboundInterface; + +pub fn add_route(_: &OutboundInterface, _: &IpNet) -> std::io::Result<()> { + warn!("add_route is not implemented on macOS"); + Ok(()) +} diff --git a/clash_lib/src/proxy/tun/routes/mod.rs b/clash_lib/src/proxy/tun/routes/mod.rs new file mode 100644 index 00000000..4b7d1e85 --- /dev/null +++ b/clash_lib/src/proxy/tun/routes/mod.rs @@ -0,0 +1,75 @@ +#[cfg(windows)] +mod windows; +#[cfg(windows)] +use windows::add_route; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +use macos::add_route; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +use linux::add_route; + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] +mod other; +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] +use other::add_route; + +use std::net::Ipv4Addr; + +use tracing::warn; + +use crate::{ + common::errors::map_io_error, config::internal::config::TunConfig, + proxy::utils::OutboundInterface, +}; + +use ipnet::IpNet; +use network_interface::NetworkInterfaceConfig; + +pub fn maybe_add_routes(cfg: &TunConfig, tun_name: &str) -> std::io::Result<()> { + if cfg.route_all || !cfg.routes.is_empty() { + let tun_iface = network_interface::NetworkInterface::show() + .map_err(map_io_error)? + .into_iter() + .find(|iface| iface.name == tun_name) + .map(|x| OutboundInterface { + name: x.name, + addr_v4: x.addr.iter().find_map(|addr| match addr { + network_interface::Addr::V4(addr) => Some(addr.ip), + _ => None, + }), + addr_v6: x.addr.iter().find_map(|addr| match addr { + network_interface::Addr::V6(addr) => Some(addr.ip), + _ => None, + }), + index: x.index, + }) + .expect("tun interface not found"); + + if cfg.route_all { + warn!( + "route_all is enabled, all traffic will be routed through the tun \ + interface" + ); + let default_routes = vec![ + IpNet::new(std::net::IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 1) + .unwrap(), + IpNet::new(std::net::IpAddr::V4(Ipv4Addr::new(128, 0, 0, 0)), 1) + .unwrap(), + ]; + for r in default_routes { + add_route(&tun_iface, &r).map_err(map_io_error)?; + } + } else { + for r in &cfg.routes { + add_route(&tun_iface, r).map_err(map_io_error)?; + } + } + } + + Ok(()) +} diff --git a/clash_lib/src/proxy/tun/routes/other.rs b/clash_lib/src/proxy/tun/routes/other.rs new file mode 100644 index 00000000..01bda458 --- /dev/null +++ b/clash_lib/src/proxy/tun/routes/other.rs @@ -0,0 +1,9 @@ +use ipnet::IpNet; +use tracing::warn; + +use crate::proxy::utils::OutboundInterface; + +pub fn add_route(_: &OutboundInterface, _: &IpNet) -> std::io::Result<()> { + warn!("add_route is not implemented on {}", std::env::consts::OS); + Ok(()) +} diff --git a/clash_lib/src/proxy/tun/routes/windows.rs b/clash_lib/src/proxy/tun/routes/windows.rs new file mode 100644 index 00000000..f91194e3 --- /dev/null +++ b/clash_lib/src/proxy/tun/routes/windows.rs @@ -0,0 +1,190 @@ +use ipnet::IpNet; +use std::{io, ptr::null_mut}; +use tracing::{error, info}; +use windows::Win32::{ + Foundation::{GetLastError, ERROR_SUCCESS}, + NetworkManagement::Rras::{ + RtmAddNextHop, RtmAddRouteToDest, RtmDeregisterEntity, RtmRegisterEntity, + RtmReleaseNextHops, RTM_ENTITY_ID, RTM_ENTITY_ID_0, RTM_ENTITY_ID_0_0, + RTM_ENTITY_INFO, RTM_NET_ADDRESS, RTM_NEXTHOP_INFO, RTM_REGN_PROFILE, + RTM_ROUTE_CHANGE_NEW, RTM_ROUTE_INFO, RTM_VIEW_MASK_MCAST, + RTM_VIEW_MASK_UCAST, + }, + Networking::WinSock::{AF_INET, AF_INET6, PROTO_IP_RIP}, +}; + +use crate::{common::errors::new_io_error, defer, proxy::utils::OutboundInterface}; + +const PROTO_TYPE_UCAST: u32 = 0; +const PROTO_VENDOR_ID: u32 = 0xFFFF; +#[inline] +fn protocol_id(typ: u32, vendor_id: u32, protocol_id: u32) -> u32 { + ((typ & 0x03) << 30) | ((vendor_id & 0x3FFF) << 16) | (protocol_id & 0xFFFF) +} + +pub fn add_route(via: &OutboundInterface, dest: &IpNet) -> io::Result<()> { + let cmd = format!( + "route add {} mask {} {} if {}", + dest.addr(), + dest.netmask(), + via.addr_v4.expect("tun interface has no ipv4 address"), + via.index, + ); + + info!("executing: {}", cmd); + + let output = std::process::Command::new("cmd") + .args(["/C", &cmd]) + .output() + .map_err(|e| new_io_error(e.to_string().as_str()))?; + + if output.status.success() { + info!("{} is now routed through {}", dest, via.name); + Ok(()) + } else { + let err = String::from_utf8_lossy(&output.stderr); + error!("failed to add route: {}", err); + Err(new_io_error(err.to_string().as_str())) + } +} + +/// Add a route to the routing table. +/// https://learn.microsoft.com/en-us/windows/win32/rras/add-and-update-routes-using-rtmaddroutetodest +/// FIXME: figure out why this doesn't work https://stackoverflow.com/questions/43632619/how-to-properly-use-rtmv2-and-rtmaddroutetodest +#[allow(dead_code)] +pub fn add_route_that_does_not_work( + via: &OutboundInterface, + dest: &IpNet, +) -> io::Result<()> { + let address_family = match dest { + IpNet::V4(_) => AF_INET, + IpNet::V6(_) => AF_INET6, + }; + + let mut rtm_reg_handle: isize = 0; + let mut rtm_entity_info = RTM_ENTITY_INFO::default(); + let mut rtm_regn_profile = RTM_REGN_PROFILE::default(); + + rtm_entity_info.RtmInstanceId = 0; + rtm_entity_info.AddressFamily = address_family.0; + rtm_entity_info.EntityId = RTM_ENTITY_ID { + Anonymous: RTM_ENTITY_ID_0 { + Anonymous: RTM_ENTITY_ID_0_0 { + EntityProtocolId: PROTO_IP_RIP.0.try_into().unwrap(), + EntityInstanceId: protocol_id( + PROTO_TYPE_UCAST, + PROTO_VENDOR_ID, + PROTO_IP_RIP.0.try_into().unwrap(), + ), + }, + }, + }; + let rv = unsafe { + RtmRegisterEntity( + &mut rtm_entity_info, + null_mut(), + None, + false, + &mut rtm_regn_profile, + &mut rtm_reg_handle, + ) + }; + + if rv != ERROR_SUCCESS.0 { + let err = unsafe { GetLastError().to_hresult().message() }; + error!("failed to register entity: {}", err); + return Err(new_io_error(err)); + } + + defer! { + let rv = unsafe {RtmDeregisterEntity(rtm_reg_handle)}; + if rv != ERROR_SUCCESS.0 { + let err = unsafe { GetLastError().to_hresult().message() }; + error!("failed to deregister entity: {}", err); + } + } + + let mut next_hop_info = RTM_NEXTHOP_INFO { + InterfaceIndex: via.index, + NextHopAddress: RTM_NET_ADDRESS { + AddressFamily: AF_INET.0, + NumBits: 32, + AddrBits: via + .addr_v4 + .expect("tun interface has no ipv4 address") + .to_ipv6_compatible() + .octets(), + }, + ..Default::default() + }; + + let mut next_hop_handle: isize = 0; + let mut change_flags = 0u32; + + let status = unsafe { + RtmAddNextHop( + rtm_reg_handle, + &mut next_hop_info, + &mut next_hop_handle, + &mut change_flags, + ) + }; + + if status != ERROR_SUCCESS.0 { + let err = unsafe { GetLastError().to_hresult().message() }; + error!("failed to add next hop: {}", err); + return Err(new_io_error(err)); + } + + defer! { + let mut next_hops = [next_hop_handle]; + let rv = unsafe { + RtmReleaseNextHops(rtm_reg_handle, 1, next_hops.as_mut_ptr()) + }; + + if rv != ERROR_SUCCESS.0 { + let err = unsafe { GetLastError().to_hresult().message() }; + error!("failed to release next hop: {}", err); + } + } + + let mut route_info = RTM_ROUTE_INFO::default(); + let mut net_address = RTM_NET_ADDRESS { + AddressFamily: address_family.0, + NumBits: dest.prefix_len() as u16, + AddrBits: match dest { + IpNet::V4(ip) => ip.addr().to_ipv6_compatible().octets(), + IpNet::V6(ip) => ip.addr().octets(), + }, + }; + route_info.Neighbour = next_hop_handle; + route_info.PrefInfo.Metric = 1; + route_info.BelongsToViews = RTM_VIEW_MASK_UCAST | RTM_VIEW_MASK_MCAST; + route_info.NextHopsList.NumNextHops = 1; + route_info.NextHopsList.NextHops[0] = next_hop_handle; + + let mut change_flags = RTM_ROUTE_CHANGE_NEW; + let rv = unsafe { + RtmAddRouteToDest( + rtm_reg_handle, + null_mut() as _, + &mut net_address, + &mut route_info, + f32::INFINITY as _, + 0, + 0, + 0, + &mut change_flags, + ) + }; + + if rv == ERROR_SUCCESS.0 { + info!("{} is now routed through {}", dest, via.name); + } else { + let err = unsafe { GetLastError().to_hresult().message() }; + error!("failed to add route: {}", err); + return Err(new_io_error(err)); + } + + Ok(()) +}