diff --git a/.gitignore b/.gitignore index f34e1dc..d883c10 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ ./wintun.dll vnt_config vnt_current_config.txt -logs/* \ No newline at end of file +logs/* +/.tools/ \ No newline at end of file diff --git a/vnt-web/src/service_http.rs b/vnt-web/src/service_http.rs index f4dc523..28486b1 100644 --- a/vnt-web/src/service_http.rs +++ b/vnt-web/src/service_http.rs @@ -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>, } #[derive(Default)] struct HttpAppStateInner { + /// 组网实例表,key = 配置文件名,同一配置最多一个实例 + instances: HashMap, +} + +#[derive(Default)] +struct InstanceState { vnt: Option, status: VntStatus, start_logs: Vec, /// 启动任务句柄,用于在 Starting 状态中断注册重试循环 start_handle: Option>, + /// 每个实例持有自己的任务组管理器(TaskGroupManager 是单槽的,不能共享) + task_group_manager: TaskGroupManager, + /// 启动时解析出的配置快照,用于多实例启动前冲突检测 + start_config: Option, + /// 展示名;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) { + fn record_log(&self, file_name: &str, msg: impl Into) { 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) { + fn record_log_and_stopped(&self, file_name: &str, msg: impl Into) { 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 { + 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, } +#[derive(Serialize)] +struct InstanceSummary { + file_name: String, + config_name: String, + status: VntStatus, +} + async fn get_start_status( State(state): State, + Query(req): Query, ) -> Json> { 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, +) -> Json>> { + let lock = state.inner.lock(); + let mut list: Vec = 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, -) -> 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 = 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 { + let Ok(content) = fs::read_to_string(CURRENT_CONFIG_RECORD).await else { + return Vec::new(); + }; + let mut names: Vec = 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, 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) -> Json> { - if state.status() == VntStatus::Stopped { +async fn stop_vnt_handler( + State(state): State, + Json(req): Json, +) -> Json> { + 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, + Query(req): Query, +) -> Json> { + 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, Json(req): Json, @@ -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) -> Json> { +async fn get_info( + State(state): State, + Query(req): Query, +) -> Json> { 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) -> Json>> { - let api = state.inner.lock().vnt.as_ref().map(|v| v.api.clone()); +async fn get_peers( + State(state): State, + Query(req): Query, +) -> Json>> { + 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) -> Json) -> Json>> { +async fn get_routes( + State(state): State, + Query(req): Query, +) -> Json>> { 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); } } diff --git a/vnt-web/static/index.html b/vnt-web/static/index.html index bdbe32b..2c63a42 100644 --- a/vnt-web/static/index.html +++ b/vnt-web/static/index.html @@ -263,7 +263,7 @@
- Client: v{{ info.version }} + Client: v{{ version || '-' }}
@@ -280,17 +280,15 @@ > {{ info.status === 'running' ? '已运行' : - (info.status === 'starting' ? '启动中...' : - '未启动') }}{{ headerStatusText }}
@@ -317,25 +315,28 @@
{{ info.ip }}{{ selectedInfo.ip }} + {{ selectedConfigName }}
-
+
设备: {{ info.name || '' }}{{ selectedInfo.name || '' }} {{ info.device_id.substring(0, 8) }}...{{ (selectedInfo.device_id || '').substring(0, 8) }}...
@@ -379,10 +380,17 @@ 'running' ? '启动成功' : '启动失败') }}
- {{ startStatus }} +
+ {{ logConfigName }} + {{ startStatus }} +
@@ -804,15 +846,21 @@
- Running + 运行中 +
+
+ 启动中