diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 45af68a..286c4a8 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -1,4 +1,5 @@ import org.gradle.api.tasks.Exec +import java.io.File import java.io.FileInputStream import java.util.Properties @@ -118,9 +119,42 @@ dependencies { val buildRust by tasks.registering(Exec::class) { group = "build" description = "Cross-compile plane-core (Rust) into jniLibs via cargo-ndk" - workingDir = rootProject.projectDir.parentFile // 仓库根目录(android-app 的上一级) + workingDir = File(rootProject.projectDir.parentFile, "plane-core") val profile = if (project.hasProperty("rustRelease")) "release" else "debug" - commandLine("bash", "scripts/build-rust.sh", profile) + + // 用 cargo 和 cargo-ndk 的全路径,绕过 MSYS2 bash 的 HOME/PATH 问题。 + // cargo 执行时本身会正确处理 Rust 工具链。 + val cargoHome = (System.getenv("CARGO_HOME") + ?: "${System.getProperty("user.home")}\\.cargo").replace("\\", "/") + val cargoExe = "$cargoHome/bin/cargo" + // 将 cargo/bin 加入 PATH(bash 的 MSYS2 转换会破坏 /mnt/c/... 路径, + // 但直接传给子进程的 PATH 条目若为 C:/... 格式,MSYS2 在 exec 时也会转换。 + // 因此改为直接用 cargo 跑 ndk 子命令,不再依赖 bash 脚本做路径发现。) + val abis = listOf("arm64-v8a", "armeabi-v7a", "x86_64") + val jniLibsDir = file("src/main/jniLibs") + val outDir = jniLibsDir.absolutePath.replace("\\", "/") + // 构建 cargo ndk 参数 + val args = mutableListOf(cargoExe, "ndk") + for (abi in abis) { args.add("-t"); args.add(abi) } + args.add("-o"); args.add(outDir) + args.add("build") + if (profile == "release") { args.add("--release") } + commandLine(args) + + // 传递 ANDROID_NDK_HOME + var ndkHome = System.getenv("ANDROID_NDK_HOME") + ?: System.getenv("ANDROID_SDK_ROOT") + ?: System.getenv("ANDROID_HOME") + if (ndkHome != null) { + val ndkDir = File(ndkHome, "ndk") + if (ndkDir.isDirectory) { + val versions = ndkDir.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.name } + if (!versions.isNullOrEmpty()) { + ndkHome = versions[0].absolutePath.replace("\\", "/") + } + } + environment("ANDROID_NDK_HOME", ndkHome) + } } tasks.named("preBuild") { diff --git a/desktop/src-tauri/resources/config-templates/proxy.yml b/desktop/src-tauri/resources/config-templates/proxy.yml new file mode 100644 index 0000000..f851505 --- /dev/null +++ b/desktop/src-tauri/resources/config-templates/proxy.yml @@ -0,0 +1,109 @@ +localPort: 1080 +remoteServers: + - host: 54.234.196.30 + port: 9090 + cipher: chacha20 + cipherKey: your-cipher-key + ssl: false +cluster: failover +loadBalance: roundrobin +timeoutMs: 8000 +connectionsPerNode: 1 +httpProxyEnabled: true +route: + defaultRoute: proxy + proxyList: + - "*.google.com" + - "*.google.com.hk" + - "*.googleapis.com" + - "*.gstatic.com" + - "*.googleusercontent.com" + - "*.googleadservices.com" + - "*.googlesyndication.com" + - "*.googletagmanager.com" + - "*.googletagservices.com" + - "*.google-analytics.com" + - "*.ggpht.com" + - "*.github.com" + - github.com + - "*.githubusercontent.com" + - "*.ghcr.io" + - "*.stackoverflow.com" + - "*.stackexchange.com" + - "*.wikipedia.org" + - "*.wikimedia.org" + - "*.youtube.com" + - "*.ytimg.com" + - "*.googlevideo.com" + - "*.twitter.com" + - twitter.com + - "*.x.com" + - x.com + - "*.twimg.com" + - "*.openai.com" + - "*.chatgpt.com" + - "*.anthropic.com" + - "*.claude.ai" + - "*.reddit.com" + - "*.medium.com" + - "*.docker.io" + - "*.docker.com" + - "*.npmjs.org" + - "*.npmjs.com" + - "*.cloudflare.com" + - "*.amazonaws.com" + directList: + - "*.meituan.com" + - "*.sankuai.com" + - "*.dianping.com" + - "*.neixin.cn" + - "*.baidu.com" + - "*.qq.com" + - "*.weixin.qq.com" + - "*.wechat.com" + - "*.aliyun.com" + - "*.aliyuncs.com" + - "*.taobao.com" + - "*.tmall.com" + - "*.jd.com" + - "*.bilibili.com" + - "*.bilivideo.com" + - "*.hdslb.com" + - "*.zhihu.com" + - "*.163.com" + - "*.126.com" + - "*.chaoxing.com" + - "*.vocabgo.com" + - "*.apple.com" + - "*.icloud.com" + - "*.cdn-apple.com" + - "*.aaplimg.com" + - "*.tencent.com" + - "*.myqcloud.com" + - "*.gtimg.cn" + - "*.idqqimg.com" + - "*.csdn.net" + - "*.douyin.com" + - "*.bytedance.com" + - "*.pstatp.com" + - "*.snssdk.com" + - "*.sina.com" + - "*.sina.com.cn" + - "*.sinaimg.cn" + - "*.weibo.com" + - "*.sogou.com" + - "*.sogo.com" + - "*.360.cn" + - "*.so.com" + - "*.cnblogs.com" + - "*.jianshu.com" + - "*.gitee.com" + - "*.coding.net" + - "*.dingtalk.com" + - "*.feishu.cn" + - "*.xuexitong.com" + - 127.0.0.1 + - localhost +systemProxy: + enabled: false + host: 127.0.0.1 diff --git a/desktop/src-tauri/resources/config-templates/tun.toml b/desktop/src-tauri/resources/config-templates/tun.toml new file mode 100644 index 0000000..cf0e357 --- /dev/null +++ b/desktop/src-tauri/resources/config-templates/tun.toml @@ -0,0 +1,114 @@ +# TUN Adapter 配置文件 + +[tun] +name = "utun9" +address = "198.18.0.1" +netmask = "255.254.0.0" +mtu = 1500 +enabled = true + +[fakeip] +range = "198.18.0.0/15" +capacity = 65536 + +[proxy] +socks5_addr = "127.0.0.1:1080" +health_check_interval = 5 +health_failure_threshold = 3 + +[routing] +default_action = "proxy" + +# --- 内网域名直连(最高优先级)--- +[[routing.rules]] +type = "domain_suffix" +value = "sankuai.com" +action = "direct" + +[[routing.rules]] +type = "domain_suffix" +value = "meituan.com" +action = "direct" + +[[routing.rules]] +type = "domain_suffix" +value = "sankuai.info" +action = "direct" + +[[routing.rules]] +type = "domain_suffix" +value = "neixin.cn" +action = "direct" + +[[routing.rules]] +type = "domain_suffix" +value = "dianping.com" +action = "direct" + +[[routing.rules]] +type = "domain_suffix" +value = "meituan.net" +action = "direct" + +# --- 通用直连规则 --- +[[routing.rules]] +type = "domain_suffix" +value = "cn" +action = "direct" + +[[routing.rules]] +type = "domain_suffix" +value = "local" +action = "direct" + +[[routing.rules]] +type = "domain_keyword" +value = "baidu" +action = "direct" + +[[routing.rules]] +type = "domain_keyword" +value = "aliyun" +action = "direct" + +[[routing.rules]] +type = "ip_cidr" +value = "10.0.0.0/8" +action = "direct" + +[[routing.rules]] +type = "ip_cidr" +value = "172.16.0.0/12" +action = "direct" + +[[routing.rules]] +type = "ip_cidr" +value = "192.168.0.0/16" +action = "direct" + +[[routing.rules]] +type = "ip_cidr" +value = "127.0.0.0/8" +action = "direct" + +[log] +level = "info,tun_adapter::socks5=debug" +format = "pretty" + +[bypass] +# proxy-remote 真实 IP,需要排除路由。 +# 必须与 proxy-local 的 proxy.yml 中 remoteServers[].host 保持一致, +# 否则 proxy-local 连接 proxy-remote 的流量会被 utun9 劫持成环路,导致全网不通。 +proxy_remote_ips = ["54.234.196.30"] +# 额外排除的网段(不进 TUN 的网段) +extra_cidrs = ["10.0.0.0/8", "11.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] +# DNS bypass:这些 DNS 服务器的流量不走 TUN(供 proxy-local 直连做真实 DNS 解析) +# 注意:内网 DNS (11.11.11.11/12) 已通过 extra_cidrs 中的 11.0.0.0/8 覆盖 +# 注意:必须和 start-tun.sh 的 DNS_SERVERS/DNS_FALLBACK 保持一致(114 主、223 兜底)。 +dns_bypass_ips = ["114.114.114.114", "223.5.5.5"] + +[intranet_dns] +# 内网 DNS 服务器(用于解析内网域名,流量走 bypass 不经过 TUN) +servers = ["11.11.11.11", "11.11.11.12"] +# 需要走内网 DNS 解析的域名后缀(会在 /etc/resolver/ 下创建分流规则) +domains = ["sankuai.com", "meituan.com", "sankuai.info", "neixin.cn", "dianping.com", "meituan.net"] diff --git a/desktop/src-tauri/resources/jre/.gitkeep b/desktop/src-tauri/resources/jre/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/desktop/src-tauri/resources/tun-adapter b/desktop/src-tauri/resources/tun-adapter new file mode 100755 index 0000000..f87174f Binary files /dev/null and b/desktop/src-tauri/resources/tun-adapter differ diff --git a/desktop/src-tauri/resources/web/app.js b/desktop/src-tauri/resources/web/app.js new file mode 100644 index 0000000..4091803 --- /dev/null +++ b/desktop/src-tauri/resources/web/app.js @@ -0,0 +1,723 @@ +/** + * SimplePlane Desktop — Full Frontend Application + * 通过 Tauri IPC (window.__TAURI__.core.invoke) 与 Rust 后端通信 + */ +const App = (function () { + 'use strict'; + + const { invoke } = window.__TAURI__.core; + + let localConfig = null; + let originalConfigJson = ''; + let hasChanges = false; + let currentSection = 'dashboard'; + let currentLogService = 'proxy-local'; + let statusInterval = null; + let logs = { 'proxy-local': [], 'tun-adapter': [], 'system': [] }; + let presets = []; + + const $ = (sel, ctx = document) => ctx.querySelector(sel); + const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)]; + + // ============================================================ + // Init + // ============================================================ + async function init() { + bindNavigation(); + bindActions(); + bindGlobalKeys(); + await loadConfig(); + await loadTunConfig(); + await loadPresets(); + startStatusPolling(); + addLog('info', '应用已启动,等待连接...'); + } + + // ============================================================ + // Navigation + // ============================================================ + function bindNavigation() { + $$('.nav-item').forEach(item => { + item.addEventListener('click', () => { + const section = item.dataset.section; + switchSection(section); + }); + }); + } + + function switchSection(name) { + currentSection = name; + $$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.section === name)); + $$('.section').forEach(s => { + s.classList.toggle('active', s.dataset.section === name); + }); + // 切换到日志页时自动加载 + if (name === 'logs') refreshLogView(); + } + + // ============================================================ + // Status Polling + // ============================================================ + function startStatusPolling() { + pollStatus(); + statusInterval = setInterval(pollStatus, 3000); + } + + async function pollStatus() { + try { + const st = await invoke('status'); + updateServiceUI('proxy-local', st.proxy_status, st.proxy_port_listening); + updateServiceUI('tun-adapter', st.tun_status, false); + $('#info-proxy-port').textContent = st.proxy_port; + $('#info-http-port').textContent = st.http_port; + $('#info-mode').textContent = st.proxy_mode === 'tun' ? 'TUN 模式' : '系统代理'; + $('#info-port-status').textContent = st.proxy_port_listening ? '监听中' : '未监听'; + + // 系统代理状态 + const proxyRunning = st.proxy_status === 'running'; + $('#systemProxyToggle').checked = proxyRunning && st.proxy_mode === 'system'; + $('#systemProxyStatus').textContent = proxyRunning ? '已开启' : '已关闭'; + } catch (e) { + // 静默 + } + } + + function updateServiceUI(name, status, portListening) { + const statusEl = $(`#status-${name}`); + if (!statusEl) return; + const indicator = $('.status-indicator', statusEl); + const label = $('.status-label', statusEl); + const card = $(`#card-${name}`); + + indicator.className = 'status-indicator ' + (status === 'running' ? 'running' : status === 'starting' ? 'starting' : 'stopped'); + const labels = { running: '运行中', starting: '启动中', stopped: '已停止', stopping: '停止中', error: '异常' }; + label.textContent = labels[status] || status; + card.className = 'service-card ' + (status === 'running' ? 'card-running' : status === 'starting' ? 'card-starting' : status === 'error' ? 'card-error' : ''); + } + + // ============================================================ + // Service Control + // ============================================================ + async function startService(name) { + toast(`正在启动 ${name}...`, 'info'); + addLog('info', `启动 ${name}...`); + try { + if (name === 'proxy-local') { + await invoke('connect', { mode: 'system' }); + toast(`${name} 已启动`, 'success'); + addLog('success', `${name} 已启动`); + } else if (name === 'tun-adapter') { + const result = await invoke('connect', { mode: 'tun' }); + if (result && result !== 'connected') { + toast(result, 'warning'); + addLog('warning', result); + } else { + toast(`${name} 已启动`, 'success'); + addLog('success', `${name} 已启动`); + } + } + } catch (err) { + toast(`启动失败: ${err}`, 'error'); + addLog('error', `${name} 启动失败: ${err}`); + } + await pollStatus(); + } + + async function stopService(name) { + toast(`正在停止 ${name}...`, 'info'); + addLog('info', `停止 ${name}...`); + try { + await invoke('disconnect'); + toast(`${name} 已停止`, 'success'); + addLog('info', `${name} 已停止`); + } catch (err) { + toast(`停止失败: ${err}`, 'error'); + addLog('error', `${name} 停止失败: ${err}`); + } + setTimeout(pollStatus, 1000); + } + + async function restartService(name) { + toast(`正在重启 ${name}...`, 'info'); + addLog('info', `重启 ${name}...`); + try { + await invoke('disconnect'); + await sleep(1000); + await invoke('connect', { mode: 'system' }); + toast(`${name} 已重启`, 'success'); + addLog('success', `${name} 已重启`); + } catch (err) { + toast(`重启失败: ${err}`, 'error'); + addLog('error', `${name} 重启失败: ${err}`); + } + setTimeout(pollStatus, 2000); + } + + // ============================================================ + // Quick Actions + // ============================================================ + async function quickStartProxy() { + toast('一键代理模式: 启动 proxy-local + 开启系统代理...', 'info'); + addLog('info', '一键代理模式启动...'); + try { + await invoke('connect', { mode: 'system' }); + toast('代理模式已激活', 'success'); + addLog('success', '一键代理模式已激活'); + } catch (err) { + toast(`启动失败: ${err}`, 'error'); + addLog('error', `一键代理失败: ${err}`); + } + await pollStatus(); + } + + async function quickStartTun() { + toast('一键 TUN 模式: 启动 proxy-local + tun-adapter...', 'info'); + addLog('info', '一键 TUN 模式启动...'); + try { + const result = await invoke('connect', { mode: 'tun' }); + if (result && result !== 'connected') { + toast(result, 'warning'); + addLog('warning', result); + } else { + toast('TUN 模式已激活', 'success'); + addLog('success', 'TUN 模式已激活'); + } + } catch (err) { + toast(`TUN 启动失败: ${err}`, 'error'); + addLog('error', `一键 TUN 失败: ${err}`); + } + await pollStatus(); + } + + async function quickStopAll() { + toast('停止所有服务...', 'info'); + addLog('info', '停止所有服务...'); + try { + await invoke('disconnect'); + toast('所有服务已停止', 'success'); + addLog('info', '所有服务已停止'); + } catch (err) { + toast(`停止失败: ${err}`, 'error'); + } + await sleep(1000); + await pollStatus(); + } + + async function resetNetwork() { + if (!confirm('确认恢复网络?将强制还原 DNS/路由并关闭系统代理。')) return; + toast('正在恢复网络...', 'info'); + addLog('info', '恢复网络...'); + try { + await invoke('reset_network'); + toast('网络已恢复', 'success'); + addLog('success', '网络已恢复'); + } catch (err) { + toast(`恢复失败: ${err}`, 'error'); + addLog('error', `网络恢复失败: ${err}`); + } + await pollStatus(); + } + + async function toggleSystemProxy(enabled) { + try { + if (enabled) { + await invoke('connect', { mode: 'system' }); + toast('系统代理已开启', 'success'); + } else { + await invoke('disconnect'); + toast('系统代理已关闭', 'success'); + } + } catch (err) { + toast(`操作失败: ${err}`, 'error'); + $('#systemProxyToggle').checked = !enabled; + } + await pollStatus(); + } + + // ============================================================ + // Config + // ============================================================ + async function loadConfig() { + try { + const config = await invoke('get_config'); + localConfig = config; + originalConfigJson = JSON.stringify(config); + populateConfigForm(config); + } catch (e) { + addLog('warning', `加载配置失败: ${e}`); + } + } + + function populateConfigForm(c) { + if (!c) return; + setVal('#localPort', c.local.port); + $('#httpProxyEnabled').checked = c.local.http_proxy_enabled; + + // 远端服务器 + renderServerCards([c.remote]); + + // 路由 + if (c.route) { + setVal('#defaultRoute', c.route.default_route); + $('#proxyList').value = (c.route.proxy_list || []).join('\n'); + $('#directList').value = (c.route.direct_list || []).join('\n'); + updateRouteCounts(); + } + } + + function collectConfig() { + if (!localConfig) localConfig = {}; + localConfig.local = { + port: intVal('#localPort', 1080), + http_proxy_enabled: $('#httpProxyEnabled').checked, + http_proxy_port: intVal('#localPort', 1080) + 7, + }; + + // 收集服务器卡片 + const cards = $$('.server-card', $('#remoteServers')); + if (cards.length > 0) { + const card = cards[0]; + localConfig.remote = { + host: val($('[data-key="host"]', card)), + port: parseInt(val($('[data-key="port"]', card))) || 9090, + cipher: val($('[data-key="cipher"]', card)), + key: val($('[data-key="key"]', card)), + }; + } + + localConfig.route = { + default_route: val('#defaultRoute'), + proxy_list: parseLines('#proxyList'), + direct_list: parseLines('#directList'), + }; + + return localConfig; + } + + async function saveAll() { + const configData = collectConfig(); + try { + await invoke('save_config', { configData }); + originalConfigJson = JSON.stringify(configData); + updateChangeState(); + toast('配置已保存', 'success'); + addLog('success', '配置已保存'); + } catch (e) { + toast(`保存失败: ${e}`, 'error'); + addLog('error', `保存配置失败: ${e}`); + } + } + + // ============================================================ + // Server Cards + // ============================================================ + function renderServerCards(servers) { + const container = $('#remoteServers'); + container.innerHTML = ''; + (servers || []).forEach((srv, i) => container.appendChild(createServerCard(srv, i))); + } + + function createServerCard(srv, index) { + const card = document.createElement('div'); + card.className = 'server-card'; + card.innerHTML = ` +
+ #${index + 1} ${esc(srv.host || '?')}:${srv.port || '?'} + +
+
+
+
+
+ +
+
+
`; + $$('input, select', card).forEach(el => { + el.addEventListener('input', markChanged); + el.addEventListener('change', markChanged); + }); + $('.btn-remove', card).addEventListener('click', () => { card.remove(); markChanged(); }); + return card; + } + + // ============================================================ + // TUN Config (Raw TOML Editor) + // ============================================================ + async function loadTunConfig() { + try { + const text = await invoke('get_tun_config_raw'); + $('#tunConfigEditor').value = text; + } catch (e) { + // 降级:从结构化接口生成文本 + try { + const config = await invoke('get_tun_config'); + $('#tunConfigEditor').value = tunConfigToToml(config); + } catch (e2) { + $('#tunConfigEditor').value = '# 加载 TUN 配置失败: ' + e2; + } + } + } + + function tunConfigToToml(c) { + let t = '# TUN Adapter 配置\n\n[tun]\n'; + t += `name = "${c.tun.name}"\n`; + t += `address = "${c.tun.address}"\n`; + if (c.tun.netmask) t += `netmask = "${c.tun.netmask}"\n`; + t += `mtu = ${c.tun.mtu}\n`; + t += `enabled = ${c.tun.enabled}\n\n`; + t += '[dns]\n'; + t += `listen = "${c.dns.listen}"\n`; + t += `upstream = "${c.dns.upstream}"\n\n`; + t += '[proxy]\n'; + if (c.proxy.socks5) t += `socks5 = "${c.proxy.socks5}"\n`; + if (c.proxy.socks5_addr) t += `socks5_addr = "${c.proxy.socks5_addr}"\n`; + if (c.routing) { + t += '\n[routing]\n'; + t += `default_action = "${c.routing.default_action}"\n`; + if (c.routing.rules && c.routing.rules.length > 0) { + t += '\n'; + c.routing.rules.forEach(r => { + t += `[[routing.rules]]\ntype = "${r.rule_type || r.type}"\nvalue = "${r.value}"\naction = "${r.action}"\n\n`; + }); + } + } + if (c.bypass) { + t += '[bypass]\n'; + if (c.bypass.proxy_remote_ips && c.bypass.proxy_remote_ips.length > 0) { + t += `proxy_remote_ips = [${c.bypass.proxy_remote_ips.map(s => `"${s}"`).join(', ')}]\n`; + } + if (c.bypass.extra_cidrs && c.bypass.extra_cidrs.length > 0) { + t += `extra_cidrs = [${c.bypass.extra_cidrs.map(s => `"${s}"`).join(', ')}]\n`; + } + if (c.bypass.dns_bypass_ips && c.bypass.dns_bypass_ips.length > 0) { + t += `dns_bypass_ips = [${c.bypass.dns_bypass_ips.map(s => `"${s}"`).join(', ')}]\n`; + } + } + return t; + } + + async function saveTunConfig() { + const content = $('#tunConfigEditor').value; + try { + await invoke('save_tun_config_raw', { content }); + toast('TUN 配置已保存', 'success'); + addLog('success', 'TUN 配置已保存'); + } catch (e) { + toast(`保存失败: ${e}`, 'error'); + addLog('error', `TUN 配置保存失败: ${e}`); + } + } + + // ============================================================ + // Presets (Backend-managed) + // ============================================================ + async function loadPresets() { + try { + presets = await invoke('get_presets'); + renderPresets(); + } catch (e) { + // 降级到本地存储 + try { + const stored = localStorage.getItem('simpleplane-presets'); + if (stored) presets = JSON.parse(stored); + } catch {} + renderPresets(); + } + } + + function renderPresets() { + const container = $('#presetList'); + if (!container) return; + if (presets.length === 0) { + container.innerHTML = '
暂无预设,点击上方按钮保存当前配置
'; + return; + } + container.innerHTML = ''; + presets.forEach((p, i) => { + const el = document.createElement('div'); + el.className = 'preset-item'; + const cfg = p.config || p; + const remote = cfg.remote || {}; + el.innerHTML = ` +
+ ${esc(p.name)} + ${esc(p.description || '')} + ${remote.host || ''}:${remote.port || ''} | ${remote.cipher || 'chacha20'} +
+
+ + +
`; + container.appendChild(el); + }); + // 绑定按钮事件 + $$('[data-action="apply"]', container).forEach(btn => { + btn.addEventListener('click', () => applyPreset(parseInt(btn.dataset.index))); + }); + $$('[data-action="delete"]', container).forEach(btn => { + btn.addEventListener('click', () => deletePreset(parseInt(btn.dataset.index))); + }); + } + + async function savePreset() { + const name = prompt('预设名称:', `预设 ${presets.length + 1}`); + if (!name) return; + const description = prompt('预设描述 (可选):', '') || ''; + const config = collectConfig(); + const preset = { name, description, config }; + try { + await invoke('save_preset', { preset }); + toast(`预设 "${name}" 已保存`, 'success'); + addLog('success', `预设已保存: ${name}`); + await loadPresets(); + } catch (e) { + // 降级到本地存储 + presets.push(preset); + localStorage.setItem('simpleplane-presets', JSON.stringify(presets)); + renderPresets(); + toast(`预设 "${name}" 已保存(本地)`, 'success'); + } + } + + async function applyPreset(index) { + const p = presets[index]; + if (!p) return; + try { + await invoke('apply_preset', { name: p.name }); + await loadConfig(); + toast(`已加载预设 "${p.name}"`, 'success'); + addLog('info', `已加载预设: ${p.name}`); + } catch (e) { + // 降级到直接应用 + const cfg = p.config || p; + localConfig = { local: cfg.local, remote: cfg.remote, route: cfg.route }; + populateConfigForm(localConfig); + markChanged(); + toast(`已加载预设 "${p.name}" (本地)`, 'success'); + } + } + + async function deletePreset(index) { + const p = presets[index]; + if (!p) return; + if (!confirm(`删除预设 "${p.name}"?`)) return; + try { + await invoke('delete_preset', { name: p.name }); + toast('预设已删除', 'success'); + await loadPresets(); + } catch (e) { + presets.splice(index, 1); + localStorage.setItem('simpleplane-presets', JSON.stringify(presets)); + renderPresets(); + toast('预设已删除 (本地)', 'success'); + } + } + + // ============================================================ + // Route + // ============================================================ + function updateRouteCounts() { + const p = parseLines('#proxyList').length; + const d = parseLines('#directList').length; + const pc = $('#proxyListCount'); + const dc = $('#directListCount'); + if (pc) pc.textContent = `${p} 条`; + if (dc) dc.textContent = `${d} 条`; + } + + // ============================================================ + // Logs (实时日志流 —— 通过后端环形缓冲区增量拉取) + // ============================================================ + let lastLogTimestamp = 0; + let logPollInterval = null; + + function addLog(level, message) { + const entry = { timestamp: Date.now(), level, message, service: 'system' }; + appendLogLine(entry); + } + + function appendLogLine(entry) { + const container = $('#logContent'); + if (!container) return; + const line = document.createElement('div'); + line.className = 'log-line'; + const time = new Date(entry.timestamp).toLocaleTimeString(); + const levelClass = entry.level === 'error' ? 'log-error' : entry.level === 'warning' ? 'log-warning' : entry.level === 'success' ? 'log-success' : 'log-info'; + line.innerHTML = `${time}[${entry.service || 'system'}]${esc(entry.message)}`; + container.appendChild(line); + autoScrollLog(); + + // 限制显示条数 + while (container.children.length > 2000) container.removeChild(container.firstChild); + } + + function autoScrollLog() { + if ($('#logAutoScroll') && $('#logAutoScroll').checked) { + const term = $('#logTerminal'); + if (term) term.scrollTop = term.scrollHeight; + } + } + + function switchLogService(service) { + currentLogService = service; + refreshLogView(); + } + + async function refreshLogView() { + const container = $('#logContent'); + if (!container) return; + container.innerHTML = ''; + lastLogTimestamp = 0; + + try { + const svc = currentLogService === 'all' ? null : currentLogService; + const entries = await invoke('get_logs', { service: svc, count: 500 }); + entries.forEach(e => appendLogLine(e)); + if (entries.length > 0) { + lastLogTimestamp = entries[entries.length - 1].timestamp; + } + } catch (e) { + // 静默 + } + + // 启动增量轮询 + startLogPolling(); + } + + function startLogPolling() { + if (logPollInterval) clearInterval(logPollInterval); + logPollInterval = setInterval(pollNewLogs, 1000); + } + + async function pollNewLogs() { + if (currentSection !== 'logs') return; + try { + const svc = currentLogService === 'all' ? null : currentLogService; + const entries = await invoke('get_logs_stream', { since: lastLogTimestamp, service: svc }); + if (entries && entries.length > 0) { + entries.forEach(e => appendLogLine(e)); + lastLogTimestamp = entries[entries.length - 1].timestamp; + } + } catch (e) { + // 静默 + } + } + + async function clearLogs() { + try { + const svc = currentLogService === 'all' ? null : currentLogService; + await invoke('clear_logs', { service: svc }); + } catch (e) {} + const container = $('#logContent'); + if (container) container.innerHTML = ''; + lastLogTimestamp = 0; + addLog('info', '日志已清空'); + } + + async function runDiagnose() { + try { + const results = await invoke('diagnose_tun'); + const container = $('#logContent'); + if (container) { + results.forEach(line => { + appendLogLine({ timestamp: Date.now(), level: 'info', service: 'diagnose', message: line }); + }); + } + toast('诊断完成,结果见日志面板', 'info'); + } catch (e) { + toast(`诊断失败: ${e}`, 'error'); + } + } + + // ============================================================ + // Change Detection + // ============================================================ + function markChanged() { + collectConfig(); + updateChangeState(); + } + + function updateChangeState() { + hasChanges = JSON.stringify(localConfig) !== originalConfigJson; + const btn = $('#btnSave'); + const ind = $('#changeIndicator'); + if (btn) btn.disabled = !hasChanges; + if (ind) ind.hidden = !hasChanges; + } + + // ============================================================ + // Bindings + // ============================================================ + function bindActions() { + $('#btnSave')?.addEventListener('click', saveAll); + $('#btnAddServer')?.addEventListener('click', () => { + const idx = $$('.server-card', $('#remoteServers')).length; + $('#remoteServers').appendChild(createServerCard({ host: '', port: 9090, cipher: 'chacha20', key: '' }, idx)); + markChanged(); + }); + + ['#proxyList', '#directList'].forEach(sel => { + $(sel)?.addEventListener('input', () => { updateRouteCounts(); markChanged(); }); + }); + + // 全局表单变更监测 + document.addEventListener('input', (e) => { + if (e.target.closest('[data-section="proxy-config"]') || e.target.closest('[data-section="route"]')) markChanged(); + }); + document.addEventListener('change', (e) => { + if (e.target.closest('[data-section="proxy-config"]') || e.target.closest('[data-section="route"]')) markChanged(); + }); + } + + function bindGlobalKeys() { + document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + if (hasChanges) saveAll(); + } + }); + } + + // ============================================================ + // Toast + // ============================================================ + function toast(message, type = 'info') { + const container = $('#toastContainer'); + if (!container) return; + const el = document.createElement('div'); + el.className = `toast ${type}`; + el.textContent = message; + container.appendChild(el); + setTimeout(() => { el.classList.add('leaving'); setTimeout(() => el.remove(), 200); }, 4000); + } + + // ============================================================ + // Helpers + // ============================================================ + function val(sel) { const el = typeof sel === 'string' ? $(sel) : sel; return el ? el.value : ''; } + function intVal(sel, fb) { const v = parseInt(val(sel), 10); return isNaN(v) ? fb : v; } + function setVal(sel, v) { const el = $(sel); if (el) el.value = v ?? ''; } + function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } + function escAttr(s) { return String(s).replace(/"/g, '"'); } + function parseLines(sel) { return val(sel).split('\n').map(s => s.trim()).filter(s => s && !s.startsWith('#')); } + function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + + // ============================================================ + // Boot + // ============================================================ + document.addEventListener('DOMContentLoaded', init); + + // Public API + return { + startService, stopService, restartService, + quickStartProxy, quickStartTun, quickStopAll, resetNetwork, toggleSystemProxy, + saveTunConfig, switchLogService, clearLogs, runDiagnose, + savePreset, applyPreset, deletePreset, + }; +})(); diff --git a/desktop/src-tauri/resources/web/index.html b/desktop/src-tauri/resources/web/index.html new file mode 100644 index 0000000..c4ba665 --- /dev/null +++ b/desktop/src-tauri/resources/web/index.html @@ -0,0 +1,328 @@ + + + + + + SimplePlane + + + +
+ + + + +
+ +
+
+

控制台

+
+ + Ready +
+
+ + +
+
+
+
+ + + + Proxy Local +
+
+ + 已停止 +
+
+
+ +
+ + + +
+
+
+ +
+
+
+ + + + TUN Adapter +
+
+ + 已停止 +
+
+
+ +
+ + +
+
+
+
+ + +
+

快捷操作

+
+ + + + +
+
+ + +
+
+ 系统代理 + 已关闭 +
+ +
+ + +
+
+ SOCKS5 端口 + 1080 +
+
+ HTTP 端口 + 1087 +
+
+ 代理模式 + 系统代理 +
+
+ 端口状态 + -- +
+
+
+ + +
+
+

代理配置

+
+ + +
+
+ +
+
+

本地设置

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

远端服务器

+ +
+
+
+
+
+ + +
+
+

TUN 模式配置

+ +
+

+ macOS: 首次使用 TUN 需要管理员授权。 +

+
+ +
+
+ + +
+
+

路由规则

+
+
+ + +
+
+
+
+

代理域名

+ 0 条 +
+ +
+
+
+

直连域名

+ 0 条 +
+ +
+
+
+ + +
+
+

配置预设

+ +
+

保存和加载不同的代理配置方案,方便在多个服务器/场景间快速切换。

+
+
暂无预设,点击上方按钮保存当前配置
+
+
+ + +
+
+

运行日志

+
+ + + +
+
+
+
+
--:--:--SimplePlane Desktop v0.1.0 就绪
+
+
+
+
+
+ + +
+ + + + diff --git a/desktop/src-tauri/resources/web/style.css b/desktop/src-tauri/resources/web/style.css new file mode 100644 index 0000000..c7012aa --- /dev/null +++ b/desktop/src-tauri/resources/web/style.css @@ -0,0 +1,609 @@ +/* SimplePlane Desktop — Full Dark Theme */ +:root { + --bg-primary: #0f1117; + --bg-secondary: #1a1d27; + --bg-card: #21242f; + --bg-hover: #282c3a; + --bg-input: #161922; + --text-primary: #e8eaed; + --text-secondary: #9aa0a6; + --text-muted: #6b7280; + --accent: #4f8cff; + --accent-hover: #3b7af0; + --accent-dim: rgba(79, 140, 255, 0.15); + --success: #34d399; + --success-dim: rgba(52, 211, 153, 0.15); + --warning: #fbbf24; + --warning-dim: rgba(251, 191, 36, 0.15); + --error: #f87171; + --error-dim: rgba(248, 113, 113, 0.15); + --border: #2d3140; + --border-light: #363b4d; + --radius: 10px; + --radius-sm: 6px; + --sidebar-width: 200px; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + height: 100vh; + overflow: hidden; + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +.app { display: flex; height: 100vh; } + +/* ============================================================ + Sidebar + ============================================================ */ +.sidebar { + width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 16px 0; + -webkit-app-region: drag; + flex-shrink: 0; +} + +.logo { + display: flex; + align-items: center; + gap: 10px; + padding: 0 16px 20px; + color: var(--accent); + font-weight: 600; + font-size: 15px; +} + +.nav-list { + list-style: none; + flex: 1; + -webkit-app-region: no-drag; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + margin: 2px 8px; + border-radius: 7px; + cursor: pointer; + color: var(--text-secondary); + font-size: 13px; + transition: all 0.12s ease; +} + +.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); } +.nav-item.active { background: var(--accent); color: white; } + +.nav-footer { + padding: 10px 16px; + color: var(--text-muted); + font-size: 11px; + -webkit-app-region: no-drag; +} + +/* ============================================================ + Content + ============================================================ */ +.content { + flex: 1; + overflow-y: auto; + padding: 28px 32px; +} + +.section { display: none; } +.section.active { display: block; } + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.section-header h2 { font-size: 20px; font-weight: 600; } + +.section-desc { + color: var(--text-secondary); + font-size: 13px; + margin-bottom: 16px; + line-height: 1.5; +} + +.header-actions { display: flex; align-items: center; gap: 12px; } + +.change-indicator { + font-size: 12px; + color: var(--warning); + background: var(--warning-dim); + padding: 3px 8px; + border-radius: 4px; +} + +.status-badge { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); +} + +.status-badge .status-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--success); +} + +.status-badge.disconnected .status-dot { background: var(--error); } + +/* ============================================================ + Service Cards + ============================================================ */ +.service-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 14px; + margin-bottom: 24px; +} + +.service-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 20px; + transition: border-color 0.2s; +} + +.service-card.card-running { border-color: var(--success); } +.service-card.card-starting { border-color: var(--warning); } +.service-card.card-error { border-color: var(--error); } + +.service-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.service-name { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + font-size: 14px; +} + +.service-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; +} + +.status-indicator { + width: 10px; height: 10px; + border-radius: 50%; + background: var(--text-muted); +} + +.status-indicator.running { background: var(--success); box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); } +.status-indicator.starting { background: var(--warning); animation: pulse 1.5s infinite; } +.status-indicator.stopped { background: var(--text-muted); } + +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } + +.service-card-body { + display: flex; + justify-content: space-between; + align-items: center; +} + +.service-uptime { font-size: 12px; color: var(--text-muted); } + +.service-actions { display: flex; gap: 6px; } + +/* ============================================================ + Quick Actions + ============================================================ */ +.quick-actions { margin-bottom: 24px; } +.quick-actions h3 { font-size: 14px; font-weight: 500; margin-bottom: 12px; color: var(--text-secondary); } + +.action-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.btn-action { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.12s ease; +} + +.btn-action:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--border-light); } +.btn-action.action-proxy:hover { border-color: var(--accent); color: var(--accent); } +.btn-action.action-tun:hover { border-color: var(--success); color: var(--success); } +.btn-action.action-stop:hover { border-color: var(--warning); color: var(--warning); } +.btn-action.action-reset:hover { border-color: var(--error); color: var(--error); } + +/* ============================================================ + Proxy Toggle + ============================================================ */ +.proxy-toggle-section { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 18px; + margin-bottom: 20px; +} + +.proxy-toggle-info { display: flex; align-items: center; gap: 12px; font-size: 14px; } +.proxy-toggle-status { font-size: 12px; color: var(--text-muted); } + +/* Toggle Switch */ +.toggle { position: relative; display: inline-flex; cursor: pointer; } +.toggle input { position: absolute; opacity: 0; width: 0; height: 0; } +.toggle-track { + width: 40px; height: 22px; + background: var(--border); + border-radius: 11px; + position: relative; + transition: background 0.2s; +} +.toggle input:checked + .toggle-track { background: var(--accent); } +.toggle-thumb { + position: absolute; + top: 2px; left: 2px; + width: 18px; height: 18px; + background: white; + border-radius: 50%; + transition: transform 0.2s; +} +.toggle input:checked + .toggle-track .toggle-thumb { transform: translateX(18px); } + +/* ============================================================ + Info Grid + ============================================================ */ +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; +} + +.info-item { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.info-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.info-value { font-size: 15px; font-weight: 500; font-family: 'SF Mono', 'JetBrains Mono', monospace; } + +/* ============================================================ + Config Form + ============================================================ */ +.config-form { display: flex; flex-direction: column; gap: 24px; } + +.form-group h3 { + font-size: 14px; + font-weight: 500; + margin-bottom: 14px; + color: var(--text-primary); + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.form-group-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.form-group-header h3 { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } + +.form-row { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 10px; +} + +.form-row label { + width: 120px; + font-size: 13px; + color: var(--text-secondary); + flex-shrink: 0; +} + +.input, .select { + flex: 1; + padding: 7px 11px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} + +.input:focus, .select:focus { border-color: var(--accent); } + +/* ============================================================ + Server Cards + ============================================================ */ +.server-cards-container { display: flex; flex-direction: column; gap: 12px; } + +.server-card { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 14px; +} + +.server-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.server-card-title { font-size: 13px; font-weight: 500; } +.server-index { color: var(--accent); margin-right: 6px; } + +.server-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px; +} + +.server-field label { + display: block; + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.server-field .input, .server-field .select { width: 100%; } + +/* ============================================================ + Code Editor / TUN Config + ============================================================ */ +.tun-editor-wrap { margin-top: 12px; } + +.code-editor { + width: 100%; + min-height: 400px; + padding: 14px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace; + font-size: 12px; + line-height: 1.6; + resize: vertical; + outline: none; + tab-size: 4; +} + +.code-editor:focus { border-color: var(--accent); } + +/* ============================================================ + Route Rules + ============================================================ */ +.route-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-top: 16px; +} + +.route-col-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.route-col-header h3 { font-size: 13px; font-weight: 500; } +.route-count { font-size: 11px; color: var(--text-muted); } + +.route-editor { min-height: 300px; } + +/* ============================================================ + Presets + ============================================================ */ +.preset-list { display: flex; flex-direction: column; gap: 8px; } + +.preset-empty { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); + font-size: 13px; +} + +.preset-item { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 16px; +} + +.preset-info { display: flex; flex-direction: column; gap: 3px; } +.preset-name { font-size: 14px; font-weight: 500; } +.preset-meta { font-size: 12px; color: var(--text-muted); font-family: monospace; } +.preset-actions { display: flex; gap: 6px; } + +/* ============================================================ + Log Terminal + ============================================================ */ +.log-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.log-controls select { + padding: 4px 8px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + font-size: 12px; +} + +.log-autoscroll { + font-size: 12px; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.log-terminal { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + height: calc(100vh - 180px); + overflow-y: auto; + font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace; + font-size: 12px; + line-height: 1.7; + margin-top: 12px; +} + +.log-line { + display: flex; + gap: 10px; + padding: 1px 0; +} + +.log-time { color: var(--text-muted); flex-shrink: 0; } +.log-text { white-space: pre-wrap; word-break: break-all; } +.log-info { color: var(--text-secondary); } +.log-success { color: var(--success); } +.log-warning { color: var(--warning); } +.log-error { color: var(--error); } + +/* ============================================================ + Buttons + ============================================================ */ +.btn-sm { + padding: 5px 10px; + font-size: 12px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-card); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.12s; +} + +.btn-sm:hover { background: var(--bg-hover); color: var(--text-primary); } +.btn-sm.btn-start { border-color: var(--success); color: var(--success); } +.btn-sm.btn-start:hover { background: var(--success-dim); } +.btn-sm.btn-stop { border-color: var(--error); color: var(--error); } +.btn-sm.btn-stop:hover { background: var(--error-dim); } +.btn-sm.btn-restart { border-color: var(--accent); color: var(--accent); } +.btn-sm.btn-restart:hover { background: var(--accent-dim); } +.btn-sm.btn-danger { border-color: var(--error); color: var(--error); } +.btn-sm.btn-danger:hover { background: var(--error-dim); } +.btn-sm.btn-add { border-color: var(--accent); color: var(--accent); } +.btn-sm.btn-add:hover { background: var(--accent-dim); } + +.btn-save { + padding: 7px 18px; + background: var(--accent); + color: white; + border: none; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +.btn-save:hover { background: var(--accent-hover); } +.btn-save:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ============================================================ + Toast Notifications + ============================================================ */ +.toast-container { + position: fixed; + top: 16px; + right: 16px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; +} + +.toast { + padding: 10px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; + color: var(--text-primary); + box-shadow: var(--shadow); + animation: toastIn 0.2s ease; + max-width: 340px; + pointer-events: auto; +} + +.toast.success { border-left: 3px solid var(--success); } +.toast.error { border-left: 3px solid var(--error); } +.toast.warning { border-left: 3px solid var(--warning); } +.toast.info { border-left: 3px solid var(--accent); } +.toast.leaving { opacity: 0; transform: translateX(20px); transition: all 0.2s; } + +@keyframes toastIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* ============================================================ + Scrollbar + ============================================================ */ +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } diff --git a/plane-core/src/dispatcher.rs b/plane-core/src/dispatcher.rs index 995a6df..0d40063 100644 --- a/plane-core/src/dispatcher.rs +++ b/plane-core/src/dispatcher.rs @@ -91,17 +91,12 @@ where return Ok(()); }; - // FakeIP → 真实域名(反查不到则丢弃该连接)。 + // FakeIP → 真实域名,回退到 IP 字符串(应用直连 IP 也能走代理)。 let domain = { let engine = fake_dns.lock().await; - engine.lookup_domain(&dst_ip).map(|s| s.to_string()) - }; - let Some(domain) = domain else { - tracing::warn!( - "丢弃连接:FakeIP {} 反查不到域名(src={}, port={})", - dst_ip, src_ip, dst_port - ); - continue; + engine.lookup_domain(&dst_ip) + .map(|s| s.to_string()) + .unwrap_or_else(|| dst_ip.to_string()) }; tracing::info!("代理新连接: {}:{} -> {} (FakeIP {})", src_ip, dst_port, domain, dst_ip); diff --git a/plane-core/src/jni_bridge.rs b/plane-core/src/jni_bridge.rs index eeee381..8aa14b7 100644 --- a/plane-core/src/jni_bridge.rs +++ b/plane-core/src/jni_bridge.rs @@ -231,8 +231,14 @@ where tracing::error!(error = %e, "nativeStart 失败"); 0 } - Err(_) => { - tracing::error!("nativeStart 捕获到 panic,已隔离,返回 0"); + Err(panic_payload) => { + // 尝试从 panic payload 中提取消息字符串,便于定位崩溃原因。 + let msg: String = panic_payload + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| panic_payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "(无法解析 panic 消息)".to_string()); + tracing::error!(panic_msg = %msg, "nativeStart 捕获到 panic,已隔离,返回 0"); 0 } } @@ -324,7 +330,10 @@ fn spawn_data_plane( config: &AndroidConfig, ) -> Result<()> { // SAFETY: tun_fd 由 Kotlin VpnService.establish().detachFd() 移交,独占且有效。 - let tun = unsafe { AndroidTun::from_raw_fd(tun_fd, config.mtu) }?; + // 必须在 tokio runtime 上下文中创建 AsyncFd,否则 panic: "no reactor running"。 + let tun = handle + .rt + .block_on(async { unsafe { AndroidTun::from_raw_fd(tun_fd, config.mtu) } })?; let (tun_reader, tun_writer) = tun.split(); let fake_dns = std::sync::Arc::new(tokio::sync::Mutex::new(FakeDnsEngine::new( diff --git a/plane-core/src/net_probe.rs b/plane-core/src/net_probe.rs index fd88a62..5d37d7e 100644 --- a/plane-core/src/net_probe.rs +++ b/plane-core/src/net_probe.rs @@ -366,7 +366,7 @@ fn build_dns_response_packet(query: &DnsQueryInfo, dns_payload: &[u8]) -> Vec u16 { +pub fn checksum(data: &[u8]) -> u16 { let mut sum: u32 = 0; let mut i = 0; while i + 1 < data.len() { diff --git a/plane-core/src/outbound.rs b/plane-core/src/outbound.rs index b22b403..0deed8e 100644 --- a/plane-core/src/outbound.rs +++ b/plane-core/src/outbound.rs @@ -228,6 +228,46 @@ impl RequestIdGen { } } +/// 全局唯一的 stream_id 生成器。 +/// +/// 服务端 `SessionManager` 以 `streamId` 为 session key,如果所有代理连接都用 +/// `streamId=0`,会导致 session 不断被覆盖(“Replacing existing session”), +/// 并发代理全部失败。每个 stream 必须分配独立的 stream_id。 +/// +/// 生成的值采用较大的起始值加递增,避免与 Java 客户端的 stream_id 冲突。 +#[derive(Debug)] +pub struct StreamIdGen { + next: AtomicI64, +} + +impl Default for StreamIdGen { + fn default() -> Self { + Self::new() + } +} + +impl StreamIdGen { + /// 构造,从一个基于时间戳的唯一基数开始,避免各实例/各连接冲突。 + pub fn new() -> Self { + // 用当前时间戳(毫秒)左移 20 位作为基数,高位表示时间,低位递增。 + // 这与 Java 客户端的大数值 stream_id 类似,且不会重复。 + let base = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + // 左移 20 位给足约 100万个递增空间,单次会话足够用。 + let start = (base << 20) | 1; + Self { + next: AtomicI64::new(start), + } + } + + /// 取下一个全局唯一的 stream_id。 + pub fn next_id(&self) -> i64 { + self.next.fetch_add(1, Ordering::Relaxed) + } +} + /// 出站连接 —— 一条到 proxy-remote 的 HTTP/2 连接,承载多路 stream。 /// /// 通过 [`OutboundConnection::handshake`] 在「已 protect 并 connect」的 IO 流上完成 h2 握手。 @@ -236,6 +276,8 @@ pub struct OutboundConnection { send_request: h2::client::SendRequest, cipher: Cipher, req_id_gen: Arc, + /// 全局唯一的 stream_id 生成器,服务端以 stream_id 为 session key。 + stream_id_gen: Arc, config: OutboundConfig, } @@ -269,6 +311,7 @@ impl OutboundConnection { send_request, cipher, req_id_gen: Arc::new(RequestIdGen::new()), + stream_id_gen: Arc::new(StreamIdGen::new()), config, }) } @@ -300,7 +343,8 @@ impl OutboundConnection { // 首个 CONNECT 消息(加密 DATA 帧)。 let request_id = self.req_id_gen.next_id(); - let connect_msg = ProxyMessage::connect(request_id, host, port); + let stream_id = self.stream_id_gen.next_id(); + let connect_msg = ProxyMessage::connect(request_id, stream_id, host, port); let frame = encode_encrypted_frame(&self.cipher, &connect_msg)?; send_stream .send_data(Bytes::from(frame), false) @@ -314,10 +358,12 @@ impl OutboundConnection { Ok(OutboundStream { request_id, + stream_id, cipher: self.cipher.clone(), send_stream, recv_stream, reassembler: InboundReassembler::new(self.cipher.clone()), + pending: Vec::new(), }) } @@ -333,10 +379,15 @@ impl OutboundConnection { /// 接收:h2 DATA 帧 → 解密 → 切包 → DATA `ProxyMessage` → 取出业务负载。 pub struct OutboundStream { request_id: i64, + /// 本 stream 的唯一 stream_id,服务端以此为 session key。 + stream_id: i64, cipher: Cipher, send_stream: h2::SendStream, recv_stream: h2::RecvStream, reassembler: InboundReassembler, + /// 已解密但尚未消费的消息(由 wait_for_connect_response 存入, + /// recv_messages 先出队这里再读网络,避免 CONNECT_RESPONSE 之外的 DATA 丢失)。 + pending: Vec, } impl OutboundStream { @@ -347,7 +398,7 @@ impl OutboundStream { /// 发送一段业务数据(包成 DATA 类型 ProxyMessage,加密为一个 h2 DATA 帧)。 pub fn send_payload(&mut self, payload: &[u8]) -> Result<()> { - let msg = ProxyMessage::data(self.request_id, payload); + let msg = ProxyMessage::data(self.request_id, self.stream_id, payload); let frame = encode_encrypted_frame(&self.cipher, &msg)?; self.send_stream .send_data(Bytes::from(frame), false) @@ -357,7 +408,7 @@ impl OutboundStream { /// 发送 DISCONNECT 并以 end_stream 关闭发送侧。 pub fn send_disconnect(&mut self) -> Result<()> { - let msg = ProxyMessage::disconnect(self.request_id); + let msg = ProxyMessage::disconnect(self.request_id, self.stream_id); let frame = encode_encrypted_frame(&self.cipher, &msg)?; self.send_stream .send_data(Bytes::from(frame), true) @@ -367,30 +418,110 @@ impl OutboundStream { /// 读取下一批入站 [`ProxyMessage`](驱动一个 h2 DATA 帧 → 解密 → 切包)。 /// - /// 返回 `Ok(Some(msgs))`:本次帧切出的消息(可能为空,半条消息留缓冲)。 - /// 返回 `Ok(None)`:对端已结束 stream(EOF)。 + /// 先出队 [`self.pending`] 中由 CONNECT_RESPONSE 阶段预读的积压消息, + /// 为空时才从 h2 stream 拉新帧。返回 `Ok(None)` 表示流已关闭。 pub async fn recv_messages(&mut self) -> Result>> { + if !self.pending.is_empty() { + return Ok(Some(std::mem::take(&mut self.pending))); + } match self.recv_stream.data().await { Some(Ok(chunk)) => { - // h2 流控:消费了多少要归还多少窗口,否则对端会被 stall。 let len = chunk.len(); let _ = self.recv_stream.flow_control().release_capacity(len); let msgs = self.reassembler.push_encrypted_frame(&chunk)?; Ok(Some(msgs)) } - Some(Err(e)) => Err(CoreError::Protocol(format!("h2 recv error: {e}"))), + Some(Err(e)) => { + // RST_STREAM NO_ERROR = 服务端优雅关闭流(如 CONNECT 失败后),当作 EOF + let msg = e.to_string(); + if msg.contains("stream no longer needed") || msg.contains("NO_ERROR") { + tracing::debug!("h2 stream reset gracefully (NO_ERROR), treating as EOF"); + Ok(None) + } else { + Err(CoreError::Protocol(format!("h2 recv error: {e}"))) + } + } None => Ok(None), } } + + /// 等待并接收 CONNECT_RESPONSE(加密链路的初始双向握手)。 + /// + /// 服务端在收到 CONNECT 后会发送一个 CONNECT_RESPONSE(status=200=OK)。 + /// 同一批帧中可能已携带目标服务器的 HTTP 响应 DATA——这些消息缓存到 + /// `self.pending`,由后续 `recv_messages` 取出,确保零数据丢失。 + /// + /// 返回 `Ok(())` 表示服务端成功连接目标,可以进入双向数据拷贝。 + /// 返回 `Err(...)` 表示 CONNECT 失败或协议异常。 + pub async fn wait_for_connect_response(&mut self) -> Result<()> { + loop { + // 先检查 pending(递归场景下不应出现,但安全处理) + let pending = std::mem::take(&mut self.pending); + let msgs = if !pending.is_empty() { + pending + } else { + match self.recv_messages().await? { + Some(msgs) => msgs, + None => { + return Err(CoreError::Protocol( + "CONNECT 响应阶段流被关闭(服务端可能未连通目标)".to_string(), + )); + } + } + }; + let mut extra: Vec = Vec::new(); + let mut found = false; + for m in msgs { + match m.type_ { + MessageType::ConnectResponse => { + found = true; + let ok = m.status == 200 || m.status == 0; + if ok { + tracing::info!( + "CONNECT_RESPONSE OK: host={}, port={}, requestId={}", + m.host, + m.port, + m.request_id + ); + } else { + let reason = match m.status { + 244 | 152 => "proxy-remote 无法连接目标(目标不可达/超时)", + _ => "未知错误", + }; + return Err(CoreError::Protocol(format!( + "CONNECT {}:{} 失败: {} (status={})", + m.host, m.port, reason, m.status + ))); + } + } + MessageType::Disconnect => { + return Err(CoreError::Protocol(format!( + "CONNECT 阶段收到 DISCONNECT: host={}, port={}", + m.host, m.port + ))); + } + _ => { + extra.push(m); + } + } + } + if found { + // 将 CONNECT_RESPONSE 之外的消息(如目标服务器的 DATA)存入 pending, + // 避免浏览器的 HTTP 响应被丢弃导致页面永远加载不出来。 + self.pending = extra; + return Ok(()); + } + // 未找到 CONNECT_RESPONSE,继续读取下一批帧 + } + } } -/// 在 [`OutboundStream`] 与本地业务连接间做双向拷贝(A5.4 `proxy_via_remote` 雏形)。 +/// 在 [`OutboundStream`] 与本地业务连接间做双向拷贝(A6 完整版)。 /// /// 该函数把「本地 TCP(被代理的应用连接)」与「远端代理 stream」桥接: +/// - **首先等待 CONNECT_RESPONSE**,如果服务端连接目标失败则立即返回错误; /// - 本地读到的字节 → `send_payload` 发往远端; /// - 远端 `recv_messages` 解出的 DATA 负载 → 写回本地。 -/// -/// 这里给出可独立编译/测试的最小骨架;与真实 TUN/TCP 栈的接线在 A6 完善。 pub async fn proxy_via_remote( mut stream: OutboundStream, mut local: L, @@ -401,34 +532,86 @@ where { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + // 先等待 CONNECT_RESPONSE:服务端可能建连失败(目标不可达 / 超时等), + // 若失败则将明确的错误原因抛给上层,由调度器决定重试或通知栈关断。 + stream.wait_for_connect_response().await?; + let mut buf = vec![0u8; read_buf_size.max(1)]; + let mut total_up: u64 = 0; + let mut total_down: u64 = 0; + let mut down_pkts: u64 = 0; + let mut pending_up: Vec = Vec::new(); + let start = std::time::Instant::now(); + let mut last_log = std::time::Instant::now(); loop { tokio::select! { - // 本地 → 远端 read = local.read(&mut buf) => { let n = read.map_err(CoreError::Io)?; if n == 0 { + // 发送残留在缓冲区的数据 + if !pending_up.is_empty() { + stream.send_payload(&pending_up)?; + } stream.send_disconnect()?; + tracing::info!(total_up, total_down, down_pkts, elapsed_ms = start.elapsed().as_millis(), "proxy 上行 EOF"); break; } - stream.send_payload(&buf[..n])?; + total_up += n as u64; + pending_up.extend_from_slice(&buf[..n]); + + // 若是 TLS ClientHello,可能跨越多个 TCP 段。将 TCP 段拼接成 + // 完整 TLS 记录后再一起发出,避免服务器收到不完整 ClientHello + // 时立即返回 TLS Alert (unexpected_message),导致后续段被丢弃。 + let ready = if pending_up.len() >= 5 && pending_up[0] == 0x16 { + let rec_len = u16::from_be_bytes([pending_up[3], pending_up[4]]) as usize; + pending_up.len() >= 5 + rec_len + } else { + true // 非 TLS 或短帧,立即发送 + }; + + if ready { + if total_up == pending_up.len() as u64 { + let hex = pending_up.iter().take(16) + .map(|b| format!("{b:02x}")) + .collect::>().join(""); + tracing::info!(len = pending_up.len(), first_hex = %hex, "proxy 上行(拼接后发出)"); + } + stream.send_payload(&pending_up)?; + pending_up.clear(); + } } - // 远端 → 本地 recv = stream.recv_messages() => { match recv? { Some(msgs) => { for m in msgs { match m.type_ { MessageType::Data => { + if total_down == 0 { + let hex = m.data.iter().take(16) + .map(|b| format!("{b:02x}")) + .collect::>().join(""); + tracing::info!(data_len = m.data.len(), first_hex = %hex, "proxy 首个下行"); + } + total_down += m.data.len() as u64; + down_pkts += 1; local.write_all(&m.data).await.map_err(CoreError::Io)?; } - MessageType::Disconnect => return Ok(()), - // CONNECT_RESPONSE / 心跳等:此骨架暂忽略 + MessageType::Disconnect => { + tracing::info!(total_up, total_down, down_pkts, "proxy 远端 DISCONNECT,正常结束"); + return Ok(()); + } _ => {} } } + if last_log.elapsed() > std::time::Duration::from_secs(1) { + tracing::info!(total_up, total_down, down_pkts, "proxy 数据流活跃"); + last_log = std::time::Instant::now(); + } + } + None => { + tracing::info!(total_up, total_down, down_pkts, elapsed_ms = start.elapsed().as_millis(), "proxy 远端 EOF"); + break; } - None => break, // 远端 EOF } } } @@ -476,7 +659,7 @@ mod tests { #[test] fn encode_encrypted_frame_is_decryptable() { let cipher = test_cipher(); - let msg = ProxyMessage::connect(42, "example.com", 443); + let msg = ProxyMessage::connect(42, 100, "example.com", 443); let frame = encode_encrypted_frame(&cipher, &msg).unwrap(); // 加密帧 = 4字节长度前缀 + nonce(12) + ct + tag(16),长度 = 明文 + 4 + 28。 let plaintext = msg.encode(); @@ -500,7 +683,7 @@ mod tests { fn reassembler_single_frame_one_message() { let cipher = test_cipher(); let mut re = InboundReassembler::new(cipher.clone()); - let msg = ProxyMessage::data(1, b"hello world"); + let msg = ProxyMessage::data(1, 1, b"hello world"); let frame = encode_encrypted_frame(&cipher, &msg).unwrap(); let got = re.push_encrypted_frame(&frame).unwrap(); assert_eq!(got.len(), 1); @@ -520,9 +703,9 @@ mod tests { fn reassembler_multiple_messages_in_one_frame() { // 同一加密块内含多条 ProxyMessage(明文拼接后整体加密)。 let cipher = test_cipher(); - let m1 = ProxyMessage::data(1, b"first"); - let m2 = ProxyMessage::connect(1, "a.com", 80); - let m3 = ProxyMessage::disconnect(1); + let m1 = ProxyMessage::data(1, 10, b"first"); + let m2 = ProxyMessage::connect(1, 10, "a.com", 80); + let m3 = ProxyMessage::disconnect(1, 10); let mut plaintext = Vec::new(); plaintext.extend_from_slice(&m1.encode()); plaintext.extend_from_slice(&m2.encode()); @@ -543,7 +726,7 @@ mod tests { // 重新切分成两个 DATA 帧。接收侧必须在密文层做字节级累积, // 集齐整块后再解密。这正是修复前会触发 Poly1305 tag 失败的场景。 let cipher = test_cipher(); - let msg = ProxyMessage::data(7, b"a-reasonably-long-payload-spanning-frames"); + let msg = ProxyMessage::data(7, 7, b"a-reasonably-long-payload-spanning-frames"); // 一条消息整体加密成一个密文块,加 4 字节长度前缀。 let frame = frame_with_prefix(&cipher.encrypt(&msg.encode()).unwrap()); let split = frame.len() / 2; @@ -566,8 +749,8 @@ mod tests { fn reassembler_two_blocks_coalesced_in_one_frame() { // 两个独立密文块(各带长度前缀)在一个 DATA 帧里被合并送达。 let cipher = test_cipher(); - let m1 = ProxyMessage::data(1, b"alpha"); - let m2 = ProxyMessage::data(2, b"bravo"); + let m1 = ProxyMessage::data(1, 1, b"alpha"); + let m2 = ProxyMessage::data(2, 2, b"bravo"); let mut frame = encode_encrypted_frame(&cipher, &m1).unwrap(); frame.extend_from_slice(&encode_encrypted_frame(&cipher, &m2).unwrap()); @@ -581,7 +764,7 @@ mod tests { #[test] fn reassembler_rejects_tampered_frame() { let cipher = test_cipher(); - let msg = ProxyMessage::data(1, b"payload"); + let msg = ProxyMessage::data(1, 1, b"payload"); let mut frame = encode_encrypted_frame(&cipher, &msg).unwrap(); // 篡改密文区(跳过 4 字节长度前缀)→ 解密时 Poly1305 tag 校验失败。 let mid = CIPHER_LENGTH_PREFIX + (frame.len() - CIPHER_LENGTH_PREFIX) / 2; diff --git a/plane-core/src/proxy_proto.rs b/plane-core/src/proxy_proto.rs index b94e3bb..3de92a8 100644 --- a/plane-core/src/proxy_proto.rs +++ b/plane-core/src/proxy_proto.rs @@ -84,12 +84,16 @@ pub struct ProxyMessage { impl ProxyMessage { /// 构造 CONNECT 消息(请求侧)。 - pub fn connect(request_id: i64, host: &str, port: u16) -> Self { + /// + /// `stream_id` 必须为每条代理 stream 分配唯一值,服务端以此为 session key。 + /// 若所有连接共用 `stream_id=0`,服务端 SessionManager 会不断覆盖 session, + /// 导致并发代理全部失败。 + pub fn connect(request_id: i64, stream_id: i64, host: &str, port: u16) -> Self { Self { type_: MessageType::Connect, status: 0, request_id, - stream_id: 0, + stream_id, host: host.to_string(), port: port as i32, data: Vec::new(), @@ -97,12 +101,14 @@ impl ProxyMessage { } /// 构造 DATA 消息(请求侧)。 - pub fn data(request_id: i64, payload: &[u8]) -> Self { + /// + /// `stream_id` 须与该连接的 CONNECT 消息保持一致。 + pub fn data(request_id: i64, stream_id: i64, payload: &[u8]) -> Self { Self { type_: MessageType::Data, status: 0, request_id, - stream_id: 0, + stream_id, host: String::new(), port: 0, data: payload.to_vec(), @@ -110,12 +116,14 @@ impl ProxyMessage { } /// 构造 DISCONNECT 消息(请求侧)。 - pub fn disconnect(request_id: i64) -> Self { + /// + /// `stream_id` 须与该连接的 CONNECT 消息保持一致。 + pub fn disconnect(request_id: i64, stream_id: i64) -> Self { Self { type_: MessageType::Disconnect, status: 0, request_id, - stream_id: 0, + stream_id, host: String::new(), port: 0, data: Vec::new(), @@ -268,7 +276,7 @@ mod tests { #[test] fn encode_decode_roundtrip_connect() { - let msg = ProxyMessage::connect(42, "example.com", 443); + let msg = ProxyMessage::connect(42, 100, "example.com", 443); let bytes = msg.encode(); // 头 28 + host 11 assert_eq!(bytes.len(), FIXED_HEADER_SIZE + "example.com".len()); @@ -282,7 +290,7 @@ mod tests { #[test] fn encode_decode_roundtrip_data() { let payload = vec![1u8, 2, 3, 4, 5]; - let msg = ProxyMessage::data(7, &payload); + let msg = ProxyMessage::data(7, 100, &payload); let bytes = msg.encode(); let decoded = ProxyMessage::decode(&bytes).unwrap(); assert_eq!(decoded, msg); @@ -315,7 +323,7 @@ mod tests { #[test] fn try_decode_one_partial_returns_none() { - let msg = ProxyMessage::connect(1, "host", 80); + let msg = ProxyMessage::connect(1, 1, "host", 80); let full = msg.encode(); // 喂入不足 28 字节。 assert!(try_decode_one(&full[..10]).unwrap().is_none()); @@ -327,8 +335,8 @@ mod tests { #[test] fn try_decode_one_streamed_in_two_parts() { - let m1 = ProxyMessage::data(1, b"first"); - let m2 = ProxyMessage::data(2, b"second-message"); + let m1 = ProxyMessage::data(1, 1, b"first"); + let m2 = ProxyMessage::data(2, 1, b"second-message"); let mut stream = m1.encode(); stream.extend_from_slice(&m2.encode()); @@ -354,9 +362,9 @@ mod tests { #[test] fn try_decode_one_concatenated_messages() { let msgs = [ - ProxyMessage::connect(1, "a.com", 443), - ProxyMessage::data(1, b"payload"), - ProxyMessage::disconnect(1), + ProxyMessage::connect(1, 10, "a.com", 443), + ProxyMessage::data(1, 10, b"payload"), + ProxyMessage::disconnect(1, 10), ProxyMessage::heartbeat_request(99), ]; let mut stream = Vec::new(); @@ -374,7 +382,7 @@ mod tests { #[test] fn decode_rejects_trailing_bytes() { - let mut bytes = ProxyMessage::data(1, b"x").encode(); + let mut bytes = ProxyMessage::data(1, 1, b"x").encode(); bytes.push(0xFF); // 多一个字节 assert!(ProxyMessage::decode(&bytes).is_err()); } diff --git a/plane-core/src/tcp_stack.rs b/plane-core/src/tcp_stack.rs index 1a107bb..a941a7b 100644 --- a/plane-core/src/tcp_stack.rs +++ b/plane-core/src/tcp_stack.rs @@ -155,6 +155,11 @@ pub async fn stack_loop( if let Err(e) = tun_writer.write_all(&dns_response).await { tracing::warn!("写回 DNS 响应到 TUN 失败: {}", e); } + } else if is_non_dns_udp(packet_data) { + // QUIC/HTTP3 等非 DNS UDP → ICMP Port Unreachable,促使浏览器立即回退 TCP。 + if let Some(icmp) = build_icmp_port_unreachable(packet_data) { + let _ = tun_writer.write_all(&icmp).await; + } } else { // TCP 包处理:过滤未知连接的非 SYN 包,避免 smoltcp 回 RST。 let should_feed = if is_tcp_packet(packet_data) { @@ -543,6 +548,65 @@ fn is_tcp_packet(packet: &[u8]) -> bool { packet.len() >= 20 && (packet[0] >> 4) & 0x0F == 4 && packet[9] == 6 } +/// 是否为非 DNS 的 IPv4 UDP 包(QUIC / HTTP3 等)。 +/// 端口 53 是 DNS,已由 FakeDNS 拦截,此处只匹配剩下的 UDP。 +fn is_non_dns_udp(packet: &[u8]) -> bool { + if packet.len() < 28 { + return false; + } + // IPv4 + UDP + if (packet[0] >> 4) != 4 { + return false; + } + if packet[9] != 17 { + return false; + } + // 排除 DNS (port 53) + let dst_port = u16::from_be_bytes([packet[22], packet[23]]); + dst_port != 53 +} + +/// 对非 DNS UDP 包构造 ICMP Destination Unreachable (Port Unreachable), +/// 告知发送方“UDP 端口不可达”,促使浏览器立即回退到 TCP(放弃 QUIC/HTTP3)。 +fn build_icmp_port_unreachable(original: &[u8]) -> Option> { + if original.len() < 28 { + return None; + } + // 原 IPv4 头 + let src_ip = Ipv4Addr::new(original[12], original[13], original[14], original[15]); + let dst_ip = Ipv4Addr::new(original[16], original[17], original[18], original[19]); + + // ICMP header (8) + IPv4 header (20) + up to 8 bytes of original payload + let quote_len = (original.len() - 20).min(8); + let icmp_len = 8 + 20 + quote_len; + let total = 20 + icmp_len; + let mut pkt = vec![0u8; total]; + + // --- IPv4 header --- + pkt[0] = 0x45; + pkt[2..4].copy_from_slice(&(total as u16).to_be_bytes()); + pkt[8] = 64; // TTL + pkt[9] = 1; // ICMP protocol + pkt[12..16].copy_from_slice(&dst_ip.octets()); // src = 原 dst + pkt[16..20].copy_from_slice(&src_ip.octets()); // dst = 原 src + let ip_csum = crate::net_probe::checksum(&pkt[0..20]); + pkt[10..12].copy_from_slice(&ip_csum.to_be_bytes()); + + // --- ICMP header --- + pkt[20] = 3; // Type: Destination Unreachable + pkt[21] = 3; // Code: Port Unreachable + // checksum at [22..24], compute after filling + // unused [24..28] + // Quote: original IP header + 8 bytes + pkt[28..28 + 20 + quote_len].copy_from_slice(&original[0..20 + quote_len]); + + // ICMP checksum over bytes [20..] + let csum = crate::net_probe::checksum(&pkt[20..]); + pkt[22..24].copy_from_slice(&csum.to_be_bytes()); + + Some(pkt) +} + /// 活跃 TCP 连接追踪(移植自桌面 `stack.rs`)。 struct ActiveConnection { handle: SocketHandle, diff --git a/scripts/build-rust.sh b/scripts/build-rust.sh index ea3ca67..38e6e27 100755 --- a/scripts/build-rust.sh +++ b/scripts/build-rust.sh @@ -29,6 +29,21 @@ OUT_DIR="${REPO_ROOT}/android-app/app/src/main/jniLibs" # x86_64 -> x86_64-linux-android (模拟器) ABIS=("arm64-v8a" "armeabi-v7a" "x86_64") +# 确保 cargo-ndk 在 PATH 中。优先用 HOME 定位(MSYS2 下 HOME = /c/Users/...)。 +_cargo_bin="" +for _dir in "${HOME}/.cargo/bin" "${CARGO_HOME:-}/bin"; do + if [ -n "${_dir}" ] && [ -x "${_dir}/cargo" ] && [ -x "${_dir}/cargo-ndk" ]; then + _cargo_bin="${_dir}" + break + fi +done +if [ -n "${_cargo_bin}" ]; then + export PATH="${_cargo_bin}:${PATH}" +fi +if ! command -v cargo >/dev/null 2>&1; then + echo "[build-rust] ERROR: 未找到 cargo。请先安装 Rust: https://rustup.rs" >&2 + exit 1 +fi if ! command -v cargo-ndk >/dev/null 2>&1; then echo "[build-rust] ERROR: 未找到 cargo-ndk。请先执行:cargo install cargo-ndk" >&2 exit 1