Files
vnt/vnt-web/src/service_http.rs
T

1972 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::defer;
use anyhow::{Context, anyhow, bail};
use axum::body::{Body, to_bytes};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
use axum::response::IntoResponse;
use axum::{
Json, Router,
extract::{Query, Request, State},
middleware,
response::Response,
routing::{delete, get, post},
};
use ipnet::Ipv4Net;
use mime_guess::from_path;
use parking_lot::Mutex;
use rand::RngExt;
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use time::{OffsetDateTime, macros::format_description};
use tokio::fs;
use tokio::net::TcpListener;
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, DeviceMode};
use vnt_core::core::{DEFAULT_MTU, NetworkManager, RegisterResponse};
use vnt_core::nat::NetInput;
use vnt_core::port_mapping::PortMapping;
use vnt_core::tls::verifier::CertValidationMode;
use vnt_core::tunnel_core::server::transport::config::ProtocolAddress;
use vnt_core::utils::task_control::TaskGroupManager;
const CONFIG_DIR: &str = "vnt_config";
const CURRENT_CONFIG_RECORD: &str = "vnt_current_config.txt";
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "lowercase")]
enum VntStatus {
#[default]
Stopped,
Starting,
Running,
}
#[derive(Clone)]
struct HttpAppState {
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, file_name: &str) -> anyhow::Result<()> {
let mut inner = self.inner.lock();
let inst = inner.instances.entry(file_name.to_string()).or_default();
if inst.status != VntStatus::Stopped {
return Err(anyhow!("配置 {} 正在启动或已运行", file_name));
}
if inst.vnt.is_some() {
return Err(anyhow!("配置 {} 已在运行", file_name));
}
inst.status = VntStatus::Starting;
inst.start_logs.clear();
inst.start_config = None;
inst.config_name = file_name.to_string();
Ok(())
}
fn stopped(&self, file_name: &str) {
let mut inner = self.inner.lock();
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, file_name: &str) {
let mut inner = self.inner.lock();
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
return;
}
inst.vnt.take();
inst.status = VntStatus::Stopped;
inst.start_logs
.push(format!("[{}] 启动中断", HttpAppState::timestamp()));
}
fn starting_to_running(&self, file_name: &str) {
let mut inner = self.inner.lock();
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;
}
inst.status = VntStatus::Running;
inst.start_logs.clear();
}
fn record_log(&self, file_name: &str, msg: impl Into<String>) {
let mut inner = self.inner.lock();
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
return;
}
inst.start_logs
.push(format!("[{}] {}", Self::timestamp(), msg.into()));
}
fn record_log_and_stopped(&self, file_name: &str, msg: impl Into<String>) {
let mut inner = self.inner.lock();
let Some(inst) = inner.instances.get_mut(file_name) else {
return;
};
if inst.status != VntStatus::Starting {
return;
}
inst.start_logs
.push(format!("[{}] {}", Self::timestamp(), msg.into()));
inst.status = VntStatus::Stopped;
}
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, 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();
}
}
fn timestamp() -> String {
let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
let format = format_description!("[hour]:[minute]:[second]");
now.format(&format)
.unwrap_or_else(|_| "00:00:00".to_string())
}
}
struct VntHandler {
api: VntApi,
config_name: String,
config_file_name: String,
/// 启动时的配置快照,用于多实例冲突检测
start_config: StartConfig,
}
#[derive(Serialize)]
struct ApiResponse<T> {
code: i32,
msg: String,
data: Option<T>,
}
impl<T> ApiResponse<T> {
fn success(data: T) -> Self {
Self {
code: 0,
msg: "success".to_string(),
data: Some(data),
}
}
fn error(msg: impl Into<String>) -> Self {
Self {
code: -1,
msg: msg.into(),
data: None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct StartConfig {
pub config_name: Option<String>,
pub server: Vec<String>,
pub cert_mode: Option<String>,
pub network_code: String,
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)]
pub no_punch: bool,
#[serde(default)]
pub compress: bool,
#[serde(default)]
pub rtx: bool,
#[serde(default)]
pub fec: bool,
#[serde(default)]
pub input: Vec<NetInput>,
#[serde(default)]
pub output: Vec<Ipv4Net>,
#[serde(default)]
pub no_nat: bool,
#[serde(default)]
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>,
#[serde(default)]
pub allow_mapping: bool,
#[serde(default)]
pub udp_stun: Vec<String>,
#[serde(default)]
pub tcp_stun: Vec<String>,
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>,
config: String,
}
#[derive(Deserialize)]
struct FileReq {
file_name: String,
}
#[derive(Serialize)]
struct ConfigSummary {
file_name: String,
config_name: String,
}
#[derive(Serialize, Default)]
struct HttpAppInfo {
name: String,
version: String,
ip: Option<Ipv4Addr>,
prefix_len: Option<u8>,
gateway: Option<Ipv4Addr>,
device_id: String,
status: VntStatus,
current_config_name: Option<String>,
current_config_file: Option<String>,
online_client_num: usize,
offline_client_num: usize,
direct_client_num: usize,
server_info: Vec<HttpServerInfo>,
nat_type: Option<String>,
public_ipv6: Option<Ipv6Addr>,
public_ipv4s: Vec<Ipv4Addr>,
network_code: Option<String>,
mtu: Option<u16>,
fec: Option<bool>,
compress: Option<bool>,
encrypt: Option<bool>,
rtx: Option<bool>,
/// 启动后配置文件是否发生过变化(与启动时的配置快照对比)
config_changed: bool,
}
#[derive(Serialize)]
struct HttpServerInfo {
server: String,
connected: bool,
server_rtt: Option<u32>,
server_version: Option<String>,
}
#[derive(Serialize)]
struct HttpClientItem {
ip: Ipv4Addr,
name: Option<String>,
online: bool,
route: Option<HttpRouteDetail>,
version: String,
last_connected_time: i64,
key_equal: i32,
nat_info: Option<HttpClientNatInfo>,
packet_loss: Option<HttpPacketLoss>,
traffic: Option<HttpTraffic>,
}
#[derive(Serialize)]
struct HttpClientNatInfo {
nat_type: String,
public_ips: Vec<Ipv4Addr>,
ipv6: Option<Ipv6Addr>,
}
#[derive(Serialize)]
struct HttpPacketLoss {
sent: u64,
received: u64,
loss_rate: f64,
}
#[derive(Serialize)]
struct HttpTraffic {
tx_bytes: u64,
rx_bytes: u64,
}
#[derive(Serialize)]
struct HttpRouteItem {
ip: Ipv4Addr,
routes: Vec<HttpRouteDetail>,
}
#[derive(Serialize)]
struct HttpRouteDetail {
addr: String,
protocol: String,
metric: u8,
rtt: u32,
loss_rate: u16,
}
#[derive(Serialize)]
struct StartStatusResponse {
status: VntStatus,
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();
// 实例不存在(从未启动或已停止并清理)时返回 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 {
let method = req.method().clone();
let uri = req.uri().clone();
let start = Instant::now();
let response = next.run(req).await;
log::info!(
"Request: {} {} | Status: {} | Took: {:?}",
method,
uri,
response.status(),
start.elapsed()
);
response
}
#[derive(RustEmbed)]
#[folder = "static/"]
struct Asset;
/// 进程内 VNT 业务服务。HTTP 和 Tauri IPC 共用同一组 handler 与状态。
#[derive(Clone)]
pub struct VntService {
router: Router,
}
#[derive(Clone, Copy)]
enum ServiceRuntime {
StandaloneWeb,
DesktopWeb,
}
impl ServiceRuntime {
fn as_str(self) -> &'static str {
match self {
Self::StandaloneWeb => "standalone_web",
Self::DesktopWeb => "desktop_web",
}
}
}
impl VntService {
pub async fn new(start_config_file_name: Option<PathBuf>) -> anyhow::Result<Self> {
Self::new_with_runtime(start_config_file_name, ServiceRuntime::StandaloneWeb).await
}
pub async fn new_desktop(start_config_file_name: Option<PathBuf>) -> anyhow::Result<Self> {
Self::new_with_runtime(start_config_file_name, ServiceRuntime::DesktopWeb).await
}
async fn new_with_runtime(
start_config_file_name: Option<PathBuf>,
runtime: ServiceRuntime,
) -> anyhow::Result<Self> {
fs::create_dir_all(CONFIG_DIR)
.await
.context("Failed to create config directory")?;
let state = HttpAppState {
inner: Arc::new(Default::default()),
};
for (file_name, path) in determine_auto_start_files(start_config_file_name).await {
log::info!("Auto starting VNT with config: {:?}", path);
let state_clone = state.clone();
tokio::spawn(async move {
if let Err(e) = start_vnt_internal(&state_clone, file_name, path).await {
log::error!("Auto start failed: {:?}", e);
}
});
}
Ok(Self {
router: api_router(state, runtime),
})
}
/// 由 Tauri command 调用,不经过 TCP/HTTP 监听端口。
pub async fn request(
&self,
method: &str,
path: &str,
body: Option<String>,
) -> anyhow::Result<serde_json::Value> {
let method = Method::from_bytes(method.as_bytes()).context("Invalid request method")?;
let request = axum::http::Request::builder()
.method(method)
.uri(path)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.unwrap_or_default()))?;
let response = self.router.clone().oneshot(request).await?;
let status = response.status();
let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024).await?;
let value: serde_json::Value = serde_json::from_slice(&bytes)
.with_context(|| format!("Invalid service response ({status})"))?;
Ok(value)
}
/// 在当前进程中按需开放带令牌鉴权的 Web 服务。
pub async fn start_http(
&self,
addr: SocketAddr,
token: String,
cancellation: CancellationToken,
) -> anyhow::Result<tokio::task::JoinHandle<anyhow::Result<()>>> {
let listener = TcpListener::bind(addr).await?;
let actual_addr = listener.local_addr()?;
let app = http_router(self.router.clone(), token);
log::info!("HTTP API Listening on http://{}", actual_addr);
Ok(tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(cancellation.cancelled_owned())
.await?;
Ok(())
}))
}
}
pub fn generate_access_token() -> String {
let mut bytes = [0_u8; 24];
rand::rng().fill(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn api_router(state: HttpAppState, runtime: ServiceRuntime) -> Router {
let get_runtime =
move || async move { Json(ApiResponse::success(runtime.as_str().to_string())) };
Router::new()
.route("/api/version", get(get_version))
.route("/api/runtime", get(get_runtime))
.route("/api/info", get(get_info))
.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))
.route("/api/config/list", get(list_configs))
.route(
"/api/config",
get(get_config).post(save_config).delete(delete_config),
)
.with_state(state)
}
async fn token_auth_middleware(
State(token): State<String>,
req: Request,
next: axum::middleware::Next,
) -> Response {
let authorized = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.is_some_and(|provided| provided == token);
if !authorized {
return (
StatusCode::UNAUTHORIZED,
Json(ApiResponse::<()>::error("访问令牌无效或已过期")),
)
.into_response();
}
next.run(req).await
}
fn http_router(api: Router, token: String) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
.merge(api.layer(middleware::from_fn_with_state(token, token_auth_middleware)))
.fallback(static_handler)
.layer(cors)
.layer(middleware::from_fn(logging_middleware))
}
pub async fn run_http_server(
addr: SocketAddr,
start_config_file_name: Option<PathBuf>,
token: String,
) -> anyhow::Result<()> {
let service = VntService::new(start_config_file_name).await?;
let cancellation = CancellationToken::new();
let handle = service
.start_http(addr, token, cancellation.clone())
.await?;
let shutdown_result = shutdown_signal().await;
cancellation.cancel();
handle.await??;
shutdown_result?;
Ok(())
}
/// 确定自动启动的配置文件列表。
/// --conf 显式指定时只返回那一个;否则读自启记录文件(每行一个 file_name),过滤存在的文件。
async fn determine_auto_start_files(
start_config_file_name: Option<PathBuf>,
) -> 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() {
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 {
Vec::new()
};
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() {
result.push((file_name, p));
} else {
log::warn!("Auto start config file not found: {:?}", p);
}
}
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 {
let mut headers = HeaderMap::new();
let is_gz = path.ends_with(".gz");
let mime = if is_gz {
let original = path.trim_end_matches(".gz");
from_path(original).first_or_octet_stream()
} else {
from_path(path).first_or_octet_stream()
};
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref())
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
if is_gz {
headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
headers.insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
}
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
headers
}
/// 将请求路径安全地映射到 static 目录内。
/// 逐组件校验,拒绝 `..`、根路径、盘符等任何可能逃逸出 static 的路径。
fn resolve_static_path(path: &str) -> Option<PathBuf> {
let mut local_path = PathBuf::from("static");
for component in Path::new(path).components() {
match component {
std::path::Component::Normal(part) => local_path.push(part),
std::path::Component::CurDir => {}
_ => return None,
}
}
Some(local_path)
}
async fn static_handler(uri: Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
let path = if path.is_empty() { "index.html" } else { path };
// 先尝试从本地文件读取
let Some(local_path) = resolve_static_path(path) else {
return (StatusCode::NOT_FOUND, "404 Not Found").into_response();
};
if local_path.is_file()
&& let Ok(content) = tokio::fs::read(&local_path).await
{
log::debug!("Serving file from local filesystem: {:?}", local_path);
let mime = from_path(&local_path).first_or_octet_stream();
return ([(header::CONTENT_TYPE, mime.as_ref())], content).into_response();
}
// 从内嵌数据中读取
if let Some(content) = Asset::get(path) {
log::debug!("Serving file from embedded assets: {}", path);
let headers = build_headers_for_path(path);
return (headers, Body::from(content.data)).into_response();
}
(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,
file_name: String,
file_path: PathBuf,
) -> anyhow::Result<()> {
log::info!("Starting VNT service: {}", file_name);
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(&file_name_for_error);
});
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(&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)?;
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(&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_clone.clone(),
config_display_name,
start_config,
core_config,
task_group,
task_group_guard,
)
.await;
if let Err(e) = result {
log::error!("Failed to start VNT network: {:?}", e);
state_clone.record_log_and_stopped(&file_name_clone, format!("启动失败: {}", e));
}
drop(on_error_guard);
});
state.set_start_handle(&file_name, start_handle);
Ok(())
}
/// 执行实际的网络启动操作
async fn start_vnt_network(
state: HttpAppState,
file_name: String,
config_display_name: String,
start_config: StartConfig,
core_config: CoreConfig,
task_group: vnt_core::utils::task_control::TaskGroup,
task_group_guard: vnt_core::utils::task_control::TaskGroupGuard,
) -> anyhow::Result<()> {
let sub_input = core_config.input.clone();
let mut network_manager =
NetworkManager::create_network(Box::new(core_config), task_group.clone())
.await
.map_err(|e| anyhow!("Create network failed: {:?}", e))?;
let vnt_api = network_manager.vnt_api();
{
let mut lock = state.inner.lock();
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"));
}
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(&file_name_for_cleanup);
});
state.record_log(&file_name, "连接服务器,执行注册");
log::info!("Registering with server");
let reg_msg = loop {
let reg_msg = match network_manager.register().await {
Ok(rs) => rs,
Err(e) => {
log::error!("Register failed: {:?}", e);
state.record_log(&file_name, format!("注册失败:{},5秒后重试", e));
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
continue;
}
};
match reg_msg {
RegisterResponse::Success(reg_msg) => {
break reg_msg;
}
RegisterResponse::Failed(e) => {
log::error!("Register failed: {:?}", e);
bail!("注册失败:{}", e.message)
}
}
};
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.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, format!("创建 {} 虚拟网卡成功,设置 IP", mode));
network_manager
.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.device_if_index().await
&& let Ok(mut route_manager) = route_manager::RouteManager::new()
{
state.record_log(&file_name, "配置子网路由");
for input in &sub_input {
let route =
route_manager::Route::new(input.net.network().into(), input.net.prefix_len())
.with_gateway(input.target_ip.into())
.with_if_index(if_index);
if let Err(e) = route_manager.add(&route) {
log::error!("add route [{route}] error: {e:?}");
} else {
log::info!("add route [{route}] successful");
}
}
}
} else {
state.record_log(&file_name, "device_mode=no,不创建虚拟网卡");
}
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.");
});
Ok(())
}
fn is_valid_file_name(file_name: &str) -> bool {
!file_name.is_empty()
&& !file_name.contains("..")
&& !file_name.contains('/')
&& !file_name.contains('\\')
}
/// 规范化配置文件名:无扩展名时补 .toml;扩展名不是 .toml 则拒绝。
/// list_configs 只列出 *.toml,不强制后缀会保存出列表中不可见的文件
fn normalize_config_file_name(file_name: String) -> Result<String, &'static str> {
match Path::new(&file_name).extension() {
None => Ok(format!("{file_name}.toml")),
Some(ext) if ext == "toml" => Ok(file_name),
Some(_) => Err("Config file name must end with .toml"),
}
}
async fn start_vnt_handler(
State(state): State<HttpAppState>,
Json(req): Json<FileReq>,
) -> Json<ApiResponse<()>> {
if !is_valid_file_name(&req.file_name) {
return Json(ApiResponse::error("Invalid file name"));
}
let path = Path::new(CONFIG_DIR).join(&req.file_name);
if !path.exists() {
return Json(ApiResponse::error("Config file not found"));
}
match start_vnt_internal(&state, req.file_name, path).await {
Ok(_) => Json(ApiResponse::success(())),
Err(e) => Json(ApiResponse::error(format!("Start failed: {:?}", e))),
}
}
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(&req.file_name);
task_group_manager.stop();
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>,
) -> Json<ApiResponse<()>> {
if !is_valid_file_name(&req.file_name) {
return Json(ApiResponse::error("Invalid file name"));
}
let path = Path::new(CONFIG_DIR).join(&req.file_name);
if !path.exists() {
return Json(ApiResponse::error("Config file not found"));
}
// 先停止(如果正在运行则停止,否则忽略)
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(&req.file_name) == VntStatus::Stopped {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
// 再启动
match start_vnt_internal(&state, req.file_name, path).await {
Ok(_) => Json(ApiResponse::success(())),
Err(e) => Json(ApiResponse::error(format!("Restart failed: {:?}", e))),
}
}
/// 客户端版本号,与组网状态无关,任何时刻都可获取
async fn get_version() -> Json<ApiResponse<String>> {
Json(ApiResponse::success(env!("CARGO_PKG_VERSION").to_string()))
}
async fn get_info(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<HttpAppInfo>> {
// 先读当前配置文件(异步),避免持锁跨 await
let current_config: Option<StartConfig> =
match fs::read_to_string(Path::new(CONFIG_DIR).join(&req.file_name)).await {
Ok(content) => toml::from_str(&content).ok(),
Err(_) => None,
};
let lock = state.inner.lock();
let Some(inst) = lock.instances.get(&req.file_name) else {
return Json(ApiResponse::error("实例不存在"));
};
let status = inst.status;
// 与启动时的配置快照对比:文件缺失或解析失败也视为已变化
let config_changed = status != VntStatus::Stopped
&& match (&inst.start_config, &current_config) {
(Some(base), Some(current)) => base != current,
(Some(_), None) => true,
(None, _) => false,
};
let info = if let Some(handler) = inst.vnt.as_ref() {
let api = &handler.api;
let config = api.get_config();
let ips = api.client_ips();
let server_node_list = api.server_node_list();
let nat_info = api.nat_info();
let network = api.network();
HttpAppInfo {
name: config
.as_ref()
.map(|v| v.device_name.clone())
.unwrap_or_default(),
version: env!("CARGO_PKG_VERSION").to_string(),
ip: network.map(|v| v.ip),
prefix_len: network.map(|v| v.prefix_len),
gateway: network.map(|v| v.gateway),
device_id: config
.as_ref()
.map(|v| v.device_id.clone())
.unwrap_or_default(),
status,
current_config_name: Some(handler.config_name.clone()),
current_config_file: Some(handler.config_file_name.clone()),
online_client_num: ips.iter().filter(|v| v.online).count(),
offline_client_num: ips.iter().filter(|v| !v.online).count(),
direct_client_num: ips.iter().filter(|ip| api.is_direct(&ip.ip)).count(),
server_info: server_node_list
.into_iter()
.map(|v| HttpServerInfo {
server: v.server_addr.to_string(),
connected: v.connected,
server_rtt: v.rtt,
server_version: v.server_version,
})
.collect(),
nat_type: nat_info.as_ref().map(|v| format!("{:?}", v.nat_type)),
public_ipv4s: nat_info
.as_ref()
.map(|v| v.public_ips.clone())
.unwrap_or_default(),
public_ipv6: nat_info.as_ref().and_then(|v| v.ipv6),
network_code: config.as_ref().map(|v| v.network_code.clone()),
mtu: config.as_ref().map(|v| v.mtu.unwrap_or(DEFAULT_MTU)),
fec: config.as_ref().map(|v| v.fec),
compress: config.as_ref().map(|v| v.compress),
encrypt: config.as_ref().map(|v| v.password.is_some()),
rtx: config.as_ref().map(|v| v.rtx),
config_changed,
}
} else {
HttpAppInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
status,
config_changed,
..Default::default()
}
};
Json(ApiResponse::success(info))
}
async fn list_configs() -> Json<ApiResponse<Vec<ConfigSummary>>> {
let mut result = Vec::new();
let Ok(mut entries) = fs::read_dir(CONFIG_DIR).await else {
return Json(ApiResponse::success(result));
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "toml") {
continue;
}
let Ok(content) = fs::read_to_string(&path).await else {
continue;
};
match toml::from_str::<StartConfig>(&content) {
Ok(cfg) => {
let file_name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
result.push(ConfigSummary {
file_name,
config_name: cfg
.config_name
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
});
}
Err(e) => {
log::warn!("Failed to parse configuration file {:?}: {:?}", path, e);
}
}
}
result.sort_by(|a, b| b.file_name.cmp(&a.file_name));
Json(ApiResponse::success(result))
}
async fn save_config(Json(req): Json<SaveConfigReq>) -> Json<ApiResponse<()>> {
// 验证配置格式
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)));
}
let file_name = req
.file_name
.filter(|name| !name.is_empty())
.unwrap_or_else(|| {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("{}.toml", now)
});
if !is_valid_file_name(&file_name) {
return Json(ApiResponse::error("Invalid file name"));
}
let file_name = match normalize_config_file_name(file_name) {
Ok(name) => name,
Err(msg) => return Json(ApiResponse::error(msg)),
};
let target_path = Path::new(CONFIG_DIR).join(&file_name);
match fs::write(&target_path, &req.config).await {
Ok(_) => Json(ApiResponse::success(())),
Err(e) => Json(ApiResponse::error(format!("Write config failed: {}", e))),
}
}
async fn get_config(Query(req): Query<FileReq>) -> Json<ApiResponse<String>> {
if !is_valid_file_name(&req.file_name) {
return Json(ApiResponse::error("Invalid file name"));
}
let path = Path::new(CONFIG_DIR).join(&req.file_name);
if !path.exists() {
return Json(ApiResponse::error("Config file not found"));
}
match fs::read_to_string(&path).await {
Ok(content) => Json(ApiResponse::success(content)),
Err(e) => Json(ApiResponse::error(format!("Read file failed: {}", e))),
}
}
async fn delete_config(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<()>> {
if !is_valid_file_name(&req.file_name) {
return Json(ApiResponse::error("Invalid 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("此配置已被使用,不能删除"));
}
}
let path = Path::new(CONFIG_DIR).join(&req.file_name);
if !path.exists() {
return Json(ApiResponse::error("Config file not found"));
}
match fs::remove_file(&path).await {
Ok(_) => Json(ApiResponse::success(())),
Err(e) => Json(ApiResponse::error(format!("Delete failed: {}", e))),
}
}
fn convert_config(cfg: StartConfig) -> anyhow::Result<CoreConfig> {
cfg.reject_legacy_no_tun()?;
let server_addrs: Vec<ProtocolAddress> = cfg
.server
.iter()
.map(|s| {
s.parse()
.map_err(|e| anyhow!("invalid server address '{}': {}", s, e))
})
.collect::<anyhow::Result<_>>()?;
let port_mapping: Vec<PortMapping> = cfg
.port_mapping
.iter()
.map(|s| {
s.parse()
.map_err(|e| anyhow!("invalid port_mapping '{}': {}", s, e))
})
.collect::<anyhow::Result<_>>()?;
let cert_mode = match cfg.cert_mode.as_deref() {
Some(s) => s
.parse()
.map_err(|e| anyhow!("invalid cert_mode '{}': {}", s, e))?,
None => CertValidationMode::InsecureSkipVerification,
};
let device_id = match cfg.device_id {
Some(id) => id,
None => vnt_core::utils::device_id::get_device_id()
.map_err(|e| anyhow!("failed to get device_id: {}", e))?,
};
let device_name = cfg.device_name.unwrap_or_else(|| {
hostname::get()
.ok()
.and_then(|v| v.into_string().ok())
.unwrap_or_default()
});
let mut udp_stun = cfg.udp_stun;
for x in udp_stun.iter_mut() {
if !x.contains(':') {
x.push_str(":3478");
}
}
let mut tcp_stun = cfg.tcp_stun;
for x in tcp_stun.iter_mut() {
if !x.contains(':') {
x.push_str(":3478");
}
}
Ok(CoreConfig {
server_addr: server_addrs,
network_code: cfg.network_code,
ip: cfg.ip,
no_punch: cfg.no_punch,
rtx: cfg.rtx,
compress: cfg.compress,
device_id,
device_name,
tun_name: cfg.tun_name,
outbound_interface: cfg.outbound_interface,
password: cfg.password,
cert_mode,
input: cfg.input,
output: cfg.output,
no_nat: cfg.no_nat,
device_mode: cfg.device_mode,
mtu: cfg.mtu,
port_mapping,
allow_port_mapping: cfg.allow_mapping,
udp_stun,
tcp_stun,
fec: cfg.fec,
tunnel_port: cfg.tunnel_port,
})
}
async fn shutdown_signal() -> anyhow::Result<()> {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.context("failed to install Ctrl+C handler")
};
#[cfg(unix)]
let terminate = async {
let mut signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to install terminate signal handler")?;
signal.recv().await;
Ok::<(), anyhow::Error>(())
};
#[cfg(not(unix))]
let terminate = std::future::pending::<anyhow::Result<()>>();
tokio::select! {
result = ctrl_c => result?,
result = terminate => result?,
}
Ok(())
}
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"));
};
let key_sign = api.get_config().and_then(|config| config.key_sign());
let calc_key_equal = |peer_key_sign: &Option<String>| -> i32 {
match (&key_sign, peer_key_sign) {
(None, None) => 2,
(Some(k1), Some(k2)) if k1 == k2 => 1,
(Some(_), Some(_)) => 5,
(Some(_), None) => 3,
(None, Some(_)) => 4,
}
};
let build_nat_info = |ip: &Ipv4Addr| -> Option<HttpClientNatInfo> {
api.peer_nat_info(ip).map(|v| HttpClientNatInfo {
nat_type: format!("{:?}", v.nat_type),
public_ips: v.public_ips,
ipv6: v.ipv6,
})
};
let build_packet_loss = |ip: &Ipv4Addr| -> Option<HttpPacketLoss> {
api.packet_loss_info(ip).map(|v| HttpPacketLoss {
sent: v.sent,
received: v.received,
loss_rate: v.loss_rate,
})
};
let build_traffic = |ip: &Ipv4Addr| -> Option<HttpTraffic> {
api.traffic_info(ip).map(|v| HttpTraffic {
tx_bytes: v.tx_bytes,
rx_bytes: v.rx_bytes,
})
};
let build_route = |ip: &Ipv4Addr| -> Option<HttpRouteDetail> {
api.find_route(ip).map(|route| HttpRouteDetail {
addr: route.route_key().to_string(),
protocol: route.route_key().protocol().to_string(),
metric: route.metric(),
rtt: route.rtt(),
loss_rate: route.loss_rate(),
})
};
// 先从本地获取基础数据
let mut merged: HashMap<Ipv4Addr, HttpClientItem> = api
.client_ips()
.into_iter()
.map(|v| {
let ip = v.ip;
let route = build_route(&ip);
// 如果有路由,说明设备在线(可以直接通信)
let has_route = route.is_some();
(
ip,
HttpClientItem {
ip,
name: None,
online: v.online || has_route,
route,
version: String::new(),
last_connected_time: 0,
key_equal: 0,
nat_info: build_nat_info(&ip),
packet_loss: build_packet_loss(&ip),
traffic: build_traffic(&ip),
},
)
})
.collect();
// 从服务器获取更详细的信息
if let Ok(resp) = api.server_rpc().client_list().await {
for v in resp.list {
let ip = Ipv4Addr::from(v.ip);
let route = build_route(&ip);
// 如果有路由,说明设备在线(可以直接通信)
let has_route = route.is_some();
merged.insert(
ip,
HttpClientItem {
ip,
name: Some(v.name),
online: v.online || has_route,
route,
version: v.version,
last_connected_time: v.last_connected_time,
key_equal: calc_key_equal(&v.key_sign),
nat_info: build_nat_info(&ip),
packet_loss: build_packet_loss(&ip),
traffic: build_traffic(&ip),
},
);
}
} else {
log::warn!("Failed to get client list from server");
}
let mut items: Vec<HttpClientItem> = merged.into_values().collect();
items.sort_by_key(|it| it.ip);
Json(ApiResponse::success(items))
}
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
.instances
.get(&req.file_name)
.and_then(|inst| inst.vnt.as_ref())
else {
return Json(ApiResponse::error("VNT not running"));
};
let table = handler.api.route_table();
let items: Vec<HttpRouteItem> = table
.into_iter()
.map(|(ip, route_list)| HttpRouteItem {
ip,
routes: route_list
.into_iter()
.map(|v| HttpRouteDetail {
addr: v.route_key().to_string(),
protocol: v.route_key().protocol().to_string(),
metric: v.metric(),
rtt: v.rtt(),
loss_rate: v.loss_rate(),
})
.collect(),
})
.collect();
Json(ApiResponse::success(items))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_ipc_request_uses_in_process_router() {
let service = VntService {
router: api_router(new_test_state(), ServiceRuntime::StandaloneWeb),
};
let response = service.request("GET", "/api/version", None).await.unwrap();
assert_eq!(response["code"], 0);
assert!(
response["data"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
let response = service.request("GET", "/api/runtime", None).await.unwrap();
assert_eq!(response["code"], 0);
assert_eq!(response["data"], "standalone_web");
let desktop_service = VntService {
router: api_router(new_test_state(), ServiceRuntime::DesktopWeb),
};
let response = desktop_service
.request("GET", "/api/runtime", None)
.await
.unwrap();
assert_eq!(response["code"], 0);
assert_eq!(response["data"], "desktop_web");
}
#[tokio::test]
async fn test_http_api_requires_bearer_token() {
let token = "test-token-with-enough-entropy".to_string();
let app = http_router(
api_router(new_test_state(), ServiceRuntime::StandaloneWeb),
token.clone(),
);
let unauthorized = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/version")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let authorized = app
.oneshot(
axum::http::Request::builder()
.uri("/api/version")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(authorized.status(), StatusCode::OK);
}
#[test]
fn test_normalize_config_file_name() {
// 无扩展名补 .toml
assert_eq!(
normalize_config_file_name("myconfig".to_string()).unwrap(),
"myconfig.toml"
);
// 已是 .toml 保持不变
assert_eq!(
normalize_config_file_name("a.toml".to_string()).unwrap(),
"a.toml"
);
// 其他扩展名拒绝(list_configs 只列 *.toml,保存了也不可见)
assert!(normalize_config_file_name("a.txt".to_string()).is_err());
assert!(normalize_config_file_name("a.json".to_string()).is_err());
}
#[test]
fn test_resolve_static_path_allows_normal_paths() {
assert_eq!(
resolve_static_path("index.html"),
Some(PathBuf::from("static").join("index.html"))
);
assert_eq!(
resolve_static_path("css/style.css"),
Some(PathBuf::from("static").join("css").join("style.css"))
);
assert_eq!(
resolve_static_path("./index.html"),
Some(PathBuf::from("static").join("index.html"))
);
}
#[test]
fn test_resolve_static_path_rejects_traversal() {
assert!(resolve_static_path("../Cargo.toml").is_none());
assert!(resolve_static_path("a/../../Cargo.toml").is_none());
assert!(resolve_static_path("/etc/passwd").is_none());
assert!(resolve_static_path("..").is_none());
// Windows 下反斜杠也是路径分隔符
#[cfg(windows)]
{
assert!(resolve_static_path("..\\..\\Cargo.toml").is_none());
assert!(resolve_static_path("C:/Windows/win.ini").is_none());
}
}
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,
outbound_interface: None,
ip: None,
password: None,
no_punch: false,
compress: false,
rtx: false,
fec: false,
input: Vec::new(),
output: Vec::new(),
no_nat: false,
// 默认无网卡,避免无关用例意外触发 tun_name 冲突
device_mode: DeviceMode::No,
legacy_no_tun: None,
mtu: None,
port_mapping: Vec::new(),
allow_mapping: false,
udp_stun: Vec::new(),
tcp_stun: Vec::new(),
tunnel_port: None,
}
}
#[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() {
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 = 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(&file_name_owned);
});
let handle = tokio::spawn(async move {
let _on_error_guard = on_error_guard;
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
});
state.set_start_handle(file_name, handle);
assert_eq!(state.status(file_name), VntStatus::Starting);
state.abort_start_task(file_name);
// abort 生效后 defer 触发,状态应迁移到 Stopped
for _ in 0..100 {
if state.status(file_name) == VntStatus::Stopped {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(state.status(file_name), VntStatus::Stopped);
}
}