Initial Super N2N desktop application
Build Windows Portable Package / portable (push) Has been cancelled

This commit is contained in:
2026-09-01 13:30:52 +08:00
commit c955401fb4
78 changed files with 8266 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Ignore generated files that would otherwise restart `tauri dev`.
/target/
/gen/
/.tauri/
# Local native build diagnostics.
*.log
+4513
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "super-n2n"
version = "0.1.0"
description = "N2N desktop control plane"
authors = ["Super N2N"]
edition = "2021"
[lib]
name = "super_n2n_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis"] }
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="88" fill="#101716"/>
<path d="M128 178h118l48 78h90" fill="none" stroke="#56e390" stroke-linecap="round" stroke-linejoin="round" stroke-width="34"/>
<path d="M128 334h118l48-78h90" fill="none" stroke="#56e390" stroke-linecap="round" stroke-linejoin="round" stroke-width="34"/>
<circle cx="128" cy="256" r="45" fill="#f2fbf5"/>
<circle cx="384" cy="256" r="45" fill="#f2fbf5"/>
<circle cx="256" cy="256" r="31" fill="#f7c75d"/>
</svg>

After

Width:  |  Height:  |  Size: 544 B

+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for the Super N2N desktop window",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+505
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
super_n2n_lib::run()
}
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Super N2N",
"version": "0.1.0",
"identifier": "com.supern2n.control",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Super N2N Control",
"width": 350,
"height": 380,
"minWidth": 350,
"minHeight": 380,
"maxWidth": 350,
"maxHeight": 380,
"resizable": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false,
"targets": "all"
}
}