Files
NexusGate/panel-go/handlers.go
T
aaaamoooo 05366d9177 perf(panel): kill per-request full-database clones (item 22, backend)
Data.Clone() was a full JSON marshal/unmarshal round trip (with a
per-record map dance for unknown fields): 6.3ms and ~13.8k allocs per
call on sandbox hardware, and every admin GET paid it at least twice
(RequireAdmin's user lookup + the handler). Clone is now a reflective
deep copy of the plain data graph — byte-identical output, proven
against the JSON clone on the real seed database by
TestCloneEquivalence — at 0.37ms / 538 allocs. CurrentAdmin resolves
the session user under a read-only store View and no longer clones
at all, so an admin GET takes exactly one snapshot. Sandbox
benchmarks (11 servers / 26 customers / 25 nodes): overview
17.2ms -> 0.38ms, servers 13.3 -> 0.85ms, customers 20.5 -> 1.54ms,
nodes 15.8 -> 3.45ms, jobs 13.0 -> 0.39ms per request.
2026-10-21 10:50:00 +00:00

3320 lines
102 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/base64"
"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"
// Exit target selection (round-2 item 8): "manual" addresses an
// unmanaged landing endpoint; anything else keeps the managed server.
exitMode := next.ExitMode
if _, ok := body["exitMode"]; ok {
if s, _ := body["exitMode"].(string); s == "manual" {
exitMode = "manual"
} else {
exitMode = "managed"
}
}
manual := !direct && exitMode == "manual"
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 || manual {
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 && !manual {
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
next.ExitMode = ""
next.ExitAddress = ""
if manual {
next.ExitMode = "manual"
address := ""
if current != nil {
address = current.ExitAddress
}
if _, ok := body["exitAddress"]; ok {
s, err := RequiredText(body["exitAddress"], "落地端点地址", 255)
if err != nil {
return nil, err
}
address = s
}
if address == "" || strings.ContainsAny(address, " \t\r\n/") || strings.Contains(address, "://") {
return nil, httpErr(400, "手动落地端点地址无效")
}
next.ExitAddress = address
}
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 manual {
// A manual endpoint's port belongs to the remote service: it is
// always explicit (never rolled, never checked against a panel
// server's port table).
fixed := "fixed"
next.ExitPortMode = &fixed
port := 0
if v, ok := body["exitPort"]; ok && !jsFalsy(v) {
if f, valid := anyToJSNumber(v); valid {
port = int(f)
}
} else if current != nil && current.ExitPort != nil {
port = *current.ExitPort
}
if port < 1 || port > 65535 {
return nil, httpErr(400, "手动落地端点端口无效")
}
next.ExitPort = &port
} else 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 manual {
// Manual endpoint credentials describe a third-party service, so
// they come from the operator (form fields or a pasted share link),
// never generated. Fields merge over the stored set so an edit that
// leaves a field blank keeps the previous value.
if rawCreds, ok := body["exitCredentials"].(map[string]any); ok {
creds := ExitCredentials{}
if next.ExitCredentials != nil {
creds = *next.ExitCredentials
}
if v, ok := rawCreds["clientId"]; ok {
creds.ClientID = CleanText(v, 128)
}
if v, ok := rawCreds["password"]; ok {
creds.Password = CleanText(v, 512)
}
if v, ok := rawCreds["socksUser"]; ok {
creds.SocksUser = CleanText(v, 128)
}
next.ExitCredentials = &creds
}
creds := next.ExitCredentials
exitProtocol := strVal(next.ExitProtocol)
switch {
case creds == nil:
return nil, httpErr(400, "手动落地端点缺少凭据")
case exitProtocol == "vless-tcp" && creds.ClientID == "":
return nil, httpErr(400, "手动落地端点缺少 VLESS 用户 ID")
case exitProtocol == "socks5-auth" && (creds.SocksUser == "" || creds.Password == ""):
return nil, httpErr(400, "手动落地端点缺少 SOCKS5 用户名或密码")
default:
if cipher, isSS := ShadowsocksMethods[exitProtocol]; isSS {
if creds.Password == "" {
return nil, httpErr(400, "手动落地端点缺少 SS 密码")
}
// SS2022 keys are base64 key material (one PSK segment, or
// serverKey:userKey); sing-box rejects anything else when
// the config loads, so reject it here with a clear error.
if strings.HasPrefix(cipher.Method, "2022-blake3-") {
segments := strings.Split(creds.Password, ":")
if len(segments) > 2 {
return nil, httpErr(400, "手动落地端点 SS 密码格式无效")
}
for _, segment := range segments {
raw, err := base64.StdEncoding.DecodeString(segment)
if err != nil {
if raw, err = base64.RawStdEncoding.DecodeString(segment); err != nil {
return nil, httpErr(400, "手动落地端点 SS 密码必须是 base64 密钥")
}
}
if len(raw) != cipher.KeyBytes {
return nil, httpErr(400, "手动落地端点 SS 密钥长度与加密方法不符")
}
}
}
}
}
}
// Round-3 item 21: a socks5-auth node carries its own fixed account.
// Fields merge over the stored pair (blank keeps the old value, the
// form's convention); a pair still missing afterwards is minted by
// EnsureNodeSecrets on save. The username must not start with "ng:"
// — that prefix is the per-customer identity namespace the agent
// maps to usage samples.
if protocol == "socks5-auth" {
if v, ok := body["socksUser"]; ok {
if user := CleanText(v, 128); user != "" {
if strings.HasPrefix(user, "ng:") || strings.ContainsAny(user, " \t\r\n") {
return nil, httpErr(400, "SOCKS5 用户名不能以 ng: 开头或包含空白字符")
}
next.SocksUser = user
}
}
if v, ok := body["socksPassword"]; ok {
if password := CleanText(v, 512); password != "" {
next.SocksPassword = password
}
}
} else {
next.SocksUser = ""
next.SocksPassword = ""
}
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" {
// Item 21: the socks identity lives on the node now (fixed
// account), not per customer.
username = node.SocksUser
}
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. Since round-3 item 22 it resolves the
// session's user under a read-only View instead of cloning the whole
// database: admin auth runs on EVERY request, and the user table is
// tiny. The returned User is a copy the caller may read freely.
func (a *App) CurrentAdmin(r *http.Request) *adminAuth {
session := a.getSessions().Get(r)
if session == nil {
return nil
}
var found *User
a.store.View(func(d *Data) {
for _, user := range d.Users {
if user.ID == session.UserID && user.Status == "active" {
copied := *user
found = &copied
return
}
}
})
if found == nil {
return nil
}
return &adminAuth{Session: session, User: found}
}
// 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
// Credential rotation is a binding-class change: it goes
// through the coalesced window (item 23), not an
// immediate per-node restart.
markRebuildPending(node, false)
}
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")
if manualExit(next) {
// Nothing is deployed on a manual landing endpoint, so the
// exit side is ready by definition and owns no resource.
next.ExitReady = true
next.ExitResourceID = ""
}
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 r.Method == http.MethodPost && pathname == "/api/reality/scan" {
// Round-3 item 16: panel-side Reality target scan. Bounded by
// parse/probe limits in realityscan.go.
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
input, _ := body["input"].(string)
if raw, ok := body["targets"].([]any); ok {
parts := []string{}
for _, item := range raw {
if s, ok := item.(string); ok {
parts = append(parts, s)
}
}
input = strings.Join(parts, ",")
}
targets, herr := parseScanTargets(input)
if herr != nil {
return true, herr
}
results := scanRealityTargets(targets)
SendJSON(w, 200, map[string]any{"results": results}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/nodes/:id/reality-keys", pathname); ok && r.Method == http.MethodGet {
// Round-3 item 17: display-only view of the panel-hosted Reality
// keypair for the node's server. This NEVER generates a pair —
// a missing entry simply reports nulls (the pair is minted by
// EnsureRealityKey at deploy time).
data := a.store.Snapshot()
node := findNode(data, params["id"])
if node == nil {
return true, httpErr(404, "节点不存在")
}
if !IsRealityProtocol(node.Protocol) {
return true, httpErr(400, "该节点不是 Reality 协议")
}
keyID := RealityKeyIDForNode(node)
var publicKey, privateKey any
if entry := data.KeyStore[keyID]; entry != nil {
publicKey, privateKey = entry.PublicKey, entry.PrivateKey
}
SendJSON(w, 200, map[string]any{"keyId": keyID, "publicKey": publicKey, "privateKey": privateKey}, 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)
oldExitResourceID := item.ExitResourceID
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 && oldExitResourceID != "" && (!forwardNode(item) || manualExit(item) || strVal(item.ExitServerID) != oldExitServerID) {
QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: oldExitResourceID, Kind: "node-exit"})
item.ExitReady = false
}
if manualExit(item) {
item.ExitReady = true
item.ExitResourceID = ""
} else if forwardNode(item) && item.ExitResourceID == "" {
// Switching from a manual endpoint back to a managed exit
// server needs a fresh exit resource identity.
item.ExitResourceID = NewID("res")
item.ExitReady = false
}
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
// A node edit ships immediately and consumes any pending
// coalesced mark (item 23).
clearRebuildPending(item)
if err := RebuildNode(d, item, true); err != nil {
return nil, err
}
}
if manualExit(item) {
item.ExitReady = true
}
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
}