Auto-select TAP adapters and persist edge settings

This commit is contained in:
2026-09-01 21:39:58 +08:00
parent 3694d157d1
commit 57e6ba8e1e
5 changed files with 187 additions and 14 deletions
+9
View File
@@ -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 `n2n/edge.exe` and `n2n/supernode.exe`; on Windows Super N2N starts them with
the relative paths `./n2n/edge.exe` and `./n2n/supernode.exe`. 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 Actions
`.gitea/workflows/build-windows.yml` runs when a Release is published and uploads `.gitea/workflows/build-windows.yml` runs when a Release is published and uploads
+5
View File
@@ -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 WebView2 is required. It is included by default on current Windows 10 and
Windows 11 installations. 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. The bundled n2n programs are distributed under n2n\LICENSE-n2n.txt.
+123 -1
View File
@@ -75,6 +75,20 @@ struct TapTraffic {
sent_bytes: u64, 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 { fn now_epoch() -> u64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@@ -183,6 +197,65 @@ fn windows_tap_traffic(tap_device: &str) -> Result<TapTraffic, String> {
traffic 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] #[tauri::command]
fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result<ServiceStatus, String> { 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())?; 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())); 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) .current_dir(&working_directory)
.args(&request.args) .args(&request.args)
.stdout(Stdio::null()) .stdout(Stdio::null())
@@ -217,6 +290,13 @@ fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result<Se
.spawn() .spawn()
.map_err(|e| format!("unable to start {}: {}", binary.display(), e))?; .map_err(|e| format!("unable to start {}: {}", binary.display(), e))?;
let pid = child.id(); 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); processes.insert(request.service.clone(), child);
Ok(ServiceStatus { 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()) 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] #[tauri::command]
fn firewall_status() -> Result<ToolResult, String> { fn firewall_status() -> Result<ToolResult, String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -497,6 +618,7 @@ pub fn run() {
run_ping, run_ping,
run_tcping, run_tcping,
tap_traffic, tap_traffic,
resolve_tap_adapter,
firewall_status, firewall_status,
export_log export_log
]) ])
+3
View File
@@ -36,4 +36,7 @@ export const checkFirewall = (fallback) => nativeCall('firewall_status', {}, fal
export const tapTraffic = (tapDevice) => nativeCall('tap_traffic', { request: { tapDevice } }, () => { export const tapTraffic = (tapDevice) => nativeCall('tap_traffic', { request: { tapDevice } }, () => {
throw new Error('浏览器预览无法读取 TAP 流量') 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) export const exportLog = (contents) => nativeCall('export_log', { contents }, () => null)
+47 -13
View File
@@ -4,7 +4,7 @@ import {
ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play, ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play,
Download, Router, ScrollText, Server, Settings2, Square, Wifi, Download, Router, ScrollText, Server, Settings2, Square, Wifi,
} from 'lucide-react' } 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' import './styles.css'
const initialEdge = { const initialEdge = {
@@ -15,13 +15,39 @@ const initialEdge = {
ip: '', ip: '',
encryptionKey: '', encryptionKey: '',
managementPort: '5644', 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() { function App() {
const [home, setHome] = useState('edge') const [home, setHome] = useState('edge')
const [edge, setEdge] = useState(initialEdge) const [edge, setEdge] = useState(loadEdgeConfig)
const [edgeRunning, setEdgeRunning] = useState(false) const [edgeRunning, setEdgeRunning] = useState(false)
const [activeTapDevice, setActiveTapDevice] = useState('')
const [advancedOpen, setAdvancedOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false)
const [logOpen, setLogOpen] = useState(false) const [logOpen, setLogOpen] = useState(false)
const [logs, setLogs] = useState([]) const [logs, setLogs] = useState([])
@@ -34,6 +60,10 @@ function App() {
setLogs((current) => [{ time, level, text }, ...current].slice(0, 100)) setLogs((current) => [{ time, level, text }, ...current].slice(0, 100))
} }
useEffect(() => {
saveEdgeConfig(edge)
}, [edge])
useEffect(() => { useEffect(() => {
let active = true let active = true
serviceStatus('edge') serviceStatus('edge')
@@ -47,7 +77,7 @@ function App() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!edgeRunning) { if (!edgeRunning || !activeTapDevice) {
lastTapSample.current = null lastTapSample.current = null
setTraffic(null) setTraffic(null)
return undefined return undefined
@@ -57,7 +87,7 @@ function App() {
let timer let timer
const poll = async () => { const poll = async () => {
try { try {
const sample = await tapTraffic(edge.tapDevice) const sample = await tapTraffic(activeTapDevice)
const sampledAt = Date.now() const sampledAt = Date.now()
const previous = lastTapSample.current const previous = lastTapSample.current
const elapsedSeconds = previous ? Math.max((sampledAt - previous.sampledAt) / 1000, .25) : 1 const elapsedSeconds = previous ? Math.max((sampledAt - previous.sampledAt) / 1000, .25) : 1
@@ -76,7 +106,7 @@ function App() {
active = false active = false
window.clearTimeout(timer) window.clearTimeout(timer)
} }
}, [edgeRunning, edge.tapDevice]) }, [activeTapDevice, edgeRunning])
const updateEdge = (field, value) => setEdge((current) => ({ ...current, [field]: value })) const updateEdge = (field, value) => setEdge((current) => ({ ...current, [field]: value }))
const notify = (text, tone = 'error') => { const notify = (text, tone = 'error') => {
@@ -89,6 +119,7 @@ function App() {
try { try {
const status = await stopService('edge') const status = await stopService('edge')
setEdgeRunning(Boolean(status.running)) setEdgeRunning(Boolean(status.running))
setActiveTapDevice('')
appendLog('已断开 N2N 网络') appendLog('已断开 N2N 网络')
notify('已断开 N2N 网络', 'neutral') notify('已断开 N2N 网络', 'neutral')
} catch (error) { } catch (error) {
@@ -115,12 +146,15 @@ function App() {
} }
try { try {
const tapAdapter = await resolveTapAdapter(edge.tapDevice)
appendLog('已选择 TAP 设备:' + tapAdapter.name)
const status = await startService({ const status = await startService({
service: 'edge', service: 'edge',
binaryPath: './n2n/edge.exe', binaryPath: './n2n/edge.exe',
args: buildEdgeArgs(edge), args: buildEdgeArgs(edge, tapAdapter.name),
}) })
setEdgeRunning(Boolean(status.running)) setEdgeRunning(Boolean(status.running))
setActiveTapDevice(tapAdapter.name)
appendLog('已连接到群组 ' + edge.community, 'success') appendLog('已连接到群组 ' + edge.community, 'success')
notify('已连接到 ' + edge.community, 'success') notify('已连接到 ' + edge.community, 'success')
} catch (error) { } catch (error) {
@@ -146,7 +180,7 @@ function App() {
</div> </div>
{home === 'edge' {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 />} : <SupernodeHome />}
</section> </section>
@@ -155,7 +189,7 @@ function App() {
</main> </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 = () => { const toggleAdvanced = () => {
setLogOpen(false) setLogOpen(false)
setAdvancedOpen((current) => !current) setAdvancedOpen((current) => !current)
@@ -203,7 +237,7 @@ function EdgeHome({ edge, updateEdge, running, traffic, advancedOpen, setAdvance
{running ? '断开连接' : '连接网络'} {running ? '断开连接' : '连接网络'}
</button> </button>
{running && <TrafficStrip traffic={traffic} tapDevice={edge.tapDevice} />} {running && <TrafficStrip traffic={traffic} tapDevice={activeTapDevice} />}
</section> </section>
<div className="secondary-actions"> <div className="secondary-actions">
@@ -214,7 +248,7 @@ function EdgeHome({ edge, updateEdge, running, traffic, advancedOpen, setAdvance
{advancedOpen && <div className="advanced-fields"> {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 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>管理端口</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>} </div>}
</section> </section>
@@ -323,12 +357,12 @@ function addOption(args, flag, value) {
if (String(value ?? '').trim()) args.push(flag, String(value).trim()) if (String(value ?? '').trim()) args.push(flag, String(value).trim())
} }
function buildEdgeArgs(config) { function buildEdgeArgs(config, tapDevice) {
const args = [] const args = []
addOption(args, '-c', config.community) addOption(args, '-c', config.community)
addOption(args, '-l', config.serverHost + ':' + (config.serverPort || '7777')) addOption(args, '-l', config.serverHost + ':' + (config.serverPort || '7777'))
addOption(args, '-a', config.dhcp ? 'dhcp:0.0.0.0' : config.ip) 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) addOption(args, '-t', config.managementPort)
if (config.encryptionKey) addOption(args, '-k', config.encryptionKey) if (config.encryptionKey) addOption(args, '-k', config.encryptionKey)
args.push('-f', '-v', '-v') args.push('-f', '-v', '-v')