Release version 1.2 with Supernode user window
Build Windows Portable Package / portable (release) Successful in 9m52s

This commit is contained in:
2026-09-02 04:45:39 +08:00
parent 2bbd370e02
commit ba914125f2
17 changed files with 1074 additions and 79 deletions
+1 -1
View File
@@ -2919,7 +2919,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "super-n2n"
version = "0.1.0"
version = "1.2.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "super-n2n"
version = "0.1.0"
version = "1.2.0"
description = "N2N desktop control plane"
authors = ["Super N2N"]
edition = "2021"
+11 -2
View File
@@ -2,6 +2,15 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for the Super N2N desktop window",
"windows": ["main"],
"permissions": ["core:default"]
"windows": ["main", "supernode-users"],
"permissions": [
"core:default",
"core:window:allow-create",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-destroy",
"core:webview:allow-create-webview-window"
]
}
+511 -22
View File
@@ -3,10 +3,14 @@ use serde_json::Value;
use std::{
collections::HashMap,
fs,
net::{Ipv4Addr, SocketAddr, TcpStream, UdpSocket},
io::{BufRead, BufReader, Write},
net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream, ToSocketAddrs, UdpSocket},
path::PathBuf,
process::{Child, Command, Stdio},
sync::Mutex,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tauri::{
@@ -17,9 +21,70 @@ use tauri::{
struct AppState {
processes: Mutex<HashMap<String, Child>>,
special_server: Mutex<Option<SpecialServerHandle>>,
}
struct SpecialServerHandle {
stop: Arc<AtomicBool>,
join: Option<std::thread::JoinHandle<()>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SupernodeRequest {
port: u16,
dhcp: bool,
dhcp_pool: String,
allowed_communities: String,
special_enabled: bool,
special_port: u16,
special_community: String,
special_pool: String,
special_gateway: String,
special_dns: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SpecialClientRequest {
host: String,
port: u16,
username: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct SpecialConfig {
community: String,
ip: String,
gateway: String,
dns: String,
supernode_port: u16,
username: String,
special: bool,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct SpecialUser {
username: String,
ip: String,
special: bool,
last_seen: u64,
}
struct SpecialServerData {
community: String,
pool_start: u32,
pool_end: u32,
gateway: String,
dns: String,
supernode_port: u16,
leases: Mutex<HashMap<String, SpecialUser>>,
}
fn stop_all_processes(state: &AppState) {
stop_special_server(state);
let Ok(mut processes) = state.processes.lock() else {
return;
};
@@ -52,6 +117,223 @@ fn tray_icon_image() -> tauri::image::Image<'static> {
tauri::image::Image::new_owned(rgba, 16, 16)
}
fn parse_cidr(value: &str) -> Result<(u32, u32, u8), String> {
let (address, prefix) = value
.trim()
.split_once('/')
.ok_or_else(|| format!("地址池必须使用 CIDR 格式:{value}"))?;
let ip = address
.parse::<Ipv4Addr>()
.map_err(|_| format!("无效的地址池:{value}"))?;
let prefix = prefix
.parse::<u8>()
.map_err(|_| format!("无效的 CIDR 前缀:{value}"))?;
if prefix > 30 {
return Err("地址池前缀必须在 0 到 30 之间".to_string());
}
let bits = u32::from(ip);
let mask = if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
};
let network = bits & mask;
Ok((network, network | !mask, prefix))
}
fn dotted_ip(value: u32) -> String {
Ipv4Addr::from(value).to_string()
}
fn cidr_range(value: &str) -> Result<String, String> {
let (start, _end, prefix) = parse_cidr(value)?;
Ok(format!(
"{}-{}/{}",
dotted_ip(start),
dotted_ip(start),
prefix
))
}
fn validate_supernode_request(request: &SupernodeRequest) -> Result<(), String> {
if !(1..=65535).contains(&request.port) {
return Err("Supernode UDP 端口必须在 1 到 65535 之间".to_string());
}
let base_pool = if request.dhcp {
Some(parse_cidr(&request.dhcp_pool)?)
} else {
None
};
if request.special_enabled {
if !(1..=65535).contains(&request.special_port) {
return Err("特殊服务端 TCP 端口必须在 1 到 65535 之间".to_string());
}
if request.special_port == request.port {
return Err("特殊服务端 TCP 端口不能与 Supernode UDP 端口相同".to_string());
}
if request.special_community.trim().is_empty() {
return Err("请填写特殊服务端下发的社区名".to_string());
}
let special_pool = parse_cidr(&request.special_pool)?;
let Some(base_pool) = base_pool else {
return Err("开启特殊服务端时必须开启 DHCP,并配置普通 DHCP 地址池".to_string());
};
if special_pool.2 < base_pool.2
|| special_pool.0 < base_pool.0
|| special_pool.1 > base_pool.1
{
return Err("特殊服务端地址池必须包含在普通 DHCP 地址池内".to_string());
}
let allowed_lines = request
.allowed_communities
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
if !allowed_lines.is_empty()
&& !allowed_lines
.iter()
.any(|line| *line == request.special_community.trim())
{
return Err("特殊服务端社区名必须出现在允许的社区名列表中".to_string());
}
}
Ok(())
}
fn stop_special_server(state: &AppState) {
let handle = state
.special_server
.lock()
.ok()
.and_then(|mut value| value.take());
if let Some(mut handle) = handle {
handle.stop.store(true, Ordering::Relaxed);
if let Some(join) = handle.join.take() {
let _ = join.join();
}
}
}
fn allocate_special_ip(data: &SpecialServerData) -> Option<String> {
let leases = data.leases.lock().ok()?;
let used = leases
.values()
.map(|lease| lease.ip.clone())
.collect::<Vec<_>>();
(data.pool_start..=data.pool_end)
.filter(|ip| *ip != data.pool_start && *ip != data.pool_end)
.map(dotted_ip)
.find(|ip| !used.iter().any(|used_ip| used_ip == ip))
}
fn handle_special_connection(mut stream: TcpStream, data: &SpecialServerData) {
let mut command = String::new();
if BufReader::new(&mut stream).read_line(&mut command).is_err() {
return;
}
let command = command.trim();
if let Some(username) = command.strip_prefix("HELLO ") {
let username = if username.trim().is_empty() {
"未命名客户端".to_string()
} else {
username.trim().chars().take(64).collect()
};
let ip = {
let existing = data
.leases
.lock()
.ok()
.and_then(|leases| leases.get(&username).map(|lease| lease.ip.clone()));
existing.or_else(|| allocate_special_ip(data))
};
let Some(ip) = ip else {
let _ = writeln!(stream, "{{\"error\":\"地址池已耗尽\"}}");
return;
};
if let Ok(mut leases) = data.leases.lock() {
leases.insert(
username.clone(),
SpecialUser {
username: username.clone(),
ip: ip.clone(),
special: true,
last_seen: now_epoch(),
},
);
}
let response = SpecialConfig {
community: data.community.clone(),
ip,
gateway: data.gateway.clone(),
dns: data.dns.clone(),
supernode_port: data.supernode_port,
username,
special: true,
};
if let Ok(body) = serde_json::to_string(&response) {
let _ = writeln!(stream, "{body}");
}
} else if command == "LIST" {
let now = now_epoch();
let users = data
.leases
.lock()
.map(|leases| {
leases
.values()
.filter(|lease| now.saturating_sub(lease.last_seen) <= 90)
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let Ok(body) = serde_json::to_string(&users) {
let _ = writeln!(stream, "{body}");
}
}
}
fn start_special_server(request: &SupernodeRequest, state: &AppState) -> Result<(), String> {
stop_special_server(state);
let (pool_start, pool_end, _) = parse_cidr(&request.special_pool)?;
let listener = TcpListener::bind(("0.0.0.0", request.special_port))
.map_err(|error| format!("特殊服务端无法监听 TCP {}{error}", request.special_port))?;
listener
.set_nonblocking(true)
.map_err(|error| format!("特殊服务端设置监听模式失败:{error}"))?;
let data = Arc::new(SpecialServerData {
community: request.special_community.trim().to_string(),
pool_start,
pool_end,
gateway: request.special_gateway.trim().to_string(),
dns: request.special_dns.trim().to_string(),
supernode_port: request.port,
leases: Mutex::new(HashMap::new()),
});
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
let join = std::thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => handle_special_connection(stream, &data),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(100));
}
Err(_) => break,
}
}
});
state
.special_server
.lock()
.map_err(|_| "特殊服务端状态不可用".to_string())?
.replace(SpecialServerHandle {
stop,
join: Some(join),
});
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StartRequest {
@@ -340,11 +622,7 @@ fn windows_tap_adapters() -> Result<Vec<TapAdapter>, String> {
Ok(adapters)
}
#[tauri::command]
fn start_service(
request: StartRequest,
state: State<'_, AppState>,
) -> Result<ServiceStatus, String> {
fn start_service_inner(request: StartRequest, state: &AppState) -> Result<ServiceStatus, String> {
let mut processes = state
.processes
.lock()
@@ -409,12 +687,147 @@ fn start_service(
})
}
#[tauri::command]
fn start_service(
request: StartRequest,
state: State<'_, AppState>,
) -> Result<ServiceStatus, String> {
start_service_inner(request, state.inner())
}
fn stop_service_process(service: &str, state: &AppState) -> ServiceStatus {
let Ok(mut processes) = state.processes.lock() else {
return empty_status(service, "process state is unavailable");
};
if let Some(mut child) = processes.remove(service) {
let _ = child.kill();
let _ = child.wait();
return empty_status(service, "stopped");
}
empty_status(service, "not running")
}
#[tauri::command]
fn start_supernode(
request: SupernodeRequest,
state: State<'_, AppState>,
) -> Result<ServiceStatus, String> {
validate_supernode_request(&request)?;
let directory = app_directory()?;
let allowed_path = directory.join("supernode-communities.list");
let allowed = request
.allowed_communities
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
if allowed.is_empty() {
let _ = fs::remove_file(&allowed_path);
} else {
fs::write(&allowed_path, format!("{}\n", allowed.join("\n")))
.map_err(|error| format!("无法写入社区列表:{error}"))?;
}
let mut config = format!("-p {}\n", request.port);
if request.dhcp {
config.push_str(&format!("-a {}\n", cidr_range(&request.dhcp_pool)?));
}
if !allowed.is_empty() {
config.push_str("-c supernode-communities.list\n");
}
config.push_str("-v\n");
fs::write(directory.join("supernode.conf"), config)
.map_err(|error| format!("无法写入 Supernode 配置:{error}"))?;
let status = start_service_inner(
StartRequest {
service: "supernode".to_string(),
binary_path: "./n2n/supernode.exe".to_string(),
args: vec!["supernode.conf".to_string()],
},
state.inner(),
)?;
if request.special_enabled {
if let Err(error) = start_special_server(&request, state.inner()) {
stop_service_process("supernode", state.inner());
return Err(error);
}
}
Ok(status)
}
#[tauri::command]
fn stop_special_service(state: State<'_, AppState>) -> Result<(), String> {
stop_special_server(state.inner());
Ok(())
}
fn special_request(request: &SpecialClientRequest, command: &str) -> Result<String, String> {
if request.host.trim().is_empty() || !(1..=65535).contains(&request.port) {
return Err("特殊服务端地址或端口无效".to_string());
}
let address = format!("{}:{}", request.host.trim(), request.port);
let socket = address
.to_socket_addrs()
.map_err(|error| format!("无法解析特殊服务端地址:{error}"))?
.next()
.ok_or_else(|| format!("无效的特殊服务端地址:{address}"))?;
let mut stream = TcpStream::connect_timeout(&socket, Duration::from_secs(3))
.map_err(|error| format!("无法连接特殊服务端:{error}"))?;
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.map_err(|error| error.to_string())?;
writeln!(stream, "{command}").map_err(|error| format!("发送特殊服务端请求失败:{error}"))?;
let mut response = String::new();
BufReader::new(stream)
.read_line(&mut response)
.map_err(|error| format!("读取特殊服务端响应失败:{error}"))?;
Ok(response)
}
fn default_username() -> String {
std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.unwrap_or_else(|_| "未命名客户端".to_string())
}
#[tauri::command]
fn fetch_special_config(request: SpecialClientRequest) -> Result<SpecialConfig, String> {
let username = if request.username.trim().is_empty() {
default_username()
} else {
request.username.trim().to_string()
};
let command = format!("HELLO {}", username.chars().take(64).collect::<String>());
let response = special_request(&request, &command)?;
if let Ok(error) = serde_json::from_str::<HashMap<String, String>>(&response) {
if let Some(message) = error.get("error") {
return Err(message.clone());
}
}
serde_json::from_str(response.trim())
.map_err(|error| format!("特殊服务端返回无效配置:{error}"))
}
#[tauri::command]
fn fetch_special_users(request: SpecialClientRequest) -> Result<Vec<SpecialUser>, String> {
if !request.username.trim().is_empty() {
let username = request.username.trim().chars().take(64).collect::<String>();
let _ = special_request(&request, &format!("HELLO {username}"))?;
}
let response = special_request(&request, "LIST")?;
serde_json::from_str(response.trim()).map_err(|error| format!("在线用户列表无效:{error}"))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TapAddressRequest {
tap_device: String,
dhcp: bool,
ip: String,
#[serde(default)]
gateway: String,
#[serde(default)]
dns: String,
}
#[tauri::command]
@@ -458,6 +871,15 @@ fn configure_tap_adapter(request: TapAddressRequest) -> Result<(), String> {
&format!("address={address}"),
"mask=255.255.255.0",
]);
let gateway = request.gateway.trim();
if gateway.is_empty() {
command.arg("gateway=none");
} else {
let gateway = gateway
.parse::<Ipv4Addr>()
.map_err(|_| format!("无效的网关:{gateway}"))?;
command.arg(format!("gateway={gateway}"));
}
}
let output = command
.output()
@@ -470,6 +892,35 @@ fn configure_tap_adapter(request: TapAddressRequest) -> Result<(), String> {
};
return Err(format!("TAP 网卡地址配置失败:{}", detail.trim()));
}
if !request.dhcp && !request.dns.trim().is_empty() {
let dns = request
.dns
.trim()
.parse::<Ipv4Addr>()
.map_err(|_| format!("无效的 DNS{}", request.dns.trim()))?;
let dns_output = Command::new("netsh")
.args([
"interface",
"ipv4",
"set",
"dnsservers",
&interface,
"source=static",
&format!("address={dns}"),
"register=primary",
"validate=no",
])
.output()
.map_err(|error| format!("无法配置 TAP DNS{error}"))?;
if !dns_output.status.success() {
let detail = if dns_output.stderr.is_empty() {
String::from_utf8_lossy(&dns_output.stdout)
} else {
String::from_utf8_lossy(&dns_output.stderr)
};
return Err(format!("TAP DNS 配置失败:{}", detail.trim()));
}
}
return Ok(());
}
@@ -482,18 +933,10 @@ fn configure_tap_adapter(request: TapAddressRequest) -> Result<(), String> {
#[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"));
if service == "supernode" {
stop_special_server(state.inner());
}
Ok(empty_status(&service, "not running"))
Ok(stop_service_process(&service, state.inner()))
}
#[tauri::command]
@@ -838,7 +1281,10 @@ fn export_log(contents: String) -> Result<String, String> {
#[cfg(test)]
mod tests {
use super::{is_tap_windows_description, relative_binary_path, StartRequest};
use super::{
cidr_range, is_tap_windows_description, relative_binary_path, validate_supernode_request,
StartRequest, SupernodeRequest,
};
#[test]
fn tap_description_filter_excludes_network_filter_interfaces() {
@@ -884,12 +1330,42 @@ mod tests {
"./n2n/supernode.exe"
);
}
#[test]
fn supernode_cidr_range_keeps_network_and_broadcast() {
assert_eq!(
cidr_range("10.10.10.128/25").unwrap(),
"10.10.10.128-10.10.10.128/25"
);
}
#[test]
fn special_pool_must_be_inside_base_pool_and_allowed_community() {
let valid = SupernodeRequest {
port: 7777,
dhcp: true,
dhcp_pool: "10.10.10.0/24".to_string(),
allowed_communities: "team\nops".to_string(),
special_enabled: true,
special_port: 7788,
special_community: "team".to_string(),
special_pool: "10.10.10.128/25".to_string(),
special_gateway: "10.10.10.1".to_string(),
special_dns: "10.10.10.1".to_string(),
};
assert!(validate_supernode_request(&valid).is_ok());
let mut invalid = valid;
invalid.special_community = "guest".to_string();
assert!(validate_supernode_request(&invalid).is_err());
}
}
pub fn run() {
let app = tauri::Builder::default()
.manage(AppState {
processes: Mutex::new(HashMap::new()),
special_server: Mutex::new(None),
})
.setup(|app| {
let connect = MenuItem::with_id(app, "tray-connect", "连接", true, None::<&str>)?;
@@ -938,14 +1414,27 @@ pub fn run() {
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
stop_all_processes(window.state::<AppState>().inner());
let _ = window.emit("tray-service-stopped", ());
let _ = window.hide();
if window.label() == "main" {
stop_all_processes(window.state::<AppState>().inner());
if let Some(users_window) =
window.app_handle().get_webview_window("supernode-users")
{
let _ = users_window.destroy();
}
let _ = window.emit("tray-service-stopped", ());
let _ = window.hide();
} else if window.label() == "supernode-users" {
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![
start_service,
start_supernode,
stop_service,
stop_special_service,
fetch_special_config,
fetch_special_users,
service_status,
management_query,
run_ping,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Super N2N",
"version": "0.1.0",
"version": "1.2.0",
"identifier": "com.supern2n.control",
"build": {
"beforeDevCommand": "npm run dev",