From ba914125f2ea4dc88caa4854fe649fcf9a4823fe Mon Sep 17 00:00:00 2001 From: chun_qiu Date: Wed, 2 Sep 2026 04:45:39 +0800 Subject: [PATCH] Release version 1.2 with Supernode user window --- .gitea/workflows/build-windows.yml | 4 +- README.md | 17 +- package-lock.json | 4 +- package.json | 2 +- scripts/build.ps1 | 4 +- scripts/package-portable.ps1 | 6 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 13 +- src-tauri/src/lib.rs | 533 ++++++++++++++++++++++++++-- src-tauri/tauri.conf.json | 2 +- src/lib/tauri.js | 8 + src/main.jsx | 374 ++++++++++++++++--- src/styles.css | 90 ++++- src/supernode-users.jsx | 71 ++++ supernode-users.html | 13 + vite.config.js | 8 + 17 files changed, 1074 insertions(+), 79 deletions(-) create mode 100644 src/supernode-users.jsx create mode 100644 supernode-users.html diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml index a265135..39f876e 100644 --- a/.gitea/workflows/build-windows.yml +++ b/.gitea/workflows/build-windows.yml @@ -26,6 +26,6 @@ jobs: with: name: super-n2n-${{ github.sha }} path: | - release/super-n2n-portable.zip - release/super-n2n-portable.zip.sha256 + release/super-n2n-portable-*.zip + release/super-n2n-portable-*.zip.sha256 if-no-files-found: error diff --git a/README.md b/README.md index 13f26f8..a809f77 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ Install dependencies, then start the Tauri development window: npm install npm run tauri dev -The portable build creates `release/super-n2n-portable.zip`. It contains +The portable build creates a versioned archive such as +`release/super-n2n-portable-1.2.zip`. It contains `n2n/edge.exe`, `n2n/supernode.exe`, and the official TAP-Windows installer in `drivers/tap-windows-9.24.7-I601-Win10.exe`; on Windows Super N2N starts the n2n programs with the relative paths `./n2n/edge.exe` and `./n2n/supernode.exe`. @@ -60,6 +61,20 @@ select a specific existing adapter. Connection settings, including advanced settings, are stored beside `super-n2n.exe` in `super-n2n.config.json` and are restored on the next application launch. +### Supernode and special server + +The `创建服务器` home starts Supernode on the configured UDP port and can +optionally restrict communities and the Supernode address-assignment pool. +The optional special server listens on its own TCP port. A special client only +needs that server address and port; it receives the community, static TAP IP, +gateway, DNS, and Supernode port automatically. The client username is sent as +the n2n edge description (`-I`) and defaults to the Windows computer name. + +The special address pool must be a subnet inside the normal Supernode pool, and +its community must be present in the allowed-community list when that list is +restricted. Supernode's compact client window polls the management API and +marks addresses issued by the special server. + ## Gitea Actions `.gitea/workflows/build-windows.yml` runs when a Release is published and uploads diff --git a/package-lock.json b/package-lock.json index 99654f3..928864d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "super-n2n", - "version": "0.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "super-n2n", - "version": "0.1.0", + "version": "1.2.0", "dependencies": { "@tauri-apps/api": "^2.8.0", "lucide-react": "^0.468.0", diff --git a/package.json b/package.json index b5f908f..bf0a500 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "super-n2n", "private": true, - "version": "0.1.0", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1 --port 1420", diff --git a/scripts/build.ps1 b/scripts/build.ps1 index f87bd0f..71ae547 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -12,7 +12,9 @@ $n2nRoot = Join-Path $projectRoot 'third_party\n2n' $cargoManifest = Join-Path $projectRoot 'src-tauri\Cargo.toml' $packageScript = Join-Path $PSScriptRoot 'package-portable.ps1' $n2nBuildScript = Join-Path $PSScriptRoot 'build-n2n.sh' -$archivePath = Join-Path $projectRoot 'release\super-n2n-portable.zip' +$packageJson = Get-Content (Join-Path $projectRoot 'package.json') -Raw | ConvertFrom-Json +$artifactVersion = (($packageJson.version -split '\.')[0..1] -join '.') +$archivePath = Join-Path $projectRoot "release\super-n2n-portable-$artifactVersion.zip" $checksumPath = $archivePath + '.sha256' function Get-RequiredCommand { diff --git a/scripts/package-portable.ps1 b/scripts/package-portable.ps1 index 78626dc..2f1756d 100644 --- a/scripts/package-portable.ps1 +++ b/scripts/package-portable.ps1 @@ -9,7 +9,9 @@ $n2nRoot = Join-Path $projectRoot 'third_party\n2n' $releaseRoot = Join-Path $projectRoot 'release' $portableRoot = Join-Path $projectRoot 'release\super-n2n-portable' $stagingRoot = Join-Path $releaseRoot 'super-n2n-portable-staging' -$archivePath = Join-Path $releaseRoot 'super-n2n-portable.zip' +$packageJson = Get-Content (Join-Path $projectRoot 'package.json') -Raw | ConvertFrom-Json +$artifactVersion = (($packageJson.version -split '\.')[0..1] -join '.') +$archivePath = Join-Path $releaseRoot "super-n2n-portable-$artifactVersion.zip" $releaseRootFull = [System.IO.Path]::GetFullPath($releaseRoot) $portableRootFull = [System.IO.Path]::GetFullPath($portableRoot) $stagingRootFull = [System.IO.Path]::GetFullPath($stagingRoot) @@ -21,7 +23,7 @@ if ((Split-Path -Parent $portableRootFull) -ne $releaseRootFull -or (Split-Path if ((Split-Path -Parent $stagingRootFull) -ne $releaseRootFull -or (Split-Path -Leaf $stagingRootFull) -ne 'super-n2n-portable-staging') { throw "Refusing to replace an unexpected staging directory: $stagingRootFull" } -if ((Split-Path -Parent $archivePathFull) -ne $releaseRootFull -or (Split-Path -Leaf $archivePathFull) -ne 'super-n2n-portable.zip') { +if ((Split-Path -Parent $archivePathFull) -ne $releaseRootFull -or (Split-Path -Leaf $archivePathFull) -ne "super-n2n-portable-$artifactVersion.zip") { throw "Refusing to replace an unexpected portable archive: $archivePathFull" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 220e218..086eb41 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2919,7 +2919,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "super-n2n" -version = "0.1.0" +version = "1.2.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2eb1a20..6af501d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fd5efab..767d36b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" + ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b216fef..0b8d3d1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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>, + special_server: Mutex>, +} + +struct SpecialServerHandle { + stop: Arc, + join: Option>, +} + +#[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>, } 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::() + .map_err(|_| format!("无效的地址池:{value}"))?; + let prefix = prefix + .parse::() + .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 { + 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::>(); + 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 { + let leases = data.leases.lock().ok()?; + let used = leases + .values() + .map(|lease| lease.ip.clone()) + .collect::>(); + (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::>() + }) + .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, String> { Ok(adapters) } -#[tauri::command] -fn start_service( - request: StartRequest, - state: State<'_, AppState>, -) -> Result { +fn start_service_inner(request: StartRequest, state: &AppState) -> Result { let mut processes = state .processes .lock() @@ -409,12 +687,147 @@ fn start_service( }) } +#[tauri::command] +fn start_service( + request: StartRequest, + state: State<'_, AppState>, +) -> Result { + 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 { + 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::>(); + 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 { + 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 { + 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::()); + let response = special_request(&request, &command)?; + if let Ok(error) = serde_json::from_str::>(&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, String> { + if !request.username.trim().is_empty() { + let username = request.username.trim().chars().take(64).collect::(); + 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::() + .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::() + .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 { - 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 { #[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::().inner()); - let _ = window.emit("tray-service-stopped", ()); - let _ = window.hide(); + if window.label() == "main" { + stop_all_processes(window.state::().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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f670dda..cde9cdf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/lib/tauri.js b/src/lib/tauri.js index 7eceb59..2908ec4 100644 --- a/src/lib/tauri.js +++ b/src/lib/tauri.js @@ -14,10 +14,18 @@ export async function nativeCall(command, args, fallback) { export const startService = (request) => nativeCall('start_service', { request }, () => { throw new Error('浏览器预览无法启动原生服务') }) +export const startSupernode = (request) => nativeCall('start_supernode', { request }, () => { + throw new Error('浏览器预览无法启动 Supernode') +}) export const stopService = (service) => nativeCall('stop_service', { service }, () => { throw new Error('浏览器预览没有受管进程') }) +export const stopSpecialService = () => nativeCall('stop_special_service', {}, () => null) +export const fetchSpecialConfig = (request) => nativeCall('fetch_special_config', { request }, () => { + throw new Error('浏览器预览无法连接特殊服务端') +}) +export const fetchSpecialUsers = (request) => nativeCall('fetch_special_users', { request }, () => []) export const serviceStatus = (service) => nativeCall('service_status', { service }, () => ({ service, diff --git a/src/main.jsx b/src/main.jsx index 9ccad95..f865159 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,14 +1,16 @@ import { useEffect, useRef, useState } from 'react' import { createRoot } from 'react-dom/client' import { getCurrentWindow } from '@tauri-apps/api/window' +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' import { listen } from '@tauri-apps/api/event' import { ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play, - Download, Router, ScrollText, Server, Settings2, Square, Wifi, + Download, Router, ScrollText, Server, Settings2, Square, Users, Wifi, } from 'lucide-react' import { - configureTapAdapter, exportLog, isTauri, openTapInstaller, readConfig, resolveTapAdapter, serviceStatus, startService, - startupStatus, stopService, tapTraffic, writeConfig, + configureTapAdapter, exportLog, fetchSpecialConfig, fetchSpecialUsers, isTauri, openTapInstaller, + queryManagement, readConfig, resolveTapAdapter, runPing, serviceStatus, startService, startSupernode, startupStatus, stopService, + stopSpecialService, tapTraffic, writeConfig, } from './lib/tauri' import './styles.css' @@ -21,6 +23,22 @@ const initialEdge = { encryptionKey: '', managementPort: '5644', tapDevice: '', + specialClient: false, + specialServerPort: '7788', + username: '', +} + +const initialSupernode = { + port: '7777', + dhcp: true, + dhcpPool: '10.10.10.0/24', + allowedCommunities: '', + specialEnabled: false, + specialPort: '7788', + specialCommunity: '', + specialPool: '10.10.10.128/25', + specialGateway: '10.10.10.1', + specialDns: '10.10.10.1', } const edgeConfigStorageKey = 'super-n2n.edge-config.v1' @@ -37,6 +55,17 @@ function parseEdgeConfig(value) { return fallback } +function parseSupernodeConfig(value) { + const fallback = { ...initialSupernode } + const saved = value && typeof value === 'object' && value.supernode && typeof value.supernode === 'object' + ? value.supernode : null + if (!saved) return fallback + for (const [field, defaultValue] of Object.entries(initialSupernode)) { + if (typeof saved[field] === typeof defaultValue) fallback[field] = saved[field] + } + return fallback +} + function loadBrowserEdgeConfig() { if (typeof window === 'undefined') return { ...initialEdge } try { @@ -47,9 +76,18 @@ function loadBrowserEdgeConfig() { } } -function saveBrowserEdgeConfig(config) { +function loadBrowserSupernodeConfig() { + if (typeof window === 'undefined') return { ...initialSupernode } try { - window.localStorage.setItem(edgeConfigStorageKey, JSON.stringify(config)) + return parseSupernodeConfig(JSON.parse(window.localStorage.getItem(edgeConfigStorageKey) || 'null')) + } catch { + return { ...initialSupernode } + } +} + +function saveBrowserAppConfig(edge, supernode) { + try { + window.localStorage.setItem(edgeConfigStorageKey, JSON.stringify({ version: 2, edge, supernode })) } catch { // The form stays usable when the platform denies local storage access. } @@ -59,15 +97,23 @@ function serializeEdgeConfig(config) { return JSON.stringify({ version: 1, edge: config }, null, 2) } +function serializeAppConfig(edge, supernode) { + return JSON.stringify({ version: 2, edge, supernode }, null, 2) +} + function App() { const [home, setHome] = useState('edge') const [edge, setEdge] = useState(loadBrowserEdgeConfig) + const [supernode, setSupernode] = useState(loadBrowserSupernodeConfig) const [edgeRunning, setEdgeRunning] = useState(false) + const [supernodeRunning, setSupernodeRunning] = useState(false) const [activeTapDevice, setActiveTapDevice] = useState('') const [advancedOpen, setAdvancedOpen] = useState(false) const [logOpen, setLogOpen] = useState(false) const [logs, setLogs] = useState([]) const [traffic, setTraffic] = useState(null) + const [specialUsers, setSpecialUsers] = useState([]) + const [specialLatencies, setSpecialLatencies] = useState({}) const [message, setMessage] = useState(null) const [startupInfo, setStartupInfo] = useState(null) const [configReady, setConfigReady] = useState(!isTauri) @@ -82,15 +128,15 @@ function App() { useEffect(() => { if (!configReady) return if (!isTauri) { - saveBrowserEdgeConfig(edge) + saveBrowserAppConfig(edge, supernode) return } - writeConfig(serializeEdgeConfig(edge)).catch((error) => { + writeConfig(serializeAppConfig(edge, supernode)).catch((error) => { if (configWriteFailed.current) return configWriteFailed.current = true appendLog('配置保存失败:' + error.message, 'warning') }) - }, [edge, configReady]) + }, [edge, supernode, configReady]) useEffect(() => { let active = true @@ -101,7 +147,11 @@ function App() { setStartupInfo(status) if (isTauri && status.configExists) { const contents = await readConfig() - if (active && contents) setEdge(parseEdgeConfig(JSON.parse(contents))) + if (active && contents) { + const parsed = JSON.parse(contents) + setEdge(parseEdgeConfig(parsed)) + setSupernode(parseSupernodeConfig(parsed)) + } } if (active) { setConfigReady(true) @@ -142,7 +192,9 @@ function App() { unlistenStopped = await listen('tray-service-stopped', () => { if (!active) return setEdgeRunning(false) + setSupernodeRunning(false) setActiveTapDevice('') + setSpecialUsers([]) }) } bindTrayEvents() @@ -156,16 +208,66 @@ function App() { useEffect(() => { let active = true - serviceStatus('edge') - .then((status) => { + Promise.all([serviceStatus('edge'), serviceStatus('supernode')]) + .then(([edgeStatus, supernodeStatus]) => { if (!active) return - setEdgeRunning(Boolean(status.running)) - appendLog(status.running ? '检测到 Edge 已连接' : 'Edge 已就绪,等待连接', status.running ? 'success' : 'info') + setEdgeRunning(Boolean(edgeStatus.running)) + setSupernodeRunning(Boolean(supernodeStatus.running)) + appendLog(edgeStatus.running ? '检测到 Edge 已连接' : 'Edge 已就绪,等待连接', edgeStatus.running ? 'success' : 'info') }) .catch(() => {}) return () => { active = false } }, []) + useEffect(() => { + if (!edgeRunning || !edge.specialClient) { + setSpecialUsers([]) + return undefined + } + let active = true + let timer + const poll = async () => { + try { + const users = await fetchSpecialUsers({ + host: edge.serverHost, + port: Number(edge.specialServerPort), + username: edge.username, + }) + if (active) setSpecialUsers(Array.isArray(users) ? users : []) + } catch { + if (active) setSpecialUsers([]) + } finally { + if (active) timer = window.setTimeout(poll, 2000) + } + } + poll() + return () => { active = false; window.clearTimeout(timer) } + }, [edgeRunning, edge.specialClient, edge.serverHost, edge.specialServerPort, edge.username]) + + useEffect(() => { + if (!edgeRunning || !edge.specialClient || specialUsers.length === 0) { + setSpecialLatencies({}) + return undefined + } + let active = true + let timer + const poll = async () => { + const entries = await Promise.all(specialUsers.map(async (user) => { + try { + const result = await runPing({ host: user.ip, count: 1 }, () => ({ ok: false, output: '' })) + const match = String(result?.output || '').match(/(?:time|时间)[=<]\s*(\d+)/i) + return [user.ip, match ? Number(match[1]) : null] + } catch { + return [user.ip, null] + } + })) + if (active) setSpecialLatencies(Object.fromEntries(entries)) + if (active) timer = window.setTimeout(poll, 3000) + } + poll() + return () => { active = false; window.clearTimeout(timer) } + }, [edgeRunning, edge.specialClient, specialUsers]) + useEffect(() => { if (!edgeRunning || !activeTapDevice) { lastTapSample.current = null @@ -199,6 +301,7 @@ function App() { }, [activeTapDevice, edgeRunning]) const updateEdge = (field, value) => setEdge((current) => ({ ...current, [field]: value })) + const updateSupernode = (field, value) => setSupernode((current) => ({ ...current, [field]: value })) const notify = (text, tone = 'error') => { setMessage({ text, tone }) window.setTimeout(() => setMessage(null), 3200) @@ -224,43 +327,96 @@ function App() { notify('请填写服务器 IP 或域名') return } - if (!edge.community.trim()) { + if (!edge.specialClient && !edge.community.trim()) { appendLog('连接校验:未填写群组名称', 'warning') notify('请填写群组名称') return } - if (!edge.dhcp && !edge.ip.trim()) { + if (!edge.specialClient && !edge.dhcp && !edge.ip.trim()) { appendLog('连接校验:静态 IP 未填写', 'warning') notify('关闭 DHCP 后,请填写本机 IP') return } try { + let runtimeEdge = edge + if (edge.specialClient) { + const special = await fetchSpecialConfig({ + host: edge.serverHost, + port: Number(edge.specialServerPort), + username: edge.username || '', + }) + runtimeEdge = { + ...edge, + community: special.community, + serverPort: String(special.supernodePort), + dhcp: false, + ip: special.ip, + gateway: special.gateway, + dns: special.dns, + } + if (!edge.username.trim() && special.username) { + setEdge((current) => ({ ...current, username: special.username })) + } + appendLog('已从特殊服务端获取网络配置', 'success') + } const tapAdapter = await resolveTapAdapter(edge.tapDevice) appendLog('已选择 TAP 设备:' + tapAdapter.name) if (isWindowsRuntime) { await configureTapAdapter({ tapDevice: tapAdapter.guid || tapAdapter.name, - dhcp: edge.dhcp, - ip: edge.ip, + dhcp: runtimeEdge.dhcp, + ip: runtimeEdge.ip, + gateway: runtimeEdge.gateway || '', + dns: runtimeEdge.dns || '', }) appendLog('已配置 TAP 网卡地址', 'success') } const status = await startService({ service: 'edge', binaryPath: './n2n/edge.exe', - args: buildEdgeArgs(edge, tapAdapter.guid || tapAdapter.name), + args: buildEdgeArgs(runtimeEdge, tapAdapter.guid || tapAdapter.name), }) setEdgeRunning(Boolean(status.running)) setActiveTapDevice(tapAdapter.name) - appendLog('已连接到群组 ' + edge.community, 'success') - notify('已连接到 ' + edge.community, 'success') + appendLog('已连接到群组 ' + runtimeEdge.community, 'success') + notify('已连接到 ' + runtimeEdge.community, 'success') } catch (error) { appendLog('连接失败:' + error.message, 'error') notify('无法建立连接:' + error.message) } } + const toggleSupernode = async () => { + if (supernodeRunning) { + try { + await stopService('supernode') + await stopSpecialService() + await hideSupernodeUsersWindow() + setSupernodeRunning(false) + appendLog('Supernode 已停止') + notify('Supernode 已停止', 'neutral') + } catch (error) { + notify('停止 Supernode 失败:' + error.message) + } + return + } + try { + const status = await startSupernode({ + ...supernode, + port: Number(supernode.port), + specialPort: Number(supernode.specialPort), + }) + setSupernodeRunning(Boolean(status.running)) + await openSupernodeUsersWindow() + appendLog('Supernode 已启动', 'success') + notify('Supernode 已启动', 'success') + } catch (error) { + appendLog('启动 Supernode 失败:' + error.message, 'error') + notify('无法启动 Supernode:' + error.message) + } + } + const installTapDriver = async () => { try { const path = await openTapInstaller() @@ -275,7 +431,7 @@ function App() { return
Super N2N
-
{edgeRunning ? '已连接' : '未连接'}
+
{edgeRunning ? 'Edge 已连接' : supernodeRunning ? 'Supernode 运行中' : '未连接'}
@@ -289,8 +445,8 @@ function App() { {home === 'edge' - ? exportLogs(logs, notify)} /> - : } + ? exportLogs(logs, notify)} /> + : }
{isTauri ? '本机服务控制已就绪' : '浏览器预览模式'}n2n 3.1.1
@@ -298,7 +454,108 @@ function App() {
} -function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startupInfo, advancedOpen, setAdvancedOpen, setLogOpen, logOpen, logs, onToggle, onInstallTap, onExportLog }) { +async function openSupernodeUsersWindow() { + if (!isTauri) return + const existing = await WebviewWindow.getByLabel('supernode-users') + if (existing) { + await existing.destroy() + } + const window = new WebviewWindow('supernode-users', { + url: 'supernode-users.html', + title: 'Supernode 在线用户', + width: 320, + height: 460, + minWidth: 320, + minHeight: 360, + maxWidth: 420, + maxHeight: 720, + resizable: true, + visible: true, + }) + window.once('tauri://error', (event) => console.error('无法打开 Supernode 在线用户窗口', event)) +} + +async function hideSupernodeUsersWindow() { + if (!isTauri) return + const existing = await WebviewWindow.getByLabel('supernode-users') + if (existing) await existing.hide() +} + +function SupernodeUsersWindow() { + const [users, setUsers] = useState([]) + const [running, setRunning] = useState(false) + const [specialPort, setSpecialPort] = useState(7788) + const [specialEnabled, setSpecialEnabled] = useState(false) + + useEffect(() => { + let active = true + let timer + const poll = async () => { + try { + const status = await serviceStatus('supernode') + if (!active) return + setRunning(Boolean(status.running)) + const configText = await readConfig() + const config = configText ? JSON.parse(configText).supernode || {} : {} + const enabled = Boolean(config.specialEnabled) + const port = Number(config.specialPort) || 7788 + setSpecialEnabled(enabled) + setSpecialPort(port) + if (!status.running) { + setUsers([]) + return + } + const rows = await queryManagement({ host: '127.0.0.1', port: 5645, command: 'edges' }) + const merged = (Array.isArray(rows) ? rows : []) + .filter((row) => row?._type === 'row' && row.ip4addr) + .map((row) => ({ username: row.desc || '未知主机', ip: row.ip4addr, special: false })) + if (enabled) { + const leased = await fetchSpecialUsers({ host: '127.0.0.1', port, username: '' }) + const knownIps = new Set(merged.map((user) => user.ip)) + ;(Array.isArray(leased) ? leased : []).forEach((user) => { + const current = merged.find((entry) => entry.ip === user.ip) + if (current) current.special = true + else if (!knownIps.has(user.ip)) merged.push({ username: user.username, ip: user.ip, special: true }) + }) + } + if (active) setUsers(merged) + } catch { + if (active) setUsers([]) + } finally { + if (active) timer = window.setTimeout(poll, 2000) + } + } + poll() + return () => { active = false; window.clearTimeout(timer) } + }, []) + + return
+
+
Supernode 在线用户
+ {users.length} +
+
{running ? (specialEnabled ? `特殊服务端 :${specialPort}` : '当前连接的客户端') : 'Supernode 未运行'}
+ {users.length === 0 + ?
{running ? '暂无在线用户' : '启动 Supernode 后显示在线用户'}
+ :
    {users.map((user) =>
  • + {(user.username || '?').slice(0, 1).toUpperCase()} + {user.username}{user.ip} + {user.special && 特殊} +
  • )}
} +
+} + +function Root() { + const queryView = typeof window !== 'undefined' + ? new URLSearchParams(window.location.search).get('view') + : null + const label = isTauri ? getCurrentWindow().label : 'main' + return queryView === 'supernode-users' || label === 'supernode-users' + ? + : +} + +function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, specialUsers, specialLatencies, startupInfo, advancedOpen, setAdvancedOpen, setLogOpen, logOpen, logs, onToggle, onInstallTap, onExportLog }) { const toggleAdvanced = () => { setLogOpen(false) setAdvancedOpen((current) => !current) @@ -325,20 +582,24 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startup updateEdge('serverHost', event.target.value)} placeholder="IP 地址或域名" autoComplete="off" disabled={running} aria-label="服务器 IP 或域名" /> : - updateEdge('serverPort', event.target.value)} placeholder="7777" disabled={running} aria-label="服务器端口" /> + updateEdge(edge.specialClient ? 'specialServerPort' : 'serverPort', event.target.value)} placeholder={edge.specialClient ? '7788' : '7777'} disabled={running} aria-label={edge.specialClient ? '特殊服务端端口' : '服务器端口'} /> - + {edge.specialClient + ?
特殊客户端模式:社区、IP、网关和 DNS 由服务端下发。
+ : <> + -
- 本机 IP updateEdge('dhcp', checked)} /> - updateEdge('ip', event.target.value)} placeholder={edge.dhcp ? '开启 DHCP 后由服务器分配' : '例如:10.0.0.2'} autoComplete="off" disabled={edge.dhcp || running} aria-describedby="ip-help" /> - {edge.dhcp ? 'DHCP 已开启,连接后自动获取 IP。' : '使用静态 IP 时,请确保它没有被其他设备占用。'} -
+
+ 本机 IP updateEdge('dhcp', checked)} /> + updateEdge('ip', event.target.value)} placeholder={edge.dhcp ? '开启 DHCP 后由服务器分配' : '例如:10.0.0.2'} autoComplete="off" disabled={edge.dhcp || running} aria-describedby="ip-help" /> + {edge.dhcp ? 'DHCP 已开启,连接后自动获取 IP。' : '使用静态 IP 时,请确保它没有被其他设备占用。'} +
+ } @@ -349,6 +610,7 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startup {running && } + {running && edge.specialClient && }
@@ -360,6 +622,8 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startup + + {edge.specialClient && }
} @@ -400,23 +664,48 @@ function TrafficMetric({ direction, label, rate, total }) { } -function DhcpToggle({ checked, disabled, onChange }) { +function DhcpToggle({ checked, disabled, onChange, label = 'DHCP' }) { return } -function SupernodeHome() { - return
-
-

SUPERNODE

-

创建服务器

-

服务器配置即将加入此页面。

+function SupernodeHome({ config, update, running, onToggle }) { + return
+
+

SUPERNODE

创建服务器

+
+ + + +