Initial Super N2N desktop application
Build Windows Portable Package / portable (push) Has been cancelled
Build Windows Portable Package / portable (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
net::{SocketAddr, TcpStream, UdpSocket},
|
||||
path::PathBuf,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tauri::State;
|
||||
|
||||
struct AppState {
|
||||
processes: Mutex<HashMap<String, Child>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StartRequest {
|
||||
service: String,
|
||||
binary_path: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ServiceStatus {
|
||||
service: String,
|
||||
running: bool,
|
||||
pid: Option<u32>,
|
||||
started_at: u64,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManagementRequest {
|
||||
host: String,
|
||||
port: u16,
|
||||
command: String,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ToolResult {
|
||||
ok: bool,
|
||||
summary: String,
|
||||
output: String,
|
||||
latency_ms: Option<u128>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PingRequest {
|
||||
host: String,
|
||||
count: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TcpingRequest {
|
||||
host: String,
|
||||
port: u16,
|
||||
timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TapTrafficRequest {
|
||||
tap_device: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TapTraffic {
|
||||
interface_name: String,
|
||||
received_bytes: u64,
|
||||
sent_bytes: u64,
|
||||
}
|
||||
|
||||
fn now_epoch() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn empty_status(service: &str, message: &str) -> ServiceStatus {
|
||||
ServiceStatus {
|
||||
service: service.to_string(),
|
||||
running: false,
|
||||
pid: None,
|
||||
started_at: 0,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_binary_path(service: &str) -> Result<&'static str, String> {
|
||||
match service {
|
||||
"edge" => Ok("./n2n/edge.exe"),
|
||||
"supernode" => Ok("./n2n/supernode.exe"),
|
||||
_ => Err(format!("unsupported service: {service}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_directory() -> Result<std::path::PathBuf, String> {
|
||||
std::env::current_exe()
|
||||
.map_err(|error| format!("unable to locate application executable: {error}"))?
|
||||
.parent()
|
||||
.map(|path| path.to_path_buf())
|
||||
.ok_or_else(|| "application executable has no parent directory".to_string())
|
||||
}
|
||||
|
||||
fn downloads_directory() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(profile) = std::env::var_os("USERPROFILE") {
|
||||
return PathBuf::from(profile).join("Downloads");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home).join("Downloads");
|
||||
}
|
||||
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_tap_traffic(tap_device: &str) -> Result<TapTraffic, String> {
|
||||
use std::{ptr, slice};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::NO_ERROR,
|
||||
NetworkManagement::IpHelper::{FreeMibTable, GetIfTable2, MIB_IF_TABLE2},
|
||||
};
|
||||
|
||||
fn wide_string(value: &[u16]) -> String {
|
||||
let end = value
|
||||
.iter()
|
||||
.position(|unit| *unit == 0)
|
||||
.unwrap_or(value.len());
|
||||
String::from_utf16_lossy(&value[..end])
|
||||
}
|
||||
|
||||
fn guid_string(guid: &windows_sys::core::GUID) -> String {
|
||||
format!(
|
||||
"{{{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}",
|
||||
guid.data1,
|
||||
guid.data2,
|
||||
guid.data3,
|
||||
guid.data4[0],
|
||||
guid.data4[1],
|
||||
guid.data4[2],
|
||||
guid.data4[3],
|
||||
guid.data4[4],
|
||||
guid.data4[5],
|
||||
guid.data4[6],
|
||||
guid.data4[7]
|
||||
)
|
||||
}
|
||||
|
||||
let mut table: *mut MIB_IF_TABLE2 = ptr::null_mut();
|
||||
let result = unsafe { GetIfTable2(&mut table) };
|
||||
if result != NO_ERROR || table.is_null() {
|
||||
return Err(format!("unable to read network adapter table: {result}"));
|
||||
}
|
||||
|
||||
let traffic = unsafe {
|
||||
let rows = slice::from_raw_parts((*table).Table.as_ptr(), (*table).NumEntries as usize);
|
||||
rows.iter()
|
||||
.find_map(|row| {
|
||||
let alias = wide_string(&row.Alias);
|
||||
let description = wide_string(&row.Description);
|
||||
let guid = guid_string(&row.InterfaceGuid);
|
||||
(alias.eq_ignore_ascii_case(tap_device)
|
||||
|| description.eq_ignore_ascii_case(tap_device)
|
||||
|| guid.eq_ignore_ascii_case(tap_device))
|
||||
.then_some(TapTraffic {
|
||||
interface_name: alias,
|
||||
received_bytes: row.InOctets,
|
||||
sent_bytes: row.OutOctets,
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| format!("TAP adapter not found: {tap_device}"))
|
||||
};
|
||||
unsafe { FreeMibTable(table.cast()) };
|
||||
traffic
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
|
||||
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
|
||||
if let Some(existing) = processes.get_mut(&request.service) {
|
||||
if existing.try_wait().map_err(|e| e.to_string())?.is_none() {
|
||||
return Ok(ServiceStatus {
|
||||
service: request.service,
|
||||
running: true,
|
||||
pid: Some(existing.id()),
|
||||
started_at: now_epoch(),
|
||||
message: "already running".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let relative_binary = relative_binary_path(&request.service)?;
|
||||
if request.binary_path != relative_binary {
|
||||
return Err(format!("{} must use {}", request.service, relative_binary));
|
||||
}
|
||||
|
||||
let working_directory = app_directory()?;
|
||||
let binary = working_directory.join(relative_binary);
|
||||
if !binary.is_file() {
|
||||
return Err(format!("required n2n binary is missing: {}", binary.display()));
|
||||
}
|
||||
|
||||
let child = Command::new(relative_binary)
|
||||
.current_dir(&working_directory)
|
||||
.args(&request.args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("unable to start {}: {}", binary.display(), e))?;
|
||||
let pid = child.id();
|
||||
processes.insert(request.service.clone(), child);
|
||||
|
||||
Ok(ServiceStatus {
|
||||
service: request.service,
|
||||
running: true,
|
||||
pid: Some(pid),
|
||||
started_at: now_epoch(),
|
||||
message: format!("started {relative_binary}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_service(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
|
||||
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
|
||||
if let Some(mut child) = processes.remove(&service) {
|
||||
child.kill().map_err(|e| format!("unable to stop {}: {}", service, e))?;
|
||||
let _ = child.wait();
|
||||
return Ok(empty_status(&service, "stopped"));
|
||||
}
|
||||
Ok(empty_status(&service, "not running"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn service_status(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
|
||||
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
|
||||
if let Some(child) = processes.get_mut(&service) {
|
||||
if child.try_wait().map_err(|e| e.to_string())?.is_none() {
|
||||
return Ok(ServiceStatus {
|
||||
service,
|
||||
running: true,
|
||||
pid: Some(child.id()),
|
||||
started_at: now_epoch(),
|
||||
message: "running".to_string(),
|
||||
});
|
||||
}
|
||||
processes.remove(&service);
|
||||
return Ok(empty_status(&service, "exited"));
|
||||
}
|
||||
Ok(empty_status(&service, "not running"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn management_query(request: ManagementRequest) -> Result<Vec<Value>, String> {
|
||||
let address: SocketAddr = format!("{}:{}", request.host, request.port)
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid management address: {}", e))?;
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| e.to_string())?;
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(650)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag = now_epoch() % 1000;
|
||||
let auth = request
|
||||
.password
|
||||
.filter(|password| !password.is_empty())
|
||||
.map(|password| format!(":1:{}", password))
|
||||
.unwrap_or_default();
|
||||
let payload = format!("r {}{} {}\n", tag, auth, request.command);
|
||||
socket
|
||||
.send_to(payload.as_bytes(), address)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut packets = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
match socket.recv_from(&mut buffer) {
|
||||
Ok((size, _)) => {
|
||||
if let Ok(text) = std::str::from_utf8(&buffer[..size]) {
|
||||
for line in text.lines() {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(line) {
|
||||
let finished = value.get("_type").and_then(Value::as_str) == Some("end");
|
||||
packets.push(value);
|
||||
if finished {
|
||||
return Ok(packets);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock || error.kind() == std::io::ErrorKind::TimedOut => {
|
||||
break;
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
if packets.is_empty() {
|
||||
Err(format!("management API timed out: {address}"))
|
||||
} else {
|
||||
Ok(packets)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn run_ping(request: PingRequest) -> Result<ToolResult, String> {
|
||||
let count = request.count.clamp(1, 8).to_string();
|
||||
let started = Instant::now();
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = Command::new("ping")
|
||||
.args(["-n", &count, &request.host])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let output = Command::new("ping")
|
||||
.args(["-c", &count, &request.host])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
Ok(ToolResult {
|
||||
ok: output.status.success(),
|
||||
summary: if output.status.success() { "目标可达".to_string() } else { "目标不可达".to_string() },
|
||||
output: if stdout.is_empty() { stderr } else { stdout },
|
||||
latency_ms: Some(started.elapsed().as_millis()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn run_tcping(request: TcpingRequest) -> Result<ToolResult, String> {
|
||||
let address: SocketAddr = format!("{}:{}", request.host, request.port)
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid TCP address: {}", e))?;
|
||||
let started = Instant::now();
|
||||
match TcpStream::connect_timeout(&address, Duration::from_millis(request.timeout_ms.max(100))) {
|
||||
Ok(_) => Ok(ToolResult {
|
||||
ok: true,
|
||||
summary: "TCP 端口可连接".to_string(),
|
||||
output: format!("connected to {} in {} ms", address, started.elapsed().as_millis()),
|
||||
latency_ms: Some(started.elapsed().as_millis()),
|
||||
}),
|
||||
Err(error) => Ok(ToolResult {
|
||||
ok: false,
|
||||
summary: "TCP 端口不可连接".to_string(),
|
||||
output: error.to_string(),
|
||||
latency_ms: Some(started.elapsed().as_millis()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn tap_traffic(request: TapTrafficRequest) -> Result<TapTraffic, String> {
|
||||
let tap_device = request.tap_device.trim();
|
||||
if tap_device.is_empty() {
|
||||
return Err("TAP device name is empty".to_string());
|
||||
}
|
||||
if tap_device.contains(['/', '\\']) || tap_device == "." || tap_device == ".." {
|
||||
return Err("invalid TAP device name".to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return windows_tap_traffic(tap_device);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let path = std::path::Path::new("/sys/class/net").join(tap_device).join("statistics");
|
||||
let received_bytes = fs::read_to_string(path.join("rx_bytes"))
|
||||
.map_err(|error| format!("unable to read TAP adapter {tap_device}: {error}"))?
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|error| format!("invalid TAP receive counter: {error}"))?;
|
||||
let sent_bytes = fs::read_to_string(path.join("tx_bytes"))
|
||||
.map_err(|error| format!("unable to read TAP adapter {tap_device}: {error}"))?
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|error| format!("invalid TAP transmit counter: {error}"))?;
|
||||
return Ok(TapTraffic {
|
||||
interface_name: tap_device.to_string(),
|
||||
received_bytes,
|
||||
sent_bytes,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
Err("TAP traffic monitoring is not supported on this operating system".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn firewall_status() -> Result<ToolResult, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = Command::new("netsh")
|
||||
.args(["advfirewall", "show", "allprofiles", "state"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
#[cfg(target_os = "linux")]
|
||||
let output = Command::new("sh")
|
||||
.args(["-c", "ufw status verbose 2>/dev/null || firewall-cmd --state 2>/dev/null || true"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let output = Command::new("/usr/libexec/ApplicationFirewall/socketfilterfw")
|
||||
.args(["--getglobalstate"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
|
||||
let output = Command::new("true").output().map_err(|e| e.to_string())?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let normalized = stdout
|
||||
.to_ascii_lowercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let enabled = normalized.contains("state on")
|
||||
|| normalized.contains("status: active")
|
||||
|| normalized.contains("firewall is enabled")
|
||||
|| normalized.contains("state = 1")
|
||||
|| normalized.split_whitespace().any(|token| token == "running");
|
||||
Ok(ToolResult {
|
||||
ok: output.status.success(),
|
||||
summary: if enabled { "本地防火墙已开启".to_string() } else { "未检测到防火墙开启".to_string() },
|
||||
output: stdout,
|
||||
latency_ms: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_log(contents: String) -> Result<String, String> {
|
||||
let directory = downloads_directory();
|
||||
fs::create_dir_all(&directory)
|
||||
.map_err(|error| format!("unable to create downloads directory: {error}"))?;
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let file = directory.join(format!("super-n2n-{millis}.log"));
|
||||
fs::write(&file, contents).map_err(|error| format!("unable to export log: {error}"))?;
|
||||
Ok(file.display().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{relative_binary_path, StartRequest};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_tap_traffic_uses_the_adapter_table() {
|
||||
assert!(super::windows_tap_traffic("__super_n2n_missing_tap__").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_request_accepts_an_argument_vector_from_tauri() {
|
||||
let request: StartRequest = serde_json::from_str(
|
||||
r#"{
|
||||
"service": "edge",
|
||||
"binaryPath": "./n2n/edge.exe",
|
||||
"args": ["-c", "test-community", "-l", "127.0.0.1:7777"]
|
||||
}"#,
|
||||
)
|
||||
.expect("a Tauri start request with an argument array should deserialize");
|
||||
|
||||
assert_eq!(request.binary_path, "./n2n/edge.exe");
|
||||
assert_eq!(
|
||||
request.args,
|
||||
["-c", "test-community", "-l", "127.0.0.1:7777"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn services_have_fixed_relative_binary_paths() {
|
||||
assert_eq!(relative_binary_path("edge").unwrap(), "./n2n/edge.exe");
|
||||
assert_eq!(
|
||||
relative_binary_path("supernode").unwrap(),
|
||||
"./n2n/supernode.exe"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(AppState {
|
||||
processes: Mutex::new(HashMap::new()),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_service,
|
||||
stop_service,
|
||||
service_status,
|
||||
management_query,
|
||||
run_ping,
|
||||
run_tcping,
|
||||
tap_traffic,
|
||||
firewall_status,
|
||||
export_log
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Super N2N");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
super_n2n_lib::run()
|
||||
}
|
||||
Reference in New Issue
Block a user