Files
pangolin/server/internal/httpapi/subscription.go
T
wangjia 43b25c8aa0
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
feat: 国内流量直连分流(geoip-cn / geosite-cn)— #5
国内 IP/域名直连(不走隧道)→ 省流量 + 国内访问快;非国内走代理。

- clientconfig.go: BuildClientConfig 加 ClientConfigOpts{SplitCN,RulesBaseURL};
  开启时 route 加 {rule_set:[geoip-cn,geosite-cn]→direct} + 定义 rule_set
  (remote .srs,download_detour:direct 直连下载)
- rules.go: 控制面静态服务 /v1/rules/{name}.srs(白名单防穿越)——自托管避免
  GitHub 在国内被墙的鸡生蛋;客户端反正连控制面,可达性有保证
- nodes.go ConnectNode: 读 ?split_cn → opts;NodeAPI 加 rulesBaseURL
  (PANGOLIN_PUBLIC_URL);main.go 挂 /v1/rules 路由 + RulesHandler
- 客户端: connect_api splitCN→?split_cn=1;connection_provider 传 smartRoute 偏好
- deploy/single-node: 拉 geoip-cn/geosite-cn.srs 到 $DATA_DIR/rules +
  设 PANGOLIN_PUBLIC_URL/PANGOLIN_RULES_DIR

验证:go test(splitCN 开/关渲染 + RulesHandler 白名单/404)+ flutter analyze +
shellcheck;不需要节点。订阅链接暂用默认 opts、DNS 分流为后续增强。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:34:00 +08:00

192 lines
6.2 KiB
Go

package httpapi
import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"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"
)
// SubscriptionAPI serves the per-user subscription URL used by the web
// user-center ("订阅导入"). The URL points at GET /sub/{token}, which renders the
// user's sing-box client config so it can be imported into a client app.
//
// GET /v1/me/subscription → {"url": "<subBase>/sub/<token>"}
// POST /v1/me/subscription/reset → rotates the token, returns the new url
// GET /sub/{token} → public; sing-box client config for that user
type SubscriptionAPI struct {
db *sql.DB
nodes nodes.NodeStore
deriveKey string
subBase string // e.g. "https://sub.example.com" (no trailing slash); may be empty
}
// NewSubscriptionAPI creates a SubscriptionAPI. subBase is the public base URL
// the subscription link is built from; when empty the request scheme+host is used.
func NewSubscriptionAPI(db *sql.DB, store nodes.NodeStore, deriveKey, subBase string) *SubscriptionAPI {
return &SubscriptionAPI{db: db, nodes: store, deriveKey: deriveKey, subBase: subBase}
}
type subscriptionResponse struct {
URL string `json:"url"`
}
// GetSubscription handles GET /v1/me/subscription.
func (s *SubscriptionAPI) GetSubscription(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFromContext(r.Context())
if !ok {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
token, apiErr := s.ensureToken(r.Context(), uid)
if apiErr != nil {
apierr.WriteJSON(w, statusFor(apiErr), apiErr)
return
}
writeJSON(w, http.StatusOK, subscriptionResponse{URL: s.urlFor(r, token)})
}
// ResetSubscription handles POST /v1/me/subscription/reset — rotates the token.
func (s *SubscriptionAPI) ResetSubscription(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFromContext(r.Context())
if !ok {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
token, err := newSubToken()
if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
if _, err := s.db.ExecContext(r.Context(),
`UPDATE users SET sub_token = ? WHERE id = ? AND status = 'active'`, token, uid); err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
writeJSON(w, http.StatusOK, subscriptionResponse{URL: s.urlFor(r, token)})
}
// ServeSub handles GET /sub/{token} — public, no JWT. Renders the sing-box client
// config for the user owning the token, against the first active node.
func (s *SubscriptionAPI) ServeSub(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
return
}
var dpUUID string
err := s.db.QueryRowContext(r.Context(),
`SELECT dp_uuid FROM users WHERE sub_token = ? AND status = 'active'`, token).Scan(&dpUUID)
if err == sql.ErrNoRows {
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
return
} else if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
node, apiErr := s.firstActiveNode(r.Context())
if apiErr != nil {
apierr.WriteJSON(w, statusFor(apiErr), apiErr)
return
}
// 订阅链接暂不开国内分流(默认 opts);后续可按需加 query/偏好。
cfg, err := BuildClientConfig(node, dpUUID, s.deriveKey, ClientConfigOpts{})
if err != nil {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(cfg)
}
// ── helpers ──────────────────────────────────────────────────────────────────
// ensureToken returns the user's sub_token, generating and persisting one on
// first access.
func (s *SubscriptionAPI) ensureToken(ctx context.Context, uid int64) (string, *apierr.Error) {
var token sql.NullString
if err := s.db.QueryRowContext(ctx,
`SELECT sub_token FROM users WHERE id = ? AND status = 'active'`, uid).Scan(&token); err == sql.ErrNoRows {
return "", apierr.ErrNotFound
} else if err != nil {
return "", apierr.ErrInternal
}
if token.Valid && token.String != "" {
return token.String, nil
}
t, err := newSubToken()
if err != nil {
return "", apierr.ErrInternal
}
if _, err := s.db.ExecContext(ctx,
`UPDATE users SET sub_token = ? WHERE id = ?`, t, uid); err != nil {
return "", apierr.ErrInternal
}
return t, nil
}
func (s *SubscriptionAPI) firstActiveNode(ctx context.Context) (*nodes.NodeRow, *apierr.Error) {
uuids, err := s.nodes.ActiveNodeUUIDs(ctx)
if err != nil || len(uuids) == 0 {
return nil, apierr.ErrNotFound
}
node, err := s.nodes.NodeByUUID(ctx, uuids[0])
if err != nil || node == nil {
return nil, apierr.ErrNotFound
}
return node, nil
}
func (s *SubscriptionAPI) urlFor(r *http.Request, token string) string {
base := s.subBase
if base == "" {
scheme := "https"
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" {
scheme = "http"
}
base = scheme + "://" + r.Host
}
return base + "/sub/" + token
}
// newSubToken returns a random UUIDv4 string (fits users.sub_token CHAR(36)).
func newSubToken() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// statusFor maps the small set of apierr values used here to HTTP status codes.
func statusFor(e *apierr.Error) int {
switch e {
case apierr.ErrNotFound:
return http.StatusNotFound
case apierr.ErrUnauthorized:
return http.StatusUnauthorized
default:
return http.StatusInternalServerError
}
}