Merge branch 'main' into feature/windows
This commit is contained in:
@@ -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 生产代码或后端模块。
|
|
||||||
@@ -82,3 +82,35 @@ cd web/website && npm run gen:tokens
|
|||||||
- `client/lib/pangolin_theme.dart` — 只含实现层(`PangolinScheme`/`PangolinText`/`PangolinTheme`),不含 token 数值。
|
- `client/lib/pangolin_theme.dart` — 只含实现层(`PangolinScheme`/`PangolinText`/`PangolinTheme`),不含 token 数值。
|
||||||
- `design/flutter/` 已删除;Flutter 组件 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`。
|
- `design/flutter/` 已删除;Flutter 组件 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`。
|
||||||
- **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移)。
|
- **禁止**再向 `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 原生格式 `<TeamID>.<name>`**(`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` 预取**,避免启动期下载。
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import 'dart:io' show Platform;
|
|||||||
/// P1 方案B 开关:macOS 是否走原生 System Extension(VpnNativeBridge)而非 PoC 的
|
/// P1 方案B 开关:macOS 是否走原生 System Extension(VpnNativeBridge)而非 PoC 的
|
||||||
/// sudo 子进程(DesktopVpnBridge)。默认 false——待 PacketTunnel target/签名就绪、
|
/// sudo 子进程(DesktopVpnBridge)。默认 false——待 PacketTunnel target/签名就绪、
|
||||||
/// 原生侧联调通过后置 true。见 docs/p1-macos-system-extension.md。
|
/// 原生侧联调通过后置 true。见 docs/p1-macos-system-extension.md。
|
||||||
const bool kUseNativeVpnMacOS = false;
|
const bool kUseNativeVpnMacOS = true;
|
||||||
|
|
||||||
/// 全局单例 VpnBridge。Ref 生命周期内不变。
|
/// 全局单例 VpnBridge。Ref 生命周期内不变。
|
||||||
final vpnBridgeProvider = Provider<VpnBridge>((ref) {
|
final vpnBridgeProvider = Provider<VpnBridge>((ref) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../l10n/app_text.dart';
|
|||||||
import '../pangolin_theme.dart';
|
import '../pangolin_theme.dart';
|
||||||
import '../state/account_providers.dart';
|
import '../state/account_providers.dart';
|
||||||
import '../state/app_providers.dart';
|
import '../state/app_providers.dart';
|
||||||
|
import '../state/auth_provider.dart';
|
||||||
import '../state/navigation_provider.dart';
|
import '../state/navigation_provider.dart';
|
||||||
import '../widgets/account_screens.dart';
|
import '../widgets/account_screens.dart';
|
||||||
import '../widgets/app_top_bar.dart';
|
import '../widgets/app_top_bar.dart';
|
||||||
@@ -93,7 +94,13 @@ class AccountPage extends ConsumerWidget {
|
|||||||
]),
|
]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_Card(children: [
|
_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(),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// api_config.dart — 控制面 API 基址单源。
|
// api_config.dart — 控制面 API 基址单源。
|
||||||
//
|
//
|
||||||
// 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里,
|
// 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里,
|
||||||
// 由 --dart-define=PANGOLIN_API_URL 注入(默认本地 8080)。
|
// 由 --dart-define=PANGOLIN_API_URL 注入。
|
||||||
|
// TODO(联调临时): 默认值改成测试节点,避免 release 构建漏传 dart-define;发版前改回 localhost 或正式控制面域名。
|
||||||
const String kApiBaseUrl = String.fromEnvironment(
|
const String kApiBaseUrl = String.fromEnvironment(
|
||||||
'PANGOLIN_API_URL',
|
'PANGOLIN_API_URL',
|
||||||
defaultValue: 'http://localhost:8080',
|
defaultValue: 'http://103.119.13.48:8080',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -99,15 +99,20 @@ class AuthApi {
|
|||||||
|
|
||||||
Future<http.Response> _post(String path, Map<String, dynamic> body) async {
|
Future<http.Response> _post(String path, Map<String, dynamic> body) async {
|
||||||
final uri = Uri.parse('$baseUrl$path');
|
final uri = Uri.parse('$baseUrl$path');
|
||||||
|
print('>>>AUTHLOG POST $uri (baseUrl=$baseUrl) 发起...');
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
return await _client
|
final resp = await _client
|
||||||
.post(
|
.post(
|
||||||
uri,
|
uri,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode(body),
|
body: jsonEncode(body),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 15));
|
.timeout(const Duration(seconds: 15));
|
||||||
|
print('>>>AUTHLOG POST $uri -> HTTP ${resp.statusCode} (${sw.elapsedMilliseconds}ms)');
|
||||||
|
return resp;
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
|
print('>>>AUTHLOG POST $uri 失败 (${sw.elapsedMilliseconds}ms): ${e.runtimeType}: $e');
|
||||||
throw AuthApiException(
|
throw AuthApiException(
|
||||||
statusCode: -1,
|
statusCode: -1,
|
||||||
messageZh: '网络请求失败,请检查连接后重试',
|
messageZh: '网络请求失败,请检查连接后重试',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../bridge/vpn_bridge.dart';
|
import '../bridge/vpn_bridge.dart';
|
||||||
import '../bridge/vpn_bridge_provider.dart';
|
import '../bridge/vpn_bridge_provider.dart';
|
||||||
import '../l10n/app_text.dart';
|
import '../l10n/app_text.dart';
|
||||||
|
import '../services/api_config.dart';
|
||||||
import '../services/connect_api.dart';
|
import '../services/connect_api.dart';
|
||||||
import 'app_providers.dart';
|
import 'app_providers.dart';
|
||||||
import 'auth_provider.dart';
|
import 'auth_provider.dart';
|
||||||
@@ -25,12 +26,7 @@ const _kDeviceId = String.fromEnvironment(
|
|||||||
defaultValue: 'mac-001',
|
defaultValue: 'mac-001',
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── API base URL ─────────────────────────────────────────────────
|
// API base URL 统一用 api_config.dart 的 kApiBaseUrl(单一来源,勿再重复声明)。
|
||||||
|
|
||||||
const _kApiUrl = String.fromEnvironment(
|
|
||||||
'PANGOLIN_API_URL',
|
|
||||||
defaultValue: 'http://localhost:8080',
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 连接阶段枚举 ──────────────────────────────────────────────────
|
// ── 连接阶段枚举 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -103,7 +99,6 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||||||
Future<void> _connect() async {
|
Future<void> _connect() async {
|
||||||
state = const ConnectionState(phase: VpnPhase.connecting);
|
state = const ConnectionState(phase: VpnPhase.connecting);
|
||||||
|
|
||||||
final token = _ref.read(authProvider).accessToken ?? '';
|
|
||||||
final node = _ref.read(effectiveNodeProvider);
|
final node = _ref.read(effectiveNodeProvider);
|
||||||
final zh = _ref.read(localeProvider) == AppLang.zh;
|
final zh = _ref.read(localeProvider) == AppLang.zh;
|
||||||
|
|
||||||
@@ -119,14 +114,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_api?.dispose();
|
final configJson = await _fetchConfigWithRefresh(node.uuid);
|
||||||
_api = ConnectApi(baseUrl: _kApiUrl, authToken: token);
|
|
||||||
final configJson = await _api!.fetchConfig(
|
|
||||||
nodeId: node.uuid,
|
|
||||||
deviceId: _kDeviceId,
|
|
||||||
// smartRoute 偏好 → 国内分流(#5):国内 IP/域名直连,不走隧道。
|
|
||||||
splitCN: _ref.read(settingsProvider).smartRoute,
|
|
||||||
);
|
|
||||||
// bridge.start() 不阻塞至连接建立;on 状态由 statusStream 回调驱动。
|
// bridge.start() 不阻塞至连接建立;on 状态由 statusStream 回调驱动。
|
||||||
await _bridge.start(configJson);
|
await _bridge.start(configJson);
|
||||||
} on ConnectApiException catch (e) {
|
} on ConnectApiException catch (e) {
|
||||||
@@ -142,6 +130,33 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 取配置;access token 过期(401)时用 refresh token 续期后**重试一次**。
|
||||||
|
/// 续期失败(refresh 也过期 / 被拒)由 authProvider.refresh() 触发登出 → UI 回登录页。
|
||||||
|
Future<String> _fetchConfigWithRefresh(String nodeUuid) async {
|
||||||
|
Future<String> 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<void> _disconnect() async {
|
Future<void> _disconnect() async {
|
||||||
_stopElapsed();
|
_stopElapsed();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,17 +8,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../l10n/app_text.dart';
|
import '../l10n/app_text.dart';
|
||||||
import '../pangolin_theme.dart';
|
import '../pangolin_theme.dart';
|
||||||
|
import '../services/api_config.dart';
|
||||||
import '../services/auth_api.dart';
|
import '../services/auth_api.dart';
|
||||||
import '../state/auth_provider.dart';
|
import '../state/auth_provider.dart';
|
||||||
import 'pangolin_button.dart';
|
import 'pangolin_button.dart';
|
||||||
import 'pangolin_icons.dart';
|
import 'pangolin_icons.dart';
|
||||||
import 'pangolin_logo.dart';
|
import 'pangolin_logo.dart';
|
||||||
|
|
||||||
// API base URL(由 --dart-define 注入)
|
// API base URL 统一用 api_config.dart 的 kApiBaseUrl(单一来源,勿再重复声明)。
|
||||||
const _kApiUrl = String.fromEnvironment(
|
|
||||||
'PANGOLIN_API_URL',
|
|
||||||
defaultValue: 'http://localhost:8080',
|
|
||||||
);
|
|
||||||
|
|
||||||
const _easeOut = Cubic(0.22, 1, 0.36, 1);
|
const _easeOut = Cubic(0.22, 1, 0.36, 1);
|
||||||
|
|
||||||
@@ -55,7 +52,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
duration: const Duration(milliseconds: 620),
|
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);
|
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,21 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<!-- 网络扩展类别强制要求:扩展自己的 Info.plist 必须有此键,否则
|
||||||
|
sysextd category 校验失败并卸载("require the presence of the
|
||||||
|
'NSSystemExtensionUsageDescription' property")。主 app 的同名键不顶用。 -->
|
||||||
|
<key>NSSystemExtensionUsageDescription</key>
|
||||||
|
<string>Pangolin 需要安装网络扩展以提供安全的网络连接。</string>
|
||||||
<!-- 自定义键:声明 NEPacketTunnelProvider。其余标准 CFBundle* 键由
|
<!-- 自定义键:声明 NEPacketTunnelProvider。其余标准 CFBundle* 键由
|
||||||
GENERATE_INFOPLIST_FILE=YES 自动生成并合并,此处不重复写以免冲突。 -->
|
GENERATE_INFOPLIST_FILE=YES 自动生成并合并,此处不重复写以免冲突。 -->
|
||||||
<key>NetworkExtension</key>
|
<key>NetworkExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<!-- NEMachServiceName 必须以扩展所属的某个 App Group 为前缀(Apple 规则)。
|
||||||
|
App Group 用 macOS 原生格式 BYL4KQHMTN.com.pangolin.pangolin,mach 名 = 该 group
|
||||||
|
+ .PacketTunnel。参照可工作的 Tailscale:group=<Team>.io.tailscale.ipn.macsys、
|
||||||
|
mach=该 group + .network-extension。 -->
|
||||||
<key>NEMachServiceName</key>
|
<key>NEMachServiceName</key>
|
||||||
<string>$(TeamIdentifierPrefix)group.com.pangolin.pangolin</string>
|
<string>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</string>
|
||||||
<key>NEProviderClasses</key>
|
<key>NEProviderClasses</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.networkextension.packet-tunnel</key>
|
<key>com.apple.networkextension.packet-tunnel</key>
|
||||||
|
|||||||
@@ -2,20 +2,26 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- 开发期:App Extension 变体,Apple Development 自动签名即可编/签/本机调试。
|
<!-- Developer ID 分发 + 公证:禁止调试附加。 -->
|
||||||
⚠️ 上线(站外 Developer ID 分发)时改为 System Extension:
|
<key>com.apple.security.get-task-allow</key>
|
||||||
- 把本值换成 packet-tunnel-provider-systemextension
|
<false/>
|
||||||
- target 产物类型转 System Extension(.systemextension)
|
<!-- 沙箱内的 packet tunnel 需显式网络权限才能对外建连/收发(对照可工作的 Tailscale)。 -->
|
||||||
- 用 Developer ID 手动签名 + 公证;首启 systemextensionsctl developer on
|
<key>com.apple.security.network.client</key>
|
||||||
Swift 代码两形态一致,仅打包/签名不同。详见 docs/p1-macos-system-extension.md。 -->
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
<!-- System Extension 形态(站外 Developer ID 分发):用 -systemextension 变体,
|
||||||
|
与 Developer ID profile(Pangolin PacketTunnel DevID)授权一致。
|
||||||
|
详见 docs/p1-macos-system-extension.md。 -->
|
||||||
<key>com.apple.developer.networking.networkextension</key>
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
<array>
|
<array>
|
||||||
<string>packet-tunnel-provider</string>
|
<string>packet-tunnel-provider-systemextension</string>
|
||||||
</array>
|
</array>
|
||||||
<!-- 与主 app 共享配置/状态(同一 App Group) -->
|
<!-- 与主 app 共享配置/状态(同一 App Group)。macOS 原生格式 <TeamID>.<name>,
|
||||||
|
非 iOS 的 group. 前缀——sysextd realize 前校验请求方/扩展时要求此格式。 -->
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.pangolin.pangolin</string>
|
<string>BYL4KQHMTN.com.pangolin.pangolin</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import NetworkExtension
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
private let log = Logger(subsystem: "com.pangolin.pangolin.PacketTunnel", category: "provider")
|
private let log = Logger(subsystem: "com.pangolin.pangolin.PacketTunnel", category: "provider")
|
||||||
private let appGroup = "group.com.pangolin.pangolin"
|
// macOS 原生 App Group 格式 <TeamID>.<name>(非 iOS 的 group. 前缀)。
|
||||||
|
// sysextd 校验请求方 app 时要求此格式;用 group. 式会在 realize 暂存前被拒。
|
||||||
|
private let appGroup = "BYL4KQHMTN.com.pangolin.pangolin"
|
||||||
|
|
||||||
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
private var commandServer: LibboxCommandServer?
|
private var commandServer: LibboxCommandServer?
|
||||||
@@ -24,40 +26,48 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
override func startTunnel(options: [String: NSObject]?,
|
override func startTunnel(options: [String: NSObject]?,
|
||||||
completionHandler: @escaping (Error?) -> Void) {
|
completionHandler: @escaping (Error?) -> Void) {
|
||||||
log.info("startTunnel")
|
log.info("startTunnel")
|
||||||
do {
|
// libbox 启动必须放后台队列:startOrReloadService 会同步回调 openTun →
|
||||||
let configContent = try resolveConfig(options)
|
// 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
|
guard let base = FileManager.default
|
||||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
|
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
|
||||||
throw simpleError("no app group container")
|
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)
|
|
||||||
|
|
||||||
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()
|
|
||||||
try server.startOrReloadService(configContent, options: nil)
|
|
||||||
self.commandServer = server
|
|
||||||
|
|
||||||
log.info("startTunnel: service started")
|
|
||||||
completionHandler(nil)
|
|
||||||
} catch {
|
|
||||||
log.error("startTunnel failed: \(error.localizedDescription)")
|
|
||||||
completionHandler(error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +106,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol {
|
extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol {
|
||||||
func serviceReload() throws {
|
func serviceReload() throws {
|
||||||
if let cfg = try? resolveConfig(nil) {
|
if let cfg = try? resolveConfig(nil) {
|
||||||
try commandServer?.startOrReloadService(cfg, options: nil)
|
try commandServer?.startOrReloadService(cfg, options: LibboxOverrideOptions())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func serviceStop() throws { cancelTunnelWithError(nil) }
|
func serviceStop() throws { cancelTunnelWithError(nil) }
|
||||||
@@ -170,6 +180,11 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
|
|||||||
func startDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws {
|
func startDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws {
|
||||||
let m = NWPathMonitor()
|
let m = NWPathMonitor()
|
||||||
monitor = m
|
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
|
m.pathUpdateHandler = { [weak self] path in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let iface = path.availableInterfaces.first { path.usesInterfaceType($0.type) }
|
let iface = path.availableInterfaces.first { path.usesInterfaceType($0.type) }
|
||||||
@@ -180,8 +195,10 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
|
|||||||
listener?.updateDefaultInterface(name, interfaceIndex: index,
|
listener?.updateDefaultInterface(name, interfaceIndex: index,
|
||||||
isExpensive: path.isExpensive,
|
isExpensive: path.isExpensive,
|
||||||
isConstrained: path.isConstrained)
|
isConstrained: path.isConstrained)
|
||||||
|
if !signaled { signaled = true; firstUpdate.signal() }
|
||||||
}
|
}
|
||||||
m.start(queue: monitorQueue)
|
m.start(queue: monitorQueue)
|
||||||
|
_ = firstUpdate.wait(timeout: .now() + 5)
|
||||||
}
|
}
|
||||||
func closeDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws {
|
func closeDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws {
|
||||||
stopMonitor()
|
stopMonitor()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 70;
|
objectVersion = 54;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXAggregateTarget section */
|
/* Begin PBXAggregateTarget section */
|
||||||
@@ -92,7 +92,6 @@
|
|||||||
dstPath = "";
|
dstPath = "";
|
||||||
dstSubfolderSpec = 10;
|
dstSubfolderSpec = 10;
|
||||||
files = (
|
files = (
|
||||||
A17B79F72FE52309001ABF28 /* Libbox.xcframework in Embed Frameworks */,
|
|
||||||
);
|
);
|
||||||
name = "Embed Frameworks";
|
name = "Embed Frameworks";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -137,7 +136,7 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
A17B79EE2FE50746001ABF28 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
A17B79EE2FE50746001ABF28 /* Exceptions for "PacketTunnel" folder in "PacketTunnel" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
@@ -147,7 +146,18 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
A17B79E22FE50746001ABF28 /* PacketTunnel */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A17B79EE2FE50746001ABF28 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = PacketTunnel; sourceTree = "<group>"; };
|
A17B79E22FE50746001ABF28 /* PacketTunnel */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
A17B79EE2FE50746001ABF28 /* Exceptions for "PacketTunnel" folder in "PacketTunnel" target */,
|
||||||
|
);
|
||||||
|
explicitFileTypes = {
|
||||||
|
};
|
||||||
|
explicitFolders = (
|
||||||
|
);
|
||||||
|
path = PacketTunnel;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -338,6 +348,20 @@
|
|||||||
productReference = 33CC10ED2044A3C60003C045 /* pangolin_vpn.app */;
|
productReference = 33CC10ED2044A3C60003C045 /* pangolin_vpn.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */ = {
|
A17B79DE2FE50745001ABF28 /* PacketTunnel */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = A17B79EF2FE50746001ABF28 /* Build configuration list for PBXNativeTarget "PacketTunnel" */;
|
buildConfigurationList = A17B79EF2FE50746001ABF28 /* Build configuration list for PBXNativeTarget "PacketTunnel" */;
|
||||||
@@ -346,6 +370,7 @@
|
|||||||
A17B79DC2FE50745001ABF28 /* Frameworks */,
|
A17B79DC2FE50745001ABF28 /* Frameworks */,
|
||||||
A17B79DD2FE50745001ABF28 /* Resources */,
|
A17B79DD2FE50745001ABF28 /* Resources */,
|
||||||
A17B79F82FE52309001ABF28 /* Embed Frameworks */,
|
A17B79F82FE52309001ABF28 /* Embed Frameworks */,
|
||||||
|
FACE0FF0FACE0FF0FACE0001 /* Sign Libbox (Developer ID) */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -355,8 +380,6 @@
|
|||||||
A17B79E22FE50746001ABF28 /* PacketTunnel */,
|
A17B79E22FE50746001ABF28 /* PacketTunnel */,
|
||||||
);
|
);
|
||||||
name = PacketTunnel;
|
name = PacketTunnel;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = PacketTunnel;
|
productName = PacketTunnel;
|
||||||
productReference = A17B79DF2FE50745001ABF28 /* PacketTunnel.systemextension */;
|
productReference = A17B79DF2FE50745001ABF28 /* PacketTunnel.systemextension */;
|
||||||
productType = "com.apple.product-type.system-extension";
|
productType = "com.apple.product-type.system-extension";
|
||||||
@@ -379,7 +402,6 @@
|
|||||||
33CC10EC2044A3C60003C045 = {
|
33CC10EC2044A3C60003C045 = {
|
||||||
CreatedOnToolsVersion = 9.2;
|
CreatedOnToolsVersion = 9.2;
|
||||||
LastSwiftMigration = 1100;
|
LastSwiftMigration = 1100;
|
||||||
ProvisioningStyle = Automatic;
|
|
||||||
SystemCapabilities = {
|
SystemCapabilities = {
|
||||||
com.apple.Sandbox = {
|
com.apple.Sandbox = {
|
||||||
enabled = 1;
|
enabled = 1;
|
||||||
@@ -405,7 +427,7 @@
|
|||||||
);
|
);
|
||||||
mainGroup = 33CC10E42044A3C60003C045;
|
mainGroup = 33CC10E42044A3C60003C045;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -536,14 +558,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
@@ -616,7 +634,7 @@
|
|||||||
baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */;
|
baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 13;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||||
@@ -631,7 +649,7 @@
|
|||||||
baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */;
|
baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 13;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||||
@@ -646,7 +664,7 @@
|
|||||||
baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */;
|
baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 13;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||||
@@ -712,11 +730,11 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -847,11 +865,11 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -870,17 +888,18 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
CODE_SIGN_STYLE = Manual;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||||
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
OTHER_CODE_SIGN_FLAGS = "--timestamp";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "Pangolin App DevID";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -914,13 +933,13 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 13;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -933,12 +952,14 @@
|
|||||||
"@executable_path/../../../../Frameworks",
|
"@executable_path/../../../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
@@ -962,13 +983,13 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 13;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -981,11 +1002,14 @@
|
|||||||
"@executable_path/../../../../Frameworks",
|
"@executable_path/../../../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
|
OTHER_CODE_SIGN_FLAGS = "--timestamp";
|
||||||
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
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;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@@ -1008,13 +1032,13 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 13;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -1027,11 +1051,12 @@
|
|||||||
"@executable_path/../../../../Frameworks",
|
"@executable_path/../../../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
@@ -1097,7 +1122,7 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,14 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- P1 方案B:主 app 经 OSSystemExtensionRequest 安装 PacketTunnel sysext,需此权限。 -->
|
||||||
|
<key>com.apple.developer.system-extension.install</key>
|
||||||
|
<true/>
|
||||||
|
<!-- 主 app 经 NETunnelProviderManager 管理 packet-tunnel,需 NE 权限(App ID 已开 Network Extensions)。 -->
|
||||||
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
|
<array>
|
||||||
|
<string>packet-tunnel-provider</string>
|
||||||
|
</array>
|
||||||
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
|
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
@@ -28,5 +28,8 @@
|
|||||||
<string>MainMenu</string>
|
<string>MainMenu</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>NSApplication</string>
|
<string>NSApplication</string>
|
||||||
|
<!-- 安装 PacketTunnel System Extension 的必需键:OSSystemExtensionRequest 审批时展示给用户。 -->
|
||||||
|
<key>NSSystemExtensionUsageDescription</key>
|
||||||
|
<string>Pangolin 需要安装系统扩展以提供安全的网络连接。</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -2,20 +2,33 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<!-- Developer ID 分发 + 公证:禁止调试附加(否则公证会拒;也避免 Xcode 注入 =true)。 -->
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<false/>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- P1 方案B:主 app 经 OSSystemExtensionRequest 安装 PacketTunnel sysext,需此权限。 -->
|
||||||
|
<key>com.apple.developer.system-extension.install</key>
|
||||||
|
<true/>
|
||||||
|
<!-- 主 app 经 NETunnelProviderManager 管理 packet-tunnel(System Extension 形态)。
|
||||||
|
Developer ID profile 授权的是 -systemextension 变体,故用它(非 plain)。 -->
|
||||||
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
|
<array>
|
||||||
|
<string>packet-tunnel-provider-systemextension</string>
|
||||||
|
</array>
|
||||||
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
|
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)com.pangolin.pangolin</string>
|
<string>$(AppIdentifierPrefix)com.pangolin.pangolin</string>
|
||||||
</array>
|
</array>
|
||||||
<!-- P1 方案B:接 System Extension 时在此加(注册 App Group 后,见 p1-macos-system-extension.md §3):
|
<!-- 与 PacketTunnel sysext 共享 App Group。必须用 macOS 原生格式 <TeamID>.<name>
|
||||||
|
(BYL4KQHMTN.com.pangolin.pangolin),非 iOS 的 group. 前缀——否则 sysextd 在 realize
|
||||||
|
暂存前校验请求方 app 时认定 app group 非法而拒绝(参照可工作的 Tailscale)。 -->
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.pangolin.pangolin</string>
|
<string>BYL4KQHMTN.com.pangolin.pangolin</string>
|
||||||
</array>
|
</array>
|
||||||
-->
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -13,9 +13,16 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
import SystemExtensions
|
import SystemExtensions
|
||||||
|
import os.log
|
||||||
|
|
||||||
// NSLog 兼容老部署目标(Runner < macOS 11,os.Logger 不可用)。
|
// os_log + %{public} —— 让日志在 Console.app / `log show` 里可见(NSLog 的 %@ 参数会被
|
||||||
private func vpnLog(_ message: String) { NSLog("[pangolin/vpn] %@", message) }
|
// 系统 redact 成 <private>,排障时看不到内容)。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 {
|
final class VpnChannel: NSObject {
|
||||||
private static let tunnelBundleId = "com.pangolin.pangolin.PacketTunnel"
|
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 {
|
private func start(_ configJson: String, _ result: @escaping FlutterResult) async {
|
||||||
|
vpnLog("start() 收到调用, config 长度=\(configJson.count) bytes")
|
||||||
do {
|
do {
|
||||||
|
vpnLog("step① 激活 System Extension …")
|
||||||
try await activateSystemExtensionIfNeeded()
|
try await activateSystemExtensionIfNeeded()
|
||||||
|
vpnLog("step① System Extension 激活完成 ✓")
|
||||||
|
|
||||||
|
vpnLog("step② 装配 NETunnelProviderManager …")
|
||||||
let mgr = try await loadOrCreateManager()
|
let mgr = try await loadOrCreateManager()
|
||||||
self.manager = mgr
|
self.manager = mgr
|
||||||
|
vpnLog("step② manager 就绪, 当前隧道状态=\(Self.statusString(mgr.connection.status))")
|
||||||
|
|
||||||
|
vpnLog("step③ startVPNTunnel(options: configContent) …")
|
||||||
try mgr.connection.startVPNTunnel(options: [
|
try mgr.connection.startVPNTunnel(options: [
|
||||||
"configContent": configJson as NSString,
|
"configContent": configJson as NSString,
|
||||||
])
|
])
|
||||||
|
vpnLog("step③ startVPNTunnel 调用已返回(实际起停由 NEVPNStatus 流驱动)✓")
|
||||||
result(nil)
|
result(nil)
|
||||||
} catch {
|
} 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))
|
result(FlutterError(code: "start_failed", message: error.localizedDescription, details: nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +107,7 @@ final class VpnChannel: NSObject {
|
|||||||
// ── NETunnelProviderManager 装配 ────────────────────────────────
|
// ── NETunnelProviderManager 装配 ────────────────────────────────
|
||||||
private func loadOrCreateManager() async throws -> NETunnelProviderManager {
|
private func loadOrCreateManager() async throws -> NETunnelProviderManager {
|
||||||
let all = try await NETunnelProviderManager.loadAllFromPreferences()
|
let all = try await NETunnelProviderManager.loadAllFromPreferences()
|
||||||
|
vpnLog(" loadAllFromPreferences: 已有 \(all.count) 个 VPN 配置")
|
||||||
let mgr = all.first ?? NETunnelProviderManager()
|
let mgr = all.first ?? NETunnelProviderManager()
|
||||||
let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) ?? NETunnelProviderProtocol()
|
let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) ?? NETunnelProviderProtocol()
|
||||||
proto.providerBundleIdentifier = Self.tunnelBundleId
|
proto.providerBundleIdentifier = Self.tunnelBundleId
|
||||||
@@ -97,14 +115,20 @@ final class VpnChannel: NSObject {
|
|||||||
mgr.protocolConfiguration = proto
|
mgr.protocolConfiguration = proto
|
||||||
mgr.localizedDescription = "Pangolin"
|
mgr.localizedDescription = "Pangolin"
|
||||||
mgr.isEnabled = true
|
mgr.isEnabled = true
|
||||||
|
vpnLog(" saveToPreferences(providerBundleId=\(Self.tunnelBundleId)) …")
|
||||||
try await mgr.saveToPreferences()
|
try await mgr.saveToPreferences()
|
||||||
try await mgr.loadFromPreferences() // 保存后重载,拿到有效 connection
|
try await mgr.loadFromPreferences() // 保存后重载,拿到有效 connection
|
||||||
|
vpnLog(" manager 保存+重载完成 ✓")
|
||||||
return mgr
|
return mgr
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求系统加载/更新 PacketTunnel System Extension。首启系统会弹「隐私与安全性」
|
// 请求系统加载/更新 PacketTunnel System Extension。首启系统会弹「隐私与安全性」
|
||||||
// 让用户允许;允许后 didFinishWithResult 回来。已是最新则快速完成。
|
// 让用户允许;允许后 didFinishWithResult 回来。已是最新则快速完成。
|
||||||
private func activateSystemExtensionIfNeeded() async throws {
|
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<Void, Error>) in
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
let req = OSSystemExtensionRequest.activationRequest(
|
let req = OSSystemExtensionRequest.activationRequest(
|
||||||
forExtensionWithIdentifier: Self.tunnelBundleId, queue: .main)
|
forExtensionWithIdentifier: Self.tunnelBundleId, queue: .main)
|
||||||
@@ -112,6 +136,7 @@ final class VpnChannel: NSObject {
|
|||||||
self.sysextDelegate = delegate // 保活到回调结束
|
self.sysextDelegate = delegate // 保活到回调结束
|
||||||
req.delegate = delegate
|
req.delegate = delegate
|
||||||
OSSystemExtensionManager.shared.submitRequest(req)
|
OSSystemExtensionManager.shared.submitRequest(req)
|
||||||
|
vpnLog(" submitRequest 已提交, 等待 sysextd 回调(didFinish / didFail / needsApproval)…")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +146,9 @@ final class VpnChannel: NSObject {
|
|||||||
forName: .NEVPNStatusDidChange, object: nil, queue: .main
|
forName: .NEVPNStatusDidChange, object: nil, queue: .main
|
||||||
) { [weak self] note in
|
) { [weak self] note in
|
||||||
guard let conn = note.object as? NEVPNConnection else { return }
|
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,
|
func request(_ request: OSSystemExtensionRequest,
|
||||||
didFinishWithResult result: OSSystemExtensionRequest.Result) {
|
didFinishWithResult result: OSSystemExtensionRequest.Result) {
|
||||||
|
vpnLog("sysext didFinishWithResult ✓ result=\(result.rawValue) (0=completed, 1=willCompleteAfterReboot)")
|
||||||
guard !resumed else { return }
|
guard !resumed else { return }
|
||||||
resumed = true
|
resumed = true
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
}
|
}
|
||||||
func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
|
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 }
|
guard !resumed else { return }
|
||||||
resumed = true
|
resumed = true
|
||||||
continuation.resume(throwing: error)
|
continuation.resume(throwing: error)
|
||||||
}
|
}
|
||||||
func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
|
func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
|
||||||
vpnLog("system extension 待用户允许(系统设置 → 隐私与安全性)")
|
vpnLog("sysext requestNeedsUserApproval —— 需在 系统设置 → 隐私与安全性 点「允许」(等待中…)")
|
||||||
}
|
}
|
||||||
func request(_ request: OSSystemExtensionRequest,
|
func request(_ request: OSSystemExtensionRequest,
|
||||||
actionForReplacingExtension existing: OSSystemExtensionProperties,
|
actionForReplacingExtension existing: OSSystemExtensionProperties,
|
||||||
withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
|
withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
|
||||||
.replace
|
vpnLog("sysext 替换扩展: 已装 v\(existing.bundleVersion)/\(existing.bundleShortVersion) → 新 v\(ext.bundleVersion)/\(ext.bundleShortVersion), 选择 replace")
|
||||||
|
return .replace
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+13
@@ -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: 完成"
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>macOS PacketTunnel 系统扩展 realize 失败(code=4)踩坑复盘</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||||
|
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||||
|
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||||
|
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||||
|
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
|
||||||
|
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||||
|
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||||
|
h2{font-size:21px;margin:48px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||||
|
h3{font-size:16px;margin:28px 0 8px;color:var(--accent2)}
|
||||||
|
p{margin:10px 0}
|
||||||
|
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||||
|
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||||
|
pre .c{color:#6b7385}
|
||||||
|
pre .r{color:var(--bad)}
|
||||||
|
pre .g{color:var(--ok)}
|
||||||
|
pre .y{color:var(--warn)}
|
||||||
|
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||||
|
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||||
|
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||||
|
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||||
|
.card.root{border-left:3px solid var(--accent)}
|
||||||
|
.card h3{margin-top:0}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||||
|
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||||
|
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||||
|
td code{font-size:.85em}
|
||||||
|
.ok-c{color:var(--ok)} .bad-c{color:var(--bad)} .warn-c{color:var(--warn)}
|
||||||
|
ul,ol{padding-left:22px;margin:10px 0}
|
||||||
|
li{margin:5px 0}
|
||||||
|
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||||
|
.kbd{font-family:var(--mono);font-size:.85em;color:var(--accent)}
|
||||||
|
.small{color:var(--fg2);font-size:13px}
|
||||||
|
.step{counter-increment:step;position:relative;padding-left:6px}
|
||||||
|
hr{border:none;border-top:1px solid var(--border);margin:40px 0}
|
||||||
|
a{color:var(--accent2)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<h1>macOS PacketTunnel 系统扩展 realize 失败(<code>OSSystemExtensionErrorDomain code=4</code>)踩坑复盘</h1>
|
||||||
|
<p class="sub">Pangolin 客户端 · P1 原生隧道 · 2026-06-21 · 在 macOS 15.3.2 / 26 上排查与修复</p>
|
||||||
|
|
||||||
|
<div class="lead">
|
||||||
|
<strong>一句话结论:</strong> 客户端内嵌的 <code>PacketTunnel</code> 系统扩展(<code>NEPacketTunnelProvider</code>)一直无法激活,报
|
||||||
|
<code>code=4 "Extension not found in App bundle. Unable to find any matched extension"</code>。
|
||||||
|
表面像"找不到扩展",实则是 <b>三个叠加的工程配置 bug</b> 让 <code>sysextd</code> 在 realize/暂存/分类校验三个不同阶段先后拒绝。
|
||||||
|
逐个修复后扩展成功 <code>activated enabled</code>、隧道连通。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>1. 现象</h2>
|
||||||
|
<p>客户端点"连接"时,主 app 调 <code>OSSystemExtensionRequest.activationRequest</code> 激活内嵌的 packet-tunnel 系统扩展,但回调恒为失败:</p>
|
||||||
|
<pre><span class="r">sysext didFailWithError ✗ domain=OSSystemExtensionErrorDomain code=4</span>
|
||||||
|
desc=Extension not found in App bundle. Unable to find any matched extension
|
||||||
|
with identifier: com.pangolin.pangolin.PacketTunnel</pre>
|
||||||
|
<p>诡异之处:</p>
|
||||||
|
<ul>
|
||||||
|
<li>扩展明明在 <code>Contents/Library/SystemExtensions/</code> 里,<code>sysextd</code> 日志也打了 <code>attempting to realize</code>(说明找到了)。</li>
|
||||||
|
<li>签名、公证、staple、Gatekeeper 全部通过;<code>codesign --verify --deep --strict</code> 通过。</li>
|
||||||
|
<li><code>sysextd</code> 在验签通过后 <b>~10ms 内静默 <code>xpc_connection_cancel</code></b>,自身不打印任何拒绝原因。</li>
|
||||||
|
<li><b>从不弹"允许扩展"批准框</b>——在批准阶段之前就被拒。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>2. 走过的弯路(全部排除)</h2>
|
||||||
|
<p>以下都查过并确认<strong>不是</strong>根因,记录下来免得后人重复:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>假设</th><th>结论</th></tr>
|
||||||
|
<tr><td>签名 / 公证 / 时间戳 / hardened runtime / get-task-allow</td><td class="ok-c">全部正确</td></tr>
|
||||||
|
<tr><td>entitlements 变体(<code>packet-tunnel-provider-systemextension</code>)</td><td class="ok-c">正确,且描述文件已授权</td></tr>
|
||||||
|
<tr><td>Info.plist 用 <code>NetworkExtension</code>/<code>NEProviderClasses</code>(而非 appex 的 <code>NSExtension</code>)</td><td class="ok-c">正确(sysext 就该用这个)</td></tr>
|
||||||
|
<tr><td>Xcode 26 beta / macOS 26.2 SDK 工具链太新</td><td class="bad-c">证伪:Tailscale 用更新的 SDK 26.5 照样能用</td></tr>
|
||||||
|
<tr><td>App 文件归属(user vs root:wheel)</td><td class="bad-c">改 root:wheel 无效</td></tr>
|
||||||
|
<tr><td>App 不在 /Applications / LaunchServices 注册 / 翻译重定位</td><td class="bad-c">都正常,无效</td></tr>
|
||||||
|
<tr><td><code>systemextensionsctl reset</code> 清缓存</td><td class="bad-c">无效</td></tr>
|
||||||
|
<tr><td>App Group 用 iOS 式 <code>group.</code> 前缀</td><td class="warn-c">确实该改成 macOS 原生格式,但单独改它仍失败</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>3. 破局方法:在同一台机器上对照一个"能用的"开源同类</h2>
|
||||||
|
<p>关键转折是<strong>不再盲猜,而是拿一个已知能用、且分发模型完全相同的开源 app 在同一台机器上对照</strong>。我们选了 <b>Tailscale 独立版</b>(<code>io.tailscale.ipn.macsys</code>):同样是 Developer ID 公证 + <code>NEPacketTunnelProvider</code> <b>系统扩展</b>分发(非 App Store appex)。</p>
|
||||||
|
<div class="card">
|
||||||
|
<p>把本机能用的 <code>Tailscale.app</code> 直接拷到出问题的测试机(cara,干净 macOS 15.3.2,Intel),它的扩展<strong>一路走完</strong> <code>validating → staging → validating_by_category → activated_waiting_for_user</code> 并弹出"允许扩展"框。</p>
|
||||||
|
<p><strong>这一步同时证明了两件事:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>cara 的环境完全正常——能 realize 第三方 Developer ID 系统扩展。</li>
|
||||||
|
<li>所以 100% 是<strong>我们自己的包</strong>有问题(用户的直觉:"是我们代码的问题")。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="small">附:也对照了 Shadowrocket,但它是 App Store 的 <b>appex</b> 模型(<code>NSExtension</code>、系统预信任、不走 sysextd 批准流),与我们不是一个机制,不能直接类比。要对照必须找<strong>同为 Developer ID system extension</strong> 的实现。</p>
|
||||||
|
|
||||||
|
<h2>4. 三个根因(按 sysextd 处理阶段排序)</h2>
|
||||||
|
|
||||||
|
<div class="card root">
|
||||||
|
<h3>根因 ① 扩展不自包含 <span class="tag bad">staging 前被拒</span></h3>
|
||||||
|
<p>这是 Flutter + 扩展工程的经典坑。工程用 CocoaPods,<b>项目级</b>配置经 <code>Release.xcconfig</code> 注入了链接所有 pod 框架的 <code>OTHER_LDFLAGS</code>。<code>PacketTunnel</code> target 没有自己的 <code>OTHER_LDFLAGS</code>,于是<strong>继承了项目级的</strong>,把主 app 的 Flutter 插件 <code>flutter_secure_storage_macos.framework</code> 也链进了扩展——而该框架并不在扩展 bundle 内:</p>
|
||||||
|
<pre>$ otool -L PacketTunnel.systemextension/Contents/MacOS/PacketTunnel
|
||||||
|
<span class="r">@rpath/flutter_secure_storage_macos.framework/.../flutter_secure_storage_macos</span> ← 悬空依赖!
|
||||||
|
/System/Library/Frameworks/...</pre>
|
||||||
|
<p>同时,gomobile 产出的 <code>Libbox.xcframework</code> 实为<strong>静态库</strong>(<code>ar archive</code>),已被静态链进扩展二进制,却又被 <b>Embed Frameworks</b> 冗余地塞了一份畸形的 <code>Libbox.framework</code> 进扩展。</p>
|
||||||
|
<p><b>system extension 必须自包含</b>(realize 时会被拷到 <code>/Library/SystemExtensions/<UUID>/</code> 独立运行,相对 rpath 回不到主 app)。</p>
|
||||||
|
<p><strong>修复:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>给 <code>PacketTunnel</code> 的 Debug/Release 配置加 <code class="kbd">OTHER_LDFLAGS = "";</code>,切断对项目级 pod 链接标志的继承。</li>
|
||||||
|
<li>从 <b>Embed Frameworks</b> 移除 <code>Libbox.xcframework</code>(它是静态库,只需 <b>Link</b>,不需 Embed)。</li>
|
||||||
|
</ul>
|
||||||
|
<p class="small">验证:<code>otool -L</code> 扩展二进制应只剩 <code>/System/...</code> 与 <code>/usr/lib/...</code>,无任何 <code>@rpath</code>;<code>Contents/</code> 下不再有 <code>Frameworks/</code> 目录(与 Tailscale 一致)。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card root">
|
||||||
|
<h3>根因 ② 扩展 bundle 名 ≠ bundle 标识符 <span class="tag bad">staging 阶段</span></h3>
|
||||||
|
<p>我们扩展的产物名是 <code>PacketTunnel.systemextension</code>,而 <code>CFBundleIdentifier</code> 是 <code>com.pangolin.pangolin.PacketTunnel</code>。Tailscale 的产物名 = 标识符(<code>io.tailscale.ipn.macsys.network-extension.systemextension</code>)。</p>
|
||||||
|
<p>在受影响的 macOS 上,<code>sysextd</code> 必须能按标识符把 bundle 拷进暂存区;名字对不上时,在 <code>attempting to realize</code> 之后直接静默 cancel,<b>进不了 staging</b>。改名后日志立刻出现 <code>[Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension</code>。</p>
|
||||||
|
<p><strong>修复:</strong> 把 <code>PacketTunnel</code> target 的 <code class="kbd">PRODUCT_NAME</code> 从 <code>$(TARGET_NAME)</code> 改为 <code>com.pangolin.pangolin.PacketTunnel</code>(三个配置 Debug/Release/Profile 都改)。</p>
|
||||||
|
<p class="small">连带:<code>PRODUCT_MODULE_NAME</code> 会变成 <code>com_pangolin_pangolin_PacketTunnel</code>,而 Info.plist 的 <code>NEProviderClasses</code> 用 <code>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</code> 自动跟随,无需手改。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card root">
|
||||||
|
<h3>根因 ③ 扩展 Info.plist 缺 <code>NSSystemExtensionUsageDescription</code> <span class="tag bad">category 校验阶段</span></h3>
|
||||||
|
<p>过了 staging 后,日志终于给出<strong>明确原因</strong>:</p>
|
||||||
|
<pre><span class="r">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.</span>
|
||||||
|
→ uninstalling invalid extension</pre>
|
||||||
|
<p>网络扩展类别<strong>强制要求扩展自己的 Info.plist</strong> 里有 <code>NSSystemExtensionUsageDescription</code>。我们之前只在<strong>主 app</strong> 的 Info.plist 加了——<b>不顶用</b>。Tailscale 的扩展 Info.plist 里就有这个键。</p>
|
||||||
|
<p><strong>修复:</strong> 在 <code>PacketTunnel/Info.plist</code> 顶层加:</p>
|
||||||
|
<pre><key>NSSystemExtensionUsageDescription</key>
|
||||||
|
<string>Pangolin 需要安装网络扩展以提供安全的网络连接。</string></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>附带一并修正的项</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>App Group 格式</b>:<code>group.com.pangolin.pangolin</code>(iOS 式)→ macOS 原生 <code>BYL4KQHMTN.com.pangolin.pangolin</code>(主 app + 扩展 entitlements + Swift <code>appGroup</code> 常量三处同步)。</li>
|
||||||
|
<li><b>NEMachServiceName</b>:必须以扩展所属 App Group 为前缀 → <code>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</code>。</li>
|
||||||
|
<li><b>扩展网络权限</b>:沙箱扩展补 <code>com.apple.security.network.client</code> / <code>network.server</code>(packet tunnel 要对外建连)。</li>
|
||||||
|
<li><b>公证前置</b>:<code>get-task-allow=false</code>、Xcode 签名加 <code>--timestamp</code>(<code>OTHER_CODE_SIGN_FLAGS</code>)、构建期把静态 Libbox 以 Developer ID 重签。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. 成功的 sysextd 日志长这样</h2>
|
||||||
|
<pre>attempting to realize extension with identifier com.pangolin.pangolin.PacketTunnel
|
||||||
|
[Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension
|
||||||
|
advancing state from staging to <span class="g">validating</span>
|
||||||
|
advancing state from validating to <span class="g">validating_by_category</span>
|
||||||
|
<span class="g">Category delegate com.apple.system_extension.network_extension returned error (null)</span>
|
||||||
|
advancing state ... to <span class="g">activated_waiting_for_user</span>
|
||||||
|
observer 'notify user' reached a success state <span class="c"># ← 弹"允许扩展"框</span>
|
||||||
|
|
||||||
|
<span class="c"># 用户在 系统设置 → 隐私与安全性 点允许后:</span>
|
||||||
|
sysext didFinishWithResult ✓ result=0 (completed)
|
||||||
|
$ systemextensionsctl list
|
||||||
|
* * BYL4KQHMTN com.pangolin.pangolin.PacketTunnel (1.0/1) <span class="g">[activated enabled]</span></pre>
|
||||||
|
|
||||||
|
<h2>5.5 加载之后:从"扩展能起"到"真正连通"(运行时)</h2>
|
||||||
|
<p>系统扩展能 realize/激活只是第一步。让内嵌的 libbox(sing-box)真正建起隧道、能上网,又踩了四个坑(均在 <code>PacketTunnelProvider.swift</code> / 服务端配置):</p>
|
||||||
|
|
||||||
|
<div class="card root">
|
||||||
|
<h3>运行时 ① libbox <code>startOrReloadService(options:)</code> 传 nil → 空指针崩溃 <span class="tag bad">扩展进程 SIGABRT</span></h3>
|
||||||
|
<p>扩展进程启动 360ms 后自杀(SIGABRT)。把 stderr 重定向到 App Group 容器文件后抓到 Go panic:</p>
|
||||||
|
<pre><span class="r">panic: runtime error: invalid memory address or nil pointer dereference</span>
|
||||||
|
libbox.(*CommandServer).StartOrReloadService(server, {config}, <span class="r">0x0</span>) command_server.go:175</pre>
|
||||||
|
<p>此版本 libbox 会解引用第三个 <code>options</code> 参数(虽标 <code>_Nullable</code>)。<strong>修复:</strong>传非空 <code>LibboxOverrideOptions()</code> 而非 <code>nil</code>。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card root">
|
||||||
|
<h3>运行时 ② 默认接口监控异步返回 → <code>no available network interface</code> <span class="tag bad">启动报错</span></h3>
|
||||||
|
<p><code>startDefaultInterfaceMonitor</code> 启动 <code>NWPathMonitor</code> 后立即返回,但首个 path 回调是异步晚到的;sing-box 紧接着的网络操作拿到的默认接口索引还是 -1。<strong>修复:</strong>用信号量**阻塞到首个 path 更新再返回**(带 5s 超时),对齐 sing-box-for-apple。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card root">
|
||||||
|
<h3>运行时 ③ provider 队列三方死锁 → 隧道永远卡 connecting <span class="tag bad">最隐蔽</span></h3>
|
||||||
|
<p>openTun 打点显示卡在 <code>setTunnelNetworkSettings</code> 的信号量等待,回调永不触发:</p>
|
||||||
|
<ul>
|
||||||
|
<li>NE 在 <b>provider 队列</b>上调 <code>startTunnel</code> → 同步调 <code>startOrReloadService</code>(阻塞该队列)</li>
|
||||||
|
<li>sing-box 在 Go 线程调 openTun → <code>sem.wait()</code> 等 <code>setTunnelNetworkSettings</code> 完成</li>
|
||||||
|
<li>而该完成回调**正要在被阻塞的 provider 队列上投递** → 死锁</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>修复:</strong>把 libbox 启动整段放进后台队列(<code>DispatchQueue.global().async</code>),<code>startTunnel</code> 立即返回、provider 队列腾出来投递回调。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card root">
|
||||||
|
<h3>运行时 ④ 缺 DNS 劫持 → 隧道连上但打不开网站 <span class="tag bad">最后一关</span></h3>
|
||||||
|
<p>隧道起来了、TCP 能经 REALITY 出海,但域名打不开。box.log 显示发往隧道 DNS 的查询走了直连:</p>
|
||||||
|
<pre>inbound packet connection to <span class="y">172.19.0.2:53</span>
|
||||||
|
router: match ip_cidr=[..<span class="r">172.16.0.0/12</span>..] => route(<span class="r">direct</span>) <span class="c"># DNS 被 LAN 规则吞去直连 → 解析失败</span></pre>
|
||||||
|
<p>隧道 DNS 地址 172.19.0.2 落在 LAN 直连规则 172.16/12 内,被路由成直连(发往不存在的主机)→ 解析全失败。<strong>修复(服务端 <code>clientconfig.go</code>):</strong>route.rules 首条加 <code>{"action":"hijack-dns","port":[53]}</code>(排在 LAN 规则之前),把 :53 查询交给 sing-box DNS 模块。</p>
|
||||||
|
<p class="small">注:sing-box 1.13 的 <code>protocol:"dns"</code> 匹配需先 sniff;按目的端口 53 匹配最稳、不依赖 sniff。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p><strong>另一个发版必知:每次构建必递增 <code>CFBundleVersion</code>。</strong> <code>sysextd</code> 按(标识符, 版本)去重;同版本号重装**不会替换**已激活的旧扩展,跑的还是旧代码(排查时极易被误导)。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>连通验证(从命令行):</strong></p>
|
||||||
|
<pre>$ curl https://api.ipify.org → <span class="g">103.119.13.48</span> <span class="c"># 出口=节点 IP,流量走隧道</span>
|
||||||
|
$ curl -o/dev/null -w '%{http_code}' https://github.com → <span class="g">200</span>
|
||||||
|
box.log: router: match[0] port=53 => <span class="g">hijack-dns</span> → dns: exchanged A github.com 140.82.116.3</pre>
|
||||||
|
|
||||||
|
<h2>6. 给后人的排查 checklist(Developer ID 网络系统扩展)</h2>
|
||||||
|
<ol>
|
||||||
|
<li><b>先找同模型的能用实现对照</b>(Tailscale 独立版 / Mullvad),<u>在同一台机器</u>上验证环境没问题,把范围锁到自己的包。</li>
|
||||||
|
<li>扩展二进制 <code>otool -L</code> 必须<strong>零 <code>@rpath</code> 外部依赖</strong>(自包含)。警惕 CocoaPods/Flutter 的 <code>OTHER_LDFLAGS</code> 继承。</li>
|
||||||
|
<li>扩展 <b>bundle 名 = CFBundleIdentifier</b>(设 <code>PRODUCT_NAME</code> = 标识符)。</li>
|
||||||
|
<li>扩展 Info.plist <strong>必须有 <code>NSSystemExtensionUsageDescription</code></strong>(不是主 app 的)。</li>
|
||||||
|
<li>App Group 用 macOS 原生 <code><TeamID>.<name></code>;<code>NEMachServiceName</code> 以该 group 为前缀。</li>
|
||||||
|
<li>沙箱扩展按需补 <code>network.client/server</code>、文件访问等能力。</li>
|
||||||
|
<li>静态库 xcframework 只 <b>Link</b> 不 <b>Embed</b>;动态 framework 才需 Embed + CodeSignOnCopy。</li>
|
||||||
|
<li>排查时:<code>log stream --debug --predicate 'process=="sysextd" OR process=="syspolicyd"'</code>,看卡在 <code>realize / staging / validating_by_category</code> 哪一步;category 校验失败会打出明确缺什么键。</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>7. 已知环境坑:macOS 26 (Tahoe) 回归</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p>在 macOS 26 上,即使包完全正确,<code>sysextd</code> 仍可能报
|
||||||
|
<code>no policy, cannot allow apps outside /Applications</code>(app 明明在 /Applications)。这是 <b>Apple 在 Tahoe 的已知回归</b>(开发者论坛多人复现、非 MDM 个人机),<u>不是我们能修的</u>,也不影响 macOS 15/14 的真实用户。开发机若是 26,本机调试可考虑关 SIP 后开
|
||||||
|
<code>systemextensionsctl developer on</code>,或在 15/14 机器上验证。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>8. 运行时 checklist(libbox + NE 集成)</h2>
|
||||||
|
<ol>
|
||||||
|
<li>libbox <code>startOrReloadService(options:)</code> 传**非空** <code>LibboxOverrideOptions()</code>,别传 nil。</li>
|
||||||
|
<li><code>startTunnel</code> 里的 libbox 启动放**后台队列**(<code>DispatchQueue.global().async</code>),别在 provider 队列同步跑(死锁)。</li>
|
||||||
|
<li><code>startDefaultInterfaceMonitor</code> **阻塞到首个 path 更新再返回**。</li>
|
||||||
|
<li>TUN 配置必须有 **DNS 劫持**(<code>action:hijack-dns</code>,按 port 53),排在 LAN/分流规则之前。</li>
|
||||||
|
<li>**每次构建递增 <code>CFBundleVersion</code>**,否则 sysextd 不更新已激活的扩展。</li>
|
||||||
|
<li>看 libbox 自身日志:配置 <code>log.output</code> 指向容器文件(<code>writeLogs</code> 回调启动期不触发);Go fatal 走 stderr,需把扩展 stderr 重定向到文件才看得到。</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<p class="small">改动文件:扩展 <code>Info.plist</code>/<code>entitlements</code>/<code>main.swift</code>/<code>PacketTunnelProvider.swift</code> · <code>Runner/Release.entitlements</code> · <code>Runner.xcodeproj/project.pbxproj</code>(<code>OTHER_LDFLAGS</code>/<code>PRODUCT_NAME</code>/移除 Embed Libbox/<code>--timestamp</code>/版本号) · <code>scripts/local_test.sh</code> · <code>client/macos/sign_libbox.sh</code> · 服务端 <code>server/internal/httpapi/clientconfig.go</code>(DNS 劫持)。验证机:cara,干净 macOS 15.3.2 Intel,出口 IP=节点、国外站可达。</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Pangolin VPN 测试方案 + 报告
|
||||||
|
|
||||||
|
> 两个方向:**黑盒**(用户视角,只看输入→输出)+ **白盒**(利用我们对客户端/节点/sing-box
|
||||||
|
> 的完全可见性,拆链路、看协议、做稳定性)。日期 2026-06-22。配套:`scripts/vpn_test.py`、
|
||||||
|
> 调研见 `docs/vpn-testing-research.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、测试方向总览
|
||||||
|
|
||||||
|
| | 黑盒 | 白盒 |
|
||||||
|
|---|---|---|
|
||||||
|
| 视角 | 用户视角,不看内部 | 工程视角,看内部链路/协议/状态 |
|
||||||
|
| 目的 | 能不能用、快不快(可用性 + 体验) | 慢在哪、走什么协议、稳不稳(定位 + 稳定性) |
|
||||||
|
| 数据源 | `curl` / `ping` 黑盒探测 | sing-box Clash API、libbox 命令服务、curl 五段拆解、节点侧探测、长跑监控 |
|
||||||
|
| 工具 | `scripts/vpn_test.py`(已实现) | 待实现:`scripts/vpn_whitebox.sh` + 稳定性长跑 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、黑盒测试方案
|
||||||
|
|
||||||
|
**对象:国内外常用站点矩阵**,开/关分流各测一遍,多时段(白天 / 晚高峰 20:00–24:00)。
|
||||||
|
|
||||||
|
### 指标
|
||||||
|
- **连通性**:HTTP 状态码(能否访问)
|
||||||
|
- **延迟**:**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
|
||||||
|
- **测速点**:Cloudflare(国外)、清华/阿里镜像(国内,用 `-r 0-N` range 取固定大小)
|
||||||
|
|
||||||
|
### 方法
|
||||||
|
- 先 **裸连基线**(不开 VPN 跑一遍存档)→ 再开 VPN 对比衰减。
|
||||||
|
- 每站取 中位数(多次),记录时段。
|
||||||
|
- 一条命令产报告:`vpn_test.py`(现覆盖连通/出口/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 <size>` 二分探测路径 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.py` 全量:**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.py`
|
||||||
|
> 已据此修正。
|
||||||
|
|
||||||
|
### 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.py`(黑盒)**:加 上传测速 / ping TTL / 站点矩阵 / `--baseline` 基线 / 多时段 cron。
|
||||||
|
2. **新增 `scripts/vpn_whitebox.sh`(白盒)**:读节点 Clash API(连接/出站/延迟)+ curl 五段拆解 + 接入/出海分段 RTT + 路径 MTU。
|
||||||
|
3. **新增稳定性长跑**:cron 周期探测落 CSV,出趋势;Kill switch / 重连 / GFW 存活脚本。
|
||||||
@@ -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.py`(客户端侧黑盒,优先做)
|
||||||
|
在已连接的机器(本机/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
|
||||||
+154
-52
@@ -1,65 +1,167 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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 并运行
|
# 背景:flutter build 出来的包是 Apple Development 签名;要让 System Extension
|
||||||
# bash scripts/local_test.sh --no-build # 跳过构建,直接跑上次的产物(快速重跑)
|
# 能加载(且不依赖 SIP/dev mode),需重签成 Developer ID。本脚本封装这套流程。
|
||||||
#
|
#
|
||||||
# 可选环境变量:
|
# 子命令:
|
||||||
# PANGOLIN_API_URL 控制面地址(默认连 racknerd 节点)
|
# build flutter build macos --release(注入联调节点地址)
|
||||||
# SINGBOX_BIN sing-box 路径(默认 brew 的 /opt/homebrew/bin/sing-box)
|
# 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 配好签名团队。
|
# 用法: scripts/local_test.sh all | scripts/local_test.sh build ...
|
||||||
# TUN 需 root → 脚本会装一条 NOPASSWD sudoers(一次性,可能让你输一次电脑密码)。
|
|
||||||
# 正式版会用 SMJobBless 特权 Helper 取代 sudo(BACKLOG-11D-HELPER)。
|
|
||||||
set -euo pipefail
|
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"
|
CLIENT="$REPO_ROOT/client"
|
||||||
API_URL="${PANGOLIN_API_URL:-http://107.172.55.251:8080}"
|
APP="$CLIENT/build/macos/Build/Products/Release/pangolin_vpn.app"
|
||||||
SINGBOX="${SINGBOX_BIN:-/opt/homebrew/bin/sing-box}"
|
SE="$APP/Contents/Library/SystemExtensions/PacketTunnel.systemextension"
|
||||||
NO_BUILD=0
|
LIBFW="$SE/Contents/Frameworks/Libbox.framework"
|
||||||
[ "${1:-}" = "--no-build" ] && NO_BUILD=1
|
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; }
|
log(){ printf '\033[1;33m== %s ==\033[0m\n' "$*"; }
|
||||||
[ -x "$SINGBOX" ] || { echo "✗ 找不到 sing-box: $SINGBOX(brew install sing-box,或设 SINGBOX_BIN)"; exit 1; }
|
die(){ printf '\033[1;31m❌ %s\033[0m\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
# ── 1. TUN 免密 sudoers(已生效则跳过,避免反复要密码)─────────────────────────
|
# 按 Name 在描述文件目录里定位 .provisionprofile(结果写入全局 FOUND)。
|
||||||
echo "==> [1/3] sing-box 免密 sudo(TUN 需 root)"
|
FOUND=""
|
||||||
if sudo -n "$SINGBOX" version >/dev/null 2>&1; then
|
find_profile(){
|
||||||
echo " 已生效,跳过(无需密码)"
|
local want="$1" f
|
||||||
else
|
for f in "$PROF_DIR"/*.provisionprofile; do
|
||||||
SUDO_FILE=/etc/sudoers.d/pangolin-singbox
|
[ -e "$f" ] || continue
|
||||||
WANT="$(whoami) ALL=(root) NOPASSWD: $SINGBOX"
|
if security cms -D -i "$f" 2>/dev/null | grep -q ">$want<"; then
|
||||||
TMP="$(mktemp)"
|
FOUND="$f"; return 0
|
||||||
printf '%s\n' "$WANT" > "$TMP"
|
fi
|
||||||
if sudo visudo -cf "$TMP" >/dev/null 2>&1; then
|
done
|
||||||
sudo install -m 440 -o root -g wheel "$TMP" "$SUDO_FILE"
|
return 1
|
||||||
echo " 已写入 $SUDO_FILE: $WANT"
|
}
|
||||||
else
|
|
||||||
echo "✗ sudoers 语法校验失败"; rm -f "$TMP"; exit 1
|
|
||||||
fi
|
|
||||||
rm -f "$TMP"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 2. 构建 release(--no-build 跳过,直接跑上次产物)─────────────────────────
|
write_entitlements(){
|
||||||
if [ "$NO_BUILD" = "1" ]; then
|
cat > "$WORK/app.entitlements" <<'PLIST'
|
||||||
echo "==> [2/3] 跳过构建(--no-build)"
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
else
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
echo "==> [2/3] flutter build macos --release (API=$API_URL)"
|
<plist version="1.0"><dict>
|
||||||
( cd "$CLIENT" && flutter build macos --release --dart-define=PANGOLIN_API_URL="$API_URL" )
|
<key>com.apple.application-identifier</key><string>BYL4KQHMTN.com.pangolin.pangolin</string>
|
||||||
fi
|
<key>com.apple.developer.team-identifier</key><string>BYL4KQHMTN</string>
|
||||||
|
<key>com.apple.developer.system-extension.install</key><true/>
|
||||||
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
|
<array><string>packet-tunnel-provider-systemextension</string></array>
|
||||||
|
<key>com.apple.security.app-sandbox</key><false/>
|
||||||
|
<key>com.apple.security.network.client</key><true/>
|
||||||
|
<key>com.apple.security.network.server</key><true/>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array><string>BYL4KQHMTN.com.pangolin.pangolin</string></array>
|
||||||
|
</dict></plist>
|
||||||
|
PLIST
|
||||||
|
cat > "$WORK/sysext.entitlements" <<'PLIST'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>com.apple.application-identifier</key><string>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</string>
|
||||||
|
<key>com.apple.developer.team-identifier</key><string>BYL4KQHMTN</string>
|
||||||
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
|
<array><string>packet-tunnel-provider-systemextension</string></array>
|
||||||
|
<key>com.apple.security.app-sandbox</key><true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array><string>group.com.pangolin.pangolin</string></array>
|
||||||
|
</dict></plist>
|
||||||
|
PLIST
|
||||||
|
}
|
||||||
|
|
||||||
APP="$(ls -d "$CLIENT"/build/macos/Build/Products/Release/*.app 2>/dev/null | head -1)"
|
cs(){ codesign --force --options runtime --timestamp "$@"; }
|
||||||
[ -n "$APP" ] || { echo "✗ 未找到构建产物 .app"; exit 1; }
|
|
||||||
EXE="$APP/Contents/MacOS/$(basename "$APP" .app)"
|
|
||||||
|
|
||||||
# ── 3. 前台运行(日志直出)────────────────────────────────────────────────────
|
cmd_build(){
|
||||||
echo "==> [3/3] 运行 release 包(前台,日志直出;Ctrl+C 退出)"
|
log "构建 release(API=$API_URL)"
|
||||||
echo " app : $APP"
|
cd "$CLIENT"
|
||||||
echo " 内核: 客户端会解析到 $SINGBOX(已加进默认查找路径)"
|
flutter build macos --release --dart-define="PANGOLIN_API_URL=$API_URL"
|
||||||
echo " 连上后另开终端验证出口: curl https://api.ipify.org → 期望 ${API_URL#http://}"
|
[ -d "$APP" ] || die "构建产物不存在: $APP"
|
||||||
echo "------------------------------------------------------------------"
|
}
|
||||||
exec "$EXE"
|
|
||||||
|
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
|
||||||
|
|||||||
Executable
+289
@@ -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"<tr><td>{html.escape(r['name'])}</td><td class=mono>{html.escape(r['host'])}</td>"
|
||||||
|
f"<td class=fail colspan=4>不可达 {html.escape(r['err'][:40])}</td></tr>")
|
||||||
|
return (f"<tr><td>{html.escape(r['name'])}</td><td class=mono>{html.escape(r['host'])}</td>"
|
||||||
|
f"<td>HTTP {r['code']}</td>"
|
||||||
|
f"<td class='mono {cls(r['tls_ms'])}'>{r['tls_ms']} ms</td>"
|
||||||
|
f"<td class=mono>{r['ttfb_ms']} ms</td>"
|
||||||
|
f"<td class=mono>{r['dns_ms']} ms</td></tr>")
|
||||||
|
ftab = "\n".join(row(r) for r in f_rows)
|
||||||
|
dtab = "\n".join(row(r) for r in d_rows)
|
||||||
|
dlrows = "\n".join(
|
||||||
|
f"<tr><td>{html.escape(l)}</td><td class='mono {'pass' if m else 'fail'}'>"
|
||||||
|
f"{m if m is not None else '失败'} Mbps</td><td class=mono>{n}B / {dt}s</td></tr>"
|
||||||
|
for l, m, n, dt in dls)
|
||||||
|
egress_cls = "pass" if tunneled else "warn"
|
||||||
|
return f"""<!DOCTYPE html><html lang=zh-CN><head><meta charset=UTF-8>
|
||||||
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||||
|
<title>Pangolin VPN 测试报告 {started}</title>
|
||||||
|
<style>
|
||||||
|
body{{font-family:-apple-system,"PingFang SC",Arial,sans-serif;margin:0;background:#f6f7f9;color:#1c2330;line-height:1.6}}
|
||||||
|
.wrap{{max-width:960px;margin:0 auto;padding:32px 20px 80px}}
|
||||||
|
h1{{font-size:24px;margin:0 0 4px}} .sub{{color:#6b7280;margin:0 0 20px}}
|
||||||
|
h2{{font-size:18px;margin:32px 0 10px;border-bottom:2px solid #e5e7eb;padding-bottom:6px}}
|
||||||
|
table{{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.06);font-size:14px}}
|
||||||
|
th,td{{text-align:left;padding:9px 12px;border-bottom:1px solid #eef0f3}}
|
||||||
|
th{{background:#fafbfc;color:#6b7280;font-weight:600;font-size:13px}}
|
||||||
|
.mono{{font-family:"SF Mono",Menlo,monospace;font-size:13px}}
|
||||||
|
.pass{{color:#16a34a;font-weight:600}} .warn{{color:#d97706;font-weight:600}} .fail{{color:#dc2626;font-weight:600}}
|
||||||
|
.card{{background:#fff;border-radius:10px;padding:14px 18px;margin:12px 0;box-shadow:0 1px 3px rgba(0,0,0,.06)}}
|
||||||
|
.note{{background:#fff8ee;border:1px solid #f5e3c4;border-radius:10px;padding:14px 18px;margin:16px 0;font-size:13px;color:#7a5b25}}
|
||||||
|
code{{background:#eef0f3;padding:1px 5px;border-radius:4px;font-family:"SF Mono",monospace;font-size:.9em}}
|
||||||
|
.big{{font-size:15px}}
|
||||||
|
</style></head><body><div class=wrap>
|
||||||
|
<h1>Pangolin VPN 测试报告</h1>
|
||||||
|
<p class=sub>{started} · 期望节点出口 {NODE_IP}</p>
|
||||||
|
|
||||||
|
<div class=card big>
|
||||||
|
出口 IP:<b class="mono {egress_cls}">{html.escape(eip or '取不到')}</b> {html.escape(egeo)}
|
||||||
|
— {'<b class=pass>流量走隧道(出口=节点)</b>' if tunneled else '<b class=warn>出口≠节点(可能直连/其他 VPN/DNS 挂)</b>'}
|
||||||
|
{('<br>本机:tun(172.19.0.1)=' + ('有' if conn.get('tun') else '无') + ' · 系统扩展=' + (conn.get('sysext') or '未激活')) if conn else ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=note>
|
||||||
|
<b>延迟口径(TTL):</b>本表「TTL/延迟」= <b>TLS 握手耗时</b>(TCP 建立完成→TLS 握手完成),
|
||||||
|
≈ 真实网络链路往返 × 握手轮数,跨站可比、不含目标服务器后端处理。<b>非 IP TTL</b>;也不用
|
||||||
|
<code>ping</code>/<code>tcp_connect</code>(经 TUN 被隧道本地应答,几 ms 假象,不可信)。
|
||||||
|
<b>TTFB</b> 为参考 = 请求→首字节(含服务器处理),受目标站自身快慢影响。
|
||||||
|
判定:TLS 握手 <{LAT_GOOD}ms 优 / {LAT_GOOD}–{LAT_WARN}ms 偏高 / >{LAT_WARN}ms 高。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>国外站点(应经隧道)</h2>
|
||||||
|
<table><tr><th>站点</th><th>域名</th><th>连通</th><th>TTL/延迟(TLS握手)</th><th>TTFB(参考)</th><th>DNS</th></tr>
|
||||||
|
{ftab}</table>
|
||||||
|
|
||||||
|
<h2>国内站点(开分流应直连)</h2>
|
||||||
|
<table><tr><th>站点</th><th>域名</th><th>连通</th><th>TTL/延迟(TLS握手)</th><th>TTFB(参考)</th><th>DNS</th></tr>
|
||||||
|
{dtab}</table>
|
||||||
|
|
||||||
|
<h2>吞吐(下载 ~10MB)</h2>
|
||||||
|
<table><tr><th>测速点</th><th>速度</th><th>明细</th></tr>
|
||||||
|
{dlrows or '<tr><td colspan=3>(已跳过)</td></tr>'}</table>
|
||||||
|
|
||||||
|
<p class=sub style=margin-top:32px>方法详见 docs/vpn-test-plan.md / docs/vpn-testing-research.md。生成工具:scripts/vpn_test.py</p>
|
||||||
|
</div></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -126,8 +126,13 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
|
|||||||
"tolerance": 50,
|
"tolerance": 50,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route: LAN direct;(可选)国内 IP/域名直连;其余 via auto。
|
// Route: DNS 劫持 → LAN direct →(可选)国内直连 → 其余 via auto。
|
||||||
routeRules := []any{
|
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{
|
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"},
|
"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",
|
"outbound": "direct",
|
||||||
|
|||||||
Generated
+2
-2
@@ -8,7 +8,7 @@
|
|||||||
"name": "pangolin-usercenter",
|
"name": "pangolin-usercenter",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "<14.3.0",
|
"next": "14.2.35",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1"
|
||||||
},
|
},
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"@types/react": "18.3.3",
|
"@types/react": "18.3.3",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-next": "<14.3.0",
|
"eslint-config-next": "14.2.35",
|
||||||
"typescript": "5.5.3"
|
"typescript": "5.5.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
--clay-200: #E6C7AC;
|
--clay-200: #E6C7AC;
|
||||||
--clay-300: #D9A982;
|
--clay-300: #D9A982;
|
||||||
--clay-400: #CC8B5C;
|
--clay-400: #CC8B5C;
|
||||||
--clay-500: #FF0000; /* ← brand primary */
|
--clay-500: #B96A3D; /* ← brand primary */
|
||||||
--clay-600: #9E5630;
|
--clay-600: #9E5630;
|
||||||
--clay-700: #7E4426;
|
--clay-700: #7E4426;
|
||||||
--clay-800: #5E331D;
|
--clay-800: #5E331D;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
--clay-200: #E6C7AC;
|
--clay-200: #E6C7AC;
|
||||||
--clay-300: #D9A982;
|
--clay-300: #D9A982;
|
||||||
--clay-400: #CC8B5C;
|
--clay-400: #CC8B5C;
|
||||||
--clay-500: #FF0000; /* ← brand primary */
|
--clay-500: #B96A3D; /* ← brand primary */
|
||||||
--clay-600: #9E5630;
|
--clay-600: #9E5630;
|
||||||
--clay-700: #7E4426;
|
--clay-700: #7E4426;
|
||||||
--clay-800: #5E331D;
|
--clay-800: #5E331D;
|
||||||
|
|||||||
Reference in New Issue
Block a user