feat(M6): 控制面联调 — mock connect server + 客户端 ConnectApi 透传

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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 14:16:29 +08:00
parent 787151245e
commit bd8f974a25
9 changed files with 954 additions and 142 deletions
+4
View File
@@ -21,6 +21,10 @@ deploy/backup/backup.env
*.age-private*
age-keypair.txt
# Go 编译产物(mock server 等)
server/mockserver
server/pangolin-server
# 杂项
.DS_Store
*.log
+97
View File
@@ -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<String> 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();
}
+38
View File
@@ -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 透传约束:客户端零组装)。
/// 当前为 stub11C 完成后替换为 `libbox.start(configJson)` 原生调用。
Future<void> start(String configJson) async {
_lastConfigJson = configJson;
// TODO(11C): await _libbox.start(configJson);
print('[VpnBridge] start — length=${configJson.length} (stub, libbox pending 11C)');
}
/// 停止隧道。当前为 stub。
Future<void> stop() async {
_lastConfigJson = null;
// TODO(11C): await _libbox.stop();
print('[VpnBridge] stop (stub)');
}
/// 最近一次传入 [start] 的原始 JSON 字符串。
/// 仅供测试验证透传约束,生产代码不应依赖此字段。
// @visibleForTesting
String? get lastConfigJson => _lastConfigJson;
}
+445 -141
View File
@@ -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<HomeShell> {
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<void> _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<void> _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<HomeShell> {
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<HomeShell> {
/// ── 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<int> onTap;
final bool zh;
final int index;
final ValueChanged<int> 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<ServerInfo> onPick;
final bool zh;
final String current;
final ValueChanged<ServerInfo> 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(
+14 -1
View File
@@ -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 {
+1
View File
@@ -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:
+146
View File
@@ -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<ConnectApiException>()
.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<ConnectApiException>()),
);
});
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<ConnectApiException>()
.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: '原始响应体的所有空格/格式必须原样保留,不得重新序列化',
);
});
});
}
+79
View File
@@ -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 <bearer-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)
}
}
+130
View File
@@ -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)
}
}