feat: add outbound interface binding

This commit is contained in:
lbl
2026-08-22 07:58:18 +08:00
parent 55d47c5a1c
commit 74c47bce69
29 changed files with 562 additions and 74 deletions
Generated
+3
View File
@@ -6091,6 +6091,7 @@ dependencies = [
"getifaddrs",
"hex",
"ipnet",
"libc",
"log",
"lz4_flex",
"machine-uid",
@@ -6121,6 +6122,7 @@ dependencies = [
"uuid",
"widestring",
"winapi",
"windows-sys 0.60.2",
"zerocopy",
]
@@ -6154,6 +6156,7 @@ dependencies = [
"log",
"prost",
"prost-build",
"protoc-bin-vendored",
"time",
"tokio",
"tokio-util",
+15
View File
@@ -31,6 +31,7 @@ pub struct FileConfig {
pub device_id: Option<String>,
pub device_name: Option<String>,
pub tun_name: Option<String>,
pub outbound_interface: Option<String>,
pub password: Option<String>,
pub cert_mode: Option<String>,
pub udp_stun: Option<Vec<String>>,
@@ -127,6 +128,9 @@ pub struct Args {
/// 虚拟网卡名称
#[clap(long)]
pub tun_name: Option<String>,
/// 绑定对外通信 Socket 的出口网卡名称
#[clap(long)]
pub outbound_interface: Option<String>,
/// 关闭内置子网NAT
#[clap(long)]
pub no_nat: bool,
@@ -246,6 +250,9 @@ fn build_from_args_and_file(args: Args, file: FileConfig) -> anyhow::Result<(Con
.or_else(|| file.device_name.clone())
.unwrap_or_else(default_hostname),
tun_name: args.tun_name.or_else(|| file.tun_name.clone()),
outbound_interface: args
.outbound_interface
.or_else(|| file.outbound_interface.clone()),
password: args.password.or_else(|| file.password.clone()),
cert_mode,
input,
@@ -285,6 +292,7 @@ fn build_from_args_only(args: Args) -> anyhow::Result<(Config, CtrlConfig)> {
device_id,
device_name: args.device_name.unwrap_or_else(default_hostname),
tun_name: args.tun_name,
outbound_interface: args.outbound_interface,
password: args.password,
cert_mode: args
.cert_mode
@@ -345,6 +353,7 @@ fn build_from_file_only(file: FileConfig) -> anyhow::Result<(Config, CtrlConfig)
device_id,
device_name: file.device_name.clone().unwrap_or_else(default_hostname),
tun_name: file.tun_name.clone(),
outbound_interface: file.outbound_interface.clone(),
password: file.password.clone(),
cert_mode,
output: file.output.unwrap_or_default(),
@@ -448,6 +457,9 @@ server = ["quic://1.2.3.4:29872"]
# 虚拟网卡名称
# tun_name = "vnt-tun"
# 绑定对外通信 Socket 的出口网卡名称(用于服务端通信、P2P 打洞及转发流量)
# outbound_interface = "Ethernet"
# --- 安全配置 ---
# 加密密码 (可选)
@@ -493,9 +505,12 @@ mod tests {
"test-net",
"--tunnel-port",
"12345",
"--outbound-interface",
"Ethernet",
])
.unwrap();
let (config, _) = build_from_args_only(args).unwrap();
assert_eq!(config.tunnel_port, Some(12345));
assert_eq!(config.outbound_interface.as_deref(), Some("Ethernet"));
}
}
+4
View File
@@ -52,6 +52,10 @@ socket2 = { version = "0.6.1", features = ["all"] }
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3.9", features = ["winreg"] }
widestring = "1.2"
windows-sys = { version = "0.60.2", features = ["Win32_Networking_WinSock"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[build-dependencies]
prost-build = "0.14"
+8 -1
View File
@@ -22,6 +22,8 @@ pub struct Config {
pub device_id: String,
pub device_name: String,
pub tun_name: Option<String>,
/// 绑定 VNT 对外通信 Socket 的物理网卡名称。
pub outbound_interface: Option<String>,
pub ip: Option<Ipv4Addr>,
pub password: Option<String>,
pub no_punch: bool,
@@ -87,7 +89,11 @@ impl Config {
pub fn key_sign(&self) -> Option<String> {
self.password.as_ref().map(|p| PacketCrypto::key_sign(p))
}
pub(crate) fn to_connect_config(&self, index: usize) -> ConnectRegConfig {
pub(crate) fn to_connect_config(
&self,
index: usize,
default_interface: Option<rust_p2p_core::socket::LocalInterface>,
) -> ConnectRegConfig {
ConnectRegConfig {
server_addr: self.server_addr[index].clone(),
cert_mode: self.cert_mode.clone(),
@@ -97,6 +103,7 @@ impl Config {
ip: self.ip,
key_sign: self.key_sign(),
ip_variable: self.ip.is_none(),
default_interface,
}
}
}
+27 -2
View File
@@ -60,11 +60,32 @@ impl NetworkManager {
) -> anyhow::Result<NetworkManager> {
let app_state = AppState::default();
config.check()?;
let outbound_interface_name = config
.outbound_interface
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_owned);
let resolved_interface =
crate::utils::socket::resolve_interface(outbound_interface_name.as_deref())?;
let default_interface = resolved_interface
.as_ref()
.map(|interface| interface.socket_interface.clone());
let canonical_interface_name = resolved_interface
.as_ref()
.map(|interface| interface.name.clone());
if let Some(name) = canonical_interface_name.as_deref() {
log::info!("绑定出口网卡: {name}");
}
let mtu = config.mtu.unwrap_or(DEFAULT_MTU);
let packet_crypto = PacketCrypto::new_from_str(config.password.as_deref());
let packet_compression = PacketCompression::new(config.compress);
let (server_manager_list, tunnel_to_server, server_rpc) =
create_server_tunnel(app_state.clone(), &config, packet_crypto.clone());
let (server_manager_list, tunnel_to_server, server_rpc) = create_server_tunnel(
app_state.clone(),
&config,
packet_crypto.clone(),
default_interface.clone(),
);
let device_io_manager = DeviceIOManager::new(task_group.clone());
let allow_subnet = AllowSubnetExternalRoute::new(config.output.clone());
@@ -75,6 +96,8 @@ impl NetworkManager {
tunnel_to_server.clone(),
packet_crypto.clone(),
config.tunnel_port,
default_interface.clone(),
canonical_interface_name,
)
.await?;
@@ -116,6 +139,7 @@ impl NetworkManager {
config.no_tun,
config.allow_port_mapping,
app_state.network.clone(),
default_interface.clone(),
);
let internal_nat_inbound = if config.no_nat && !config.no_tun {
None
@@ -127,6 +151,7 @@ impl NetworkManager {
allow_subnet.clone(),
app_state.network.clone(),
config.no_tun,
default_interface.clone(),
)
.await?;
Some(nat_inbound)
@@ -5,6 +5,7 @@ use pnet_packet::Packet;
use pnet_packet::icmp::echo_reply::{Identifier, SequenceNumber};
use pnet_packet::icmp::{IcmpPacket, IcmpTypes};
use pnet_packet::ipv4::Ipv4Packet;
use rust_p2p_core::socket::LocalInterface;
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::time::{Duration, Instant};
@@ -24,6 +25,7 @@ pub async fn start_icmp_nat(
ip_stack: &IpStack,
no_tun: bool,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
) -> anyhow::Result<()> {
let net_icmp_socket = socket2::Socket::new(
socket2::Domain::IPV4,
@@ -31,6 +33,12 @@ pub async fn start_icmp_nat(
Some(socket2::Protocol::ICMPV4),
)
.context("new Socket RAW ICMPV4 failed")?;
crate::utils::socket::bind_socket_to_interface(
&net_icmp_socket,
default_interface.as_ref(),
true,
)
.context("bind ICMP socket to outbound interface failed")?;
let addr: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0);
net_icmp_socket
.bind(&socket2::SockAddr::from(addr))
+64 -9
View File
@@ -8,6 +8,7 @@ use anyhow::Context;
use bytes::BytesMut;
use pnet_packet::ip::IpNextHeaderProtocol;
use pnet_packet::ipv4::Ipv4Packet;
use rust_p2p_core::socket::LocalInterface;
use std::net::{Ipv4Addr, SocketAddr};
use std::str::FromStr;
use std::sync::Arc;
@@ -24,6 +25,7 @@ pub(crate) struct InternalNatInbound {
ip_stack_send: Arc<IpStackSend>,
allow_subnet: AllowSubnetExternalRoute,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
}
impl InternalNatInbound {
pub async fn create(
@@ -33,13 +35,28 @@ impl InternalNatInbound {
allow_subnet: AllowSubnetExternalRoute,
network: SharedNetworkAddr,
no_tun: bool,
default_interface: Option<LocalInterface>,
) -> anyhow::Result<Self> {
let ip_stack_config = IpStackConfig::builder().mtu(mtu).build();
let (ip_stack, ip_stack_send, ip_stack_recv) = tcp_ip::ip_stack(ip_stack_config)?;
#[cfg(not(target_os = "android"))]
icmp_nat::start_icmp_nat(task_group, &ip_stack, no_tun, network.clone()).await?;
tcp_nat::start_tcp_nat(task_group, &ip_stack, no_tun, network.clone()).await?;
udp_nat::start_udp_nat(task_group, &ip_stack).await?;
icmp_nat::start_icmp_nat(
task_group,
&ip_stack,
no_tun,
network.clone(),
default_interface.clone(),
)
.await?;
tcp_nat::start_tcp_nat(
task_group,
&ip_stack,
no_tun,
network.clone(),
default_interface.clone(),
)
.await?;
udp_nat::start_udp_nat(task_group, &ip_stack, default_interface.clone()).await?;
task_group.spawn(async move {
if let Err(e) = ip_stack_recv_task(ip_stack_recv, hybrid_outbound).await {
log::error!("ip stack recv task error: {e:?}");
@@ -50,6 +67,7 @@ impl InternalNatInbound {
ip_stack_send: Arc::new(ip_stack_send),
allow_subnet,
network,
default_interface,
})
}
pub async fn send(&self, data: &[u8], net: &NetworkAddr) -> anyhow::Result<()> {
@@ -140,7 +158,13 @@ impl InternalNatInbound {
}
}
let dst = SocketAddr::new(dest_ip.into(), dest_port);
tcp_nat::stream_nat(recv_stream, send_stream, dst).await
tcp_nat::stream_nat(
recv_stream,
send_stream,
dst,
self.default_interface.as_ref(),
)
.await
}
}
@@ -149,14 +173,21 @@ pub(crate) struct PortMappingManager {
no_tun: bool,
allow_port_mapping: bool,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
}
impl PortMappingManager {
pub fn new(no_tun: bool, allow_port_mapping: bool, network: SharedNetworkAddr) -> Self {
pub fn new(
no_tun: bool,
allow_port_mapping: bool,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
) -> Self {
Self {
no_tun,
allow_port_mapping,
network,
default_interface,
}
}
pub async fn tcp_mapping<R, W>(
@@ -181,13 +212,25 @@ impl PortMappingManager {
if dest_ip == net.ip {
let dst = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), dest_port);
return tcp_nat::stream_nat(recv_stream, send_stream, dst).await;
return tcp_nat::stream_nat(
recv_stream,
send_stream,
dst,
self.default_interface.as_ref(),
)
.await;
} else if net.network().contains(&dest_ip) {
return Ok(());
}
}
let dst = format!("{}:{}", dest, dest_port);
tcp_nat::stream_nat(recv_stream, send_stream, dst).await
tcp_nat::stream_nat(
recv_stream,
send_stream,
dst,
self.default_interface.as_ref(),
)
.await
}
pub async fn udp_mapping<R, W>(
&self,
@@ -211,12 +254,24 @@ impl PortMappingManager {
if dest_ip == net.ip {
let dst = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), dest_port);
return udp_nat::stream_nat(recv_stream, send_stream, dst).await;
return udp_nat::stream_nat(
recv_stream,
send_stream,
dst,
self.default_interface.as_ref(),
)
.await;
} else if net.network().contains(&dest_ip) {
return Ok(());
}
}
let dst = format!("{}:{}", dest, dest_port);
udp_nat::stream_nat(recv_stream, send_stream, dst).await
udp_nat::stream_nat(
recv_stream,
send_stream,
dst,
self.default_interface.as_ref(),
)
.await
}
}
+13 -6
View File
@@ -1,23 +1,26 @@
use crate::context::SharedNetworkAddr;
use crate::utils::task_control::TaskGroup;
use anyhow::Context;
use rust_p2p_core::socket::LocalInterface;
use std::fmt::Debug;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use tcp_ip::IpStack;
use tcp_ip::tcp::TcpListener;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::{TcpStream, ToSocketAddrs};
use tokio::net::ToSocketAddrs;
pub async fn start_tcp_nat(
task_group: &TaskGroup,
ip_stack: &IpStack,
no_tun: bool,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
) -> anyhow::Result<()> {
let tcp_listener = TcpListener::bind_all(ip_stack.clone()).await?;
let group = task_group.clone();
task_group.spawn(async move {
if let Err(e) = listen_task(&group, tcp_listener, no_tun, network).await {
if let Err(e) = listen_task(&group, tcp_listener, no_tun, network, default_interface).await
{
log::error!("listen task error: {:?}", e);
}
});
@@ -29,6 +32,7 @@ async fn listen_task(
mut tcp_listener: TcpListener,
no_tun: bool,
network: SharedNetworkAddr,
default_interface: Option<LocalInterface>,
) -> anyhow::Result<()> {
loop {
// 单次 accept/地址查询失败不能拖垮整个监听任务:
@@ -60,8 +64,9 @@ async fn listen_task(
local_addr.set_ip(IpAddr::V4(Ipv4Addr::LOCALHOST));
}
}
let default_interface = default_interface.clone();
task_group.spawn(async move {
if let Err(e) = stream_task(stream, local_addr).await {
if let Err(e) = stream_task(stream, local_addr, default_interface.as_ref()).await {
log::error!("stream task Error: {:?},{peer_addr}->{local_addr}", e);
}
});
@@ -71,8 +76,9 @@ async fn listen_task(
async fn stream_task(
mut inner_stream: tcp_ip::tcp::TcpStream,
addr: SocketAddr,
default_interface: Option<&LocalInterface>,
) -> anyhow::Result<()> {
let mut tokio_stream = TcpStream::connect(addr).await?;
let mut tokio_stream = crate::utils::socket::connect_tcp(addr, default_interface).await?;
tokio::io::copy_bidirectional(&mut inner_stream, &mut tokio_stream).await?;
Ok(())
}
@@ -81,14 +87,15 @@ pub(crate) async fn stream_nat<R, W, A: ToSocketAddrs + Debug>(
recv_stream: R,
send_stream: W,
addr: A,
default_interface: Option<&LocalInterface>,
) -> anyhow::Result<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut tokio_stream = TcpStream::connect(&addr)
let mut tokio_stream = crate::utils::socket::connect_tcp_resolved(addr, default_interface)
.await
.with_context(|| format!("error connecting to {:?}", addr))?;
.context("error connecting to NAT destination")?;
crate::port_mapping::tcp_port_mapping::copy_bidirectional_split(
&mut tokio_stream,
recv_stream,
+47 -11
View File
@@ -2,6 +2,7 @@ use crate::utils::task_control::{SubTask, TaskGroup};
use anyhow::Context;
use bytes::Bytes;
use futures::{SinkExt, StreamExt};
use rust_p2p_core::socket::LocalInterface;
use std::collections::HashMap;
use std::fmt::Debug;
use std::net::SocketAddr;
@@ -25,7 +26,11 @@ type NatTable = Arc<Mutex<HashMap<(SocketAddr, SocketAddr), NatEntry>>>;
const NAT_IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 5);
const NAT_GC_INTERVAL: Duration = Duration::from_secs(60);
pub async fn start_udp_nat(task_group: &TaskGroup, ip_stack: &IpStack) -> anyhow::Result<()> {
pub async fn start_udp_nat(
task_group: &TaskGroup,
ip_stack: &IpStack,
default_interface: Option<LocalInterface>,
) -> anyhow::Result<()> {
let inner_socket = tcp_ip::udp::UdpSocket::bind_all(ip_stack.clone()).await?;
let inner_socket = Arc::new(inner_socket);
let nat_table: NatTable = Arc::new(Mutex::new(HashMap::new()));
@@ -42,8 +47,16 @@ pub async fn start_udp_nat(task_group: &TaskGroup, ip_stack: &IpStack) -> anyhow
}
};
if let Err(e) =
handle_outbound(&group, &inner_socket, &nat_table, src, dst, &buf[..len]).await
if let Err(e) = handle_outbound(
&group,
&inner_socket,
&nat_table,
src,
dst,
&buf[..len],
default_interface.as_ref(),
)
.await
{
log::warn!("udp nat outbound error: {e:?}");
}
@@ -60,6 +73,7 @@ async fn handle_outbound(
src: SocketAddr,
dst: SocketAddr,
packet: &[u8],
default_interface: Option<&LocalInterface>,
) -> anyhow::Result<()> {
let key = (src, dst);
@@ -70,7 +84,17 @@ async fn handle_outbound(
entry.socket.clone()
} else {
// 创建真实 UDP socket
let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
let bind_addr = if dst.is_ipv4() {
"0.0.0.0:0".parse().expect("valid IPv4 bind address")
} else {
"[::]:0".parse().expect("valid IPv6 bind address")
};
let interface = if dst.ip().is_loopback() {
None
} else {
default_interface
};
let sock = crate::utils::socket::bind_udp(bind_addr, interface)?;
sock.connect(dst).await?;
let sock = Arc::new(sock);
@@ -184,19 +208,32 @@ fn spawn_nat_gc(task_group: &TaskGroup, nat: NatTable) {
});
}
pub(crate) async fn stream_nat<R, W, A: ToSocketAddrs + Debug>( recv_stream: R,
pub(crate) async fn stream_nat<R, W, A: ToSocketAddrs + Debug>(
recv_stream: R,
send_stream: W,
addr: A,
default_interface: Option<&LocalInterface>,
) -> anyhow::Result<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let udp_socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
udp_socket
.connect(&addr)
.await
.with_context(|| format!("error connecting to {:?}", addr))?;
let destination = tokio::net::lookup_host(addr)
.await?
.next()
.context("UDP NAT destination resolved to no address")?;
let bind_addr = if destination.is_ipv4() {
"0.0.0.0:0".parse().expect("valid IPv4 bind address")
} else {
"[::]:0".parse().expect("valid IPv6 bind address")
};
let interface = if destination.ip().is_loopback() {
None
} else {
default_interface
};
let udp_socket = crate::utils::socket::bind_udp(bind_addr, interface)?;
udp_socket.connect(destination).await?;
let mut framed_read = FramedRead::new(recv_stream, LengthDelimitedCodec::new());
let mut framed_write = FramedWrite::new(send_stream, LengthDelimitedCodec::new());
let mut buf = vec![0u8; 65536];
@@ -219,7 +256,6 @@ where
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1,5 +1,6 @@
use crate::context::AppState;
use rust_p2p_core::nat::{NatInfo, NatType};
use rust_p2p_core::socket::LocalInterface;
use rust_p2p_core::tunnel::SocketManager;
use rust_p2p_core::tunnel::udp::Model;
use std::collections::HashMap;
@@ -9,19 +10,38 @@ use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
pub async fn my_nat_info(app_context: AppState, socket_manager: SocketManager) {
pub async fn my_nat_info(
app_context: AppState,
socket_manager: SocketManager,
default_interface: Option<LocalInterface>,
outbound_interface_name: Option<String>,
) {
loop {
my_nat_info_impl(&app_context, &socket_manager).await;
my_nat_info_impl(
&app_context,
&socket_manager,
default_interface.as_ref(),
outbound_interface_name.as_deref(),
)
.await;
tokio::time::sleep(Duration::from_secs(60 * 30)).await;
}
}
async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager) {
async fn my_nat_info_impl(
app_context: &AppState,
socket_manager: &SocketManager,
default_interface: Option<&LocalInterface>,
outbound_interface_name: Option<&str>,
) {
let network = app_context.network.network();
let mut local_ipv4s = Vec::new();
let mut local_ipv6 = Vec::new();
match getifaddrs::getifaddrs() {
Ok(addrs) => {
for x in addrs {
if outbound_interface_name.is_some_and(|name| x.name != name) {
continue;
}
let Some(ip) = x.address.ip_addr() else {
continue;
};
@@ -67,20 +87,28 @@ async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager
}
}
log::info!("local_ipv4s: {:?}", local_ipv4s);
let detected = rust_p2p_core::extend::addr::local_ipv4()
.await
.map_err(|e| {
log::warn!("local ipv4 failed {e:?}");
e
})
.ok();
let detected = if outbound_interface_name.is_none() {
rust_p2p_core::extend::addr::local_ipv4()
.await
.map_err(|e| {
log::warn!("local ipv4 failed {e:?}");
e
})
.ok()
} else {
None
};
let Some((local_ipv4, merged)) = select_local_ipv4(detected, &local_ipv4s) else {
log::warn!("未发现可用本机 IPv4 地址,跳过本次 NAT 信息更新");
return;
};
// 保留网卡扫描结果,主地址排在首位
local_ipv4s = merged;
let mut ipv6 = rust_p2p_core::extend::addr::local_ipv6().await.ok();
let mut ipv6 = if outbound_interface_name.is_none() {
rust_p2p_core::extend::addr::local_ipv6().await.ok()
} else {
local_ipv6.first().cloned()
};
if let Some(addr) = ipv6 {
if addr.is_loopback()
|| addr.is_unique_local()
@@ -134,12 +162,13 @@ async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager
if stun_server.is_empty() {
stun_server = default_udp_stun();
}
let (nat_type, public_ips, port_range) = rust_p2p_core::stun::stun_test_nat(stun_server, None)
.await
.unwrap_or_else(|e| {
log::warn!("stun_test_nat {e:?}");
(NatType::Cone, vec![], 0)
});
let (nat_type, public_ips, port_range) =
rust_p2p_core::stun::stun_test_nat(stun_server, default_interface)
.await
.unwrap_or_else(|e| {
log::warn!("stun_test_nat {e:?}");
(NatType::Cone, vec![], 0)
});
log::info!("nat_type:{nat_type:?},public_ips:{public_ips:?},port_range={port_range}");
nat_info.nat_type = nat_type;
nat_info.public_ips = public_ips;
+17 -2
View File
@@ -13,6 +13,7 @@ use crate::tunnel_core::p2p::transport::punch::{PunchTaskContext, punch_task};
use crate::tunnel_core::server::outbound::ServerOutbound;
use crate::utils::task_control::TaskGroup;
use rust_p2p_core::punch::Puncher;
use rust_p2p_core::socket::LocalInterface;
use rust_p2p_core::tunnel::{Tunnel, TunnelDispatcher, new_tunnel_component};
use std::net::Ipv4Addr;
use std::sync::Arc;
@@ -24,17 +25,29 @@ pub async fn init_tunnel(
tunnel_to_server: ServerOutbound,
packet_crypto: PacketCrypto,
tunnel_port: Option<u16>,
default_interface: Option<LocalInterface>,
outbound_interface_name: Option<String>,
) -> anyhow::Result<(Puncher, P2pOutbound, P2pTask)> {
let tunnel_port = tunnel_port.unwrap_or(0);
let udp_config = rust_p2p_core::tunnel::config::UdpTunnelConfig::default()
let mut udp_config = rust_p2p_core::tunnel::config::UdpTunnelConfig::default()
.set_main_udp_count(2)
.set_sub_udp_count(82)
.set_simple_udp_port(tunnel_port);
let tcp_config = rust_p2p_core::tunnel::config::TcpTunnelConfig::new(Box::new(
let mut tcp_config = rust_p2p_core::tunnel::config::TcpTunnelConfig::new(Box::new(
rust_p2p_core::tunnel::tcp::LengthPrefixedInitCodec,
))
.set_tcp_multiplexing_limit(2)
.set_tcp_port(tunnel_port);
if let Some(interface) = default_interface.clone() {
// rust-p2p-core 当前的接口绑定实现针对 IPv4;指定出口网卡时关闭
// 未绑定的 IPv6 Socket,避免流量绕过所选网卡。
udp_config = udp_config
.set_default_interface(interface.clone())
.set_use_v6(false);
tcp_config = tcp_config
.set_default_interface(interface)
.set_use_v6(false);
}
let config = rust_p2p_core::tunnel::config::TunnelConfig::empty()
.set_udp_tunnel_config(udp_config)
.set_tcp_tunnel_config(tcp_config);
@@ -48,6 +61,8 @@ pub async fn init_tunnel(
task_group.spawn(my_nat_info(
app_state.clone(),
tunnel_dispatcher.socket_manager(),
default_interface,
outbound_interface_name,
));
let manager = tunnel_dispatcher.socket_manager();
task_group.spawn(query_udp_public_addr_loop(
@@ -47,13 +47,14 @@ pub(crate) fn create_server_tunnel(
app_state: AppState,
config: &Config,
packet_crypto: PacketCrypto,
default_interface: Option<rust_p2p_core::socket::LocalInterface>,
) -> (Vec<ServerTurnManager>, ServerOutbound, ServerRPC) {
let mut rpc_notifier: HashMap<u32, RpcNotifier> = HashMap::new();
let mut sender_map: HashMap<u32, Sender<(Bytes, Instant)>> = HashMap::new();
let mut server_manager_list = Vec::with_capacity(config.server_addr.len());
let mut server_addr_list = Vec::with_capacity(config.server_addr.len());
for (index, server_addr) in config.server_addr.iter().enumerate() {
let connect_reg_config = config.to_connect_config(index);
let connect_reg_config = config.to_connect_config(index, default_interface.clone());
let server_id = index as u32;
@@ -2,6 +2,7 @@ use crate::protocol::control_message::{RegRequestMsg, RegistrationMode};
use crate::tls::verifier::CertValidationMode;
use anyhow::Context;
use rand::seq::SliceRandom;
use rust_p2p_core::socket::LocalInterface;
use std::fmt;
use std::net::{Ipv4Addr, SocketAddr};
use std::str::FromStr;
@@ -16,6 +17,7 @@ pub(crate) struct ConnectRegConfig {
pub ip: Option<Ipv4Addr>,
pub key_sign: Option<String>,
pub ip_variable: bool,
pub default_interface: Option<LocalInterface>,
}
#[derive(Debug, Clone)]
pub(crate) struct ConnectConfig {
@@ -23,6 +25,7 @@ pub(crate) struct ConnectConfig {
pub server_addr: SocketAddr,
pub server_domain: String,
pub cert_mode: CertValidationMode,
pub default_interface: Option<LocalInterface>,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
pub enum ProtocolType {
@@ -110,7 +113,7 @@ impl ConnectRegConfig {
let mut txt = crate::utils::dns_query::dns_query_txt(
&self.server_addr.address,
vec![],
&None,
&self.default_interface,
)
.await?;
txt.shuffle(&mut rand::rng());
@@ -121,14 +124,19 @@ impl ConnectRegConfig {
}
v => (v, self.server_addr.address.to_string()),
};
let server_addr =
crate::utils::dns_query::dns_query_one(&server_domain, &vec![], &None).await?;
let server_addr = crate::utils::dns_query::dns_query_one(
&server_domain,
&vec![],
&self.default_interface,
)
.await?;
let server_domain = strip_port(&server_domain).to_owned();
Ok(ConnectConfig {
protocol_type,
server_addr,
server_domain,
cert_mode: self.cert_mode.clone(),
default_interface: self.default_interface.clone(),
})
}
}
@@ -4,7 +4,7 @@ use crate::tunnel_core::server::transport::config::ConnectConfig;
use anyhow::{Context, bail};
use bytes::Bytes;
use futures::{SinkExt, StreamExt};
use quinn::{ClientConfig, Endpoint, RecvStream, SendStream};
use quinn::{ClientConfig, Endpoint, RecvStream, SendStream, TokioRuntime};
use std::sync::Arc;
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
@@ -56,14 +56,15 @@ pub async fn connect_quic(
let server_addr = config.server_addr();
let server_name = config.server_name();
let quic_config = create_client_config(&config.cert_mode)?;
let mut endpoint = match Endpoint::client((std::net::Ipv6Addr::UNSPECIFIED, 0).into()) {
Ok(endpoint) => endpoint,
Err(e) => {
log::warn!("Failed to create QUIC endpoint: {}", e);
Endpoint::client((std::net::Ipv4Addr::UNSPECIFIED, 0).into())
.context("Failed to create QUIC endpoint")?
}
let bind_addr = if server_addr.is_ipv4() {
(std::net::Ipv4Addr::UNSPECIFIED, 0).into()
} else {
(std::net::Ipv6Addr::UNSPECIFIED, 0).into()
};
let socket = crate::utils::socket::bind_udp(bind_addr, config.default_interface.as_ref())?;
let socket = socket.into_std()?;
let mut endpoint = Endpoint::new(Default::default(), None, socket, Arc::new(TokioRuntime))
.context("Failed to create QUIC endpoint")?;
endpoint.set_default_client_config(quic_config);
let connection = endpoint
@@ -58,9 +58,10 @@ pub async fn connect_tls_tcp(
let rustls_config = config.cert_mode.create_tls_client_config()?;
let connector = TlsConnector::from(Arc::new(rustls_config));
let tcp_stream = TcpStream::connect(server_addr)
.await
.context("Failed to establish underlying TCP connection")?;
let tcp_stream =
crate::utils::socket::connect_tcp(server_addr, config.default_interface.as_ref())
.await
.context("Failed to establish underlying TCP connection")?;
if let Err(e) = tcp_stream.set_nodelay(true) {
log::error!("Failed to set TCP_NODELAY: {}", e);
}
@@ -71,9 +71,10 @@ pub async fn connect_wss(config: &ConnectConfig) -> anyhow::Result<WssStream> {
let rustls_config = config.cert_mode.create_tls_client_config()?;
let connector = TlsConnector::from(Arc::new(rustls_config));
let tcp_stream = TcpStream::connect(server_addr)
.await
.context("Failed to establish underlying TCP connection")?;
let tcp_stream =
crate::utils::socket::connect_tcp(server_addr, config.default_interface.as_ref())
.await
.context("Failed to establish underlying TCP connection")?;
if let Err(e) = tcp_stream.set_nodelay(true) {
log::error!("Failed to set TCP_NODELAY: {}", e);
}
+4
View File
@@ -44,6 +44,10 @@ pub async fn dns_query_one(
default_interface: &Option<LocalInterface>,
) -> anyhow::Result<SocketAddr> {
let mut vec = dns_query_all(domain, name_servers, default_interface).await?;
if default_interface.is_some() {
// 出口网卡绑定目前应用于 IPv4 Socket;优先且只使用可绑定的 IPv4 地址。
vec.retain(SocketAddr::is_ipv4);
}
vec.shuffle(&mut rand::rng());
vec.pop().context("DNS query failed")
}
+1
View File
@@ -1,5 +1,6 @@
pub mod device_id;
pub(crate) mod dns_query;
pub(crate) mod socket;
pub mod task_control;
pub(crate) mod time {
pub fn now_ts_ms() -> i64 {
+216
View File
@@ -0,0 +1,216 @@
use anyhow::Context;
use rust_p2p_core::socket::LocalInterface;
use socket2::{Domain, Protocol, Socket, Type};
use std::io;
use std::net::SocketAddr;
#[derive(Clone, Debug)]
pub(crate) struct ResolvedInterface {
pub(crate) name: String,
pub(crate) socket_interface: LocalInterface,
}
/// 将配置中的网卡名称解析为底层 Socket 所需的接口标识。
/// Linux/Android 使用名称绑定,Windows/macOS 使用接口索引。
pub(crate) fn resolve_interface(name: Option<&str>) -> anyhow::Result<Option<ResolvedInterface>> {
let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
return Ok(None);
};
let (canonical_name, index) = match getifaddrs::if_nametoindex(name) {
Ok(index) => (
getifaddrs::if_indextoname(index).unwrap_or_else(|_| name.to_owned()),
index,
),
Err(_) => {
let interface = getifaddrs::getifaddrs()
.context("读取本机网卡列表失败")?
.find(|interface| {
if interface.name.eq_ignore_ascii_case(name) {
return true;
}
#[cfg(windows)]
{
interface.description.eq_ignore_ascii_case(name)
}
#[cfg(not(windows))]
{
false
}
})
.ok_or_else(|| anyhow::anyhow!("找不到出口网卡 '{name}'"))?;
let index = interface
.index
.ok_or_else(|| anyhow::anyhow!("出口网卡 '{}' 没有可用索引", interface.name))?;
(interface.name, index)
}
};
#[cfg(any(target_os = "linux", target_os = "android"))]
let interface = LocalInterface::new(canonical_name.clone());
#[cfg(not(any(target_os = "linux", target_os = "android")))]
let interface = LocalInterface::new(index);
// Linux/Android 只用名称,但仍执行 if_nametoindex 来提前校验配置。
let _ = index;
Ok(Some(ResolvedInterface {
name: canonical_name,
socket_interface: interface,
}))
}
pub(crate) trait SocketTrait {
fn set_ip_unicast_if(&self, _interface: &LocalInterface) -> io::Result<()> {
Ok(())
}
}
#[cfg(target_os = "windows")]
impl SocketTrait for Socket {
fn set_ip_unicast_if(&self, interface: &LocalInterface) -> io::Result<()> {
use std::os::windows::io::AsRawSocket;
use windows_sys::Win32::Networking::WinSock::{
IP_UNICAST_IF, IPPROTO_IP, SOCKET_ERROR, htonl, setsockopt,
};
let raw_socket = self.as_raw_socket();
let result = unsafe {
let best_interface = htonl(interface.index);
setsockopt(
raw_socket as usize,
IPPROTO_IP,
IP_UNICAST_IF,
&best_interface as *const _ as *const u8,
std::mem::size_of_val(&best_interface) as i32,
)
};
if result == SOCKET_ERROR {
return Err(io::Error::last_os_error());
}
Ok(())
}
}
#[cfg(any(target_os = "linux", target_os = "android"))]
impl SocketTrait for Socket {
fn set_ip_unicast_if(&self, interface: &LocalInterface) -> io::Result<()> {
self.bind_device(Some(interface.name.as_bytes()))
}
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
impl SocketTrait for Socket {
fn set_ip_unicast_if(&self, interface: &LocalInterface) -> io::Result<()> {
self.bind_device_by_index_v4(std::num::NonZeroU32::new(interface.index))
}
}
#[cfg(target_os = "freebsd")]
impl SocketTrait for Socket {}
pub(crate) fn bind_socket_to_interface(
socket: &Socket,
interface: Option<&LocalInterface>,
is_ipv4: bool,
) -> io::Result<()> {
if is_ipv4 && let Some(interface) = interface {
socket.set_ip_unicast_if(interface)?;
}
Ok(())
}
pub(crate) fn bind_udp(
addr: SocketAddr,
interface: Option<&LocalInterface>,
) -> io::Result<tokio::net::UdpSocket> {
let socket = rust_p2p_core::socket::bind_udp(addr, interface)?;
tokio::net::UdpSocket::from_std(socket.into())
}
pub(crate) async fn connect_tcp(
addr: SocketAddr,
interface: Option<&LocalInterface>,
) -> io::Result<tokio::net::TcpStream> {
let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?;
bind_socket_to_interface(
&socket,
interface,
addr.is_ipv4() && !addr.ip().is_loopback(),
)?;
socket.set_nonblocking(true)?;
socket.set_tcp_nodelay(true)?;
match socket.connect(&addr.into()) {
Ok(()) => {}
Err(ref error) if error.kind() == io::ErrorKind::WouldBlock => {}
#[cfg(unix)]
Err(ref error) if error.raw_os_error() == Some(libc::EINPROGRESS) => {}
Err(error) => return Err(error),
}
let stream = tokio::net::TcpStream::from_std(socket.into())?;
stream.writable().await?;
if let Some(error) = stream.take_error()? {
return Err(error);
}
Ok(stream)
}
pub(crate) async fn connect_tcp_resolved<A: tokio::net::ToSocketAddrs>(
addr: A,
interface: Option<&LocalInterface>,
) -> io::Result<tokio::net::TcpStream> {
let addrs = tokio::net::lookup_host(addr).await?.collect::<Vec<_>>();
let mut last_error = None;
for addr in addrs {
match connect_tcp(addr, interface).await {
Ok(stream) => return Ok(stream),
Err(error) => last_error = Some(error),
}
}
Err(last_error.unwrap_or_else(|| io::Error::other("目标地址解析结果为空")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_interface_name_disables_binding() {
assert!(resolve_interface(None).unwrap().is_none());
assert!(resolve_interface(Some(" ")).unwrap().is_none());
}
#[test]
fn invalid_interface_name_is_rejected() {
let name = "vnt-interface-that-must-not-exist";
assert!(resolve_interface(Some(name)).is_err());
}
#[test]
fn existing_interface_name_resolves() {
let interface = getifaddrs::getifaddrs()
.unwrap()
.find(|interface| interface.index.is_some())
.expect("at least one indexed interface");
let resolved = resolve_interface(Some(&interface.name)).unwrap().unwrap();
assert_eq!(resolved.name, interface.name);
#[cfg(not(any(target_os = "linux", target_os = "android")))]
assert_eq!(resolved.socket_interface.index, interface.index.unwrap());
}
#[cfg(windows)]
#[test]
fn windows_friendly_name_resolves() {
let interface = getifaddrs::getifaddrs()
.unwrap()
.find(|interface| interface.index.is_some() && !interface.description.is_empty())
.expect("at least one described interface");
let resolved = resolve_interface(Some(&interface.description))
.unwrap()
.unwrap();
assert_eq!(resolved.socket_interface.index, interface.index.unwrap());
}
}
+1
View File
@@ -18,3 +18,4 @@ cli-table = "0.5.0"
[build-dependencies]
prost-build = "0.14"
protoc-bin-vendored = "3"
+5
View File
@@ -1,5 +1,10 @@
fn main() {
let protoc_path = protoc_bin_vendored::protoc_bin_path()
.expect("failed to find vendored protoc");
let mut config = prost_build::Config::new();
config.protoc_executable(protoc_path);
config.protoc_arg("--experimental_allow_proto3_optional");
config
.compile_protos(&["proto/local_ipc.proto"], &["proto"])
@@ -18,6 +18,7 @@ public class VntConfig {
private final String deviceId;
private final String deviceName;
private final String tunName;
private final String outboundInterface;
private final String ip;
private final String certMode;
private final boolean noPunch;
@@ -39,6 +40,7 @@ public class VntConfig {
this.deviceId = builder.deviceId;
this.deviceName = builder.deviceName;
this.tunName = builder.tunName;
this.outboundInterface = builder.outboundInterface;
this.ip = builder.ip;
this.certMode = builder.certMode;
this.noPunch = builder.noPunch;
@@ -73,6 +75,7 @@ public class VntConfig {
if (deviceId != null) json.put("device_id", deviceId);
if (deviceName != null) json.put("device_name", deviceName);
if (tunName != null) json.put("tun_name", tunName);
if (outboundInterface != null) json.put("outbound_interface", outboundInterface);
if (ip != null) json.put("ip", ip);
if (certMode != null) json.put("cert_mode", certMode);
if (mtu != null) json.put("mtu", mtu);
@@ -124,6 +127,7 @@ public class VntConfig {
private String deviceId;
private String deviceName;
private String tunName;
private String outboundInterface;
private String ip;
private String certMode;
private boolean noPunch = false;
@@ -188,6 +192,14 @@ public class VntConfig {
return this;
}
/**
* 绑定对外通信Socket的出口网卡名称(可选)
*/
public Builder setOutboundInterface(String outboundInterface) {
this.outboundInterface = outboundInterface;
return this;
}
/**
* 设置固定IP(可选)
*/
+3
View File
@@ -895,6 +895,8 @@ fn parse_config_from_json(json_str: &str) -> anyhow::Result<Config> {
#[serde(default)]
tun_name: Option<String>,
#[serde(default)]
outbound_interface: Option<String>,
#[serde(default)]
ip: Option<Ipv4Addr>,
#[serde(default)]
password: Option<String>,
@@ -994,6 +996,7 @@ fn parse_config_from_json(json_str: &str) -> anyhow::Result<Config> {
device_id,
device_name,
tun_name: cfg.tun_name,
outbound_interface: cfg.outbound_interface,
password: cfg.password,
cert_mode,
input: cfg.input,
+3
View File
@@ -254,6 +254,7 @@ pub struct StartConfig {
pub device_id: Option<String>,
pub device_name: Option<String>,
pub tun_name: Option<String>,
pub outbound_interface: Option<String>,
pub ip: Option<Ipv4Addr>,
pub password: Option<String>,
#[serde(default)]
@@ -1389,6 +1390,7 @@ fn convert_config(cfg: StartConfig) -> anyhow::Result<CoreConfig> {
device_id,
device_name,
tun_name: cfg.tun_name,
outbound_interface: cfg.outbound_interface,
password: cfg.password,
cert_mode,
input: cfg.input,
@@ -1690,6 +1692,7 @@ mod tests {
device_id: Some("device-a".to_string()),
device_name: None,
tun_name: None,
outbound_interface: None,
ip: None,
password: None,
no_punch: false,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -19,8 +19,8 @@
if (dark) document.documentElement.classList.add("dark");
})();
</script>
<script type="module" crossorigin src="/assets/index-DIvs683K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-F22jYCcu.css">
<script type="module" crossorigin src="/assets/index-JN_O_VQt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DviShRor.css">
</head>
<body>
<div id="app"></div>
+11
View File
@@ -19,6 +19,7 @@ export const emptyFormData = () => ({
device_name: "",
device_id: "",
tun_name: "",
outbound_interface: "",
password: "",
cert_mode: "skip",
fingerprint: "",
@@ -95,6 +96,9 @@ export const parseTomlToForm = (toml) => {
} else if (trimmed.includes("tun_name")) {
const match = trimmed.match(/tun_name\s*=\s*"([^"]*)"/);
if (match) data.tun_name = match[1];
} else if (trimmed.includes("outbound_interface")) {
const match = trimmed.match(/outbound_interface\s*=\s*"([^"]*)"/);
if (match) data.outbound_interface = match[1];
} else if (trimmed.includes("password =")) {
const match = trimmed.match(/password\s*=\s*"([^"]*)"/);
if (match) data.password = match[1];
@@ -231,6 +235,10 @@ export const formToToml = (formData) => {
toml += "\n# 虚拟网卡名称\n";
toml += `tun_name = "${formData.tun_name}"\n`;
}
if (formData.outbound_interface) {
toml += "\n# 绑定对外通信 Socket 的出口网卡名称(用于服务端通信、P2P 打洞及转发流量)\n";
toml += `outbound_interface = "${formData.outbound_interface}"\n`;
}
toml += "\n# --- 安全配置 ---\n";
if (formData.password) {
@@ -331,6 +339,9 @@ server = ["quic://1.2.3.4:29872"]
# 虚拟网卡名称
# tun_name = "vnt-tun"
# 绑定对外通信 Socket 的出口网卡名称(例如 Ethernet、Wi-Fi、eth0
# outbound_interface = "Ethernet"
# --- 安全配置 ---
# 加密密码 (可选)
+11 -1
View File
@@ -515,7 +515,7 @@ const sectionTitleClass = "text-md mb-4 flex items-center font-bold text-slate-9
</svg>
设备配置
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">设备名称</label>
<input v-model="formData.device_name" type="text" placeholder="默认为主机名" class="input" />
@@ -528,6 +528,16 @@ const sectionTitleClass = "text-md mb-4 flex items-center font-bold text-slate-9
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">虚拟网卡名</label>
<input v-model="formData.tun_name" type="text" placeholder="默认为vnt-tun" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">绑定出口网卡</label>
<input
v-model="formData.outbound_interface"
type="text"
placeholder="例如 Ethernet、Wi-Fi、eth0"
class="input"
/>
<p class="mt-1.5 text-xs leading-5 text-slate-400">服务端通信P2P 打洞及转发流量将使用此网卡</p>
</div>
</div>
</div>