diff --git a/client/lib/services/token_store.dart b/client/lib/services/token_store.dart index bf2438d..d3721f3 100644 --- a/client/lib/services/token_store.dart +++ b/client/lib/services/token_store.dart @@ -3,7 +3,11 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class TokenStore { 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; diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index 357dad3..cd21e21 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -1,5 +1,4 @@ // auth_screen.dart — 登录 / 注册页(邮箱 + 验证码流程) -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -41,22 +40,6 @@ class _AuthScreenState extends ConsumerState { 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); @override @@ -97,13 +80,6 @@ class _AuthScreenState extends ConsumerState { Future _doLogin() async { 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 { final tokens = await _api.login( email: _email.text.trim(), diff --git a/dev/.gitignore b/dev/.gitignore new file mode 100644 index 0000000..4742a54 --- /dev/null +++ b/dev/.gitignore @@ -0,0 +1,2 @@ +# 本地 dev 密钥(JWT 私钥/公钥、webhook secret、derive key),绝不进 git。 +.local/ diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml new file mode 100644 index 0000000..4fd3ca6 --- /dev/null +++ b/dev/docker-compose.yml @@ -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: diff --git a/dev/run-local.md b/dev/run-local.md new file mode 100644 index 0000000..acbb90d --- /dev/null +++ b/dev/run-local.md @@ -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 ' +# → 含 HK 测试节点 + +# 4) 当前用户 +curl localhost:18080/v1/me -H 'Authorization: Bearer ' +``` + +## 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(本地隔离,避免污染线上)。 diff --git a/dev/run-local.sh b/dev/run-local.sh new file mode 100755 index 0000000..909461e --- /dev/null +++ b/dev/run-local.sh @@ -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 ) diff --git a/dev/seed.sql b/dev/seed.sql new file mode 100644 index 0000000..5cba796 --- /dev/null +++ b/dev/seed.sql @@ -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 +); diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index bfbb3e2..224881d 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -102,11 +102,12 @@ func main() { "GRPC_CERT_PATH_set", grpcCertPath != "", "GRPC_KEY_PATH_set", grpcKeyPath != "") } - // Even without gRPC, build a Hub-only nodeSvc for local/test use. - // nil CA means Enroll will panic if called — acceptable when gRPC is off. - hub := nodes.NewHub(rdb) - hub.Start(context.Background()) - log.Printf("nodes.Service: gRPC not configured; Hub active for command queueing") + // Even without gRPC, build a Hub-only nodeSvc so the HTTP /v1/nodes + // routes (ListNodes/Connect) work locally. nil CA/tokens means + // agent Enroll would panic if called — acceptable when gRPC is off. + nodeSvc = nodes.NewService(nil, nil, rdb, nodeStore) + 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) { protected.Use(auth.RequireAuth(tm)) - protected.Get("/me", accountAPI.GetMe) protected.Route("/me", func(me chi.Router) { + me.Get("/", accountAPI.GetMe) // 子路由根,避免与 Route("/me") 冲突致 404 devicesHandler.RegisterRoutes(me) }) protected.Post("/redeem", redeemHandler.ServeHTTP) diff --git a/server/server b/server/server new file mode 100755 index 0000000..0cd6d0f Binary files /dev/null and b/server/server differ