diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 999898b..bfbb3e2 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -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 +} diff --git a/server/internal/agentd/config.go b/server/internal/agentd/config.go index 2375096..c503d21 100644 --- a/server/internal/agentd/config.go +++ b/server/internal/agentd/config.go @@ -32,6 +32,7 @@ const ( DefaultSingboxCfg = "/etc/sing-box/config.json" // DefaultFlow is the REALITY VLESS flow (doc/02 §3.1). + // Kept here as an alias; canonical definition is in internal/dpcred. DefaultFlow = "xtls-rprx-vision" ) diff --git a/server/internal/agentd/derive.go b/server/internal/agentd/derive.go index 5a1d144..2a9047d 100644 --- a/server/internal/agentd/derive.go +++ b/server/internal/agentd/derive.go @@ -1,34 +1,11 @@ package agentd -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" -) +import "github.com/wangjia/pangolin/server/internal/dpcred" -// DeriveHy2Password derives a node-agnostic Hysteria2 password from the opaque -// data-plane credential id (dp_uuid). The REALITY inbound uses dp_uuid directly as -// the VLESS user uuid; the Hy2 inbound cannot reuse a UUID as a password verbatim -// (it must look like an opaque secret), so it is derived from the SAME source — -// "password = dp_uuid 同源派生" — via a keyed HMAC. -// -// The derivation is deterministic given (dp_uuid, key): the control plane runs the -// exact same function when it builds the client's connect config (doc/02 §3.1), so -// both sides agree without the password ever crossing the agent contract. -// -// Risk note (carried from task #5): the derivation key is shared material. If a -// node is seized the attacker still only sees dp_uuids and this node's key, which -// lets them recompute Hy2 passwords for dp_uuids THEY ALREADY HOLD — it does not -// reveal other subscribers' credentials or any account identity. Rotating the key -// rotates every Hy2 password. When key == "" the password falls back to the raw -// dp_uuid (acceptable for dev; production cloud-init always injects a key). +// DeriveHy2Password is an alias for dpcred.DeriveHy2Password kept here so +// existing callers within the agentd package compile without change. +// The canonical implementation lives in internal/dpcred so that both the node +// agent and the HTTP connect handler share the exact same derivation logic. func DeriveHy2Password(dpUUID, key string) string { - if key == "" { - return dpUUID - } - mac := hmac.New(sha256.New, []byte(key)) - mac.Write([]byte(dpUUID)) - sum := mac.Sum(nil) - // base64url without padding → URL/JSON-safe, 43 chars. - return base64.RawURLEncoding.EncodeToString(sum) + return dpcred.DeriveHy2Password(dpUUID, key) } diff --git a/server/internal/dpcred/dpcred.go b/server/internal/dpcred/dpcred.go new file mode 100644 index 0000000..4d26327 --- /dev/null +++ b/server/internal/dpcred/dpcred.go @@ -0,0 +1,39 @@ +// Package dpcred holds the data-plane credential derivation utilities shared +// between the node agent (agentd) and the HTTP control-plane connect handler. +// The functions must produce identical output on both sides — keeping them in a +// single package is the only safe way to guarantee that. +package dpcred + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" +) + +// DefaultFlow is the REALITY VLESS flow used for both server inbound and client +// outbound configuration (doc/02 §3.1). +const DefaultFlow = "xtls-rprx-vision" + +// DeriveHy2Password derives a node-agnostic Hysteria2 password from the opaque +// data-plane credential id (dp_uuid). The REALITY inbound uses dp_uuid directly +// as the VLESS user uuid; the Hy2 inbound cannot reuse a UUID as a password +// verbatim (it must look like an opaque secret), so it is derived from the SAME +// source — "password = dp_uuid 同源派生" — via a keyed HMAC. +// +// The derivation is deterministic given (dp_uuid, key): the control plane runs +// the exact same function when it builds the client's connect config (doc/02 +// §3.1), so both sides agree without the password ever crossing the agent +// contract. +// +// When key == "" the password falls back to the raw dp_uuid (acceptable for +// dev; production always injects a key via NODE_DERIVE_KEY env). +func DeriveHy2Password(dpUUID, key string) string { + if key == "" { + return dpUUID + } + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(dpUUID)) + sum := mac.Sum(nil) + // base64url without padding → URL/JSON-safe, 43 chars. + return base64.RawURLEncoding.EncodeToString(sum) +} diff --git a/server/internal/httpapi/account.go b/server/internal/httpapi/account.go new file mode 100644 index 0000000..2a90b03 --- /dev/null +++ b/server/internal/httpapi/account.go @@ -0,0 +1,135 @@ +package httpapi + +import ( + "database/sql" + "encoding/json" + "net/http" + "time" + + "github.com/wangjia/pangolin/server/internal/apierr" + "github.com/wangjia/pangolin/server/internal/auth" +) + +// AccountAPI serves /v1/me and supporting endpoints. +type AccountAPI struct { + db *sql.DB +} + +// NewAccountAPI creates an AccountAPI. +func NewAccountAPI(db *sql.DB) *AccountAPI { return &AccountAPI{db: db} } + +// ─── GET /v1/me ────────────────────────────────────────────────────────────── + +type meResponse struct { + UUID string `json:"uuid"` + Email string `json:"email"` + DpUUID string `json:"dp_uuid"` + Plan string `json:"plan"` // "free" | "pro" | "team" + ExpireAt *string `json:"expire_at"` // RFC3339 UTC, null = no active sub +} + +// GetMe handles GET /v1/me. +func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { + uid, ok := auth.UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + + var ( + uuid string + email string + dpUUID string + ) + if err := a.db.QueryRowContext(r.Context(), + `SELECT uuid, email, dp_uuid FROM users WHERE id = ? AND status = 'active'`, uid, + ).Scan(&uuid, &email, &dpUUID); err == sql.ErrNoRows { + apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) + return + } else if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + + // Best active subscription. + var planCode string + var expiresAt sql.NullTime + err := a.db.QueryRowContext(r.Context(), ` + SELECT p.code, s.expires_at + FROM subscriptions s + JOIN plans p ON p.id = s.plan_id + WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP() + ORDER BY s.expires_at DESC + LIMIT 1 + `, uid).Scan(&planCode, &expiresAt) + if err == sql.ErrNoRows { + planCode = "free" + } else if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + + resp := meResponse{ + UUID: uuid, + Email: email, + DpUUID: dpUUID, + Plan: planCode, + } + if expiresAt.Valid { + s := expiresAt.Time.UTC().Format(time.RFC3339) + resp.ExpireAt = &s + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(resp) +} + +// ─── GET /v1/plans ─────────────────────────────────────────────────────────── + +type planResponse struct { + Code string `json:"code"` + NameZH string `json:"name_zh"` + NameEN string `json:"name_en"` + DailyMinutes *int64 `json:"daily_minutes"` // null = unlimited + AdGate bool `json:"ad_gate"` +} + +// ListPlans handles GET /v1/plans. +func (a *AccountAPI) ListPlans(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), + `SELECT code, name_zh, name_en, daily_minutes, ad_gate FROM plans ORDER BY id`) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + defer rows.Close() + + var plans []planResponse + for rows.Next() { + var p planResponse + var dm sql.NullInt64 + if err := rows.Scan(&p.Code, &p.NameZH, &p.NameEN, &dm, &p.AdGate); err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if dm.Valid { + p.DailyMinutes = &dm.Int64 + } + plans = append(plans, p) + } + if err := rows.Err(); err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"plans": plans}) +} + +// ─── GET /v1/notices ───────────────────────────────────────────────────────── + +// ListNotices handles GET /v1/notices. Returns an empty list for the MVP. +func (a *AccountAPI) ListNotices(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"notices": []any{}}) +} diff --git a/server/internal/httpapi/clientconfig.go b/server/internal/httpapi/clientconfig.go new file mode 100644 index 0000000..9137550 --- /dev/null +++ b/server/internal/httpapi/clientconfig.go @@ -0,0 +1,155 @@ +package httpapi + +import ( + "encoding/json" + "strings" + + "github.com/wangjia/pangolin/server/internal/dpcred" + "github.com/wangjia/pangolin/server/internal/nodes" +) + +// BuildClientConfig renders a complete sing-box CLIENT configuration JSON that +// the client app passes verbatim to the local tunnel kernel. +// +// Design rule (ARCHITECTURE.md §3.1): the Dart/Flutter client MUST NOT assemble +// or modify the config — it is rendered here, server-side, and returned raw. +// +// Parameters: +// - node: the target node row (provides endpoint, keys, ports) +// - dpUUID: the authenticated user's data-plane UUID (used as VLESS uuid) +// - deriveKey: shared HMAC key used by both server and agent to derive the +// Hysteria2 password from dp_uuid (must equal PANGOLIN_AGENT_DERIVE_KEY) +// - ttlSeconds: credential lifetime hint; not embedded in the config but can +// be used by callers to set a session timer +func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string) ([]byte, error) { + // Parse host:port from endpoint; endpoint format is "host:port". + host, _ := splitHostPort(node.Endpoint) + if host == "" { + host = node.Endpoint + } + + realityPublicKey := node.RealityPBK + realityShortID := node.RealityShortID + hy2Password := dpcred.DeriveHy2Password(dpUUID, deriveKey) + hy2Port := int32(443) // default + if node.Hy2Port.Valid { + hy2Port = node.Hy2Port.Int32 + } + + // REALITY outbound (VLESS + REALITY TLS, TCP 443). + realityOut := map[string]any{ + "type": "vless", + "tag": "reality-out", + "server": host, + "server_port": 11443, // REALITY always uses port from endpoint + "uuid": dpUUID, + "flow": dpcred.DefaultFlow, + "tls": map[string]any{ + "enabled": true, + "server_name": node.RealitySNI, + "utls": map[string]any{ + "enabled": true, + "fingerprint": "chrome", + }, + "reality": map[string]any{ + "enabled": true, + "public_key": realityPublicKey, + "short_id": realityShortID, + }, + }, + } + + // Parse the REALITY listen port from endpoint. + if _, portStr := splitHostPort(node.Endpoint); portStr != "" { + port := 0 + for _, ch := range portStr { + if ch >= '0' && ch <= '9' { + port = port*10 + int(ch-'0') + } + } + if port > 0 { + realityOut["server_port"] = port + } + } + + // Hysteria2 outbound (UDP 443). + hy2Out := map[string]any{ + "type": "hysteria2", + "tag": "hy2-out", + "server": host, + "server_port": hy2Port, + "password": hy2Password, + "tls": map[string]any{ + "enabled": true, + "alpn": []string{"h3"}, + }, + } + + // TUN inbound with kill-switch (strict_route). + tunIn := map[string]any{ + "type": "tun", + "tag": "tun-in", + "address": []string{"172.19.0.1/30"}, + "mtu": 9000, + "auto_route": true, + "strict_route": true, + "stack": "system", + } + + // urltest auto-select outbound. + autoBest := map[string]any{ + "type": "urltest", + "tag": "auto", + "outbounds": []string{"reality-out", "hy2-out"}, + "url": "https://www.gstatic.com/generate_204", + "interval": "3m", + "tolerance": 50, + } + + // Route: LAN direct, everything else via auto. + route := map[string]any{ + "rules": []any{ + map[string]any{ + "ip_cidr": []string{"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: remote over tunnel, local for domestic. + dns := map[string]any{ + "servers": []any{ + map[string]any{"tag": "remote", "address": "tls://8.8.8.8", "detour": "auto"}, + map[string]any{"tag": "local", "address": "223.5.5.5", "detour": "direct"}, + }, + "final": "remote", + "strategy": "ipv4_only", + } + + cfg := map[string]any{ + "log": map[string]any{"level": "warn", "timestamp": true}, + "inbounds": []any{tunIn}, + "outbounds": []any{ + realityOut, + hy2Out, + autoBest, + map[string]any{"type": "block", "tag": "block"}, + map[string]any{"type": "direct", "tag": "direct"}, + }, + "route": route, + "dns": dns, + } + + return json.Marshal(cfg) +} + +// splitHostPort splits "host:port" into (host, port). Returns ("", "") on failure. +func splitHostPort(s string) (host, port string) { + i := strings.LastIndexByte(s, ':') + if i < 0 { + return s, "" + } + return s[:i], s[i+1:] +} diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go new file mode 100644 index 0000000..1348cde --- /dev/null +++ b/server/internal/httpapi/nodes.go @@ -0,0 +1,224 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/wangjia/pangolin/server/internal/apierr" + "github.com/wangjia/pangolin/server/internal/auth" + "github.com/wangjia/pangolin/server/internal/nodes" + agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" +) + +const ( + // paidCredentialTTL is the default connect credential lifetime for paid users. + paidCredentialTTL = 24 * time.Hour + // freeCredentialTTL is the per-minute TTL for free users (per remaining minutes). + freeMinuteTTL = time.Minute +) + +// NodeAPI serves the /v1/nodes endpoints. +type NodeAPI struct { + store nodes.NodeStore + hub *nodes.Hub + deriveKey string +} + +// NewNodeAPI creates a NodeAPI. +func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, deriveKey string) *NodeAPI { + return &NodeAPI{store: store, hub: hub, deriveKey: deriveKey} +} + +// ─── GET /v1/nodes ─────────────────────────────────────────────────────────── + +type nodeResponse struct { + ID string `json:"id"` // UUID (used as path param for connect) + Region string `json:"region"` // HK / JP / SG / US + NameZH string `json:"name_zh"` + NameEN string `json:"name_en"` + Tier string `json:"tier"` // "free" | "pro" + Status string `json:"status"` // always "up" in this endpoint +} + +// ListNodes handles GET /v1/nodes. +func (a *NodeAPI) ListNodes(w http.ResponseWriter, r *http.Request) { + nodeRows, err := a.store.ListUp(r.Context()) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + + resp := make([]nodeResponse, 0, len(nodeRows)) + for _, n := range nodeRows { + resp = append(resp, nodeResponse{ + ID: n.UUID, + Region: n.Region, + NameZH: n.NameZH, + NameEN: n.NameEN, + Tier: n.Tier, + Status: n.Status, + }) + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"nodes": resp}) +} + +// ─── POST /v1/nodes/{id}/connect ───────────────────────────────────────────── + +type connectRequest struct { + DeviceID string `json:"device_id"` +} + +// ConnectNode handles POST /v1/nodes/{id}/connect. +func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { + uid, ok := auth.UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + + nodeUUID := chi.URLParam(r, "id") + if nodeUUID == "" { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + + var req connectRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)).Decode(&req); err != nil || strings.TrimSpace(req.DeviceID) == "" { + apierr.WriteJSON(w, http.StatusBadRequest, &apierr.Error{ + Code: "BAD_REQUEST", + MessageZH: "缺少 device_id", + MessageEn: "Missing device_id", + }) + return + } + + // 1. Load user entitlement (dp_uuid + plan). + ent, err := a.store.EntitlementForUser(r.Context(), uid) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if ent == nil { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + + // 2. Determine TTL from plan. + var ttl time.Duration + if ent.AdGate { + // Free plan: flat 10-minute session for MVP (full ad-gate in a later pass). + dm := int64(10) + if ent.DailyMinutes.Valid { + dm = ent.DailyMinutes.Int64 + } + if dm <= 0 { + apierr.WriteJSON(w, http.StatusForbidden, apierr.ErrQuotaExhausted) + return + } + ttl = time.Duration(dm) * freeMinuteTTL + } else { + ttl = paidCredentialTTL + } + expiresAt := time.Now().UTC().Add(ttl) + + // 3. Resolve node. + node, err := a.store.NodeByUUID(r.Context(), nodeUUID) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if node == nil || node.Status != "up" { + apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) + return + } + + // 4. Build the agentv1.Credential. + cred := &agentv1.Credential{ + DpUUID: ent.DpUUID, + Protocol: agentv1.ProtocolBoth, + Flow: "xtls-rprx-vision", + ExpiresAtUnix: expiresAt.Unix(), + } + + // 5. Push to the node agent via Hub (real gRPC channel). + pushErr := a.hub.Push(r.Context(), node.UUID, &agentv1.Command{ + Type: agentv1.CommandTypeUpsert, + Upsert: &agentv1.UpsertPayload{Credential: cred}, + }) + if pushErr != nil { + // Non-fatal: command is queued in Redis and will be replayed on reconnect. + // Log but don't abort — return the config so the client can attempt the tunnel. + _ = pushErr + } + + // 6. Persist credential for agent resync. + if persistErr := a.store.PersistCredential(r.Context(), node.ID, cred, expiresAt); persistErr != nil { + // Non-fatal: tunnel still works via Hub push; resync will miss it on agent restart. + _ = persistErr + } + + // 7. Render and return the full sing-box CLIENT config JSON. + cfgJSON, renderErr := BuildClientConfig(node, ent.DpUUID, a.deriveKey) + if renderErr != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write(cfgJSON) +} + +// ─── POST /v1/nodes/{id}/disconnect ────────────────────────────────────────── + +// DisconnectNode handles POST /v1/nodes/{id}/disconnect. +func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) { + uid, ok := auth.UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + + nodeUUID := chi.URLParam(r, "id") + if nodeUUID == "" { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + + // Load dp_uuid. + ent, err := a.store.EntitlementForUser(r.Context(), uid) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if ent == nil { + w.WriteHeader(http.StatusNoContent) // nothing to revoke + return + } + + node, err := a.store.NodeByUUID(r.Context(), nodeUUID) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if node == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + // Push revoke command. + _ = a.hub.Push(r.Context(), node.UUID, &agentv1.Command{ + Type: agentv1.CommandTypeRevoke, + Revoke: &agentv1.RevokePayload{DpUUID: ent.DpUUID}, + }) + + // Delete persisted credential. + _ = a.store.DeleteCredential(r.Context(), node.ID, ent.DpUUID) + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/nodes/grpc_test.go b/server/internal/nodes/grpc_test.go index 755cc9e..a90e9d2 100644 --- a/server/internal/nodes/grpc_test.go +++ b/server/internal/nodes/grpc_test.go @@ -80,6 +80,22 @@ func (m *mockNodeStore) UserIDByDpUUID(_ context.Context, dpUUID string) (int64, return 0, false, nil } +func (m *mockNodeStore) ListUp(_ context.Context) ([]*nodes.NodeRow, error) { + return []*nodes.NodeRow{{ID: 1, UUID: m.nodeUUID, Status: "up"}}, nil +} + +func (m *mockNodeStore) EntitlementForUser(_ context.Context, _ int64) (*nodes.Entitlement, error) { + return nil, nil +} + +func (m *mockNodeStore) PersistCredential(_ context.Context, _ int64, _ *agentv1.Credential, _ time.Time) error { + return nil +} + +func (m *mockNodeStore) DeleteCredential(_ context.Context, _ int64, _ string) error { + return nil +} + func (m *mockNodeStore) AccumulateUsage(_ context.Context, userID int64, date time.Time, bytesUp, bytesDown, minutes int64, ) error { diff --git a/server/internal/nodes/handler_grpc.go b/server/internal/nodes/handler_grpc.go index fd203d9..4842fe6 100644 --- a/server/internal/nodes/handler_grpc.go +++ b/server/internal/nodes/handler_grpc.go @@ -106,9 +106,19 @@ func (h *Handler) Register(ctx context.Context, req *agentv1.RegisterRequest) (* } // Populate inbound configs from the nodes row. - if node.RealityPBK != "" { + // reality_prk is the PRIVATE key the agent's VLESS inbound needs; + // reality_pbk is the PUBLIC key sent to clients in the connect config. + if node.RealityPRK != "" { + snap.Reality = &agentv1.RealityInbound{ + PrivateKey: node.RealityPRK, + ShortID: node.RealityShortID, + ServerName: node.RealitySNI, + } + } else if node.RealityPBK != "" { + // Fallback for nodes seeded before migration 000011: use pbk field. snap.Reality = &agentv1.RealityInbound{ PrivateKey: node.RealityPBK, + ShortID: node.RealityShortID, ServerName: node.RealitySNI, } } diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index 4c1e77e..448959b 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -11,13 +11,28 @@ import ( // NodeRow holds a node's essential fields from the nodes table. type NodeRow struct { - ID int64 - UUID string - Status string - RealityPBK string - RealitySNI string - Endpoint string - Hy2Port sql.NullInt32 + ID int64 + UUID string + Region string + NameZH string + NameEN string + Tier string + Status string + RealityPBK string // REALITY x25519 PUBLIC key (for client connect config) + RealityPRK string // REALITY x25519 PRIVATE key (for agent inbound TLS) + RealitySNI string + RealityShortID string + Endpoint string + Hy2Port sql.NullInt32 +} + +// Entitlement summarises a user's active plan for the connect gate. +type Entitlement struct { + DpUUID string + PlanCode string + AdGate bool // true = free plan, require ad unlock + minute quota + DailyMinutes sql.NullInt64 + ExpiresAt sql.NullTime // latest subscription expiry (nil = trial/active) } // NodeStore is the persistence interface used by the nodes domain handlers. @@ -27,6 +42,13 @@ type NodeStore interface { // Returns (nil, nil) when no matching row exists. NodeByUUID(ctx context.Context, uuid string) (*NodeRow, error) + // ListUp returns all nodes with status='up', ordered by weight DESC. + ListUp(ctx context.Context) ([]*NodeRow, error) + + // EntitlementForUser returns the user's dp_uuid and active plan entitlement. + // Returns (nil, nil) when the user has no active subscription (treats as free). + EntitlementForUser(ctx context.Context, userID int64) (*Entitlement, error) + // ConfigVersion returns the current global directory version. // This is the version from the directory_version singleton table. ConfigVersion(ctx context.Context) (int64, error) @@ -36,9 +58,14 @@ type NodeStore interface { ActiveNodeUUIDs(ctx context.Context) ([]string, error) // CredentialsForNode returns the active data-plane credentials for nodeUUID. - // Returns an empty slice until task 5d creates the connect_credentials table. CredentialsForNode(ctx context.Context, nodeUUID string) ([]*agentv1.Credential, error) + // PersistCredential upserts a credential row in connect_credentials. + PersistCredential(ctx context.Context, nodeID int64, cred *agentv1.Credential, expiresAt time.Time) error + + // DeleteCredential removes the credential for (nodeID, dpUUID). + DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error + // UserIDByDpUUID maps a data-plane UUID to the owning user's internal ID. // Returns (0, false, nil) if the dp_uuid is unknown or the user is inactive. UserIDByDpUUID(ctx context.Context, dpUUID string) (int64, bool, error) @@ -62,14 +89,17 @@ func NewSQLNodeStore(db *sql.DB) *SQLNodeStore { // NodeByUUID looks up a node by UUID. Returns (nil, nil) when not found. func (s *SQLNodeStore) NodeByUUID(ctx context.Context, uuid string) (*NodeRow, error) { const q = ` - SELECT id, uuid, status, reality_pbk, reality_sni, endpoint, hy2_port + SELECT id, uuid, region, name_zh, name_en, tier, status, + reality_pbk, reality_prk, reality_sni, reality_short_id, + endpoint, hy2_port FROM nodes WHERE uuid = ? ` var n NodeRow err := s.db.QueryRowContext(ctx, q, uuid).Scan( - &n.ID, &n.UUID, &n.Status, - &n.RealityPBK, &n.RealitySNI, &n.Endpoint, &n.Hy2Port, + &n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Tier, &n.Status, + &n.RealityPBK, &n.RealityPRK, &n.RealitySNI, &n.RealityShortID, + &n.Endpoint, &n.Hy2Port, ) if err == sql.ErrNoRows { return nil, nil @@ -80,6 +110,77 @@ func (s *SQLNodeStore) NodeByUUID(ctx context.Context, uuid string) (*NodeRow, e return &n, nil } +// ListUp returns nodes with status='up', ordered by weight DESC. +func (s *SQLNodeStore) ListUp(ctx context.Context) ([]*NodeRow, error) { + const q = ` + SELECT id, uuid, region, name_zh, name_en, tier, status, + reality_pbk, reality_prk, reality_sni, reality_short_id, + endpoint, hy2_port + FROM nodes + WHERE status = 'up' + ORDER BY weight DESC + ` + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return nil, fmt.Errorf("nodes.SQLNodeStore.ListUp: %w", err) + } + defer rows.Close() + + var out []*NodeRow + for rows.Next() { + var n NodeRow + if err := rows.Scan( + &n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Tier, &n.Status, + &n.RealityPBK, &n.RealityPRK, &n.RealitySNI, &n.RealityShortID, + &n.Endpoint, &n.Hy2Port, + ); err != nil { + return nil, fmt.Errorf("nodes.SQLNodeStore.ListUp scan: %w", err) + } + out = append(out, &n) + } + return out, rows.Err() +} + +// EntitlementForUser returns the user's dp_uuid and best active plan entitlement. +// Picks the subscription with the latest expires_at; falls back to the 'free' plan +// if the user has no active subscription. +func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*Entitlement, error) { + // First get dp_uuid from the users table. + var dpUUID string + if err := s.db.QueryRowContext(ctx, + `SELECT dp_uuid FROM users WHERE id = ? AND status = 'active'`, userID, + ).Scan(&dpUUID); err == sql.ErrNoRows { + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: dp_uuid: %w", err) + } + + // Look up the best active subscription. + const q = ` + SELECT p.code, p.ad_gate, p.daily_minutes, s.expires_at + FROM subscriptions s + JOIN plans p ON p.id = s.plan_id + WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP() + ORDER BY s.expires_at DESC + LIMIT 1 + ` + e := &Entitlement{DpUUID: dpUUID} + err := s.db.QueryRowContext(ctx, q, userID).Scan( + &e.PlanCode, &e.AdGate, &e.DailyMinutes, &e.ExpiresAt, + ) + if err == sql.ErrNoRows { + // No active subscription → free plan defaults. + e.PlanCode = "free" + e.AdGate = true + e.DailyMinutes = sql.NullInt64{Valid: true, Int64: 10} + return e, nil + } + if err != nil { + return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: plan: %w", err) + } + return e, nil +} + // ConfigVersion returns the current global directory version. // Returns 0 with nil error if the directory_version row does not yet exist. func (s *SQLNodeStore) ConfigVersion(ctx context.Context) (int64, error) { @@ -115,10 +216,58 @@ func (s *SQLNodeStore) ActiveNodeUUIDs(ctx context.Context) ([]string, error) { return uuids, rows.Err() } -// CredentialsForNode returns active credentials for nodeUUID. -// Stub: returns empty slice until task 5d creates the connect_credentials table. -func (s *SQLNodeStore) CredentialsForNode(_ context.Context, _ string) ([]*agentv1.Credential, error) { - return nil, nil +// CredentialsForNode returns active (non-expired) credentials for nodeUUID. +func (s *SQLNodeStore) CredentialsForNode(ctx context.Context, nodeUUID string) ([]*agentv1.Credential, error) { + const q = ` + SELECT cc.dp_uuid, cc.protocol, cc.flow, UNIX_TIMESTAMP(cc.expires_at) + FROM connect_credentials cc + JOIN nodes n ON n.id = cc.node_id + WHERE n.uuid = ? AND cc.expires_at > UTC_TIMESTAMP() + ` + rows, err := s.db.QueryContext(ctx, q, nodeUUID) + if err != nil { + return nil, fmt.Errorf("nodes.SQLNodeStore.CredentialsForNode: %w", err) + } + defer rows.Close() + + var out []*agentv1.Credential + for rows.Next() { + var c agentv1.Credential + if err := rows.Scan(&c.DpUUID, &c.Protocol, &c.Flow, &c.ExpiresAtUnix); err != nil { + return nil, fmt.Errorf("nodes.SQLNodeStore.CredentialsForNode scan: %w", err) + } + out = append(out, &c) + } + return out, rows.Err() +} + +// PersistCredential upserts the credential into connect_credentials. +func (s *SQLNodeStore) PersistCredential(ctx context.Context, nodeID int64, cred *agentv1.Credential, expiresAt time.Time) error { + const q = ` + INSERT INTO connect_credentials (node_id, dp_uuid, protocol, flow, expires_at) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + protocol = VALUES(protocol), + flow = VALUES(flow), + expires_at = VALUES(expires_at) + ` + if _, err := s.db.ExecContext(ctx, q, + nodeID, cred.DpUUID, int32(cred.Protocol), cred.Flow, expiresAt.UTC(), + ); err != nil { + return fmt.Errorf("nodes.SQLNodeStore.PersistCredential: %w", err) + } + return nil +} + +// DeleteCredential removes the credential for (nodeID, dpUUID). +func (s *SQLNodeStore) DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error { + if _, err := s.db.ExecContext(ctx, + `DELETE FROM connect_credentials WHERE node_id = ? AND dp_uuid = ?`, + nodeID, dpUUID, + ); err != nil { + return fmt.Errorf("nodes.SQLNodeStore.DeleteCredential: %w", err) + } + return nil } // UserIDByDpUUID resolves a data-plane UUID to an active user's internal ID. diff --git a/server/migrations/000011_node_reality_private.down.sql b/server/migrations/000011_node_reality_private.down.sql new file mode 100644 index 0000000..974c117 --- /dev/null +++ b/server/migrations/000011_node_reality_private.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE nodes + DROP COLUMN reality_short_id, + DROP COLUMN reality_prk; diff --git a/server/migrations/000011_node_reality_private.up.sql b/server/migrations/000011_node_reality_private.up.sql new file mode 100644 index 0000000..d99e652 --- /dev/null +++ b/server/migrations/000011_node_reality_private.up.sql @@ -0,0 +1,10 @@ +-- Add reality_prk to store the REALITY private key on the node row. +-- Previously reality_pbk was overloaded to hold the private key (which the +-- agent needs for its inbound); this migration separates concerns: +-- reality_pbk = REALITY x25519 PUBLIC key (used in client connect config) +-- reality_prk = REALITY x25519 PRIVATE key (used by the agent for its inbound TLS) +-- Existing rows: reality_pbk continues to hold whatever was there until the +-- operator seeds / updates the node row via nodectl or direct SQL. +ALTER TABLE nodes + ADD COLUMN reality_prk VARCHAR(64) NOT NULL DEFAULT '' AFTER reality_pbk, + ADD COLUMN reality_short_id VARCHAR(16) NOT NULL DEFAULT '' AFTER reality_prk; diff --git a/server/migrations/000012_connect_credentials.down.sql b/server/migrations/000012_connect_credentials.down.sql new file mode 100644 index 0000000..cfaa8cc --- /dev/null +++ b/server/migrations/000012_connect_credentials.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS connect_credentials; diff --git a/server/migrations/000012_connect_credentials.up.sql b/server/migrations/000012_connect_credentials.up.sql new file mode 100644 index 0000000..699e193 --- /dev/null +++ b/server/migrations/000012_connect_credentials.up.sql @@ -0,0 +1,15 @@ +-- Persist active connect credentials so the agent can replay them on reconnect. +-- Without this table, an agent restart drops all active user tunnels until each +-- user reconnects. The connect handler writes here; disconnect deletes; the +-- gRPC Register handler calls CredentialsForNode to replay on full resync. +CREATE TABLE connect_credentials ( + node_id BIGINT UNSIGNED NOT NULL, + dp_uuid CHAR(36) NOT NULL, + protocol TINYINT NOT NULL DEFAULT 3, -- agentv1.ProtocolBoth = 3 + flow VARCHAR(64) NOT NULL DEFAULT 'xtls-rprx-vision', + expires_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (node_id, dp_uuid), + INDEX idx_node_expires (node_id, expires_at), + CONSTRAINT fk_cc_node FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;