Release version 1.2 with Supernode user window
Build Windows Portable Package / portable (release) Successful in 9m52s
Build Windows Portable Package / portable (release) Successful in 9m52s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2919,7 +2919,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "super-n2n"
|
||||
version = "0.1.0"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+332
-42
@@ -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 <main className="app-shell">
|
||||
<header className="app-header">
|
||||
<div className="brand"><span className="brand-icon"><Network size={19} /></span><span>Super N2N</span></div>
|
||||
<div className="runtime"><span className={edgeRunning ? 'status-light live' : 'status-light'} />{edgeRunning ? '已连接' : '未连接'}</div>
|
||||
<div className="runtime"><span className={edgeRunning || supernodeRunning ? 'status-light live' : 'status-light'} />{edgeRunning ? 'Edge 已连接' : supernodeRunning ? 'Supernode 运行中' : '未连接'}</div>
|
||||
</header>
|
||||
|
||||
<section className="workspace" aria-label="N2N 连接设置">
|
||||
@@ -289,8 +445,8 @@ function App() {
|
||||
</div>
|
||||
|
||||
{home === 'edge'
|
||||
? <EdgeHome edge={edge} updateEdge={updateEdge} running={edgeRunning} traffic={traffic} activeTapDevice={activeTapDevice} startupInfo={startupInfo} advancedOpen={advancedOpen} setAdvancedOpen={setAdvancedOpen} logOpen={logOpen} setLogOpen={setLogOpen} logs={logs} onToggle={toggleEdge} onInstallTap={installTapDriver} onExportLog={() => exportLogs(logs, notify)} />
|
||||
: <SupernodeHome />}
|
||||
? <EdgeHome edge={edge} updateEdge={updateEdge} running={edgeRunning} traffic={traffic} activeTapDevice={activeTapDevice} specialUsers={specialUsers} specialLatencies={specialLatencies} startupInfo={startupInfo} advancedOpen={advancedOpen} setAdvancedOpen={setAdvancedOpen} logOpen={logOpen} setLogOpen={setLogOpen} logs={logs} onToggle={toggleEdge} onInstallTap={installTapDriver} onExportLog={() => exportLogs(logs, notify)} />
|
||||
: <SupernodeHome config={supernode} update={updateSupernode} running={supernodeRunning} onToggle={toggleSupernode} />}
|
||||
</section>
|
||||
|
||||
<footer className="app-footer"><span>{isTauri ? '本机服务控制已就绪' : '浏览器预览模式'}</span><span>n2n 3.1.1</span></footer>
|
||||
@@ -298,7 +454,108 @@ function App() {
|
||||
</main>
|
||||
}
|
||||
|
||||
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 <main className="friends-window">
|
||||
<header className="friends-header">
|
||||
<div><span className={'status-light ' + (running ? 'live' : '')} /><strong>Supernode 在线用户</strong></div>
|
||||
<span className="friends-count">{users.length}</span>
|
||||
</header>
|
||||
<div className="friends-subtitle">{running ? (specialEnabled ? `特殊服务端 :${specialPort}` : '当前连接的客户端') : 'Supernode 未运行'}</div>
|
||||
{users.length === 0
|
||||
? <div className="friends-empty"><Users size={30} /><span>{running ? '暂无在线用户' : '启动 Supernode 后显示在线用户'}</span></div>
|
||||
: <ul className="friends-list">{users.map((user) => <li key={user.username + user.ip}>
|
||||
<span className="friend-avatar">{(user.username || '?').slice(0, 1).toUpperCase()}</span>
|
||||
<span className="friend-main"><strong>{user.username}</strong><small>{user.ip}</small></span>
|
||||
{user.special && <span className="friend-badge">特殊</span>}
|
||||
</li>)}</ul>}
|
||||
</main>
|
||||
}
|
||||
|
||||
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'
|
||||
? <SupernodeUsersWindow />
|
||||
: <App />
|
||||
}
|
||||
|
||||
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
|
||||
<span className="server-inputs">
|
||||
<input value={edge.serverHost} onChange={(event) => updateEdge('serverHost', event.target.value)} placeholder="IP 地址或域名" autoComplete="off" disabled={running} aria-label="服务器 IP 或域名" />
|
||||
<span className="port-separator">:</span>
|
||||
<input className="port-input" inputMode="numeric" value={edge.serverPort} onChange={(event) => updateEdge('serverPort', event.target.value)} placeholder="7777" disabled={running} aria-label="服务器端口" />
|
||||
<input className="port-input" inputMode="numeric" value={edge.specialClient ? edge.specialServerPort : edge.serverPort} onChange={(event) => updateEdge(edge.specialClient ? 'specialServerPort' : 'serverPort', event.target.value)} placeholder={edge.specialClient ? '7788' : '7777'} disabled={running} aria-label={edge.specialClient ? '特殊服务端端口' : '服务器端口'} />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="input-group">
|
||||
<span className="field-title"><Network size={18} />群组名称</span>
|
||||
<input value={edge.community} onChange={(event) => updateEdge('community', event.target.value)} placeholder="例如:my-network" autoComplete="off" disabled={running} />
|
||||
</label>
|
||||
{edge.specialClient
|
||||
? <div className="special-client-note"><Users size={16} /><span>特殊客户端模式:社区、IP、网关和 DNS 由服务端下发。</span></div>
|
||||
: <>
|
||||
<label className="input-group">
|
||||
<span className="field-title"><Network size={18} />群组名称</span>
|
||||
<input value={edge.community} onChange={(event) => updateEdge('community', event.target.value)} placeholder="例如:my-network" autoComplete="off" disabled={running} />
|
||||
</label>
|
||||
|
||||
<div className="input-group">
|
||||
<span className="ip-heading"><span className="field-title"><Router size={18} />本机 IP</span><DhcpToggle checked={edge.dhcp} disabled={running} onChange={(checked) => updateEdge('dhcp', checked)} /></span>
|
||||
<input value={edge.dhcp ? '' : edge.ip} onChange={(event) => updateEdge('ip', event.target.value)} placeholder={edge.dhcp ? '开启 DHCP 后由服务器分配' : '例如:10.0.0.2'} autoComplete="off" disabled={edge.dhcp || running} aria-describedby="ip-help" />
|
||||
<small id="ip-help">{edge.dhcp ? 'DHCP 已开启,连接后自动获取 IP。' : '使用静态 IP 时,请确保它没有被其他设备占用。'}</small>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<span className="ip-heading"><span className="field-title"><Router size={18} />本机 IP</span><DhcpToggle checked={edge.dhcp} disabled={running} onChange={(checked) => updateEdge('dhcp', checked)} /></span>
|
||||
<input value={edge.dhcp ? '' : edge.ip} onChange={(event) => updateEdge('ip', event.target.value)} placeholder={edge.dhcp ? '开启 DHCP 后由服务器分配' : '例如:10.0.0.2'} autoComplete="off" disabled={edge.dhcp || running} aria-describedby="ip-help" />
|
||||
<small id="ip-help">{edge.dhcp ? 'DHCP 已开启,连接后自动获取 IP。' : '使用静态 IP 时,请确保它没有被其他设备占用。'}</small>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
<TapSetupPrompt status={startupInfo} onInstall={onInstallTap} />
|
||||
@@ -349,6 +610,7 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startup
|
||||
</button>
|
||||
|
||||
{running && <TrafficStrip traffic={traffic} tapDevice={activeTapDevice} />}
|
||||
{running && edge.specialClient && <SpecialUsersPanel users={specialUsers} latencies={specialLatencies} />}
|
||||
</section>
|
||||
|
||||
<div className="secondary-actions">
|
||||
@@ -360,6 +622,8 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startup
|
||||
<label><span>加密密钥</span><input type="password" value={edge.encryptionKey} onChange={(event) => updateEdge('encryptionKey', event.target.value)} placeholder="可选" disabled={running} /></label>
|
||||
<label><span>管理端口</span><input inputMode="numeric" value={edge.managementPort} onChange={(event) => updateEdge('managementPort', event.target.value)} disabled={running} /></label>
|
||||
<label><span>TAP 设备(可选)</span><input value={edge.tapDevice} onChange={(event) => updateEdge('tapDevice', event.target.value)} placeholder="留空自动选择" disabled={running} /></label>
|
||||
<label className="switch-field"><span>特殊客户端</span><DhcpToggle label="启用" checked={edge.specialClient} disabled={running} onChange={(checked) => updateEdge('specialClient', checked)} /></label>
|
||||
{edge.specialClient && <label><span>用户名</span><input value={edge.username} onChange={(event) => updateEdge('username', event.target.value)} placeholder="留空使用主机名" disabled={running} /></label>}
|
||||
</div>}
|
||||
</section>
|
||||
|
||||
@@ -400,23 +664,48 @@ function TrafficMetric({ direction, label, rate, total }) {
|
||||
</output>
|
||||
}
|
||||
|
||||
function DhcpToggle({ checked, disabled, onChange }) {
|
||||
function DhcpToggle({ checked, disabled, onChange, label = 'DHCP' }) {
|
||||
return <label className="dhcp-toggle">
|
||||
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
|
||||
<span className="toggle-track" aria-hidden="true"><i /></span>
|
||||
<span>DHCP</span>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
}
|
||||
|
||||
function SupernodeHome() {
|
||||
return <div className="coming-soon">
|
||||
<div className="coming-icon"><Server size={29} /></div>
|
||||
<p className="eyebrow">SUPERNODE</p>
|
||||
<h1>创建服务器</h1>
|
||||
<p>服务器配置即将加入此页面。</p>
|
||||
function SupernodeHome({ config, update, running, onToggle }) {
|
||||
return <div className="supernode-flow">
|
||||
<section className="supernode-card">
|
||||
<div className="supernode-heading"><span className="intro-icon"><Server size={23} /></span><span><p className="eyebrow">SUPERNODE</p><h1>创建服务器</h1></span></div>
|
||||
<div className="supernode-fields">
|
||||
<label><span>UDP 监听端口</span><input inputMode="numeric" value={config.port} onChange={(event) => update('port', event.target.value)} disabled={running} /></label>
|
||||
<label className="supernode-dhcp"><span>DHCP 服务</span><DhcpToggle checked={config.dhcp} disabled={running} onChange={(checked) => update('dhcp', checked)} /></label>
|
||||
<label className="wide"><span>DHCP 地址池</span><input value={config.dhcp ? config.dhcpPool : ''} onChange={(event) => update('dhcpPool', event.target.value)} placeholder="10.10.10.0/24" disabled={!config.dhcp || running} /></label>
|
||||
<label className="wide"><span>允许的社区名</span><textarea value={config.allowedCommunities} onChange={(event) => update('allowedCommunities', event.target.value)} placeholder="每行一个;留空时不限制" disabled={running} /></label>
|
||||
</div>
|
||||
<section className="special-server">
|
||||
<div className="special-server-toggle"><span><Users size={16} />特殊服务端</span><DhcpToggle label="启用" checked={config.specialEnabled} disabled={running} onChange={(checked) => update('specialEnabled', checked)} /></div>
|
||||
{config.specialEnabled && <div className="special-server-fields">
|
||||
<label><span>TCP 端口</span><input inputMode="numeric" value={config.specialPort} onChange={(event) => update('specialPort', event.target.value)} disabled={running} /></label>
|
||||
<label><span>下发社区</span><input value={config.specialCommunity} onChange={(event) => update('specialCommunity', event.target.value)} disabled={running} /></label>
|
||||
<label><span>下发地址池</span><input value={config.specialPool} onChange={(event) => update('specialPool', event.target.value)} placeholder="10.10.10.128/25" disabled={running} /></label>
|
||||
<label><span>网关</span><input value={config.specialGateway} onChange={(event) => update('specialGateway', event.target.value)} placeholder="可选" disabled={running} /></label>
|
||||
<label><span>DNS</span><input value={config.specialDns} onChange={(event) => update('specialDns', event.target.value)} placeholder="可选" disabled={running} /></label>
|
||||
</div>}
|
||||
</section>
|
||||
<button className={running ? 'connect-button stop' : 'connect-button'} onClick={onToggle}>{running ? <Square size={18} fill="currentColor" /> : <Play size={19} fill="currentColor" />}{running ? '停止 Supernode' : '启动 Supernode'}</button>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
function SpecialUsersPanel({ users, latencies = {} }) {
|
||||
return <section className="special-users" aria-label="社区在线用户">
|
||||
<header><span><Users size={16} />社区在线用户</span><small>{users.length} 人</small></header>
|
||||
{users.length === 0
|
||||
? <p>暂无客户端向特殊服务端请求配置。</p>
|
||||
: <ol>{users.map((user) => <li key={user.username + user.ip}><strong>{user.username}</strong><span>{user.ip}</span><small>{latencies[user.ip] == null ? '延迟 --' : `延迟 ${latencies[user.ip]} ms`}</small></li>)}</ol>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function LogPanel({ open, onToggle, logs, onExport }) {
|
||||
return <section className="log-section">
|
||||
<button className="log-toggle" onClick={onToggle} aria-expanded={open}>
|
||||
@@ -486,6 +775,7 @@ function buildEdgeArgs(config, tapDevice) {
|
||||
addOption(args, '-a', config.dhcp ? 'dhcp:0.0.0.0' : config.ip)
|
||||
addOption(args, '-d', tapDevice)
|
||||
addOption(args, '-t', config.managementPort)
|
||||
addOption(args, '-I', config.username)
|
||||
if (config.encryptionKey) addOption(args, '-k', config.encryptionKey)
|
||||
// Windows edge does not implement the POSIX-only -f daemon flag.
|
||||
if (!isWindowsRuntime) args.push('-f')
|
||||
|
||||
+89
-1
@@ -21,7 +21,7 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
body { min-height: 100vh; background: var(--canvas); }
|
||||
button, input { font: inherit; }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { border: 0; cursor: pointer; }
|
||||
button:focus-visible, input:focus-visible { outline: 3px solid rgba(43, 108, 176, .35); outline-offset: 2px; }
|
||||
button:disabled, input:disabled { cursor: not-allowed; }
|
||||
@@ -49,6 +49,8 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
|
||||
.intro-copy { margin: 4px 0 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||
|
||||
.connection-card { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; box-shadow: 0 7px 20px rgba(21, 53, 57, .07); }
|
||||
.special-client-note { display: flex; align-items: center; gap: 8px; margin: 8px 0 2px; padding: 9px 10px; color: #245c78; background: #edf7fb; border: 1px solid #c9e1ed; border-radius: 5px; font-size: 12px; line-height: 1.4; }
|
||||
.special-client-note svg { flex: 0 0 auto; color: var(--blue); }
|
||||
.field-stack { display: grid; padding: 18px 22px 6px; }
|
||||
.input-group { display: grid; gap: 7px; padding: 14px 0; border-bottom: 1px solid #e7eeee; }
|
||||
.input-group:last-child { border-bottom: 0; }
|
||||
@@ -141,6 +143,38 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
|
||||
.coming-soon h1 { margin-bottom: 10px; }
|
||||
.coming-soon p:last-child { margin: 0; color: var(--muted); font-size: 14px; }
|
||||
|
||||
.supernode-flow { display: grid; gap: 8px; animation: reveal 220ms ease-out; }
|
||||
.supernode-card { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; box-shadow: 0 7px 20px rgba(21, 53, 57, .07); }
|
||||
.supernode-heading { display: flex; align-items: center; gap: 10px; padding: 14px 16px 10px; border-bottom: 1px solid #e7eeee; }
|
||||
.supernode-heading .intro-icon { width: 36px; height: 36px; border-radius: 7px; }
|
||||
.supernode-heading h1 { font-size: 18px; }
|
||||
.supernode-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px 10px; padding: 12px 16px; }
|
||||
.supernode-fields label, .special-server-fields label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 600; }
|
||||
.supernode-fields .wide { grid-column: 1 / -1; }
|
||||
.supernode-fields input, .supernode-fields textarea, .special-server-fields input { width: 100%; height: 34px; padding: 0 9px; color: var(--ink); background: #fbfdfd; border: 1px solid var(--line-strong); border-radius: 5px; font-size: 13px; }
|
||||
.supernode-fields textarea { height: 58px; padding: 8px 9px; resize: vertical; }
|
||||
.supernode-fields input:disabled, .supernode-fields textarea:disabled, .special-server-fields input:disabled { color: #83969b; background: #f0f5f5; border-color: #dce6e7; }
|
||||
.supernode-dhcp { align-content: space-between; }
|
||||
.special-server { margin: 0 16px; border: 1px solid #cbdede; border-radius: 6px; background: #f7fbfb; }
|
||||
.special-server-toggle { min-height: 38px; display: flex; align-items: center; justify-content: space-between; padding: 0 10px; color: var(--ink); font-size: 12px; font-weight: 700; }
|
||||
.special-server-toggle > span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.special-server-toggle svg { color: var(--blue); }
|
||||
.special-server-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 10px 10px; border-top: 1px solid #dbe8e8; }
|
||||
.special-server-fields label { padding-top: 8px; }
|
||||
.special-server-fields label:nth-child(n + 3) { grid-column: 1 / -1; }
|
||||
.supernode-card .connect-button { margin-top: 11px; margin-bottom: 13px; }
|
||||
.special-users { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; }
|
||||
.special-users header { min-height: 38px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; color: var(--ink); background: #f5fbfa; border-bottom: 1px solid var(--line); font-size: 12px; font-weight: 700; }
|
||||
.special-users header span { display: inline-flex; align-items: center; gap: 6px; color: var(--teal-dark); }
|
||||
.special-users header small { color: var(--faint); font-size: 11px; }
|
||||
.special-users > p { margin: 0; padding: 13px; color: var(--muted); font-size: 12px; }
|
||||
.special-users ol { max-height: 130px; margin: 0; padding: 0; overflow-y: auto; list-style: none; }
|
||||
.special-users li { display: grid; grid-template-columns: minmax(0, 1fr) 94px 76px; gap: 8px; align-items: center; min-height: 34px; padding: 7px 12px; border-bottom: 1px solid #e7eeee; font-size: 12px; }
|
||||
.special-users li:last-child { border-bottom: 0; }
|
||||
.special-users li strong { overflow: hidden; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.special-users li span { color: var(--blue); font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 11px; }
|
||||
.special-users li small { color: var(--faint); font-size: 10px; text-align: right; }
|
||||
|
||||
.app-footer { color: var(--faint); border-top: 1px solid var(--line); font-size: 12px; }
|
||||
.notice { position: fixed; right: 24px; bottom: 24px; max-width: min(420px, calc(100vw - 48px)); display: flex; align-items: center; gap: 10px; padding: 14px 16px; color: #803035; background: #fff7f7; border: 1px solid #e6b9bb; border-radius: 7px; box-shadow: 0 8px 22px rgba(44, 44, 44, .13); font-size: 14px; animation: reveal 200ms ease-out; }
|
||||
.notice.success { color: #0d6a5e; background: #f0fbf8; border-color: #a8d9cd; }
|
||||
@@ -254,6 +288,27 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
|
||||
.advanced-fields label { gap: 4px; font-size: 11px; }
|
||||
.advanced-fields input { height: 30px; padding: 0 7px; border-radius: 4px; font-size: 12px; }
|
||||
|
||||
.supernode-flow { height: calc(100% - 41px); grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; }
|
||||
.supernode-card { min-height: 0; overflow-y: auto; box-shadow: none; border-radius: 6px; }
|
||||
.supernode-heading { display: none; }
|
||||
.supernode-fields { grid-template-columns: 1fr; gap: 5px; padding: 7px 8px; }
|
||||
.supernode-fields label, .special-server-fields label { gap: 3px; font-size: 10px; }
|
||||
.supernode-fields input, .supernode-fields textarea, .special-server-fields input { height: 28px; padding: 0 7px; border-radius: 4px; font-size: 11px; }
|
||||
.supernode-fields textarea { height: 42px; padding: 5px 7px; }
|
||||
.supernode-fields .wide { grid-column: auto; }
|
||||
.supernode-dhcp { display: flex !important; align-items: center; justify-content: space-between; }
|
||||
.special-server { margin: 0 8px; border-radius: 4px; }
|
||||
.special-server-toggle { min-height: 31px; padding: 0 7px; font-size: 11px; }
|
||||
.special-server-fields { grid-template-columns: 1fr; gap: 5px; padding: 0 7px 7px; }
|
||||
.special-server-fields label { grid-column: auto !important; padding-top: 5px; }
|
||||
.supernode-card .connect-button { width: calc(100% - 16px); min-height: 32px; margin: 6px 8px; font-size: 12px; }
|
||||
.special-users { border-radius: 5px; }
|
||||
.special-users header { min-height: 30px; padding: 0 8px; font-size: 11px; }
|
||||
.special-users > p { padding: 8px; font-size: 10px; }
|
||||
.special-users ol { max-height: 64px; }
|
||||
.special-users li { grid-template-columns: minmax(0, 1fr) 78px 60px; min-height: 26px; gap: 4px; padding: 4px 8px; font-size: 10px; }
|
||||
.special-users li span, .special-users li small { font-size: 9px; }
|
||||
|
||||
.log-content { padding: 8px 0 0; }
|
||||
.log-toolbar { min-height: 32px; padding: 0 4px; gap: 8px; }
|
||||
.log-toolbar > span { font-size: 11px; }
|
||||
@@ -276,3 +331,36 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
|
||||
.friends-window {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 0 0 10px;
|
||||
overflow: hidden;
|
||||
background: #eef5f5;
|
||||
color: #163238;
|
||||
}
|
||||
.friends-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 58px;
|
||||
padding: 0 16px;
|
||||
color: #f6ffff;
|
||||
background: #173f46;
|
||||
border-bottom: 3px solid #16a394;
|
||||
}
|
||||
.friends-header > div { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||||
.friends-header strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; letter-spacing: .01em; }
|
||||
.friends-count { display: grid; place-items: center; min-width: 25px; height: 25px; border-radius: 50%; color: #173f46; background: #a7eee2; font-size: 12px; font-weight: 800; }
|
||||
.friends-subtitle { padding: 11px 16px 7px; color: #698084; font-size: 11px; font-weight: 600; }
|
||||
.friends-list { display: grid; gap: 2px; margin: 0; padding: 0 8px; list-style: none; }
|
||||
.friends-list li { display: flex; align-items: center; gap: 10px; min-height: 53px; padding: 0 9px; border: 1px solid transparent; border-radius: 7px; background: rgba(255,255,255,.72); transition: background .15s ease, border-color .15s ease; }
|
||||
.friends-list li:hover { border-color: #a6d9d3; background: #fff; }
|
||||
.friend-avatar { display: grid; place-items: center; width: 30px; height: 30px; flex: 0 0 30px; border-radius: 8px; color: #fff; background: #0b8d82; font-size: 14px; font-weight: 800; }
|
||||
.friend-main { display: grid; min-width: 0; gap: 3px; }
|
||||
.friend-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.friend-main small { color: #6c8588; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; }
|
||||
.friend-badge { margin-left: auto; padding: 3px 6px; border-radius: 4px; color: #08786f; background: #d1f3ee; font-size: 9px; font-weight: 800; }
|
||||
.friends-empty { display: grid; justify-items: center; gap: 10px; padding: 60px 20px; color: #7c9295; text-align: center; font-size: 12px; }
|
||||
.friends-empty svg { color: #8fb2b2; }
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Users } from 'lucide-react'
|
||||
import { fetchSpecialUsers, isTauri, queryManagement, readConfig, serviceStatus } from './lib/tauri'
|
||||
import './styles.css'
|
||||
|
||||
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 <main className="friends-window">
|
||||
<header className="friends-header">
|
||||
<div><span className={'status-light ' + (running ? 'live' : '')} /><strong>Supernode 在线用户</strong></div>
|
||||
<span className="friends-count">{users.length}</span>
|
||||
</header>
|
||||
<div className="friends-subtitle">{running ? (specialEnabled ? `特殊服务端 :${specialPort}` : '当前连接的客户端') : 'Supernode 未运行'}</div>
|
||||
{users.length === 0
|
||||
? <div className="friends-empty"><Users size={30} /><span>{running ? '暂无在线用户' : '启动 Supernode 后显示在线用户'}</span></div>
|
||||
: <ul className="friends-list">{users.map((user) => <li key={user.username + user.ip}>
|
||||
<span className="friend-avatar">{(user.username || '?').slice(0, 1).toUpperCase()}</span>
|
||||
<span className="friend-main"><strong>{user.username}</strong><small>{user.ip}</small></span>
|
||||
{user.special && <span className="friend-badge">特殊</span>}
|
||||
</li>)}</ul>}
|
||||
</main>
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<SupernodeUsersWindow />)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#173f46" />
|
||||
<title>Supernode 在线用户</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/supernode-users.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,14 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: 'index.html',
|
||||
supernodeUsers: 'supernode-users.html',
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
strictPort: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user