feat: add TAP device mode and Ethernet interoperability

This commit is contained in:
lbl
2026-08-22 21:47:49 +08:00
parent 26a44e64bd
commit de0183139d
26 changed files with 1073 additions and 139 deletions
+26 -7
View File
@@ -18,9 +18,18 @@ fn main() {
// UI 源码变化时重新运行本脚本
println!("cargo:rerun-if-changed={}", ui_dir.join("src").display());
println!("cargo:rerun-if-changed={}", ui_dir.join("index.html").display());
println!("cargo:rerun-if-changed={}", ui_dir.join("vite.config.js").display());
println!("cargo:rerun-if-changed={}", ui_dir.join("package.json").display());
println!(
"cargo:rerun-if-changed={}",
ui_dir.join("index.html").display()
);
println!(
"cargo:rerun-if-changed={}",
ui_dir.join("vite.config.js").display()
);
println!(
"cargo:rerun-if-changed={}",
ui_dir.join("package.json").display()
);
// 产物被删除时也要重新运行
println!(
"cargo:rerun-if-changed={}",
@@ -53,9 +62,9 @@ fn main() {
if !ui_dir.join("node_modules").is_dir() {
// ui 依赖使用 workspace catalog,必须在仓库根目录安装
run_or_panic(&pnpm, &["install", "--frozen-lockfile"], workspace_root);
run_or_panic(pnpm, &["install", "--frozen-lockfile"], workspace_root);
}
run_or_panic(&pnpm, &["--filter", "vnt-web-ui", "build"], workspace_root);
run_or_panic(pnpm, &["--filter", "vnt-web-ui", "build"], workspace_root);
}
/// pnpm 命令名(Windows 上是 pnpm.cmd,由 cmd.exe 执行)
@@ -72,14 +81,24 @@ fn find_pnpm() -> Option<&'static str> {
}
fn run_or_panic(program: &str, args: &[&str], dir: &Path) {
println!("cargo:warning=执行前端构建: {} {} ({})", program, args.join(" "), dir.display());
println!(
"cargo:warning=执行前端构建: {} {} ({})",
program,
args.join(" "),
dir.display()
);
let status = Command::new(program)
.args(args)
.current_dir(dir)
.status()
.unwrap_or_else(|e| panic!("执行 {} 失败: {}", program, e));
if !status.success() {
panic!("前端构建失败: {} {} (exit: {:?})", program, args.join(" "), status.code());
panic!(
"前端构建失败: {} {} (exit: {:?})",
program,
args.join(" "),
status.code()
);
}
}
+50 -12
View File
@@ -28,7 +28,7 @@ use tokio_util::sync::CancellationToken;
use tower::ServiceExt;
use tower_http::cors::{Any, CorsLayer};
use vnt_core::api::VntApi;
use vnt_core::context::config::Config as CoreConfig;
use vnt_core::context::config::{Config as CoreConfig, DeviceMode};
use vnt_core::core::{DEFAULT_MTU, NetworkManager, RegisterResponse};
use vnt_core::nat::NetInput;
use vnt_core::port_mapping::PortMapping;
@@ -271,7 +271,9 @@ pub struct StartConfig {
#[serde(default)]
pub no_nat: bool,
#[serde(default)]
pub no_tun: bool,
pub device_mode: DeviceMode,
#[serde(default, rename = "no_tun", skip_serializing)]
pub legacy_no_tun: Option<bool>,
pub mtu: Option<u16>,
#[serde(default)]
pub port_mapping: Vec<String>,
@@ -284,6 +286,15 @@ pub struct StartConfig {
pub tunnel_port: Option<u16>,
}
impl StartConfig {
fn reject_legacy_no_tun(&self) -> anyhow::Result<()> {
if self.legacy_no_tun.is_some() {
bail!("configuration key 'no_tun' was removed; use device_mode = \"no|tun|tap\"")
}
Ok(())
}
}
#[derive(Deserialize)]
struct SaveConfigReq {
file_name: Option<String>,
@@ -982,19 +993,20 @@ async fn start_vnt_network(
format!("注册成功 {}/{}", reg_msg.ip, reg_msg.prefix_len),
);
log::info!("Network Started: {}/{}", reg_msg.ip, reg_msg.prefix_len);
if !network_manager.is_no_tun() {
state.record_log(&file_name, "正在创建 TUN 虚拟网卡");
network_manager.start_tun().await?;
if network_manager.device_mode().has_device() {
let mode = network_manager.device_mode();
state.record_log(&file_name, format!("正在创建 {} 虚拟网卡", mode));
network_manager.start_device().await?;
state.record_log(&file_name, "创建 TUN 虚拟网卡成功,设置 IP");
state.record_log(&file_name, format!("创建 {} 虚拟网卡成功,设置 IP", mode));
network_manager
.set_tun_network_ip(reg_msg.ip, reg_msg.prefix_len)
.set_device_network_ip(reg_msg.ip, reg_msg.prefix_len)
.await?;
state.record_log(&file_name, "设置 IP 成功");
// 配置子网路由
if !sub_input.is_empty()
&& let Ok(if_index) = network_manager.tun_if_index().await
&& let Ok(if_index) = network_manager.device_if_index().await
&& let Ok(mut route_manager) = route_manager::RouteManager::new()
{
state.record_log(&file_name, "配置子网路由");
@@ -1011,6 +1023,8 @@ async fn start_vnt_network(
}
}
}
} else {
state.record_log(&file_name, "device_mode=no,不创建虚拟网卡");
}
state.starting_to_running(&file_name);
@@ -1278,7 +1292,13 @@ async fn list_configs() -> Json<ApiResponse<Vec<ConfigSummary>>> {
async fn save_config(Json(req): Json<SaveConfigReq>) -> Json<ApiResponse<()>> {
// 验证配置格式
if let Err(e) = toml::from_str::<StartConfig>(&req.config) {
let parsed = toml::from_str::<StartConfig>(&req.config).and_then(|config| {
config
.reject_legacy_no_tun()
.map(|_| config)
.map_err(serde::de::Error::custom)
});
if let Err(e) = parsed {
log::warn!("Failed to parse configuration: {:?}", e);
return Json(ApiResponse::error(format!("Invalid TOML format: {}", e)));
}
@@ -1358,6 +1378,7 @@ async fn delete_config(
}
fn convert_config(cfg: StartConfig) -> anyhow::Result<CoreConfig> {
cfg.reject_legacy_no_tun()?;
let server_addrs: Vec<ProtocolAddress> = cfg
.server
.iter()
@@ -1423,7 +1444,7 @@ fn convert_config(cfg: StartConfig) -> anyhow::Result<CoreConfig> {
input: cfg.input,
output: cfg.output,
no_nat: cfg.no_nat,
no_tun: cfg.no_tun,
device_mode: cfg.device_mode,
mtu: cfg.mtu,
port_mapping,
allow_port_mapping: cfg.allow_mapping,
@@ -1750,8 +1771,9 @@ mod tests {
input: Vec::new(),
output: Vec::new(),
no_nat: false,
// 默认 no_tun,避免无关用例意外触发 tun_name 冲突
no_tun: true,
// 默认无网卡,避免无关用例意外触发 tun_name 冲突
device_mode: DeviceMode::No,
legacy_no_tun: None,
mtu: None,
port_mapping: Vec::new(),
allow_mapping: false,
@@ -1761,6 +1783,22 @@ mod tests {
}
}
#[test]
fn test_device_mode_config_and_legacy_rejection() {
let base = r#"server = ["quic://127.0.0.1:29872"]
network_code = "test"
"#;
let default_cfg: StartConfig = toml::from_str(base).unwrap();
assert_eq!(default_cfg.device_mode, DeviceMode::Tun);
let tap_cfg: StartConfig =
toml::from_str(&format!("{base}device_mode = \"tap\"\n")).unwrap();
assert_eq!(tap_cfg.device_mode, DeviceMode::Tap);
let legacy: StartConfig = toml::from_str(&format!("{base}no_tun = true\n")).unwrap();
assert!(legacy.reject_legacy_no_tun().is_err());
}
/// 两个实例同时处于 Starting 互不影响
#[test]
fn test_two_instances_starting_independent() {
+13 -8
View File
@@ -13,7 +13,7 @@ export const emptyFormData = () => ({
input: [],
output: [],
no_nat: false,
no_tun: false,
device_mode: "tun",
port_mapping: [],
allow_mapping: false,
device_name: "",
@@ -78,7 +78,13 @@ export const parseTomlToForm = (toml) => {
} else if (trimmed.match(/^no_nat\s*=/)) {
data.no_nat = trimmed.includes("true");
} else if (trimmed.match(/^no_tun\s*=/)) {
data.no_tun = trimmed.includes("true");
throw new Error('配置项 no_tun 已移除,请改用 device_mode = "no|tun|tap"');
} else if (trimmed.match(/^device_mode\s*=/)) {
const match = trimmed.match(/device_mode\s*=\s*"([^"]*)"/);
if (!match || !["no", "tun", "tap"].includes(match[1])) {
throw new Error('device_mode 必须是 "no"、"tun" 或 "tap"');
}
data.device_mode = match[1];
} else if (trimmed.startsWith("port_mapping")) {
const match = trimmed.match(/port_mapping\s*=\s*\[(.*)\]/);
if (match) {
@@ -196,10 +202,8 @@ export const formToToml = (formData) => {
toml += "no_nat = true\n";
}
if (formData.no_tun) {
toml += "\n# 是否关闭TUN虚拟网卡,关闭后只能充当流量出口或者进行端口映射,关闭后无需管理员权限\n";
toml += "no_tun = true\n";
}
toml += "\n# 虚拟网卡模式:no(无网卡)、tun(三层网卡)、tap(二层网卡)\n";
toml += `device_mode = "${formData.device_mode || "tun"}"\n`;
const portMappings = formData.port_mapping.filter((s) => s.trim());
if (portMappings.length > 0) {
@@ -310,8 +314,9 @@ server = ["quic://1.2.3.4:29872"]
# 是否关闭内置子网NAT,关闭(设为true)后需要配置网卡转发,否则无法使用点对网。通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好
# no_nat = false
# 是否关闭TUN虚拟网卡,关闭(设为true)后只能充当流量出口或者进行端口映射,关闭后无需管理员权限
# no_tun = false
# 虚拟网卡模式:no(无网卡)、tun(三层网卡,默认)、tap(二层网卡)
# Windows 的 tap 模式需要预先安装 tap-windows (tap0901) 驱动
device_mode = "tun"
# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址
# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址,从而实现跨网络或内网服务访问
+22 -13
View File
@@ -26,10 +26,15 @@ const hasFormChanges = ref(false);
const isParsingToml = ref(false);
const formData = ref(emptyFormData());
const certificateModeOptions = [
{ value: "skip", label: "跳过验证默认" },
{ value: "skip", label: "跳过验证(默认)" },
{ value: "standard", label: "系统证书验证" },
{ value: "finger", label: "证书指纹验证" },
];
const deviceModeOptions = [
{ value: "no", label: "无虚拟网卡" },
{ value: "tun", label: "TUN(三层)" },
{ value: "tap", label: "TAP(二层)" },
];
//
watch(
@@ -68,13 +73,17 @@ watch(
//
const switchToFormMode = () => {
if (editMode.value === "toml") {
editMode.value = "form";
// TOML
isParsingToml.value = true;
formData.value = parseTomlToForm(editorContent.value);
nextTick(() => {
isParsingToml.value = false;
});
try {
const parsed = parseTomlToForm(editorContent.value);
editMode.value = "form";
isParsingToml.value = true;
formData.value = parsed;
nextTick(() => {
isParsingToml.value = false;
});
} catch (e) {
ui.toast.error("配置解析失败: " + e.message);
}
} else {
editMode.value = "form";
}
@@ -440,13 +449,13 @@ const sectionTitleClass = "text-md mb-4 flex items-center font-bold text-slate-9
</div>
<input v-model="formData.no_nat" type="checkbox" :class="checkboxClass" />
</label>
<label :class="toggleLabelClass">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-700 dark:bg-slate-800/50">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭TUN网卡</div>
<div class="text-xs text-slate-400 mt-0.5">仅作流量出口或端口映射</div>
<div class="text-sm font-medium text-slate-800 dark:text-white">虚拟网卡模式</div>
<div class="text-xs text-slate-400 mt-0.5">TAP 为二层网卡Windows 需要 tap-windows 驱动</div>
</div>
<input v-model="formData.no_tun" type="checkbox" :class="checkboxClass" />
</label>
<AppSelect v-model="formData.device_mode" :options="deviceModeOptions" class="mt-2" aria-label="虚拟网卡模式" />
</div>
</div>
</div>
</div>