feat: implement split-tunneling bypass for TCP/UDP and native UDP NAT

This commit is contained in:
ospab 2026-05-29 00:06:11 +03:00
parent 61c6d0d10b
commit 4975073e3f
11 changed files with 562 additions and 125 deletions

View File

@ -2,6 +2,7 @@ mod proxy;
mod wintun_handler; mod wintun_handler;
mod linux_handler; mod linux_handler;
pub mod native_handler; pub mod native_handler;
mod udp_nat;
pub async fn run_tun_tunnel( pub async fn run_tun_tunnel(
config: crate::config::ClientConfig, config: crate::config::ClientConfig,

View File

@ -162,50 +162,7 @@ pub async fn run_native_tunnel(
let debug_udp = config.debug; let debug_udp = config.debug;
let mut udp_proxy_task = tokio::spawn(async move { let mut udp_proxy_task = tokio::spawn(async move {
if let Some(udp_sock) = udp_socket { if let Some(udp_sock) = udp_socket {
let (mut rx, tx) = udp_sock.split(); super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp).await;
let tx = std::sync::Arc::new(tokio::sync::Mutex::new(tx));
while let Some((payload, src, dst)) = rx.next().await {
if payload.is_empty() { continue; }
if dst.port() == 53 {
let tx_clone = tx.clone();
let proxy_addr = udp_proxy_addr.clone();
tokio::spawn(async move {
if debug_udp { tracing::info!("Native TUN intercepted UDP DNS to {}", dst); }
if let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await {
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
let mut buf = [0u8; 2];
if socks.read_exact(&mut buf).await.is_err() || buf[0] != 5 || buf[1] != 0 { return; }
let mut req = vec![5, 1, 0];
match dst.ip() {
std::net::IpAddr::V4(v4) => { req.push(1); req.extend_from_slice(&v4.octets()); }
std::net::IpAddr::V6(v6) => { req.push(4); req.extend_from_slice(&v6.octets()); }
}
req.extend_from_slice(&dst.port().to_be_bytes());
if socks.write_all(&req).await.is_err() { return; }
let mut rep = [0u8; 10];
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
let len = payload.len() as u16;
let mut dns_req = Vec::with_capacity(2 + payload.len());
dns_req.extend_from_slice(&len.to_be_bytes());
dns_req.extend_from_slice(&payload);
if socks.write_all(&dns_req).await.is_ok() {
let mut len_buf = [0u8; 2];
if socks.read_exact(&mut len_buf).await.is_ok() {
let resp_len = u16::from_be_bytes(len_buf) as usize;
let mut response_buf = vec![0u8; resp_len];
if socks.read_exact(&mut response_buf).await.is_ok() {
let _ = tx_clone.lock().await.send((response_buf, dst, src)).await;
}
}
}
}
});
}
}
} }
}); });
@ -413,54 +370,13 @@ pub async fn run_native_tunnel_from_fd(
} }
}); });
let udp_proxy_addr = config.local_proxy.bind_addr.clone(); let udp_proxy_addr = config.local_proxy.bind_addr.clone();
let debug_udp = config.debug; let debug_udp = config.debug;
let mut udp_proxy_task = tokio::spawn(async move { let mut udp_proxy_task = tokio::spawn(async move {
if let Some(udp_sock) = udp_socket { if let Some(udp_sock) = udp_socket {
let (mut rx, tx) = udp_sock.split(); super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp).await;
let tx = std::sync::Arc::new(tokio::sync::Mutex::new(tx));
while let Some((payload, src, dst)) = rx.next().await {
if payload.is_empty() { continue; }
if dst.port() == 53 {
let tx_clone = tx.clone();
let proxy_addr = udp_proxy_addr.clone();
tokio::spawn(async move {
if debug_udp { tracing::info!("Native TUN intercepted UDP DNS to {}", dst); }
if let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await {
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
let mut buf = [0u8; 2];
if socks.read_exact(&mut buf).await.is_err() || buf[0] != 5 || buf[1] != 0 { return; }
let mut req = vec![5, 1, 0];
match dst.ip() {
std::net::IpAddr::V4(v4) => { req.push(1); req.extend_from_slice(&v4.octets()); }
std::net::IpAddr::V6(v6) => { req.push(4); req.extend_from_slice(&v6.octets()); }
}
req.extend_from_slice(&dst.port().to_be_bytes());
if socks.write_all(&req).await.is_err() { return; }
let mut rep = [0u8; 10];
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
let len = payload.len() as u16;
let mut dns_req = Vec::with_capacity(2 + payload.len());
dns_req.extend_from_slice(&len.to_be_bytes());
dns_req.extend_from_slice(&payload);
if socks.write_all(&dns_req).await.is_ok() {
let mut len_buf = [0u8; 2];
if socks.read_exact(&mut len_buf).await.is_ok() {
let resp_len = u16::from_be_bytes(len_buf) as usize;
let mut response_buf = vec![0u8; resp_len];
if socks.read_exact(&mut response_buf).await.is_ok() {
let _ = tx_clone.lock().await.send((response_buf, dst, src)).await;
}
}
}
}
});
}
}
} }
}); });

View File

@ -9,6 +9,199 @@ use tokio::time::{timeout, Duration};
use crate::config::{ExclusionConfig, LocalProxyConfig, OstpConfig}; use crate::config::{ExclusionConfig, LocalProxyConfig, OstpConfig};
use crate::tunnel::{ProxyEvent, ProxyToClientMsg}; use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
#[cfg(target_os = "windows")]
use std::os::windows::io::AsRawSocket;
#[cfg(target_os = "linux")]
use std::os::fd::AsRawFd;
#[cfg(target_os = "windows")]
#[link(name = "ws2_32")]
extern "system" {
fn setsockopt(
s: usize,
level: i32,
optname: i32,
optval: *const u8,
optlen: i32,
) -> i32;
}
#[cfg(target_os = "windows")]
fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index: u32) -> std::io::Result<()> {
let s = socket.as_raw_socket() as usize;
if is_ipv6 {
let optval = if_index;
let ret = unsafe {
setsockopt(
s,
41, // IPPROTO_IPV6
31, // IPV6_UNICAST_IF
&optval as *const u32 as *const u8,
4,
)
};
if ret != 0 {
return Err(std::io::Error::last_os_error());
}
} else {
let optval = if_index.to_be();
let ret = unsafe {
setsockopt(
s,
0, // IPPROTO_IP
31, // IP_UNICAST_IF
&optval as *const u32 as *const u8,
4,
)
};
if ret != 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Result<()> {
let fd = socket.as_raw_fd();
let mut if_name_bytes = if_name.as_bytes().to_vec();
if_name_bytes.push(0);
let ret = unsafe {
libc::setsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_BINDTODEVICE,
if_name_bytes.as_ptr() as *const std::ffi::c_void,
if_name_bytes.len() as libc::socklen_t,
)
};
if ret != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
fn get_windows_physical_if_index() -> Option<u32> {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = std::process::Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.args([
"-NoProfile",
"-Command",
"Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Where-Object { $_.InterfaceAlias -notmatch 'ostp' -and $_.InterfaceAlias -notmatch 'tun' -and $_.InterfaceAlias -notmatch 'wintun' } | Sort-Object RouteMetric | Select-Object -ExpandProperty InterfaceIndex -First 1"
])
.output()
.ok()?;
if output.status.success() {
let s = String::from_utf8_lossy(&output.stdout);
if let Ok(index) = s.trim().parse::<u32>() {
return Some(index);
}
}
}
None
}
fn get_linux_physical_if_name() -> Option<String> {
#[cfg(target_os = "linux")]
{
let output = std::process::Command::new("ip")
.args(["route", "show", "default"])
.output()
.ok()?;
if output.status.success() {
let s = String::from_utf8_lossy(&output.stdout);
if let Some(dev_part) = s.split_whitespace().skip_while(|w| *w != "dev").nth(1) {
return Some(dev_part.to_string());
}
}
}
None
}
async fn connect_bypassing_tun(
target: &str,
physical_if_index: Option<u32>,
_physical_if_name: &Option<String>,
) -> Result<TcpStream> {
let resolved = tokio::net::lookup_host(target).await
.with_context(|| format!("failed to resolve host for bypass connect: {target}"))?;
let mut last_err = None;
for addr in resolved {
let socket = if addr.is_ipv6() {
let s = tokio::net::TcpSocket::new_v6()?;
let _ = s.bind("[::]:0".parse().unwrap());
s
} else {
let s = tokio::net::TcpSocket::new_v4()?;
let _ = s.bind("0.0.0.0:0".parse().unwrap());
s
};
#[cfg(target_os = "windows")]
if let Some(if_index) = physical_if_index {
if let Err(e) = bind_socket_to_interface(&socket, addr.is_ipv6(), if_index) {
tracing::warn!("Failed to bind TCP socket to interface {}: {}", if_index, e);
}
}
#[cfg(target_os = "linux")]
if let Some(ref if_name) = _physical_if_name {
if let Err(e) = bind_socket_to_interface(&socket, if_name) {
tracing::warn!("Failed to bind TCP socket to interface {}: {}", if_name, e);
}
}
match socket.connect(addr).await {
Ok(stream) => return Ok(stream),
Err(e) => {
last_err = Some(e);
}
}
}
Err(anyhow!(
"direct connect failed: {:?}",
last_err.map(|e| e.to_string()).unwrap_or_else(|| "no addresses resolved".to_string())
))
}
async fn create_udp_socket_bypassing_tun(
is_ipv6: bool,
physical_if_index: Option<u32>,
_physical_if_name: &Option<String>,
) -> Result<UdpSocket> {
let addr: std::net::SocketAddr = if is_ipv6 {
"[::]:0".parse().unwrap()
} else {
"0.0.0.0:0".parse().unwrap()
};
let socket = UdpSocket::bind(addr).await
.with_context(|| format!("failed to bind direct UdpSocket to wildcard {}", addr))?;
#[cfg(target_os = "windows")]
if let Some(if_index) = physical_if_index {
if let Err(e) = bind_socket_to_interface(&socket, is_ipv6, if_index) {
tracing::warn!("Failed to bind UDP socket to interface index {}: {}", if_index, e);
}
}
#[cfg(target_os = "linux")]
if let Some(ref if_name) = _physical_if_name {
if let Err(e) = bind_socket_to_interface(&socket, if_name) {
tracing::warn!("Failed to bind UDP socket to interface {}: {}", if_name, e);
}
}
Ok(socket)
}
pub async fn run_local_socks5_proxy( pub async fn run_local_socks5_proxy(
cfg: LocalProxyConfig, cfg: LocalProxyConfig,
ostp: OstpConfig, ostp: OstpConfig,
@ -28,7 +221,17 @@ pub async fn run_local_socks5_proxy(
tracing::info!("Windows system proxy: set HTTP proxy to {}. tun2socks: SOCKS5 on same address.", cfg.bind_addr); tracing::info!("Windows system proxy: set HTTP proxy to {}. tun2socks: SOCKS5 on same address.", cfg.bind_addr);
} }
let matcher = ExclusionMatcher::new(&exclusions); let physical_if_index = tokio::task::spawn_blocking(get_windows_physical_if_index).await.unwrap_or(None);
let physical_if_name = tokio::task::spawn_blocking(get_linux_physical_if_name).await.unwrap_or(None);
if physical_if_index.is_some() {
tracing::info!("Local proxy physical interface index: {:?}", physical_if_index);
}
if physical_if_name.is_some() {
tracing::info!("Local proxy physical interface name: {:?}", physical_if_name);
}
let matcher = ExclusionMatcher::new(&exclusions, physical_if_index, physical_if_name.clone());
let (connect_tx, mut connect_rx) = mpsc::channel(128); let (connect_tx, mut connect_rx) = mpsc::channel(128);
let max_chunk = ostp.mtu.saturating_sub(150).max(512); let max_chunk = ostp.mtu.saturating_sub(150).max(512);
@ -148,15 +351,20 @@ async fn handle_udp_associate(
event_tx: mpsc::Sender<ProxyEvent>, event_tx: mpsc::Sender<ProxyEvent>,
mut rx: mpsc::UnboundedReceiver<ProxyToClientMsg>, mut rx: mpsc::UnboundedReceiver<ProxyToClientMsg>,
close_tx: mpsc::Sender<u16>, close_tx: mpsc::Sender<u16>,
_debug: bool, debug: bool,
matcher: ExclusionMatcher,
connect_timeout: Duration,
) -> Result<()> { ) -> Result<()> {
let mut client_udp_addr = None; let client_udp_addr = Arc::new(std::sync::Mutex::new(None));
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
let udp_socket = Arc::new(udp_socket); let udp_socket = Arc::new(udp_socket);
let sock_rx = udp_socket.clone(); let sock_rx = udp_socket.clone();
let sock_tx = udp_socket; let sock_tx = udp_socket;
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
let mut tcp_buf = [0u8; 1]; let mut tcp_buf = [0u8; 1];
loop { loop {
tokio::select! { tokio::select! {
@ -166,8 +374,11 @@ async fn handle_udp_associate(
} }
res = sock_rx.recv_from(&mut buf) => { res = sock_rx.recv_from(&mut buf) => {
let (len, addr) = res?; let (len, addr) = res?;
if client_udp_addr.is_none() { {
client_udp_addr = Some(addr); let mut guard = client_udp_addr.lock().unwrap();
if guard.is_none() {
*guard = Some(addr);
}
} }
if len < 4 { continue; } if len < 4 { continue; }
let frag = buf[2]; let frag = buf[2];
@ -199,12 +410,66 @@ async fn handle_udp_associate(
_ => continue, _ => continue,
}; };
let payload = bytes::Bytes::copy_from_slice(&buf[header_len..len]); let payload = bytes::Bytes::copy_from_slice(&buf[header_len..len]);
let _ = event_tx.send(ProxyEvent::UdpData { stream_id, target, payload }).await;
// Check if target should bypass the tunnel
if matcher.should_bypass(&target, connect_timeout).await {
if debug {
tracing::info!("proxy UDP BYPASS target={}", target);
}
// Resolve target to find if it is IPv4 or IPv6
if let Ok(resolved_addrs) = tokio::net::lookup_host(&target).await {
if let Some(target_addr) = resolved_addrs.into_iter().next() {
let is_ipv6 = target_addr.is_ipv6();
let direct_socket = if is_ipv6 {
if direct_udp_v6.is_none() {
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
Ok(s) => {
let s_arc = Arc::new(s);
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
direct_udp_v6 = Some(s_arc);
}
Err(e) => {
tracing::error!("Failed to create bypass UDP v6 socket: {}", e);
}
}
}
&direct_udp_v6
} else {
if direct_udp_v4.is_none() {
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
Ok(s) => {
let s_arc = Arc::new(s);
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
direct_udp_v4 = Some(s_arc);
}
Err(e) => {
tracing::error!("Failed to create bypass UDP v4 socket: {}", e);
}
}
}
&direct_udp_v4
};
if let Some(s) = direct_socket {
if let Err(e) = s.send_to(&payload, target_addr).await {
if debug {
tracing::warn!("failed to send bypass UDP packet to {}: {}", target_addr, e);
}
}
}
}
}
} else {
let _ = event_tx.send(ProxyEvent::UdpData { stream_id, target, payload }).await;
}
} }
msg = rx.recv() => { msg = rx.recv() => {
match msg { match msg {
Some(ProxyToClientMsg::UdpData(target, data)) => { Some(ProxyToClientMsg::UdpData(target, data)) => {
if let Some(client_addr) = client_udp_addr { if let Some(client_addr) = {
let guard = client_udp_addr.lock().unwrap();
*guard
} {
let mut packet = vec![0x00, 0x00, 0x00]; let mut packet = vec![0x00, 0x00, 0x00];
let mut parts = target.rsplitn(2, ':'); let mut parts = target.rsplitn(2, ':');
let port_str = parts.next().unwrap_or("0"); let port_str = parts.next().unwrap_or("0");
@ -239,6 +504,52 @@ async fn handle_udp_associate(
Ok(()) Ok(())
} }
fn spawn_direct_udp_reader(
direct_socket: Arc<UdpSocket>,
sock_tx: Arc<UdpSocket>,
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
debug: bool,
) {
tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
match direct_socket.recv_from(&mut buf).await {
Ok((len, target_addr)) => {
let client_addr = {
let guard = client_udp_addr.lock().unwrap();
*guard
};
if let Some(client_addr) = client_addr {
let mut packet = vec![0x00, 0x00, 0x00];
if let Ok(ipv4) = target_addr.ip().to_string().parse::<std::net::Ipv4Addr>() {
packet.push(0x01);
packet.extend_from_slice(&ipv4.octets());
} else if let Ok(ipv6) = target_addr.ip().to_string().parse::<std::net::Ipv6Addr>() {
packet.push(0x04);
packet.extend_from_slice(&ipv6.octets());
} else {
continue;
}
packet.extend_from_slice(&target_addr.port().to_be_bytes());
packet.extend_from_slice(&buf[..len]);
if let Err(e) = sock_tx.send_to(&packet, client_addr).await {
if debug {
tracing::warn!("failed to send direct UDP response to client: {e}");
}
}
}
}
Err(e) => {
if debug {
tracing::debug!("direct UDP socket read loop exiting: {e}");
}
break;
}
}
}
});
}
async fn handle_proxy_client( async fn handle_proxy_client(
mut client: TcpStream, mut client: TcpStream,
stream_id: u16, stream_id: u16,
@ -327,14 +638,32 @@ async fn handle_proxy_client(
client.write_all(&reply).await?; client.write_all(&reply).await?;
event_tx.send(ProxyEvent::UdpAssociate { stream_id }).await?; event_tx.send(ProxyEvent::UdpAssociate { stream_id }).await?;
return handle_udp_associate(client, udp_socket, stream_id, event_tx, rx, close_tx, debug).await; return handle_udp_associate(
client,
udp_socket,
stream_id,
event_tx,
rx,
close_tx,
debug,
matcher,
connect_timeout,
).await;
} }
if debug { if debug {
tracing::info!("proxy CONNECT stream_id={stream_id} target={target}"); tracing::info!("proxy CONNECT stream_id={stream_id} target={target}");
} }
if matcher.should_bypass(&target, connect_timeout).await { if matcher.should_bypass(&target, connect_timeout).await {
return direct_connect_socks5(client, stream_id, &target, close_tx, debug).await; return direct_connect_socks5(
client,
stream_id,
&target,
matcher.physical_if_index,
&matcher.physical_if_name,
close_tx,
debug,
).await;
} }
event_tx.send(ProxyEvent::NewStream { stream_id, target: target.clone() }).await?; event_tx.send(ProxyEvent::NewStream { stream_id, target: target.clone() }).await?;
@ -417,6 +746,8 @@ async fn handle_proxy_client(
&target, &target,
method.as_str(), method.as_str(),
header_bytes, header_bytes,
matcher.physical_if_index,
&matcher.physical_if_name,
close_tx, close_tx,
debug, debug,
).await; ).await;
@ -513,10 +844,16 @@ async fn handle_proxy_client(
struct ExclusionMatcher { struct ExclusionMatcher {
domain_suffix: Vec<String>, domain_suffix: Vec<String>,
cidrs: Vec<Cidr>, cidrs: Vec<Cidr>,
physical_if_index: Option<u32>,
physical_if_name: Option<String>,
} }
impl ExclusionMatcher { impl ExclusionMatcher {
fn new(exclusions: &ExclusionConfig) -> Self { fn new(
exclusions: &ExclusionConfig,
physical_if_index: Option<u32>,
physical_if_name: Option<String>,
) -> Self {
let mut cidrs = Vec::new(); let mut cidrs = Vec::new();
for ip in &exclusions.ips { for ip in &exclusions.ips {
if let Some(cidr) = parse_cidr(ip) { if let Some(cidr) = parse_cidr(ip) {
@ -532,6 +869,8 @@ impl ExclusionMatcher {
.filter(|d| !d.is_empty()) .filter(|d| !d.is_empty())
.collect(), .collect(),
cidrs, cidrs,
physical_if_index,
physical_if_name,
} }
} }
@ -645,14 +984,15 @@ async fn direct_connect_socks5(
mut client: TcpStream, mut client: TcpStream,
stream_id: u16, stream_id: u16,
target: &str, target: &str,
physical_if_index: Option<u32>,
physical_if_name: &Option<String>,
close_tx: mpsc::Sender<u16>, close_tx: mpsc::Sender<u16>,
debug: bool, debug: bool,
) -> Result<()> { ) -> Result<()> {
if debug { if debug {
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}"); tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
} }
let mut remote = TcpStream::connect(target).await let mut remote = connect_bypassing_tun(target, physical_if_index, physical_if_name).await?;
.with_context(|| format!("direct connect failed: {target}"))?;
client.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; client.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
let _ = tokio::io::copy_bidirectional(&mut client, &mut remote).await; let _ = tokio::io::copy_bidirectional(&mut client, &mut remote).await;
@ -666,14 +1006,15 @@ async fn direct_connect_http(
target: &str, target: &str,
method: &str, method: &str,
header_bytes: Vec<u8>, header_bytes: Vec<u8>,
physical_if_index: Option<u32>,
physical_if_name: &Option<String>,
close_tx: mpsc::Sender<u16>, close_tx: mpsc::Sender<u16>,
debug: bool, debug: bool,
) -> Result<()> { ) -> Result<()> {
if debug { if debug {
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}"); tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
} }
let mut remote = TcpStream::connect(target).await let mut remote = connect_bypassing_tun(target, physical_if_index, physical_if_name).await?;
.with_context(|| format!("direct connect failed: {target}"))?;
if method == "CONNECT" { if method == "CONNECT" {
client.write_all(b"HTTP/1.1 200 Connection Established\r\nProxy-Agent: ostp/1.0\r\n\r\n").await?; client.write_all(b"HTTP/1.1 200 Connection Established\r\nProxy-Agent: ostp/1.0\r\n\r\n").await?;

View File

@ -0,0 +1,145 @@
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, UdpSocket};
use futures::StreamExt;
pub async fn run_udp_nat(
udp_socket: netstack_smoltcp::UdpSocket,
proxy_addr: String,
debug: bool,
) {
let (mut rx, tx) = udp_socket.split();
let tx = Arc::new(Mutex::new(tx));
// map from internal client src to a channel that sends (payload, external_dst)
let mut sessions: HashMap<SocketAddr, mpsc::Sender<(Vec<u8>, SocketAddr)>> = HashMap::new();
while let Some((payload, src, dst)) = rx.next().await {
if payload.is_empty() { continue; }
if !sessions.contains_key(&src) {
let (session_tx, mut session_rx) = mpsc::channel::<(Vec<u8>, SocketAddr)>(128);
sessions.insert(src, session_tx);
let proxy_addr_clone = proxy_addr.clone();
let tx_clone = tx.clone();
tokio::spawn(async move {
if debug { tracing::info!("Starting UDP NAT session for {}", src); }
let res = start_udp_session(src, proxy_addr_clone, &mut session_rx, tx_clone).await;
if debug && res.is_err() {
tracing::info!("UDP NAT session for {} ended: {:?}", src, res.err());
}
});
}
if let Some(sender) = sessions.get(&src) {
if sender.send((payload, dst)).await.is_err() {
sessions.remove(&src);
}
}
}
}
async fn start_udp_session(
client_src: SocketAddr,
proxy_addr: String,
session_rx: &mut mpsc::Receiver<(Vec<u8>, SocketAddr)>,
smoltcp_tx: Arc<Mutex<netstack_smoltcp::udp::WriteHalf>>,
) -> anyhow::Result<()> {
// 1. TCP Connect to SOCKS5 proxy
let mut tcp = TcpStream::connect(&proxy_addr).await?;
// Auth
tcp.write_all(&[5, 1, 0]).await?;
let mut buf = [0u8; 2];
tcp.read_exact(&mut buf).await?;
if buf[0] != 5 || buf[1] != 0 {
return Err(anyhow::anyhow!("socks5 auth rejected"));
}
// UDP ASSOCIATE to 0.0.0.0:0
tcp.write_all(&[5, 3, 0, 1, 0, 0, 0, 0, 0, 0]).await?;
let mut rep = [0u8; 10];
tcp.read_exact(&mut rep).await?;
if rep[1] != 0 {
return Err(anyhow::anyhow!("socks5 udp associate rejected"));
}
// Parse BND.ADDR and BND.PORT
let relay_ip = std::net::Ipv4Addr::new(rep[4], rep[5], rep[6], rep[7]);
let relay_port = u16::from_be_bytes([rep[8], rep[9]]);
let mut relay_addr = SocketAddr::new(std::net::IpAddr::V4(relay_ip), relay_port);
// If proxy returned 0.0.0.0, use the proxy's IP
if relay_ip.is_unspecified() {
if let Ok(proxy_sock) = proxy_addr.parse::<SocketAddr>() {
relay_addr.set_ip(proxy_sock.ip());
}
}
let udp = UdpSocket::bind("127.0.0.1:0").await?;
let mut buf = vec![0u8; 65536];
let timeout = std::time::Duration::from_secs(300); // 5 min idle timeout
let mut tcp_buf = [0u8; 1];
loop {
tokio::select! {
res = tokio::time::timeout(timeout, session_rx.recv()) => {
match res {
Ok(Some((payload, dst))) => {
let mut packet = vec![0u8; 3]; // RSV, FRAG
match dst.ip() {
std::net::IpAddr::V4(v4) => { packet.push(1); packet.extend_from_slice(&v4.octets()); }
std::net::IpAddr::V6(v6) => { packet.push(4); packet.extend_from_slice(&v6.octets()); }
}
packet.extend_from_slice(&dst.port().to_be_bytes());
packet.extend_from_slice(&payload);
udp.send_to(&packet, relay_addr).await?;
}
Ok(None) => break,
Err(_) => break, // timeout
}
}
res = udp.recv_from(&mut buf) => {
let (len, _peer) = res?;
if len < 10 { continue; } // At least 10 bytes for SOCKS5 header
let frag = buf[2];
if frag != 0 { continue; } // fragment not supported
let atyp = buf[3];
let (header_len, remote_dst) = match atyp {
1 => {
if len < 10 { continue; }
let ip = std::net::Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
(10, SocketAddr::new(std::net::IpAddr::V4(ip), port))
}
4 => {
if len < 22 { continue; }
let mut octets = [0u8; 16];
octets.copy_from_slice(&buf[4..20]);
let ip = std::net::Ipv6Addr::from(octets);
let port = u16::from_be_bytes([buf[20], buf[21]]);
(22, SocketAddr::new(std::net::IpAddr::V6(ip), port))
}
_ => continue, // Domain name not supported for incoming packets in typical UDP associate
};
let payload = buf[header_len..len].to_vec();
use futures::SinkExt;
let _ = smoltcp_tx.lock().await.send((payload, remote_dst, client_src)).await;
}
// If TCP drops, UDP association is over
res = tcp.read(&mut tcp_buf) => {
let n = res?;
if n == 0 { break; }
}
}
}
Ok(())
}

View File

@ -1,6 +1,7 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use tokio::sync::watch; use tokio::sync::watch;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub async fn run_wintun_tunnel( pub async fn run_wintun_tunnel(
config: crate::config::ClientConfig, config: crate::config::ClientConfig,
@ -115,7 +116,7 @@ pub async fn run_wintun_tunnel(
); );
// 4. Launch tun2socks + route setup IN PARALLEL to save ~3 seconds // 4. Launch tun2socks + route setup IN PARALLEL to save ~3 seconds
let proxy_url = format!("http://{}", config.local_proxy.bind_addr); let proxy_url = format!("socks5://{}", config.local_proxy.bind_addr);
tracing::info!("Starting tun2socks (proxy={})", proxy_url); tracing::info!("Starting tun2socks (proxy={})", proxy_url);
// Spawn tun2socks immediately — it creates the adapter on its own // Spawn tun2socks immediately — it creates the adapter on its own
@ -191,6 +192,7 @@ pub async fn run_wintun_tunnel(
config.ostp.mtu config.ostp.mtu
); );
if let Some(ref dns) = config.dns_server { if let Some(ref dns) = config.dns_server {
if !dns.is_empty() { if !dns.is_empty() {
tracing::info!("DNS server: {}", dns); tracing::info!("DNS server: {}", dns);
@ -238,6 +240,8 @@ pub async fn run_wintun_tunnel(
// 8. Wait for shutdown signal // 8. Wait for shutdown signal
let _ = shutdown.changed().await; let _ = shutdown.changed().await;
tracing::info!("Deactivating TUN tunnel..."); tracing::info!("Deactivating TUN tunnel...");
drop(_guard); drop(_guard);
tracing::info!("TUN tunnel stopped."); tracing::info!("TUN tunnel stopped.");

View File

@ -500,12 +500,12 @@ impl ProtocolMachine {
let mut actions = Vec::new(); let mut actions = Vec::new();
// ── Gap Recovery ────────────────────────────────────────────── // ── Gap Recovery ──────────────────────────────────────────────
// If expected_recv_nonce hasn't advanced for 5+ seconds and there // If expected_recv_nonce hasn't advanced for 500ms+ and there
// are buffered frames waiting, the sender likely evicted the lost // are buffered frames waiting, the sender likely evicted the lost
// frame from sent_history. Skip the gap to restore data flow. // frame from sent_history. Skip the gap to restore data flow.
// This trades a small amount of data loss for connection liveness. // This trades a small amount of data loss for connection liveness.
if !self.reorder_buffer.is_empty() if !self.reorder_buffer.is_empty()
&& self.last_recv_advance.elapsed() > Duration::from_secs(5) && self.last_recv_advance.elapsed() > Duration::from_millis(500)
{ {
if let Some(&first_buffered) = self.reorder_buffer.keys().next() { if let Some(&first_buffered) = self.reorder_buffer.keys().next() {
let skipped = first_buffered.saturating_sub(self.expected_recv_nonce); let skipped = first_buffered.saturating_sub(self.expected_recv_nonce);

Binary file not shown.

View File

@ -2632,7 +2632,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-client" name = "ostp-client"
version = "0.2.61" version = "0.2.66"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@ -2662,7 +2662,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-core" name = "ostp-core"
version = "0.2.61" version = "0.2.66"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",

View File

@ -21,6 +21,8 @@ const translations = {
ph_key: 'Enter secure access key', ph_key: 'Enter secure access key',
label_socks: 'Local Proxy Address', label_socks: 'Local Proxy Address',
label_dns: 'Custom DNS Server', label_dns: 'Custom DNS Server',
label_owndns: 'Built-in Server DNS',
owndns_hint: 'Route DNS queries through the VPN server (10.1.0.1)',
label_tun: 'TUN Tunnel Mode', label_tun: 'TUN Tunnel Mode',
tun_hint: 'Route all system traffic (Admin req.)', tun_hint: 'Route all system traffic (Admin req.)',
label_transport: 'Transport Protocol', label_transport: 'Transport Protocol',
@ -66,6 +68,8 @@ const translations = {
ph_key: 'Введите ключ доступа', ph_key: 'Введите ключ доступа',
label_socks: 'Адрес локального прокси', label_socks: 'Адрес локального прокси',
label_dns: 'DNS сервер', label_dns: 'DNS сервер',
label_owndns: 'Встроенный DNS сервера',
owndns_hint: 'Направлять DNS-запросы через VPN сервер (10.1.0.1)',
label_tun: 'Режим TUN-туннеля', label_tun: 'Режим TUN-туннеля',
tun_hint: 'Направить весь трафик (нужны права администратора)', tun_hint: 'Направить весь трафик (нужны права администратора)',
label_transport: 'Транспортный протокол', label_transport: 'Транспортный протокол',

View File

@ -172,12 +172,24 @@
<input id="in-socks" class="field-input" type="text" placeholder="127.0.0.1:1088" /> <input id="in-socks" class="field-input" type="text" placeholder="127.0.0.1:1088" />
</div> </div>
<div class="field-group"> <!-- Built-in DNS toggle -->
<label class="field-label" for="in-dns" data-i18n="label_dns">DNS Server</label> <div class="toggle-row">
<div style="display: flex; gap: 8px;"> <div class="toggle-text">
<input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" style="flex: 1;" /> <span class="toggle-name" data-i18n="label_owndns">Built-in Server DNS</span>
<button id="btn-use-builtin-dns" class="btn" style="padding: 0 12px; white-space: nowrap; cursor: pointer;" data-i18n="btn_use_builtin_dns">Use Built-in</button> <span class="toggle-hint" data-i18n="owndns_hint">Route DNS through the VPN server (10.1.0.1)</span>
</div> </div>
<label class="toggle">
<input type="checkbox" id="in-owndns" />
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
<!-- Custom DNS (hidden when built-in is ON) -->
<div class="field-group" id="group-custom-dns">
<label class="field-label" for="in-dns" data-i18n="label_dns">Custom DNS Server</label>
<input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" />
</div> </div>
<div class="field-group"> <div class="field-group">

View File

@ -41,6 +41,8 @@ const inServer = $('in-server');
const inKey = $('in-key'); const inKey = $('in-key');
const inSocks = $('in-socks'); const inSocks = $('in-socks');
const inDns = $('in-dns'); const inDns = $('in-dns');
const inOwndns = $('in-owndns');
const groupCustomDns = $('group-custom-dns');
const inTransport = $('in-transport'); const inTransport = $('in-transport');
const inSni = $('in-stealth-sni'); const inSni = $('in-stealth-sni');
const inPbk = $('in-pbk'); const inPbk = $('in-pbk');
@ -89,6 +91,13 @@ function showToast(msg, variant = '') {
}, 2400); }, 2400);
} }
// ── DNS visibility ────────────────────────────────────────────────────────────
function updateDnsVisibility() {
if (!groupCustomDns || !inOwndns) return;
groupCustomDns.style.display = inOwndns.checked ? 'none' : 'block';
}
// ── State machine ──────────────────────────────────────────────────────────── // ── State machine ────────────────────────────────────────────────────────────
function setState(next) { function setState(next) {
if (appState === next) return; if (appState === next) return;
@ -239,7 +248,14 @@ async function loadConfigIntoForm() {
inMuxSessions.value = c.mux?.sessions || ''; inMuxSessions.value = c.mux?.sessions || '';
groupTunStack.style.display = inTun.checked ? 'block' : 'none'; groupTunStack.style.display = inTun.checked ? 'block' : 'none';
inDns.value = c.tun?.dns || '';
// owndns: detect if saved dns is 10.1.0.1
const savedDns = c.tun?.dns || '';
const isOwndns = savedDns === '10.1.0.1';
inOwndns.checked = isOwndns;
inDns.value = isOwndns ? '' : savedDns;
updateDnsVisibility();
inDebug.checked = !!c.debug; inDebug.checked = !!c.debug;
const ex = c.exclude || {}; const ex = c.exclude || {};
@ -306,7 +322,8 @@ async function handleSave(silent = false) {
rawConfig.tun = { wintun_path: './wintun.dll', ipv4_address: '10.1.0.2/24' }; rawConfig.tun = { wintun_path: './wintun.dll', ipv4_address: '10.1.0.2/24' };
} }
rawConfig.tun.enable = inTun.checked; rawConfig.tun.enable = inTun.checked;
rawConfig.tun.dns = inDns.value.trim() || null; // owndns: if toggle is on, always write 10.1.0.1; otherwise use the custom field
rawConfig.tun.dns = inOwndns.checked ? '10.1.0.1' : (inDns.value.trim() || null);
rawConfig.tun.stack = inTunStack.value; rawConfig.tun.stack = inTunStack.value;
rawConfig.exclude = { rawConfig.exclude = {
@ -367,6 +384,7 @@ function togglePeek() {
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
applyTranslations(); applyTranslations();
setState('disconnected'); setState('disconnected');
updateDnsVisibility(); // initialise field visibility from current checkbox state
// Event wiring // Event wiring
if (window.__TAURI__ && window.__TAURI__.event) { if (window.__TAURI__ && window.__TAURI__.event) {
@ -381,15 +399,11 @@ window.addEventListener('DOMContentLoaded', async () => {
btnBack.addEventListener('click', () => showScreen('home')); btnBack.addEventListener('click', () => showScreen('home'));
btnImport.addEventListener('click', handleImport); btnImport.addEventListener('click', handleImport);
btnPeekKey.addEventListener('click', togglePeek); btnPeekKey.addEventListener('click', togglePeek);
const btnUseBuiltinDns = $('btn-use-builtin-dns'); inOwndns.addEventListener('change', () => {
if (btnUseBuiltinDns) { updateDnsVisibility();
btnUseBuiltinDns.addEventListener('click', () => { scheduleAutoSave();
inDns.value = '10.1.0.1'; });
saveConfig(); inTun.addEventListener('change', () => { groupTunStack.style.display = inTun.checked ? 'block' : 'none'; });
showToast('DNS set to built-in server (10.1.0.1)', 'success');
});
}
inTun.addEventListener('change', () => { groupTunStack.style.display = inTun.checked ? 'block' : 'none'; });
importInput.addEventListener('keydown', e => { if (e.key === 'Enter') handleImport(); }); importInput.addEventListener('keydown', e => { if (e.key === 'Enter') handleImport(); });
// Auto-save wiring // Auto-save wiring