Auto-select TAP adapters and persist edge settings
This commit is contained in:
@@ -49,6 +49,15 @@ The portable build creates `release/super-n2n-portable.zip`. It contains
|
||||
`n2n/edge.exe` and `n2n/supernode.exe`; on Windows Super N2N starts them with
|
||||
the relative paths `./n2n/edge.exe` and `./n2n/supernode.exe`.
|
||||
|
||||
### Windows TAP requirement
|
||||
|
||||
N2N uses an installed TAP-Windows virtual adapter; it does not create a new
|
||||
adapter itself. Super N2N automatically selects an available TAP-Windows
|
||||
adapter before starting Edge and reports a clear error when none is installed.
|
||||
The optional TAP device field is only needed to select a specific existing
|
||||
adapter. Connection settings, including advanced settings, are restored on the
|
||||
next application launch.
|
||||
|
||||
## Gitea Actions
|
||||
|
||||
`.gitea/workflows/build-windows.yml` runs when a Release is published and uploads
|
||||
|
||||
@@ -8,4 +8,9 @@ them as .\n2n\edge.exe and .\n2n\supernode.exe, so no PATH configuration is requ
|
||||
Windows WebView2 is required. It is included by default on current Windows 10 and
|
||||
Windows 11 installations.
|
||||
|
||||
N2N requires a TAP-Windows virtual network adapter installed on the computer.
|
||||
Super N2N automatically selects an available adapter when connecting. It does
|
||||
not install or create a TAP adapter itself; install the TAP-Windows driver once
|
||||
if the application reports that no TAP adapter is available.
|
||||
|
||||
The bundled n2n programs are distributed under n2n\LICENSE-n2n.txt.
|
||||
|
||||
+123
-1
@@ -75,6 +75,20 @@ struct TapTraffic {
|
||||
sent_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TapAdapterRequest {
|
||||
tap_device: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TapAdapter {
|
||||
name: String,
|
||||
description: String,
|
||||
guid: String,
|
||||
}
|
||||
|
||||
fn now_epoch() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -183,6 +197,65 @@ fn windows_tap_traffic(tap_device: &str) -> Result<TapTraffic, String> {
|
||||
traffic
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_tap_adapters() -> Result<Vec<TapAdapter>, String> {
|
||||
use std::{ptr, slice};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::NO_ERROR,
|
||||
NetworkManagement::IpHelper::{FreeMibTable, GetIfTable2, MIB_IF_TABLE2},
|
||||
};
|
||||
|
||||
fn wide_string(value: &[u16]) -> String {
|
||||
let end = value
|
||||
.iter()
|
||||
.position(|unit| *unit == 0)
|
||||
.unwrap_or(value.len());
|
||||
String::from_utf16_lossy(&value[..end])
|
||||
}
|
||||
|
||||
fn guid_string(guid: &windows_sys::core::GUID) -> String {
|
||||
format!(
|
||||
"{{{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}",
|
||||
guid.data1,
|
||||
guid.data2,
|
||||
guid.data3,
|
||||
guid.data4[0],
|
||||
guid.data4[1],
|
||||
guid.data4[2],
|
||||
guid.data4[3],
|
||||
guid.data4[4],
|
||||
guid.data4[5],
|
||||
guid.data4[6],
|
||||
guid.data4[7]
|
||||
)
|
||||
}
|
||||
|
||||
let mut table: *mut MIB_IF_TABLE2 = ptr::null_mut();
|
||||
let result = unsafe { GetIfTable2(&mut table) };
|
||||
if result != NO_ERROR || table.is_null() {
|
||||
return Err(format!("unable to read network adapter table: {result}"));
|
||||
}
|
||||
|
||||
let adapters = unsafe {
|
||||
let rows = slice::from_raw_parts((*table).Table.as_ptr(), (*table).NumEntries as usize);
|
||||
rows.iter()
|
||||
.filter_map(|row| {
|
||||
let description = wide_string(&row.Description);
|
||||
description
|
||||
.to_ascii_lowercase()
|
||||
.contains("tap-windows")
|
||||
.then_some(TapAdapter {
|
||||
name: wide_string(&row.Alias),
|
||||
description,
|
||||
guid: guid_string(&row.InterfaceGuid),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
unsafe { FreeMibTable(table.cast()) };
|
||||
Ok(adapters)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
|
||||
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
|
||||
@@ -209,7 +282,7 @@ fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result<Se
|
||||
return Err(format!("required n2n binary is missing: {}", binary.display()));
|
||||
}
|
||||
|
||||
let child = Command::new(relative_binary)
|
||||
let mut child = Command::new(relative_binary)
|
||||
.current_dir(&working_directory)
|
||||
.args(&request.args)
|
||||
.stdout(Stdio::null())
|
||||
@@ -217,6 +290,13 @@ fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result<Se
|
||||
.spawn()
|
||||
.map_err(|e| format!("unable to start {}: {}", binary.display(), e))?;
|
||||
let pid = child.id();
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
|
||||
return Err(format!(
|
||||
"{} exited immediately after startup with status {}",
|
||||
request.service, status
|
||||
));
|
||||
}
|
||||
processes.insert(request.service.clone(), child);
|
||||
|
||||
Ok(ServiceStatus {
|
||||
@@ -393,6 +473,47 @@ fn tap_traffic(request: TapTrafficRequest) -> Result<TapTraffic, String> {
|
||||
Err("TAP traffic monitoring is not supported on this operating system".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn resolve_tap_adapter(request: TapAdapterRequest) -> Result<TapAdapter, String> {
|
||||
let requested = request.tap_device.trim();
|
||||
if requested.contains(['/', '\\']) || requested == "." || requested == ".." {
|
||||
return Err("invalid TAP device name".to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let adapters = windows_tap_adapters()?;
|
||||
if adapters.is_empty() {
|
||||
return Err(
|
||||
"未找到 TAP-Windows 网卡。请先安装 TAP-Windows 驱动,然后重新连接。".to_string(),
|
||||
);
|
||||
}
|
||||
if requested.is_empty() {
|
||||
return Ok(adapters.into_iter().next().expect("non-empty adapter list"));
|
||||
}
|
||||
return adapters
|
||||
.into_iter()
|
||||
.find(|adapter| {
|
||||
adapter.name.eq_ignore_ascii_case(requested)
|
||||
|| adapter.description.eq_ignore_ascii_case(requested)
|
||||
|| adapter.guid.eq_ignore_ascii_case(requested)
|
||||
})
|
||||
.ok_or_else(|| format!("找不到指定的 TAP 设备:{requested}"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
if requested.is_empty() {
|
||||
return Err("请输入 TAP 设备名称".to_string());
|
||||
}
|
||||
Ok(TapAdapter {
|
||||
name: requested.to_string(),
|
||||
description: requested.to_string(),
|
||||
guid: String::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn firewall_status() -> Result<ToolResult, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -497,6 +618,7 @@ pub fn run() {
|
||||
run_ping,
|
||||
run_tcping,
|
||||
tap_traffic,
|
||||
resolve_tap_adapter,
|
||||
firewall_status,
|
||||
export_log
|
||||
])
|
||||
|
||||
@@ -36,4 +36,7 @@ export const checkFirewall = (fallback) => nativeCall('firewall_status', {}, fal
|
||||
export const tapTraffic = (tapDevice) => nativeCall('tap_traffic', { request: { tapDevice } }, () => {
|
||||
throw new Error('浏览器预览无法读取 TAP 流量')
|
||||
})
|
||||
export const resolveTapAdapter = (tapDevice) => nativeCall('resolve_tap_adapter', { request: { tapDevice } }, () => {
|
||||
throw new Error('浏览器预览无法识别 TAP 网卡')
|
||||
})
|
||||
export const exportLog = (contents) => nativeCall('export_log', { contents }, () => null)
|
||||
|
||||
+47
-13
@@ -4,7 +4,7 @@ import {
|
||||
ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play,
|
||||
Download, Router, ScrollText, Server, Settings2, Square, Wifi,
|
||||
} from 'lucide-react'
|
||||
import { exportLog, isTauri, serviceStatus, startService, stopService, tapTraffic } from './lib/tauri'
|
||||
import { exportLog, isTauri, resolveTapAdapter, serviceStatus, startService, stopService, tapTraffic } from './lib/tauri'
|
||||
import './styles.css'
|
||||
|
||||
const initialEdge = {
|
||||
@@ -15,13 +15,39 @@ const initialEdge = {
|
||||
ip: '',
|
||||
encryptionKey: '',
|
||||
managementPort: '5644',
|
||||
tapDevice: 'n2n0',
|
||||
tapDevice: '',
|
||||
}
|
||||
|
||||
const edgeConfigStorageKey = 'super-n2n.edge-config.v1'
|
||||
|
||||
function loadEdgeConfig() {
|
||||
const fallback = { ...initialEdge }
|
||||
if (typeof window === 'undefined') return fallback
|
||||
try {
|
||||
const saved = JSON.parse(window.localStorage.getItem(edgeConfigStorageKey) || 'null')
|
||||
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return fallback
|
||||
for (const [field, defaultValue] of Object.entries(initialEdge)) {
|
||||
if (typeof saved[field] === typeof defaultValue) fallback[field] = saved[field]
|
||||
}
|
||||
} catch {
|
||||
// A damaged or unavailable browser store must not block the connection form.
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function saveEdgeConfig(config) {
|
||||
try {
|
||||
window.localStorage.setItem(edgeConfigStorageKey, JSON.stringify(config))
|
||||
} catch {
|
||||
// The form stays usable when the platform denies local storage access.
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [home, setHome] = useState('edge')
|
||||
const [edge, setEdge] = useState(initialEdge)
|
||||
const [edge, setEdge] = useState(loadEdgeConfig)
|
||||
const [edgeRunning, setEdgeRunning] = useState(false)
|
||||
const [activeTapDevice, setActiveTapDevice] = useState('')
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [logOpen, setLogOpen] = useState(false)
|
||||
const [logs, setLogs] = useState([])
|
||||
@@ -34,6 +60,10 @@ function App() {
|
||||
setLogs((current) => [{ time, level, text }, ...current].slice(0, 100))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
saveEdgeConfig(edge)
|
||||
}, [edge])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
serviceStatus('edge')
|
||||
@@ -47,7 +77,7 @@ function App() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!edgeRunning) {
|
||||
if (!edgeRunning || !activeTapDevice) {
|
||||
lastTapSample.current = null
|
||||
setTraffic(null)
|
||||
return undefined
|
||||
@@ -57,7 +87,7 @@ function App() {
|
||||
let timer
|
||||
const poll = async () => {
|
||||
try {
|
||||
const sample = await tapTraffic(edge.tapDevice)
|
||||
const sample = await tapTraffic(activeTapDevice)
|
||||
const sampledAt = Date.now()
|
||||
const previous = lastTapSample.current
|
||||
const elapsedSeconds = previous ? Math.max((sampledAt - previous.sampledAt) / 1000, .25) : 1
|
||||
@@ -76,7 +106,7 @@ function App() {
|
||||
active = false
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [edgeRunning, edge.tapDevice])
|
||||
}, [activeTapDevice, edgeRunning])
|
||||
|
||||
const updateEdge = (field, value) => setEdge((current) => ({ ...current, [field]: value }))
|
||||
const notify = (text, tone = 'error') => {
|
||||
@@ -89,6 +119,7 @@ function App() {
|
||||
try {
|
||||
const status = await stopService('edge')
|
||||
setEdgeRunning(Boolean(status.running))
|
||||
setActiveTapDevice('')
|
||||
appendLog('已断开 N2N 网络')
|
||||
notify('已断开 N2N 网络', 'neutral')
|
||||
} catch (error) {
|
||||
@@ -115,12 +146,15 @@ function App() {
|
||||
}
|
||||
|
||||
try {
|
||||
const tapAdapter = await resolveTapAdapter(edge.tapDevice)
|
||||
appendLog('已选择 TAP 设备:' + tapAdapter.name)
|
||||
const status = await startService({
|
||||
service: 'edge',
|
||||
binaryPath: './n2n/edge.exe',
|
||||
args: buildEdgeArgs(edge),
|
||||
args: buildEdgeArgs(edge, tapAdapter.name),
|
||||
})
|
||||
setEdgeRunning(Boolean(status.running))
|
||||
setActiveTapDevice(tapAdapter.name)
|
||||
appendLog('已连接到群组 ' + edge.community, 'success')
|
||||
notify('已连接到 ' + edge.community, 'success')
|
||||
} catch (error) {
|
||||
@@ -146,7 +180,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
{home === 'edge'
|
||||
? <EdgeHome edge={edge} updateEdge={updateEdge} running={edgeRunning} traffic={traffic} advancedOpen={advancedOpen} setAdvancedOpen={setAdvancedOpen} logOpen={logOpen} setLogOpen={setLogOpen} logs={logs} onToggle={toggleEdge} onExportLog={() => exportLogs(logs, notify)} />
|
||||
? <EdgeHome edge={edge} updateEdge={updateEdge} running={edgeRunning} traffic={traffic} activeTapDevice={activeTapDevice} advancedOpen={advancedOpen} setAdvancedOpen={setAdvancedOpen} logOpen={logOpen} setLogOpen={setLogOpen} logs={logs} onToggle={toggleEdge} onExportLog={() => exportLogs(logs, notify)} />
|
||||
: <SupernodeHome />}
|
||||
</section>
|
||||
|
||||
@@ -155,7 +189,7 @@ function App() {
|
||||
</main>
|
||||
}
|
||||
|
||||
function EdgeHome({ edge, updateEdge, running, traffic, advancedOpen, setAdvancedOpen, logOpen, setLogOpen, logs, onToggle, onExportLog }) {
|
||||
function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, advancedOpen, setAdvancedOpen, logOpen, setLogOpen, logs, onToggle, onExportLog }) {
|
||||
const toggleAdvanced = () => {
|
||||
setLogOpen(false)
|
||||
setAdvancedOpen((current) => !current)
|
||||
@@ -203,7 +237,7 @@ function EdgeHome({ edge, updateEdge, running, traffic, advancedOpen, setAdvance
|
||||
{running ? '断开连接' : '连接网络'}
|
||||
</button>
|
||||
|
||||
{running && <TrafficStrip traffic={traffic} tapDevice={edge.tapDevice} />}
|
||||
{running && <TrafficStrip traffic={traffic} tapDevice={activeTapDevice} />}
|
||||
</section>
|
||||
|
||||
<div className="secondary-actions">
|
||||
@@ -214,7 +248,7 @@ function EdgeHome({ edge, updateEdge, running, traffic, advancedOpen, setAdvance
|
||||
{advancedOpen && <div className="advanced-fields">
|
||||
<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)} disabled={running} /></label>
|
||||
<label><span>TAP 设备(可选)</span><input value={edge.tapDevice} onChange={(event) => updateEdge('tapDevice', event.target.value)} placeholder="留空自动选择" disabled={running} /></label>
|
||||
</div>}
|
||||
</section>
|
||||
|
||||
@@ -323,12 +357,12 @@ function addOption(args, flag, value) {
|
||||
if (String(value ?? '').trim()) args.push(flag, String(value).trim())
|
||||
}
|
||||
|
||||
function buildEdgeArgs(config) {
|
||||
function buildEdgeArgs(config, tapDevice) {
|
||||
const args = []
|
||||
addOption(args, '-c', config.community)
|
||||
addOption(args, '-l', config.serverHost + ':' + (config.serverPort || '7777'))
|
||||
addOption(args, '-a', config.dhcp ? 'dhcp:0.0.0.0' : config.ip)
|
||||
addOption(args, '-d', config.tapDevice)
|
||||
addOption(args, '-d', tapDevice)
|
||||
addOption(args, '-t', config.managementPort)
|
||||
if (config.encryptionKey) addOption(args, '-k', config.encryptionKey)
|
||||
args.push('-f', '-v', '-v')
|
||||
|
||||
Reference in New Issue
Block a user