feat(vnt-web): 支持同时启动多个组网配置

- 后端实例状态从全局单槽改为按配置文件名隔离的 HashMap,每个实例持有
  独立的状态机、启动日志与任务组管理器
- 启动/停止/重启/信息/对端/路由/启动状态接口均按 file_name 维度隔离,
  新增 GET /api/instances 实例列表与 DELETE /api/instance 移除已停止实例
- 启动前冲突检测:device_id 相同(同服务器同组网范围内)或 tunnel_port
  显式相同则拒绝启动
- vnt_current_config.txt 改为每行一个配置名,重启后自动恢复所有运行中实例
- 前端重做为多实例模型:实例卡片列表、按实例隔离的启动日志弹窗、
  peers/routes 顶部实例切换器、配置列表运行状态标记、已停止实例可移除
This commit is contained in:
lbl
2026-08-21 21:31:48 +08:00
parent 94d380f9b6
commit 292d54fbc3
3 changed files with 1140 additions and 338 deletions
+576 -113
View File
@@ -8,7 +8,7 @@ use axum::{
extract::{Query, Request, State},
middleware,
response::Response,
routing::{get, post},
routing::{delete, get, post},
};
use ipnet::Ipv4Net;
use mime_guess::from_path;
@@ -47,84 +47,156 @@ enum VntStatus {
#[derive(Clone)]
struct HttpAppState {
task_group_manager: TaskGroupManager,
inner: Arc<Mutex<HttpAppStateInner>>,
}
#[derive(Default)]
struct HttpAppStateInner {
/// 组网实例表,key = 配置文件名,同一配置最多一个实例
instances: HashMap<String, InstanceState>,
}
#[derive(Default)]
struct InstanceState {
vnt: Option<VntHandler>,
status: VntStatus,
start_logs: Vec<String>,
/// 启动任务句柄,用于在 Starting 状态中断注册重试循环
start_handle: Option<tokio::task::JoinHandle<()>>,
/// 每个实例持有自己的任务组管理器(TaskGroupManager 是单槽的,不能共享)
task_group_manager: TaskGroupManager,
/// 启动时解析出的配置快照,用于多实例启动前冲突检测
start_config: Option<StartConfig>,
/// 展示名;Starting 阶段还没有 vnt,用配置里的 config_name 或 file_name 兜底
config_name: String,
}
impl HttpAppState {
fn starting(&self) -> anyhow::Result<()> {
fn starting(&self, file_name: &str) -> anyhow::Result<()> {
let mut inner = self.inner.lock();
if inner.status != VntStatus::Stopped {
return Err(anyhow!("VNT is already starting or running"));
let inst = inner.instances.entry(file_name.to_string()).or_default();
if inst.status != VntStatus::Stopped {
return Err(anyhow!("配置 {} 正在启动或已运行", file_name));
}
if inner.vnt.is_some() {
return Err(anyhow!("VNT is already running"));
if inst.vnt.is_some() {
return Err(anyhow!("配置 {} 已在运行", file_name));
}
inner.status = VntStatus::Starting;
inner.start_logs.clear();
inst.status = VntStatus::Starting;
inst.start_logs.clear();
inst.start_config = None;
inst.config_name = file_name.to_string();
Ok(())
}
fn stopped(&self) {
fn stopped(&self, file_name: &str) {
let mut inner = self.inner.lock();
inner.vnt.take();
inner.status = VntStatus::Stopped;
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
inst.vnt.take();
inst.status = VntStatus::Stopped;
inst.start_config = None;
// 已完成任务的句柄只是残留,不算运行内容
if inst.start_handle.as_ref().is_some_and(|h| h.is_finished()) {
inst.start_handle.take();
}
// 实例已无任何运行内容时移除条目,避免实例表堆积已停止的配置。
// 注意 Starting 失败路径走 record_log_and_stopped/starting_to_stopped 保留日志,
// 不经过这里,不会被误删。
let removable = inst.start_handle.is_none() && inst.task_group_manager.is_stopped();
if removable {
inner.instances.remove(file_name);
}
}
fn starting_to_stopped(&self) {
fn starting_to_stopped(&self, file_name: &str) {
let mut inner = self.inner.lock();
if inner.status != VntStatus::Starting {
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
return;
}
inner.vnt.take();
inner.status = VntStatus::Stopped;
inner
inst.vnt.take();
inst.status = VntStatus::Stopped;
inst
.start_logs
.push(format!("[{}] 启动中断", HttpAppState::timestamp()));
}
fn starting_to_running(&self) {
fn starting_to_running(&self, file_name: &str) {
let mut inner = self.inner.lock();
if inner.status != VntStatus::Starting {
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
log::error!("starting_to_running VNT is not starting");
return;
}
inner.status = VntStatus::Running;
inner.start_logs.clear();
inst.status = VntStatus::Running;
inst.start_logs.clear();
}
fn record_log(&self, msg: impl Into<String>) {
fn record_log(&self, file_name: &str, msg: impl Into<String>) {
let mut inner = self.inner.lock();
if inner.status != VntStatus::Starting {
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
return;
}
inner
.start_logs
inst.start_logs
.push(format!("[{}] {}", Self::timestamp(), msg.into()));
}
fn record_log_and_stopped(&self, msg: impl Into<String>) {
fn record_log_and_stopped(&self, file_name: &str, msg: impl Into<String>) {
let mut inner = self.inner.lock();
if inner.status != VntStatus::Starting {
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
return;
}
inner
.start_logs
inst.start_logs
.push(format!("[{}] {}", Self::timestamp(), msg.into()));
inner.status = VntStatus::Stopped;
inst.status = VntStatus::Stopped;
}
fn status(&self) -> VntStatus {
self.inner.lock().status
fn status(&self, file_name: &str) -> VntStatus {
self.inner
.lock()
.instances
.get(file_name)
.map(|inst| inst.status)
.unwrap_or(VntStatus::Stopped)
}
fn task_group_manager(&self, file_name: &str) -> Option<TaskGroupManager> {
self.inner
.lock()
.instances
.get(file_name)
.map(|inst| inst.task_group_manager.clone())
}
/// 启动解析出配置后写入展示名和配置快照(供实例列表与冲突检测使用)
fn set_starting_config(&self, file_name: &str, config_name: String, cfg: StartConfig) {
if let Some(inst) = self.inner.lock().instances.get_mut(file_name) {
inst.config_name = config_name;
inst.start_config = Some(cfg);
}
}
fn set_start_handle(&self, file_name: &str, handle: tokio::task::JoinHandle<()>) {
if let Some(inst) = self.inner.lock().instances.get_mut(file_name) {
inst.start_handle = Some(handle);
}
}
/// 中断启动任务(如注册重试循环)。任务已完成时为空操作。
fn abort_start_task(&self) {
if let Some(handle) = self.inner.lock().start_handle.take() {
fn abort_start_task(&self, file_name: &str) {
let handle = self
.inner
.lock()
.instances
.get_mut(file_name)
.and_then(|inst| inst.start_handle.take());
if let Some(handle) = handle {
handle.abort();
}
}
@@ -141,6 +213,8 @@ struct VntHandler {
api: VntApi,
config_name: String,
config_file_name: String,
/// 启动时的配置快照,用于多实例冲突检测
start_config: StartConfig,
}
#[derive(Serialize)]
@@ -313,14 +387,61 @@ struct StartStatusResponse {
logs: Vec<String>,
}
#[derive(Serialize)]
struct InstanceSummary {
file_name: String,
config_name: String,
status: VntStatus,
}
async fn get_start_status(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<StartStatusResponse>> {
let lock = state.inner.lock();
Json(ApiResponse::success(StartStatusResponse {
status: lock.status,
logs: lock.start_logs.clone(),
}))
// 实例不存在(从未启动或已停止并清理)时返回 Stopped + 空日志,
// 前端轮询已停止实例时自然终止
let resp = match lock.instances.get(&req.file_name) {
Some(inst) => StartStatusResponse {
status: inst.status,
logs: inst.start_logs.clone(),
},
None => StartStatusResponse {
status: VntStatus::Stopped,
logs: Vec::new(),
},
};
Json(ApiResponse::success(resp))
}
async fn get_instances(
State(state): State<HttpAppState>,
) -> Json<ApiResponse<Vec<InstanceSummary>>> {
let lock = state.inner.lock();
let mut list: Vec<InstanceSummary> = lock
.instances
.iter()
.map(|(file_name, inst)| {
let config_name = inst
.vnt
.as_ref()
.map(|v| v.config_name.clone())
.unwrap_or_else(|| {
if inst.config_name.is_empty() {
file_name.clone()
} else {
inst.config_name.clone()
}
});
InstanceSummary {
file_name: file_name.clone(),
config_name,
status: inst.status,
}
})
.collect();
list.sort_by(|a, b| a.file_name.cmp(&b.file_name));
Json(ApiResponse::success(list))
}
async fn logging_middleware(req: Request, next: axum::middleware::Next) -> Response {
@@ -351,14 +472,13 @@ pub async fn run_http_server(
.context("Failed to create config directory")?;
let state = HttpAppState {
task_group_manager: TaskGroupManager::new(),
inner: Arc::new(Default::default()),
};
// 自动启动逻辑
let auto_start_file = determine_auto_start_file(start_config_file_name).await;
let auto_start_files = determine_auto_start_files(start_config_file_name).await;
if let Some((file_name, path)) = auto_start_file {
for (file_name, path) in auto_start_files {
log::info!("Auto starting VNT with config: {:?}", path);
let state_clone = state.clone();
tokio::spawn(async move {
@@ -378,6 +498,8 @@ pub async fn run_http_server(
.route("/api/peers", get(get_peers))
.route("/api/routes", get(get_routes))
.route("/api/start/status", get(get_start_status))
.route("/api/instances", get(get_instances))
.route("/api/instance", delete(dismiss_instance_handler))
.route("/api/start", post(start_vnt_handler))
.route("/api/stop", post(stop_vnt_handler))
.route("/api/restart", post(restart_vnt_handler))
@@ -400,31 +522,83 @@ pub async fn run_http_server(
Ok(())
}
/// 确定自动启动的配置文件
async fn determine_auto_start_file(
/// 确定自动启动的配置文件列表。
/// --conf 显式指定时只返回那一个;否则读自启记录文件(每行一个 file_name),过滤存在的文件。
async fn determine_auto_start_files(
start_config_file_name: Option<PathBuf>,
) -> Option<(String, PathBuf)> {
let path = if let Some(name) = start_config_file_name {
Some(name)
) -> Vec<(String, PathBuf)> {
let mut result = Vec::new();
let paths: Vec<PathBuf> = if let Some(name) = start_config_file_name {
vec![name]
} else if Path::new(CURRENT_CONFIG_RECORD).exists() {
fs::read_to_string(CURRENT_CONFIG_RECORD)
.await
.ok()
.filter(|content| !content.trim().is_empty())
.map(|content| Path::new(CONFIG_DIR).join(content.trim()))
match fs::read_to_string(CURRENT_CONFIG_RECORD).await {
Ok(content) => content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.map(|line| Path::new(CONFIG_DIR).join(line))
.collect(),
Err(e) => {
log::warn!("Failed to read auto start record: {}", e);
Vec::new()
}
}
} else {
None
Vec::new()
};
path.and_then(|p| {
let file_name = p.file_name()?.to_str()?.to_string();
for p in paths {
let Some(file_name) = p.file_name().and_then(|s| s.to_str()).map(|s| s.to_string()) else {
continue;
};
if result.iter().any(|(name, _)| *name == file_name) {
continue;
}
if p.exists() {
Some((file_name, p))
result.push((file_name, p));
} else {
log::warn!("Auto start config file not found: {:?}", p);
None
}
})
}
result
}
/// 读取自启记录文件(每行一个 file_name,去空白、去重)
async fn read_running_records() -> Vec<String> {
let Ok(content) = fs::read_to_string(CURRENT_CONFIG_RECORD).await else {
return Vec::new();
};
let mut names: Vec<String> = Vec::new();
for line in content.lines() {
let name = line.trim();
if !name.is_empty() && !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
names
}
async fn write_running_records(names: &[String]) {
if let Err(e) = fs::write(CURRENT_CONFIG_RECORD, names.join("\n")).await {
log::warn!("Failed to record running configs: {}", e);
}
}
/// 启动成功后把 file_name 加入自启记录
async fn record_add_running(file_name: &str) {
let mut names = read_running_records().await;
if !names.iter().any(|n| n == file_name) {
names.push(file_name.to_string());
}
write_running_records(&names).await;
}
/// 实例停止后把 file_name 从自启记录移除
async fn record_remove_running(file_name: &str) {
let mut names = read_running_records().await;
names.retain(|n| n != file_name);
write_running_records(&names).await;
}
fn build_headers_for_path(path: &str) -> HeaderMap {
@@ -493,6 +667,40 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
(StatusCode::NOT_FOUND, "404 Not Found").into_response()
}
/// 启动前冲突检测:新配置与所有 Starting/Running 实例的配置比对。
/// 纯函数,便于单元测试。
fn check_config_conflict(new: &StartConfig, running: &[&StartConfig]) -> Result<(), String> {
for cfg in running {
// device_id 的唯一性只在"同一服务器 + 同一组网编号"范围内成立:
// 不同服务器或不同 network_code 的实例互不影响
let same_network = new.network_code == cfg.network_code;
let server_overlap = (new.server.is_empty() && cfg.server.is_empty())
|| new.server.iter().any(|s| cfg.server.contains(s));
// 两者都为 None 也算冲突:缺省 device_id 使用同一 machine_uid
if same_network && server_overlap && new.device_id == cfg.device_id {
return Err(match &new.device_id {
Some(id) => format!(
"启动冲突:device_id \"{}\" 已被同服务器同组网的运行中实例使用",
id
),
None => {
"启动冲突:与同服务器同组网的实例都未指定 device_id,缺省会使用相同的本机标识"
.to_string()
}
});
}
if let (Some(a), Some(b)) = (new.tunnel_port, cfg.tunnel_port)
&& a == b
{
return Err(format!(
"启动冲突:tunnel_port {} 已被其他运行中的实例使用",
a
));
}
}
Ok(())
}
/// 启动 VNT 服务的入口函数
async fn start_vnt_internal(
state: &HttpAppState,
@@ -500,42 +708,72 @@ async fn start_vnt_internal(
file_path: PathBuf,
) -> anyhow::Result<()> {
log::info!("Starting VNT service: {}", file_name);
state.starting()?;
state.starting(&file_name)?;
let state_for_error = state.clone();
let file_name_for_error = file_name.clone();
let on_error_guard = defer(move || {
state_for_error.starting_to_stopped();
state_for_error.starting_to_stopped(&file_name_for_error);
});
state.record_log(format!("启动配置: {}", file_name));
state.record_log("读取配置文件");
state.record_log(&file_name, format!("启动配置: {}", file_name));
state.record_log(&file_name, "读取配置文件");
// 读取并解析配置
let content = fs::read_to_string(&file_path)
.await
.with_context(|| format!("Config file not found: {:?}", file_path))?;
state.record_log("解析配置文件内容");
state.record_log(&file_name, "解析配置文件内容");
let cfg: StartConfig = toml::from_str(&content).context("Failed to parse TOML config")?;
let config_display_name = cfg.config_name.clone().unwrap_or_else(|| file_name.clone());
// 启动前冲突检测:与所有 Starting/Running 实例的配置比对
{
let inner = state.inner.lock();
let running: Vec<&StartConfig> = inner
.instances
.iter()
.filter(|(name, inst)| {
name.as_str() != file_name && inst.status != VntStatus::Stopped
})
.filter_map(|(_, inst)| {
inst.vnt
.as_ref()
.map(|v| &v.start_config)
.or(inst.start_config.as_ref())
})
.collect();
if let Err(msg) = check_config_conflict(&cfg, &running) {
bail!(msg);
}
}
state.set_starting_config(&file_name, config_display_name.clone(), cfg.clone());
let start_config = cfg.clone();
let core_config = convert_config(cfg)?;
let sub_input = core_config.input.clone();
state.record_log("创建异步任务组");
let (task_group, task_group_guard) = state
.task_group_manager
state.record_log(&file_name, "创建异步任务组");
let task_group_manager = state
.task_group_manager(&file_name)
.context("Instance not found")?;
let (task_group, task_group_guard) = task_group_manager
.create_task()
.context("Create task failed")?;
state.record_log("创建组网管理器");
state.record_log(&file_name, "创建组网管理器");
let state_clone = state.clone();
let file_name_clone = file_name.clone();
let start_handle = tokio::spawn(async move {
let result = start_vnt_network(
state_clone.clone(),
file_name,
file_name_clone.clone(),
config_display_name,
start_config,
core_config,
sub_input,
task_group,
@@ -545,11 +783,11 @@ async fn start_vnt_internal(
if let Err(e) = result {
log::error!("Failed to start VNT network: {:?}", e);
state_clone.record_log_and_stopped(format!("启动失败: {}", e));
state_clone.record_log_and_stopped(&file_name_clone, format!("启动失败: {}", e));
}
drop(on_error_guard);
});
state.inner.lock().start_handle = Some(start_handle);
state.set_start_handle(&file_name, start_handle);
Ok(())
}
@@ -559,6 +797,7 @@ async fn start_vnt_network(
state: HttpAppState,
file_name: String,
config_display_name: String,
start_config: StartConfig,
core_config: CoreConfig,
sub_input: Vec<NetInput>,
task_group: vnt_core::utils::task_control::TaskGroup,
@@ -573,22 +812,27 @@ async fn start_vnt_network(
{
let mut lock = state.inner.lock();
if lock.vnt.is_some() {
let Some(inst) = lock.instances.get_mut(&file_name) else {
return Err(anyhow!("Instance not found: {}", file_name));
};
if inst.vnt.is_some() {
return Err(anyhow!("VNT is already running"));
}
lock.vnt = Some(VntHandler {
inst.vnt = Some(VntHandler {
api: vnt_api,
config_name: config_display_name,
config_file_name: file_name.clone(),
start_config,
});
}
let state_for_vnt_cleanup = state.clone();
let file_name_for_cleanup = file_name.clone();
let vnt_cleanup_guard = defer(move || {
state_for_vnt_cleanup.stopped();
state_for_vnt_cleanup.stopped(&file_name_for_cleanup);
});
state.record_log("连接服务器,执行注册");
state.record_log(&file_name, "连接服务器,执行注册");
log::info!("Registering with server");
let reg_msg = loop {
@@ -596,7 +840,7 @@ async fn start_vnt_network(
Ok(rs) => rs,
Err(e) => {
log::error!("Register failed: {:?}", e);
state.record_log(format!("注册失败:{},5秒后重试", e));
state.record_log(&file_name, format!("注册失败:{},5秒后重试", e));
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
continue;
}
@@ -611,24 +855,27 @@ async fn start_vnt_network(
}
}
};
state.record_log(format!("注册成功 {}/{}", reg_msg.ip, reg_msg.prefix_len));
state.record_log(
&file_name,
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("正在创建 TUN 虚拟网卡");
state.record_log(&file_name, "正在创建 TUN 虚拟网卡");
network_manager.start_tun().await?;
state.record_log("创建 TUN 虚拟网卡成功,设置 IP");
state.record_log(&file_name, "创建 TUN 虚拟网卡成功,设置 IP");
network_manager
.set_tun_network_ip(reg_msg.ip, reg_msg.prefix_len)
.await?;
state.record_log("设置 IP 成功");
state.record_log(&file_name, "设置 IP 成功");
// 配置子网路由
if !sub_input.is_empty()
&& let Ok(if_index) = network_manager.tun_if_index().await
&& let Ok(mut route_manager) = route_manager::RouteManager::new()
{
state.record_log("配置子网路由");
state.record_log(&file_name, "配置子网路由");
for input in &sub_input {
let route =
route_manager::Route::new(input.net.network().into(), input.net.prefix_len())
@@ -644,23 +891,24 @@ async fn start_vnt_network(
}
}
state.starting_to_running();
state.starting_to_running(&file_name);
// 启动成功后记录到自启列表
record_add_running(&file_name).await;
// 启动网络管理任务。
// 注意必须在任务组外等待:等待目标就是这个 task_group
// 若 spawn 进组内会形成自引用等待,网络自行停止时永不返回
let file_name_for_wait = file_name.clone();
tokio::spawn(async move {
network_manager.wait_all_stopped().await;
drop(task_group_guard);
drop(network_manager);
drop(vnt_cleanup_guard);
record_remove_running(&file_name_for_wait).await;
log::info!("Network manager stopped.");
});
// 记录当前配置
if let Err(e) = fs::write(CURRENT_CONFIG_RECORD, &file_name).await {
log::warn!("Failed to record current config: {}", e);
}
Ok(())
}
@@ -700,18 +948,42 @@ async fn start_vnt_handler(
}
}
async fn stop_vnt_handler(State(state): State<HttpAppState>) -> Json<ApiResponse<()>> {
if state.status() == VntStatus::Stopped {
async fn stop_vnt_handler(
State(state): State<HttpAppState>,
Json(req): Json<FileReq>,
) -> Json<ApiResponse<()>> {
let Some(task_group_manager) = state.task_group_manager(&req.file_name) else {
return Json(ApiResponse::error("实例不存在"));
};
if state.status(&req.file_name) == VntStatus::Stopped {
return Json(ApiResponse::error("Vnt stopped"));
}
// 先中断可能处于注册重试循环中的启动任务,再停止任务组
state.abort_start_task();
state.task_group_manager.stop();
state.abort_start_task(&req.file_name);
task_group_manager.stop();
let _ = fs::write(CURRENT_CONFIG_RECORD, "").await;
record_remove_running(&req.file_name).await;
Json(ApiResponse::success(()))
}
/// 移除已停止的实例条目(清理启动失败的残留卡片)
async fn dismiss_instance_handler(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<()>> {
let mut lock = state.inner.lock();
match lock.instances.get(&req.file_name) {
None => Json(ApiResponse::error("实例不存在")),
Some(inst) if inst.status != VntStatus::Stopped => {
Json(ApiResponse::error("实例正在运行,不能移除"))
}
Some(_) => {
lock.instances.remove(&req.file_name);
Json(ApiResponse::success(()))
}
}
}
async fn restart_vnt_handler(
State(state): State<HttpAppState>,
Json(req): Json<FileReq>,
@@ -726,12 +998,14 @@ async fn restart_vnt_handler(
}
// 先停止(如果正在运行则停止,否则忽略)
if state.status() != VntStatus::Stopped {
state.abort_start_task();
state.task_group_manager.stop();
if state.status(&req.file_name) != VntStatus::Stopped {
state.abort_start_task(&req.file_name);
if let Some(task_group_manager) = state.task_group_manager(&req.file_name) {
task_group_manager.stop();
}
// 等待停止完成
for _ in 0..50 {
if state.status() == VntStatus::Stopped {
if state.status(&req.file_name) == VntStatus::Stopped {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -745,11 +1019,17 @@ async fn restart_vnt_handler(
}
}
async fn get_info(State(state): State<HttpAppState>) -> Json<ApiResponse<HttpAppInfo>> {
async fn get_info(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<HttpAppInfo>> {
let lock = state.inner.lock();
let status = lock.status;
let Some(inst) = lock.instances.get(&req.file_name) else {
return Json(ApiResponse::error("实例不存在"));
};
let status = inst.status;
let info = if let Some(handler) = lock.vnt.as_ref() {
let info = if let Some(handler) = inst.vnt.as_ref() {
let api = &handler.api;
let config = api.get_config();
let ips = api.client_ips();
@@ -912,8 +1192,10 @@ async fn delete_config(
return Json(ApiResponse::error("Invalid file name"));
}
{
if let Some(vnt) = &state.inner.lock().vnt
&& vnt.config_file_name == req.file_name
let lock = state.inner.lock();
// 实例存在且有运行内容(已运行或非 Stopped)即视为占用
if let Some(inst) = lock.instances.get(&req.file_name)
&& (inst.vnt.is_some() || inst.status != VntStatus::Stopped)
{
return Json(ApiResponse::error("此配置已被使用,不能删除"));
}
@@ -1031,8 +1313,17 @@ async fn shutdown_signal() {
}
}
async fn get_peers(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<HttpClientItem>>> {
let api = state.inner.lock().vnt.as_ref().map(|v| v.api.clone());
async fn get_peers(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<Vec<HttpClientItem>>> {
let api = state
.inner
.lock()
.instances
.get(&req.file_name)
.and_then(|inst| inst.vnt.as_ref())
.map(|v| v.api.clone());
let Some(api) = api else {
return Json(ApiResponse::error("VNT not running"));
@@ -1143,10 +1434,17 @@ async fn get_peers(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<Ht
Json(ApiResponse::success(items))
}
async fn get_routes(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<HttpRouteItem>>> {
async fn get_routes(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<Vec<HttpRouteItem>>> {
let lock = state.inner.lock();
let Some(handler) = lock.vnt.as_ref() else {
let Some(handler) = lock
.instances
.get(&req.file_name)
.and_then(|inst| inst.vnt.as_ref())
else {
return Json(ApiResponse::error("VNT not running"));
};
@@ -1222,21 +1520,186 @@ mod tests {
}
}
fn new_test_state() -> HttpAppState {
HttpAppState {
inner: Arc::new(Mutex::new(HttpAppStateInner::default())),
}
}
fn new_test_config() -> StartConfig {
StartConfig {
config_name: None,
server: Vec::new(),
cert_mode: None,
network_code: "test".to_string(),
device_id: Some("device-a".to_string()),
device_name: None,
tun_name: None,
ip: None,
password: None,
no_punch: false,
compress: false,
rtx: false,
fec: false,
input: Vec::new(),
output: Vec::new(),
no_nat: false,
// 默认 no_tun,避免无关用例意外触发 tun_name 冲突
no_tun: true,
mtu: None,
port_mapping: Vec::new(),
allow_mapping: false,
udp_stun: Vec::new(),
tcp_stun: Vec::new(),
tunnel_port: None,
}
}
/// 两个实例同时处于 Starting 互不影响
#[test]
fn test_two_instances_starting_independent() {
let state = new_test_state();
state.starting("a.toml").unwrap();
state.starting("b.toml").unwrap();
state.record_log("a.toml", "a 的日志");
state.record_log("b.toml", "b 的日志");
assert_eq!(state.status("a.toml"), VntStatus::Starting);
assert_eq!(state.status("b.toml"), VntStatus::Starting);
// a 启动失败停止,b 的状态和日志不受影响
state.record_log_and_stopped("a.toml", "启动失败");
assert_eq!(state.status("a.toml"), VntStatus::Stopped);
assert_eq!(state.status("b.toml"), VntStatus::Starting);
let lock = state.inner.lock();
let a = lock.instances.get("a.toml").unwrap();
assert!(a.start_logs.iter().any(|l| l.contains("启动失败")));
let b = lock.instances.get("b.toml").unwrap();
assert_eq!(b.start_logs.len(), 1);
assert!(b.start_logs[0].contains("b 的日志"));
}
/// 移除已停止实例:Stopped 可移除,Starting 拒绝
#[tokio::test]
async fn test_dismiss_instance() {
let state = new_test_state();
state.starting("a.toml").unwrap();
state.record_log_and_stopped("a.toml", "启动失败");
state.starting("b.toml").unwrap();
// Starting 中的实例不能移除
let resp = dismiss_instance_handler(
State(state.clone()),
Query(FileReq {
file_name: "b.toml".to_string(),
}),
)
.await;
assert_eq!(resp.code, -1);
assert!(state.inner.lock().instances.contains_key("b.toml"));
// 已停止(启动失败残留)的实例可以移除
let resp = dismiss_instance_handler(
State(state.clone()),
Query(FileReq {
file_name: "a.toml".to_string(),
}),
)
.await;
assert_eq!(resp.code, 0);
assert!(!state.inner.lock().instances.contains_key("a.toml"));
// 不存在的实例报错
let resp = dismiss_instance_handler(
State(state.clone()),
Query(FileReq {
file_name: "nope.toml".to_string(),
}),
)
.await;
assert_eq!(resp.code, -1);
}
/// 同一 file_name 重复 starting 报错
#[test]
fn test_duplicate_starting_same_file() {
let state = new_test_state();
state.starting("a.toml").unwrap();
assert!(state.starting("a.toml").is_err());
// 不同 file_name 不受影响
state.starting("b.toml").unwrap();
}
/// device_id 相同(含双方都为 None)且同服务器同组网时冲突;
/// 不同服务器或不同 network_code 时允许相同 device_id
#[test]
fn test_conflict_same_device_id() {
let running = new_test_config();
// 相同 device_id(双方 server 均为空,视为同范围)
let new = new_test_config();
assert!(check_config_conflict(&new, &[&running]).is_err());
// 双方都不指定 device_id(缺省会用同一 machine_uid)也算冲突
let mut a = new_test_config();
a.device_id = None;
let mut b = new_test_config();
b.device_id = None;
assert!(check_config_conflict(&b, &[&a]).is_err());
// 不同 device_id 不冲突
let mut c = new_test_config();
c.device_id = Some("device-c".to_string());
assert!(check_config_conflict(&c, &[&running]).is_ok());
// 相同 device_id 但 network_code 不同 → 不冲突
let mut d = new_test_config();
d.network_code = "other-net".to_string();
assert!(check_config_conflict(&d, &[&running]).is_ok());
// 相同 device_id 相同 network_code 但服务器不同 → 不冲突
let mut e_running = new_test_config();
e_running.server = vec!["server1:29870".to_string()];
let mut e = new_test_config();
e.server = vec!["server2:29870".to_string()];
assert!(check_config_conflict(&e, &[&e_running]).is_ok());
// 相同 device_id 相同 network_code 且服务器有交集 → 冲突
let mut f = new_test_config();
f.server = vec!["server1:29870".to_string(), "server3:29870".to_string()];
assert!(check_config_conflict(&f, &[&e_running]).is_err());
}
/// tunnel_port 都为 Some 且相等时冲突
#[test]
fn test_conflict_same_tunnel_port() {
let mut running = new_test_config();
running.device_id = Some("d1".to_string());
running.tunnel_port = Some(12345);
let mut new = new_test_config();
new.device_id = Some("d2".to_string());
new.tunnel_port = Some(12345);
assert!(check_config_conflict(&new, &[&running]).is_err());
// 一方未指定不冲突
let mut new_none = new_test_config();
new_none.device_id = Some("d2".to_string());
assert!(check_config_conflict(&new_none, &[&running]).is_ok());
// 端口不同不冲突
let mut new_other = new_test_config();
new_other.device_id = Some("d2".to_string());
new_other.tunnel_port = Some(23456);
assert!(check_config_conflict(&new_other, &[&running]).is_ok());
}
/// Starting 状态下执行停止:必须中断注册重试循环并迁移到 Stopped。
/// 复现 bug 场景——服务器不可达时启动任务陷在无限重试里,
/// 不中断启动任务则状态永远卡在 Starting。
#[tokio::test]
async fn test_stop_during_starting() {
let state = HttpAppState {
task_group_manager: TaskGroupManager::new(),
inner: Arc::new(Mutex::new(HttpAppStateInner::default())),
};
state.starting().unwrap();
let state = new_test_state();
let file_name = "a.toml";
state.starting(file_name).unwrap();
// 模拟启动任务:注册一直失败、5 秒重试的无限循环
let state_clone = state.clone();
let file_name_owned = file_name.to_string();
let on_error_guard = defer(move || {
state_clone.starting_to_stopped();
state_clone.starting_to_stopped(&file_name_owned);
});
let handle = tokio::spawn(async move {
let _on_error_guard = on_error_guard;
@@ -1244,18 +1707,18 @@ mod tests {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
});
state.inner.lock().start_handle = Some(handle);
state.set_start_handle(file_name, handle);
assert_eq!(state.status(), VntStatus::Starting);
state.abort_start_task();
assert_eq!(state.status(file_name), VntStatus::Starting);
state.abort_start_task(file_name);
// abort 生效后 defer 触发,状态应迁移到 Stopped
for _ in 0..100 {
if state.status() == VntStatus::Stopped {
if state.status(file_name) == VntStatus::Stopped {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(state.status(), VntStatus::Stopped);
assert_eq!(state.status(file_name), VntStatus::Stopped);
}
}