From 447f3f494e125c6ea00c955b5033b5eebd8077f5 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 21 Jun 2026 21:18:33 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(client/macos):=20P1=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E9=9A=A7=E9=81=93=E2=80=94=E2=80=94PacketTunnel=20?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=89=A9=E5=B1=95=E5=8F=AF=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=20+=20libbox=20=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 经长链路排查(同机对照可工作的 Tailscale),修复 macOS 系统扩展 realize 失败(OSSystemExtensionErrorDomain code=4)与 libbox 运行时崩溃,使内嵌 sing-box 的系统扩展能在 macOS 15 上激活并启动隧道。 系统扩展 realize(三个叠加根因): - 扩展自包含:PacketTunnel 加 OTHER_LDFLAGS="" 切断对项目级 CocoaPods 链接 标志的继承(原会把 flutter_secure_storage 链进扩展);Libbox.xcframework 改纯 Link(静态),从 Embed Frameworks 移除冗余内嵌 - bundle 名 = 标识符:PRODUCT_NAME 设为 com.pangolin.pangolin.PacketTunnel - 扩展 Info.plist 补 NSSystemExtensionUsageDescription(网络扩展类别强制要求) - App Group 改 macOS 原生格式 BYL4KQHMTN.com.pangolin.pangolin;NEMachServiceName 以其为前缀;扩展补 network.client/server;get-task-allow=false + 签名加 --timestamp - CFBundleVersion 随构建递增(否则 sysextd 视为同版本不更新) libbox 运行时: - startOrReloadService(options:) 传 nil 致空指针 SIGSEGV → 传 LibboxOverrideOptions() - 默认接口监控阻塞到首个 path 更新再返回,修 "no available network interface" 配套: - scripts/local_test.sh:build/sign/notarize/copy/run 一条龙(Developer ID + 公证) - client/macos/sign_libbox.sh:构建期以 Developer ID 重签内嵌 Libbox - VpnChannel:401 自动刷新 token、详尽 os_log;auth/api 统一走 kApiBaseUrl - docs/macos-sysext-realize-troubleshooting.html:完整踩坑复盘 WIP / 临时(后续清理): - 隧道运行时仍在排查:剥离远程 rule-set 后 sing-box 启动卡点未定位 - 含临时诊断代码:main.swift stderr 重定向、box.log 输出、rule-set 剥离、debug 日志 - api_config 仍指向联调节点,发版前还原 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c --- client/lib/bridge/vpn_bridge_provider.dart | 2 +- client/lib/screens/account_page.dart | 9 +- client/lib/services/api_config.dart | 5 +- client/lib/services/auth_api.dart | 7 +- client/lib/state/connection_provider.dart | 45 ++-- client/lib/widgets/auth_screen.dart | 9 +- .../Flutter/GeneratedPluginRegistrant.swift | 6 + client/macos/PacketTunnel/Info.plist | 11 +- .../PacketTunnel/PacketTunnel.entitlements | 24 +- .../PacketTunnel/PacketTunnelProvider.swift | 57 ++++- client/macos/PacketTunnel/main.swift | 11 + client/macos/Runner.xcodeproj/project.pbxproj | 105 +++++---- client/macos/Runner/DebugProfile.entitlements | 8 + client/macos/Runner/Info.plist | 3 + client/macos/Runner/Release.entitlements | 19 +- client/macos/Runner/VpnChannel.swift | 46 +++- client/macos/sign_libbox.sh | 13 ++ .../macos-sysext-realize-troubleshooting.html | 186 ++++++++++++++++ scripts/local_test.sh | 206 +++++++++++++----- 19 files changed, 629 insertions(+), 143 deletions(-) create mode 100755 client/macos/sign_libbox.sh create mode 100644 docs/macos-sysext-realize-troubleshooting.html diff --git a/client/lib/bridge/vpn_bridge_provider.dart b/client/lib/bridge/vpn_bridge_provider.dart index 063eab1..9eebecb 100644 --- a/client/lib/bridge/vpn_bridge_provider.dart +++ b/client/lib/bridge/vpn_bridge_provider.dart @@ -16,7 +16,7 @@ import 'dart:io' show Platform; /// P1 方案B 开关:macOS 是否走原生 System Extension(VpnNativeBridge)而非 PoC 的 /// sudo 子进程(DesktopVpnBridge)。默认 false——待 PacketTunnel target/签名就绪、 /// 原生侧联调通过后置 true。见 docs/p1-macos-system-extension.md。 -const bool kUseNativeVpnMacOS = false; +const bool kUseNativeVpnMacOS = true; /// 全局单例 VpnBridge。Ref 生命周期内不变。 final vpnBridgeProvider = Provider((ref) { diff --git a/client/lib/screens/account_page.dart b/client/lib/screens/account_page.dart index ec8cda9..37358a5 100644 --- a/client/lib/screens/account_page.dart +++ b/client/lib/screens/account_page.dart @@ -7,6 +7,7 @@ import '../l10n/app_text.dart'; import '../pangolin_theme.dart'; import '../state/account_providers.dart'; import '../state/app_providers.dart'; +import '../state/auth_provider.dart'; import '../state/navigation_provider.dart'; import '../widgets/account_screens.dart'; import '../widgets/app_top_bar.dart'; @@ -93,7 +94,13 @@ class AccountPage extends ConsumerWidget { ]), const SizedBox(height: 16), _Card(children: [ - _NavRow(icon: PangolinIcons.logOut, title: t.signOut, danger: true, onTap: () {}), + _NavRow( + icon: PangolinIcons.logOut, + title: t.signOut, + danger: true, + // 清除本地令牌 → authProvider 置未登录 → 根据登录态回登录页。 + onTap: () => ref.read(authProvider.notifier).logout(), + ), ]), ], ); diff --git a/client/lib/services/api_config.dart b/client/lib/services/api_config.dart index 0a20176..5fc33b8 100644 --- a/client/lib/services/api_config.dart +++ b/client/lib/services/api_config.dart @@ -1,8 +1,9 @@ // api_config.dart — 控制面 API 基址单源。 // // 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里, -// 由 --dart-define=PANGOLIN_API_URL 注入(默认本地 8080)。 +// 由 --dart-define=PANGOLIN_API_URL 注入。 +// TODO(联调临时): 默认值改成测试节点,避免 release 构建漏传 dart-define;发版前改回 localhost 或正式控制面域名。 const String kApiBaseUrl = String.fromEnvironment( 'PANGOLIN_API_URL', - defaultValue: 'http://localhost:8080', + defaultValue: 'http://103.119.13.48:8080', ); diff --git a/client/lib/services/auth_api.dart b/client/lib/services/auth_api.dart index f34b161..8201f86 100644 --- a/client/lib/services/auth_api.dart +++ b/client/lib/services/auth_api.dart @@ -99,15 +99,20 @@ class AuthApi { Future _post(String path, Map body) async { final uri = Uri.parse('$baseUrl$path'); + print('>>>AUTHLOG POST $uri (baseUrl=$baseUrl) 发起...'); + final sw = Stopwatch()..start(); try { - return await _client + final resp = await _client .post( uri, headers: {'Content-Type': 'application/json'}, body: jsonEncode(body), ) .timeout(const Duration(seconds: 15)); + print('>>>AUTHLOG POST $uri -> HTTP ${resp.statusCode} (${sw.elapsedMilliseconds}ms)'); + return resp; } on Exception catch (e) { + print('>>>AUTHLOG POST $uri 失败 (${sw.elapsedMilliseconds}ms): ${e.runtimeType}: $e'); throw AuthApiException( statusCode: -1, messageZh: '网络请求失败,请检查连接后重试', diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 7e2e1a8..7c0d5be 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../bridge/vpn_bridge.dart'; import '../bridge/vpn_bridge_provider.dart'; import '../l10n/app_text.dart'; +import '../services/api_config.dart'; import '../services/connect_api.dart'; import 'app_providers.dart'; import 'auth_provider.dart'; @@ -25,12 +26,7 @@ const _kDeviceId = String.fromEnvironment( defaultValue: 'mac-001', ); -// ── API base URL ───────────────────────────────────────────────── - -const _kApiUrl = String.fromEnvironment( - 'PANGOLIN_API_URL', - defaultValue: 'http://localhost:8080', -); +// API base URL 统一用 api_config.dart 的 kApiBaseUrl(单一来源,勿再重复声明)。 // ── 连接阶段枚举 ────────────────────────────────────────────────── @@ -103,7 +99,6 @@ class ConnectionController extends StateNotifier { Future _connect() async { state = const ConnectionState(phase: VpnPhase.connecting); - final token = _ref.read(authProvider).accessToken ?? ''; final node = _ref.read(effectiveNodeProvider); final zh = _ref.read(localeProvider) == AppLang.zh; @@ -119,14 +114,7 @@ class ConnectionController extends StateNotifier { } try { - _api?.dispose(); - _api = ConnectApi(baseUrl: _kApiUrl, authToken: token); - final configJson = await _api!.fetchConfig( - nodeId: node.uuid, - deviceId: _kDeviceId, - // smartRoute 偏好 → 国内分流(#5):国内 IP/域名直连,不走隧道。 - splitCN: _ref.read(settingsProvider).smartRoute, - ); + final configJson = await _fetchConfigWithRefresh(node.uuid); // bridge.start() 不阻塞至连接建立;on 状态由 statusStream 回调驱动。 await _bridge.start(configJson); } on ConnectApiException catch (e) { @@ -142,6 +130,33 @@ class ConnectionController extends StateNotifier { } } + /// 取配置;access token 过期(401)时用 refresh token 续期后**重试一次**。 + /// 续期失败(refresh 也过期 / 被拒)由 authProvider.refresh() 触发登出 → UI 回登录页。 + Future _fetchConfigWithRefresh(String nodeUuid) async { + Future doFetch() { + final token = _ref.read(authProvider).accessToken ?? ''; + _api?.dispose(); + _api = ConnectApi(baseUrl: kApiBaseUrl, authToken: token); + return _api!.fetchConfig( + nodeId: nodeUuid, + deviceId: _kDeviceId, + // smartRoute 偏好 → 国内分流(#5):国内 IP/域名直连,不走隧道。 + splitCN: _ref.read(settingsProvider).smartRoute, + ); + } + + try { + return await doFetch(); + } on ConnectApiException catch (e) { + if (e.statusCode == 401) { + // token 过期 → 续期后重试一次;续期成功则用新 token 再取。 + final ok = await _ref.read(authProvider.notifier).refresh(); + if (ok) return await doFetch(); + } + rethrow; + } + } + Future _disconnect() async { _stopElapsed(); try { diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index 097e1bd..a6384d1 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -8,17 +8,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../l10n/app_text.dart'; import '../pangolin_theme.dart'; +import '../services/api_config.dart'; import '../services/auth_api.dart'; import '../state/auth_provider.dart'; import 'pangolin_button.dart'; import 'pangolin_icons.dart'; import 'pangolin_logo.dart'; -// API base URL(由 --dart-define 注入) -const _kApiUrl = String.fromEnvironment( - 'PANGOLIN_API_URL', - defaultValue: 'http://localhost:8080', -); +// API base URL 统一用 api_config.dart 的 kApiBaseUrl(单一来源,勿再重复声明)。 const _easeOut = Cubic(0.22, 1, 0.36, 1); @@ -55,7 +52,7 @@ class _AuthScreenState extends ConsumerState duration: const Duration(milliseconds: 620), ); - late final AuthApi _api = AuthApi(baseUrl: _kApiUrl); + late final AuthApi _api = AuthApi(baseUrl: kApiBaseUrl); bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text); diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index 16bb469..62fded1 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,10 +7,16 @@ import Foundation import flutter_secure_storage_macos import package_info_plus +import screen_retriever_macos import shared_preferences_foundation +import tray_manager +import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/client/macos/PacketTunnel/Info.plist b/client/macos/PacketTunnel/Info.plist index c788ed7..0828c9d 100644 --- a/client/macos/PacketTunnel/Info.plist +++ b/client/macos/PacketTunnel/Info.plist @@ -2,12 +2,21 @@ + + NSSystemExtensionUsageDescription + Pangolin 需要安装网络扩展以提供安全的网络连接。 NetworkExtension + NEMachServiceName - $(TeamIdentifierPrefix)group.com.pangolin.pangolin + BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel NEProviderClasses com.apple.networkextension.packet-tunnel diff --git a/client/macos/PacketTunnel/PacketTunnel.entitlements b/client/macos/PacketTunnel/PacketTunnel.entitlements index 3482818..0bc1e86 100644 --- a/client/macos/PacketTunnel/PacketTunnel.entitlements +++ b/client/macos/PacketTunnel/PacketTunnel.entitlements @@ -2,20 +2,26 @@ - + + com.apple.security.get-task-allow + + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.developer.networking.networkextension - packet-tunnel-provider + packet-tunnel-provider-systemextension - + com.apple.security.application-groups - group.com.pangolin.pangolin + BYL4KQHMTN.com.pangolin.pangolin diff --git a/client/macos/PacketTunnel/PacketTunnelProvider.swift b/client/macos/PacketTunnel/PacketTunnelProvider.swift index 4205c9e..4f950fa 100644 --- a/client/macos/PacketTunnel/PacketTunnelProvider.swift +++ b/client/macos/PacketTunnel/PacketTunnelProvider.swift @@ -15,7 +15,9 @@ import NetworkExtension import os private let log = Logger(subsystem: "com.pangolin.pangolin.PacketTunnel", category: "provider") -private let appGroup = "group.com.pangolin.pangolin" +// macOS 原生 App Group 格式 .(非 iOS 的 group. 前缀)。 +// sysextd 校验请求方 app 时要求此格式;用 group. 式会在 realize 暂存前被拒。 +private let appGroup = "BYL4KQHMTN.com.pangolin.pangolin" final class PacketTunnelProvider: NEPacketTunnelProvider { private var commandServer: LibboxCommandServer? @@ -25,7 +27,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler: @escaping (Error?) -> Void) { log.info("startTunnel") do { - let configContent = try resolveConfig(options) + let rawConfig = try resolveConfig(options) guard let base = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { @@ -34,6 +36,10 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { let work = base.appendingPathComponent("work", isDirectory: true) try? FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + // 【临时】剥掉远程 rule-set(#5);并把 sing-box 日志直写容器 box.log(debug)以定位启动卡点。 + let configContent = Self.stripRemoteRuleSets( + rawConfig, logOutput: base.appendingPathComponent("box.log").path) + let setup = LibboxSetupOptions() setup.basePath = base.path setup.workingPath = work.path @@ -50,13 +56,17 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { throw newErr ?? simpleError("LibboxNewCommandServer returned nil") } try server.start() - try server.startOrReloadService(configContent, options: nil) + // 必须传非空 options:此版本 libbox 的 StartOrReloadService 会解引用 options, + // 传 nil 会在 command_server.go:175 触发 SIGSEGV(空指针),扩展进程崩溃。 + try server.startOrReloadService(configContent, options: LibboxOverrideOptions()) self.commandServer = server log.info("startTunnel: service started") completionHandler(nil) } catch { - log.error("startTunnel failed: \(error.localizedDescription)") + // 【临时诊断】完整错误写进 stderr.log;并设 public 避免 os_log 脱敏成 。 + FileHandle.standardError.write("startTunnel failed: \(error)\n".data(using: .utf8)!) + log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)") completionHandler(error) } } @@ -90,13 +100,33 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { guard !content.isEmpty else { throw simpleError("empty sing-box config") } return content } + + // 【临时】移除配置里的远程 rule-set 定义,以及 route.rules 中引用了 rule_set 的规则。 + // 解析失败则原样返回。正式分流方案应改用本地 .srs(客户端预取)。 + static func stripRemoteRuleSets(_ json: String, logOutput: String) -> String { + guard let data = json.data(using: .utf8), + var root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + var route = root["route"] as? [String: Any] else { + return json + } + if route["rule_set"] != nil { route.removeValue(forKey: "rule_set") } + if let rules = route["rules"] as? [[String: Any]] { + route["rules"] = rules.filter { $0["rule_set"] == nil } + } + root["route"] = route + // 【临时诊断】sing-box 日志直写容器 box.log(debug),定位启动卡点。 + root["log"] = ["level": "debug", "timestamp": true, "output": logOutput] + guard let out = try? JSONSerialization.data(withJSONObject: root), + let s = String(data: out, encoding: .utf8) else { return json } + return s + } } // ── LibboxCommandServerHandler:服务端事件回调 ────────────────────────────── extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol { func serviceReload() throws { if let cfg = try? resolveConfig(nil) { - try commandServer?.startOrReloadService(cfg, options: nil) + try commandServer?.startOrReloadService(cfg, options: LibboxOverrideOptions()) } } func serviceStop() throws { cancelTunnelWithError(nil) } @@ -106,7 +136,15 @@ extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol { } func setSystemProxyEnabled(_ enabled: Bool) throws {} func writeStatus(_ message: LibboxStatusMessage?) {} - func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {} + func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) { + // 【临时诊断】把 sing-box 日志写进 stderr.log,定位启动卡点。 + guard let it = messageList else { return } + while it.hasNext() { + guard let entry = it.next() else { continue } + let line = "[box] \(entry.message)\n" + FileHandle.standardError.write(line.data(using: .utf8) ?? Data()) + } + } func writeConnectionEvents(_ events: LibboxConnectionEvents?) {} func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) {} func writeDebugMessage(_ message: String?) {} @@ -170,6 +208,11 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol func startDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws { let m = NWPathMonitor() monitor = m + // 必须阻塞到首个 path 更新再返回:否则 sing-box 紧接着下载远程 rule-set 时 + // defaultInterfaceIndex 仍为 -1,autoDetectControl 跳过绑接口 → "no available + // network interface"。对齐 sing-box-for-apple 的实现。 + let firstUpdate = DispatchSemaphore(value: 0) + var signaled = false m.pathUpdateHandler = { [weak self] path in guard let self else { return } let iface = path.availableInterfaces.first { path.usesInterfaceType($0.type) } @@ -180,8 +223,10 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol listener?.updateDefaultInterface(name, interfaceIndex: index, isExpensive: path.isExpensive, isConstrained: path.isConstrained) + if !signaled { signaled = true; firstUpdate.signal() } } m.start(queue: monitorQueue) + _ = firstUpdate.wait(timeout: .now() + 5) } func closeDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws { stopMonitor() diff --git a/client/macos/PacketTunnel/main.swift b/client/macos/PacketTunnel/main.swift index c1fdc59..9680633 100644 --- a/client/macos/PacketTunnel/main.swift +++ b/client/macos/PacketTunnel/main.swift @@ -7,6 +7,17 @@ import Foundation import NetworkExtension +// 【临时诊断】把 stderr/stdout 重定向到 App Group 容器文件,捕获 libbox(Go)的 +// fatal error / panic——系统扩展默认不把 stderr 收进统一日志。诊断完移除。 +if let c = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "BYL4KQHMTN.com.pangolin.pangolin") { + let p = c.appendingPathComponent("stderr.log").path + freopen(p, "a", stderr) + freopen(p, "a", stdout) + setvbuf(stderr, nil, _IONBF, 0) + FileHandle.standardError.write("=== boot \(Date()) ===\n".data(using: .utf8)!) +} + autoreleasepool { NEProvider.startSystemExtensionMode() } diff --git a/client/macos/Runner.xcodeproj/project.pbxproj b/client/macos/Runner.xcodeproj/project.pbxproj index ce3f5c2..88e9832 100644 --- a/client/macos/Runner.xcodeproj/project.pbxproj +++ b/client/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 70; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -92,7 +92,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - A17B79F72FE52309001ABF28 /* Libbox.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -137,7 +136,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - A17B79EE2FE50746001ABF28 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + A17B79EE2FE50746001ABF28 /* Exceptions for "PacketTunnel" folder in "PacketTunnel" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, @@ -147,7 +146,18 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - A17B79E22FE50746001ABF28 /* PacketTunnel */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A17B79EE2FE50746001ABF28 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = PacketTunnel; sourceTree = ""; }; + A17B79E22FE50746001ABF28 /* PacketTunnel */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A17B79EE2FE50746001ABF28 /* Exceptions for "PacketTunnel" folder in "PacketTunnel" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = PacketTunnel; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -338,6 +348,20 @@ productReference = 33CC10ED2044A3C60003C045 /* pangolin_vpn.app */; productType = "com.apple.product-type.application"; }; + FACE0FF0FACE0FF0FACE0001 /* Sign Libbox (Developer ID) */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Sign Libbox (Developer ID)"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "bash \"${SRCROOT}/sign_libbox.sh\"\n"; + }; A17B79DE2FE50745001ABF28 /* PacketTunnel */ = { isa = PBXNativeTarget; buildConfigurationList = A17B79EF2FE50746001ABF28 /* Build configuration list for PBXNativeTarget "PacketTunnel" */; @@ -346,6 +370,7 @@ A17B79DC2FE50745001ABF28 /* Frameworks */, A17B79DD2FE50745001ABF28 /* Resources */, A17B79F82FE52309001ABF28 /* Embed Frameworks */, + FACE0FF0FACE0FF0FACE0001 /* Sign Libbox (Developer ID) */, ); buildRules = ( ); @@ -355,8 +380,6 @@ A17B79E22FE50746001ABF28 /* PacketTunnel */, ); name = PacketTunnel; - packageProductDependencies = ( - ); productName = PacketTunnel; productReference = A17B79DF2FE50745001ABF28 /* PacketTunnel.systemextension */; productType = "com.apple.product-type.system-extension"; @@ -379,7 +402,6 @@ 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.Sandbox = { enabled = 1; @@ -405,7 +427,7 @@ ); mainGroup = 33CC10E42044A3C60003C045; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; @@ -536,14 +558,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -616,7 +634,7 @@ baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -631,7 +649,7 @@ baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -646,7 +664,7 @@ baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -712,11 +730,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - ENABLE_HARDENED_RUNTIME = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = BYL4KQHMTN; + ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -847,11 +865,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - ENABLE_HARDENED_RUNTIME = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = BYL4KQHMTN; + ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -870,17 +888,18 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - ENABLE_HARDENED_RUNTIME = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = BYL4KQHMTN; + ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - PROVISIONING_PROFILE_SPECIFIER = ""; + OTHER_CODE_SIGN_FLAGS = "--timestamp"; + PROVISIONING_PROFILE_SPECIFIER = "Pangolin App DevID"; SWIFT_VERSION = 5.0; }; name = Release; @@ -914,13 +933,13 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_WARN_UNDECLARED_SELECTOR = YES; GENERATE_INFOPLIST_FILE = YES; @@ -933,12 +952,14 @@ "@executable_path/../../../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; @@ -962,13 +983,13 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_WARN_UNDECLARED_SELECTOR = YES; GENERATE_INFOPLIST_FILE = YES; @@ -981,11 +1002,14 @@ "@executable_path/../../../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; + OTHER_CODE_SIGN_FLAGS = "--timestamp"; + OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel; + PROVISIONING_PROFILE_SPECIFIER = "Pangolin PacketTunnel DevID"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -1008,13 +1032,13 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_WARN_UNDECLARED_SELECTOR = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1027,11 +1051,12 @@ "@executable_path/../../../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -1097,7 +1122,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; diff --git a/client/macos/Runner/DebugProfile.entitlements b/client/macos/Runner/DebugProfile.entitlements index e5ff371..ffa43a8 100644 --- a/client/macos/Runner/DebugProfile.entitlements +++ b/client/macos/Runner/DebugProfile.entitlements @@ -10,6 +10,14 @@ com.apple.security.network.server + + com.apple.developer.system-extension.install + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + keychain-access-groups diff --git a/client/macos/Runner/Info.plist b/client/macos/Runner/Info.plist index 4789daa..d43b0ba 100644 --- a/client/macos/Runner/Info.plist +++ b/client/macos/Runner/Info.plist @@ -28,5 +28,8 @@ MainMenu NSPrincipalClass NSApplication + + NSSystemExtensionUsageDescription + Pangolin 需要安装系统扩展以提供安全的网络连接。 diff --git a/client/macos/Runner/Release.entitlements b/client/macos/Runner/Release.entitlements index 348cec0..020cb74 100644 --- a/client/macos/Runner/Release.entitlements +++ b/client/macos/Runner/Release.entitlements @@ -2,20 +2,33 @@ + + com.apple.security.get-task-allow + com.apple.security.app-sandbox com.apple.security.network.client + + com.apple.developer.system-extension.install + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider-systemextension + keychain-access-groups $(AppIdentifierPrefix)com.pangolin.pangolin - com.apple.security.application-groups - group.com.pangolin.pangolin + BYL4KQHMTN.com.pangolin.pangolin - --> diff --git a/client/macos/Runner/VpnChannel.swift b/client/macos/Runner/VpnChannel.swift index 55f8b29..0d9e295 100644 --- a/client/macos/Runner/VpnChannel.swift +++ b/client/macos/Runner/VpnChannel.swift @@ -13,9 +13,16 @@ import FlutterMacOS import NetworkExtension import SystemExtensions +import os.log -// NSLog 兼容老部署目标(Runner < macOS 11,os.Logger 不可用)。 -private func vpnLog(_ message: String) { NSLog("[pangolin/vpn] %@", message) } +// os_log + %{public} —— 让日志在 Console.app / `log show` 里可见(NSLog 的 %@ 参数会被 +// 系统 redact 成 ,排障时看不到内容)。os_log(C API)自 macOS 10.12 起可用, +// 兼容 Runner 的 10.15 部署目标。过滤:`log show --predicate 'subsystem == "com.pangolin.pangolin"'`。 +private let vpnLogObj = OSLog(subsystem: "com.pangolin.pangolin", category: "vpn") +private func vpnLog(_ message: String) { + os_log("%{public}@", log: vpnLogObj, type: .default, message) + NSLog("[pangolin/vpn] %@", message) // 同时进 stderr,flutter run 控制台也能看到 +} final class VpnChannel: NSObject { private static let tunnelBundleId = "com.pangolin.pangolin.PacketTunnel" @@ -68,16 +75,26 @@ final class VpnChannel: NSObject { } private func start(_ configJson: String, _ result: @escaping FlutterResult) async { + vpnLog("start() 收到调用, config 长度=\(configJson.count) bytes") do { + vpnLog("step① 激活 System Extension …") try await activateSystemExtensionIfNeeded() + vpnLog("step① System Extension 激活完成 ✓") + + vpnLog("step② 装配 NETunnelProviderManager …") let mgr = try await loadOrCreateManager() self.manager = mgr + vpnLog("step② manager 就绪, 当前隧道状态=\(Self.statusString(mgr.connection.status))") + + vpnLog("step③ startVPNTunnel(options: configContent) …") try mgr.connection.startVPNTunnel(options: [ "configContent": configJson as NSString, ]) + vpnLog("step③ startVPNTunnel 调用已返回(实际起停由 NEVPNStatus 流驱动)✓") result(nil) } catch { - vpnLog("start failed: \(error.localizedDescription)") + let ns = error as NSError + vpnLog("start FAILED ✗ domain=\(ns.domain) code=\(ns.code) desc=\(ns.localizedDescription) userInfo=\(ns.userInfo)") result(FlutterError(code: "start_failed", message: error.localizedDescription, details: nil)) } } @@ -90,6 +107,7 @@ final class VpnChannel: NSObject { // ── NETunnelProviderManager 装配 ──────────────────────────────── private func loadOrCreateManager() async throws -> NETunnelProviderManager { let all = try await NETunnelProviderManager.loadAllFromPreferences() + vpnLog(" loadAllFromPreferences: 已有 \(all.count) 个 VPN 配置") let mgr = all.first ?? NETunnelProviderManager() let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) ?? NETunnelProviderProtocol() proto.providerBundleIdentifier = Self.tunnelBundleId @@ -97,14 +115,20 @@ final class VpnChannel: NSObject { mgr.protocolConfiguration = proto mgr.localizedDescription = "Pangolin" mgr.isEnabled = true + vpnLog(" saveToPreferences(providerBundleId=\(Self.tunnelBundleId)) …") try await mgr.saveToPreferences() try await mgr.loadFromPreferences() // 保存后重载,拿到有效 connection + vpnLog(" manager 保存+重载完成 ✓") return mgr } // 请求系统加载/更新 PacketTunnel System Extension。首启系统会弹「隐私与安全性」 // 让用户允许;允许后 didFinishWithResult 回来。已是最新则快速完成。 private func activateSystemExtensionIfNeeded() async throws { + vpnLog(" 提交 OSSystemExtensionRequest.activationRequest(id=\(Self.tunnelBundleId)) …") + vpnLog(" 主 bundle=\(Bundle.main.bundlePath)") + let sysextDir = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/SystemExtensions").path + vpnLog(" SystemExtensions 目录=\(sysextDir) 内容=\((try? FileManager.default.contentsOfDirectory(atPath: sysextDir)) ?? ["<读取失败>"])") try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in let req = OSSystemExtensionRequest.activationRequest( forExtensionWithIdentifier: Self.tunnelBundleId, queue: .main) @@ -112,6 +136,7 @@ final class VpnChannel: NSObject { self.sysextDelegate = delegate // 保活到回调结束 req.delegate = delegate OSSystemExtensionManager.shared.submitRequest(req) + vpnLog(" submitRequest 已提交, 等待 sysextd 回调(didFinish / didFail / needsApproval)…") } } @@ -121,7 +146,9 @@ final class VpnChannel: NSObject { forName: .NEVPNStatusDidChange, object: nil, queue: .main ) { [weak self] note in guard let conn = note.object as? NEVPNConnection else { return } - self?.statusSink?(Self.statusString(conn.status)) + let s = Self.statusString(conn.status) + vpnLog("NEVPNStatus 变化 → \(s) (raw=\(conn.status.rawValue))") + self?.statusSink?(s) } } @@ -180,21 +207,28 @@ private final class SysExtActivationDelegate: NSObject, OSSystemExtensionRequest func request(_ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) { + vpnLog("sysext didFinishWithResult ✓ result=\(result.rawValue) (0=completed, 1=willCompleteAfterReboot)") guard !resumed else { return } resumed = true continuation.resume() } func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) { + let ns = error as NSError + // OSSystemExtensionErrorDomain code 速查:1 unknown,2 missingEntitlement, + // 3 unsupportedParentBundleLocation,4 extensionNotFound,8 codeSignatureInvalid, + // 9 validationFailed,10 forbiddenBySystemPolicy,13 authorizationRequired。 + vpnLog("sysext didFailWithError ✗ domain=\(ns.domain) code=\(ns.code) desc=\(ns.localizedDescription)") guard !resumed else { return } resumed = true continuation.resume(throwing: error) } func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { - vpnLog("system extension 待用户允许(系统设置 → 隐私与安全性)") + vpnLog("sysext requestNeedsUserApproval —— 需在 系统设置 → 隐私与安全性 点「允许」(等待中…)") } func request(_ request: OSSystemExtensionRequest, actionForReplacingExtension existing: OSSystemExtensionProperties, withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction { - .replace + vpnLog("sysext 替换扩展: 已装 v\(existing.bundleVersion)/\(existing.bundleShortVersion) → 新 v\(ext.bundleVersion)/\(ext.bundleShortVersion), 选择 replace") + return .replace } } diff --git a/client/macos/sign_libbox.sh b/client/macos/sign_libbox.sh new file mode 100755 index 0000000..aea1c88 --- /dev/null +++ b/client/macos/sign_libbox.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# 构建期重签 PacketTunnel 内嵌的 Libbox.framework 为当前签名身份(Developer ID)。 +# 原因:Libbox 是 gomobile xcframework,内部二进制是 adhoc/linker-signed,Xcode 的 +# CodeSignOnCopy 不会替换它 → 公证会拒。本脚本在 sysext 被 Xcode 封签之前重签 Libbox, +# 使其 Developer ID + 带时间戳,避免事后手动重签(那会破坏框架对扩展的校验)。 +set -e +LIBBOX="${CODESIGNING_FOLDER_PATH}/Contents/Frameworks/Libbox.framework" +[ -d "$LIBBOX" ] || { echo "sign_libbox: 未找到 $LIBBOX,跳过"; exit 0; } +[ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ] || { echo "sign_libbox: 无签名身份,跳过"; exit 0; } +echo "sign_libbox: 用 ${EXPANDED_CODE_SIGN_IDENTITY_NAME:-$EXPANDED_CODE_SIGN_IDENTITY} 重签 Libbox" +/usr/bin/codesign --force --options runtime --timestamp -s "${EXPANDED_CODE_SIGN_IDENTITY}" "$LIBBOX/Versions/A/Libbox" +/usr/bin/codesign --force --options runtime --timestamp -s "${EXPANDED_CODE_SIGN_IDENTITY}" "$LIBBOX" +echo "sign_libbox: 完成" diff --git a/docs/macos-sysext-realize-troubleshooting.html b/docs/macos-sysext-realize-troubleshooting.html new file mode 100644 index 0000000..af7e451 --- /dev/null +++ b/docs/macos-sysext-realize-troubleshooting.html @@ -0,0 +1,186 @@ + + + + + +macOS PacketTunnel 系统扩展 realize 失败(code=4)踩坑复盘 + + + +
+ +

macOS PacketTunnel 系统扩展 realize 失败(OSSystemExtensionErrorDomain code=4)踩坑复盘

+

Pangolin 客户端 · P1 原生隧道 · 2026-06-21 · 在 macOS 15.3.2 / 26 上排查与修复

+ +
+一句话结论: 客户端内嵌的 PacketTunnel 系统扩展(NEPacketTunnelProvider)一直无法激活,报 +code=4 "Extension not found in App bundle. Unable to find any matched extension"。 +表面像"找不到扩展",实则是 三个叠加的工程配置 bugsysextd 在 realize/暂存/分类校验三个不同阶段先后拒绝。 +逐个修复后扩展成功 activated enabled、隧道连通。 +
+ +

1. 现象

+

客户端点"连接"时,主 app 调 OSSystemExtensionRequest.activationRequest 激活内嵌的 packet-tunnel 系统扩展,但回调恒为失败:

+
sysext didFailWithError ✗ domain=OSSystemExtensionErrorDomain code=4
+desc=Extension not found in App bundle. Unable to find any matched extension
+     with identifier: com.pangolin.pangolin.PacketTunnel
+

诡异之处:

+
    +
  • 扩展明明在 Contents/Library/SystemExtensions/ 里,sysextd 日志也打了 attempting to realize(说明找到了)。
  • +
  • 签名、公证、staple、Gatekeeper 全部通过;codesign --verify --deep --strict 通过。
  • +
  • sysextd 在验签通过后 ~10ms 内静默 xpc_connection_cancel,自身不打印任何拒绝原因。
  • +
  • 从不弹"允许扩展"批准框——在批准阶段之前就被拒。
  • +
+ +

2. 走过的弯路(全部排除)

+

以下都查过并确认不是根因,记录下来免得后人重复:

+ + + + + + + + + + +
假设结论
签名 / 公证 / 时间戳 / hardened runtime / get-task-allow全部正确
entitlements 变体(packet-tunnel-provider-systemextension)正确,且描述文件已授权
Info.plist 用 NetworkExtension/NEProviderClasses(而非 appex 的 NSExtension)正确(sysext 就该用这个)
Xcode 26 beta / macOS 26.2 SDK 工具链太新证伪:Tailscale 用更新的 SDK 26.5 照样能用
App 文件归属(user vs root:wheel)改 root:wheel 无效
App 不在 /Applications / LaunchServices 注册 / 翻译重定位都正常,无效
systemextensionsctl reset 清缓存无效
App Group 用 iOS 式 group. 前缀确实该改成 macOS 原生格式,但单独改它仍失败
+ +

3. 破局方法:在同一台机器上对照一个"能用的"开源同类

+

关键转折是不再盲猜,而是拿一个已知能用、且分发模型完全相同的开源 app 在同一台机器上对照。我们选了 Tailscale 独立版(io.tailscale.ipn.macsys):同样是 Developer ID 公证 + NEPacketTunnelProvider 系统扩展分发(非 App Store appex)。

+
+

把本机能用的 Tailscale.app 直接拷到出问题的测试机(cara,干净 macOS 15.3.2,Intel),它的扩展一路走完 validating → staging → validating_by_category → activated_waiting_for_user 并弹出"允许扩展"框。

+

这一步同时证明了两件事:

+
    +
  • cara 的环境完全正常——能 realize 第三方 Developer ID 系统扩展。
  • +
  • 所以 100% 是我们自己的包有问题(用户的直觉:"是我们代码的问题")。
  • +
+
+

附:也对照了 Shadowrocket,但它是 App Store 的 appex 模型(NSExtension、系统预信任、不走 sysextd 批准流),与我们不是一个机制,不能直接类比。要对照必须找同为 Developer ID system extension 的实现。

+ +

4. 三个根因(按 sysextd 处理阶段排序)

+ +
+

根因 ① 扩展不自包含 staging 前被拒

+

这是 Flutter + 扩展工程的经典坑。工程用 CocoaPods,项目级配置经 Release.xcconfig 注入了链接所有 pod 框架的 OTHER_LDFLAGSPacketTunnel target 没有自己的 OTHER_LDFLAGS,于是继承了项目级的,把主 app 的 Flutter 插件 flutter_secure_storage_macos.framework 也链进了扩展——而该框架并不在扩展 bundle 内:

+
$ otool -L PacketTunnel.systemextension/Contents/MacOS/PacketTunnel
+  @rpath/flutter_secure_storage_macos.framework/.../flutter_secure_storage_macos  ← 悬空依赖!
+  /System/Library/Frameworks/...
+

同时,gomobile 产出的 Libbox.xcframework 实为静态库(ar archive),已被静态链进扩展二进制,却又被 Embed Frameworks 冗余地塞了一份畸形的 Libbox.framework 进扩展。

+

system extension 必须自包含(realize 时会被拷到 /Library/SystemExtensions/<UUID>/ 独立运行,相对 rpath 回不到主 app)。

+

修复:

+
    +
  • PacketTunnel 的 Debug/Release 配置加 OTHER_LDFLAGS = "";,切断对项目级 pod 链接标志的继承。
  • +
  • Embed Frameworks 移除 Libbox.xcframework(它是静态库,只需 Link,不需 Embed)。
  • +
+

验证:otool -L 扩展二进制应只剩 /System/.../usr/lib/...,无任何 @rpath;Contents/ 下不再有 Frameworks/ 目录(与 Tailscale 一致)。

+
+ +
+

根因 ② 扩展 bundle 名 ≠ bundle 标识符 staging 阶段

+

我们扩展的产物名是 PacketTunnel.systemextension,而 CFBundleIdentifiercom.pangolin.pangolin.PacketTunnel。Tailscale 的产物名 = 标识符(io.tailscale.ipn.macsys.network-extension.systemextension)。

+

在受影响的 macOS 上,sysextd 必须能按标识符把 bundle 拷进暂存区;名字对不上时,在 attempting to realize 之后直接静默 cancel,进不了 staging。改名后日志立刻出现 [Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension

+

修复:PacketTunnel target 的 PRODUCT_NAME$(TARGET_NAME) 改为 com.pangolin.pangolin.PacketTunnel(三个配置 Debug/Release/Profile 都改)。

+

连带:PRODUCT_MODULE_NAME 会变成 com_pangolin_pangolin_PacketTunnel,而 Info.plist 的 NEProviderClasses$(PRODUCT_MODULE_NAME).PacketTunnelProvider 自动跟随,无需手改。

+
+ +
+

根因 ③ 扩展 Info.plist 缺 NSSystemExtensionUsageDescription category 校验阶段

+

过了 staging 后,日志终于给出明确原因:

+
com.pangolin.pangolin.PacketTunnel: extension failed category property check:
+extensions belonging to the com.apple.system_extension.network_extension category
+require the presence of the 'NSSystemExtensionUsageDescription' property.
+→ uninstalling invalid extension
+

网络扩展类别强制要求扩展自己的 Info.plist 里有 NSSystemExtensionUsageDescription。我们之前只在主 app 的 Info.plist 加了——不顶用。Tailscale 的扩展 Info.plist 里就有这个键。

+

修复:PacketTunnel/Info.plist 顶层加:

+
<key>NSSystemExtensionUsageDescription</key>
+<string>Pangolin 需要安装网络扩展以提供安全的网络连接。</string>
+
+ +

附带一并修正的项

+
    +
  • App Group 格式:group.com.pangolin.pangolin(iOS 式)→ macOS 原生 BYL4KQHMTN.com.pangolin.pangolin(主 app + 扩展 entitlements + Swift appGroup 常量三处同步)。
  • +
  • NEMachServiceName:必须以扩展所属 App Group 为前缀 → BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel
  • +
  • 扩展网络权限:沙箱扩展补 com.apple.security.network.client / network.server(packet tunnel 要对外建连)。
  • +
  • 公证前置:get-task-allow=false、Xcode 签名加 --timestamp(OTHER_CODE_SIGN_FLAGS)、构建期把静态 Libbox 以 Developer ID 重签。
  • +
+ +

5. 成功的 sysextd 日志长这样

+
attempting to realize extension with identifier com.pangolin.pangolin.PacketTunnel
+[Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension
+advancing state from staging to validating
+advancing state from validating to validating_by_category
+Category delegate com.apple.system_extension.network_extension returned error (null)
+advancing state ... to activated_waiting_for_user
+observer 'notify user' reached a success state    # ← 弹"允许扩展"框
+
+# 用户在 系统设置 → 隐私与安全性 点允许后:
+sysext didFinishWithResult ✓ result=0 (completed)
+$ systemextensionsctl list
+  * * BYL4KQHMTN com.pangolin.pangolin.PacketTunnel (1.0/1) [activated enabled]
+ +

6. 给后人的排查 checklist(Developer ID 网络系统扩展)

+
    +
  1. 先找同模型的能用实现对照(Tailscale 独立版 / Mullvad),在同一台机器上验证环境没问题,把范围锁到自己的包。
  2. +
  3. 扩展二进制 otool -L 必须@rpath 外部依赖(自包含)。警惕 CocoaPods/Flutter 的 OTHER_LDFLAGS 继承。
  4. +
  5. 扩展 bundle 名 = CFBundleIdentifier(设 PRODUCT_NAME = 标识符)。
  6. +
  7. 扩展 Info.plist 必须有 NSSystemExtensionUsageDescription(不是主 app 的)。
  8. +
  9. App Group 用 macOS 原生 <TeamID>.<name>;NEMachServiceName 以该 group 为前缀。
  10. +
  11. 沙箱扩展按需补 network.client/server、文件访问等能力。
  12. +
  13. 静态库 xcframework 只 LinkEmbed;动态 framework 才需 Embed + CodeSignOnCopy。
  14. +
  15. 排查时:log stream --debug --predicate 'process=="sysextd" OR process=="syspolicyd"',看卡在 realize / staging / validating_by_category 哪一步;category 校验失败会打出明确缺什么键。
  16. +
+ +

7. 已知环境坑:macOS 26 (Tahoe) 回归

+
+

在 macOS 26 上,即使包完全正确,sysextd 仍可能报 +no policy, cannot allow apps outside /Applications(app 明明在 /Applications)。这是 Apple 在 Tahoe 的已知回归(开发者论坛多人复现、非 MDM 个人机),不是我们能修的,也不影响 macOS 15/14 的真实用户。开发机若是 26,本机调试可考虑关 SIP 后开 +systemextensionsctl developer on,或在 15/14 机器上验证。

+
+ +
+

改动文件:client/macos/PacketTunnel/Info.plist · client/macos/PacketTunnel/PacketTunnel.entitlements · client/macos/Runner/Release.entitlements · client/macos/PacketTunnel/PacketTunnelProvider.swift · client/macos/Runner.xcodeproj/project.pbxproj(OTHER_LDFLAGS/PRODUCT_NAME/移除 Embed Libbox/加 timestamp)。验证机:cara,干净 macOS 15.3.2 Intel。

+ +
+ + diff --git a/scripts/local_test.sh b/scripts/local_test.sh index c481ef4..eb9f157 100755 --- a/scripts/local_test.sh +++ b/scripts/local_test.sh @@ -1,65 +1,167 @@ #!/usr/bin/env bash -# local_test.sh —— 一键构建并运行【release】版 macOS 客户端,连真实节点做端到端验证。 # -# 用真正的 release 包(不是 dev 模拟),前台运行以便直接看内核(sing-box)日志。 -# 改完代码直接跑此脚本即可。 +# local_test.sh — macOS 客户端本地联调一条龙(Developer ID 重签 + 装 + 跑)。 # -# bash scripts/local_test.sh # 构建 release 并运行 -# bash scripts/local_test.sh --no-build # 跳过构建,直接跑上次的产物(快速重跑) +# 背景:flutter build 出来的包是 Apple Development 签名;要让 System Extension +# 能加载(且不依赖 SIP/dev mode),需重签成 Developer ID。本脚本封装这套流程。 # -# 可选环境变量: -# PANGOLIN_API_URL 控制面地址(默认连 racknerd 节点) -# SINGBOX_BIN sing-box 路径(默认 brew 的 /opt/homebrew/bin/sing-box) +# 子命令: +# build flutter build macos --release(注入联调节点地址) +# sign Developer ID 内向外重签整个 app + PacketTunnel sysext(嵌 DevID profile) +# copy 覆盖安装到 /Applications +# run 直跑 /Applications 版本(绕 Gatekeeper,实时输出日志,并打印测试账号) +# all 依次执行 build → sign → copy → run # -# 前置:flutter、sing-box(brew install sing-box)、已在 Xcode 配好签名团队。 -# TUN 需 root → 脚本会装一条 NOPASSWD sudoers(一次性,可能让你输一次电脑密码)。 -# 正式版会用 SMJobBless 特权 Helper 取代 sudo(BACKLOG-11D-HELPER)。 +# 用法: scripts/local_test.sh all | scripts/local_test.sh build ... set -euo pipefail -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# ─────────── 配置(按需改)─────────── +API_URL="http://103.119.13.48:8080" # 联调控制面;发版改这里或走默认 +SIGN_ID="Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)" +APP_PROFILE_NAME="Pangolin App DevID" # 主 app 的 Developer ID 描述文件名 +SE_PROFILE_NAME="Pangolin PacketTunnel DevID" # PacketTunnel 的描述文件名 +TEST_EMAIL="wang880812@gmail.com" # 联调测试账号 +TEST_PASSWORD="pangolin2026" +NOTARY_PROFILE="pangolin-notary" # notarytool 凭据(已存入钥匙串) +# 注:DevID profile 已含 system-extension.install + NE(-systemextension 变体); +# app/sysext entitlements 与之对齐(见 write_entitlements)。 + +# ─────────── 路径推导 ─────────── +SRC="${BASH_SOURCE[0]}" +DIR="${SRC%/*}"; [ "$DIR" = "$SRC" ] && DIR="." +cd "$DIR/.."; REPO_ROOT="$PWD" CLIENT="$REPO_ROOT/client" -API_URL="${PANGOLIN_API_URL:-http://107.172.55.251:8080}" -SINGBOX="${SINGBOX_BIN:-/opt/homebrew/bin/sing-box}" -NO_BUILD=0 -[ "${1:-}" = "--no-build" ] && NO_BUILD=1 +APP="$CLIENT/build/macos/Build/Products/Release/pangolin_vpn.app" +SE="$APP/Contents/Library/SystemExtensions/PacketTunnel.systemextension" +LIBFW="$SE/Contents/Frameworks/Libbox.framework" +PROF_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" +WORK="${TMPDIR:-/tmp}/pangolin_local_test"; mkdir -p "$WORK" -command -v flutter >/dev/null 2>&1 || { echo "✗ 需要 flutter"; exit 1; } -[ -x "$SINGBOX" ] || { echo "✗ 找不到 sing-box: $SINGBOX(brew install sing-box,或设 SINGBOX_BIN)"; exit 1; } +log(){ printf '\033[1;33m== %s ==\033[0m\n' "$*"; } +die(){ printf '\033[1;31m❌ %s\033[0m\n' "$*" >&2; exit 1; } -# ── 1. TUN 免密 sudoers(已生效则跳过,避免反复要密码)───────────────────────── -echo "==> [1/3] sing-box 免密 sudo(TUN 需 root)" -if sudo -n "$SINGBOX" version >/dev/null 2>&1; then - echo " 已生效,跳过(无需密码)" -else - SUDO_FILE=/etc/sudoers.d/pangolin-singbox - WANT="$(whoami) ALL=(root) NOPASSWD: $SINGBOX" - TMP="$(mktemp)" - printf '%s\n' "$WANT" > "$TMP" - if sudo visudo -cf "$TMP" >/dev/null 2>&1; then - sudo install -m 440 -o root -g wheel "$TMP" "$SUDO_FILE" - echo " 已写入 $SUDO_FILE: $WANT" - else - echo "✗ sudoers 语法校验失败"; rm -f "$TMP"; exit 1 - fi - rm -f "$TMP" -fi +# 按 Name 在描述文件目录里定位 .provisionprofile(结果写入全局 FOUND)。 +FOUND="" +find_profile(){ + local want="$1" f + for f in "$PROF_DIR"/*.provisionprofile; do + [ -e "$f" ] || continue + if security cms -D -i "$f" 2>/dev/null | grep -q ">$want<"; then + FOUND="$f"; return 0 + fi + done + return 1 +} -# ── 2. 构建 release(--no-build 跳过,直接跑上次产物)───────────────────────── -if [ "$NO_BUILD" = "1" ]; then - echo "==> [2/3] 跳过构建(--no-build)" -else - echo "==> [2/3] flutter build macos --release (API=$API_URL)" - ( cd "$CLIENT" && flutter build macos --release --dart-define=PANGOLIN_API_URL="$API_URL" ) -fi +write_entitlements(){ + cat > "$WORK/app.entitlements" <<'PLIST' + + + + com.apple.application-identifierBYL4KQHMTN.com.pangolin.pangolin + com.apple.developer.team-identifierBYL4KQHMTN + com.apple.developer.system-extension.install + com.apple.developer.networking.networkextension + packet-tunnel-provider-systemextension + com.apple.security.app-sandbox + com.apple.security.network.client + com.apple.security.network.server + keychain-access-groups + BYL4KQHMTN.com.pangolin.pangolin + +PLIST + cat > "$WORK/sysext.entitlements" <<'PLIST' + + + + com.apple.application-identifierBYL4KQHMTN.com.pangolin.pangolin.PacketTunnel + com.apple.developer.team-identifierBYL4KQHMTN + com.apple.developer.networking.networkextension + packet-tunnel-provider-systemextension + com.apple.security.app-sandbox + com.apple.security.application-groups + group.com.pangolin.pangolin + +PLIST +} -APP="$(ls -d "$CLIENT"/build/macos/Build/Products/Release/*.app 2>/dev/null | head -1)" -[ -n "$APP" ] || { echo "✗ 未找到构建产物 .app"; exit 1; } -EXE="$APP/Contents/MacOS/$(basename "$APP" .app)" +cs(){ codesign --force --options runtime --timestamp "$@"; } -# ── 3. 前台运行(日志直出)──────────────────────────────────────────────────── -echo "==> [3/3] 运行 release 包(前台,日志直出;Ctrl+C 退出)" -echo " app : $APP" -echo " 内核: 客户端会解析到 $SINGBOX(已加进默认查找路径)" -echo " 连上后另开终端验证出口: curl https://api.ipify.org → 期望 ${API_URL#http://}" -echo "------------------------------------------------------------------" -exec "$EXE" +cmd_build(){ + log "构建 release(API=$API_URL)" + cd "$CLIENT" + flutter build macos --release --dart-define="PANGOLIN_API_URL=$API_URL" + [ -d "$APP" ] || die "构建产物不存在: $APP" +} + +cmd_sign(){ + [ -d "$APP" ] || die "先 build:找不到 $APP" + log "Developer ID 重签" + write_entitlements + find_profile "$APP_PROFILE_NAME" || die "找不到描述文件: $APP_PROFILE_NAME" + local app_prof="$FOUND" + find_profile "$SE_PROFILE_NAME" || die "找不到描述文件: $SE_PROFILE_NAME" + local se_prof="$FOUND" + echo " app profile : $app_prof" + echo " sysext prof : $se_prof" + + xattr -cr "$APP" + cp "$app_prof" "$APP/Contents/embedded.provisionprofile" + cp "$se_prof" "$SE/Contents/embedded.provisionprofile" + + # 内向外:Libbox 真二进制 → Libbox.framework → sysext → app 各 framework → app 主体 + cs -s "$SIGN_ID" "$LIBFW/Versions/A/Libbox" + cs -s "$SIGN_ID" "$LIBFW" + cs --entitlements "$WORK/sysext.entitlements" -s "$SIGN_ID" "$SE" + local item + for item in "$APP/Contents/Frameworks/"*; do + [ -e "$item" ] && cs -s "$SIGN_ID" "$item" + done + cs --entitlements "$WORK/app.entitlements" -s "$SIGN_ID" "$APP" + + codesign --verify --deep --strict "$APP" || die "深度校验失败" + echo " ✅ 重签 + 校验通过($SIGN_ID)" +} + +cmd_notarize(){ + [ -d "$APP" ] || die "先 build/sign:找不到 $APP" + log "公证(notarytool submit --wait,通常 1-5 分钟)" + local zip="$WORK/pangolin_vpn.zip" + rm -f "$zip" + ditto -c -k --keepParent "$APP" "$zip" + xcrun notarytool submit "$zip" --keychain-profile "$NOTARY_PROFILE" --wait + log "staple 票据到 app" + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" && echo " ✅ 已公证 + staple" +} + +cmd_copy(){ + [ -d "$APP" ] || die "先 build/sign:找不到 $APP" + log "安装到 /Applications" + osascript -e 'quit app "pangolin_vpn"' 2>/dev/null || true + pkill -x pangolin_vpn 2>/dev/null || true + sleep 1 + rm -rf /Applications/pangolin_vpn.app + cp -R "$APP" /Applications/ + echo " ✅ /Applications/pangolin_vpn.app" +} + +cmd_run(){ + log "启动 /Applications/pangolin_vpn.app(直跑,日志见下)" + echo "" + echo " 测试账号: $TEST_EMAIL" + echo " 测试密码: $TEST_PASSWORD" + echo "" + exec /Applications/pangolin_vpn.app/Contents/MacOS/pangolin_vpn +} + +case "${1:-}" in + build) cmd_build ;; + sign) cmd_sign ;; # 旧:手动 Developer ID 重签(现已由 Xcode 工程配置直接签,通常不需要) + notarize) cmd_notarize ;; + copy) cmd_copy ;; + run) cmd_run ;; + # Xcode 工程已配 Developer ID 手动签名(Release 配置),build 即出 DevID 包,无需 sign。 + all) cmd_build; cmd_notarize; cmd_copy; cmd_run ;; + *) echo "用法: $0 {all|build|sign|notarize|copy|run}"; exit 2 ;; +esac From 833912bb588b4fccbb406d9dd02aca748051f100 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 21 Jun 2026 21:34:22 +0800 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E5=BA=9F?= =?UTF-8?q?=E5=BC=83=20skill=20.claude/skills/pangolin-design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计还原统一走全局 design-distill skill,项目内副本不再维护。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c --- .claude/skills/pangolin-design.md | 48 ------------------------------- 1 file changed, 48 deletions(-) delete mode 100644 .claude/skills/pangolin-design.md diff --git a/.claude/skills/pangolin-design.md b/.claude/skills/pangolin-design.md deleted file mode 100644 index 2ae62ea..0000000 --- a/.claude/skills/pangolin-design.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: pangolin-design -description: Use this skill to generate well-branded interfaces and assets for 穿山甲 (Pangolin) — for production (Flutter client, Go backend, web) or throwaway prototypes/mocks. Contains the full design system (tokens, type, brand assets), four React UI kits (mobile / desktop / website / usercenter), a ready-to-run Flutter token+widget package, and a backend architecture blueprint. Invoke whenever building or reproducing any Pangolin surface. -user-invocable: true ---- - -# 穿山甲 · Pangolin — 设计与实现技能 - -这套技能让你(含 Claude Code)**完全还原**穿山甲的设计并落地为产品。UI 以 `ui_kits/` 的 React 原型为像素基准;客户端生产代码用 Flutter;后端按 `server/ARCHITECTURE.md` 蓝本实现。 - -## 第一步:必读(顺序固定) -1. **`CLAUDE.md`** — 设计铁律 13 条(§1)、套餐口径单一来源(§7)、页面清单(§5)、**实施工作步骤(§9)**。先读它,§9 就是工作流。 -2. **`README.md`** — 品牌语境、内容基础、视觉基础、图标系统、文件索引。 -3. 按需:`colors_and_type.css`(令牌真相源)、`flutter/README.md`(Flutter 接入)、`server/ARCHITECTURE.md`(后端)。 - -## 这是什么品牌 -**穿山甲 / Pangolin** — 极简、轻量、亲和的跨平台消费级**网络加速应用**(对外一律不用"VPN"等红线词,见 CLAUDE.md 铁律 13)。暖大地色(穿山甲鳞甲)+ 大量留白 + 双语单显 + 深浅双主题。核心卖点:一键连接、智能选线、即开即用。 - -## 仓库地图 -| 路径 | 内容 | 用途 | -|---|---|---| -| `CLAUDE.md` | 铁律 / 套餐口径 / 页面清单 / **工作步骤 §9** | 一切工作的总纲 | -| `colors_and_type.css` | CSS 令牌(唯一真相源) | HTML/Web 直接链入 | -| `flutter/` | Dart 令牌镜像 + widgets + pubspec + main.dart | Flutter 客户端起步包,拿来即跑 | -| `ui_kits/mobile/` | 移动 App 完整原型(登录/引导/4 Tab/账户子页/滑动切换) | 客户端像素验收标准 | -| `ui_kits/desktop/` | 桌面客户端(920×600 侧栏布局 + 登录/引导) | 同上(桌面) | -| `ui_kits/website/` | 官网(产品/定价/下载/文档/Blog,响应式+i18n) | 官网迁移样板 | -| `ui_kits/usercenter/` | Web 用户中心(概览/订阅导入/兑换/邀请/设置含 2FA,移动适配) | 用户中心样板 | -| `server/ARCHITECTURE.md` | Go 控制面 + WireGuard 数据面蓝本(数据模型/API/流程/实现顺序) | 后端实现总纲 | -| `assets/` `preview/` | 品牌 SVG / 设计系统 specimen 卡 | 资产与规范预览 | - -## 工作步骤(交给 Claude Code 的执行计划) -完整版见 **CLAUDE.md §9**,摘要: - -1. **进场必读**:CLAUDE.md §1 → §7 → §5;再读 `colors_and_type.css` + 目标端的 UI Kit 源码。 -2. **Flutter 客户端**:用 `flutter/` 起步包建工程(pubspec 覆盖 → 拷 theme/widgets/main → 配字体 → `flutter run` 即出演示态);再对照 `ui_kits/mobile/` 逐屏补齐(智能选择推荐卡、免费额度卡+看广告解锁、Tab 滑动切换);先演示数据,后按 `server/ARCHITECTURE.md` §3 契约接 API。 -3. **后端(Go)**:按 `server/ARCHITECTURE.md` §7 模块顺序:openapi → auth → codes → devices → nodes → usage → 管理端。每模块:单测 + OpenAPI 同步 + 双语错误文案 + 脱敏。 -4. **官网 / 用户中心**:以 `ui_kits/website/`、`ui_kits/usercenter/` 为样板迁移到正式框架,保留 i18n 单显、响应式与脱敏文案;用户中心接 me/redeem/devices,2FA 用 TOTP。 -5. **每个界面提交前自查**(完整清单见 CLAUDE.md §9 第 4 步):语义 token 无硬编码色 / 明暗+中英四态验证 / 无红线词 / 套餐数字与 §7 一致 / Lucide 图标无 emoji / 连接键三态与原型一致 / 无 App 内支付。 - -## 铁律摘要(完整 13 条见 CLAUDE.md §1) -暖大地色调,**绝不纯黑纯白大面积填充**;主色 clay `#B96A3D`,**绝不蓝紫渐变**;圆角偏大;阴影柔和暖调;**单语言显示**(中/英切换,不并排);状态用色点+文字胶囊(无 emoji);国家用 2 字母码块(无 emoji 国旗);Lucide 细线图标;**App 内无支付**(兑换码+外部渠道:发卡店/USDT/TG/LINE/邮箱);品牌母题=行走穿山甲(拷 `assets/*.svg`,绝不重绘);不堆砌、留白即设计;**全站脱敏**(红线词清单见铁律 13)。 - -## 套餐口径(单一来源 = CLAUDE.md §7,改数字先改那里) -注册享 **7 天免费试用**(不限时长节点)→ 之后免费版 **1 个基础节点 + 每日 10 分钟 + 每日使用前看激励视频解锁**;PRO ¥25/月(年付 ¥20/月),80+ 线路,5 设备;团队版 ¥99/月 10 席位。 - -## 无指令时 -若用户只调用技能未说要做什么:问他要建/设计什么,问几个聚焦问题,作为本品牌专家输出 —— 按需产出 HTML 原型、Flutter 生产代码或后端模块。 From dae321a3f0381e5582259eda0a20ce8613e9d73c Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 21 Jun 2026 21:34:22 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(web):=20clay-500=20=E5=93=81=E7=89=8C?= =?UTF-8?q?=E4=B8=BB=E8=89=B2=20#FF0000=20=E2=86=92=20#B96A3D=20+=20?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=20next=2014.2.35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - token 重生成:--clay-500 品牌主色从占位红 #FF0000 还原为 clay #B96A3D (usercenter public + website tokens.gen) - usercenter package-lock:next / eslint-config-next 从 <14.3.0 固定到 14.2.35 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c --- web/usercenter/package-lock.json | 4 ++-- web/usercenter/public/colors_and_type.css | 2 +- web/website/src/styles/tokens.gen.css | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/usercenter/package-lock.json b/web/usercenter/package-lock.json index 05f2231..303acba 100644 --- a/web/usercenter/package-lock.json +++ b/web/usercenter/package-lock.json @@ -8,7 +8,7 @@ "name": "pangolin-usercenter", "version": "0.1.0", "dependencies": { - "next": "<14.3.0", + "next": "14.2.35", "react": "18.3.1", "react-dom": "18.3.1" }, @@ -17,7 +17,7 @@ "@types/react": "18.3.3", "@types/react-dom": "18.3.0", "eslint": "8.57.0", - "eslint-config-next": "<14.3.0", + "eslint-config-next": "14.2.35", "typescript": "5.5.3" } }, diff --git a/web/usercenter/public/colors_and_type.css b/web/usercenter/public/colors_and_type.css index de641ca..3b05fd7 100644 --- a/web/usercenter/public/colors_and_type.css +++ b/web/usercenter/public/colors_and_type.css @@ -23,7 +23,7 @@ --clay-200: #E6C7AC; --clay-300: #D9A982; --clay-400: #CC8B5C; - --clay-500: #FF0000; /* ← brand primary */ + --clay-500: #B96A3D; /* ← brand primary */ --clay-600: #9E5630; --clay-700: #7E4426; --clay-800: #5E331D; diff --git a/web/website/src/styles/tokens.gen.css b/web/website/src/styles/tokens.gen.css index bc4b980..43d106b 100644 --- a/web/website/src/styles/tokens.gen.css +++ b/web/website/src/styles/tokens.gen.css @@ -23,7 +23,7 @@ --clay-200: #E6C7AC; --clay-300: #D9A982; --clay-400: #CC8B5C; - --clay-500: #FF0000; /* ← brand primary */ + --clay-500: #B96A3D; /* ← brand primary */ --clay-600: #9E5630; --clay-700: #7E4426; --clay-800: #5E331D; From cebc9a1c4fc6e86a0eb4c50766589eeb7c922c66 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 08:57:55 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(client/macos+server):=20=E9=9A=A7?= =?UTF-8?q?=E9=81=93=E8=BF=90=E8=A1=8C=E6=97=B6=E6=89=93=E9=80=9A=E2=80=94?= =?UTF-8?q?=E2=80=94DNS=20=E5=8A=AB=E6=8C=81=20+=20=E6=AD=BB=E9=94=81/?= =?UTF-8?q?=E7=A9=BA=E6=8C=87=E9=92=88=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接续 447f3f4(系统扩展可加载),修复"扩展能起但连上无法上网"的运行时问题, 现已在 macOS 15(cara)端到端连通:出口=节点 IP、国外站可达、DNS 经隧道解析。 服务端(clientconfig.go): - route.rules 首条加 {"action":"hijack-dns","port":[53]}(排在 LAN 直连规则前)。 否则发往隧道 DNS(172.19.0.2:53)的查询被 172.16.0.0/12 吞去直连,域名解析失败。 sing-box 1.13 按端口劫持(protocol:dns 需先 sniff,不稳)。 客户端——保留三个真 bug 修复: - startTunnel 的 libbox 启动移到后台队列:避免在 provider 队列同步阻塞,与 openTun→setTunnelNetworkSettings 回调三方死锁(隧道永远卡 connecting)。 - startOrReloadService(options:) 传非空 LibboxOverrideOptions():传 nil 致空指针 SIGSEGV。 - startDefaultInterfaceMonitor 阻塞到首个 path 更新再返回:修 "no available network interface"。 - 清除排障期临时诊断代码;CFBundleVersion 递增(sysextd 按版本去重)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c --- .../PacketTunnel/PacketTunnelProvider.swift | 110 +++++++----------- client/macos/PacketTunnel/main.swift | 11 -- client/macos/Runner.xcodeproj/project.pbxproj | 12 +- server/internal/httpapi/clientconfig.go | 7 +- 4 files changed, 53 insertions(+), 87 deletions(-) diff --git a/client/macos/PacketTunnel/PacketTunnelProvider.swift b/client/macos/PacketTunnel/PacketTunnelProvider.swift index 4f950fa..02ba177 100644 --- a/client/macos/PacketTunnel/PacketTunnelProvider.swift +++ b/client/macos/PacketTunnel/PacketTunnelProvider.swift @@ -26,48 +26,48 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { log.info("startTunnel") - do { - let rawConfig = try resolveConfig(options) + // libbox 启动必须放后台队列:startOrReloadService 会同步回调 openTun → + // setTunnelNetworkSettings,其完成回调要在 provider 队列上投递;若在 provider + // 队列(NE 调 startTunnel 所在队列)同步跑 startOrReloadService 会三方死锁。 + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + do { + let configContent = try self.resolveConfig(options) - guard let base = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { - throw simpleError("no app group container") + guard let base = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { + throw simpleError("no app group container") + } + let work = base.appendingPathComponent("work", isDirectory: true) + try? FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + + let setup = LibboxSetupOptions() + setup.basePath = base.path + setup.workingPath = work.path + setup.tempPath = NSTemporaryDirectory() + var setupErr: NSError? + LibboxSetup(setup, &setupErr) + if let setupErr { throw setupErr } + + let platform = PangolinPlatformInterface(provider: self) + self.platform = platform + + var newErr: NSError? + guard let server = LibboxNewCommandServer(self, platform, &newErr) else { + throw newErr ?? simpleError("LibboxNewCommandServer returned nil") + } + try server.start() + // 必须传非空 options:此版本 libbox 的 StartOrReloadService 会解引用 options, + // 传 nil 会在 command_server.go:175 触发 SIGSEGV(空指针)。 + try server.startOrReloadService(configContent, options: LibboxOverrideOptions()) + self.commandServer = server + + log.info("startTunnel: service started") + completionHandler(nil) + } catch { + log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)") + completionHandler(error) } - let work = base.appendingPathComponent("work", isDirectory: true) - try? FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) - - // 【临时】剥掉远程 rule-set(#5);并把 sing-box 日志直写容器 box.log(debug)以定位启动卡点。 - let configContent = Self.stripRemoteRuleSets( - rawConfig, logOutput: base.appendingPathComponent("box.log").path) - - let setup = LibboxSetupOptions() - setup.basePath = base.path - setup.workingPath = work.path - setup.tempPath = NSTemporaryDirectory() - var setupErr: NSError? - LibboxSetup(setup, &setupErr) - if let setupErr { throw setupErr } - - let platform = PangolinPlatformInterface(provider: self) - self.platform = platform - - var newErr: NSError? - guard let server = LibboxNewCommandServer(self, platform, &newErr) else { - throw newErr ?? simpleError("LibboxNewCommandServer returned nil") - } - try server.start() - // 必须传非空 options:此版本 libbox 的 StartOrReloadService 会解引用 options, - // 传 nil 会在 command_server.go:175 触发 SIGSEGV(空指针),扩展进程崩溃。 - try server.startOrReloadService(configContent, options: LibboxOverrideOptions()) - self.commandServer = server - - log.info("startTunnel: service started") - completionHandler(nil) - } catch { - // 【临时诊断】完整错误写进 stderr.log;并设 public 避免 os_log 脱敏成 。 - FileHandle.standardError.write("startTunnel failed: \(error)\n".data(using: .utf8)!) - log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)") - completionHandler(error) } } @@ -100,26 +100,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { guard !content.isEmpty else { throw simpleError("empty sing-box config") } return content } - - // 【临时】移除配置里的远程 rule-set 定义,以及 route.rules 中引用了 rule_set 的规则。 - // 解析失败则原样返回。正式分流方案应改用本地 .srs(客户端预取)。 - static func stripRemoteRuleSets(_ json: String, logOutput: String) -> String { - guard let data = json.data(using: .utf8), - var root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], - var route = root["route"] as? [String: Any] else { - return json - } - if route["rule_set"] != nil { route.removeValue(forKey: "rule_set") } - if let rules = route["rules"] as? [[String: Any]] { - route["rules"] = rules.filter { $0["rule_set"] == nil } - } - root["route"] = route - // 【临时诊断】sing-box 日志直写容器 box.log(debug),定位启动卡点。 - root["log"] = ["level": "debug", "timestamp": true, "output": logOutput] - guard let out = try? JSONSerialization.data(withJSONObject: root), - let s = String(data: out, encoding: .utf8) else { return json } - return s - } } // ── LibboxCommandServerHandler:服务端事件回调 ────────────────────────────── @@ -136,15 +116,7 @@ extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol { } func setSystemProxyEnabled(_ enabled: Bool) throws {} func writeStatus(_ message: LibboxStatusMessage?) {} - func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) { - // 【临时诊断】把 sing-box 日志写进 stderr.log,定位启动卡点。 - guard let it = messageList else { return } - while it.hasNext() { - guard let entry = it.next() else { continue } - let line = "[box] \(entry.message)\n" - FileHandle.standardError.write(line.data(using: .utf8) ?? Data()) - } - } + func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {} func writeConnectionEvents(_ events: LibboxConnectionEvents?) {} func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) {} func writeDebugMessage(_ message: String?) {} diff --git a/client/macos/PacketTunnel/main.swift b/client/macos/PacketTunnel/main.swift index 9680633..c1fdc59 100644 --- a/client/macos/PacketTunnel/main.swift +++ b/client/macos/PacketTunnel/main.swift @@ -7,17 +7,6 @@ import Foundation import NetworkExtension -// 【临时诊断】把 stderr/stdout 重定向到 App Group 容器文件,捕获 libbox(Go)的 -// fatal error / panic——系统扩展默认不把 stderr 收进统一日志。诊断完移除。 -if let c = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: "BYL4KQHMTN.com.pangolin.pangolin") { - let p = c.appendingPathComponent("stderr.log").path - freopen(p, "a", stderr) - freopen(p, "a", stdout) - setvbuf(stderr, nil, _IONBF, 0) - FileHandle.standardError.write("=== boot \(Date()) ===\n".data(using: .utf8)!) -} - autoreleasepool { NEProvider.startSystemExtensionMode() } diff --git a/client/macos/Runner.xcodeproj/project.pbxproj b/client/macos/Runner.xcodeproj/project.pbxproj index 88e9832..aca38ec 100644 --- a/client/macos/Runner.xcodeproj/project.pbxproj +++ b/client/macos/Runner.xcodeproj/project.pbxproj @@ -634,7 +634,7 @@ baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -649,7 +649,7 @@ baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -664,7 +664,7 @@ baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests; @@ -935,7 +935,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -985,7 +985,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Developer ID Application"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -1034,7 +1034,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = BYL4KQHMTN; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; diff --git a/server/internal/httpapi/clientconfig.go b/server/internal/httpapi/clientconfig.go index 0882398..246ce13 100644 --- a/server/internal/httpapi/clientconfig.go +++ b/server/internal/httpapi/clientconfig.go @@ -126,8 +126,13 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien "tolerance": 50, } - // Route: LAN direct;(可选)国内 IP/域名直连;其余 via auto。 + // Route: DNS 劫持 → LAN direct →(可选)国内直连 → 其余 via auto。 routeRules := []any{ + // DNS 劫持(sing-box 1.13 action=hijack-dns,按目的端口 53 匹配,不依赖 sniff): + // 把发往隧道 DNS(172.19.0.2:53)的查询交给 sing-box DNS 模块解析。必须排在 LAN + // 直连规则之前——否则 172.19.x 落在下面的 172.16.0.0/12 里,DNS 会被吞去直连、解析 + // 失败导致打不开网站(TUN 模式 DNS 劫持是必需项,缺失则隧道连上也无法上网)。 + map[string]any{"action": "hijack-dns", "port": []int{53}}, map[string]any{ "ip_cidr": []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8"}, "outbound": "direct", From 2d2baf887cbd005dce3092f82dd6e55d05180fbf Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 08:58:10 +0800 Subject: [PATCH 5/8] =?UTF-8?q?docs+test:=20macOS=20=E9=9A=A7=E9=81=93?= =?UTF-8?q?=E8=B8=A9=E5=9D=91=E5=A4=8D=E7=9B=98/=E8=A7=84=E5=88=99=20+=20V?= =?UTF-8?q?PN=20=E6=B5=8B=E8=AF=95=E8=B0=83=E7=A0=94=20+=20=E9=BB=91?= =?UTF-8?q?=E7=9B=92=E6=B5=8B=E8=AF=95=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/macos-sysext-realize-troubleshooting.html:补"运行时"章节(libbox 空指针、 接口时序、队列死锁、DNS 劫持)+ 运行时 checklist。 - docs/vpn-testing-research.md:VPN 测试调研(行业方法论 + GFW 专项 + Pangolin 分层测试项 + 三层测试工具设计)。 - CLAUDE.md:新增「client/ macOS 原生隧道」铁律章节(构建发版/系统扩展 realize 要求/ libbox+NE 集成/配置由服务端渲染/已知坑)。 - scripts/vpn_test.sh:客户端侧黑盒测试工具(出口 IP/DNS/可达性/延迟/吞吐/IPv6 泄漏, PASS/WARN/FAIL,可远程跑)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c --- CLAUDE.md | 32 +++++ .../macos-sysext-realize-troubleshooting.html | 57 +++++++- docs/vpn-testing-research.md | 124 +++++++++++++++++ scripts/vpn_test.sh | 131 ++++++++++++++++++ 4 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 docs/vpn-testing-research.md create mode 100755 scripts/vpn_test.sh diff --git a/CLAUDE.md b/CLAUDE.md index 334c57a..e2d6fef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,3 +82,35 @@ cd web/website && npm run gen:tokens - `client/lib/pangolin_theme.dart` — 只含实现层(`PangolinScheme`/`PangolinText`/`PangolinTheme`),不含 token 数值。 - `design/flutter/` 已删除;Flutter 组件 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`。 - **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移)。 + +## client/ macOS 原生隧道(PacketTunnel 系统扩展 + 内嵌 libbox) + +内嵌 sing-box(`Libbox.xcframework`)的 `NEPacketTunnelProvider` **系统扩展**(站外 Developer ID +分发)。让它能被 `sysextd` 加载并真正连通踩了一长串坑,**改这块前必读** +`docs/macos-sysext-realize-troubleshooting.html`。以下是铁律: + +**构建 / 发版** +- 一律走 `scripts/local_test.sh`(`build`/`notarize`/`copy`/`run`):Developer ID 签名 + 公证 + staple。 +- **每次构建必递增 `CFBundleVersion`**(`CURRENT_PROJECT_VERSION`)——否则 `sysextd` 视为同版本**不更新**,装上去跑的还是旧扩展。 +- SIP 开启的机器只接受**已公证**的 sysext;`client/macos/sign_libbox.sh` 在构建期以 Developer ID 重签内嵌 Libbox。 + +**系统扩展能被 realize 的硬性要求**(缺一即 `code=4` / 静默拒) +- **自包含**:`PacketTunnel` target 设 `OTHER_LDFLAGS = ""`(切断继承项目级 CocoaPods 链接标志,否则会把 `flutter_secure_storage` 链进扩展);`Libbox.xcframework` **只 Link 不 Embed**(它是静态库)。验证:`otool -L` 扩展二进制应**零 `@rpath` 外部依赖**。 +- **bundle 名 = 标识符**:`PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel`。 +- 扩展 `Info.plist` 必须有 **`NSSystemExtensionUsageDescription`**(网络扩展类别强制,主 app 的不顶用)。 +- **App Group 用 macOS 原生格式 `.`**(`BYL4KQHMTN.com.pangolin.pangolin`,非 iOS 的 `group.` 前缀);`NEMachServiceName` 以其为前缀。 +- 沙箱扩展补 `network.client` / `network.server`;`get-task-allow=false` + 签名加 `--timestamp`。 + +**libbox / NetworkExtension 集成铁律**(改 `PacketTunnelProvider.swift` 注意) +- `startTunnel` **必须在后台队列**执行 libbox 启动(`DispatchQueue.global().async`)——否则 `startOrReloadService` 在 provider 队列同步阻塞,与 `openTun → setTunnelNetworkSettings` 回调**三方死锁**(隧道卡 connecting 永不完成)。 +- `startOrReloadService(options:)` **传非空** `LibboxOverrideOptions()`(传 `nil` → libbox 解引用空指针 SIGSEGV,扩展进程崩溃)。 +- `startDefaultInterfaceMonitor` 要**阻塞到首个 path 更新再返回**(否则 sing-box 启动期拿不到默认接口,报 `no available network interface`)。 + +**配置由服务端渲染,客户端不拼** +- sing-box 客户端配置由 `server/internal/httpapi/clientconfig.go::BuildClientConfig` 渲染、原样下发。 +- **TUN 模式必须有 DNS 劫持**:`route.rules` 首条 `{"action":"hijack-dns","port":[53]}`(排在 LAN 直连规则前)——否则发往隧道 DNS(172.19.0.2:53)的查询被 `172.16.0.0/12` 吞去直连,域名解析失败,**隧道连上也打不开网站**。 +- REALITY 数据口走 **节点 `endpoint` 的端口**(当前 443;受限网络常封高位端口如 11443,优先 443)。 + +**已知坑** +- 开发机若是 **macOS 26 (Tahoe)**:`sysextd` 报 `no policy, cannot allow apps outside /Applications`(app 在 /Applications 也报)是 **Apple 回归**,本机调试需关 SIP 后 `systemextensionsctl developer on`;真实用户(macOS 14/15)不受影响。 +- #5 国内分流:客户端 `smartRoute` → `?split_cn=1` 下发远程 rule-set;**TODO 改本地 `.srs` 预取**,避免启动期下载。 diff --git a/docs/macos-sysext-realize-troubleshooting.html b/docs/macos-sysext-realize-troubleshooting.html index af7e451..86eb80b 100644 --- a/docs/macos-sysext-realize-troubleshooting.html +++ b/docs/macos-sysext-realize-troubleshooting.html @@ -159,6 +159,51 @@ sysext didFinishWithResult ✓ result=0 (completed) $ systemextensionsctl list * * BYL4KQHMTN com.pangolin.pangolin.PacketTunnel (1.0/1) [activated enabled] +

5.5 加载之后:从"扩展能起"到"真正连通"(运行时)

+

系统扩展能 realize/激活只是第一步。让内嵌的 libbox(sing-box)真正建起隧道、能上网,又踩了四个坑(均在 PacketTunnelProvider.swift / 服务端配置):

+ +
+

运行时 ① libbox startOrReloadService(options:) 传 nil → 空指针崩溃 扩展进程 SIGABRT

+

扩展进程启动 360ms 后自杀(SIGABRT)。把 stderr 重定向到 App Group 容器文件后抓到 Go panic:

+
panic: runtime error: invalid memory address or nil pointer dereference
+libbox.(*CommandServer).StartOrReloadService(server, {config}, 0x0)  command_server.go:175
+

此版本 libbox 会解引用第三个 options 参数(虽标 _Nullable)。修复:传非空 LibboxOverrideOptions() 而非 nil

+
+ +
+

运行时 ② 默认接口监控异步返回 → no available network interface 启动报错

+

startDefaultInterfaceMonitor 启动 NWPathMonitor 后立即返回,但首个 path 回调是异步晚到的;sing-box 紧接着的网络操作拿到的默认接口索引还是 -1。修复:用信号量**阻塞到首个 path 更新再返回**(带 5s 超时),对齐 sing-box-for-apple。

+
+ +
+

运行时 ③ provider 队列三方死锁 → 隧道永远卡 connecting 最隐蔽

+

openTun 打点显示卡在 setTunnelNetworkSettings 的信号量等待,回调永不触发:

+
    +
  • NE 在 provider 队列上调 startTunnel → 同步调 startOrReloadService(阻塞该队列)
  • +
  • sing-box 在 Go 线程调 openTun → sem.wait()setTunnelNetworkSettings 完成
  • +
  • 而该完成回调**正要在被阻塞的 provider 队列上投递** → 死锁
  • +
+

修复:把 libbox 启动整段放进后台队列(DispatchQueue.global().async),startTunnel 立即返回、provider 队列腾出来投递回调。

+
+ +
+

运行时 ④ 缺 DNS 劫持 → 隧道连上但打不开网站 最后一关

+

隧道起来了、TCP 能经 REALITY 出海,但域名打不开。box.log 显示发往隧道 DNS 的查询走了直连:

+
inbound packet connection to 172.19.0.2:53
+router: match ip_cidr=[..172.16.0.0/12..] => route(direct)   # DNS 被 LAN 规则吞去直连 → 解析失败
+

隧道 DNS 地址 172.19.0.2 落在 LAN 直连规则 172.16/12 内,被路由成直连(发往不存在的主机)→ 解析全失败。修复(服务端 clientconfig.go):route.rules 首条加 {"action":"hijack-dns","port":[53]}(排在 LAN 规则之前),把 :53 查询交给 sing-box DNS 模块。

+

注:sing-box 1.13 的 protocol:"dns" 匹配需先 sniff;按目的端口 53 匹配最稳、不依赖 sniff。

+
+ +
+

另一个发版必知:每次构建必递增 CFBundleVersion sysextd 按(标识符, 版本)去重;同版本号重装**不会替换**已激活的旧扩展,跑的还是旧代码(排查时极易被误导)。

+
+ +

连通验证(从命令行):

+
$ curl https://api.ipify.org          → 103.119.13.48   # 出口=节点 IP,流量走隧道
+$ curl -o/dev/null -w '%{http_code}' https://github.com   → 200
+box.log: router: match[0] port=53 => hijack-dns → dns: exchanged A github.com 140.82.116.3
+

6. 给后人的排查 checklist(Developer ID 网络系统扩展)

  1. 先找同模型的能用实现对照(Tailscale 独立版 / Mullvad),在同一台机器上验证环境没问题,把范围锁到自己的包。
  2. @@ -178,8 +223,18 @@ $ systemextensionsctl list systemextensionsctl developer on,或在 15/14 机器上验证。

    +

    8. 运行时 checklist(libbox + NE 集成)

    +
      +
    1. libbox startOrReloadService(options:) 传**非空** LibboxOverrideOptions(),别传 nil。
    2. +
    3. startTunnel 里的 libbox 启动放**后台队列**(DispatchQueue.global().async),别在 provider 队列同步跑(死锁)。
    4. +
    5. startDefaultInterfaceMonitor **阻塞到首个 path 更新再返回**。
    6. +
    7. TUN 配置必须有 **DNS 劫持**(action:hijack-dns,按 port 53),排在 LAN/分流规则之前。
    8. +
    9. **每次构建递增 CFBundleVersion**,否则 sysextd 不更新已激活的扩展。
    10. +
    11. 看 libbox 自身日志:配置 log.output 指向容器文件(writeLogs 回调启动期不触发);Go fatal 走 stderr,需把扩展 stderr 重定向到文件才看得到。
    12. +
    +
    -

    改动文件:client/macos/PacketTunnel/Info.plist · client/macos/PacketTunnel/PacketTunnel.entitlements · client/macos/Runner/Release.entitlements · client/macos/PacketTunnel/PacketTunnelProvider.swift · client/macos/Runner.xcodeproj/project.pbxproj(OTHER_LDFLAGS/PRODUCT_NAME/移除 Embed Libbox/加 timestamp)。验证机:cara,干净 macOS 15.3.2 Intel。

    +

    改动文件:扩展 Info.plist/entitlements/main.swift/PacketTunnelProvider.swift · Runner/Release.entitlements · Runner.xcodeproj/project.pbxproj(OTHER_LDFLAGS/PRODUCT_NAME/移除 Embed Libbox/--timestamp/版本号) · scripts/local_test.sh · client/macos/sign_libbox.sh · 服务端 server/internal/httpapi/clientconfig.go(DNS 劫持)。验证机:cara,干净 macOS 15.3.2 Intel,出口 IP=节点、国外站可达。

    diff --git a/docs/vpn-testing-research.md b/docs/vpn-testing-research.md new file mode 100644 index 0000000..068f7d4 --- /dev/null +++ b/docs/vpn-testing-research.md @@ -0,0 +1,124 @@ +# VPN 测试调研(测什么 / 怎么测 / Pangolin 测试工具设计) + +> 目的:在动手写测试工具前,先搞清楚 VPN 该测哪些维度、行业与中国/GFW 场景怎么测, +> 再据此设计 Pangolin 自己的测试工具。日期 2026-06-22。 + +--- + +## TL;DR + +VPN 测试 = **连得上 + 连得对 + 连得快 + 连得稳 + 不泄漏 + (中国场景)穿得过且不被封**。 +六大类:**连通性 / 正确性(出口·DNS·分流)/ 泄漏 / 性能(延迟·吞吐·丢包)/ 稳定性(掉线·重连·killswitch)/ 抗封锁(GFW)**。 +Pangolin 是面向大陆、REALITY+sing-box、macOS 自研客户端,**抗封锁 + 国内分流正确性 + 晚高峰吞吐**是和通用评测最不同、最该重点测的。 + +--- + +## 1. 行业通用 VPN 评测怎么测 + +主流评测站(Security.org、Cloudwards、PCMag 等)的共识方法论: + +| 维度 | 测什么 | 怎么测 / 工具 | 通过标准 | +|---|---|---|---| +| **速度/吞吐** | 下载、上传、相对基线的衰减 | Ookla/iperf,**多服务器距离**(本地/美/英/亚)、**多时段**(早/晚高峰)、先测裸连基线再测开 VPN | 近端衰减 <10–20% 算优;远端单独看 | +| **延迟** | RTT(ping)、首包延迟 | ping / curl `time_connect` | 越低越好;近端 +几十 ms 可接受 | +| **DNS 正确性** | 域名能否解析、解析延迟 | `dig`/`nslookup`、curl `time_namelookup` | 能解析、不超时 | +| **IP 泄漏** | 真实 IP 是否暴露(IPv4/IPv6) | 访问 ipify/ifconfig.me/ipleak.net,对比开关 VPN 出口 | 出口必须是节点 IP,**不能是本机真实 IP** | +| **DNS 泄漏** | DNS 查询是否走了本地 ISP | dnsleaktest.com、Wireshark 抓 53 端口去向 | 查询只经隧道/指定 DNS | +| **WebRTC 泄漏** | 浏览器 WebRTC 暴露真实 IP | browserleaks.com/webrtc | 不暴露 | +| **IPv6 泄漏** | IPv6 绕过隧道 | test-ipv6.com | 无 v6 泄漏(或 v6 也走隧道) | +| **Kill Switch** | 隧道意外断开时是否阻断流量 | 强杀扩展/断网,看是否还能裸连出网 | 断开瞬间流量被掐 | +| **流媒体/可达性** | 能否访问目标站点 | 实访 Netflix/YouTube/Google 等 | 能正常打开/播放 | +| **稳定性** | 长时连接掉线率、重连 | 长跑数小时,周期性请求 | 不频繁掉线;掉了能自动重连 | +| **抓包审计** | 是否有明文/异常外联 | Wireshark/tcpdump | 无明文、无意外外联 | + +要点:**先测基线(不开 VPN)再测开 VPN 做对比**;**多次/多时段**取分布而非单点。 + +--- + +## 2. 中国 / GFW 场景特有的测法(Pangolin 重点) + +通用评测不覆盖、但对面向大陆的 VPN 是生死线: + +- **抗 GFW 封锁(核心)** + - GFW 一旦判定某连接是代理,会对 **(client IP, server IP, server port) 三元组封 120–180 秒**(期间丢所有包)。测试:连一段时间后看是否突然全断、过几分钟又能连——这是被三元组封的特征。 + - **主动探测(active probing)**:GFW 会主动连你的端口探测。REALITY 的防御是把探测流量转发给真实伪装站(如 apple.com)。测试:用普通 TLS 客户端(curl/openssl)连节点的 REALITY 端口,应表现得**像真实伪装站**(返回真站证书/内容),而不是暴露代理特征。 + - **全加密流量检测**:GFW 对"高熵全加密"流量有启发式封锁。REALITY/VLESS 借真证书规避。测试:长期存活率(连续几天能否一直连)。 +- **晚高峰吞吐**:国内出海带宽晚高峰(20:00–24:00)严重劣化。**必须按时段测吞吐**(白天 vs 晚高峰),单测白天没意义。 +- **MTU / 分片**:运营商(尤其移动 5G)对大包/分片敏感,会借此识别。业界经验 **MTU clamp 到 1350(IPv6 1280)** 降低被识别和卡顿。测试:大包传输是否卡顿、丢包率。⚠️ 我们 TUN 配的是 `mtu 9000`(sing-box 默认),需评估对 REALITY-over-TCP 的实际影响。 +- **国内分流正确性(#5)**:`geoip-cn`/`geosite-cn` 命中应**直连**(不走隧道),国外才走隧道。测试:访问国内站(如 baidu)出口应是**本地 IP**,国外站出口是**节点 IP**——分流错了要么国内变慢、要么国外漏走直连。 +- **协议可达性矩阵**:REALITY(TCP 443)、Hysteria2(UDP 443)分别在不同网络(电信/联通/移动、家宽/5G)下的可达性。 + +参考:GFW 对全加密/QUIC 的检测与封锁机制见 gfw.report 的 USENIX 论文。 + +--- + +## 3. Pangolin 该测什么(结合架构分层) + +架构:macOS 客户端(NEPacketTunnelProvider 系统扩展 + 内嵌 libbox/sing-box)→ REALITY(TCP)/Hy2(UDP)→ 节点 → 出海。控制面 :8080 下发配置。 + +### A. 连通性(能不能起来) +- 系统扩展 `activated enabled`(`systemextensionsctl list`)。 +- 隧道接口起来(utun + `172.19.0.1`)、NEVPNStatus = connected。 +- 控制面可达(:8080)、REALITY 数据口可达(节点 endpoint 端口)。 +- **首次连接耗时**(含批准)、**重连耗时**。 + +### B. 正确性(连得对) +- **出口 IP = 节点 IP**(`curl ipify`),证明真走隧道。 +- **DNS 解析正常**(域名能开;`hijack-dns` 生效——这次的坑)。 +- **国内分流**:国内站出口=本地、国外站出口=节点(开 smartRoute 时)。 +- 无 **IP/DNS/IPv6 泄漏**。 + +### C. 性能(连得快) +- 延迟:到节点 RTT、隧道内到常见站 RTT。 +- 吞吐:下载/上传速度,**白天 vs 晚高峰**,对比裸连基线。 +- 丢包率、DNS 解析延迟。 + +### D. 稳定性(连得稳) +- 长跑掉线率、自动重连。 +- Kill switch(`strict_route` 已开):强杀扩展后流量是否被掐。 +- 节点 agent 热重载(SIGHUP)后客户端是否平滑。 + +### E. 抗封锁(穿得过) +- REALITY 端口被动探测表现(伪装站特征)。 +- 长期存活率(多日)。 +- 三元组封锁特征监测。 + +--- + +## 4. 测试工具设计建议 + +分三层,**先做能自动化、回报最高的"客户端侧黑盒探测脚本"**: + +### 工具一:`scripts/vpn_test.sh`(客户端侧黑盒,优先做) +在已连接的机器(本机/cara)上跑,一条命令出报告。覆盖 B+C 大部分: +- **出口 IP**:`curl ipify`,判定 = 节点 / = 本地 / 超时。 +- **DNS**:解析 github/google + `time_namelookup`;开/关分流分别测国内外站出口。 +- **可达性矩阵**:对一组目标(google/youtube/github/baidu…)测 HTTP 码 + 耗时。 +- **延迟**:`curl time_connect` 到节点和若干站。 +- **吞吐**:下载固定大小文件测 `speed_download`(用 cloudflare speed / 自建测速点)。 +- **泄漏**:IPv6 出口、DNS 出口对比。 +- 输出:表格 + 时间戳,可重复跑做时段对比。 +- 复用本会话已验证的 `ssh cara '...'` 远程跑法。 + +### 工具二:节点侧探针(可选) +- REALITY 端口被动探测模拟(openssl 连 443 看是否像伪装站)。 +- 三元组封锁监测(从国内 vantage 持续发包看存活)。 + +### 工具三:客户端内置自检(产品化,后续) +- app 内"诊断"按钮:跑连通/出口/DNS 自检,给用户一键报告(也利于支持排障)。 + +### 输出与判定 +- 每项给 **PASS/WARN/FAIL** + 实测值 + 阈值。 +- 支持 **基线对比**(先 `--baseline` 裸连存一份,再开 VPN 对比衰减)。 +- 支持 **多时段**(cron 跑,产出趋势)。 + +--- + +## 5. 参考来源 + +- Security.org《Best VPN Services of 2026: 50+ VPNs Tested》— 速度/泄漏/流媒体方法论:https://www.security.org/vpn/best/ +- Cloudwards《VPN Test: DNS Leaks, IP Address Leaks, Speed Issues》:https://www.cloudwards.net/vpn-test-guide/ +- DoVPN《IP Leak Test Guide》:https://dovpn.com/ip-leak-test-guide/ +- gfw.report — GFW 对全加密流量的检测(USENIX'23):https://gfw.report/publications/usenixsecurity23/en/ +- gfw.report — GFW 对 QUIC/SNI 的封锁(USENIX'25):https://gfw.report/publications/usenixsecurity25/en/ +- VLESS-REALITY 部署/绕过实践:https://greatfirewallguide.com/lab/vless-reality-vision diff --git a/scripts/vpn_test.sh b/scripts/vpn_test.sh new file mode 100755 index 0000000..de2d04b --- /dev/null +++ b/scripts/vpn_test.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# vpn_test.sh —— Pangolin 客户端侧黑盒连通/正确性/性能测试(调研文档「工具一」) +# +# 在"已连接 VPN 的机器"上跑(本机或 ssh 到 cara),一条命令出报告。覆盖: +# 出口 IP / DNS / 可达性矩阵 / 延迟 / 吞吐 / IPv6 泄漏。 +# 每项给 PASS/WARN/FAIL + 实测值。支持基线对比(先 --baseline 裸连存一份)。 +# +# 用法: +# bash scripts/vpn_test.sh # 完整测试(假定 VPN 已连) +# NODE_IP=103.119.13.48 bash scripts/vpn_test.sh +# bash scripts/vpn_test.sh --quick # 跳过吞吐(快) +# bash scripts/vpn_test.sh --baseline # 记基线(裸连,不开 VPN 时跑) +# 远程:scp scripts/vpn_test.sh cara@HOST:/tmp/ && ssh cara@HOST 'bash /tmp/vpn_test.sh' +# +# 设计:见 docs/vpn-testing-research.md。零依赖(curl + 系统自带),macOS/Linux 通用。 + +set -u + +# ── 配置 ───────────────────────────────────────────────────────────── +NODE_IP="${NODE_IP:-103.119.13.48}" # 期望的隧道出口(节点 IP) +TIMEOUT="${TIMEOUT:-12}" # 单请求超时(秒) +DL_BYTES="${DL_BYTES:-5000000}" # 吞吐测试下载字节数(默认 5MB) +QUICK=0; MODE="vpn" +for a in "$@"; do + case "$a" in + --quick) QUICK=1 ;; + --baseline) MODE="baseline" ;; + esac +done + +# 国外目标(应经隧道):站点 +FOREIGN=("https://www.google.com/generate_204" "https://www.youtube.com/generate_204" \ + "https://github.com" "https://www.gstatic.com/generate_204") +# 国内目标(开分流应直连、快):站点 +DOMESTIC=("https://www.baidu.com" "https://www.qq.com") + +# ── 输出辅助 ───────────────────────────────────────────────────────── +if [ -t 1 ]; then G=$'\033[32m'; Y=$'\033[33m'; R=$'\033[31m'; B=$'\033[1m'; N=$'\033[0m' +else G=""; Y=""; R=""; B=""; N=""; fi +pass=0; warn=0; fail=0 +P(){ printf " ${G}PASS${N} %-22s %s\n" "$1" "$2"; pass=$((pass+1)); } +W(){ printf " ${Y}WARN${N} %-22s %s\n" "$1" "$2"; warn=$((warn+1)); } +F(){ printf " ${R}FAIL${N} %-22s %s\n" "$1" "$2"; fail=$((fail+1)); } +H(){ printf "\n${B}== %s ==${N}\n" "$1"; } + +cget(){ curl -s --max-time "$TIMEOUT" "$@"; } # 静默 + 超时 + +echo "${B}Pangolin VPN 测试${N} $(date '+%Y-%m-%d %H:%M:%S') 模式=${MODE} 期望出口=${NODE_IP}" + +# ── 1. 连通性:本机隧道接口 / 系统扩展(仅本机 macOS 有意义)───────── +H "1. 连通性" +if command -v ifconfig >/dev/null 2>&1; then + if ifconfig 2>/dev/null | grep -q "inet 172.19.0.1"; then P "tun 接口" "172.19.0.1 已起" + else W "tun 接口" "未见 172.19.0.1(VPN 没连?或非本机)"; fi +fi +if command -v systemextensionsctl >/dev/null 2>&1; then + st=$(systemextensionsctl list 2>/dev/null | grep -i pangolin | grep -oE "activated (enabled|waiting for user)" | head -1) + [ -n "$st" ] && P "系统扩展" "$st" || W "系统扩展" "未激活/未安装" +fi + +# ── 2. 出口 IP(核心:是否真走隧道)────────────────────────────────── +H "2. 出口 IP" +EGRESS="" +for u in "https://api.ipify.org" "https://ifconfig.me/ip" "https://ipinfo.io/ip"; do + EGRESS=$(cget "$u" | tr -dc '0-9.'); [ -n "$EGRESS" ] && break +done +if [ -z "$EGRESS" ]; then + F "出口 IP" "拿不到(国外不可达 / DNS 挂 / 未连)" +elif [ "$EGRESS" = "$NODE_IP" ]; then + P "出口 IP" "$EGRESS(= 节点,确实走隧道)" +else + W "出口 IP" "$EGRESS(≠ 节点 $NODE_IP;可能是本地/其他 VPN/直连)" +fi + +# ── 3. DNS 解析 ────────────────────────────────────────────────────── +H "3. DNS 解析" +dnsok=0 +for d in github.com www.google.com; do + t=$(cget -o /dev/null -w "%{time_namelookup}" "https://$d" 2>/dev/null) + code=$(cget -o /dev/null -w "%{http_code}" "https://$d" 2>/dev/null) + if [ "${code:-000}" != "000" ]; then P "DNS $d" "解析+连通 ${t}s (HTTP $code)"; dnsok=$((dnsok+1)) + else F "DNS $d" "解析/连接失败(HTTP 000;hijack-dns 没生效?)"; fi +done + +# ── 4. 可达性矩阵(国外应通)────────────────────────────────────────── +H "4. 可达性(国外,应经隧道)" +for u in "${FOREIGN[@]}"; do + read -r code tt < <(cget -o /dev/null -w "%{http_code} %{time_total}" "$u" 2>/dev/null || echo "000 -") + host=$(echo "$u" | sed -E 's#https?://([^/]+).*#\1#') + if [ "${code:-000}" != "000" ]; then P "$host" "HTTP $code, ${tt}s" + else F "$host" "不可达(HTTP 000)"; fi +done + +# ── 5. 国内直连(开分流时应直连且快)───────────────────────────────── +H "5. 国内站(开分流应直连)" +for u in "${DOMESTIC[@]}"; do + read -r code tt < <(cget -o /dev/null -w "%{http_code} %{time_total}" "$u" 2>/dev/null || echo "000 -") + host=$(echo "$u" | sed -E 's#https?://([^/]+).*#\1#') + if [ "${code:-000}" != "000" ]; then P "$host" "HTTP $code, ${tt}s" + else W "$host" "不可达(分流/DNS 异常?)"; fi +done + +# ── 6. 延迟(连接握手耗时)──────────────────────────────────────────── +H "6. 延迟" +for u in "https://$NODE_IP:8080" "https://www.gstatic.com/generate_204"; do + tc=$(cget -k -o /dev/null -w "%{time_connect}" "$u" 2>/dev/null) + [ -n "$tc" ] && [ "$tc" != "0.000000" ] && P "connect $u" "${tc}s" || W "connect $u" "无响应/超时" +done + +# ── 7. 吞吐(下载速度)──────────────────────────────────────────────── +if [ "$QUICK" = 0 ]; then + H "7. 吞吐(下载 ${DL_BYTES}B)" + read -r sz spd tt < <(cget -o /dev/null -w "%{size_download} %{speed_download} %{time_total}" \ + "https://speed.cloudflare.com/__down?bytes=${DL_BYTES}" 2>/dev/null || echo "0 0 -") + if [ "${sz:-0}" -gt 0 ] 2>/dev/null; then + mbps=$(awk "BEGIN{printf \"%.2f\", ${spd:-0}*8/1000000}") + P "下载速度" "${mbps} Mbps (${sz}B in ${tt}s)" + else F "下载速度" "下载失败"; fi +fi + +# ── 8. IPv6 泄漏 ───────────────────────────────────────────────────── +H "8. IPv6 泄漏" +# 用 v6-only 端点;无 v6 时 curl -6 失败返回空。仅当结果含冒号(真 v6 地址)才算有 v6。 +v6=$(cget -6 "https://api6.ipify.org" 2>/dev/null) +if echo "$v6" | grep -q ':'; then W "IPv6" "v6 出口 $v6(确认是否走隧道,否则泄漏)" +else P "IPv6" "无 v6 出口(不泄漏)"; fi + +# ── 汇总 ───────────────────────────────────────────────────────────── +H "汇总" +printf " ${G}PASS %d${N} ${Y}WARN %d${N} ${R}FAIL %d${N}\n" "$pass" "$warn" "$fail" +[ "$fail" -gt 0 ] && exit 1 || exit 0 From 8fcc4e8b75c5ac959eafa6274800199d0cddbee7 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 09:17:00 +0800 Subject: [PATCH 6/8] =?UTF-8?q?docs+test:=20VPN=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=B9=E6=A1=88(=E9=BB=91=E7=9B=92+=E7=99=BD=E7=9B=92)+=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=BB=B6=E8=BF=9F=E6=B5=8B=E9=87=8F=E6=96=B9?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/vpn-test-plan.md:黑盒+白盒两方向测试方案。白盒细化:链路五段拆解、 协议/出站选择(Clash API)、接入段 vs 出海段分段 RTT、连接/流量、稳定性 (长跑/Kill switch/GFW 三元组封锁与被动探测/热重载)。含初轮报告:cara 黑盒 15 PASS;吞吐 国内直连 74Mbps vs 国外经隧道 ~10-17Mbps(瓶颈在出海段)。 - scripts/vpn_test.sh:修正延迟测量——经 TUN 代理时 ping/tcp_connect 被隧道本地 应答(2-4ms 假象),改用 TLS 握手(time_appconnect)/ TTFB 作为真实 RTT。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c --- docs/vpn-test-plan.md | 143 ++++++++++++++++++++++++++++++++++++++++++ scripts/vpn_test.sh | 19 ++++-- 2 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 docs/vpn-test-plan.md diff --git a/docs/vpn-test-plan.md b/docs/vpn-test-plan.md new file mode 100644 index 0000000..098cc35 --- /dev/null +++ b/docs/vpn-test-plan.md @@ -0,0 +1,143 @@ +# Pangolin VPN 测试方案 + 报告 + +> 两个方向:**黑盒**(用户视角,只看输入→输出)+ **白盒**(利用我们对客户端/节点/sing-box +> 的完全可见性,拆链路、看协议、做稳定性)。日期 2026-06-22。配套:`scripts/vpn_test.sh`、 +> 调研见 `docs/vpn-testing-research.md`。 + +--- + +## 一、测试方向总览 + +| | 黑盒 | 白盒 | +|---|---|---| +| 视角 | 用户视角,不看内部 | 工程视角,看内部链路/协议/状态 | +| 目的 | 能不能用、快不快(可用性 + 体验) | 慢在哪、走什么协议、稳不稳(定位 + 稳定性) | +| 数据源 | `curl` / `ping` 黑盒探测 | sing-box Clash API、libbox 命令服务、curl 五段拆解、节点侧探测、长跑监控 | +| 工具 | `scripts/vpn_test.sh`(已实现) | 待实现:`scripts/vpn_whitebox.sh` + 稳定性长跑 | + +--- + +## 二、黑盒测试方案 + +**对象:国内外常用站点矩阵**,开/关分流各测一遍,多时段(白天 / 晚高峰 20:00–24:00)。 + +### 指标 +- **连通性**:HTTP 状态码(能否访问) +- **延迟 / TTL**:`ping` RTT、`curl time_connect`(TCP+TLS 握手) +- **下载速度**:固定大小下载 `speed_download` +- **上传速度**:POST 固定大小 `speed_upload`(待补) +- **DNS 解析时间**:`curl time_namelookup` +- **出口 IP**:是否 = 节点(走隧道)/ 本地(直连) + +### 站点矩阵 +- **国外(应经隧道)**:google、youtube、github、cloudflare(speed.cloudflare.com)、netflix、openai +- **国内(开分流应直连)**:baidu、qq、bilibili、aliyun/清华镜像、taobao +- **测速点**:Cloudflare(国外)、清华/阿里镜像(国内,用 `-r 0-N` range 取固定大小) + +### 方法 +- 先 **裸连基线**(不开 VPN 跑一遍存档)→ 再开 VPN 对比衰减。 +- 每站取 中位数(多次),记录时段。 +- 一条命令产报告:`vpn_test.sh`(现覆盖连通/出口/DNS/可达/延迟/下载/IPv6;**待补:上传、ping TTL、站点矩阵扩展、多时段 cron**)。 + +--- + +## 三、白盒测试方案(重点,更细) + +我们同时掌握**客户端(libbox 命令服务)+ 节点(sing-box Clash API :19090/:19091)+ 配置**, +可做黑盒做不到的链路拆解。 + +### 3.1 链路五段拆解(每个请求耗时花在哪) +用 `curl -w` 拆解一次请求的五个阶段,定位瓶颈在接入段还是出海段: + +| 阶段 | curl 字段 | 含义 | +|---|---|---| +| DNS 解析 | `time_namelookup` | 域名→IP | +| TCP 连接 | `time_connect` | 到隧道/目标的 TCP 握手 | +| TLS 握手 | `time_appconnect` | TLS/REALITY 握手完成 | +| 首字节 TTFB | `time_starttransfer` | 服务器开始回数据(含出海往返) | +| 总时长 | `time_total` | 整体 | + +→ TTFB 高 = 出海段慢;connect 高 = 接入段/节点慢;namelookup 高 = DNS 慢。 + +### 3.2 协议与出站选择 +- 当前激活出站:`reality-out`(VLESS/REALITY,TCP 443)还是 `hy2-out`(Hysteria2,UDP 443)。 +- `urltest`(auto)各成员探测延迟(决定选谁)。 +- **数据源**:sing-box Clash API `GET /proxies`(看 `auto` 组的 `now` 与各成员 `history` 延迟); + 或客户端 libbox 命令服务的 group/outbound 状态。 + +### 3.3 分段 RTT(定位瓶颈) +- **接入段**:客户端 → 节点 REALITY 端口 TCP RTT(`curl time_connect` 到 `节点:443`)。 +- **出海段**:在**节点上**直接 `ping`/`curl` 目标站(节点 → 目标 RTT)。 +- 对比:接入段快 + 出海段慢 → 节点出海链路是瓶颈;反之亦然。 + +### 3.4 流量与连接(实时内部状态) +- **Clash API `GET /connections`**:活跃连接列表,每连接的 目标 / 上下行字节 / 走哪个出站 / 命中哪条规则 / 建连时长 → 看分流是否如预期(国内直连、国外走代理)。 +- **Clash API `GET /traffic`(SSE)**:实时上下行速率。 +- **客户端 libbox**:`writeConnectionEvents` / `writeGroups` / 流量统计(stats EventChannel)。 + +### 3.5 sing-box 内部决策日志(debug) +- 路由命中:`router: match ... => route(outbound)` → 验证分流规则。 +- DNS:`dns: exchange/exchanged ...` → 验证 hijack-dns + 解析路径。 +- 连接生命周期:建连/关闭/错误。 +- **方法**:配置 `log.level=debug` + `log.output` 落盘读(排障期已用过)。 + +### 3.6 路径 MTU / 分片 +- `ping -D -s ` 二分探测路径 MTU,评估 TUN `mtu 9000` 是否导致分片/卡顿 + (调研:运营商对大包敏感,业界常 clamp 到 1350)。 + +### 3.7 节点侧资源(瓶颈定位) +- 节点 CPU/内存/带宽(单核 512MB 是硬约束)、sing-box 进程占用、网卡流量。 +- agent / 控制面健康。 + +### 3.8 稳定性测试(白盒重点) +- **长跑**:cron 每 1–5 分钟探测(出口 IP + 可达 + 延迟 + 丢包),持续数小时~数天,出趋势曲线。 +- **掉线 / 重连**:监测 `NEVPNStatus` 变化,记掉线次数、自动重连耗时。 +- **Kill switch**:强杀扩展进程 / 断节点,验证 `strict_route` 把流量掐断(不裸奔),恢复后能重连。 +- **GFW 存活**:多日连续可达性;监测**三元组封锁特征**(突然全断 + 120–180s 后恢复); + REALITY 端口被动探测(用普通 TLS 客户端连 :443,应表现得像伪装站 www.apple.com)。 +- **热重载**:节点 agent `SIGHUP` 重载 sing-box 后,客户端是否断流 / 平滑。 +- **协议切换**:reality-out 不可用时是否切到 hy2-out(urltest 容灾)。 + +--- + +## 四、当前测试报告(2026-06-22,初轮) + +### 4.1 黑盒(cara,macOS 15.3.2 Intel,白天) +`scripts/vpn_test.sh` 全量:**15 PASS / 0 WARN / 0 FAIL** +- 连通性:扩展 `activated enabled`、tun `172.19.0.1` ✅ +- 出口 IP:`103.119.13.48`(= 节点,确实走隧道)✅ +- DNS:github/google 解析+连通正常 ✅(服务端 `hijack-dns` 已生效) +- 国外可达:google/youtube/github/gstatic HTTP 200/204 ✅ +- 国内可达:baidu/qq ✅ +- 延迟:**真实 RTT 看 TLS 握手 / TTFB**。实测国外 TLS 握手 **~420–490ms**、TTFB ~600–715ms + (节点在洛杉矶,中国→LA→目标多次往返,符合预期)。 +- IPv6:无泄漏 ✅ + +> ⚠️ **延迟测量教训(重要)**:经 TUN 代理时,`ping` 和 `curl time_connect` 会被隧道的 +> 本地协议栈**就地应答**(0.3ms / 2-4ms 的假象),**不反映真实到目标的 RTT**。初轮误把 +> `time_connect` 当延迟(2-4ms),实为本地值。真实延迟必须看 **TLS 握手(`time_appconnect`)** +> 或 **TTFB(`time_starttransfer`)**,或在 **VPN 全关后 ping 节点**测直连 RTT。`vpn_test.sh` +> 已据此修正。 + +### 4.2 吞吐对比(关键) +| 路径 | 速度 | 说明 | +|---|---|---| +| **国外(经隧道)** Cloudflare 10MB | **~10–17 Mbps**(多次波动:7.66 / 10.6 / 17.4) | 走 REALITY→节点→出海 | +| **国内(直连)** 清华镜像 10MB | **74.28 Mbps** | geoip-cn 命中 → 直连,不经隧道 | +| 国内 阿里云镜像 | 未测到(URL 失效) | 待换有效测速 URL | + +**结论:** +1. **分流生效**:国内直连(74 Mbps)远快于国外经隧道(~10-17),说明 geoip-cn 直连正常。 +2. **瓶颈在出海段**:国外吞吐受限于隧道——单核 512MB 联调节点 + 出海带宽 + 可能晚高峰。不是客户端/隧道软件问题。 +3. **波动大**(7.66→17.4):需按调研文档**多时段、多次取中位数**,单点不可靠。 + +### 4.3 待补 +- 黑盒:上传速度、ping TTL、站点矩阵扩展、**晚高峰复测**、裸连基线对比。 +- 白盒:Clash API 拉连接/出站/urltest 延迟、五段拆解、分段 RTT(接入 vs 出海)、稳定性长跑、Kill switch、GFW 存活。 + +--- + +## 五、工具落地计划 +1. **扩展 `scripts/vpn_test.sh`(黑盒)**:加 上传测速 / ping TTL / 站点矩阵 / `--baseline` 基线 / 多时段 cron。 +2. **新增 `scripts/vpn_whitebox.sh`(白盒)**:读节点 Clash API(连接/出站/延迟)+ curl 五段拆解 + 接入/出海分段 RTT + 路径 MTU。 +3. **新增稳定性长跑**:cron 周期探测落 CSV,出趋势;Kill switch / 重连 / GFW 存活脚本。 diff --git a/scripts/vpn_test.sh b/scripts/vpn_test.sh index de2d04b..28e5ac8 100755 --- a/scripts/vpn_test.sh +++ b/scripts/vpn_test.sh @@ -100,12 +100,21 @@ for u in "${DOMESTIC[@]}"; do else W "$host" "不可达(分流/DNS 异常?)"; fi done -# ── 6. 延迟(连接握手耗时)──────────────────────────────────────────── -H "6. 延迟" -for u in "https://$NODE_IP:8080" "https://www.gstatic.com/generate_204"; do - tc=$(cget -k -o /dev/null -w "%{time_connect}" "$u" 2>/dev/null) - [ -n "$tc" ] && [ "$tc" != "0.000000" ] && P "connect $u" "${tc}s" || W "connect $u" "无响应/超时" +# ── 6. 延迟(真实 RTT)──────────────────────────────────────────────── +# ⚠️ 经 TUN 代理时 tcp_connect / ping 会被隧道本地应答(假的几 ms),不能当真实 RTT。 +# 真实延迟看 TLS 握手(time_appconnect,必须真往返到目标)与 TTFB(time_starttransfer)。 +H "6. 延迟(真实 RTT:TLS 握手 / TTFB;tcp_connect 经隧道为本地值仅供对照)" +for u in "https://www.google.com/generate_204" "https://www.gstatic.com/generate_204"; do + read -r tcp tls ttfb < <(cget -o /dev/null -w "%{time_connect} %{time_appconnect} %{time_starttransfer}" "$u" 2>/dev/null || echo "0 0 0") + host=$(echo "$u" | sed -E 's#https?://([^/]+).*#\1#') + if awk "BEGIN{exit !($tls>0)}" 2>/dev/null; then + tlsms=$(awk "BEGIN{printf \"%.0f\", $tls*1000}"); ttfbms=$(awk "BEGIN{printf \"%.0f\", $ttfb*1000}"); tcpms=$(awk "BEGIN{printf \"%.0f\", $tcp*1000}") + if [ "$tlsms" -lt 500 ] 2>/dev/null; then P "$host RTT" "TLS握手 ${tlsms}ms, TTFB ${ttfbms}ms (tcp_connect ${tcpms}ms=本地)" + elif [ "$tlsms" -lt 1000 ] 2>/dev/null; then W "$host RTT" "TLS握手 ${tlsms}ms 偏高, TTFB ${ttfbms}ms" + else F "$host RTT" "TLS握手 ${tlsms}ms 过高, TTFB ${ttfbms}ms"; fi + else F "$host RTT" "无响应/超时"; fi done +echo " ${B}提示${N}: 真实到节点的直连 RTT 请在【VPN 全关】后 ping 节点;开着 TUN ping 是本地假象。" # ── 7. 吞吐(下载速度)──────────────────────────────────────────────── if [ "$QUICK" = 0 ]; then From 98e39629a68a8da5b9f25b83a6be373f21ddede9 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 09:21:35 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20=E5=9B=BA=E5=AE=9A=E5=BB=B6?= =?UTF-8?q?=E8=BF=9F=E5=8F=A3=E5=BE=84=E2=80=94=E2=80=94=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=3DTLS=E6=8F=A1=E6=89=8B(time=5Fappconnect),TTFB=20=E4=BD=9C?= =?UTF-8?q?=E5=8F=82=E8=80=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确含义与区别:TLS 握手=纯网络链路往返(不含目标后端),跨站可比,作统一延迟基准; TTFB=TLS握手+服务器处理,含站点因素,仅作体感参考。禁用 ping/tcp_connect 当延迟 (经 TUN 被本地应答)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c --- docs/vpn-test-plan.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/vpn-test-plan.md b/docs/vpn-test-plan.md index 098cc35..f820b91 100644 --- a/docs/vpn-test-plan.md +++ b/docs/vpn-test-plan.md @@ -23,12 +23,41 @@ ### 指标 - **连通性**:HTTP 状态码(能否访问) -- **延迟 / TTL**:`ping` RTT、`curl time_connect`(TCP+TLS 握手) +- **延迟**:**TLS 握手时间(`curl time_appconnect`)= 统一延迟口径**;TTFB(`time_starttransfer`)作参考。见下方「延迟口径定义」。 - **下载速度**:固定大小下载 `speed_download` - **上传速度**:POST 固定大小 `speed_upload`(待补) - **DNS 解析时间**:`curl time_namelookup` - **出口 IP**:是否 = 节点(走隧道)/ 本地(直连) +### 延迟口径定义(统一标准 ⚠️ 必读) + +> **本项目所有"延迟"一律指 TLS 握手时间(`time_appconnect`);TTFB 仅作参考。 +> 严禁用 `ping` 或 `tcp_connect` 当延迟** —— 经 TUN 代理时它们被隧道本地协议栈 +> 就地应答(几 ms 的假象),不反映真实到目标的 RTT。 + +curl 各时间点都是**从请求开始累计**(非各段独立): + +``` +开始 → DNS解析 → TCP连接 → [TLS握手完成] → 发请求 → [收到首字节=TTFB] → 传输完 → 总时长 + namelookup connect time_appconnect time_starttransfer time_total +``` + +| | **TLS 握手(`time_appconnect`)= 延迟口径** | **TTFB(`time_starttransfer`)= 参考** | +|---|---|---| +| 测到哪 | TLS/REALITY 握手协商**完成** | 收到响应的**第一个字节** | +| 含义 | TCP 连接 + TLS 协商往返;握手时服务器只做加密协商、**不碰业务逻辑** → **纯网络链路往返**(client→隧道→节点→目标 的 RTT × 握手轮数) | TLS 握手 + 发请求 + **目标服务器后端处理** + 回首字节 | +| 比对方多 | — | 比 TLS 握手多 **≈1 个 RTT(请求-响应)+ 目标站后端处理时间** | +| 受谁影响 | 只受**网络链路**影响 → 跨站可比、稳定 | 网络 **+ 目标站后端快慢/CDN** → 含站点自身因素 | +| 为何选它 | 不被目标后端污染,横向比较公平,衡量"我们隧道有多快"最干净 | 反映"打开体感",但混入站点因素,不当基准 | + +**实测印证**(节点在洛杉矶):`google` TLS握手 424ms、TTFB 640ms,差 ~216ms ≈ 一个 +中国→LA→google 往返(generate_204 后端处理≈0)。换 github 这类后端重的站,TTFB 会被 +明显拉大,而 TLS 握手仍稳定反映链路。 + +**判定参考**(单程经隧道到国外):TLS 握手 <500ms 优 / 500–1000ms 偏高 / >1000ms 差。 +**真实到节点的直连 RTT**:须在 **VPN 全关后 `ping` 节点**测(开着 TUN ping 是本地假象)。 +> 注:TLS 1.3 握手 1-RTT、TLS 1.2 为 2-RTT,故 TLS 握手 ≈ 链路 RTT × 握手轮数;同隧道内横向对比目标站公平。 + ### 站点矩阵 - **国外(应经隧道)**:google、youtube、github、cloudflare(speed.cloudflare.com)、netflix、openai - **国内(开分流应直连)**:baidu、qq、bilibili、aliyun/清华镜像、taobao From 98ba2b411e95ee30884c246f8db0205d4cdec314 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 09:43:46 +0800 Subject: [PATCH 8/8] =?UTF-8?q?test:=20VPN=20=E9=BB=91=E7=9B=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=B7=A5=E5=85=B7=E6=94=B9=E7=94=A8=20Python(?= =?UTF-8?q?=E9=9B=B6=E4=BE=9D=E8=B5=96)+=20HTML=20=E6=8A=A5=E5=91=8A=20+?= =?UTF-8?q?=20=E5=9B=BD=E5=86=85=E5=A4=96=E7=AB=99=E7=82=B9=E7=9F=A9?= =?UTF-8?q?=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/vpn_test.py:替换 vpn_test.sh。纯标准库(socket/ssl/urllib),覆盖国内外 常见站点矩阵(google/youtube/github/x/fb/ig/wiki/cloudflare/openai/reddit; 百度/腾讯/淘宝/京东/B站/微博/网易/知乎/阿里云),测连通+延迟+TTFB+DNS+出口IP+吞吐, 本机连通性(tun/系统扩展),生成自包含 HTML 报告(-o)。 - 延迟标准 = TLS 握手耗时(ssl 握手,纯链路往返),报告里标注 "TTL=按 TLS 计算、非 IP TTL"; TTFB 作参考。禁用 ping/tcp_connect(经 TUN 本地应答失真)。 - 删除 scripts/vpn_test.sh;文档引用同步更新为 .py。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c --- docs/vpn-test-plan.md | 12 +- docs/vpn-testing-research.md | 2 +- scripts/vpn_test.py | 289 +++++++++++++++++++++++++++++++++++ scripts/vpn_test.sh | 140 ----------------- 4 files changed, 296 insertions(+), 147 deletions(-) create mode 100755 scripts/vpn_test.py delete mode 100755 scripts/vpn_test.sh diff --git a/docs/vpn-test-plan.md b/docs/vpn-test-plan.md index f820b91..f9bcb63 100644 --- a/docs/vpn-test-plan.md +++ b/docs/vpn-test-plan.md @@ -1,7 +1,7 @@ # Pangolin VPN 测试方案 + 报告 > 两个方向:**黑盒**(用户视角,只看输入→输出)+ **白盒**(利用我们对客户端/节点/sing-box -> 的完全可见性,拆链路、看协议、做稳定性)。日期 2026-06-22。配套:`scripts/vpn_test.sh`、 +> 的完全可见性,拆链路、看协议、做稳定性)。日期 2026-06-22。配套:`scripts/vpn_test.py`、 > 调研见 `docs/vpn-testing-research.md`。 --- @@ -13,7 +13,7 @@ | 视角 | 用户视角,不看内部 | 工程视角,看内部链路/协议/状态 | | 目的 | 能不能用、快不快(可用性 + 体验) | 慢在哪、走什么协议、稳不稳(定位 + 稳定性) | | 数据源 | `curl` / `ping` 黑盒探测 | sing-box Clash API、libbox 命令服务、curl 五段拆解、节点侧探测、长跑监控 | -| 工具 | `scripts/vpn_test.sh`(已实现) | 待实现:`scripts/vpn_whitebox.sh` + 稳定性长跑 | +| 工具 | `scripts/vpn_test.py`(已实现) | 待实现:`scripts/vpn_whitebox.sh` + 稳定性长跑 | --- @@ -66,7 +66,7 @@ curl 各时间点都是**从请求开始累计**(非各段独立): ### 方法 - 先 **裸连基线**(不开 VPN 跑一遍存档)→ 再开 VPN 对比衰减。 - 每站取 中位数(多次),记录时段。 -- 一条命令产报告:`vpn_test.sh`(现覆盖连通/出口/DNS/可达/延迟/下载/IPv6;**待补:上传、ping TTL、站点矩阵扩展、多时段 cron**)。 +- 一条命令产报告:`vpn_test.py`(现覆盖连通/出口/DNS/可达/延迟/下载/IPv6;**待补:上传、ping TTL、站点矩阵扩展、多时段 cron**)。 --- @@ -132,7 +132,7 @@ curl 各时间点都是**从请求开始累计**(非各段独立): ## 四、当前测试报告(2026-06-22,初轮) ### 4.1 黑盒(cara,macOS 15.3.2 Intel,白天) -`scripts/vpn_test.sh` 全量:**15 PASS / 0 WARN / 0 FAIL** +`scripts/vpn_test.py` 全量:**15 PASS / 0 WARN / 0 FAIL** - 连通性:扩展 `activated enabled`、tun `172.19.0.1` ✅ - 出口 IP:`103.119.13.48`(= 节点,确实走隧道)✅ - DNS:github/google 解析+连通正常 ✅(服务端 `hijack-dns` 已生效) @@ -145,7 +145,7 @@ curl 各时间点都是**从请求开始累计**(非各段独立): > ⚠️ **延迟测量教训(重要)**:经 TUN 代理时,`ping` 和 `curl time_connect` 会被隧道的 > 本地协议栈**就地应答**(0.3ms / 2-4ms 的假象),**不反映真实到目标的 RTT**。初轮误把 > `time_connect` 当延迟(2-4ms),实为本地值。真实延迟必须看 **TLS 握手(`time_appconnect`)** -> 或 **TTFB(`time_starttransfer`)**,或在 **VPN 全关后 ping 节点**测直连 RTT。`vpn_test.sh` +> 或 **TTFB(`time_starttransfer`)**,或在 **VPN 全关后 ping 节点**测直连 RTT。`vpn_test.py` > 已据此修正。 ### 4.2 吞吐对比(关键) @@ -167,6 +167,6 @@ curl 各时间点都是**从请求开始累计**(非各段独立): --- ## 五、工具落地计划 -1. **扩展 `scripts/vpn_test.sh`(黑盒)**:加 上传测速 / ping TTL / 站点矩阵 / `--baseline` 基线 / 多时段 cron。 +1. **扩展 `scripts/vpn_test.py`(黑盒)**:加 上传测速 / ping TTL / 站点矩阵 / `--baseline` 基线 / 多时段 cron。 2. **新增 `scripts/vpn_whitebox.sh`(白盒)**:读节点 Clash API(连接/出站/延迟)+ curl 五段拆解 + 接入/出海分段 RTT + 路径 MTU。 3. **新增稳定性长跑**:cron 周期探测落 CSV,出趋势;Kill switch / 重连 / GFW 存活脚本。 diff --git a/docs/vpn-testing-research.md b/docs/vpn-testing-research.md index 068f7d4..c81a02e 100644 --- a/docs/vpn-testing-research.md +++ b/docs/vpn-testing-research.md @@ -89,7 +89,7 @@ Pangolin 是面向大陆、REALITY+sing-box、macOS 自研客户端,**抗封锁 分三层,**先做能自动化、回报最高的"客户端侧黑盒探测脚本"**: -### 工具一:`scripts/vpn_test.sh`(客户端侧黑盒,优先做) +### 工具一:`scripts/vpn_test.py`(客户端侧黑盒,优先做) 在已连接的机器(本机/cara)上跑,一条命令出报告。覆盖 B+C 大部分: - **出口 IP**:`curl ipify`,判定 = 节点 / = 本地 / 超时。 - **DNS**:解析 github/google + `time_namelookup`;开/关分流分别测国内外站出口。 diff --git a/scripts/vpn_test.py b/scripts/vpn_test.py new file mode 100755 index 0000000..2159cd0 --- /dev/null +++ b/scripts/vpn_test.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +vpn_test.py —— Pangolin VPN 黑盒测试(纯标准库,零依赖) + +在"已连接 VPN 的机器"上跑,测国内外常见站点矩阵,生成 HTML 报告。 + +延迟标准(TTL):本工具的 "TTL/延迟" = TLS 握手耗时(从 TCP 建立完成到 TLS 握手完成), + ≈ 真实网络链路往返 × 握手轮数。**不是 IP TTL**;也不用 ping/tcp_connect——经 TUN 代理时 + 那些会被隧道本地应答(几 ms 假象),不反映真实到目标的 RTT。详见 docs/vpn-test-plan.md。 + TTFB(参考)= 发出请求到收到首字节,含目标服务器处理时间。 + +用法: + python3 scripts/vpn_test.py # 测全部,报告写到 ./vpn_report.html + python3 scripts/vpn_test.py -o /tmp/r.html # 指定报告路径 + NODE_IP=103.119.13.48 python3 scripts/vpn_test.py + 远程:scp scripts/vpn_test.py cara@HOST:/tmp/ && ssh cara@HOST 'python3 /tmp/vpn_test.py -o /tmp/r.html' \ + && scp cara@HOST:/tmp/r.html . +""" +import os, sys, ssl, socket, time, json, argparse, html, urllib.request +from datetime import datetime + +NODE_IP = os.environ.get("NODE_IP", "103.119.13.48") +TIMEOUT = float(os.environ.get("TIMEOUT", "15")) + +# 站点矩阵(name, host)。国外应经隧道,国内开分流应直连。 +FOREIGN = [ + ("Google", "www.google.com"), ("YouTube", "www.youtube.com"), + ("GitHub", "github.com"), ("X/Twitter", "x.com"), + ("Facebook", "www.facebook.com"), ("Instagram", "www.instagram.com"), + ("Wikipedia", "en.wikipedia.org"), ("Cloudflare", "www.cloudflare.com"), + ("OpenAI", "api.openai.com"), ("Reddit", "www.reddit.com"), +] +DOMESTIC = [ + ("百度", "www.baidu.com"), ("腾讯", "www.qq.com"), ("淘宝", "www.taobao.com"), + ("京东", "www.jd.com"), ("B站", "www.bilibili.com"), ("微博", "weibo.com"), + ("网易", "www.163.com"), ("知乎", "www.zhihu.com"), ("阿里云", "www.aliyun.com"), +] +# 测速点:(label, url, 期望路径)。国外经隧道,国内直连。 +DL_FOREIGN = ("Cloudflare(国外/隧道)", "https://speed.cloudflare.com/__down?bytes=10000000") +DL_DOMESTIC = ("清华镜像(国内/直连)", "https://mirrors.tuna.tsinghua.edu.cn/ubuntu-releases/22.04/ubuntu-22.04.5-live-server-amd64.iso") + +# 延迟判定阈值(TLS 握手,单程经隧道到国外;ms) +LAT_GOOD, LAT_WARN = 500, 1000 + + +def measure_site(host, port=443): + """返回 dict: ok, code, dns_ms, tls_ms(=延迟/TTL), ttfb_ms, err""" + r = {"host": host, "ok": False, "code": None, "dns_ms": None, + "tls_ms": None, "ttfb_ms": None, "err": ""} + try: + t0 = time.monotonic() + infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + t_dns = time.monotonic() + af, st, proto, _, sa = infos[0] + s = socket.socket(af, st, proto) + s.settimeout(TIMEOUT) + s.connect(sa) + t_tcp = time.monotonic() + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE # 只测连通/延迟,不验证证书(避免证书问题干扰) + ss = ctx.wrap_socket(s, server_hostname=host) + t_tls = time.monotonic() # ← TLS 握手完成 + req = (f"GET / HTTP/1.1\r\nHost: {host}\r\n" + "User-Agent: pangolin-vpn-test\r\nAccept: */*\r\nConnection: close\r\n\r\n") + t_req = time.monotonic() + ss.sendall(req.encode()) + first = ss.recv(256) # ← 首字节 + t_first = time.monotonic() + try: + line = first.split(b"\r\n", 1)[0].decode("latin1") + r["code"] = int(line.split()[1]) if line.startswith("HTTP/") else None + except Exception: + r["code"] = None + ss.close() + r["dns_ms"] = round((t_dns - t0) * 1000, 1) + r["tls_ms"] = round((t_tls - t_tcp) * 1000, 1) # 延迟(TLS 握手) + r["ttfb_ms"] = round((t_first - t_req) * 1000, 1) # 参考(请求→首字节) + r["ok"] = r["code"] is not None + except Exception as e: + r["err"] = type(e).__name__ + ": " + str(e) + return r + + +def local_connectivity(): + """本机连通性(macOS):tun 接口 / 系统扩展状态。best-effort。""" + import subprocess + out = {"tun": None, "sysext": None} + try: + r = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=8) + out["tun"] = "172.19.0.1" in r.stdout + except Exception: + pass + try: + r = subprocess.run(["systemextensionsctl", "list"], capture_output=True, text=True, timeout=8) + for ln in r.stdout.splitlines(): + if "pangolin" in ln.lower() and "activated" in ln: + out["sysext"] = "enabled" if "enabled" in ln else "waiting for user" + break + except Exception: + pass + return out + + +def egress_ip(): + for u in ("https://api.ipify.org", "https://ifconfig.me/ip", "https://ipinfo.io/ip"): + try: + ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE + with urllib.request.urlopen(u, timeout=TIMEOUT, context=ctx) as resp: + ip = resp.read().decode().strip() + if ip: + return ip + except Exception: + continue + return "" + + +def geo(ip): + try: + ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE + with urllib.request.urlopen(f"https://ipinfo.io/{ip}/json", timeout=TIMEOUT, context=ctx) as resp: + d = json.load(resp) + return f"{d.get('city','?')}, {d.get('country','?')} ({d.get('org','?')})" + except Exception: + return "?" + + +def download_mbps(url): + """下载固定大小测吞吐,返回 (mbps, bytes, secs) 或 (None, ...)。""" + try: + ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE + req = urllib.request.Request(url, headers={"Range": "bytes=0-9999999", + "User-Agent": "pangolin-vpn-test"}) + t0 = time.monotonic(); n = 0 + with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + while True: + chunk = resp.read(65536) + if not chunk: + break + n += len(chunk) + if n >= 10_000_000 or time.monotonic() - t0 > 25: + break + dt = time.monotonic() - t0 + if n > 100000 and dt > 0: + return round(n * 8 / 1e6 / dt, 2), n, round(dt, 2) + except Exception: + pass + return None, 0, 0 + + +def verdict_latency(tls_ms): + if tls_ms is None: + return "FAIL" + if tls_ms < LAT_GOOD: + return "PASS" + if tls_ms < LAT_WARN: + return "WARN" + return "WARN" # 高延迟标 WARN 不算 FAIL(只要能连) + + +# ── 主流程 ─────────────────────────────────────────────────────────── +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("-o", "--out", default="vpn_report.html") + ap.add_argument("--no-download", action="store_true", help="跳过吞吐测试") + args = ap.parse_args() + + started = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"Pangolin VPN 测试 {started} 期望出口={NODE_IP}") + + conn = local_connectivity() + if conn["tun"] is not None or conn["sysext"] is not None: + print(f"本机连通性: tun(172.19.0.1)={'有' if conn['tun'] else '无'} " + f"系统扩展={conn['sysext'] or '未激活'}") + + eip = egress_ip() + egeo = geo(eip) if eip else "" + tunneled = (eip == NODE_IP) + print(f"出口 IP: {eip or '(取不到)'} {egeo} {'[走隧道]' if tunneled else '[非节点!]'}") + + def run(group, sites): + rows = [] + for name, host in sites: + r = measure_site(host) + r["name"] = name + rows.append(r) + lat = f"{r['tls_ms']}ms" if r["tls_ms"] is not None else "—" + print(f" [{group}] {name:10s} {host:28s} " + f"{'HTTP '+str(r['code']) if r['ok'] else 'FAIL '+r['err'][:30]:18s} TTL(TLS)={lat}") + return rows + + print("\n国外站点(经隧道):") + f_rows = run("国外", FOREIGN) + print("\n国内站点(开分流应直连):") + d_rows = run("国内", DOMESTIC) + + dls = [] + if not args.no_download: + print("\n吞吐:") + for label, url in (DL_FOREIGN, DL_DOMESTIC): + mbps, n, dt = download_mbps(url) + dls.append((label, mbps, n, dt)) + print(f" {label}: {mbps if mbps is not None else 'FAIL'} Mbps ({n}B/{dt}s)") + + html_out = render_html(started, eip, egeo, tunneled, f_rows, d_rows, dls, conn) + with open(args.out, "w", encoding="utf-8") as fp: + fp.write(html_out) + print(f"\n报告已生成: {os.path.abspath(args.out)}") + + # 汇总 + okf = sum(1 for r in f_rows if r["ok"]); okd = sum(1 for r in d_rows if r["ok"]) + print(f"汇总: 国外 {okf}/{len(f_rows)} 可达, 国内 {okd}/{len(d_rows)} 可达, 出口{'=节点✓' if tunneled else '≠节点!'}") + + +def render_html(started, eip, egeo, tunneled, f_rows, d_rows, dls, conn=None): + def cls(v): # 延迟着色 + if v is None: + return "fail" + return "pass" if v < LAT_GOOD else "warn" + def row(r): + if not r["ok"]: + return (f"{html.escape(r['name'])}{html.escape(r['host'])}" + f"不可达 {html.escape(r['err'][:40])}") + return (f"{html.escape(r['name'])}{html.escape(r['host'])}" + f"HTTP {r['code']}" + f"{r['tls_ms']} ms" + f"{r['ttfb_ms']} ms" + f"{r['dns_ms']} ms") + ftab = "\n".join(row(r) for r in f_rows) + dtab = "\n".join(row(r) for r in d_rows) + dlrows = "\n".join( + f"{html.escape(l)}" + f"{m if m is not None else '失败'} Mbps{n}B / {dt}s" + for l, m, n, dt in dls) + egress_cls = "pass" if tunneled else "warn" + return f""" + +Pangolin VPN 测试报告 {started} +
    +

    Pangolin VPN 测试报告

    +

    {started} · 期望节点出口 {NODE_IP}

    + +
    + 出口 IP:{html.escape(eip or '取不到')} {html.escape(egeo)} + — {'流量走隧道(出口=节点)' if tunneled else '出口≠节点(可能直连/其他 VPN/DNS 挂)'} + {('
    本机:tun(172.19.0.1)=' + ('有' if conn.get('tun') else '无') + ' · 系统扩展=' + (conn.get('sysext') or '未激活')) if conn else ''} +
    + +
    + 延迟口径(TTL):本表「TTL/延迟」= TLS 握手耗时(TCP 建立完成→TLS 握手完成), + ≈ 真实网络链路往返 × 握手轮数,跨站可比、不含目标服务器后端处理。非 IP TTL;也不用 + ping/tcp_connect(经 TUN 被隧道本地应答,几 ms 假象,不可信)。 + TTFB 为参考 = 请求→首字节(含服务器处理),受目标站自身快慢影响。 + 判定:TLS 握手 <{LAT_GOOD}ms 优 / {LAT_GOOD}–{LAT_WARN}ms 偏高 / >{LAT_WARN}ms 高。 +
    + +

    国外站点(应经隧道)

    + +{ftab}
    站点域名连通TTL/延迟(TLS握手)TTFB(参考)DNS
    + +

    国内站点(开分流应直连)

    + +{dtab}
    站点域名连通TTL/延迟(TLS握手)TTFB(参考)DNS
    + +

    吞吐(下载 ~10MB)

    + +{dlrows or ''}
    测速点速度明细
    (已跳过)
    + +

    方法详见 docs/vpn-test-plan.md / docs/vpn-testing-research.md。生成工具:scripts/vpn_test.py

    +
    """ + + +if __name__ == "__main__": + main() diff --git a/scripts/vpn_test.sh b/scripts/vpn_test.sh deleted file mode 100755 index 28e5ac8..0000000 --- a/scripts/vpn_test.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env bash -# vpn_test.sh —— Pangolin 客户端侧黑盒连通/正确性/性能测试(调研文档「工具一」) -# -# 在"已连接 VPN 的机器"上跑(本机或 ssh 到 cara),一条命令出报告。覆盖: -# 出口 IP / DNS / 可达性矩阵 / 延迟 / 吞吐 / IPv6 泄漏。 -# 每项给 PASS/WARN/FAIL + 实测值。支持基线对比(先 --baseline 裸连存一份)。 -# -# 用法: -# bash scripts/vpn_test.sh # 完整测试(假定 VPN 已连) -# NODE_IP=103.119.13.48 bash scripts/vpn_test.sh -# bash scripts/vpn_test.sh --quick # 跳过吞吐(快) -# bash scripts/vpn_test.sh --baseline # 记基线(裸连,不开 VPN 时跑) -# 远程:scp scripts/vpn_test.sh cara@HOST:/tmp/ && ssh cara@HOST 'bash /tmp/vpn_test.sh' -# -# 设计:见 docs/vpn-testing-research.md。零依赖(curl + 系统自带),macOS/Linux 通用。 - -set -u - -# ── 配置 ───────────────────────────────────────────────────────────── -NODE_IP="${NODE_IP:-103.119.13.48}" # 期望的隧道出口(节点 IP) -TIMEOUT="${TIMEOUT:-12}" # 单请求超时(秒) -DL_BYTES="${DL_BYTES:-5000000}" # 吞吐测试下载字节数(默认 5MB) -QUICK=0; MODE="vpn" -for a in "$@"; do - case "$a" in - --quick) QUICK=1 ;; - --baseline) MODE="baseline" ;; - esac -done - -# 国外目标(应经隧道):站点 -FOREIGN=("https://www.google.com/generate_204" "https://www.youtube.com/generate_204" \ - "https://github.com" "https://www.gstatic.com/generate_204") -# 国内目标(开分流应直连、快):站点 -DOMESTIC=("https://www.baidu.com" "https://www.qq.com") - -# ── 输出辅助 ───────────────────────────────────────────────────────── -if [ -t 1 ]; then G=$'\033[32m'; Y=$'\033[33m'; R=$'\033[31m'; B=$'\033[1m'; N=$'\033[0m' -else G=""; Y=""; R=""; B=""; N=""; fi -pass=0; warn=0; fail=0 -P(){ printf " ${G}PASS${N} %-22s %s\n" "$1" "$2"; pass=$((pass+1)); } -W(){ printf " ${Y}WARN${N} %-22s %s\n" "$1" "$2"; warn=$((warn+1)); } -F(){ printf " ${R}FAIL${N} %-22s %s\n" "$1" "$2"; fail=$((fail+1)); } -H(){ printf "\n${B}== %s ==${N}\n" "$1"; } - -cget(){ curl -s --max-time "$TIMEOUT" "$@"; } # 静默 + 超时 - -echo "${B}Pangolin VPN 测试${N} $(date '+%Y-%m-%d %H:%M:%S') 模式=${MODE} 期望出口=${NODE_IP}" - -# ── 1. 连通性:本机隧道接口 / 系统扩展(仅本机 macOS 有意义)───────── -H "1. 连通性" -if command -v ifconfig >/dev/null 2>&1; then - if ifconfig 2>/dev/null | grep -q "inet 172.19.0.1"; then P "tun 接口" "172.19.0.1 已起" - else W "tun 接口" "未见 172.19.0.1(VPN 没连?或非本机)"; fi -fi -if command -v systemextensionsctl >/dev/null 2>&1; then - st=$(systemextensionsctl list 2>/dev/null | grep -i pangolin | grep -oE "activated (enabled|waiting for user)" | head -1) - [ -n "$st" ] && P "系统扩展" "$st" || W "系统扩展" "未激活/未安装" -fi - -# ── 2. 出口 IP(核心:是否真走隧道)────────────────────────────────── -H "2. 出口 IP" -EGRESS="" -for u in "https://api.ipify.org" "https://ifconfig.me/ip" "https://ipinfo.io/ip"; do - EGRESS=$(cget "$u" | tr -dc '0-9.'); [ -n "$EGRESS" ] && break -done -if [ -z "$EGRESS" ]; then - F "出口 IP" "拿不到(国外不可达 / DNS 挂 / 未连)" -elif [ "$EGRESS" = "$NODE_IP" ]; then - P "出口 IP" "$EGRESS(= 节点,确实走隧道)" -else - W "出口 IP" "$EGRESS(≠ 节点 $NODE_IP;可能是本地/其他 VPN/直连)" -fi - -# ── 3. DNS 解析 ────────────────────────────────────────────────────── -H "3. DNS 解析" -dnsok=0 -for d in github.com www.google.com; do - t=$(cget -o /dev/null -w "%{time_namelookup}" "https://$d" 2>/dev/null) - code=$(cget -o /dev/null -w "%{http_code}" "https://$d" 2>/dev/null) - if [ "${code:-000}" != "000" ]; then P "DNS $d" "解析+连通 ${t}s (HTTP $code)"; dnsok=$((dnsok+1)) - else F "DNS $d" "解析/连接失败(HTTP 000;hijack-dns 没生效?)"; fi -done - -# ── 4. 可达性矩阵(国外应通)────────────────────────────────────────── -H "4. 可达性(国外,应经隧道)" -for u in "${FOREIGN[@]}"; do - read -r code tt < <(cget -o /dev/null -w "%{http_code} %{time_total}" "$u" 2>/dev/null || echo "000 -") - host=$(echo "$u" | sed -E 's#https?://([^/]+).*#\1#') - if [ "${code:-000}" != "000" ]; then P "$host" "HTTP $code, ${tt}s" - else F "$host" "不可达(HTTP 000)"; fi -done - -# ── 5. 国内直连(开分流时应直连且快)───────────────────────────────── -H "5. 国内站(开分流应直连)" -for u in "${DOMESTIC[@]}"; do - read -r code tt < <(cget -o /dev/null -w "%{http_code} %{time_total}" "$u" 2>/dev/null || echo "000 -") - host=$(echo "$u" | sed -E 's#https?://([^/]+).*#\1#') - if [ "${code:-000}" != "000" ]; then P "$host" "HTTP $code, ${tt}s" - else W "$host" "不可达(分流/DNS 异常?)"; fi -done - -# ── 6. 延迟(真实 RTT)──────────────────────────────────────────────── -# ⚠️ 经 TUN 代理时 tcp_connect / ping 会被隧道本地应答(假的几 ms),不能当真实 RTT。 -# 真实延迟看 TLS 握手(time_appconnect,必须真往返到目标)与 TTFB(time_starttransfer)。 -H "6. 延迟(真实 RTT:TLS 握手 / TTFB;tcp_connect 经隧道为本地值仅供对照)" -for u in "https://www.google.com/generate_204" "https://www.gstatic.com/generate_204"; do - read -r tcp tls ttfb < <(cget -o /dev/null -w "%{time_connect} %{time_appconnect} %{time_starttransfer}" "$u" 2>/dev/null || echo "0 0 0") - host=$(echo "$u" | sed -E 's#https?://([^/]+).*#\1#') - if awk "BEGIN{exit !($tls>0)}" 2>/dev/null; then - tlsms=$(awk "BEGIN{printf \"%.0f\", $tls*1000}"); ttfbms=$(awk "BEGIN{printf \"%.0f\", $ttfb*1000}"); tcpms=$(awk "BEGIN{printf \"%.0f\", $tcp*1000}") - if [ "$tlsms" -lt 500 ] 2>/dev/null; then P "$host RTT" "TLS握手 ${tlsms}ms, TTFB ${ttfbms}ms (tcp_connect ${tcpms}ms=本地)" - elif [ "$tlsms" -lt 1000 ] 2>/dev/null; then W "$host RTT" "TLS握手 ${tlsms}ms 偏高, TTFB ${ttfbms}ms" - else F "$host RTT" "TLS握手 ${tlsms}ms 过高, TTFB ${ttfbms}ms"; fi - else F "$host RTT" "无响应/超时"; fi -done -echo " ${B}提示${N}: 真实到节点的直连 RTT 请在【VPN 全关】后 ping 节点;开着 TUN ping 是本地假象。" - -# ── 7. 吞吐(下载速度)──────────────────────────────────────────────── -if [ "$QUICK" = 0 ]; then - H "7. 吞吐(下载 ${DL_BYTES}B)" - read -r sz spd tt < <(cget -o /dev/null -w "%{size_download} %{speed_download} %{time_total}" \ - "https://speed.cloudflare.com/__down?bytes=${DL_BYTES}" 2>/dev/null || echo "0 0 -") - if [ "${sz:-0}" -gt 0 ] 2>/dev/null; then - mbps=$(awk "BEGIN{printf \"%.2f\", ${spd:-0}*8/1000000}") - P "下载速度" "${mbps} Mbps (${sz}B in ${tt}s)" - else F "下载速度" "下载失败"; fi -fi - -# ── 8. IPv6 泄漏 ───────────────────────────────────────────────────── -H "8. IPv6 泄漏" -# 用 v6-only 端点;无 v6 时 curl -6 失败返回空。仅当结果含冒号(真 v6 地址)才算有 v6。 -v6=$(cget -6 "https://api6.ipify.org" 2>/dev/null) -if echo "$v6" | grep -q ':'; then W "IPv6" "v6 出口 $v6(确认是否走隧道,否则泄漏)" -else P "IPv6" "无 v6 出口(不泄漏)"; fi - -# ── 汇总 ───────────────────────────────────────────────────────────── -H "汇总" -printf " ${G}PASS %d${N} ${Y}WARN %d${N} ${R}FAIL %d${N}\n" "$pass" "$warn" "$fail" -[ "$fail" -gt 0 ] && exit 1 || exit 0