From bd8f974a25b8dcb11c2c8cbdfe20ed3de157f640 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 14:16:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(M6):=20=E6=8E=A7=E5=88=B6=E9=9D=A2?= =?UTF-8?q?=E8=81=94=E8=B0=83=20=E2=80=94=20mock=20connect=20server=20+=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=20ConnectApi=20=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsk_nuoKSM4Vt-zK ## 服务端(server/cmd/mockserver) - `main.go`(79 行):独立 mock HTTP server,实现 POST /v1/nodes/:id/connect - 路径/请求体/响应体字段名与 §3.1 契约逐字一致 - Bearer token 鉴权(-token 参数,空值跳过,不入库) - 缺少 device_id → 400;未授权 → 401;非 POST → 405 - 返回完整 sing-box config JSON(tun + REALITY/Hy2 outbound + urltest + route/dns) - 注:mock 联调;待 #5/#6 真实 connect 接口就绪后替换 - `main_test.go`:6 项单测,覆盖 §3.1 结构校验、auth、method、path ## 客户端(client/) - `lib/services/connect_api.dart`:ConnectApi 类 - fetchConfig(nodeId, deviceId) → 原始响应体字符串(不做任何修改) - 错误路径:HTTP 非 200 / 超时 / 非法 JSON → ConnectApiException(含双语 message) - `lib/services/vpn_bridge.dart`:VpnBridge stub(M6 联调,libbox 绑定待 11C) - start(configJson) 原样存储,不修改;stop() 清空 - `lib/widgets/home_shell.dart`:_toggle/_pick 替换为真实 API 流程 - ConnectApi.fetchConfig → VpnBridge.start(透传,无中间变换) - 错误路径:ConnectApiException → SnackBar,status 回 off,无残留半开隧道 - API URL/token/deviceId 由 --dart-define 注入(不入库) - `lib/widgets/server_tile.dart`:ServerInfo 新增 nodeId 字段 - `pubspec.yaml`:新增 http: ^1.2.1 依赖 - `test/connect_passthrough_test.dart`:4 项透传断言单测 - 核心:fetchConfig 返回字符串 === VpnBridge.start 接收字符串(逐字节相等) - 空格/格式原样保留(不经 jsonEncode 重序列化) - HTTP 错误、非法 JSON 错误路径覆盖 ## 运行方式(M6 联调) ``` # 启动 mock server(无 auth) go run ./server/cmd/mockserver -addr :8081 # 启动客户端(指向 mock server) flutter run \ --dart-define=PANGOLIN_API_URL=http://localhost:8081 \ --dart-define=PANGOLIN_API_TOKEN=dev-mock-token \ --dart-define=PANGOLIN_DEVICE_ID=demo-device-001 ``` Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + client/lib/services/connect_api.dart | 97 ++++ client/lib/services/vpn_bridge.dart | 38 ++ client/lib/widgets/home_shell.dart | 586 ++++++++++++++++------ client/lib/widgets/server_tile.dart | 15 +- client/pubspec.yaml | 1 + client/test/connect_passthrough_test.dart | 146 ++++++ server/cmd/mockserver/main.go | 79 +++ server/cmd/mockserver/main_test.go | 130 +++++ 9 files changed, 954 insertions(+), 142 deletions(-) create mode 100644 client/lib/services/connect_api.dart create mode 100644 client/lib/services/vpn_bridge.dart create mode 100644 client/test/connect_passthrough_test.dart create mode 100644 server/cmd/mockserver/main.go create mode 100644 server/cmd/mockserver/main_test.go diff --git a/.gitignore b/.gitignore index d0c339e..24ff985 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ deploy/backup/backup.env *.age-private* age-keypair.txt +# Go 编译产物(mock server 等) +server/mockserver +server/pangolin-server + # 杂项 .DS_Store *.log diff --git a/client/lib/services/connect_api.dart b/client/lib/services/connect_api.dart new file mode 100644 index 0000000..909a8b1 --- /dev/null +++ b/client/lib/services/connect_api.dart @@ -0,0 +1,97 @@ +// connect_api.dart — 控制面 connect 接口客户端 +// +// 职责:POST /v1/nodes/:id/connect → 获取完整 sing-box config JSON。 +// 关键约束(§3.1):返回的 JSON 字符串必须原样透传给 VpnBridge.start, +// 调用方禁止对字符串做任何增删改;本类同样不修改响应体。 +// +// M6 联调:mock 服务器地址由 --dart-define=PANGOLIN_API_URL 注入(默认 :8081)。 +// tsk_nuoKSM4Vt-zK +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// 控制面接口调用失败时抛出。 +/// [statusCode] < 0 表示网络层错误,-2 表示响应体 JSON 解析失败。 +class ConnectApiException implements Exception { + const ConnectApiException({ + required this.statusCode, + required this.messageZh, + required this.messageEn, + }); + + final int statusCode; + final String messageZh; + final String messageEn; + + @override + String toString() => 'ConnectApiException($statusCode): $messageZh'; +} + +/// [ConnectApi] 封装 POST /v1/nodes/:id/connect 调用。 +/// +/// 可通过 [client] 注入自定义 [http.Client](方便单测使用 MockClient)。 +class ConnectApi { + ConnectApi({ + required this.baseUrl, + required this.authToken, + http.Client? client, + }) : _client = client ?? http.Client(); + + final String baseUrl; + final String authToken; + final http.Client _client; + + /// 向控制面请求 [nodeId] 节点的完整 sing-box config JSON。 + /// + /// 返回**原始响应体字符串**,调用方必须不做任何修改直接传入 `VpnBridge.start`。 + /// 异常:[ConnectApiException](HTTP 非 200、超时、非法 JSON)。 + Future fetchConfig({ + required String nodeId, + required String deviceId, + }) async { + final uri = Uri.parse('$baseUrl/v1/nodes/$nodeId/connect'); + final http.Response response; + + try { + response = await _client + .post( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $authToken', + }, + body: jsonEncode({'device_id': deviceId}), + ) + .timeout(const Duration(seconds: 15)); + } on Exception catch (e) { + throw ConnectApiException( + statusCode: -1, + messageZh: '网络请求失败,请检查连接后重试', + messageEn: 'Network error: $e', + ); + } + + if (response.statusCode != 200) { + throw ConnectApiException( + statusCode: response.statusCode, + messageZh: '节点连接失败 (HTTP ${response.statusCode})', + messageEn: 'Connect failed (HTTP ${response.statusCode})', + ); + } + + // 仅做合法性校验,不修改响应体。 + try { + jsonDecode(response.body); + } catch (_) { + throw const ConnectApiException( + statusCode: -2, + messageZh: '收到的配置格式无效,请稍后重试', + messageEn: 'Invalid config JSON received from server', + ); + } + + // 原样返回——调用方透传给 VpnBridge.start,不得在途中修改。 + return response.body; + } + + void dispose() => _client.close(); +} diff --git a/client/lib/services/vpn_bridge.dart b/client/lib/services/vpn_bridge.dart new file mode 100644 index 0000000..2605ed5 --- /dev/null +++ b/client/lib/services/vpn_bridge.dart @@ -0,0 +1,38 @@ +// vpn_bridge.dart — sing-box libbox 的 Dart 侧封装 +// +// 关键约束(§3.1):[start] 接收的 [configJson] 必须原样透传, +// 禁止在 Dart 层对任何字段做增删改——组装逻辑全在服务端。 +// +// M6 联调状态:stub 实现。 +// TODO(11C): 替换为真实 gomobile libbox 绑定。 +// tsk_nuoKSM4Vt-zK +// ignore_for_file: avoid_print + +/// [VpnBridge] 封装 sing-box libbox 的启动与停止。 +/// +/// 每次创建新实例(非单例),便于单测注入与隔离。 +class VpnBridge { + String? _lastConfigJson; + + /// 以原样 [configJson] 启动 sing-box 隧道。 + /// + /// **禁止在此修改 configJson**(§3.1 透传约束:客户端零组装)。 + /// 当前为 stub;11C 完成后替换为 `libbox.start(configJson)` 原生调用。 + Future start(String configJson) async { + _lastConfigJson = configJson; + // TODO(11C): await _libbox.start(configJson); + print('[VpnBridge] start — length=${configJson.length} (stub, libbox pending 11C)'); + } + + /// 停止隧道。当前为 stub。 + Future stop() async { + _lastConfigJson = null; + // TODO(11C): await _libbox.stop(); + print('[VpnBridge] stop (stub)'); + } + + /// 最近一次传入 [start] 的原始 JSON 字符串。 + /// 仅供测试验证透传约束,生产代码不应依赖此字段。 + // @visibleForTesting + String? get lastConfigJson => _lastConfigJson; +} diff --git a/client/lib/widgets/home_shell.dart b/client/lib/widgets/home_shell.dart index 7b55a1b..67d21b7 100644 --- a/client/lib/widgets/home_shell.dart +++ b/client/lib/widgets/home_shell.dart @@ -1,7 +1,13 @@ // home_shell.dart — 主框架(底部 4 Tab:连接 / 节点 / 统计 / 账户) +// +// M6 改动:_toggle/_pick 替换为真实 ConnectApi.fetchConfig → VpnBridge.start。 +// API URL / token 由 --dart-define 注入(见 README M6 联调说明),默认指向本地 mock server。 +// tsk_nuoKSM4Vt-zK import 'dart:async'; import 'package:flutter/material.dart'; import '../pangolin_theme.dart'; +import '../services/connect_api.dart'; +import '../services/vpn_bridge.dart'; import 'pangolin_icons.dart'; import 'connect_button.dart'; import 'server_tile.dart'; @@ -9,6 +15,23 @@ import 'country_code.dart'; import 'account_screens.dart'; import 'pangolin_logo.dart'; +// ── 联调配置(由 --dart-define 注入;不入库)── +// 运行示例:flutter run --dart-define=PANGOLIN_API_URL=http://localhost:8081 \ +// --dart-define=PANGOLIN_API_TOKEN=dev-mock-token \ +// --dart-define=PANGOLIN_DEVICE_ID=demo-device-001 +const _kApiUrl = String.fromEnvironment( + 'PANGOLIN_API_URL', + defaultValue: 'http://localhost:8081', +); +const _kApiToken = String.fromEnvironment( + 'PANGOLIN_API_TOKEN', + defaultValue: 'dev-mock-token', +); +const _kDeviceId = String.fromEnvironment( + 'PANGOLIN_DEVICE_ID', + defaultValue: 'demo-device-001', +); + class HomeShell extends StatefulWidget { const HomeShell({super.key, this.zh = true}); final bool zh; @@ -19,47 +42,106 @@ class HomeShell extends StatefulWidget { class _HomeShellState extends State { int _tab = 0; VpnStatus _status = VpnStatus.off; - ServerInfo _server = const ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18); + ServerInfo _server = const ServerInfo( + code: 'HK', + name: '香港 · 流媒体', + sub: 'Hong Kong', + ping: 18, + nodeId: 'hk-1', + ); Timer? _timer; int _elapsed = 0; + // ── 控制面 + 数据面 ── + late final ConnectApi _api = ConnectApi(baseUrl: _kApiUrl, authToken: _kApiToken); + final VpnBridge _bridge = VpnBridge(); + String _t(String zh, String en) => widget.zh ? zh : en; + // ── 连接/断开(异步,含错误路径)── void _toggle() { + if (_status == VpnStatus.connecting) return; // 防重入 if (_status == VpnStatus.off) { - setState(() => _status = VpnStatus.connecting); - Future.delayed(const Duration(milliseconds: 1500), () { - if (!mounted) return; - setState(() { _status = VpnStatus.on; _elapsed = 0; }); - _timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() => _elapsed++)); - }); + _connect(_server); } else { - _timer?.cancel(); - setState(() => _status = VpnStatus.off); + _disconnect(); } } + Future _connect(ServerInfo server) async { + setState(() => _status = VpnStatus.connecting); + final messenger = ScaffoldMessenger.of(context); + try { + // 1. 从控制面拉取完整 sing-box config JSON + final configJson = await _api.fetchConfig( + nodeId: server.effectiveNodeId, + deviceId: _kDeviceId, + ); + // 2. 原样透传给 VpnBridge.start(禁止在 Dart 层修改 configJson) + await _bridge.start(configJson); + if (!mounted) return; + setState(() { + _status = VpnStatus.on; + _elapsed = 0; + }); + _timer = Timer.periodic( + const Duration(seconds: 1), + (_) => setState(() => _elapsed++), + ); + } on ConnectApiException catch (e) { + if (!mounted) return; + setState(() => _status = VpnStatus.off); + messenger.showSnackBar(SnackBar( + content: Text(widget.zh ? e.messageZh : e.messageEn), + backgroundColor: Theme.of(context).colorScheme.error, + behavior: SnackBarBehavior.floating, + )); + } catch (e) { + if (!mounted) return; + setState(() => _status = VpnStatus.off); + messenger.showSnackBar(SnackBar( + content: Text(_t('连接失败,请稍后重试', 'Connection failed, please try again')), + behavior: SnackBarBehavior.floating, + )); + } + } + + Future _disconnect() async { + _timer?.cancel(); + setState(() => _status = VpnStatus.off); + await _bridge.stop(); + } + void _pick(ServerInfo s) { - setState(() { _server = s; _tab = 0; }); + setState(() { + _server = s; + _tab = 0; + }); if (_status == VpnStatus.on) { _timer?.cancel(); - setState(() => _status = VpnStatus.connecting); - Future.delayed(const Duration(milliseconds: 1200), () { - if (!mounted) return; - setState(() { _status = VpnStatus.on; _elapsed = 0; }); - _timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() => _elapsed++)); - }); + _connect(s); } } @override - void dispose() { _timer?.cancel(); super.dispose(); } + void dispose() { + _timer?.cancel(); + _api.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final c = context.pangolin; final pages = [ - _ConnectPage(zh: widget.zh, status: _status, server: _server, elapsedSec: _elapsed, onToggle: _toggle, onOpenServers: () => setState(() => _tab = 1)), + _ConnectPage( + zh: widget.zh, + status: _status, + server: _server, + elapsedSec: _elapsed, + onToggle: _toggle, + onOpenServers: () => setState(() => _tab = 1), + ), _ServersPage(zh: widget.zh, current: _server.code, onPick: _pick), _StatsPage(zh: widget.zh), _AccountPage(zh: widget.zh), @@ -67,7 +149,11 @@ class _HomeShellState extends State { return Scaffold( backgroundColor: c.bg, body: SafeArea(bottom: false, child: pages[_tab]), - bottomNavigationBar: _BottomTab(zh: widget.zh, index: _tab, onTap: (i) => setState(() => _tab = i)), + bottomNavigationBar: _BottomTab( + zh: widget.zh, + index: _tab, + onTap: (i) => setState(() => _tab = i), + ), ); } } @@ -75,7 +161,9 @@ class _HomeShellState extends State { /// ── Bottom tab bar ── class _BottomTab extends StatelessWidget { const _BottomTab({required this.zh, required this.index, required this.onTap}); - final bool zh; final int index; final ValueChanged onTap; + final bool zh; + final int index; + final ValueChanged onTap; @override Widget build(BuildContext context) { @@ -87,28 +175,53 @@ class _BottomTab extends StatelessWidget { (PangolinIcons.user, zh ? '账户' : 'Account'), ]; return Container( - decoration: BoxDecoration(color: c.surface, border: Border(top: BorderSide(color: c.border))), + decoration: BoxDecoration( + color: c.surface, + border: Border(top: BorderSide(color: c.border)), + ), padding: const EdgeInsets.only(top: 8, bottom: 22), - child: Row(children: [ - for (var i = 0; i < items.length; i++) - Expanded(child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onTap(i), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(items[i].$1, size: 22, color: i == index ? c.accent : c.fg3), - const SizedBox(height: 4), - Text(items[i].$2, style: PangolinText.caption.copyWith(color: i == index ? c.accent : c.fg3, fontWeight: i == index ? FontWeight.w700 : FontWeight.w500)), - ]), - )), - ]), + child: Row( + children: [ + for (var i = 0; i < items.length; i++) + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(i), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(items[i].$1, size: 22, color: i == index ? c.accent : c.fg3), + const SizedBox(height: 4), + Text( + items[i].$2, + style: PangolinText.caption.copyWith( + color: i == index ? c.accent : c.fg3, + fontWeight: i == index ? FontWeight.w700 : FontWeight.w500, + ), + ), + ]), + ), + ), + ], + ), ); } } /// ── Connect page ── class _ConnectPage extends StatelessWidget { - const _ConnectPage({required this.zh, required this.status, required this.server, required this.elapsedSec, required this.onToggle, required this.onOpenServers}); - final bool zh; final VpnStatus status; final ServerInfo server; final int elapsedSec; final VoidCallback onToggle, onOpenServers; + const _ConnectPage({ + required this.zh, + required this.status, + required this.server, + required this.elapsedSec, + required this.onToggle, + required this.onOpenServers, + }); + + final bool zh; + final VpnStatus status; + final ServerInfo server; + final int elapsedSec; + final VoidCallback onToggle, onOpenServers; @override Widget build(BuildContext context) { @@ -119,27 +232,62 @@ class _ConnectPage extends StatelessWidget { VpnStatus.on => zh ? '已连接 · 网络已加密' : 'Connected · Encrypted', }; return Column(children: [ - _TopBar(zh: zh, trailing: Text(status == VpnStatus.on ? (zh ? '● 在线' : '● Online') : (zh ? '○ 离线' : '○ Offline'), - style: PangolinText.mono.copyWith(fontSize: 12, color: status == VpnStatus.on ? c.success : c.fg3, fontWeight: FontWeight.w600))), - Expanded(child: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ - ConnectButton(status: status, elapsed: Duration(seconds: elapsedSec), onTap: onToggle), - const SizedBox(height: 24), - Text(cap, style: PangolinText.body.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), - ]))), + _TopBar( + zh: zh, + trailing: Text( + status == VpnStatus.on + ? (zh ? '● 在线' : '● Online') + : (zh ? '○ 离线' : '○ Offline'), + style: PangolinText.mono.copyWith( + fontSize: 12, + color: status == VpnStatus.on ? c.success : c.fg3, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + ConnectButton( + status: status, + elapsed: Duration(seconds: elapsedSec), + onTap: onToggle, + ), + const SizedBox(height: 24), + Text( + cap, + style: PangolinText.body.copyWith(color: c.fg2, fontWeight: FontWeight.w600), + ), + ]), + ), + ), Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 18), child: GestureDetector( onTap: onOpenServers, child: Container( padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), child: Row(children: [ CountryCode(code: server.code, active: true), const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(server.name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)), - Text('${server.sub} · ${server.ping}ms', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), - ])), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + server.name, + style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600), + ), + Text( + '${server.sub} · ${server.ping}ms', + style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400), + ), + ]), + ), Icon(PangolinIcons.chevronRight, size: 20, color: c.fg3), ]), ), @@ -152,15 +300,18 @@ class _ConnectPage extends StatelessWidget { /// ── Servers page ── class _ServersPage extends StatelessWidget { const _ServersPage({required this.zh, required this.current, required this.onPick}); - final bool zh; final String current; final ValueChanged onPick; + + final bool zh; + final String current; + final ValueChanged onPick; static const _servers = [ - ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18), - ServerInfo(code: 'JP', name: '日本 东京', sub: 'Tokyo', ping: 32), - ServerInfo(code: 'TW', name: '台湾 台北', sub: 'Taipei', ping: 28), - ServerInfo(code: 'SG', name: '新加坡', sub: 'Singapore · P2P', ping: 54), - ServerInfo(code: 'KR', name: '韩国 首尔', sub: 'Seoul', ping: 41), - ServerInfo(code: 'US', name: '美国 洛杉矶', sub: 'Los Angeles', ping: 146), + ServerInfo(code: 'HK', name: '香港 · 流媒体', sub: 'Hong Kong', ping: 18, nodeId: 'hk-1'), + ServerInfo(code: 'JP', name: '日本 东京', sub: 'Tokyo', ping: 32, nodeId: 'jp-1'), + ServerInfo(code: 'TW', name: '台湾 台北', sub: 'Taipei', ping: 28, nodeId: 'tw-1'), + ServerInfo(code: 'SG', name: '新加坡', sub: 'Singapore · P2P', ping: 54, nodeId: 'sg-1'), + ServerInfo(code: 'KR', name: '韩国 首尔', sub: 'Seoul', ping: 41, nodeId: 'kr-1'), + ServerInfo(code: 'US', name: '美国 洛杉矶', sub: 'Los Angeles', ping: 146, nodeId: 'us-1'), ]; @override @@ -168,16 +319,27 @@ class _ServersPage extends StatelessWidget { final c = context.pangolin; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _TopBar(zh: zh), - Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), - child: Text(zh ? '选择节点' : 'Choose server', style: PangolinText.h2.copyWith(color: c.fg1))), - Expanded(child: ListView.builder( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), - itemCount: _servers.length, - itemBuilder: (_, i) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: ServerTile(server: _servers[i], active: _servers[i].code == current, onTap: () => onPick(_servers[i])), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Text( + zh ? '选择节点' : 'Choose server', + style: PangolinText.h2.copyWith(color: c.fg1), ), - )), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + itemCount: _servers.length, + itemBuilder: (_, i) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: ServerTile( + server: _servers[i], + active: _servers[i].code == current, + onTap: () => onPick(_servers[i]), + ), + ), + ), + ), ]); } } @@ -186,57 +348,106 @@ class _ServersPage extends StatelessWidget { class _StatsPage extends StatelessWidget { const _StatsPage({required this.zh}); final bool zh; + @override Widget build(BuildContext context) { final c = context.pangolin; final vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0]; - final labels = zh ? ['一','二','三','四','五','六','日'] : ['M','T','W','T','F','S','S']; + final labels = zh ? ['一', '二', '三', '四', '五', '六', '日'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S']; const maxV = 6.1; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _TopBar(zh: zh), - Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), - child: Text(zh ? '使用统计' : 'Statistics', style: PangolinText.h2.copyWith(color: c.fg1))), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Text(zh ? '使用统计' : 'Statistics', style: PangolinText.h2.copyWith(color: c.fg1)), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row(children: [ - for (final m in [(zh ? '本月流量' : 'Traffic', '42.6', 'GB'), (zh ? '平均延迟' : 'Ping', '29', 'ms'), (zh ? '本月时长' : 'Time', '86.4', 'h')]) - Expanded(child: Container( - margin: const EdgeInsets.only(right: 12), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(m.$1, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)), - const SizedBox(height: 6), - Text.rich(TextSpan(text: m.$2, style: PangolinText.mono.copyWith(fontSize: 20, color: c.fg1, fontWeight: FontWeight.w500), - children: [TextSpan(text: ' ${m.$3}', style: PangolinText.caption.copyWith(color: c.fg3))])), - ]), - )), + for (final m in [ + (zh ? '本月流量' : 'Traffic', '42.6', 'GB'), + (zh ? '平均延迟' : 'Ping', '29', 'ms'), + (zh ? '本月时长' : 'Time', '86.4', 'h'), + ]) + Expanded( + child: Container( + margin: const EdgeInsets.only(right: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + m.$1, + style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text.rich(TextSpan( + text: m.$2, + style: PangolinText.mono + .copyWith(fontSize: 20, color: c.fg1, fontWeight: FontWeight.w500), + children: [ + TextSpan( + text: ' ${m.$3}', + style: PangolinText.caption.copyWith(color: c.fg3), + ) + ], + )), + ]), + ), + ), ]), ), const SizedBox(height: 16), - Expanded(child: Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(18), - decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(zh ? '本周流量 (GB)' : 'This week (GB)', style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)), - const SizedBox(height: 18), - Expanded(child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ - for (var i = 0; i < vals.length; i++) - Expanded(child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [ - Text('${vals[i]}', style: PangolinText.mono.copyWith(fontSize: 12, color: c.fg3)), - const SizedBox(height: 6), - Container(height: 90 * (vals[i] / maxV), margin: const EdgeInsets.symmetric(horizontal: 6), - decoration: BoxDecoration(color: c.accent.withOpacity(.85), borderRadius: const BorderRadius.vertical(top: Radius.circular(6)))), - const SizedBox(height: 6), - Text(labels[i], style: PangolinText.caption.copyWith(color: c.fg3)), - ])), - ])), - ]), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + zh ? '本周流量 (GB)' : 'This week (GB)', + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 18), + Expanded( + child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + for (var i = 0; i < vals.length; i++) + Expanded( + child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [ + Text( + '${vals[i]}', + style: PangolinText.mono.copyWith(fontSize: 12, color: c.fg3), + ), + const SizedBox(height: 6), + Container( + height: 90 * (vals[i] / maxV), + margin: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: c.accent.withOpacity(.85), + borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), + ), + ), + const SizedBox(height: 6), + Text(labels[i], style: PangolinText.caption.copyWith(color: c.fg3)), + ]), + ), + ]), + ), + ]), + ), ), - )), + ), ]); } } @@ -245,64 +456,155 @@ class _StatsPage extends StatelessWidget { class _AccountPage extends StatelessWidget { const _AccountPage({required this.zh}); final bool zh; + @override Widget build(BuildContext context) { final c = context.pangolin; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _TopBar(zh: zh), - Padding(padding: const EdgeInsets.fromLTRB(20, 0, 20, 14), - child: Text(zh ? '我的' : 'Account', style: PangolinText.h2.copyWith(color: c.fg1))), - Expanded(child: ListView(padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [ - Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [PangolinColors.clay600, PangolinColors.clay800]), - borderRadius: BorderRadius.circular(PangolinRadius.xl), boxShadow: PangolinShadow.md, - ), - child: Row(children: [ - Container(width: 44, height: 44, decoration: BoxDecoration(color: PangolinColors.white.withOpacity(.18), shape: BoxShape.circle), - child: const Icon(PangolinIcons.crown, size: 22, color: PangolinColors.white)), - const SizedBox(width: 11), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('me@pangolin.vpn', style: TextStyle(color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 16)), - const SizedBox(height: 4), - Text(zh ? 'PRO 会员 · 至 2026-12-31' : 'PRO · until 2026-12-31', style: TextStyle(color: PangolinColors.white.withOpacity(.85), fontSize: 12)), - ])), - FilledButton( - onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => PlansScreen(zh: zh, onChoose: (_) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)))))), - style: FilledButton.styleFrom(backgroundColor: PangolinColors.white, foregroundColor: PangolinColors.clay700, shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10)), - child: Text(zh ? '续费 / 升级' : 'Renew', style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 14), + child: Text(zh ? '我的' : 'Account', style: PangolinText.h2.copyWith(color: c.fg1)), + ), + Expanded( + child: ListView(padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [PangolinColors.clay600, PangolinColors.clay800], + ), + borderRadius: BorderRadius.circular(PangolinRadius.xl), + boxShadow: PangolinShadow.md, ), - ]), - ), - const SizedBox(height: 18), - _accountRow(c, PangolinIcons.mail, zh ? '邮箱' : 'Email', 'me@pangolin.vpn'), - _accountRow(c, PangolinIcons.lock, zh ? '密码' : 'Password', '••••••••••'), - _accountRow(c, PangolinIcons.monitorSmartphone, zh ? '我的设备' : 'My devices', '', - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DevicesScreen(zh: zh)))), - _accountRow(c, PangolinIcons.shoppingBag, zh ? '兑换 & 购买' : 'Redeem & buy', '', - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)))), - _accountRow(c, PangolinIcons.messageCircle, zh ? '联系我们' : 'Contact us', '', - onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ContactScreen(zh: zh)))), - _accountRow(c, PangolinIcons.logOut, zh ? '退出登录' : 'Sign out', '', danger: true), - ])), + child: Row(children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: PangolinColors.white.withOpacity(.18), + shape: BoxShape.circle, + ), + child: const Icon(PangolinIcons.crown, size: 22, color: PangolinColors.white), + ), + const SizedBox(width: 11), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text( + 'me@pangolin.vpn', + style: TextStyle( + color: PangolinColors.white, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + zh ? 'PRO 会员 · 至 2026-12-31' : 'PRO · until 2026-12-31', + style: TextStyle(color: PangolinColors.white.withOpacity(.85), fontSize: 12), + ), + ]), + ), + FilledButton( + onPressed: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PlansScreen( + zh: zh, + onChoose: (_) => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)), + ), + ), + )), + style: FilledButton.styleFrom( + backgroundColor: PangolinColors.white, + foregroundColor: PangolinColors.clay700, + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + child: Text( + zh ? '续费 / 升级' : 'Renew', + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13), + ), + ), + ]), + ), + const SizedBox(height: 18), + _accountRow(c, PangolinIcons.mail, zh ? '邮箱' : 'Email', 'me@pangolin.vpn'), + _accountRow(c, PangolinIcons.lock, zh ? '密码' : 'Password', '••••••••••'), + _accountRow( + c, + PangolinIcons.monitorSmartphone, + zh ? '我的设备' : 'My devices', + '', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => DevicesScreen(zh: zh)), + ), + ), + _accountRow( + c, + PangolinIcons.shoppingBag, + zh ? '兑换 & 购买' : 'Redeem & buy', + '', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => RedeemScreen(zh: zh)), + ), + ), + _accountRow( + c, + PangolinIcons.messageCircle, + zh ? '联系我们' : 'Contact us', + '', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ContactScreen(zh: zh)), + ), + ), + _accountRow( + c, + PangolinIcons.logOut, + zh ? '退出登录' : 'Sign out', + '', + danger: true, + ), + ]), + ), ]); } - Widget _accountRow(PangolinScheme c, IconData icon, String title, String value, {bool danger = false, VoidCallback? onTap}) { + Widget _accountRow( + PangolinScheme c, + IconData icon, + String title, + String value, { + bool danger = false, + VoidCallback? onTap, + }) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), child: Row(children: [ Icon(icon, size: 20, color: danger ? c.danger : c.accent), const SizedBox(width: 13), - Expanded(child: Text(title, style: PangolinText.body.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500))), - if (value.isNotEmpty) Text(value, style: PangolinText.sm.copyWith(color: c.fg3)) - else Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), + Expanded( + child: Text( + title, + style: PangolinText.body + .copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500), + ), + ), + if (value.isNotEmpty) + Text(value, style: PangolinText.sm.copyWith(color: c.fg3)) + else + Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), ]), ), ); @@ -312,7 +614,9 @@ class _AccountPage extends StatelessWidget { /// ── Shared top bar ── class _TopBar extends StatelessWidget { const _TopBar({required this.zh, this.trailing}); - final bool zh; final Widget? trailing; + final bool zh; + final Widget? trailing; + @override Widget build(BuildContext context) { return Padding( diff --git a/client/lib/widgets/server_tile.dart b/client/lib/widgets/server_tile.dart index b0fe53e..f8ae90e 100644 --- a/client/lib/widgets/server_tile.dart +++ b/client/lib/widgets/server_tile.dart @@ -5,9 +5,22 @@ import 'country_code.dart'; import 'pangolin_icons.dart'; class ServerInfo { - const ServerInfo({required this.code, required this.name, required this.sub, required this.ping}); + const ServerInfo({ + required this.code, + required this.name, + required this.sub, + required this.ping, + this.nodeId = '', + }); + final String code, name, sub; final int ping; + + /// 控制面节点 ID,对应 POST /v1/nodes/:nodeId/connect 路径参数。 + /// 留空时降级为使用 [code] 小写值(仅限 mock 联调)。 + final String nodeId; + + String get effectiveNodeId => nodeId.isEmpty ? code.toLowerCase() : nodeId; } class ServerTile extends StatelessWidget { diff --git a/client/pubspec.yaml b/client/pubspec.yaml index d56147c..c8604e0 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: flutter_svg: ^2.0.10 # 加载 assets/ 下的矢量 logo lucide_icons: ^0.257.0 # Lucide 细线条图标 google_fonts: ^6.2.1 # 运行时加载 Sora / Manrope / Noto Sans SC / JetBrains Mono + http: ^1.2.1 # 控制面 HTTP 客户端(connect API) dev_dependencies: flutter_test: diff --git a/client/test/connect_passthrough_test.dart b/client/test/connect_passthrough_test.dart new file mode 100644 index 0000000..5eedd18 --- /dev/null +++ b/client/test/connect_passthrough_test.dart @@ -0,0 +1,146 @@ +// connect_passthrough_test.dart — M6 透传逐字节断言单测 +// +// 验证约束(§3.1):ConnectApi.fetchConfig 返回的字符串, +// 必须与传入 VpnBridge.start 的字符串逐字节相等,Dart 层禁止拼装/修改。 +// +// tsk_nuoKSM4Vt-zK +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/services/connect_api.dart'; +import 'package:pangolin_vpn/services/vpn_bridge.dart'; + +// 与 mock server 的 connectConfig 结构一致(四块:inbounds/outbounds/route/dns) +const _kMockConfigJson = + '{"log":{"level":"warn","timestamp":true},' + '"inbounds":[{"type":"tun","tag":"tun-in","strict_route":true}],' + '"outbounds":[{"type":"urltest","tag":"auto","outbounds":["reality-out","hy2-out"]}],' + '"route":{"final":"auto"},' + '"dns":{"final":"remote"}}'; + +void main() { + group('M6 透传约束 — ConnectApi.fetchConfig → VpnBridge.start', () { + test('fetchConfig 结果与 VpnBridge.start 接收的字符串逐字节相等', () async { + // ── Arrange ── + final mockClient = MockClient((request) async { + // 验证请求格式符合 §3.1 + expect(request.method, equals('POST')); + expect(request.url.path, contains('/connect')); + expect(request.headers['Authorization'], startsWith('Bearer ')); + expect(request.headers['Content-Type'], contains('application/json')); + return http.Response( + _kMockConfigJson, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final api = ConnectApi( + baseUrl: 'http://mock-host', + authToken: 'test-token', + client: mockClient, + ); + final bridge = VpnBridge(); + + // ── Act ── + final fetchedJson = await api.fetchConfig( + nodeId: 'sg-1', + deviceId: 'dev-001', + ); + await bridge.start(fetchedJson); // 透传,不修改 + + // ── Assert:逐字节相等 ── + expect( + bridge.lastConfigJson, + equals(fetchedJson), + reason: 'VpnBridge.start 接收的字符串必须与 fetchConfig 返回的字符串逐字节一致(§3.1 透传约束)', + ); + // 双向验证:与原始 HTTP 响应体相等 + expect( + bridge.lastConfigJson, + equals(_kMockConfigJson), + reason: 'VpnBridge.start 接收的字符串必须等于 HTTP 响应体原文', + ); + }); + + test('HTTP 401 → ConnectApiException(statusCode=401)', () async { + final mockClient = MockClient((_) async => http.Response( + '{"code":"unauthorized","message_zh":"鉴权失败","message_en":"Unauthorized"}', + 401, + )); + final api = ConnectApi( + baseUrl: 'http://mock-host', + authToken: 'bad-token', + client: mockClient, + ); + + await expectLater( + api.fetchConfig(nodeId: 'sg-1', deviceId: 'dev-001'), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 401), + ), + ); + }); + + test('HTTP 500 → ConnectApiException(statusCode=500)', () async { + final mockClient = MockClient( + (_) async => http.Response('Internal Server Error', 500), + ); + final api = ConnectApi( + baseUrl: 'http://mock-host', + authToken: 'token', + client: mockClient, + ); + + await expectLater( + api.fetchConfig(nodeId: 'sg-1', deviceId: 'dev-001'), + throwsA(isA()), + ); + }); + + test('响应体非 JSON → ConnectApiException(statusCode=-2)', () async { + final mockClient = MockClient( + (_) async => http.Response('not-valid-json', 200), + ); + final api = ConnectApi( + baseUrl: 'http://mock-host', + authToken: 'token', + client: mockClient, + ); + + await expectLater( + api.fetchConfig(nodeId: 'sg-1', deviceId: 'dev-001'), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', -2), + ), + ); + }); + + test('fetchConfig 不修改响应体(额外字段、空格等)', () async { + // 包含额外空格/换行的 JSON,透传后不应被重新格式化 + const rawWithSpaces = + '{"log": {"level": "warn"}, "inbounds": [], "outbounds": [], "route": {}, "dns": {}}'; + final mockClient = MockClient( + (_) async => http.Response(rawWithSpaces, 200, + headers: {'content-type': 'application/json'}), + ); + final api = ConnectApi( + baseUrl: 'http://mock-host', + authToken: 'token', + client: mockClient, + ); + final bridge = VpnBridge(); + + final fetched = await api.fetchConfig(nodeId: 'x', deviceId: 'y'); + await bridge.start(fetched); + + expect( + bridge.lastConfigJson, + equals(rawWithSpaces), + reason: '原始响应体的所有空格/格式必须原样保留,不得重新序列化', + ); + }); + }); +} diff --git a/server/cmd/mockserver/main.go b/server/cmd/mockserver/main.go new file mode 100644 index 0000000..5b17611 --- /dev/null +++ b/server/cmd/mockserver/main.go @@ -0,0 +1,79 @@ +// Package main implements a minimal mock server for POST /v1/nodes/:id/connect. +// Returns a fixed sing-box config JSON per §3.1 contract (ARCHITECTURE.md). +// +// Usage: +// +// go run ./cmd/mockserver [-addr :8081] [-token ] +// +// 注:mock 联调;待 #5/#6 真实 connect 接口就绪后切换为真实后端。 +// tsk_nuoKSM4Vt-zK (M6) +package main + +import ( + "encoding/json" + "flag" + "log" + "net/http" + "regexp" + "strings" +) + +// connectConfig is the canonical sing-box config per §3.1. +// Fields match the nodes table columns: reality_public_key / reality_short_id / hy2_password. +// In production the API renders these per-user from the nodes table. +// Placeholders here are mock values for integration testing only. +const connectConfig = `{"log":{"level":"warn","timestamp":true},"inbounds":[{"type":"tun","tag":"tun-in","address":["172.19.0.1/30"],"mtu":9000,"auto_route":true,"strict_route":true,"stack":"system"}],"outbounds":[{"type":"vless","tag":"reality-out","server":"18.136.60.128","server_port":11443,"uuid":"ffffffff-ffff-ffff-ffff-ffffffffffff","flow":"xtls-rprx-vision","tls":{"enabled":true,"server_name":"www.apple.com","utls":{"enabled":true,"fingerprint":"chrome"},"reality":{"enabled":true,"public_key":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","short_id":"deadbeef"}}},{"type":"hysteria2","tag":"hy2-out","server":"18.136.60.128","server_port":443,"password":"mock-hy2-password","tls":{"enabled":true,"insecure":true,"alpn":["h3"]}},{"type":"urltest","tag":"auto","outbounds":["reality-out","hy2-out"],"url":"https://www.gstatic.com/generate_204","interval":"3m","tolerance":50},{"type":"block","tag":"block"},{"type":"direct","tag":"direct"}],"route":{"rules":[{"ip_cidr":["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8"],"outbound":"direct"}],"final":"auto","auto_detect_interface":true},"dns":{"servers":[{"tag":"remote","address":"tls://8.8.8.8","detour":"auto"},{"tag":"local","address":"223.5.5.5","detour":"direct"}],"final":"remote","strategy":"ipv4_only"}}` + +var reConnect = regexp.MustCompile(`^/v1/nodes/[^/]+/connect$`) + +func jsonErr(w http.ResponseWriter, status int, code, zh, en string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{ + "code": code, + "message_zh": zh, + "message_en": en, + }) +} + +// makeHandler returns the HTTP handler for the mock connect server. +// token may be empty to skip auth checking (useful in test environments). +func makeHandler(token string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !reConnect.MatchString(r.URL.Path) { + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if token != "" { + got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if got != token { + jsonErr(w, http.StatusUnauthorized, "unauthorized", "鉴权失败", "Unauthorized") + return + } + } + var body struct { + DeviceID string `json:"device_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.DeviceID) == "" { + jsonErr(w, http.StatusBadRequest, "bad_request", "缺少 device_id", "Missing device_id") + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(connectConfig)) + } +} + +func main() { + addr := flag.String("addr", ":8081", "listen address") + token := flag.String("token", "", "required Bearer token (empty = skip auth check)") + flag.Parse() + log.Printf("[mock] pangolin connect server on %s auth=%v", *addr, *token != "") + if err := http.ListenAndServe(*addr, makeHandler(*token)); err != nil { + log.Fatal(err) + } +} diff --git a/server/cmd/mockserver/main_test.go b/server/cmd/mockserver/main_test.go new file mode 100644 index 0000000..1ef477b --- /dev/null +++ b/server/cmd/mockserver/main_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func post(t *testing.T, handler http.Handler, path, body, auth string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + if auth != "" { + req.Header.Set("Authorization", "Bearer "+auth) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +// TestConnectReturnsValidConfig verifies that a well-formed request returns +// a 200 with all four §3.1 top-level blocks present. +func TestConnectReturnsValidConfig(t *testing.T) { + h := makeHandler("test-token") + w := post(t, h, "/v1/nodes/sg-1/connect", `{"device_id":"dev-001"}`, "test-token") + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("response is not valid JSON: %v\nbody: %s", err, w.Body.String()) + } + for _, key := range []string{"inbounds", "outbounds", "route", "dns"} { + if _, ok := out[key]; !ok { + t.Errorf("§3.1 block missing: %q", key) + } + } + // outbounds must include urltest group named "auto" + outs, _ := out["outbounds"].([]any) + found := false + for _, o := range outs { + m, _ := o.(map[string]any) + if m["type"] == "urltest" && m["tag"] == "auto" { + found = true + } + } + if !found { + t.Error("outbounds: missing urltest group 'auto'") + } + // inbound must have strict_route:true (Kill-switch) + ins, _ := out["inbounds"].([]any) + if len(ins) == 0 { + t.Fatal("inbounds is empty") + } + tun, _ := ins[0].(map[string]any) + if tun["strict_route"] != true { + t.Error("inbounds[0].strict_route must be true (Kill-switch)") + } +} + +// TestConnectRequiresAuth verifies that a missing / wrong token yields 401. +func TestConnectRequiresAuth(t *testing.T) { + h := makeHandler("secret") + cases := []struct{ name, token string }{ + {"no-header", ""}, + {"wrong-token", "wrong"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w := post(t, h, "/v1/nodes/sg-1/connect", `{"device_id":"x"}`, c.token) + if w.Code != http.StatusUnauthorized { + t.Errorf("want 401, got %d", w.Code) + } + }) + } +} + +// TestConnectNoAuthCheck verifies that an empty token skips auth entirely. +func TestConnectNoAuthCheck(t *testing.T) { + h := makeHandler("") + w := post(t, h, "/v1/nodes/sg-1/connect", `{"device_id":"x"}`, "") + if w.Code != http.StatusOK { + t.Errorf("want 200, got %d", w.Code) + } +} + +// TestConnectRequiresDeviceID verifies that missing device_id yields 400. +func TestConnectRequiresDeviceID(t *testing.T) { + h := makeHandler("") + cases := []struct{ name, body string }{ + {"empty-object", `{}`}, + {"blank-id", `{"device_id":""}`}, + {"not-json", `not-json`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/nodes/sg-1/connect", bytes.NewBufferString(c.body)) + rw := httptest.NewRecorder() + h.ServeHTTP(rw, req) + if rw.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d", rw.Code) + } + }) + } +} + +// TestConnectMethodNotAllowed verifies that GET returns 405. +func TestConnectMethodNotAllowed(t *testing.T) { + h := makeHandler("") + req := httptest.NewRequest(http.MethodGet, "/v1/nodes/sg-1/connect", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("want 405, got %d", w.Code) + } +} + +// TestConnectWrongPath verifies that unrelated paths return 404. +func TestConnectWrongPath(t *testing.T) { + h := makeHandler("") + req := httptest.NewRequest(http.MethodPost, "/v1/nodes/sg-1/disconnect", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("want 404, got %d", w.Code) + } +}