Files
NexusGate/panel-go/handlers.go
T
aaaamoooo c6f6b9dbea feat(panel-go): monthly traffic reset for customers
- trafficResetDay/Period/At on the shared customer record (omitempty,
  inert for the Node panel, which preserves unknown fields)
- housekeeping applies due resets before quota evaluation: period-key
  idempotency, short-month clamping (31st fires Feb 28), missed-period
  catch-up after panel downtime, traffic suspensions auto-resume
- first sight of a schedule only arms the period marker, never zeroes;
  API create/PATCH accept trafficResetDay (0=off) and re-arm on change
- UI: reset-day input + last-reset display in the customer form
- tests: calendar math, apply pass, API lifecycle, persistence;
  full Go/Node suites green, difftest formal 50/50
2026-10-21 03:16:30 +08:00

2635 lines
80 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
// All HTTP handlers + the server.js internal helpers (normalize/public
// shapers, auth guards, login rate limiting, subscription endpoint).
// Route order, status codes, error codes and message text follow server.js
// line by line.
import (
"crypto/subtle"
"encoding/json"
"fmt"
"math"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// ---------- app state ----------
type loginAttempt struct {
Count int
BlockedUntil int64 // epoch ms
}
type App struct {
store *Store
orchestrator *Orchestrator
sessMu sync.RWMutex
sessions *SessionManager
loginMu sync.Mutex
loginAttempts map[string]*loginAttempt
}
func NewApp(store *Store, sessions *SessionManager, orch *Orchestrator) *App {
return &App{
store: store,
orchestrator: orch,
sessions: sessions,
loginAttempts: map[string]*loginAttempt{},
}
}
func (a *App) getSessions() *SessionManager {
a.sessMu.RLock()
defer a.sessMu.RUnlock()
return a.sessions
}
// replaceSessions swaps the whole SessionManager (account PATCH / restore),
// which invalidates every existing session, as in server.js.
func (a *App) replaceSessions(sm *SessionManager) {
a.sessMu.Lock()
defer a.sessMu.Unlock()
a.sessions = sm
}
func (a *App) sessionHours() float64 {
h := a.store.Snapshot().Settings.SessionHours
if h == 0 || math.IsNaN(h) {
return 12
}
return h
}
// ---------- normalize ----------
// The JS pattern anchors the total length with a lookahead
// (?=.{1,253}$), which RE2 lacks; the length is checked in code instead.
var tlsDomainRe = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`)
func validTLSDomain(s string) bool {
return len(s) >= 1 && len(s) <= 253 && tlsDomainRe.MatchString(s)
}
var certDomainRe = regexp.MustCompile(`^(?:[a-z0-9-]+\.)+[a-z]{2,63}$`)
func numOr(v float64, fallback float64) float64 {
if v == 0 || math.IsNaN(v) {
return fallback
}
return v
}
// NormalizeServer is normalizeServer. Presence semantics come from map key
// existence, exactly like JS `'field' in body`.
func NormalizeServer(body map[string]any, current *Server) (*Server, error) {
var next Server
if current != nil {
next = *current
}
if _, ok := body["name"]; ok {
name, err := RequiredText(body["name"], "服务器名称", 120)
if err != nil {
return nil, err
}
next.Name = name
}
if v, ok := body["role"]; ok {
if s, isStr := v.(string); isStr && (s == "relay" || s == "exit" || s == "hybrid") {
next.Role = s
} else if next.Role == "" {
next.Role = "hybrid"
}
} else if next.Role == "" {
next.Role = "hybrid"
}
if _, ok := body["region"]; ok {
next.Region = CleanText(body["region"], 80)
}
if _, ok := body["publicAddress"]; ok {
addr, err := RequiredText(body["publicAddress"], "公网地址", 255)
if err != nil {
return nil, err
}
next.PublicAddress = addr
}
if _, ok := body["publicAddressV6"]; ok {
next.PublicAddressV6 = CleanText(body["publicAddressV6"], 255)
}
if _, ok := body["tlsDomain"]; ok {
next.TLSDomain = strings.ToLower(CleanText(body["tlsDomain"], 253))
}
// Port range: JS computes Number(body value) when the key is present
// (Number(null) is 0), else Number(current || default). Validation is on
// the float values (integer/range checks), then stored as ints.
portValue := func(key string, currentVal, def int) (float64, bool) {
if v, ok := body[key]; ok {
if v == nil {
return 0, true
}
return jsNumber(v)
}
if currentVal == 0 {
return float64(def), true
}
return float64(currentVal), true
}
startF, startOk := portValue("portRangeStart", next.PortRangeStart, 20000)
endF, endOk := portValue("portRangeEnd", next.PortRangeEnd, 50000)
next.PortRangeStart = int(startF)
next.PortRangeEnd = int(endF)
if _, ok := body["labels"]; ok {
next.Labels = sliceStrings(asIds(body["labels"]), 20)
} else if next.Labels == nil {
next.Labels = []string{}
}
if !startOk || !endOk || startF != math.Trunc(startF) || endF != math.Trunc(endF) ||
startF < 1024 || endF > 65535 || startF > endF {
return nil, httpErr(400, "端口范围必须是 1024–65535 之间的整数")
}
if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) {
return nil, httpErr(400, "TLS 域名必须是有效的公网域名")
}
return &next, nil
}
// NormalizeNode is normalizeNode, including its port-(re)selection rules.
func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error) {
var next Node
if current != nil {
next = *current
}
topology := next.Topology
if _, ok := body["topology"]; ok {
if s, _ := body["topology"].(string); s == "direct" {
topology = "direct"
} else {
topology = "forward"
}
} else if topology == "" {
topology = "forward"
}
direct := topology == "direct"
protocol := next.Protocol
if _, ok := body["protocol"]; ok {
protocol = CleanText(body["protocol"], 64)
}
serverID := next.ServerID
if _, ok := body["serverId"]; ok {
id, err := RequiredText(body["serverId"], "服务器", 120)
if err != nil {
return nil, err
}
serverID = id
}
var server *Server
for _, item := range data.Servers {
if item.ID == serverID {
server = item
break
}
}
if server == nil {
return nil, httpErr(404, "服务器不存在")
}
var exitServerID *string
if direct {
exitServerID = nil
} else if _, ok := body["exitServerId"]; ok {
id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120)
if err != nil {
return nil, err
}
exitServerID = &id
} else {
exitServerID = next.ExitServerID
}
if !direct {
found := false
for _, item := range data.Servers {
if item.ID == strVal(exitServerID) {
found = true
break
}
}
if !found {
return nil, httpErr(404, "出口服务器不存在")
}
}
if _, ok := body["name"]; ok {
name, err := RequiredText(body["name"], "节点名称", 120)
if err != nil {
return nil, err
}
next.Name = name
}
next.ServerID = serverID
next.Protocol = protocol
next.Topology = topology
next.ExitServerID = exitServerID
if direct {
next.ExitProtocol = nil
} else if _, ok := body["exitProtocol"]; ok {
s := CleanText(body["exitProtocol"], 64)
next.ExitProtocol = &s
}
if v, ok := body["networkMode"]; ok {
if s, isStr := v.(string); isStr && (s == "ipv4" || s == "ipv6" || s == "dual") {
next.NetworkMode = s
} else if next.NetworkMode == "" {
next.NetworkMode = "ipv4"
}
} else if next.NetworkMode == "" {
next.NetworkMode = "ipv4"
}
if _, ok := body["realityServerName"]; ok {
next.RealityServerName = CleanText(body["realityServerName"], 255)
} else if next.RealityServerName == "" {
next.RealityServerName = "www.tesla.com"
}
if _, ok := body["realityDestPort"]; ok {
f, valid := jsNumber(body["realityDestPort"])
if !valid || f == 0 {
next.RealityDestPort = 443
} else {
next.RealityDestPort = int(f)
}
} else {
next.RealityDestPort = int(numOr(float64(next.RealityDestPort), 443))
}
portMode := next.PortMode
if _, ok := body["portMode"]; ok {
if s, _ := body["portMode"].(string); s == "fixed" {
portMode = "fixed"
} else {
portMode = "random"
}
} else if portMode == "" {
portMode = "random"
}
next.PortMode = portMode
currentPortMode := "random"
var currentID string
var currentPort int
if current != nil {
if current.PortMode != "" {
currentPortMode = current.PortMode
}
currentID = current.ID
currentPort = current.Port
}
_, portModeInBody := body["portMode"]
_, portInBody := body["port"]
portModeChanged := portModeInBody && portMode != currentPortMode
_, serverIDInBody := body["serverId"]
if portModeChanged || portInBody || serverIDInBody || currentPort == 0 {
var requested any
if portInBody {
requested = body["port"]
} else if current != nil {
requested = json.Number(fmt.Sprint(currentPort))
}
port, err := SelectPort(data, server, portMode, requested, currentID)
if err != nil {
return nil, err
}
next.Port = port
}
if !direct {
exitPortMode := strVal(next.ExitPortMode)
if _, ok := body["exitPortMode"]; ok {
if s, _ := body["exitPortMode"].(string); s == "fixed" {
exitPortMode = "fixed"
} else {
exitPortMode = "random"
}
} else if exitPortMode == "" {
exitPortMode = "random"
}
next.ExitPortMode = &exitPortMode
var exitServer *Server
for _, item := range data.Servers {
if item.ID == strVal(exitServerID) {
exitServer = item
break
}
}
currentExitPortMode := "random"
var currentExitPort *int
if current != nil {
if strVal(current.ExitPortMode) != "" {
currentExitPortMode = strVal(current.ExitPortMode)
}
currentExitPort = current.ExitPort
}
_, exitPortModeInBody := body["exitPortMode"]
_, exitPortInBody := body["exitPort"]
exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode
_, exitServerIDInBody := body["exitServerId"]
if exitPortModeChanged || exitPortInBody || exitServerIDInBody || currentExitPort == nil {
var requested any
if exitPortInBody {
requested = body["exitPort"]
} else if currentExitPort != nil {
requested = json.Number(fmt.Sprint(*currentExitPort))
}
port, err := SelectPort(data, exitServer, exitPortMode, requested, currentID)
if err != nil {
return nil, err
}
next.ExitPort = &port
}
} else {
next.ExitPortMode = nil
next.ExitPort = nil
}
if direct {
if herr := ValidateEntryProtocol(protocol); herr != nil {
return nil, herr
}
} else {
if herr := ValidateProtocolPair(protocol, strVal(next.ExitProtocol)); herr != nil {
return nil, herr
}
}
if IsRealityProtocol(protocol) && next.RealityServerName == "" {
return nil, httpErr(400, "Reality SNI 不能为空")
}
if IsRealityProtocol(protocol) && (next.RealityDestPort < 1 || next.RealityDestPort > 65535) {
return nil, httpErr(400, "Reality 目标端口无效")
}
return &next, nil
}
// ---------- public shapers ----------
// PublicNode is publicNode: credentials stripped, error redacted. Built via
// the record's MarshalJSON so unknown (Extra) fields pass through.
func PublicNode(item *Node) map[string]any {
out := recordToMap(item)
delete(out, "exitCredentials")
delete(out, "ssServerKey")
if s, ok := out["error"].(string); ok && s != "" {
out["error"] = RedactSecrets(s)
}
return out
}
// PublicServer is publicServer: engine detail redacted, plus agentVersion
// and pendingCleanup computed against data.
func PublicServer(item *Server, data *Data) map[string]any {
out := recordToMap(item)
if eng, ok := out["engine"].(map[string]any); ok && eng != nil {
copied := map[string]any{}
for k, v := range eng {
copied[k] = v
}
if d, isStr := copied["detail"].(string); isStr {
copied["detail"] = RedactSecrets(d)
}
out["engine"] = copied
}
var version any
for _, agent := range data.Agents {
if agent.ServerID == item.ID && agent.Status == "active" {
version = agent.Version
break
}
}
out["agentVersion"] = version
pending := 0
for _, n := range data.Nodes {
if (n.ServerID == item.ID || strVal(n.ExitServerID) == item.ID) && n.Status == "removing" {
pending++
}
}
out["pendingCleanup"] = pending
return out
}
// PublicCustomer is publicCustomer plus observed/subscription counters.
func PublicCustomer(item *Customer, data *Data) map[string]any {
out := recordToMap(item)
out["observedIpCount"] = len(ObservedIPs(data, item.ID, time.Now()))
out["subscriptionClientCount"] = len(ActiveClients(data, item.ID, time.Now()))
return out
}
// CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" /
// "ip_limit", or "" for none (JS null).
func CustomerBlockReason(data *Data, customer *Customer) string {
if customer.ExpiresAt != nil {
if ms, ok := ParseISOMs(strVal(customer.ExpiresAt)); ok && ms <= time.Now().UnixMilli() {
return "expired"
}
}
if customer.TrafficLimitBytes > 0 && float64(customer.UsedBytes) >= customer.TrafficLimitBytes {
return "traffic_limit"
}
if customer.IPLimit > 0 {
seen := map[string]bool{}
for _, obs := range ObservedIPs(data, customer.ID, time.Now()) {
seen[obs.IP] = true
}
if int64(len(seen)) > customer.IPLimit {
return "ip_limit"
}
}
return ""
}
// GetIP is getIp: first x-forwarded-for segment, else the peer address.
func GetIP(r *http.Request) string {
first := strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]
if first == "" {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
first = host
}
return CleanTextStr(first, 80)
}
// ---------- admin auth ----------
type adminAuth struct {
Session *Session
User *User
}
// CurrentAdmin is currentAdmin.
func (a *App) CurrentAdmin(r *http.Request) *adminAuth {
session := a.getSessions().Get(r)
if session == nil {
return nil
}
data := a.store.Snapshot()
for _, user := range data.Users {
if user.ID == session.UserID && user.Status == "active" {
return &adminAuth{Session: session, User: user}
}
}
return nil
}
// RequireAdmin is requireAdmin: sends 401/403 itself and returns nil.
func (a *App) RequireAdmin(w http.ResponseWriter, r *http.Request, csrf bool) *adminAuth {
auth := a.CurrentAdmin(r)
if auth == nil {
SendError(w, 401, "请先登录", "unauthorized")
return nil
}
if csrf && r.Header.Get("X-CSRF-Token") != auth.Session.CSRF {
SendError(w, 403, "请求校验失败,请刷新页面后重试", "csrf_failed")
return nil
}
return auth
}
// FindAgentByBearer is findAgentByBearer: fingerprint fast path, then a
// full VerifySecret scan over legacy agents without a fingerprint.
func (a *App) FindAgentByBearer(r *http.Request) *Agent {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return nil
}
token := header[len("Bearer "):]
fingerprint := TokenFingerprint(token)
data := a.store.Snapshot()
var fast *Agent
for _, agent := range data.Agents {
if agent.Status == "active" && agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint {
fast = agent
break
}
}
if fast != nil && VerifySecret(token, fast.KeyHash) {
return fast
}
for _, agent := range data.Agents {
if agent.Status == "active" && agent.KeyFingerprint == nil && VerifySecret(token, agent.KeyHash) {
return agent
}
}
return nil
}
func clearSessionCookie() map[string]string {
return map[string]string{"Set-Cookie": "ng_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"}
}
// ---------- auth routes ----------
func (a *App) handleAuth(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) {
if r.Method == http.MethodPost && pathname == "/api/auth/login" {
ip := GetIP(r)
now := time.Now().UnixMilli()
a.loginMu.Lock()
record := a.loginAttempts[ip]
blocked := record != nil && record.BlockedUntil > now
a.loginMu.Unlock()
if blocked {
SendError(w, 429, "尝试次数过多,请稍后再试", "rate_limited")
return true, nil
}
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
username := CleanText(body["username"], 64)
data := a.store.Snapshot()
var user *User
for _, item := range data.Users {
if item.Username == username && item.Status == "active" {
user = item
break
}
}
if user == nil || !VerifySecret(secretString(body["password"]), user.PasswordHash) {
a.loginMu.Lock()
rec := a.loginAttempts[ip]
if rec == nil {
rec = &loginAttempt{}
a.loginAttempts[ip] = rec
}
rec.Count++
if rec.Count >= 6 {
rec.BlockedUntil = now + 15*60*1000
rec.Count = 0
}
a.loginMu.Unlock()
SendError(w, 401, "账号或密码不正确", "invalid_credentials")
return true, nil
}
a.loginMu.Lock()
delete(a.loginAttempts, ip)
a.loginMu.Unlock()
sessions := a.getSessions()
session := sessions.Create(user.ID)
secure := ""
if cookieSecure {
secure = "; Secure"
}
cookie := fmt.Sprintf("ng_session=%s; HttpOnly; SameSite=Strict; Path=/; Max-Age=%d%s",
session.ID, int64(sessions.TTL/time.Second), secure)
SendJSON(w, 200, map[string]any{
"user": map[string]any{"id": user.ID, "username": user.Username, "role": user.Role},
"csrf": session.CSRF,
}, map[string]string{"Set-Cookie": cookie})
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/auth/logout" {
auth := a.RequireAdmin(w, r, true)
if auth == nil {
return true, nil
}
a.getSessions().Delete(r)
SendJSON(w, 200, map[string]any{"ok": true}, clearSessionCookie())
return true, nil
}
if r.Method == http.MethodGet && pathname == "/api/session" {
auth := a.CurrentAdmin(r)
if auth == nil {
SendJSON(w, 200, map[string]any{"authenticated": false}, nil)
} else {
SendJSON(w, 200, map[string]any{
"authenticated": true,
"csrf": auth.Session.CSRF,
"user": map[string]any{"id": auth.User.ID, "username": auth.User.Username, "role": auth.User.Role},
}, nil)
}
return true, nil
}
return false, nil
}
// ---------- agent routes ----------
// rawSystemValue mirrors `body.system || {}` for the RawMessage system blob.
func rawSystemValue(v any) json.RawMessage {
switch t := v.(type) {
case nil:
return json.RawMessage("{}")
case bool:
if !t {
return json.RawMessage("{}")
}
case string:
if t == "" {
return json.RawMessage("{}")
}
case json.Number:
if t.String() == "0" {
return json.RawMessage("{}")
}
}
raw, err := marshalJSON(v)
if err != nil {
return json.RawMessage("{}")
}
return raw
}
func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) {
if r.Method == http.MethodPost && pathname == "/api/agent/enroll" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
token := JSString(body["token"])
data := a.store.Snapshot()
var enrollment *EnrollmentToken
nowMs := time.Now().UnixMilli()
for _, item := range data.EnrollmentTokens {
if item.UsedAt != nil {
continue
}
expMs, ok := ParseISOMs(item.ExpiresAt)
if !ok || expMs <= nowMs {
continue
}
if VerifySecret(token, item.TokenHash) {
enrollment = item
break
}
}
if enrollment == nil {
SendError(w, 401, "注册令牌无效或已过期", "invalid_enrollment")
return true, nil
}
agentKey := RandomToken(40)
resultAny, err := a.store.Transaction(func(d *Data) (any, error) {
var target *EnrollmentToken
for _, item := range d.EnrollmentTokens {
if item.ID == enrollment.ID {
target = item
break
}
}
if target == nil || target.UsedAt != nil {
return nil, httpErr(409, "注册令牌已被使用")
}
now := NowISO()
target.UsedAt = &now
var server *Server
for _, item := range d.Servers {
if item.ID == target.ServerID {
server = item
break
}
}
if server == nil {
return nil, httpErr(404, "目标服务器不存在")
}
for _, old := range d.Agents {
if old.ServerID == server.ID {
old.Status = "revoked"
}
}
fp := TokenFingerprint(agentKey)
agent := &Agent{
ID: NewID("agt"),
ServerID: server.ID,
KeyHash: HashSecret(agentKey),
KeyFingerprint: &fp,
Status: "active",
Hostname: CleanText(body["hostname"], 128),
Version: CleanText(body["version"], 32),
CreatedAt: now,
LastSeenAt: now,
}
d.Agents = append(d.Agents, agent)
server.Status = "online"
server.LastSeenAt = &now
server.System = rawSystemValue(body["system"])
server.UpdatedAt = now
Audit(d, "agent:"+agent.ID, "enroll_agent", server.ID, nil)
return map[string]any{"agentId": agent.ID, "serverId": server.ID, "serverName": server.Name}, nil
})
if err != nil {
return true, err
}
result := resultAny.(map[string]any)
reconciled := 0
if n, rerr := a.orchestrator.ReconcileServer(result["serverId"].(string), "agent:"+result["agentId"].(string)); rerr != nil {
fmt.Fprintf(os.Stderr, "Agent reconciliation failed: %v\n", rerr)
} else {
reconciled = n
}
SendJSON(w, 201, map[string]any{
"agentId": result["agentId"],
"serverId": result["serverId"],
"serverName": result["serverName"],
"agentKey": agentKey,
"reconciled": reconciled,
}, nil)
return true, nil
}
if !strings.HasPrefix(pathname, "/api/agent/") {
return false, nil
}
agent := a.FindAgentByBearer(r)
if agent == nil {
SendError(w, 401, "Agent 身份校验失败", "invalid_agent")
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/agent/heartbeat" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
a.store.Transient(func(d *Data) {
now := NowISO()
var current *Agent
for _, item := range d.Agents {
if item.ID == agent.ID {
current = item
break
}
}
var server *Server
for _, item := range d.Servers {
if item.ID == agent.ServerID {
server = item
break
}
}
if current != nil {
current.LastSeenAt = now
version := JSString(body["version"])
if version == "" {
version = current.Version
}
current.Version = CleanTextStr(version, 32)
}
if server != nil {
server.Status = "online"
server.LastSeenAt = &now
if sys, ok := body["system"]; ok && jsTruthy(sys) {
server.System = rawSystemValue(sys)
} else if len(server.System) == 0 {
server.System = json.RawMessage("{}")
}
server.EnginePresent = true
if eng, ok := body["engine"].(map[string]any); ok {
status, _ := eng["status"].(string)
if status == "ready" || status == "error" {
certs := []string{}
if arr, isArr := eng["certificates"].([]any); isArr {
for _, v := range arr[:min(50, len(arr))] {
c := strings.ToLower(CleanText(v, 253))
if certDomainRe.MatchString(c) {
certs = append(certs, c)
}
}
}
singBoxStatus := "inactive"
if s, _ := eng["singBoxStatus"].(string); s == "ready" {
singBoxStatus = "ready"
}
installed, _ := eng["singBoxInstalled"].(bool)
server.Engine = &EngineState{
Status: status,
Detail: RedactSecrets(CleanText(eng["detail"], 400)),
SingBoxInstalled: installed,
Certificates: certs,
SingBoxStatus: singBoxStatus,
At: now,
}
}
}
}
})
if arr, ok := body["resources"].([]any); ok {
resources := []string{}
for _, v := range arr {
if s, isStr := v.(string); isStr {
resources = append(resources, s)
}
}
if _, rerr := a.orchestrator.ReconcileFromHeartbeat(agent.ServerID, resources); rerr != nil {
fmt.Fprintf(os.Stderr, "Heartbeat reconciliation failed: %v\n", rerr)
}
}
SendJSON(w, 200, map[string]any{"ok": true, "serverTime": NowISO()}, nil)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/agent/tls-domain" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
domainText, err := RequiredText(body["domain"], "节点 TLS 域名", 253)
if err != nil {
return true, err
}
domain := strings.ToLower(domainText)
_, err = a.store.Transaction(func(d *Data) (any, error) {
var server *Server
for _, item := range d.Servers {
if item.ID == agent.ServerID {
server = item
break
}
}
if server == nil {
return nil, httpErr(404, "设备不存在")
}
if _, verr := NormalizeServer(map[string]any{"tlsDomain": domain}, server); verr != nil {
return nil, verr
}
if server.TLSDomain != "" && server.TLSDomain != domain {
for _, node := range d.Nodes {
if node.ServerID != server.ID {
continue
}
if node.Protocol != "anytls" && node.Protocol != "hysteria2" && node.Protocol != "vless-ws-tls" {
continue
}
if node.Status == "active" || node.Status == "deploying" {
return nil, httpErr(409, "设备已有使用其他域名的 TLS 节点;请先处理现有节点")
}
}
}
if server.TLSDomain != domain {
server.TLSDomain = domain
server.UpdatedAt = NowISO()
Audit(d, "agent:"+agent.ID, "set_tls_domain", server.ID, map[string]any{"domain": domain})
}
if server.Engine == nil {
server.Engine = &EngineState{}
}
found := false
for _, c := range server.Engine.Certificates {
if c == domain {
found = true
break
}
}
if !found {
server.Engine.Certificates = append(server.Engine.Certificates, domain)
}
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true, "domain": domain}, nil)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/agent/poll" {
data := a.store.Snapshot()
hasQueued := false
for _, job := range data.Jobs {
if job.ServerID == agent.ServerID && job.Status == "queued" {
hasQueued = true
break
}
}
if !hasQueued {
SendJSON(w, 200, map[string]any{"job": nil}, nil)
return true, nil
}
pickedAny, err := a.store.Transaction(func(d *Data) (any, error) {
for _, job := range d.Jobs {
if job.ServerID == agent.ServerID && job.Status == "queued" {
now := NowISO()
job.Status = "running"
job.Attempts++
job.StartedAt = &now
job.UpdatedAt = now
lease := ISOFromTime(time.Now().Add(5 * time.Minute))
job.LeaseUntil = &lease
return cloneJSONValue(job), nil
}
}
return nil, ErrSkip
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"job": pickedAny}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/agent/jobs/:id/complete", pathname); ok && r.Method == http.MethodPost {
data := a.store.Snapshot()
var job *Job
for _, item := range data.Jobs {
if item.ID == params["id"] && item.ServerID == agent.ServerID {
job = item
break
}
}
if job == nil {
SendError(w, 404, "任务不存在", "not_found")
return true, nil
}
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
artifacts := map[string]json.RawMessage{}
if resMap, ok := body["result"].(map[string]any); ok {
if artMap, ok := resMap["artifacts"].(map[string]any); ok {
for k, v := range artMap {
raw, merr := marshalJSON(v)
if merr == nil {
artifacts[k] = raw
}
}
}
}
var errPtr *string
if s := JSString(body["error"]); s != "" {
errPtr = &s
}
updated, err := a.orchestrator.CompleteJob(job.ID, jsTruthy(body["success"]), CompleteJobResult{Artifacts: artifacts}, errPtr)
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"job": updated}, nil)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/agent/usage" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
epoch := CleanText(body["epoch"], 80)
samples := []UsageSample{}
if arr, ok := body["samples"].([]any); ok {
for _, item := range arr {
m, isMap := item.(map[string]any)
if !isMap {
continue
}
sampleEpoch := epoch
if s, isStr := m["epoch"].(string); isStr {
sampleEpoch = truncateRunes(s, 80)
}
sample := UsageSample{
Kind: JSString(m["kind"]),
ID: JSString(m["id"]),
Uplink: toJSONNumber(m["uplink"]),
Downlink: toJSONNumber(m["downlink"]),
Epoch: &sampleEpoch,
}
samples = append(samples, sample)
}
}
errText := RedactSecrets(CleanText(body["error"], 240))
if err := a.orchestrator.RecordUsage(agent.ServerID, samples, errText, epoch); err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true}, nil)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/agent/observations" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
_, err = a.store.Transaction(func(d *Data) (any, error) {
ttlMinutes := d.Settings.ObservationTtlMinutes
if ttlMinutes == 0 {
ttlMinutes = 10
}
cutoff := time.Now().UnixMilli() - int64(ttlMinutes*60000)
kept := d.Observations[:0]
for _, item := range d.Observations {
if ms, ok := ParseISOMs(item.LastSeenAt); ok && ms > cutoff {
kept = append(kept, item)
}
}
d.Observations = kept
affected := []string{}
affectedSet := map[string]bool{}
samples, _ := body["observations"].([]any)
if len(samples) > 1000 {
samples = samples[:1000]
}
now := NowISO()
for _, rawSample := range samples {
m, isMap := rawSample.(map[string]any)
if !isMap {
continue
}
customerID := CleanText(m["customerId"], 100)
ip := CleanText(m["ip"], 64)
ip = strings.TrimPrefix(ip, "[")
ip = strings.TrimSuffix(ip, "]")
if net.ParseIP(ip) == nil {
continue
}
valid := false
for _, nc := range d.NodeClients {
if nc.CustomerID != customerID || nc.Status != "active" {
continue
}
for _, node := range d.Nodes {
if node.ID == nc.NodeID && node.ServerID == agent.ServerID && node.Status == "active" {
valid = true
break
}
}
if valid {
break
}
}
if !valid {
continue
}
var existing *Observation
for _, item := range d.Observations {
if item.CustomerID == customerID && item.IP == ip {
existing = item
break
}
}
if existing != nil {
existing.LastSeenAt = now
} else {
d.Observations = append(d.Observations, &Observation{
ID: NewID("obs"),
CustomerID: customerID,
IP: ip,
FirstSeen: now,
LastSeenAt: now,
})
}
if !affectedSet[customerID] {
affectedSet[customerID] = true
affected = append(affected, customerID)
}
}
for _, customerID := range affected {
var customer *Customer
for _, item := range d.Customers {
if item.ID == customerID {
customer = item
break
}
}
if customer == nil || customer.Status != "active" || customer.IPLimit <= 0 {
continue
}
ips := map[string]bool{}
for _, item := range d.Observations {
if item.CustomerID == customerID {
ips[item.IP] = true
}
}
if int64(len(ips)) <= customer.IPLimit {
continue
}
reason := "ip_limit"
customer.Status = "suspended"
customer.SuspendReason = &reason
customer.SuspendReasonPresent = true
customer.UpdatedAt = NowISO()
_, _ = SuspendCustomerResources(d, customer, "agent:"+agent.ID)
Audit(d, "agent:"+agent.ID, "suspend_ip_limit", customer.ID, map[string]any{"ipCount": len(ips), "limit": customer.IPLimit})
}
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true}, nil)
return true, nil
}
return false, nil
}
// toJSONNumber normalizes a body value into a json.Number for
// UsageSample fields; the orchestrator applies the JS safe-integer
// validation (empty/invalid numbers are skipped there).
func toJSONNumber(v any) json.Number {
switch t := v.(type) {
case json.Number:
return t
case string:
return json.Number(t)
case bool:
if t {
return json.Number("1")
}
return json.Number("0")
case float64:
return json.Number(strconv.FormatFloat(t, 'g', -1, 64))
default:
return json.Number("")
}
}
// ---------- admin routes ----------
func findServer(d *Data, id string) *Server {
for _, item := range d.Servers {
if item.ID == id {
return item
}
}
return nil
}
func findCustomer(d *Data, id string) *Customer {
for _, item := range d.Customers {
if item.ID == id {
return item
}
}
return nil
}
func findNode(d *Data, id string) *Node {
for _, item := range d.Nodes {
if item.ID == id {
return item
}
}
return nil
}
func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) {
if !strings.HasPrefix(pathname, "/api/") {
return false, nil
}
changing := r.Method != http.MethodGet && r.Method != http.MethodHead
auth := a.RequireAdmin(w, r, changing)
if auth == nil {
return true, nil
}
actor := auth.User.Username
if r.Method == http.MethodGet && pathname == "/api/overview" {
data := a.store.Snapshot()
nowMs := time.Now().UnixMilli()
online := 0
for _, item := range data.Servers {
if item.Status != "online" {
continue
}
var seenMs int64
if item.LastSeenAt != nil {
if ms, ok := ParseISOMs(*item.LastSeenAt); ok {
seenMs = ms
}
}
if nowMs-seenMs < 120000 {
online++
}
}
activeNodes := 0
for _, item := range data.Nodes {
if item.Status == "active" {
activeNodes++
}
}
queuedJobs := 0
for _, item := range data.Jobs {
if item.Status == "queued" || item.Status == "running" {
queuedJobs++
}
}
activity := data.Activity
if len(activity) > 12 {
activity = activity[:12]
}
if activity == nil {
activity = []*Activity{}
}
degraded := []map[string]any{}
for _, item := range data.Nodes {
if item.Status == "failed" || item.Status == "degraded" {
degraded = append(degraded, PublicNode(item))
if len(degraded) == 10 {
break
}
}
}
SendJSON(w, 200, map[string]any{
"counts": map[string]any{
"servers": len(data.Servers),
"online": online,
"customers": len(data.Customers),
"nodes": len(data.Nodes),
"activeNodes": activeNodes,
"queuedJobs": queuedJobs,
},
"version": VERSION,
"activity": activity,
"degraded": degraded,
}, nil)
return true, nil
}
if r.Method == http.MethodGet && pathname == "/api/protocols" {
SendJSON(w, 200, map[string]any{"profiles": ProfileCatalog, "realityPresets": RealityPresets}, nil)
return true, nil
}
if r.Method == http.MethodPatch && pathname == "/api/account" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
if !VerifySecret(secretString(body["currentPassword"]), auth.User.PasswordHash) {
return true, httpErr(400, "当前密码不正确")
}
username := auth.User.Username
if _, ok := body["username"]; ok {
name, err := RequiredText(body["username"], "登录账号", 64)
if err != nil {
return true, err
}
username = name
}
newPassword := ""
if body["newPassword"] != nil {
newPassword = truncateRunes(JSString(body["newPassword"]), 256)
}
if newPassword != "" && len([]rune(newPassword)) < 10 {
return true, httpErr(400, "新密码至少需要 10 位")
}
_, err = a.store.Transaction(func(d *Data) (any, error) {
var item *User
for _, entry := range d.Users {
if entry.ID == auth.User.ID {
item = entry
break
}
}
if item == nil {
return nil, httpErr(404, "账号不存在")
}
for _, entry := range d.Users {
if entry.ID != item.ID && entry.Username == username {
return nil, httpErr(409, "该登录账号已被使用")
}
}
item.Username = username
if newPassword != "" {
item.PasswordHash = HashSecret(newPassword)
}
now := NowISO()
item.UpdatedAt = &now
Audit(d, actor, "update_account", item.ID, map[string]any{"username": username})
return nil, nil
})
if err != nil {
return true, err
}
a.replaceSessions(NewSessionManager(a.sessionHours()))
SendJSON(w, 200, map[string]any{"ok": true, "reauthenticate": true}, clearSessionCookie())
return true, nil
}
if r.Method == http.MethodGet && pathname == "/api/servers" {
data := a.store.Snapshot()
servers := []map[string]any{}
for _, item := range data.Servers {
servers = append(servers, PublicServer(item, data))
}
SendJSON(w, 200, map[string]any{"servers": servers}, nil)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/servers" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
serverAny, err := a.store.Transaction(func(d *Data) (any, error) {
next, err := NormalizeServer(body, nil)
if err != nil {
return nil, err
}
next.ID = NewID("srv")
next.Status = "pending"
next.CreatedAt = NowISO()
next.UpdatedAt = NowISO()
d.Servers = append(d.Servers, next)
Audit(d, actor, "create_server", next.ID, map[string]any{"name": next.Name})
return cloneJSONValue(next), nil
})
if err != nil {
return true, err
}
SendJSON(w, 201, map[string]any{"server": serverAny}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/servers/:id", pathname); ok && r.Method == http.MethodPatch {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
serverAny, err := a.store.Transaction(func(d *Data) (any, error) {
item := findServer(d, params["id"])
if item == nil {
return nil, httpErr(404, "服务器不存在")
}
next, err := NormalizeServer(body, item)
if err != nil {
return nil, err
}
*item = *next
item.UpdatedAt = NowISO()
Audit(d, actor, "update_server", item.ID, map[string]any{"name": item.Name})
return cloneJSONValue(item), nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"server": serverAny}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/servers/:id", pathname); ok && r.Method == http.MethodDelete {
_, err := a.store.Transaction(func(d *Data) (any, error) {
index := -1
for i, item := range d.Servers {
if item.ID == params["id"] {
index = i
break
}
}
if index < 0 {
return nil, httpErr(404, "服务器不存在")
}
var referencing []*Node
for _, item := range d.Nodes {
if item.ServerID == params["id"] || strVal(item.ExitServerID) == params["id"] {
referencing = append(referencing, item)
}
}
for _, item := range referencing {
if item.Status == "removing" {
return nil, httpErr(409, "设备仍有待清理资源。等待 Agent 上线确认清理后,再删除设备")
}
}
if len(referencing) > 0 {
return nil, httpErr(409, "设备仍被节点引用。请先删除关联节点")
}
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
for _, agent := range d.Agents {
if agent.ServerID == params["id"] {
agent.Status = "revoked"
}
}
Audit(d, actor, "delete_server", params["id"], nil)
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/servers/:id/enrollment-token", pathname); ok && r.Method == http.MethodPost {
raw := RandomToken(30)
tokenAny, err := a.store.Transaction(func(d *Data) (any, error) {
if findServer(d, params["id"]) == nil {
return nil, httpErr(404, "服务器不存在")
}
next := &EnrollmentToken{
ID: NewID("enr"),
ServerID: params["id"],
TokenHash: HashSecret(raw),
CreatedAt: NowISO(),
ExpiresAt: ISOFromTime(time.Now().Add(30 * time.Minute)),
UsedAt: nil,
}
d.EnrollmentTokens = append(d.EnrollmentTokens, next)
Audit(d, actor, "create_enrollment", params["id"], nil)
return next, nil
})
if err != nil {
return true, err
}
SendJSON(w, 201, map[string]any{"token": raw, "expiresAt": tokenAny.(*EnrollmentToken).ExpiresAt}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/servers/:id/cleanup", pathname); ok && r.Method == http.MethodPost {
countAny, err := a.store.Transaction(func(d *Data) (any, error) {
if findServer(d, params["id"]) == nil {
return nil, httpErr(404, "服务器不存在")
}
queued := 0
for _, node := range d.Nodes {
if node.ServerID != params["id"] && strVal(node.ExitServerID) != params["id"] {
continue
}
if node.Status != "removing" {
continue
}
type leg struct {
kind, resourceID string
}
legs := []leg{}
if node.ServerID == params["id"] {
legs = append(legs, leg{"node", node.ResourceID})
}
if strVal(node.ExitServerID) == params["id"] && forwardNode(node) && node.ExitResourceID != "" {
legs = append(legs, leg{"node-exit", node.ExitResourceID})
}
for _, l := range legs {
pending := false
for _, job := range d.Jobs {
if job.NodeID == node.ID && job.Action == "delete_resource" && job.Payload != nil &&
job.Payload.ResourceID == l.resourceID && (job.Status == "queued" || job.Status == "running") {
pending = true
break
}
}
if pending {
continue
}
QueueJob(d, params["id"], node.ID, "delete_resource", &JobPayload{ResourceID: l.resourceID, Kind: l.kind})
queued++
}
}
Audit(d, actor, "retry_cleanup", params["id"], map[string]any{"queued": queued})
return queued, nil
})
if err != nil {
return true, err
}
SendJSON(w, 202, map[string]any{"queued": countAny}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/servers/:id/forget", pathname); ok && r.Method == http.MethodPost {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
resultAny, err := a.store.Transaction(func(d *Data) (any, error) {
index := -1
for i, item := range d.Servers {
if item.ID == params["id"] {
index = i
break
}
}
if index < 0 {
return nil, httpErr(404, "服务器不存在")
}
server := d.Servers[index]
if JSString(body["confirm"]) != "FORGET" || JSString(body["name"]) != server.Name {
return nil, httpErr(400, "请输入准确设备名称以确认")
}
if server.Status == "online" {
return nil, httpErr(409, "在线设备不能强制遗忘,请先正常停用并清理资源")
}
var residual []*Node
for _, item := range d.Nodes {
if item.ServerID == server.ID || strVal(item.ExitServerID) == server.ID {
residual = append(residual, item)
}
}
for _, item := range residual {
if item.Status != "removing" {
return nil, httpErr(409, "仍有未停用的节点,请先删除关联节点")
}
}
residualIDs := map[string]bool{}
for _, item := range residual {
residualIDs[item.ID] = true
}
for _, item := range d.Jobs {
if residualIDs[item.NodeID] && item.Status == "running" {
return nil, httpErr(409, "设备任务尚在执行,不能强制遗忘")
}
}
jobs := d.Jobs[:0]
for _, item := range d.Jobs {
if !residualIDs[item.NodeID] {
jobs = append(jobs, item)
}
}
d.Jobs = jobs
ncs := d.NodeClients[:0]
for _, item := range d.NodeClients {
if !residualIDs[item.NodeID] {
ncs = append(ncs, item)
}
}
d.NodeClients = ncs
for _, item := range residual {
DropRealityKey(d, item)
}
nodes := d.Nodes[:0]
for _, item := range d.Nodes {
if !residualIDs[item.ID] {
nodes = append(nodes, item)
}
}
d.Nodes = nodes
for _, agent := range d.Agents {
if agent.ServerID == server.ID {
agent.Status = "revoked"
}
}
d.Servers = append(d.Servers[:index], d.Servers[index+1:]...)
Audit(d, actor, "force_forget_server", server.ID, map[string]any{"name": server.Name, "unconfirmedResources": len(residual)})
return map[string]any{"unconfirmedResources": len(residual)}, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true, "unconfirmedResources": resultAny.(map[string]any)["unconfirmedResources"]}, nil)
return true, nil
}
if r.Method == http.MethodGet && pathname == "/api/customers" {
data := a.store.Snapshot()
customers := []map[string]any{}
for _, item := range data.Customers {
customers = append(customers, PublicCustomer(item, data))
}
SendJSON(w, 200, map[string]any{"customers": customers}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodGet {
data := a.store.Snapshot()
customer := findCustomer(data, params["id"])
if customer == nil {
return true, httpErr(404, "客户不存在")
}
observed := []map[string]any{}
for _, obs := range ObservedIPs(data, customer.ID, time.Now()) {
observed = append(observed, map[string]any{"ip": obs.IP, "firstSeenAt": obs.FirstSeen, "lastSeenAt": obs.LastSeenAt})
}
var events []*SubscriptionAccess
for _, item := range data.SubscriptionAccess {
if item.CustomerID == customer.ID {
events = append(events, item)
}
}
if len(events) > 100 {
events = events[len(events)-100:]
}
eventList := []map[string]any{}
for i := len(events) - 1; i >= 0; i-- {
m := recordToMap(events[i])
delete(m, "clientKey")
eventList = append(eventList, m)
}
SendJSON(w, 200, map[string]any{
"limits": map[string]any{"ip": customer.IPLimit, "subscriptionClients": customer.DeviceLimit},
"observedIps": observed,
"subscriptionClients": len(ActiveClients(data, customer.ID, time.Now())),
"events": eventList,
}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodDelete {
_, err := a.store.Transaction(func(d *Data) (any, error) {
if findCustomer(d, params["id"]) == nil {
return nil, httpErr(404, "客户不存在")
}
access := d.SubscriptionAccess[:0]
for _, item := range d.SubscriptionAccess {
if item.CustomerID != params["id"] {
access = append(access, item)
}
}
d.SubscriptionAccess = access
clients := d.SubscriptionClients[:0]
for _, item := range d.SubscriptionClients {
if item.CustomerID != params["id"] {
clients = append(clients, item)
}
}
d.SubscriptionClients = clients
Audit(d, actor, "reset_subscription_access", params["id"], nil)
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true}, nil)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/customers" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
name, err := RequiredText(body["name"], "客户名称", 120)
if err != nil {
return nil, err
}
traffic, err := NonNegativeNumber(body["trafficLimitBytes"], "流量上限", false)
if err != nil {
return nil, err
}
expires, err := OptionalISO(body["expiresAt"], "到期日期")
if err != nil {
return nil, err
}
ipLimit, err := NonNegativeNumber(body["ipLimit"], "IP 上限", true)
if err != nil {
return nil, err
}
deviceLimit, err := NonNegativeNumber(body["deviceLimit"], "设备上限", true)
if err != nil {
return nil, err
}
resetDay, err := ParseResetDay(body["trafficResetDay"])
if err != nil {
return nil, err
}
next := &Customer{
ID: NewID("cus"),
SubscriptionToken: RandomToken(32),
Name: name,
Group: CleanText(body["group"], 80),
Status: "active",
TrafficLimitBytes: traffic,
UsedBytes: 0,
UsedUplinkBytes: 0,
UsedDownlinkBytes: 0,
ExpiresAt: expires,
IPLimit: int64(ipLimit),
DeviceLimit: int64(deviceLimit),
TrafficResetDay: resetDay,
Tags: sliceStrings(asIds(body["tags"]), 20),
Notes: CleanText(body["notes"], 1000),
CreatedAt: NowISO(),
UpdatedAt: NowISO(),
}
// Arm the monthly-reset schedule: it starts with the next
// reset instant, it never fires retroactively at creation.
ArmTrafficReset(next, time.Now())
d.Customers = append(d.Customers, next)
Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name})
return cloneJSONValue(next), nil
})
if err != nil {
return true, err
}
SendJSON(w, 201, map[string]any{"customer": customerAny}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id", pathname); ok && r.Method == http.MethodPatch {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
item := findCustomer(d, params["id"])
if item == nil {
return nil, httpErr(404, "客户不存在")
}
wasActive := item.Status == "active"
previousReason := strVal(item.SuspendReason)
if _, ok := body["name"]; ok {
name, err := RequiredText(body["name"], "客户名称", 120)
if err != nil {
return nil, err
}
item.Name = name
}
if _, ok := body["group"]; ok {
item.Group = CleanText(body["group"], 80)
}
if _, ok := body["notes"]; ok {
item.Notes = CleanText(body["notes"], 1000)
}
if _, ok := body["trafficLimitBytes"]; ok {
v, err := NonNegativeNumber(body["trafficLimitBytes"], "流量上限", false)
if err != nil {
return nil, err
}
item.TrafficLimitBytes = v
}
if _, ok := body["ipLimit"]; ok {
v, err := NonNegativeNumber(body["ipLimit"], "IP 上限", true)
if err != nil {
return nil, err
}
item.IPLimit = int64(v)
}
if _, ok := body["deviceLimit"]; ok {
v, err := NonNegativeNumber(body["deviceLimit"], "设备上限", true)
if err != nil {
return nil, err
}
item.DeviceLimit = int64(v)
}
if _, ok := body["expiresAt"]; ok {
v, err := OptionalISO(body["expiresAt"], "到期日期")
if err != nil {
return nil, err
}
item.ExpiresAt = v
}
if _, ok := body["tags"]; ok {
item.Tags = sliceStrings(asIds(body["tags"]), 20)
}
if _, ok := body["trafficResetDay"]; ok {
v, err := ParseResetDay(body["trafficResetDay"])
if err != nil {
return nil, err
}
if v != item.TrafficResetDay {
item.TrafficResetDay = v
// A changed schedule is re-armed rather than fired:
// it takes effect from the next reset instant. An
// unchanged value (the UI echoes it on every edit)
// leaves the marker — and any pending reset — alone.
ArmTrafficReset(item, time.Now())
}
}
reason := CustomerBlockReason(d, item)
statusWanted := JSString(body["status"])
if statusWanted == "active" && reason != "" {
labels := map[string]string{"expired": "到期时间", "traffic_limit": "流量额度", "ip_limit": "节点 IP 上限"}
return nil, httpErr(409, "客户仍受%s限制,请先修改限制再启用", labels[reason])
}
if statusWanted == "suspended" {
item.Status = "suspended"
item.SuspendReason = StrPtr("manual")
item.SuspendReasonPresent = true
} else if reason != "" && wasActive {
item.Status = "suspended"
item.SuspendReason = StrPtr(reason)
item.SuspendReasonPresent = true
} else if statusWanted == "active" || (!wasActive && reason == "" &&
(previousReason == "traffic_limit" || previousReason == "expired" || previousReason == "ip_limit")) {
item.Status = "active"
item.SuspendReason = nil
item.SuspendReasonPresent = true
}
if wasActive && item.Status == "suspended" {
_, _ = SuspendCustomerResources(d, item, actor)
}
if !wasActive && item.Status == "active" {
if _, err := ResumeCustomerResources(d, item, actor); err != nil {
return nil, err
}
}
item.UpdatedAt = NowISO()
Audit(d, actor, "update_customer", item.ID, nil)
return cloneJSONValue(item), nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"customer": customerAny}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/reset-usage", pathname); ok && r.Method == http.MethodPost {
_, err := a.store.Transaction(func(d *Data) (any, error) {
item := findCustomer(d, params["id"])
if item == nil {
return nil, httpErr(404, "客户不存在")
}
item.UsedBytes = 0
item.UsedUplinkBytes = 0
item.UsedDownlinkBytes = 0
if item.Status == "suspended" && strVal(item.SuspendReason) == "traffic_limit" && CustomerBlockReason(d, item) == "" {
item.Status = "active"
item.SuspendReason = nil
item.SuspendReasonPresent = true
if _, err := ResumeCustomerResources(d, item, actor); err != nil {
return nil, err
}
}
item.UpdatedAt = NowISO()
Audit(d, actor, "reset_customer_usage", item.ID, nil)
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/rotate-subscription", pathname); ok && r.Method == http.MethodPost {
customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
item := findCustomer(d, params["id"])
if item == nil {
return nil, httpErr(404, "客户不存在")
}
item.SubscriptionToken = RandomToken(32)
item.UpdatedAt = NowISO()
Audit(d, actor, "rotate_subscription", item.ID, nil)
return PublicCustomer(item, d), nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"customer": customerAny}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id", pathname); ok && r.Method == http.MethodDelete {
_, err := a.store.Transaction(func(d *Data) (any, error) {
index := -1
for i, item := range d.Customers {
if item.ID == params["id"] {
index = i
break
}
}
if index < 0 {
return nil, httpErr(404, "客户不存在")
}
for _, nc := range d.NodeClients {
if nc.CustomerID != params["id"] {
continue
}
if node := findNode(d, nc.NodeID); node != nil && !node.PendingDelete {
return nil, httpErr(409, "客户仍有节点分配。请先移除全部分配")
}
}
d.Customers = append(d.Customers[:index], d.Customers[index+1:]...)
access := d.SubscriptionAccess[:0]
for _, item := range d.SubscriptionAccess {
if item.CustomerID != params["id"] {
access = append(access, item)
}
}
d.SubscriptionAccess = access
clients := d.SubscriptionClients[:0]
for _, item := range d.SubscriptionClients {
if item.CustomerID != params["id"] {
clients = append(clients, item)
}
}
d.SubscriptionClients = clients
obs := d.Observations[:0]
for _, item := range d.Observations {
if item.CustomerID != params["id"] {
obs = append(obs, item)
}
}
d.Observations = obs
Audit(d, actor, "delete_customer", params["id"], nil)
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true}, nil)
return true, nil
}
if r.Method == http.MethodGet && pathname == "/api/nodes" {
data := a.store.Snapshot()
nodes := []map[string]any{}
visibleIDs := map[string]bool{}
for _, item := range data.Nodes {
if item.PendingDelete {
continue
}
visibleIDs[item.ID] = true
nodes = append(nodes, PublicNode(item))
}
nodeClients := []map[string]any{}
for _, nc := range data.NodeClients {
if !visibleIDs[nc.NodeID] {
continue
}
m := recordToMap(nc)
delete(m, "credentials")
uri := NodeClientURI(data, nc)
if uri == "" {
m["clientUri"] = nil
} else {
m["clientUri"] = uri
}
nodeClients = append(nodeClients, m)
}
SendJSON(w, 200, map[string]any{"nodes": nodes, "nodeClients": nodeClients}, nil)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/nodes" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
nodeAny, err := a.store.Transaction(func(d *Data) (any, error) {
normalized, err := NormalizeNode(body, nil, d)
if err != nil {
return nil, err
}
next := normalized
next.ID = NewID("nod")
next.Status = "draft"
next.EntryReady = false
next.ExitReady = false
next.ResourceID = NewID("res")
next.ExitResourceID = NewID("res")
next.Artifacts = map[string]json.RawMessage{}
next.Error = nil
next.PendingDelete = false
next.UsageCounters = nil
next.RetiredUsageEpochs = []string{}
next.MeteredTraffic = nil
next.LastUsageAt = nil
next.CreatedAt = NowISO()
next.UpdatedAt = NowISO()
EnsureNodeSecrets(next)
d.Nodes = append(d.Nodes, next)
Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name})
return cloneJSONValue(next), nil
})
if err != nil {
return true, err
}
SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodPatch {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
nodeAny, err := a.store.Transaction(func(d *Data) (any, error) {
item := findNode(d, params["id"])
if item == nil {
return nil, httpErr(404, "节点不存在")
}
if item.Status == "removing" {
return nil, httpErr(409, "节点正在移除中,请等待完成后再编辑")
}
oldServerID := item.ServerID
oldExitServerID := strVal(item.ExitServerID)
oldForward := forwardNode(item)
oldProtocol := item.Protocol
next, err := NormalizeNode(body, item, d)
if err != nil {
return nil, err
}
*item = *next
EnsureNodeSecrets(item)
if item.Protocol != oldProtocol {
for _, nc := range d.NodeClients {
if nc.NodeID == item.ID {
nc.Credentials = NewNodeClientCredentials(item.Protocol)
nc.UpdatedAt = NowISO()
}
}
}
if item.ServerID != oldServerID {
QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"})
item.EntryReady = false
}
if oldForward && (!forwardNode(item) || strVal(item.ExitServerID) != oldExitServerID) {
QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ExitResourceID, Kind: "node-exit"})
item.ExitReady = false
}
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
if err := RebuildNode(d, item, true); err != nil {
return nil, err
}
}
item.UpdatedAt = NowISO()
Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name})
return cloneJSONValue(item), nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodDelete {
data := a.store.Snapshot()
for _, job := range data.Jobs {
if job.NodeID == params["id"] && job.Status == "running" {
return true, httpErr(409, "节点任务正在设备上执行,请稍后刷新再删除")
}
}
if _, err := a.orchestrator.RemoveNode(params["id"], actor, true); err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/nodes/:id/deploy", pathname); ok && r.Method == http.MethodPost {
if _, err := a.orchestrator.DeployNode(params["id"], actor); err != nil {
return true, err
}
SendJSON(w, 202, map[string]any{"ok": true}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/nodes/:id/remove", pathname); ok && r.Method == http.MethodPost {
if _, err := a.orchestrator.RemoveNode(params["id"], actor, false); err != nil {
return true, err
}
SendJSON(w, 202, map[string]any{"ok": true}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/nodes/:id/repair", pathname); ok && r.Method == http.MethodPost {
if _, err := a.orchestrator.RepairNode(params["id"], actor); err != nil {
return true, err
}
SendJSON(w, 202, map[string]any{"ok": true}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/nodes/:id/assign", pathname); ok && r.Method == http.MethodPost {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
customerID, err := RequiredText(body["customerId"], "客户", 120)
if err != nil {
return true, err
}
nodeClient, err := a.orchestrator.AssignCustomer(params["id"], customerID, actor)
if err != nil {
return true, err
}
m := recordToMap(nodeClient)
delete(m, "credentials")
SendJSON(w, 200, map[string]any{"nodeClient": m}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/nodes/:id/unassign", pathname); ok && r.Method == http.MethodPost {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
customerID, err := RequiredText(body["customerId"], "客户", 120)
if err != nil {
return true, err
}
if _, err := a.orchestrator.UnassignCustomer(params["id"], customerID, actor); err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true}, nil)
return true, nil
}
if r.Method == http.MethodGet && pathname == "/api/jobs" {
data := a.store.Snapshot()
jobs := data.Jobs
if len(jobs) > 200 {
jobs = jobs[len(jobs)-200:]
}
out := []map[string]any{}
for i := len(jobs) - 1; i >= 0; i-- {
m := recordToMap(jobs[i])
delete(m, "payload")
if s, ok := m["error"].(string); ok && s != "" {
m["error"] = RedactSecrets(s)
}
out = append(out, m)
}
SendJSON(w, 200, map[string]any{"jobs": out}, nil)
return true, nil
}
if r.Method == http.MethodGet && pathname == "/api/backup" {
body, err := marshalIndentJSON(a.store.Snapshot())
if err != nil {
return true, err
}
h := w.Header()
h.Set("Content-Type", "application/json; charset=utf-8")
h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="nexusgate-backup-%s.json"`, NowISO()[:10]))
h.Set("Content-Length", fmt.Sprint(len(body)))
w.WriteHeader(200)
_, _ = w.Write(body)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/restore" {
body, err := ReadJSON(r, 20*1024*1024)
if err != nil {
return true, err
}
dataRaw, hasData := body["data"]
if JSString(body["confirm"]) != "RESTORE" || !hasData || dataRaw == nil {
return true, httpErr(400, "恢复确认信息不正确")
}
raw, err := marshalJSON(dataRaw)
if err != nil {
return true, err
}
var restored Data
if err := unmarshalJSONUseNumber(raw, &restored); err != nil {
return true, err
}
if err := a.store.Replace(&restored); err != nil {
return true, err
}
snapshot := a.store.Snapshot()
needsToken := false
for _, item := range snapshot.Customers {
if item.SubscriptionToken == "" {
needsToken = true
break
}
}
if needsToken {
_, err := a.store.Transaction(func(d *Data) (any, error) {
for _, customer := range d.Customers {
if customer.SubscriptionToken == "" {
customer.SubscriptionToken = RandomToken(32)
}
}
return nil, nil
})
if err != nil {
return true, err
}
}
a.replaceSessions(NewSessionManager(a.sessionHours()))
SendJSON(w, 200, map[string]any{"ok": true, "message": "备份已恢复,请重新登录"}, clearSessionCookie())
return true, nil
}
return false, nil
}
// ---------- subscription ----------
// FormatBytes is formatBytes from server.js.
func FormatBytes(bytes float64) string {
if bytes == 0 || bytes <= 0 {
return "0 B"
}
units := []string{"B", "KB", "MB", "GB", "TB"}
v := bytes
i := 0
for v >= 1024 && i < len(units)-1 {
v /= 1024
i++
}
return fmt.Sprintf("%.2f%s", v, units[i])
}
var htmlEscaper = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#39;",
)
func escHTML(s string) string { return htmlEscaper.Replace(s) }
// formatSubscriptionDate renders a stored ISO time the way the info page's
// toLocaleString('zh-CN') does on the reference deployment
// ("2006/01/02 15:04:05"); see the report for the locale/timezone caveat.
func formatSubscriptionDate(iso string) string {
t, ok := ParseISO(iso)
if !ok {
return "Invalid Date"
}
return t.Format("2006/01/02 15:04:05")
}
func tokenPrefix16(token string) string {
r := []rune(token)
if len(r) > 16 {
r = r[:16]
}
return string(r)
}
// BuildSubscriptionInfoPage is buildSubscriptionInfoPage, template verbatim.
func BuildSubscriptionInfoPage(data *Data, customer *Customer, r *http.Request) string {
used := float64(customer.UsedBytes)
up := float64(customer.UsedUplinkBytes)
down := float64(customer.UsedDownlinkBytes)
total := customer.TrafficLimitBytes
remaining := -1.0
if total > 0 {
remaining = math.Max(0, total-used)
}
pct := 0.0
if total > 0 {
pct = math.Min(100, used/total*100)
}
statusText := "停用"
statusClass := "off"
if customer.Status == "active" {
statusText = "启用"
statusClass = "on"
}
expiresText := "永不过期"
if customer.ExpiresAt != nil {
expiresText = formatSubscriptionDate(*customer.ExpiresAt)
}
lastOnline := "从未"
if customer.LastOnlineAt != nil {
lastOnline = formatSubscriptionDate(*customer.LastOnlineAt)
}
var assignments []*NodeClient
for _, nc := range data.NodeClients {
if nc.CustomerID == customer.ID && nc.Status == "active" {
assignments = append(assignments, nc)
}
}
host := r.Host
proto := r.Header.Get("X-Forwarded-Proto")
if proto == "" {
proto = "http"
}
subURL := proto + "://" + host + "/s/" + customer.SubscriptionToken + "/auto"
var rows strings.Builder
for _, nc := range assignments {
node := findNode(data, nc.NodeID)
if node == nil || node.Status != "active" {
continue
}
uri := NodeClientURI(data, nc)
if uri == "" {
continue
}
protoLabel := "AnyTLS"
switch {
case strings.Contains(node.Protocol, "vless"):
protoLabel = "Vless"
case strings.Contains(node.Protocol, "shadowsocks"):
protoLabel = "Shadowsocks"
case strings.Contains(node.Protocol, "socks"):
protoLabel = "Socks"
case strings.Contains(node.Protocol, "hysteria"):
protoLabel = "Hysteria2"
}
secLabel := ""
switch {
case strings.Contains(node.Protocol, "reality"):
secLabel = "REALITY"
case strings.Contains(node.Protocol, "tls"):
secLabel = "TLS"
}
nodeName := node.Name + "-" + customer.Name
secBadge := ""
if secLabel != "" {
secBadge = `<span class="badge sec">` + secLabel + "</span>"
}
rows.WriteString("<div class=\"node-row\">\n" +
" <span class=\"badge proto\">" + escHTML(protoLabel) + "</span>\n" +
" <span class=\"badge net\">TCP</span>\n" +
" " + secBadge + "\n" +
" <span class=\"node-name\">" + escHTML(nodeName) + "</span>\n" +
" <span class=\"spacer\"></span>\n" +
" <button class=\"icon-btn\" onclick=\"copyText('" + escHTML(uri) + "', this)\" title=\"复制链接\">📋</button>\n" +
" <button class=\"icon-btn\" onclick=\"showQR('" + escHTML(uri) + "')\" title=\"二维码\">🔳</button>\n" +
" </div>")
}
nodeRows := rows.String()
var uris []string
for _, nc := range assignments {
if uri := NodeClientURI(data, nc); uri != "" {
uris = append(uris, uri)
}
}
allUris := strings.Join(uris, "\n")
totalText := "无限制"
if total > 0 {
totalText = FormatBytes(total)
}
remainingText := "无限制"
if remaining >= 0 {
remainingText = FormatBytes(remaining)
}
progress := ""
if total > 0 {
progress = "<div class=\"progress-wrap\">\n" +
"<div class=\"progress-top\"><div><span class=\"used\">" + FormatBytes(used) + "</span> <span class=\"total\">/ " + FormatBytes(total) + "</span></div></div>\n" +
"<div class=\"progress-bar\"><div class=\"fill\" style=\"width:" + fmt.Sprintf("%.1f", pct) + "%\"></div></div>\n" +
"<div class=\"progress-bottom\"><span>" + FormatBytes(remaining) + " 剩余</span><span>" + fmt.Sprintf("%.1f", pct) + "%</span></div>\n" +
"</div>"
}
nodesHTML := nodeRows
if nodesHTML == "" {
nodesHTML = `<p style="text-align:center;color:#8b94a7;padding:20px">暂无可用节点</p>`
}
token16 := escHTML(tokenPrefix16(customer.SubscriptionToken))
return `<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>订阅信息 - ` + escHTML(customer.Name) + `</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0f1115;color:#e8eaf0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;min-height:100vh;display:flex;justify-content:center;padding:24px 12px}
.card{width:100%;max-width:640px;background:#161a22;border:1px solid #262c38;border-radius:16px;padding:28px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
.card h1{font-size:20px;margin-bottom:4px;display:flex;align-items:center;gap:10px}
.card h1 .tag{background:#2a3040;color:#9fb0d0;font-size:12px;padding:3px 10px;border-radius:6px;font-weight:400}
.info-table{width:100%;border-collapse:collapse;margin:20px 0;border:1px solid #2a3040;border-radius:12px;overflow:hidden}
.info-table td{padding:12px 16px;border-bottom:1px solid #232936;font-size:14px}
.info-table tr:last-child td{border-bottom:none}
.info-table td:first-child{color:#8b94a7;width:140px;background:#1a1f2a}
.badge-on{background:#123a24;color:#4ade80;font-size:12px;padding:3px 12px;border-radius:6px}
.badge-off{background:#3a1a1a;color:#f87171;font-size:12px;padding:3px 12px;border-radius:6px}
.progress-wrap{background:#1a1f2a;border:1px solid #2a3040;border-radius:12px;padding:18px;margin:16px 0}
.progress-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
.progress-top .used{font-size:22px;font-weight:700}
.progress-top .total{color:#8b94a7;font-size:14px}
.progress-top .days{background:#1e2a4a;color:#6ea8fe;font-size:12px;padding:4px 12px;border-radius:8px}
.progress-bar{height:10px;background:#2a3040;border-radius:5px;overflow:hidden}
.progress-bar .fill{height:100%;background:linear-gradient(90deg,#34d399,#10b981);border-radius:5px;transition:width .3s}
.progress-bottom{display:flex;justify-content:space-between;color:#8b94a7;font-size:13px;margin-top:8px}
.section-title{text-align:center;color:#c8d0e0;font-size:15px;margin:24px 0 12px;position:relative}
.section-title::before,.section-title::after{content:'';position:absolute;top:50%;width:32%;height:1px;background:#2a3040}
.section-title::before{left:0}.section-title::after{right:0}
.sub-row{display:flex;align-items:center;gap:10px;background:#1a1f2a;border:1px solid #2a3040;border-radius:10px;padding:12px 14px;margin-bottom:10px}
.sub-row .sub-badge{background:#123a24;color:#4ade80;font-size:12px;font-weight:700;padding:4px 10px;border-radius:6px}
.sub-row .sub-url{flex:1;font-size:13px;color:#c8d0e0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.icon-btn{background:#2a3040;border:none;color:#c8d0e0;width:34px;height:34px;border-radius:8px;cursor:pointer;font-size:16px}
.icon-btn:hover{background:#354052}
.node-row{display:flex;align-items:center;gap:8px;background:#1a1f2a;border:1px solid #2a3040;border-radius:10px;padding:12px 14px;margin-bottom:10px}
.badge{font-size:11px;font-weight:700;padding:4px 10px;border-radius:6px}
.badge.proto{background:#1e2a4a;color:#6ea8fe}
.badge.net{background:#3a2f1a;color:#fbbf24}
.badge.sec{background:#2a1a3a;color:#c084fc}
.node-name{font-size:14px;color:#e8eaf0}
.spacer{flex:1}
.copy-all{display:flex;align-items:center;justify-content:space-between;background:#1a1f2a;border:1px solid #2a3040;border-radius:10px;padding:12px 14px;margin-bottom:10px;font-size:14px}
.client-btns{display:flex;gap:16px;justify-content:center;margin-top:20px}
.client-btns button{background:#2563eb;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:15px;cursor:pointer}
.client-btns button:hover{background:#1d4ed8}
#qr-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);align-items:center;justify-content:center;z-index:100}
#qr-modal .box{background:#fff;padding:24px;border-radius:12px;text-align:center}
#qr-modal img{width:220px;height:220px}
#toast{position:fixed;bottom:32px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:10px 24px;border-radius:8px;display:none;z-index:200;font-size:14px}
</style></head><body>
<div class="card">
<h1>订阅信息 <span class="tag">` + token16 + `</span></h1>
<table class="info-table">
<tr><td>订阅 ID</td><td>` + token16 + `</td></tr>
<tr><td>用户</td><td>` + escHTML(customer.Name) + `</td></tr>
<tr><td>状态</td><td><span class="badge-` + statusClass + `">` + statusText + `</span></td></tr>
<tr><td>已下载</td><td>` + FormatBytes(down) + `</td></tr>
<tr><td>已上传</td><td>` + FormatBytes(up) + `</td></tr>
<tr><td>使用情况</td><td>` + FormatBytes(used) + `</td></tr>
<tr><td>总配额</td><td>` + totalText + `</td></tr>
<tr><td>剩余</td><td>` + remainingText + `</td></tr>
<tr><td>上次在线</td><td>` + escHTML(lastOnline) + `</td></tr>
<tr><td>到期</td><td>` + escHTML(expiresText) + `</td></tr>
</table>
` + progress + `
<div class="section-title">订阅信息</div>
<div class="sub-row"><span class="sub-badge">SUB</span><span class="sub-url">` + escHTML(subURL) + `</span>
<button class="icon-btn" onclick="copyText('` + escHTML(subURL) + `', this)">📋</button>
<button class="icon-btn" onclick="showQR('` + escHTML(subURL) + `')">🔳</button></div>
<div class="section-title">复制链接</div>
<div class="copy-all"><span>复制全部配置</span><button class="icon-btn" onclick="copyText(` + "`" + escHTML(allUris) + "`" + `, this)">📋</button></div>
` + nodesHTML + `
<div class="client-btns"><button onclick="alert('请在应用商店搜索 Shadowrocket / Clash 等客户端')">📱 Android ▾</button><button onclick="alert('请在 App Store 搜索 Shadowrocket / Stash 等客户端')">🍎 iOS ▾</button></div>
</div>
<div id="qr-modal"><div class="box"><div id="qr-code"></div><br><button onclick="document.getElementById('qr-modal').style.display='none'" style="padding:8px 24px;border-radius:8px;border:none;background:#2563eb;color:#fff;cursor:pointer">关闭</button></div></div>
<div id="toast"></div>
<script>
function copyText(t, btn){(navigator.clipboard?navigator.clipboard.writeText(t):Promise.reject()).then(()=>toast('已复制')).catch(()=>{const ta=document.createElement('textarea');ta.value=t;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');toast('已复制')}catch(e){toast('复制失败')}document.body.removeChild(ta)})}
function toast(m){const e=document.getElementById('toast');e.textContent=m;e.style.display='block';setTimeout(()=>e.style.display='none',1800)}
function showQR(t){const m=document.getElementById('qr-modal');document.getElementById('qr-code').innerHTML='<img src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&data='+encodeURIComponent(t)+'">';m.style.display='flex';m.onclick=(e)=>{if(e.target===m)m.style.display='none'}}
</script></body></html>`
}
var subscriptionPathRe = regexp.MustCompile(`^/s/([A-Za-z0-9_-]{32,100})/(auto|raw|base64|v2ray|shadowrocket|clash|clash-smart|mihomo|singbox|surge|info)$`)
func (a *App) handleSubscription(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) {
match := subscriptionPathRe.FindStringSubmatch(pathname)
if match == nil {
return false, nil
}
headers := map[string]string{
"Cache-Control": "no-store, private",
"X-Robots-Tag": "noindex, nofollow",
"Vary": "User-Agent",
}
if r.Method != http.MethodGet {
SendError(w, 405, "只支持 GET 请求", "request_error")
return true, nil
}
tokenText := match[1]
token := []byte(tokenText)
data := a.store.Snapshot()
var customer *Customer
for _, item := range data.Customers {
if item.SubscriptionToken == "" {
continue
}
stored := []byte(item.SubscriptionToken)
if len(stored) == len(token) && subtle.ConstantTimeCompare(stored, token) == 1 {
customer = item
break
}
}
if customer == nil {
SendJSON(w, 404, map[string]any{"message": "订阅链接不存在或已重置"}, headers)
return true, nil
}
identity := ClientIdentity(r.Header, data.CreatedAt)
access := &SubscriptionAccess{
ID: NewID("acc"),
CustomerID: customer.ID,
At: NowISO(),
IP: GetIP(r),
UserAgent: identity.UserAgent,
IdentityType: identity.Kind,
ClientKey: identity.Key,
Format: match[2],
Status: 0,
Reason: "",
Bytes: 0,
}
nowMs := time.Now().UnixMilli()
blocked := customer.Status != "active"
if customer.ExpiresAt != nil {
if ms, ok := ParseISOMs(*customer.ExpiresAt); ok && ms <= nowMs {
blocked = true
}
}
if customer.TrafficLimitBytes > 0 && float64(customer.UsedBytes) >= customer.TrafficLimitBytes {
blocked = true
}
if blocked {
access.Status = 403
access.Reason = "客户停用、到期或流量用尽"
_, err := a.store.Transaction(func(d *Data) (any, error) {
d.SubscriptionAccess = append(d.SubscriptionAccess, access)
PruneAccess(d, time.Now())
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 403, map[string]any{"message": "客户已停用、到期或流量用尽"}, headers)
return true, nil
}
if match[2] == "info" {
html := BuildSubscriptionInfoPage(data, customer, r)
h := w.Header()
for k, v := range headers {
h.Set(k, v)
}
h.Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
_, _ = w.Write([]byte(html))
return true, nil
}
format := match[2]
switch format {
case "v2ray", "shadowrocket":
format = "base64"
case "mihomo":
format = "clash-smart"
}
result, formatOK := FormatSubscription(data, customer, format, r.Header.Get("User-Agent"))
if !formatOK || result == nil {
restoring := false
for _, nc := range data.NodeClients {
if nc.CustomerID != customer.ID || nc.Status != "active" {
continue
}
node := findNode(data, nc.NodeID)
if node == nil || (node.Status != "deploying" && node.Status != "removing") {
continue
}
for _, job := range data.Jobs {
if job.NodeID == nc.NodeID && (job.Status == "queued" || job.Status == "running") {
restoring = true
break
}
}
if restoring {
break
}
}
access.Status = 503
if restoring {
access.Reason = "入口节点恢复中"
} else {
access.Reason = "没有已部署的入口节点"
}
_, err := a.store.Transaction(func(d *Data) (any, error) {
d.SubscriptionAccess = append(d.SubscriptionAccess, access)
PruneAccess(d, time.Now())
return nil, nil
})
if err != nil {
return true, err
}
respHeaders := map[string]string{}
for k, v := range headers {
respHeaders[k] = v
}
message := "当前没有部署成功的入口节点,请在“转发与节点”部署节点"
if restoring {
respHeaders["Retry-After"] = "15"
message = "节点正在恢复,请稍后更新订阅"
}
SendJSON(w, 503, map[string]any{"message": message}, respHeaders)
return true, nil
}
body := []byte(result.Body)
allowedAny, err := a.store.Transaction(func(d *Data) (any, error) {
current := findCustomer(d, customer.ID)
clients := ActiveClients(d, customer.ID, time.Now())
permitted := false
if current != nil && current.SubscriptionToken == tokenText && current.Status == "active" {
permitted = true
if current.ExpiresAt != nil {
if ms, ok := ParseISOMs(*current.ExpiresAt); !ok || ms <= time.Now().UnixMilli() {
permitted = false
}
}
if current.TrafficLimitBytes > 0 && !(float64(current.UsedBytes) < current.TrafficLimitBytes) {
permitted = false
}
if current.DeviceLimit != 0 {
contains := false
for _, key := range clients {
if key == identity.Key {
contains = true
break
}
}
if !contains && int64(len(clients)) >= current.DeviceLimit {
permitted = false
}
}
}
if permitted {
access.Status = 200
access.Reason = "已返回"
access.Bytes = int64(len(body))
var session *SubscriptionClient
for _, item := range d.SubscriptionClients {
if item.CustomerID == customer.ID && item.ClientKey == identity.Key {
session = item
break
}
}
if session != nil {
session.LastSeenAt = access.At
} else {
d.SubscriptionClients = append(d.SubscriptionClients, &SubscriptionClient{
CustomerID: customer.ID,
ClientKey: identity.Key,
IdentityType: identity.Kind,
FirstSeenAt: access.At,
LastSeenAt: access.At,
})
}
} else {
access.Status = 429
access.Reason = "订阅客户端估计数超限"
access.Bytes = 0
}
d.SubscriptionAccess = append(d.SubscriptionAccess, access)
PruneAccess(d, time.Now())
return permitted, nil
})
if err != nil {
return true, err
}
if allowed, _ := allowedAny.(bool); !allowed {
SendJSON(w, 429, map[string]any{"message": "订阅客户端数达到上限。可在客户访问记录中检查并重置观察窗口。"}, headers)
return true, nil
}
historical := customer.UsedBytes - customer.UsedUplinkBytes - customer.UsedDownlinkBytes
if historical < 0 {
historical = 0
}
expire := int64(0)
if customer.ExpiresAt != nil {
if ms, ok := ParseISOMs(*customer.ExpiresAt); ok {
expire = int64(math.Floor(float64(ms) / 1000))
}
}
totalFloor := int64(math.Floor(customer.TrafficLimitBytes))
if totalFloor < 0 {
totalFloor = 0
}
userinfo := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d",
customer.UsedUplinkBytes+historical, customer.UsedDownlinkBytes, totalFloor, expire)
h := w.Header()
for k, v := range headers {
h.Set(k, v)
}
h.Set("Content-Type", result.ContentType)
h.Set("Content-Length", fmt.Sprint(len(body)))
h.Set("Subscription-Userinfo", userinfo)
w.WriteHeader(200)
_, _ = w.Write(body)
return true, nil
}