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
+135
View File
@@ -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{}})
}
+155
View File
@@ -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:]
}
+224
View File
@@ -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)
}