Files
NexusGate/panel-go/handlers.go
T
aaaamoooo d316d0f8bb feat: customer-level credentials shared across all nodes (item 2)
One credential set per customer (UUID + 32-byte root relay key + shortId)
lives on the customer record; node bindings no longer store credentials.
SS2022 user keys derive deterministically from the root key by method
length (16/24/32-byte prefix), so a single password serves every protocol,
matching 3x-ui's per-client credential model. Reality public keys stay a
node property and are shown per binding in the credentials pane.

- store: load-time convergence — first binding's set wins; a converged
  password that is not a 32-byte root key is regenerated (uuid/shortId
  preserved); bindings are stripped; idempotent, covered by tests
- deploy: the orchestrator stamps the customer's set onto a build copy;
  subscriptions resolve the same set; the derivation applies in both the
  inbound builder and the URI builder (verified live: Alice's N1 link is
  byte-identical to the pre-change seed expectation, her N2 user key is
  exactly the root key's 16-byte prefix)
- API: GET /api/customers/:id/credentials returns {credentials, bindings};
  regenerate is now POST /api/customers/:id/credentials/regenerate and
  rotates the whole set, rebuilding every deployed node of the customer
- UI: credentials pane shows the single shared set plus per-binding
  node/protocol/Reality-public-key rows
- golden subscriptions fixture regenerated under one shared set (RF and
  SS entries byte-identical to before; five entries audited by hand)
- golden baseline re-captured for the new shapes; difftest normalizer now
  treats a base64 root key's prefix-truncation encodings and the Surge
  '='-mangled variants as the same secret
2026-10-21 11:18:00 +08:00

3107 lines
94 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"
"sort"
"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
sysmonMu sync.Mutex
sysmon *SystemMonitor
}
// monitor lazily creates the system monitor (data dir = store file dir).
func (a *App) monitor() *SystemMonitor {
a.sysmonMu.Lock()
defer a.sysmonMu.Unlock()
if a.sysmon == nil {
dir := "."
if a.store != nil && a.store.File != "" {
if idx := strings.LastIndex(a.store.File, "/"); idx >= 0 {
dir = a.store.File[:idx]
}
}
a.sysmon = NewSystemMonitor(dir)
}
return a.sysmon
}
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 域名必须是有效的公网域名")
}
if _, ok := body["enabled"]; ok {
enabled := true
if b, isBool := body["enabled"].(bool); isBool && !b {
enabled = false
}
next.Enabled = &enabled
} else if next.Enabled == nil {
enabled := true
next.Enabled = &enabled
}
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
// An already-assigned port is sticky. The browser form always submits
// portMode/port/serverId keys (port is null in random mode), so key
// presence alone must never trigger a re-selection — a random port is
// rolled exactly once, at creation. On edit the port only moves for an
// explicit addressing change: switching the mode to random, supplying a
// different fixed port, or moving to a server where the port is taken.
portValueChanged := false
if portInBody && !jsFalsy(body["port"]) {
if f, valid := anyToJSNumber(body["port"]); valid && int(f) != currentPort {
portValueChanged = true
}
}
serverChanged := current != nil && serverID != current.ServerID
switch {
case current == nil || 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
case portModeChanged && portMode == "random":
// Explicit switch into random mode: roll a fresh port.
port, err := SelectPort(data, server, portMode, nil, currentID)
if err != nil {
return nil, err
}
next.Port = port
case portValueChanged && portMode == "fixed":
port, err := SelectPort(data, server, portMode, body["port"], currentID)
if err != nil {
return nil, err
}
next.Port = port
case serverChanged && NodePortInUse(data, serverID, currentPort, currentID):
// The port is taken on the target server: fixed mode surfaces the
// conflict, random mode rolls a replacement. (When it is free the
// switch falls through and the port is kept verbatim.)
var requested any
if portMode == "fixed" {
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
// Same stickiness rule as the entry port: an assigned exit port is
// only re-selected on creation or an explicit addressing change.
exitPortValueChanged := false
if exitPortInBody && !jsFalsy(body["exitPort"]) && currentExitPort != nil {
if f, valid := anyToJSNumber(body["exitPort"]); valid && int(f) != *currentExitPort {
exitPortValueChanged = true
}
}
exitServerChanged := current != nil && strVal(exitServerID) != strVal(current.ExitServerID)
switch {
case current == nil || 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
case exitPortModeChanged && exitPortMode == "random":
port, err := SelectPort(data, exitServer, exitPortMode, nil, currentID)
if err != nil {
return nil, err
}
next.ExitPort = &port
case exitPortValueChanged && exitPortMode == "fixed":
port, err := SelectPort(data, exitServer, exitPortMode, body["exitPort"], currentID)
if err != nil {
return nil, err
}
next.ExitPort = &port
case exitServerChanged && NodePortInUse(data, strVal(exitServerID), *currentExitPort, currentID):
var requested any
if exitPortMode == "fixed" {
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
out["enabled"] = item.Enabled == nil || *item.Enabled
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()))
if len(item.ExternalSources) > 0 {
status := map[string]any{}
for _, source := range item.ExternalSources {
entry := data.SubSourceCache[source.ID]
summary := map[string]any{
"fetchedAt": nil, "okAt": nil, "failCount": 0,
"lastError": nil, "droppedCount": 0, "count": 0,
}
if entry != nil {
if entry.FetchedAt != nil {
summary["fetchedAt"] = *entry.FetchedAt
}
if entry.OkAt != nil {
summary["okAt"] = *entry.OkAt
}
summary["failCount"] = entry.FailCount
if entry.LastError != nil {
summary["lastError"] = *entry.LastError
}
summary["droppedCount"] = entry.DroppedCount
summary["count"] = len(entry.Items)
}
status[source.ID] = summary
}
out["externalStatus"] = status
}
return out
}
// CustomerSummary computes the customers-page stats strip, mirroring
// 3x-ui's client summary semantics (client_paging.go): total / online /
// depleted / depletingSoon / disabled / enabled. "depleted" is 3x-ui's
// depletedExpr — traffic exhausted (limit > 0 and used >= limit) OR expiry
// reached — counted regardless of status. "depletingSoon" is its
// expiringExpr — status active, not depleted, and either the expiry is
// less than settings.expireDiff days away or the remaining traffic is
// less than settings.trafficDiff GB. Both thresholds default to 0 (the
// 3x-ui factory default), under which nobody counts as depleting soon.
// "disabled" counts manual suspensions only; auto-blocks (expired /
// traffic_limit / ip_limit) surface through depleted instead.
func CustomerSummary(data *Data, nowMs int64) map[string]any {
expireDiffMs := data.Settings.ExpireDiff * 86400000
trafficDiffBytes := data.Settings.TrafficDiff * 1073741824
total, online, depleted, depletingSoon, disabled, enabled := len(data.Customers), 0, 0, 0, 0, 0
for _, c := range data.Customers {
if CustomerOnline(c, nowMs) {
online++
}
if c.Status == "active" {
enabled++
}
if c.Status == "suspended" && strVal(c.SuspendReason) == "manual" {
disabled++
}
var expiryMs int64 = -1
if c.ExpiresAt != nil {
if ms, ok := ParseISOMs(*c.ExpiresAt); ok {
expiryMs = ms
}
}
isDepleted := (c.TrafficLimitBytes > 0 && float64(c.UsedBytes) >= c.TrafficLimitBytes) ||
(expiryMs >= 0 && expiryMs <= nowMs)
if isDepleted {
depleted++
continue
}
if c.Status != "active" {
continue
}
nearExpiry := expiryMs >= 0 && float64(expiryMs-nowMs) < expireDiffMs
nearLimit := c.TrafficLimitBytes > 0 && c.TrafficLimitBytes-float64(c.UsedBytes) < trafficDiffBytes
if nearExpiry || nearLimit {
depletingSoon++
}
}
return map[string]any{
"total": total, "online": online, "depleted": depleted,
"depletingSoon": depletingSoon, "disabled": disabled, "enabled": enabled,
}
}
// realityPublicKeyOf returns the node's panel-hosted Reality public key as
// reported back by the agent ("" when unknown). The keypair is the server's
// identity — it stays per-node even though customer credentials are shared.
func realityPublicKeyOf(node *Node) string {
if node == nil {
return ""
}
if raw, ok := node.Artifacts["realityPublicKey"]; ok && len(raw) > 0 {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
}
return ""
}
// customerCredentialsView is the credentials-tab payload (round-2 item 2):
// the customer's ONE credential set, plus per-binding node facts. The
// Reality public key is shown per node because it belongs to the server,
// not to the customer.
func customerCredentialsView(d *Data, customer *Customer) map[string]any {
creds := map[string]any{"clientId": nil, "password": nil, "shortId": nil}
if customer.Credentials != nil {
if customer.Credentials.ClientID != "" {
creds["clientId"] = customer.Credentials.ClientID
}
if customer.Credentials.RelayPassword != "" {
creds["password"] = customer.Credentials.RelayPassword
}
if customer.Credentials.ShortID != "" {
creds["shortId"] = customer.Credentials.ShortID
}
}
bindings := []map[string]any{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customer.ID {
continue
}
node := findNode(d, nc.NodeID)
if node == nil {
continue
}
username := ""
if node.Protocol == "socks5-auth" {
username = NodeEmail(customer.ID)
}
var publicKey any
if key := realityPublicKeyOf(node); key != "" {
publicKey = key
}
bindings = append(bindings, map[string]any{
"nodeClientId": nc.ID,
"nodeId": node.ID,
"nodeName": node.Name,
"protocol": node.Protocol,
"status": nc.Status,
"username": username,
"realityPublicKey": publicKey,
})
}
return map[string]any{
"customerId": customer.ID,
"subscriptionToken": customer.SubscriptionToken,
"credentials": creds,
"bindings": bindings,
}
}
// 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,
SingBoxVersion: CleanText(eng["singBoxVersion"], 32),
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 customer := findCustomer(d, customerID); customer != nil {
customer.LastOnlineAt = StrPtr(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/system" {
data := a.store.Snapshot()
SendJSON(w, 200, a.monitor().Status(data, VERSION), 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()
now := time.Now()
nowMs := now.UnixMilli()
customers := []map[string]any{}
for _, item := range data.Customers {
out := PublicCustomer(item, data)
out["online"] = CustomerOnline(item, nowMs)
if item.LastOnlineAt != nil {
out["lastActiveAt"] = *item.LastOnlineAt
} else {
out["lastActiveAt"] = nil
}
up, down := CustomerSpeedBps(item, nowMs)
out["speedUpBps"] = up
out["speedDownBps"] = down
customers = append(customers, out)
}
SendJSON(w, 200, map[string]any{"customers": customers, "summary": CustomerSummary(data, nowMs)}, 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
}
externalSources, err := NormalizeExternalSources(body["externalSources"])
if err != nil {
return true, err
}
manualLinks, err := NormalizeManualLinks(body["manualLinks"])
if err != nil {
return true, err
}
var createdID string
var refreshIDs []string
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(),
Credentials: func() *Credentials { c := NewCustomerCredentials(); return &c }(),
}
// Arm the monthly-reset schedule: it starts with the next
// reset instant, it never fires retroactively at creation.
ArmTrafficReset(next, time.Now())
if len(externalSources) > 0 {
next.ExternalSources = externalSources
}
if len(manualLinks) > 0 {
next.ManualLinks = manualLinks
}
d.Customers = append(d.Customers, next)
if body["nodeIds"] != nil {
if err := AlignCustomerNodes(d, next, asIds(body["nodeIds"]), actor); err != nil {
return nil, err
}
}
Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name})
createdID = next.ID
for _, source := range externalSources {
refreshIDs = append(refreshIDs, source.ID)
}
return cloneJSONValue(next), nil
})
if err != nil {
return true, err
}
if len(refreshIDs) > 0 {
a.RefreshSourcesInBackground(createdID, refreshIDs)
}
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
}
var patchSources []ExtSource
if body["externalSources"] != nil {
patchSources, err = NormalizeExternalSources(body["externalSources"])
if err != nil {
return true, err
}
}
var patchLinks []ManualLink
if body["manualLinks"] != nil {
patchLinks, err = NormalizeManualLinks(body["manualLinks"])
if err != nil {
return true, err
}
}
var patchRefreshIDs []string
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)
previousSources := append([]ExtSource{}, item.ExternalSources...)
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 body["externalSources"] != nil {
if len(patchSources) > 0 {
item.ExternalSources = patchSources
} else {
item.ExternalSources = nil
}
keptIDs := map[string]bool{}
for _, source := range patchSources {
keptIDs[source.ID] = true
}
for _, old := range previousSources {
if !keptIDs[old.ID] && d.SubSourceCache != nil {
delete(d.SubSourceCache, old.ID)
}
}
for _, source := range patchSources {
found := false
for _, old := range previousSources {
if old.ID == source.ID && old.URL == source.URL {
found = true
break
}
}
if !found {
patchRefreshIDs = append(patchRefreshIDs, source.ID)
}
}
}
if body["manualLinks"] != nil {
if len(patchLinks) > 0 {
item.ManualLinks = patchLinks
} else {
item.ManualLinks = nil
}
}
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
}
}
if body["nodeIds"] != nil {
if err := AlignCustomerNodes(d, item, asIds(body["nodeIds"]), 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
}
if len(patchRefreshIDs) > 0 {
a.RefreshSourcesInBackground(params["id"], patchRefreshIDs)
}
SendJSON(w, 200, map[string]any{"customer": customerAny}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/credentials", pathname); ok && r.Method == http.MethodGet {
data := a.store.Snapshot()
customer := findCustomer(data, params["id"])
if customer == nil {
return true, httpErr(404, "客户不存在")
}
SendJSON(w, 200, customerCredentialsView(data, customer), nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/credentials/regenerate", pathname); ok && r.Method == http.MethodPost {
var view map[string]any
_, err := a.store.Transaction(func(d *Data) (any, error) {
customer := findCustomer(d, params["id"])
if customer == nil {
return nil, httpErr(404, "客户不存在")
}
// One set for the whole customer: rotating it rotates every
// node at once, and every deployed node is rebuilt so the new
// users land on the agents.
creds := NewCustomerCredentials()
customer.Credentials = &creds
customer.UpdatedAt = NowISO()
rebuilt := map[string]bool{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customer.ID || nc.Status != "active" {
continue
}
node := findNode(d, nc.NodeID)
if node == nil || node.Status == "draft" || node.Status == "removing" || rebuilt[node.ID] {
continue
}
rebuilt[node.ID] = true
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
}
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)})
view = customerCredentialsView(d, customer)
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, view, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/external-sources/:sid/refresh", pathname); ok && r.Method == http.MethodPost {
result := a.RefreshOneSource(params["id"], params["sid"])
if result == nil {
return true, httpErr(404, "订阅源不存在")
}
SendJSON(w, 200, map[string]any{
"ok": result.OK, "count": len(result.Items), "dropped": result.Dropped, "error": strOrNil(result.Error),
}, 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, "客户仍有节点分配。请先移除全部分配")
}
}
removedCustomer := d.Customers[index]
for _, source := range removedCustomer.ExternalSources {
if d.SubSourceCache != nil {
delete(d.SubSourceCache, source.ID)
}
}
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()
nowMs := time.Now().UnixMilli()
sortedNodes := append([]*Node{}, data.Nodes...)
sort.SliceStable(sortedNodes, func(i, j int) bool { return sortedNodes[i].SortIndex < sortedNodes[j].SortIndex })
nodes := []map[string]any{}
visibleIDs := map[string]bool{}
for _, item := range sortedNodes {
if item.PendingDelete {
continue
}
visibleIDs[item.ID] = true
out := PublicNode(item)
clientCount := 0
for _, nc := range data.NodeClients {
if nc.NodeID == item.ID {
clientCount++
}
}
onlineIDs := NodeOnlineCustomerIds(data, item, nowMs)
out["clientCount"] = clientCount
out["onlineCount"] = len(onlineIDs)
out["onlineCustomerIds"] = onlineIDs
up, down := NodeSpeedBps(item, nowMs)
out["speedUpBps"] = up
out["speedDownBps"] = down
nodes = append(nodes, out)
}
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/reorder" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
_, err = a.store.Transaction(func(d *Data) (any, error) {
ids := asIds(body["ids"])
byID := map[string]*Node{}
for _, item := range d.Nodes {
byID[item.ID] = item
}
ordered := []*Node{}
seen := map[string]bool{}
for _, id := range ids {
if node := byID[id]; node != nil && !seen[id] {
seen[id] = true
ordered = append(ordered, node)
}
}
rest := append([]*Node{}, d.Nodes...)
sort.SliceStable(rest, func(i, j int) bool { return rest[i].SortIndex < rest[j].SortIndex })
for _, item := range rest {
if !seen[item.ID] {
ordered = append(ordered, item)
}
}
for index, item := range ordered {
item.SortIndex = index
}
Audit(d, actor, "reorder_nodes", "nodes", map[string]any{"count": len(ordered)})
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/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)
maxIndex := -1
for _, item := range d.Nodes {
if item.SortIndex > maxIndex {
maxIndex = item.SortIndex
}
}
next.SortIndex = maxIndex + 1
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)
next, err := NormalizeNode(body, item, d)
if err != nil {
return nil, err
}
*item = *next
EnsureNodeSecrets(item)
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
}