Files
pangolin/server/cmd/server/main.go
T
wangjia 6979411c1d 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>
2026-06-16 13:27:58 +08:00

365 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"crypto/tls"
"database/sql"
"encoding/json"
"flag"
"log"
"log/slog"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/redis/go-redis/v9"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"github.com/wangjia/pangolin/server/internal/admin"
"github.com/wangjia/pangolin/server/internal/apierr"
"github.com/wangjia/pangolin/server/internal/auth"
"github.com/wangjia/pangolin/server/internal/codes"
"github.com/wangjia/pangolin/server/internal/db"
"github.com/wangjia/pangolin/server/internal/devices"
"github.com/wangjia/pangolin/server/internal/httpapi"
"github.com/wangjia/pangolin/server/internal/mtls"
"github.com/wangjia/pangolin/server/internal/nodes"
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
"github.com/wangjia/pangolin/server/internal/redisutil"
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
"github.com/wangjia/pangolin/server/internal/usage"
)
func main() {
listenAddr := flag.String("addr", "", "HTTP listen address (default :8080, overridden by ADDR env)")
flag.Parse()
if *listenAddr == "" {
if v := os.Getenv("ADDR"); v != "" {
*listenAddr = v
} else {
*listenAddr = ":8080"
}
}
// Admin backend (separate internal listener, optional).
startAdminIfConfigured()
// ─── Shared: Redis ────────────────────────────────────────────────────────
redisAddr := getenvDefault("REDIS_ADDR", "127.0.0.1:6379")
rdb, err := redisutil.New(redisAddr, os.Getenv("REDIS_PASSWORD"), 0)
if err != nil {
log.Fatalf("redis connect: %v", err)
}
// ─── Shared: DB ───────────────────────────────────────────────────────────
dbDSN := os.Getenv("DB_DSN")
var sqlDB *sql.DB
if dbDSN != "" {
sqlDB, err = db.Open(dbDSN)
if err != nil {
log.Fatalf("db open: %v", err)
}
}
// ─── Shared: nodes.Service (Hub shared by HTTP connect + gRPC agent) ──────
// Constructed here so both sides use the same Hub instance.
var nodeSvc *nodes.Service
if sqlDB != nil {
nodeStore := nodes.NewSQLNodeStore(sqlDB)
grpcAddr := os.Getenv("GRPC_ADDR")
caKeyPath := os.Getenv("CA_KEY_PATH")
caCertPath := os.Getenv("CA_CERT_PATH")
grpcCertPath := os.Getenv("GRPC_CERT_PATH")
grpcKeyPath := os.Getenv("GRPC_KEY_PATH")
if grpcAddr != "" && caKeyPath != "" && caCertPath != "" &&
grpcCertPath != "" && grpcKeyPath != "" {
ca, err := mtls.NewCA(mtls.CAConfig{KeyPath: caKeyPath, CertPath: caCertPath})
if err != nil {
log.Fatalf("load node CA: %v", err)
}
tokens := mtls.NewBootstrapTokenManager(rdb)
crl := mtls.NewCRL(rdb, nil)
nodeSvc = nodes.NewService(ca, tokens, rdb, nodeStore)
nodeSvc.Hub().Start(context.Background())
go startGRPCWithService(nodeSvc, grpcAddr, grpcCertPath, grpcKeyPath, ca, crl)
} else {
if grpcAddr != "" {
slog.Warn("grpc agent server disabled: one or more required env vars missing",
"CA_KEY_PATH_set", caKeyPath != "",
"CA_CERT_PATH_set", caCertPath != "",
"GRPC_CERT_PATH_set", grpcCertPath != "",
"GRPC_KEY_PATH_set", grpcKeyPath != "")
}
// 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)")
}
}
// ─── HTTP router ──────────────────────────────────────────────────────────
r := chi.NewRouter()
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(apierr.Middleware)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// Optional probe ingest route.
if probeJSON := os.Getenv("PROBE_SECRETS"); probeJSON != "" {
pRDB, err := redisutil.New(redisAddr, os.Getenv("REDIS_PASSWORD"), 0)
if err != nil {
log.Printf("probe: redis connect failed (%v) probe route disabled", err)
} else {
var secretMap map[string]string
if err := json.Unmarshal([]byte(probeJSON), &secretMap); err != nil {
log.Fatalf("probe: invalid PROBE_SECRETS JSON: %v", err)
}
reg := probe.NewMapRegistry(secretMap)
st := probe.NewStore(pRDB)
r.Post("/probe/report", probe.NewIngestHandler(reg, st).ServeHTTP)
log.Printf("probe ingest route registered (%d probe(s))", len(secretMap))
}
}
// ─── /v1 routes (requires DB) ────────────────────────────────────────────
if sqlDB != nil {
mountV1(r, sqlDB, rdb, nodeSvc)
} else {
log.Printf("DB_DSN not set; /v1 routes disabled")
}
log.Printf("pangolin server listening on %s", *listenAddr)
if err := http.ListenAndServe(*listenAddr, r); err != nil {
log.Fatalf("server error: %v", err)
}
}
// mountV1 wires all /v1 routes onto r.
func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Service) {
// ── JWT TokenManager ──────────────────────────────────────────────────────
jwtPrivPath := os.Getenv("JWT_PRIVATE_KEY_PATH")
jwtKID := os.Getenv("JWT_KEY_ID")
var tm *auth.TokenManager
if jwtPrivPath != "" && jwtKID != "" {
tc, err := auth.LoadTokenConfig(jwtPrivPath, jwtKID, parseKeyMap(os.Getenv("JWT_PUBLIC_KEYS")))
if err != nil {
log.Fatalf("JWT keys: %v", err)
}
tm, err = auth.NewTokenManager(rdb, tc)
if err != nil {
log.Fatalf("JWT token manager: %v", err)
}
} else {
log.Printf("JWT not configured — /v1 protected routes will be unavailable")
}
// ── Auth ──────────────────────────────────────────────────────────────────
var authHandler *auth.Handler
if tm != nil {
var mailer auth.Mailer
if smtpHost := os.Getenv("SMTP_HOST"); smtpHost != "" {
mailer = auth.NewSMTPMailer(auth.SMTPConfig{
Host: smtpHost,
Port: intEnvDefault("SMTP_PORT", 587),
Username: os.Getenv("SMTP_USERNAME"),
Password: os.Getenv("SMTP_PASSWORD"),
From: getenvDefault("SMTP_FROM", "no-reply@pangolin.app"),
})
} else {
mailer = auth.NewLogMailer(nil) // dev: code printed to log
}
rl := auth.NewRateLimiter(rdb, nil)
authStore := auth.NewSQLStore(sqlDB)
authSvc := auth.NewService(authStore, rdb, rl, tm, mailer, auth.ServiceConfig{}, nil)
authHandler = auth.NewHandler(authSvc)
}
// ── Codes ────────────────────────────────────────────────────────────────
codesStore := codes.NewStore(sqlDB)
codesSvc := codes.NewService(codesStore, rdb, 5, time.Hour)
redeemHandler := codes.NewRedeemHandler(codesSvc)
webhookHandler := codes.NewWebhookHandler(codesStore, rdb,
os.Getenv("WEBHOOK_SECRET"), 5*time.Minute, 15*time.Minute)
// ── Devices ───────────────────────────────────────────────────────────────
devicesStore := devices.NewStore(sqlDB)
devicesSvc := devices.NewService(devicesStore, nil) // NoopRevoker for MVP
devicesHandler := devices.NewHandler(devicesSvc)
// ── Usage ─────────────────────────────────────────────────────────────────
usageStore := usage.NewStore(sqlDB)
usageSvc := usage.NewService(usageStore, rdb, nil, time.Hour)
usageHandler := usage.NewUsageHandler(usageSvc)
adsHandler := usage.NewAdsUnlockHandler(usageSvc)
// ── Account / Plans / Notices ─────────────────────────────────────────────
accountAPI := httpapi.NewAccountAPI(sqlDB)
// ── Nodes + Connect ───────────────────────────────────────────────────────
var nodeAPI *httpapi.NodeAPI
if nodeSvc != nil {
nodeAPI = httpapi.NewNodeAPI(nodeSvc.Store(), nodeSvc.Hub(), os.Getenv("NODE_DERIVE_KEY"))
}
// ─── Mount under /v1 ─────────────────────────────────────────────────────
r.Route("/v1", func(v1 chi.Router) {
// Public (no auth): auth endpoints.
if authHandler != nil {
v1.Post("/auth/code", authHandler.SendCode)
v1.Post("/auth/register", authHandler.Register)
v1.Post("/auth/login", authHandler.Login)
v1.Post("/auth/refresh", authHandler.Refresh)
}
// Webhook: HMAC-authenticated, no JWT.
v1.Post("/webhook/store/codes", webhookHandler.ServeHTTP)
// Protected: all routes that require a valid Bearer JWT.
if tm != nil {
v1.Group(func(protected chi.Router) {
protected.Use(auth.RequireAuth(tm))
protected.Route("/me", func(me chi.Router) {
me.Get("/", accountAPI.GetMe) // 子路由根,避免与 Route("/me") 冲突致 404
devicesHandler.RegisterRoutes(me)
})
protected.Post("/redeem", redeemHandler.ServeHTTP)
protected.Get("/usage", usageHandler.ServeHTTP)
protected.Post("/ads/unlock", adsHandler.ServeHTTP)
protected.Get("/plans", accountAPI.ListPlans)
protected.Get("/notices", accountAPI.ListNotices)
if nodeAPI != nil {
protected.Get("/nodes", nodeAPI.ListNodes)
protected.Post("/nodes/{id}/connect", nodeAPI.ConnectNode)
protected.Post("/nodes/{id}/disconnect", nodeAPI.DisconnectNode)
}
})
}
})
}
// startAdminIfConfigured launches the admin listener when ADMIN_SECRET_KEY and
// DB_DSN are both present.
func startAdminIfConfigured() {
if os.Getenv("ADMIN_SECRET_KEY") == "" || os.Getenv("DB_DSN") == "" {
log.Printf("admin backend disabled (set DB_DSN and ADMIN_SECRET_KEY to enable)")
return
}
cfg, err := admin.FromEnv()
if err != nil {
log.Fatalf("admin config: %v", err)
}
database, err := db.Open(os.Getenv("DB_DSN"))
if err != nil {
log.Fatalf("admin db: %v", err)
}
rdb, err := redisutil.New(
getenvDefault("REDIS_ADDR", "127.0.0.1:6379"),
os.Getenv("REDIS_PASSWORD"), 0)
if err != nil {
log.Fatalf("admin redis: %v", err)
}
svc := admin.BuildServices(database, rdb, 5, cfg.LoginLockDuration)
handler, err := admin.NewHandler(cfg, database, rdb, svc, log.Default())
if err != nil {
log.Fatalf("admin handler: %v", err)
}
go func() {
log.Printf("admin backend listening on %s (internal only)", cfg.Listen)
if err := http.ListenAndServe(cfg.Listen, handler); err != nil {
log.Fatalf("admin server error: %v", err)
}
}()
}
// startGRPCWithService starts the mTLS gRPC agent server using the shared
// nodes.Service so the HTTP connect handler and the gRPC server share one Hub.
func startGRPCWithService(
svc *nodes.Service,
addr, grpcCertPath, grpcKeyPath string,
ca *mtls.CA,
crl *mtls.CRL,
) {
serverCert, err := tls.LoadX509KeyPair(grpcCertPath, grpcKeyPath)
if err != nil {
log.Fatalf("grpc: load transport TLS cert: %v", err)
}
tlsCfg := mtls.NewServerTLSConfig(ca, crl)
tlsCfg.Certificates = []tls.Certificate{serverCert}
srv := grpc.NewServer(
grpc.Creds(credentials.NewTLS(tlsCfg)),
grpc.ChainUnaryInterceptor(mtls.UnaryServerInterceptor()),
grpc.ChainStreamInterceptor(mtls.StreamServerInterceptor()),
)
agentv1.RegisterAgentServiceServer(srv, svc.Handler())
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("grpc: listen %s: %v", addr, err)
}
slog.Info("grpc agent server listening", "addr", addr)
if err := srv.Serve(lis); err != nil {
log.Fatalf("grpc: serve: %v", err)
}
}
// parseKeyMap parses "kid1:/path1,kid2:/path2" into a map. Used for JWT_PUBLIC_KEYS.
func parseKeyMap(raw string) map[string]string {
if raw == "" {
return nil
}
m := map[string]string{}
for _, pair := range strings.Split(raw, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
i := strings.IndexByte(pair, ':')
if i <= 0 || i == len(pair)-1 {
continue
}
m[strings.TrimSpace(pair[:i])] = strings.TrimSpace(pair[i+1:])
}
return m
}
func getenvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func intEnvDefault(key string, def int) int {
v := os.Getenv(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n == 0 {
return def
}
return n
}