feat: 档2 接真后端 — mac app 真注册/登录/拉节点
dev 本机置备(新增 dev/):
- docker-compose.yml: MySQL :13306 + Redis :16379(避让本机已占的 3306/6379)
- run-local.sh 一键: 起容器→openssl 生成密钥(dev/.local gitignore)→migrate→
seed→启动 server :18080(避让 8080);LogMailer 把验证码打日志
- seed.sql(1 provider + 1 HK 节点)、run-local.md 手册、.gitignore
后端两处修复(本地无 gRPC 场景,注释意图与代码不符的 bug):
- main.go: 无 gRPC 时真正构造 Hub-only nodeSvc(原只建 hub 没赋值),/v1/nodes 才能挂
- main.go: /me 改 Route 子路由根 Get,修 Get("/me")+Route("/me") 冲突致 404
客户端接真后端:
- token_store: MacOsOptions(useDataProtectionKeyChain:false) 修 keychain -34018
- auth_screen: 删 dev 旁路(test 账户内存登录),所有登录走真 API
验收: 后端 curl 端到端全通(验证码→注册→登录→/nodes→/me);
server go build/vet/test 通过, client analyze 0 + test 84
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,11 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||||||
|
|
||||||
class TokenStore {
|
class TokenStore {
|
||||||
const TokenStore({FlutterSecureStorage? storage})
|
const TokenStore({FlutterSecureStorage? storage})
|
||||||
: _storage = storage ?? const FlutterSecureStorage();
|
: _storage = storage ?? const FlutterSecureStorage(
|
||||||
|
// macOS 用文件式 keychain,避免未签名 app 缺 keychain-access-groups
|
||||||
|
// entitlement 时 write 报 -34018。
|
||||||
|
mOptions: MacOsOptions(useDataProtectionKeyChain: false),
|
||||||
|
);
|
||||||
|
|
||||||
final FlutterSecureStorage _storage;
|
final FlutterSecureStorage _storage;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// auth_screen.dart — 登录 / 注册页(邮箱 + 验证码流程)
|
// auth_screen.dart — 登录 / 注册页(邮箱 + 验证码流程)
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
@@ -41,22 +40,6 @@ class _AuthScreenState extends ConsumerState<AuthScreen> {
|
|||||||
|
|
||||||
late final AuthApi _api = AuthApi(baseUrl: _kApiUrl);
|
late final AuthApi _api = AuthApi(baseUrl: _kApiUrl);
|
||||||
|
|
||||||
// ── Dev-only 测试账户旁路(仅 debug build)─────────────────────
|
|
||||||
// 后端 /v1/auth 未就绪时,用此账户直接进入主界面浏览 UI。
|
|
||||||
// 登录后节点列表回退 kDemoNodes,连接键走 mock 动画。
|
|
||||||
static const _devEmail = 'test@pangolin.dev';
|
|
||||||
static const _devPassword = 'test1234';
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
// debug 下预填测试账户,方便直接点登录。
|
|
||||||
if (kDebugMode) {
|
|
||||||
_email.text = _devEmail;
|
|
||||||
_pw.text = _devPassword;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
|
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -97,13 +80,6 @@ class _AuthScreenState extends ConsumerState<AuthScreen> {
|
|||||||
|
|
||||||
Future<void> _doLogin() async {
|
Future<void> _doLogin() async {
|
||||||
setState(() { _loading = true; _errorZh = null; });
|
setState(() { _loading = true; _errorZh = null; });
|
||||||
// Dev 旁路:debug build 下用测试账户跳过后端直接登录。
|
|
||||||
// 用 devLogin 只设内存态,不写 keychain(规避 -34018 entitlement 问题)。
|
|
||||||
if (kDebugMode && _email.text.trim() == _devEmail && _pw.text == _devPassword) {
|
|
||||||
ref.read(authProvider.notifier).devLogin('dev-access-token');
|
|
||||||
if (mounted) widget.onDone();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
final tokens = await _api.login(
|
final tokens = await _api.login(
|
||||||
email: _email.text.trim(),
|
email: _email.text.trim(),
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 本地 dev 密钥(JWT 私钥/公钥、webhook secret、derive key),绝不进 git。
|
||||||
|
.local/
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 本地开发用 MySQL + Redis(与生产隔离)。
|
||||||
|
# 用法:docker compose -f dev/docker-compose.yml up -d
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: pangolin-dev-mysql
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: pangolin
|
||||||
|
ports:
|
||||||
|
- "13306:3306" # 避让本机已占用的 3306
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-proot"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
volumes:
|
||||||
|
- pangolin-dev-mysql:/var/lib/mysql
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: pangolin-dev-redis
|
||||||
|
ports:
|
||||||
|
- "16379:6379" # 避让本机已占用的 6379
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pangolin-dev-mysql:
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 本地开发:一键起后端
|
||||||
|
|
||||||
|
让后端在本机真跑起来(auth 注册/登录 + 节点列表),供 mac app 接真后端调试。
|
||||||
|
|
||||||
|
## 前置
|
||||||
|
|
||||||
|
- Docker(起 MySQL + Redis)
|
||||||
|
- Go(`/opt/homebrew/bin` 在 PATH,脚本已内置)
|
||||||
|
- openssl(生成 JWT 密钥)
|
||||||
|
|
||||||
|
## 一键启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <项目根> # pangolin/
|
||||||
|
bash dev/run-local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本做的事:
|
||||||
|
1. `docker compose` 起 `pangolin-dev-mysql`(:13306)+ `pangolin-dev-redis`(:16379,避让本机已占用的 3306/6379),等 MySQL 就绪
|
||||||
|
2. 首次生成密钥到 `dev/.local/`(gitignore):RS256 JWT 私钥/公钥、`webhook_secret`、`derive_key`
|
||||||
|
3. 组装 env(`DB_DSN`/`WEBHOOK_SECRET`/`NODE_DERIVE_KEY`/`JWT_*`),不配 SMTP → 验证码打日志
|
||||||
|
4. `go run ./cmd/migrate up`(建表 + plans seed),`dev/seed.sql` 插一个 HK 测试节点
|
||||||
|
5. 前台启动 `go run ./cmd/server`(:18080)—— **注册验证码会打印在终端日志**
|
||||||
|
|
||||||
|
停止:`Ctrl+C`(停 server);`docker compose -f dev/docker-compose.yml down` 停容器(加 `-v` 清库)。
|
||||||
|
|
||||||
|
## 端到端验证(curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 发验证码(看 server 终端日志读 6 位码)
|
||||||
|
curl -XPOST localhost:18080/v1/auth/code -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"me@x.com"}'
|
||||||
|
|
||||||
|
# 2) 注册(填上一步日志里的码)
|
||||||
|
curl -XPOST localhost:18080/v1/auth/register -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"me@x.com","code":"<码>","password":"pass1234"}'
|
||||||
|
# → {"access_token":"...","refresh_token":"..."}
|
||||||
|
|
||||||
|
# 3) 拉节点列表(带 access token)
|
||||||
|
curl localhost:18080/v1/nodes -H 'Authorization: Bearer <access>'
|
||||||
|
# → 含 HK 测试节点
|
||||||
|
|
||||||
|
# 4) 当前用户
|
||||||
|
curl localhost:18080/v1/me -H 'Authorization: Bearer <access>'
|
||||||
|
```
|
||||||
|
|
||||||
|
## mac app 接它
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
# 客户端默认连 :8080;本机 8080 被占用,dev 后端在 18080,故显式指定:
|
||||||
|
flutter run -d macos --dart-define=PANGOLIN_API_URL=http://localhost:18080
|
||||||
|
```
|
||||||
|
|
||||||
|
注册页输真邮箱 → 发验证码(后端日志读码)→ 填码+密码 → 进主界面 → 节点页显示真节点(HK·测试)。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- **密钥纯本地玩具**:`dev/.local/` 不进 git;换机重跑脚本会重新生成。
|
||||||
|
- **节点 endpoint 占位**(`127.0.0.1:11443`):档 2 只验证列表 + connect 配置渲染,**不真连**。真连接出网见档 3。
|
||||||
|
- 不连 EC2 生产 MySQL(本地隔离,避免污染线上)。
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-local.sh — 一键起本地后端(DB/Redis + 密钥 + 迁移 + seed + server)。
|
||||||
|
# 从项目根运行: bash dev/run-local.sh
|
||||||
|
# 验证码:未配 SMTP,注册验证码会打到本终端的 server 日志里。
|
||||||
|
# 密钥:首次生成到 dev/.local/(gitignore);标量密钥用固定/随机 dev 值,纯本地玩具。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export PATH="/opt/homebrew/bin:$PATH"
|
||||||
|
ROOT="$PWD"
|
||||||
|
LOCAL="$ROOT/dev/.local"
|
||||||
|
mkdir -p "$LOCAL"
|
||||||
|
|
||||||
|
# ── 1. 起 MySQL + Redis ──────────────────────────────────────────────
|
||||||
|
echo "[run-local] 起 MySQL + Redis ..."
|
||||||
|
docker compose -f dev/docker-compose.yml up -d
|
||||||
|
echo "[run-local] 等待 MySQL 就绪 ..."
|
||||||
|
until docker exec pangolin-dev-mysql mysqladmin ping -proot --silent >/dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "[run-local] MySQL 就绪。"
|
||||||
|
|
||||||
|
# ── 2. 生成密钥(仅当缺失)────────────────────────────────────────────
|
||||||
|
if [ ! -f "$LOCAL/jwt_private.pem" ]; then
|
||||||
|
echo "[run-local] 生成 RS256 JWT 密钥对 ..."
|
||||||
|
openssl genrsa -out "$LOCAL/jwt_private.pem" 2048
|
||||||
|
openssl rsa -in "$LOCAL/jwt_private.pem" -pubout -out "$LOCAL/jwt_public.pem"
|
||||||
|
fi
|
||||||
|
[ -f "$LOCAL/webhook_secret" ] || openssl rand -hex 32 > "$LOCAL/webhook_secret"
|
||||||
|
[ -f "$LOCAL/derive_key" ] || openssl rand -hex 32 > "$LOCAL/derive_key"
|
||||||
|
|
||||||
|
# ── 3. 组装环境变量 ──────────────────────────────────────────────────
|
||||||
|
read -r WEBHOOK_SECRET < "$LOCAL/webhook_secret"
|
||||||
|
read -r NODE_DERIVE_KEY < "$LOCAL/derive_key"
|
||||||
|
export WEBHOOK_SECRET NODE_DERIVE_KEY
|
||||||
|
export DB_DSN="root:root@tcp(127.0.0.1:13306)/pangolin?parseTime=true&loc=UTC&multiStatements=true"
|
||||||
|
export REDIS_ADDR="127.0.0.1:16379"
|
||||||
|
export JWT_PRIVATE_KEY_PATH="$LOCAL/jwt_private.pem"
|
||||||
|
export JWT_KEY_ID="dev-key-1"
|
||||||
|
export JWT_PUBLIC_KEYS="dev-key-1:$LOCAL/jwt_public.pem"
|
||||||
|
export ADDR=":18080" # 避让本机已占用的 8080
|
||||||
|
# 不设 SMTP_* → LogMailer(验证码打日志);不设 GRPC_* → 不启 agent server。
|
||||||
|
|
||||||
|
# ── 4. 迁移 + seed ───────────────────────────────────────────────────
|
||||||
|
echo "[run-local] 数据库迁移 ..."
|
||||||
|
( cd server && go run ./cmd/migrate up )
|
||||||
|
echo "[run-local] seed 节点 ..."
|
||||||
|
docker exec -i pangolin-dev-mysql mysql -uroot -proot pangolin < dev/seed.sql
|
||||||
|
|
||||||
|
# ── 5. 启动后端(前台,验证码看本终端日志)──────────────────────────
|
||||||
|
echo "[run-local] 启动后端 :18080 —— 注册验证码会打印在下方日志。Ctrl+C 停止。"
|
||||||
|
( cd server && go run ./cmd/server )
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- 本地开发 seed:1 provider + 1 个 status='up' 节点,供 GET /v1/nodes 返回。
|
||||||
|
-- 幂等:固定 uuid,重跑先删后插。endpoint/reality_* 为占位(档 2 不真连,档 3 再指真实数据面)。
|
||||||
|
|
||||||
|
DELETE FROM nodes WHERE uuid = '11111111-1111-1111-1111-111111111111';
|
||||||
|
DELETE FROM providers WHERE name = 'dev-local';
|
||||||
|
|
||||||
|
INSERT INTO providers (name, api_kind, regions, pool, enabled)
|
||||||
|
VALUES ('dev-local', 'manual', '["HK"]', 'consumable', TRUE);
|
||||||
|
|
||||||
|
INSERT INTO nodes
|
||||||
|
(uuid, region, name_zh, name_en, role, tier, endpoint, hy2_port,
|
||||||
|
reality_pbk, reality_prk, reality_short_id, reality_sni, provider_id, status, weight)
|
||||||
|
VALUES (
|
||||||
|
'11111111-1111-1111-1111-111111111111',
|
||||||
|
'HK', '香港 · 测试', 'Hong Kong (dev)',
|
||||||
|
'exit', 'free',
|
||||||
|
'127.0.0.1:11443', 443,
|
||||||
|
'devPlaceholderRealityPublicKey00000000000000', '', '6eb28f1a',
|
||||||
|
'www.apple.com',
|
||||||
|
(SELECT id FROM providers WHERE name = 'dev-local' LIMIT 1),
|
||||||
|
'up', 100
|
||||||
|
);
|
||||||
@@ -102,11 +102,12 @@ func main() {
|
|||||||
"GRPC_CERT_PATH_set", grpcCertPath != "",
|
"GRPC_CERT_PATH_set", grpcCertPath != "",
|
||||||
"GRPC_KEY_PATH_set", grpcKeyPath != "")
|
"GRPC_KEY_PATH_set", grpcKeyPath != "")
|
||||||
}
|
}
|
||||||
// Even without gRPC, build a Hub-only nodeSvc for local/test use.
|
// Even without gRPC, build a Hub-only nodeSvc so the HTTP /v1/nodes
|
||||||
// nil CA means Enroll will panic if called — acceptable when gRPC is off.
|
// routes (ListNodes/Connect) work locally. nil CA/tokens means
|
||||||
hub := nodes.NewHub(rdb)
|
// agent Enroll would panic if called — acceptable when gRPC is off.
|
||||||
hub.Start(context.Background())
|
nodeSvc = nodes.NewService(nil, nil, rdb, nodeStore)
|
||||||
log.Printf("nodes.Service: gRPC not configured; Hub active for command queueing")
|
nodeSvc.Hub().Start(context.Background())
|
||||||
|
log.Printf("nodes.Service: gRPC not configured; Hub active for command queueing (HTTP /v1/nodes enabled)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +238,8 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
|||||||
v1.Group(func(protected chi.Router) {
|
v1.Group(func(protected chi.Router) {
|
||||||
protected.Use(auth.RequireAuth(tm))
|
protected.Use(auth.RequireAuth(tm))
|
||||||
|
|
||||||
protected.Get("/me", accountAPI.GetMe)
|
|
||||||
protected.Route("/me", func(me chi.Router) {
|
protected.Route("/me", func(me chi.Router) {
|
||||||
|
me.Get("/", accountAPI.GetMe) // 子路由根,避免与 Route("/me") 冲突致 404
|
||||||
devicesHandler.RegisterRoutes(me)
|
devicesHandler.RegisterRoutes(me)
|
||||||
})
|
})
|
||||||
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user