feat(backend): 挂载 /v1 API + 实现 nodes/connect 端到端

- 新增 internal/dpcred 包,统一 DeriveHy2Password + DefaultFlow
  agentd 与 HTTP connect handler 共享同一实现
- 新增迁移 000011:nodes 表拆分 reality_prk 私钥 / reality_pbk 公钥
  reality_short_id;修正 handler_grpc.go 使用私钥字段
- 新增迁移 000012:connect_credentials 持久化凭证
  实现 CredentialsForNode 修复 agent 重连 resync 原先返回空的桩
- 扩展 NodeStore 接口:ListUp / EntitlementForUser /
  PersistCredential / DeleteCredential;同步 grpc_test.go mock
- 新增 httpapi/nodes.go:GET /nodes、POST /nodes/id/connect
  Hub.Push + PersistCredential + 渲染完整 sing-box client 配置 JSON
  POST /nodes/id/disconnect
- 新增 httpapi/account.go:GET /me、GET /plans、GET /notices
- 新增 httpapi/clientconfig.go:BuildClientConfig 服务端渲染
- 重写 cmd/server/main.go:手写 chi public/protected 分组
  nodes.Service/Hub 在 main 构造并共享;SMTPMailer/LogMailer

go build ./... && go vet ./... && go test ./... 全部通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-15 23:09:56 +08:00
parent b35bfe10dc
commit cadd527680
14 changed files with 1017 additions and 149 deletions
+237 -104
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"crypto/tls"
"database/sql"
"encoding/json"
"flag"
"log"
@@ -10,122 +11,254 @@ import (
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
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/api/gen"
"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() {
addr := flag.String("addr", "", "HTTP listen address (default :8080, overridden by ADDR env)")
listenAddr := flag.String("addr", "", "HTTP listen address (default :8080, overridden by ADDR env)")
flag.Parse()
if *addr == "" {
if *listenAddr == "" {
if v := os.Getenv("ADDR"); v != "" {
*addr = v
*listenAddr = v
} else {
*addr = ":8080"
*listenAddr = ":8080"
}
}
// Admin backend (separate internal listener). Started only when explicitly
// configured so the public API can run on its own. See internal/admin.
// Admin backend (separate internal listener, optional).
startAdminIfConfigured()
// ─── HTTP server ──────────────────────────────────────────────────────────
// ─── 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 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")
}
}
// ─── HTTP router ──────────────────────────────────────────────────────────
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(apierr.Middleware)
// Health check — outside the versioned API.
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"})
})
// --- Probe ingest route (optional) ---
// Requires:
// PROBE_SECRETS JSON object mapping probeId → HMAC secret, e.g.
// '{"probe-sg-01":"s3cr3t1","probe-jp-01":"s3cr3t2"}'
// REDIS_ADDR Redis address, default 127.0.0.1:6379
// REDIS_PASSWORD Redis password (optional)
//
// If PROBE_SECRETS is empty the route is not registered and the server
// starts normally without the probe ingest endpoint.
probeSecretsJSON := os.Getenv("PROBE_SECRETS")
if probeSecretsJSON != "" {
redisAddr := getenvDefault("REDIS_ADDR", "127.0.0.1:6379")
rdb, err := redisutil.New(redisAddr, os.Getenv("REDIS_PASSWORD"), 0)
// 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(probeSecretsJSON), &secretMap); err != nil {
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(rdb)
h := probe.NewIngestHandler(reg, st)
r.Post("/probe/report", h.ServeHTTP)
st := probe.NewStore(pRDB)
r.Post("/probe/report", probe.NewIngestHandler(reg, st).ServeHTTP)
log.Printf("probe ingest route registered (%d probe(s))", len(secretMap))
}
}
// Mount all /v1/... routes. HandlerFromMuxWithBaseURL registers every route
// from the OpenAPI spec onto the provided chi router with the given prefix,
// so the router itself is the http.Handler we serve.
gen.HandlerFromMuxWithBaseURL(&httpapi.UnimplementedServer{}, r, "/v1")
// ─── gRPC agent server (optional) ────────────────────────────────────────
// All five env vars must be set to activate the gRPC listener.
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")
dbDSN := os.Getenv("DB_DSN")
if grpcAddr != "" && caKeyPath != "" && caCertPath != "" &&
grpcCertPath != "" && grpcKeyPath != "" && dbDSN != "" {
redisAddr := getenvDefault("REDIS_ADDR", "127.0.0.1:6379")
go startGRPC(grpcAddr, caKeyPath, caCertPath, grpcCertPath, grpcKeyPath,
redisAddr, os.Getenv("REDIS_PASSWORD"), dbDSN)
} else if grpcAddr != "" {
slog.Warn("grpc server disabled: one or more required env vars are missing",
"GRPC_ADDR", grpcAddr,
"CA_KEY_PATH_set", caKeyPath != "",
"CA_CERT_PATH_set", caCertPath != "",
"GRPC_CERT_PATH_set", grpcCertPath != "",
"GRPC_KEY_PATH_set", grpcKeyPath != "",
"DB_DSN_set", dbDSN != "")
// ─── /v1 routes (requires DB) ────────────────────────────────────────────
if sqlDB != nil {
mountV1(r, sqlDB, rdb, nodeSvc)
} else {
log.Printf("DB_DSN not set; /v1 routes disabled")
}
// ─── Start HTTP server (blocking) ────────────────────────────────────────
log.Printf("pangolin server listening on %s", *addr)
if err := http.ListenAndServe(*addr, r); err != nil {
log.Printf("pangolin server listening on %s", *listenAddr)
if err := http.ListenAndServe(*listenAddr, r); err != nil {
log.Fatalf("server error: %v", err)
}
}
// startAdminIfConfigured launches the admin listener in a background goroutine
// when ADMIN_SECRET_KEY and DB_DSN are present. The admin port binds an
// internal address only (enforced by admin.FromEnv) and is fronted by an IP
// allowlist + two-factor login.
// 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.Get("/me", accountAPI.GetMe)
protected.Route("/me", func(me chi.Router) {
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)")
@@ -145,13 +278,11 @@ func startAdminIfConfigured() {
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 {
@@ -160,43 +291,14 @@ func startAdminIfConfigured() {
}()
}
// startGRPC initialises and starts the mTLS gRPC agent server.
// Must be called in a goroutine. Calls log.Fatalf on unrecoverable errors.
func startGRPC(
addr, caKeyPath, caCertPath, grpcCertPath, grpcKeyPath,
redisAddr, redisPassword, dbDSN string,
// 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,
) {
// Pangolin Node CA (signs + verifies node client certs).
ca, err := mtls.NewCA(mtls.CAConfig{
KeyPath: caKeyPath,
CertPath: caCertPath,
})
if err != nil {
log.Fatalf("grpc: load CA: %v", err)
}
// Redis (hub command queue, load cache, CRL, bootstrap tokens).
rdb, err := redisutil.New(redisAddr, redisPassword, 0)
if err != nil {
log.Fatalf("grpc: redis connect: %v", err)
}
// CRL + one-time bootstrap token manager.
crl := mtls.NewCRL(rdb, nil)
tokens := mtls.NewBootstrapTokenManager(rdb)
// MySQL for node catalogue and usage accumulation.
sqlDB, err := db.Open(dbDSN)
if err != nil {
log.Fatalf("grpc: db open: %v", err)
}
// Nodes service (wires Hub, LoadCache, Handler).
store := nodes.NewSQLNodeStore(sqlDB)
svc := nodes.NewService(ca, tokens, rdb, store)
svc.Hub().Start(context.Background()) // pub/sub goroutine runs for process lifetime
// gRPC transport TLS (server's own cert, typically from Let's Encrypt).
serverCert, err := tls.LoadX509KeyPair(grpcCertPath, grpcKeyPath)
if err != nil {
log.Fatalf("grpc: load transport TLS cert: %v", err)
@@ -204,7 +306,6 @@ func startGRPC(
tlsCfg := mtls.NewServerTLSConfig(ca, crl)
tlsCfg.Certificates = []tls.Certificate{serverCert}
// Build gRPC server with mTLS transport + identity interceptors.
srv := grpc.NewServer(
grpc.Creds(credentials.NewTLS(tlsCfg)),
grpc.ChainUnaryInterceptor(mtls.UnaryServerInterceptor()),
@@ -222,9 +323,41 @@ func startGRPC(
}
}
// 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
}