NormalizeNode re-selected ports whenever the request merely CONTAINED port/serverId keys; the browser form always submits them (port=null in random mode), so every save re-rolled both entry and exit ports. Now an assigned port only moves on an explicit addressing change: mode switch to random, a different fixed port value, or a server move where the port is taken (fixed mode surfaces the 409). random->fixed pins the current port. Regression: portlock_test.go (UI-shape payload saved twice, rename-only, mode switches, fixed change/conflict, server move free/taken). Golden re-captured: diff confined to nodePatchUiStyle/nodePatchJobs/ nodesAfterPatch/backup port leaves returning to the seed ports.
3045 lines
92 KiB
Go
3045 lines
92 KiB
Go
package main
|
||
|
||
// All HTTP handlers + the server.js internal helpers (normalize/public
|
||
// shapers, auth guards, login rate limiting, subscription endpoint).
|
||
// Route order, status codes, error codes and message text follow server.js
|
||
// line by line.
|
||
|
||
import (
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// ---------- app state ----------
|
||
|
||
type loginAttempt struct {
|
||
Count int
|
||
BlockedUntil int64 // epoch ms
|
||
}
|
||
|
||
type App struct {
|
||
store *Store
|
||
orchestrator *Orchestrator
|
||
|
||
sessMu sync.RWMutex
|
||
sessions *SessionManager
|
||
|
||
loginMu sync.Mutex
|
||
loginAttempts map[string]*loginAttempt
|
||
|
||
sysmonMu sync.Mutex
|
||
sysmon *SystemMonitor
|
||
}
|
||
|
||
// monitor lazily creates the system monitor (data dir = store file dir).
|
||
func (a *App) monitor() *SystemMonitor {
|
||
a.sysmonMu.Lock()
|
||
defer a.sysmonMu.Unlock()
|
||
if a.sysmon == nil {
|
||
dir := "."
|
||
if a.store != nil && a.store.File != "" {
|
||
if idx := strings.LastIndex(a.store.File, "/"); idx >= 0 {
|
||
dir = a.store.File[:idx]
|
||
}
|
||
}
|
||
a.sysmon = NewSystemMonitor(dir)
|
||
}
|
||
return a.sysmon
|
||
}
|
||
|
||
func NewApp(store *Store, sessions *SessionManager, orch *Orchestrator) *App {
|
||
return &App{
|
||
store: store,
|
||
orchestrator: orch,
|
||
sessions: sessions,
|
||
loginAttempts: map[string]*loginAttempt{},
|
||
}
|
||
}
|
||
|
||
func (a *App) getSessions() *SessionManager {
|
||
a.sessMu.RLock()
|
||
defer a.sessMu.RUnlock()
|
||
return a.sessions
|
||
}
|
||
|
||
// replaceSessions swaps the whole SessionManager (account PATCH / restore),
|
||
// which invalidates every existing session, as in server.js.
|
||
func (a *App) replaceSessions(sm *SessionManager) {
|
||
a.sessMu.Lock()
|
||
defer a.sessMu.Unlock()
|
||
a.sessions = sm
|
||
}
|
||
|
||
func (a *App) sessionHours() float64 {
|
||
h := a.store.Snapshot().Settings.SessionHours
|
||
if h == 0 || math.IsNaN(h) {
|
||
return 12
|
||
}
|
||
return h
|
||
}
|
||
|
||
// ---------- normalize ----------
|
||
|
||
// The JS pattern anchors the total length with a lookahead
|
||
// (?=.{1,253}$), which RE2 lacks; the length is checked in code instead.
|
||
var tlsDomainRe = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`)
|
||
|
||
func validTLSDomain(s string) bool {
|
||
return len(s) >= 1 && len(s) <= 253 && tlsDomainRe.MatchString(s)
|
||
}
|
||
|
||
var certDomainRe = regexp.MustCompile(`^(?:[a-z0-9-]+\.)+[a-z]{2,63}$`)
|
||
|
||
func numOr(v float64, fallback float64) float64 {
|
||
if v == 0 || math.IsNaN(v) {
|
||
return fallback
|
||
}
|
||
return v
|
||
}
|
||
|
||
// NormalizeServer is normalizeServer. Presence semantics come from map key
|
||
// existence, exactly like JS `'field' in body`.
|
||
func NormalizeServer(body map[string]any, current *Server) (*Server, error) {
|
||
var next Server
|
||
if current != nil {
|
||
next = *current
|
||
}
|
||
if _, ok := body["name"]; ok {
|
||
name, err := RequiredText(body["name"], "服务器名称", 120)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.Name = name
|
||
}
|
||
if v, ok := body["role"]; ok {
|
||
if s, isStr := v.(string); isStr && (s == "relay" || s == "exit" || s == "hybrid") {
|
||
next.Role = s
|
||
} else if next.Role == "" {
|
||
next.Role = "hybrid"
|
||
}
|
||
} else if next.Role == "" {
|
||
next.Role = "hybrid"
|
||
}
|
||
if _, ok := body["region"]; ok {
|
||
next.Region = CleanText(body["region"], 80)
|
||
}
|
||
if _, ok := body["publicAddress"]; ok {
|
||
addr, err := RequiredText(body["publicAddress"], "公网地址", 255)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.PublicAddress = addr
|
||
}
|
||
if _, ok := body["publicAddressV6"]; ok {
|
||
next.PublicAddressV6 = CleanText(body["publicAddressV6"], 255)
|
||
}
|
||
if _, ok := body["tlsDomain"]; ok {
|
||
next.TLSDomain = strings.ToLower(CleanText(body["tlsDomain"], 253))
|
||
}
|
||
// Port range: JS computes Number(body value) when the key is present
|
||
// (Number(null) is 0), else Number(current || default). Validation is on
|
||
// the float values (integer/range checks), then stored as ints.
|
||
portValue := func(key string, currentVal, def int) (float64, bool) {
|
||
if v, ok := body[key]; ok {
|
||
if v == nil {
|
||
return 0, true
|
||
}
|
||
return jsNumber(v)
|
||
}
|
||
if currentVal == 0 {
|
||
return float64(def), true
|
||
}
|
||
return float64(currentVal), true
|
||
}
|
||
startF, startOk := portValue("portRangeStart", next.PortRangeStart, 20000)
|
||
endF, endOk := portValue("portRangeEnd", next.PortRangeEnd, 50000)
|
||
next.PortRangeStart = int(startF)
|
||
next.PortRangeEnd = int(endF)
|
||
if _, ok := body["labels"]; ok {
|
||
next.Labels = sliceStrings(asIds(body["labels"]), 20)
|
||
} else if next.Labels == nil {
|
||
next.Labels = []string{}
|
||
}
|
||
if !startOk || !endOk || startF != math.Trunc(startF) || endF != math.Trunc(endF) ||
|
||
startF < 1024 || endF > 65535 || startF > endF {
|
||
return nil, httpErr(400, "端口范围必须是 1024–65535 之间的整数")
|
||
}
|
||
if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) {
|
||
return nil, httpErr(400, "TLS 域名必须是有效的公网域名")
|
||
}
|
||
if _, ok := body["enabled"]; ok {
|
||
enabled := true
|
||
if b, isBool := body["enabled"].(bool); isBool && !b {
|
||
enabled = false
|
||
}
|
||
next.Enabled = &enabled
|
||
} else if next.Enabled == nil {
|
||
enabled := true
|
||
next.Enabled = &enabled
|
||
}
|
||
return &next, nil
|
||
}
|
||
|
||
// NormalizeNode is normalizeNode, including its port-(re)selection rules.
|
||
func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error) {
|
||
var next Node
|
||
if current != nil {
|
||
next = *current
|
||
}
|
||
topology := next.Topology
|
||
if _, ok := body["topology"]; ok {
|
||
if s, _ := body["topology"].(string); s == "direct" {
|
||
topology = "direct"
|
||
} else {
|
||
topology = "forward"
|
||
}
|
||
} else if topology == "" {
|
||
topology = "forward"
|
||
}
|
||
direct := topology == "direct"
|
||
|
||
protocol := next.Protocol
|
||
if _, ok := body["protocol"]; ok {
|
||
protocol = CleanText(body["protocol"], 64)
|
||
}
|
||
serverID := next.ServerID
|
||
if _, ok := body["serverId"]; ok {
|
||
id, err := RequiredText(body["serverId"], "服务器", 120)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
serverID = id
|
||
}
|
||
var server *Server
|
||
for _, item := range data.Servers {
|
||
if item.ID == serverID {
|
||
server = item
|
||
break
|
||
}
|
||
}
|
||
if server == nil {
|
||
return nil, httpErr(404, "服务器不存在")
|
||
}
|
||
var exitServerID *string
|
||
if direct {
|
||
exitServerID = nil
|
||
} else if _, ok := body["exitServerId"]; ok {
|
||
id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
exitServerID = &id
|
||
} else {
|
||
exitServerID = next.ExitServerID
|
||
}
|
||
if !direct {
|
||
found := false
|
||
for _, item := range data.Servers {
|
||
if item.ID == strVal(exitServerID) {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
return nil, httpErr(404, "出口服务器不存在")
|
||
}
|
||
}
|
||
if _, ok := body["name"]; ok {
|
||
name, err := RequiredText(body["name"], "节点名称", 120)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.Name = name
|
||
}
|
||
next.ServerID = serverID
|
||
next.Protocol = protocol
|
||
next.Topology = topology
|
||
next.ExitServerID = exitServerID
|
||
if direct {
|
||
next.ExitProtocol = nil
|
||
} else if _, ok := body["exitProtocol"]; ok {
|
||
s := CleanText(body["exitProtocol"], 64)
|
||
next.ExitProtocol = &s
|
||
}
|
||
if v, ok := body["networkMode"]; ok {
|
||
if s, isStr := v.(string); isStr && (s == "ipv4" || s == "ipv6" || s == "dual") {
|
||
next.NetworkMode = s
|
||
} else if next.NetworkMode == "" {
|
||
next.NetworkMode = "ipv4"
|
||
}
|
||
} else if next.NetworkMode == "" {
|
||
next.NetworkMode = "ipv4"
|
||
}
|
||
if _, ok := body["realityServerName"]; ok {
|
||
next.RealityServerName = CleanText(body["realityServerName"], 255)
|
||
} else if next.RealityServerName == "" {
|
||
next.RealityServerName = "www.tesla.com"
|
||
}
|
||
if _, ok := body["realityDestPort"]; ok {
|
||
f, valid := jsNumber(body["realityDestPort"])
|
||
if !valid || f == 0 {
|
||
next.RealityDestPort = 443
|
||
} else {
|
||
next.RealityDestPort = int(f)
|
||
}
|
||
} else {
|
||
next.RealityDestPort = int(numOr(float64(next.RealityDestPort), 443))
|
||
}
|
||
portMode := next.PortMode
|
||
if _, ok := body["portMode"]; ok {
|
||
if s, _ := body["portMode"].(string); s == "fixed" {
|
||
portMode = "fixed"
|
||
} else {
|
||
portMode = "random"
|
||
}
|
||
} else if portMode == "" {
|
||
portMode = "random"
|
||
}
|
||
next.PortMode = portMode
|
||
currentPortMode := "random"
|
||
var currentID string
|
||
var currentPort int
|
||
if current != nil {
|
||
if current.PortMode != "" {
|
||
currentPortMode = current.PortMode
|
||
}
|
||
currentID = current.ID
|
||
currentPort = current.Port
|
||
}
|
||
_, portModeInBody := body["portMode"]
|
||
_, portInBody := body["port"]
|
||
portModeChanged := portModeInBody && portMode != currentPortMode
|
||
// An already-assigned port is sticky. The browser form always submits
|
||
// portMode/port/serverId keys (port is null in random mode), so key
|
||
// presence alone must never trigger a re-selection — a random port is
|
||
// rolled exactly once, at creation. On edit the port only moves for an
|
||
// explicit addressing change: switching the mode to random, supplying a
|
||
// different fixed port, or moving to a server where the port is taken.
|
||
portValueChanged := false
|
||
if portInBody && !jsFalsy(body["port"]) {
|
||
if f, valid := anyToJSNumber(body["port"]); valid && int(f) != currentPort {
|
||
portValueChanged = true
|
||
}
|
||
}
|
||
serverChanged := current != nil && serverID != current.ServerID
|
||
switch {
|
||
case current == nil || currentPort == 0:
|
||
var requested any
|
||
if portInBody {
|
||
requested = body["port"]
|
||
} else if current != nil {
|
||
requested = json.Number(fmt.Sprint(currentPort))
|
||
}
|
||
port, err := SelectPort(data, server, portMode, requested, currentID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.Port = port
|
||
case portModeChanged && portMode == "random":
|
||
// Explicit switch into random mode: roll a fresh port.
|
||
port, err := SelectPort(data, server, portMode, nil, currentID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.Port = port
|
||
case portValueChanged && portMode == "fixed":
|
||
port, err := SelectPort(data, server, portMode, body["port"], currentID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.Port = port
|
||
case serverChanged && NodePortInUse(data, serverID, currentPort, currentID):
|
||
// The port is taken on the target server: fixed mode surfaces the
|
||
// conflict, random mode rolls a replacement. (When it is free the
|
||
// switch falls through and the port is kept verbatim.)
|
||
var requested any
|
||
if portMode == "fixed" {
|
||
requested = json.Number(fmt.Sprint(currentPort))
|
||
}
|
||
port, err := SelectPort(data, server, portMode, requested, currentID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.Port = port
|
||
}
|
||
if !direct {
|
||
exitPortMode := strVal(next.ExitPortMode)
|
||
if _, ok := body["exitPortMode"]; ok {
|
||
if s, _ := body["exitPortMode"].(string); s == "fixed" {
|
||
exitPortMode = "fixed"
|
||
} else {
|
||
exitPortMode = "random"
|
||
}
|
||
} else if exitPortMode == "" {
|
||
exitPortMode = "random"
|
||
}
|
||
next.ExitPortMode = &exitPortMode
|
||
var exitServer *Server
|
||
for _, item := range data.Servers {
|
||
if item.ID == strVal(exitServerID) {
|
||
exitServer = item
|
||
break
|
||
}
|
||
}
|
||
currentExitPortMode := "random"
|
||
var currentExitPort *int
|
||
if current != nil {
|
||
if strVal(current.ExitPortMode) != "" {
|
||
currentExitPortMode = strVal(current.ExitPortMode)
|
||
}
|
||
currentExitPort = current.ExitPort
|
||
}
|
||
_, exitPortModeInBody := body["exitPortMode"]
|
||
_, exitPortInBody := body["exitPort"]
|
||
exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode
|
||
// Same stickiness rule as the entry port: an assigned exit port is
|
||
// only re-selected on creation or an explicit addressing change.
|
||
exitPortValueChanged := false
|
||
if exitPortInBody && !jsFalsy(body["exitPort"]) && currentExitPort != nil {
|
||
if f, valid := anyToJSNumber(body["exitPort"]); valid && int(f) != *currentExitPort {
|
||
exitPortValueChanged = true
|
||
}
|
||
}
|
||
exitServerChanged := current != nil && strVal(exitServerID) != strVal(current.ExitServerID)
|
||
switch {
|
||
case current == nil || currentExitPort == nil:
|
||
var requested any
|
||
if exitPortInBody {
|
||
requested = body["exitPort"]
|
||
} else if currentExitPort != nil {
|
||
requested = json.Number(fmt.Sprint(*currentExitPort))
|
||
}
|
||
port, err := SelectPort(data, exitServer, exitPortMode, requested, currentID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.ExitPort = &port
|
||
case exitPortModeChanged && exitPortMode == "random":
|
||
port, err := SelectPort(data, exitServer, exitPortMode, nil, currentID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.ExitPort = &port
|
||
case exitPortValueChanged && exitPortMode == "fixed":
|
||
port, err := SelectPort(data, exitServer, exitPortMode, body["exitPort"], currentID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.ExitPort = &port
|
||
case exitServerChanged && NodePortInUse(data, strVal(exitServerID), *currentExitPort, currentID):
|
||
var requested any
|
||
if exitPortMode == "fixed" {
|
||
requested = json.Number(fmt.Sprint(*currentExitPort))
|
||
}
|
||
port, err := SelectPort(data, exitServer, exitPortMode, requested, currentID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.ExitPort = &port
|
||
}
|
||
} else {
|
||
next.ExitPortMode = nil
|
||
next.ExitPort = nil
|
||
}
|
||
if direct {
|
||
if herr := ValidateEntryProtocol(protocol); herr != nil {
|
||
return nil, herr
|
||
}
|
||
} else {
|
||
if herr := ValidateProtocolPair(protocol, strVal(next.ExitProtocol)); herr != nil {
|
||
return nil, herr
|
||
}
|
||
}
|
||
if IsRealityProtocol(protocol) && next.RealityServerName == "" {
|
||
return nil, httpErr(400, "Reality SNI 不能为空")
|
||
}
|
||
if IsRealityProtocol(protocol) && (next.RealityDestPort < 1 || next.RealityDestPort > 65535) {
|
||
return nil, httpErr(400, "Reality 目标端口无效")
|
||
}
|
||
return &next, nil
|
||
}
|
||
|
||
// ---------- public shapers ----------
|
||
|
||
// PublicNode is publicNode: credentials stripped, error redacted. Built via
|
||
// the record's MarshalJSON so unknown (Extra) fields pass through.
|
||
func PublicNode(item *Node) map[string]any {
|
||
out := recordToMap(item)
|
||
delete(out, "exitCredentials")
|
||
delete(out, "ssServerKey")
|
||
if s, ok := out["error"].(string); ok && s != "" {
|
||
out["error"] = RedactSecrets(s)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// PublicServer is publicServer: engine detail redacted, plus agentVersion
|
||
// and pendingCleanup computed against data.
|
||
func PublicServer(item *Server, data *Data) map[string]any {
|
||
out := recordToMap(item)
|
||
if eng, ok := out["engine"].(map[string]any); ok && eng != nil {
|
||
copied := map[string]any{}
|
||
for k, v := range eng {
|
||
copied[k] = v
|
||
}
|
||
if d, isStr := copied["detail"].(string); isStr {
|
||
copied["detail"] = RedactSecrets(d)
|
||
}
|
||
out["engine"] = copied
|
||
}
|
||
var version any
|
||
for _, agent := range data.Agents {
|
||
if agent.ServerID == item.ID && agent.Status == "active" {
|
||
version = agent.Version
|
||
break
|
||
}
|
||
}
|
||
out["agentVersion"] = version
|
||
pending := 0
|
||
for _, n := range data.Nodes {
|
||
if (n.ServerID == item.ID || strVal(n.ExitServerID) == item.ID) && n.Status == "removing" {
|
||
pending++
|
||
}
|
||
}
|
||
out["pendingCleanup"] = pending
|
||
out["enabled"] = item.Enabled == nil || *item.Enabled
|
||
return out
|
||
}
|
||
|
||
// PublicCustomer is publicCustomer plus observed/subscription counters.
|
||
func PublicCustomer(item *Customer, data *Data) map[string]any {
|
||
out := recordToMap(item)
|
||
out["observedIpCount"] = len(ObservedIPs(data, item.ID, time.Now()))
|
||
out["subscriptionClientCount"] = len(ActiveClients(data, item.ID, time.Now()))
|
||
if len(item.ExternalSources) > 0 {
|
||
status := map[string]any{}
|
||
for _, source := range item.ExternalSources {
|
||
entry := data.SubSourceCache[source.ID]
|
||
summary := map[string]any{
|
||
"fetchedAt": nil, "okAt": nil, "failCount": 0,
|
||
"lastError": nil, "droppedCount": 0, "count": 0,
|
||
}
|
||
if entry != nil {
|
||
if entry.FetchedAt != nil {
|
||
summary["fetchedAt"] = *entry.FetchedAt
|
||
}
|
||
if entry.OkAt != nil {
|
||
summary["okAt"] = *entry.OkAt
|
||
}
|
||
summary["failCount"] = entry.FailCount
|
||
if entry.LastError != nil {
|
||
summary["lastError"] = *entry.LastError
|
||
}
|
||
summary["droppedCount"] = entry.DroppedCount
|
||
summary["count"] = len(entry.Items)
|
||
}
|
||
status[source.ID] = summary
|
||
}
|
||
out["externalStatus"] = status
|
||
}
|
||
return out
|
||
}
|
||
|
||
// credentialItems mirrors credentialItems in server.js.
|
||
func credentialItems(d *Data, customer *Customer) []map[string]any {
|
||
items := []map[string]any{}
|
||
for _, nc := range d.NodeClients {
|
||
if nc.CustomerID != customer.ID {
|
||
continue
|
||
}
|
||
node := findNode(d, nc.NodeID)
|
||
if node == nil {
|
||
continue
|
||
}
|
||
username := ""
|
||
if node.Protocol == "socks5-auth" {
|
||
username = NodeEmail(customer.ID)
|
||
}
|
||
creds := nc.Credentials
|
||
var clientID, password, shortID any
|
||
if creds.ClientID != "" {
|
||
clientID = creds.ClientID
|
||
}
|
||
if creds.RelayPassword != "" {
|
||
password = creds.RelayPassword
|
||
}
|
||
if creds.ShortID != "" {
|
||
shortID = creds.ShortID
|
||
}
|
||
items = append(items, map[string]any{
|
||
"nodeClientId": nc.ID,
|
||
"nodeId": node.ID,
|
||
"nodeName": node.Name,
|
||
"protocol": node.Protocol,
|
||
"status": nc.Status,
|
||
"clientId": clientID,
|
||
"password": password,
|
||
"shortId": shortID,
|
||
"username": username,
|
||
})
|
||
}
|
||
return items
|
||
}
|
||
|
||
// CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" /
|
||
// "ip_limit", or "" for none (JS null).
|
||
func CustomerBlockReason(data *Data, customer *Customer) string {
|
||
if customer.ExpiresAt != nil {
|
||
if ms, ok := ParseISOMs(strVal(customer.ExpiresAt)); ok && ms <= time.Now().UnixMilli() {
|
||
return "expired"
|
||
}
|
||
}
|
||
if customer.TrafficLimitBytes > 0 && float64(customer.UsedBytes) >= customer.TrafficLimitBytes {
|
||
return "traffic_limit"
|
||
}
|
||
if customer.IPLimit > 0 {
|
||
seen := map[string]bool{}
|
||
for _, obs := range ObservedIPs(data, customer.ID, time.Now()) {
|
||
seen[obs.IP] = true
|
||
}
|
||
if int64(len(seen)) > customer.IPLimit {
|
||
return "ip_limit"
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// GetIP is getIp: first x-forwarded-for segment, else the peer address.
|
||
func GetIP(r *http.Request) string {
|
||
first := strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]
|
||
if first == "" {
|
||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||
if err != nil {
|
||
host = r.RemoteAddr
|
||
}
|
||
first = host
|
||
}
|
||
return CleanTextStr(first, 80)
|
||
}
|
||
|
||
// ---------- admin auth ----------
|
||
|
||
type adminAuth struct {
|
||
Session *Session
|
||
User *User
|
||
}
|
||
|
||
// CurrentAdmin is currentAdmin.
|
||
func (a *App) CurrentAdmin(r *http.Request) *adminAuth {
|
||
session := a.getSessions().Get(r)
|
||
if session == nil {
|
||
return nil
|
||
}
|
||
data := a.store.Snapshot()
|
||
for _, user := range data.Users {
|
||
if user.ID == session.UserID && user.Status == "active" {
|
||
return &adminAuth{Session: session, User: user}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RequireAdmin is requireAdmin: sends 401/403 itself and returns nil.
|
||
func (a *App) RequireAdmin(w http.ResponseWriter, r *http.Request, csrf bool) *adminAuth {
|
||
auth := a.CurrentAdmin(r)
|
||
if auth == nil {
|
||
SendError(w, 401, "请先登录", "unauthorized")
|
||
return nil
|
||
}
|
||
if csrf && r.Header.Get("X-CSRF-Token") != auth.Session.CSRF {
|
||
SendError(w, 403, "请求校验失败,请刷新页面后重试", "csrf_failed")
|
||
return nil
|
||
}
|
||
return auth
|
||
}
|
||
|
||
// FindAgentByBearer is findAgentByBearer: fingerprint fast path, then a
|
||
// full VerifySecret scan over legacy agents without a fingerprint.
|
||
func (a *App) FindAgentByBearer(r *http.Request) *Agent {
|
||
header := r.Header.Get("Authorization")
|
||
if !strings.HasPrefix(header, "Bearer ") {
|
||
return nil
|
||
}
|
||
token := header[len("Bearer "):]
|
||
fingerprint := TokenFingerprint(token)
|
||
data := a.store.Snapshot()
|
||
var fast *Agent
|
||
for _, agent := range data.Agents {
|
||
if agent.Status == "active" && agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint {
|
||
fast = agent
|
||
break
|
||
}
|
||
}
|
||
if fast != nil && VerifySecret(token, fast.KeyHash) {
|
||
return fast
|
||
}
|
||
for _, agent := range data.Agents {
|
||
if agent.Status == "active" && agent.KeyFingerprint == nil && VerifySecret(token, agent.KeyHash) {
|
||
return agent
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func clearSessionCookie() map[string]string {
|
||
return map[string]string{"Set-Cookie": "ng_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"}
|
||
}
|
||
|
||
// ---------- auth routes ----------
|
||
|
||
func (a *App) handleAuth(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) {
|
||
if r.Method == http.MethodPost && pathname == "/api/auth/login" {
|
||
ip := GetIP(r)
|
||
now := time.Now().UnixMilli()
|
||
a.loginMu.Lock()
|
||
record := a.loginAttempts[ip]
|
||
blocked := record != nil && record.BlockedUntil > now
|
||
a.loginMu.Unlock()
|
||
if blocked {
|
||
SendError(w, 429, "尝试次数过多,请稍后再试", "rate_limited")
|
||
return true, nil
|
||
}
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
username := CleanText(body["username"], 64)
|
||
data := a.store.Snapshot()
|
||
var user *User
|
||
for _, item := range data.Users {
|
||
if item.Username == username && item.Status == "active" {
|
||
user = item
|
||
break
|
||
}
|
||
}
|
||
if user == nil || !VerifySecret(secretString(body["password"]), user.PasswordHash) {
|
||
a.loginMu.Lock()
|
||
rec := a.loginAttempts[ip]
|
||
if rec == nil {
|
||
rec = &loginAttempt{}
|
||
a.loginAttempts[ip] = rec
|
||
}
|
||
rec.Count++
|
||
if rec.Count >= 6 {
|
||
rec.BlockedUntil = now + 15*60*1000
|
||
rec.Count = 0
|
||
}
|
||
a.loginMu.Unlock()
|
||
SendError(w, 401, "账号或密码不正确", "invalid_credentials")
|
||
return true, nil
|
||
}
|
||
a.loginMu.Lock()
|
||
delete(a.loginAttempts, ip)
|
||
a.loginMu.Unlock()
|
||
sessions := a.getSessions()
|
||
session := sessions.Create(user.ID)
|
||
secure := ""
|
||
if cookieSecure {
|
||
secure = "; Secure"
|
||
}
|
||
cookie := fmt.Sprintf("ng_session=%s; HttpOnly; SameSite=Strict; Path=/; Max-Age=%d%s",
|
||
session.ID, int64(sessions.TTL/time.Second), secure)
|
||
SendJSON(w, 200, map[string]any{
|
||
"user": map[string]any{"id": user.ID, "username": user.Username, "role": user.Role},
|
||
"csrf": session.CSRF,
|
||
}, map[string]string{"Set-Cookie": cookie})
|
||
return true, nil
|
||
}
|
||
if r.Method == http.MethodPost && pathname == "/api/auth/logout" {
|
||
auth := a.RequireAdmin(w, r, true)
|
||
if auth == nil {
|
||
return true, nil
|
||
}
|
||
a.getSessions().Delete(r)
|
||
SendJSON(w, 200, map[string]any{"ok": true}, clearSessionCookie())
|
||
return true, nil
|
||
}
|
||
if r.Method == http.MethodGet && pathname == "/api/session" {
|
||
auth := a.CurrentAdmin(r)
|
||
if auth == nil {
|
||
SendJSON(w, 200, map[string]any{"authenticated": false}, nil)
|
||
} else {
|
||
SendJSON(w, 200, map[string]any{
|
||
"authenticated": true,
|
||
"csrf": auth.Session.CSRF,
|
||
"user": map[string]any{"id": auth.User.ID, "username": auth.User.Username, "role": auth.User.Role},
|
||
}, nil)
|
||
}
|
||
return true, nil
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// ---------- agent routes ----------
|
||
|
||
// rawSystemValue mirrors `body.system || {}` for the RawMessage system blob.
|
||
func rawSystemValue(v any) json.RawMessage {
|
||
switch t := v.(type) {
|
||
case nil:
|
||
return json.RawMessage("{}")
|
||
case bool:
|
||
if !t {
|
||
return json.RawMessage("{}")
|
||
}
|
||
case string:
|
||
if t == "" {
|
||
return json.RawMessage("{}")
|
||
}
|
||
case json.Number:
|
||
if t.String() == "0" {
|
||
return json.RawMessage("{}")
|
||
}
|
||
}
|
||
raw, err := marshalJSON(v)
|
||
if err != nil {
|
||
return json.RawMessage("{}")
|
||
}
|
||
return raw
|
||
}
|
||
|
||
func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) {
|
||
if r.Method == http.MethodPost && pathname == "/api/agent/enroll" {
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
token := JSString(body["token"])
|
||
data := a.store.Snapshot()
|
||
var enrollment *EnrollmentToken
|
||
nowMs := time.Now().UnixMilli()
|
||
for _, item := range data.EnrollmentTokens {
|
||
if item.UsedAt != nil {
|
||
continue
|
||
}
|
||
expMs, ok := ParseISOMs(item.ExpiresAt)
|
||
if !ok || expMs <= nowMs {
|
||
continue
|
||
}
|
||
if VerifySecret(token, item.TokenHash) {
|
||
enrollment = item
|
||
break
|
||
}
|
||
}
|
||
if enrollment == nil {
|
||
SendError(w, 401, "注册令牌无效或已过期", "invalid_enrollment")
|
||
return true, nil
|
||
}
|
||
agentKey := RandomToken(40)
|
||
resultAny, err := a.store.Transaction(func(d *Data) (any, error) {
|
||
var target *EnrollmentToken
|
||
for _, item := range d.EnrollmentTokens {
|
||
if item.ID == enrollment.ID {
|
||
target = item
|
||
break
|
||
}
|
||
}
|
||
if target == nil || target.UsedAt != nil {
|
||
return nil, httpErr(409, "注册令牌已被使用")
|
||
}
|
||
now := NowISO()
|
||
target.UsedAt = &now
|
||
var server *Server
|
||
for _, item := range d.Servers {
|
||
if item.ID == target.ServerID {
|
||
server = item
|
||
break
|
||
}
|
||
}
|
||
if server == nil {
|
||
return nil, httpErr(404, "目标服务器不存在")
|
||
}
|
||
for _, old := range d.Agents {
|
||
if old.ServerID == server.ID {
|
||
old.Status = "revoked"
|
||
}
|
||
}
|
||
fp := TokenFingerprint(agentKey)
|
||
agent := &Agent{
|
||
ID: NewID("agt"),
|
||
ServerID: server.ID,
|
||
KeyHash: HashSecret(agentKey),
|
||
KeyFingerprint: &fp,
|
||
Status: "active",
|
||
Hostname: CleanText(body["hostname"], 128),
|
||
Version: CleanText(body["version"], 32),
|
||
CreatedAt: now,
|
||
LastSeenAt: now,
|
||
}
|
||
d.Agents = append(d.Agents, agent)
|
||
server.Status = "online"
|
||
server.LastSeenAt = &now
|
||
server.System = rawSystemValue(body["system"])
|
||
server.UpdatedAt = now
|
||
Audit(d, "agent:"+agent.ID, "enroll_agent", server.ID, nil)
|
||
return map[string]any{"agentId": agent.ID, "serverId": server.ID, "serverName": server.Name}, nil
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
result := resultAny.(map[string]any)
|
||
reconciled := 0
|
||
if n, rerr := a.orchestrator.ReconcileServer(result["serverId"].(string), "agent:"+result["agentId"].(string)); rerr != nil {
|
||
fmt.Fprintf(os.Stderr, "Agent reconciliation failed: %v\n", rerr)
|
||
} else {
|
||
reconciled = n
|
||
}
|
||
SendJSON(w, 201, map[string]any{
|
||
"agentId": result["agentId"],
|
||
"serverId": result["serverId"],
|
||
"serverName": result["serverName"],
|
||
"agentKey": agentKey,
|
||
"reconciled": reconciled,
|
||
}, nil)
|
||
return true, nil
|
||
}
|
||
|
||
if !strings.HasPrefix(pathname, "/api/agent/") {
|
||
return false, nil
|
||
}
|
||
agent := a.FindAgentByBearer(r)
|
||
if agent == nil {
|
||
SendError(w, 401, "Agent 身份校验失败", "invalid_agent")
|
||
return true, nil
|
||
}
|
||
|
||
if r.Method == http.MethodPost && pathname == "/api/agent/heartbeat" {
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
a.store.Transient(func(d *Data) {
|
||
now := NowISO()
|
||
var current *Agent
|
||
for _, item := range d.Agents {
|
||
if item.ID == agent.ID {
|
||
current = item
|
||
break
|
||
}
|
||
}
|
||
var server *Server
|
||
for _, item := range d.Servers {
|
||
if item.ID == agent.ServerID {
|
||
server = item
|
||
break
|
||
}
|
||
}
|
||
if current != nil {
|
||
current.LastSeenAt = now
|
||
version := JSString(body["version"])
|
||
if version == "" {
|
||
version = current.Version
|
||
}
|
||
current.Version = CleanTextStr(version, 32)
|
||
}
|
||
if server != nil {
|
||
server.Status = "online"
|
||
server.LastSeenAt = &now
|
||
if sys, ok := body["system"]; ok && jsTruthy(sys) {
|
||
server.System = rawSystemValue(sys)
|
||
} else if len(server.System) == 0 {
|
||
server.System = json.RawMessage("{}")
|
||
}
|
||
server.EnginePresent = true
|
||
if eng, ok := body["engine"].(map[string]any); ok {
|
||
status, _ := eng["status"].(string)
|
||
if status == "ready" || status == "error" {
|
||
certs := []string{}
|
||
if arr, isArr := eng["certificates"].([]any); isArr {
|
||
for _, v := range arr[:min(50, len(arr))] {
|
||
c := strings.ToLower(CleanText(v, 253))
|
||
if certDomainRe.MatchString(c) {
|
||
certs = append(certs, c)
|
||
}
|
||
}
|
||
}
|
||
singBoxStatus := "inactive"
|
||
if s, _ := eng["singBoxStatus"].(string); s == "ready" {
|
||
singBoxStatus = "ready"
|
||
}
|
||
installed, _ := eng["singBoxInstalled"].(bool)
|
||
server.Engine = &EngineState{
|
||
Status: status,
|
||
Detail: RedactSecrets(CleanText(eng["detail"], 400)),
|
||
SingBoxInstalled: installed,
|
||
Certificates: certs,
|
||
SingBoxStatus: singBoxStatus,
|
||
At: now,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
if arr, ok := body["resources"].([]any); ok {
|
||
resources := []string{}
|
||
for _, v := range arr {
|
||
if s, isStr := v.(string); isStr {
|
||
resources = append(resources, s)
|
||
}
|
||
}
|
||
if _, rerr := a.orchestrator.ReconcileFromHeartbeat(agent.ServerID, resources); rerr != nil {
|
||
fmt.Fprintf(os.Stderr, "Heartbeat reconciliation failed: %v\n", rerr)
|
||
}
|
||
}
|
||
SendJSON(w, 200, map[string]any{"ok": true, "serverTime": NowISO()}, nil)
|
||
return true, nil
|
||
}
|
||
|
||
if r.Method == http.MethodPost && pathname == "/api/agent/tls-domain" {
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
domainText, err := RequiredText(body["domain"], "节点 TLS 域名", 253)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
domain := strings.ToLower(domainText)
|
||
_, err = a.store.Transaction(func(d *Data) (any, error) {
|
||
var server *Server
|
||
for _, item := range d.Servers {
|
||
if item.ID == agent.ServerID {
|
||
server = item
|
||
break
|
||
}
|
||
}
|
||
if server == nil {
|
||
return nil, httpErr(404, "设备不存在")
|
||
}
|
||
if _, verr := NormalizeServer(map[string]any{"tlsDomain": domain}, server); verr != nil {
|
||
return nil, verr
|
||
}
|
||
if server.TLSDomain != "" && server.TLSDomain != domain {
|
||
for _, node := range d.Nodes {
|
||
if node.ServerID != server.ID {
|
||
continue
|
||
}
|
||
if node.Protocol != "anytls" && node.Protocol != "hysteria2" && node.Protocol != "vless-ws-tls" {
|
||
continue
|
||
}
|
||
if node.Status == "active" || node.Status == "deploying" {
|
||
return nil, httpErr(409, "设备已有使用其他域名的 TLS 节点;请先处理现有节点")
|
||
}
|
||
}
|
||
}
|
||
if server.TLSDomain != domain {
|
||
server.TLSDomain = domain
|
||
server.UpdatedAt = NowISO()
|
||
Audit(d, "agent:"+agent.ID, "set_tls_domain", server.ID, map[string]any{"domain": domain})
|
||
}
|
||
if server.Engine == nil {
|
||
server.Engine = &EngineState{}
|
||
}
|
||
found := false
|
||
for _, c := range server.Engine.Certificates {
|
||
if c == domain {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
server.Engine.Certificates = append(server.Engine.Certificates, domain)
|
||
}
|
||
return nil, nil
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"ok": true, "domain": domain}, nil)
|
||
return true, nil
|
||
}
|
||
|
||
if r.Method == http.MethodPost && pathname == "/api/agent/poll" {
|
||
data := a.store.Snapshot()
|
||
hasQueued := false
|
||
for _, job := range data.Jobs {
|
||
if job.ServerID == agent.ServerID && job.Status == "queued" {
|
||
hasQueued = true
|
||
break
|
||
}
|
||
}
|
||
if !hasQueued {
|
||
SendJSON(w, 200, map[string]any{"job": nil}, nil)
|
||
return true, nil
|
||
}
|
||
pickedAny, err := a.store.Transaction(func(d *Data) (any, error) {
|
||
for _, job := range d.Jobs {
|
||
if job.ServerID == agent.ServerID && job.Status == "queued" {
|
||
now := NowISO()
|
||
job.Status = "running"
|
||
job.Attempts++
|
||
job.StartedAt = &now
|
||
job.UpdatedAt = now
|
||
lease := ISOFromTime(time.Now().Add(5 * time.Minute))
|
||
job.LeaseUntil = &lease
|
||
return cloneJSONValue(job), nil
|
||
}
|
||
}
|
||
return nil, ErrSkip
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"job": pickedAny}, nil)
|
||
return true, nil
|
||
}
|
||
|
||
if params, ok := RoutePattern("/api/agent/jobs/:id/complete", pathname); ok && r.Method == http.MethodPost {
|
||
data := a.store.Snapshot()
|
||
var job *Job
|
||
for _, item := range data.Jobs {
|
||
if item.ID == params["id"] && item.ServerID == agent.ServerID {
|
||
job = item
|
||
break
|
||
}
|
||
}
|
||
if job == nil {
|
||
SendError(w, 404, "任务不存在", "not_found")
|
||
return true, nil
|
||
}
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
artifacts := map[string]json.RawMessage{}
|
||
if resMap, ok := body["result"].(map[string]any); ok {
|
||
if artMap, ok := resMap["artifacts"].(map[string]any); ok {
|
||
for k, v := range artMap {
|
||
raw, merr := marshalJSON(v)
|
||
if merr == nil {
|
||
artifacts[k] = raw
|
||
}
|
||
}
|
||
}
|
||
}
|
||
var errPtr *string
|
||
if s := JSString(body["error"]); s != "" {
|
||
errPtr = &s
|
||
}
|
||
updated, err := a.orchestrator.CompleteJob(job.ID, jsTruthy(body["success"]), CompleteJobResult{Artifacts: artifacts}, errPtr)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"job": updated}, nil)
|
||
return true, nil
|
||
}
|
||
|
||
if r.Method == http.MethodPost && pathname == "/api/agent/usage" {
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
epoch := CleanText(body["epoch"], 80)
|
||
samples := []UsageSample{}
|
||
if arr, ok := body["samples"].([]any); ok {
|
||
for _, item := range arr {
|
||
m, isMap := item.(map[string]any)
|
||
if !isMap {
|
||
continue
|
||
}
|
||
sampleEpoch := epoch
|
||
if s, isStr := m["epoch"].(string); isStr {
|
||
sampleEpoch = truncateRunes(s, 80)
|
||
}
|
||
sample := UsageSample{
|
||
Kind: JSString(m["kind"]),
|
||
ID: JSString(m["id"]),
|
||
Uplink: toJSONNumber(m["uplink"]),
|
||
Downlink: toJSONNumber(m["downlink"]),
|
||
Epoch: &sampleEpoch,
|
||
}
|
||
samples = append(samples, sample)
|
||
}
|
||
}
|
||
errText := RedactSecrets(CleanText(body["error"], 240))
|
||
if err := a.orchestrator.RecordUsage(agent.ServerID, samples, errText, epoch); err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"ok": true}, nil)
|
||
return true, nil
|
||
}
|
||
|
||
if r.Method == http.MethodPost && pathname == "/api/agent/observations" {
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
_, err = a.store.Transaction(func(d *Data) (any, error) {
|
||
ttlMinutes := d.Settings.ObservationTtlMinutes
|
||
if ttlMinutes == 0 {
|
||
ttlMinutes = 10
|
||
}
|
||
cutoff := time.Now().UnixMilli() - int64(ttlMinutes*60000)
|
||
kept := d.Observations[:0]
|
||
for _, item := range d.Observations {
|
||
if ms, ok := ParseISOMs(item.LastSeenAt); ok && ms > cutoff {
|
||
kept = append(kept, item)
|
||
}
|
||
}
|
||
d.Observations = kept
|
||
affected := []string{}
|
||
affectedSet := map[string]bool{}
|
||
samples, _ := body["observations"].([]any)
|
||
if len(samples) > 1000 {
|
||
samples = samples[:1000]
|
||
}
|
||
now := NowISO()
|
||
for _, rawSample := range samples {
|
||
m, isMap := rawSample.(map[string]any)
|
||
if !isMap {
|
||
continue
|
||
}
|
||
customerID := CleanText(m["customerId"], 100)
|
||
ip := CleanText(m["ip"], 64)
|
||
ip = strings.TrimPrefix(ip, "[")
|
||
ip = strings.TrimSuffix(ip, "]")
|
||
if net.ParseIP(ip) == nil {
|
||
continue
|
||
}
|
||
valid := false
|
||
for _, nc := range d.NodeClients {
|
||
if nc.CustomerID != customerID || nc.Status != "active" {
|
||
continue
|
||
}
|
||
for _, node := range d.Nodes {
|
||
if node.ID == nc.NodeID && node.ServerID == agent.ServerID && node.Status == "active" {
|
||
valid = true
|
||
break
|
||
}
|
||
}
|
||
if valid {
|
||
break
|
||
}
|
||
}
|
||
if !valid {
|
||
continue
|
||
}
|
||
var existing *Observation
|
||
for _, item := range d.Observations {
|
||
if item.CustomerID == customerID && item.IP == ip {
|
||
existing = item
|
||
break
|
||
}
|
||
}
|
||
if existing != nil {
|
||
existing.LastSeenAt = now
|
||
} else {
|
||
d.Observations = append(d.Observations, &Observation{
|
||
ID: NewID("obs"),
|
||
CustomerID: customerID,
|
||
IP: ip,
|
||
FirstSeen: now,
|
||
LastSeenAt: now,
|
||
})
|
||
}
|
||
if 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}, 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(),
|
||
}
|
||
// 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, map[string]any{
|
||
"customerId": customer.ID,
|
||
"subscriptionToken": customer.SubscriptionToken,
|
||
"items": credentialItems(data, customer),
|
||
}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/customers/:id/credentials/:ncid/regenerate", pathname); ok && r.Method == http.MethodPost {
|
||
var responseItem map[string]any
|
||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||
customer := findCustomer(d, params["id"])
|
||
if customer == nil {
|
||
return nil, httpErr(404, "客户不存在")
|
||
}
|
||
var binding *NodeClient
|
||
for _, nc := range d.NodeClients {
|
||
if nc.ID == params["ncid"] && nc.CustomerID == customer.ID {
|
||
binding = nc
|
||
break
|
||
}
|
||
}
|
||
if binding == nil {
|
||
return nil, httpErr(404, "凭据不存在")
|
||
}
|
||
node := findNode(d, binding.NodeID)
|
||
if node == nil {
|
||
return nil, httpErr(404, "节点不存在")
|
||
}
|
||
binding.Credentials = NewNodeClientCredentials(node.Protocol)
|
||
binding.UpdatedAt = NowISO()
|
||
if node.Status != "draft" && node.Status != "removing" {
|
||
if err := RebuildNode(d, node, false); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodeId": node.ID, "node": node.Name})
|
||
for _, entry := range credentialItems(d, customer) {
|
||
if entry["nodeClientId"] == binding.ID {
|
||
responseItem = entry
|
||
break
|
||
}
|
||
}
|
||
return nil, nil
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"item": responseItem}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/customers/:id/external-sources/:sid/refresh", pathname); ok && r.Method == http.MethodPost {
|
||
result := a.RefreshOneSource(params["id"], params["sid"])
|
||
if result == nil {
|
||
return true, httpErr(404, "订阅源不存在")
|
||
}
|
||
SendJSON(w, 200, map[string]any{
|
||
"ok": result.OK, "count": len(result.Items), "dropped": result.Dropped, "error": strOrNil(result.Error),
|
||
}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/customers/:id/reset-usage", pathname); ok && r.Method == http.MethodPost {
|
||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||
item := findCustomer(d, params["id"])
|
||
if item == nil {
|
||
return nil, httpErr(404, "客户不存在")
|
||
}
|
||
item.UsedBytes = 0
|
||
item.UsedUplinkBytes = 0
|
||
item.UsedDownlinkBytes = 0
|
||
if item.Status == "suspended" && strVal(item.SuspendReason) == "traffic_limit" && CustomerBlockReason(d, item) == "" {
|
||
item.Status = "active"
|
||
item.SuspendReason = nil
|
||
item.SuspendReasonPresent = true
|
||
if _, err := ResumeCustomerResources(d, item, actor); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
item.UpdatedAt = NowISO()
|
||
Audit(d, actor, "reset_customer_usage", item.ID, nil)
|
||
return nil, nil
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"ok": true}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/customers/:id/rotate-subscription", pathname); ok && r.Method == http.MethodPost {
|
||
customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
|
||
item := findCustomer(d, params["id"])
|
||
if item == nil {
|
||
return nil, httpErr(404, "客户不存在")
|
||
}
|
||
item.SubscriptionToken = RandomToken(32)
|
||
item.UpdatedAt = NowISO()
|
||
Audit(d, actor, "rotate_subscription", item.ID, nil)
|
||
return PublicCustomer(item, d), nil
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"customer": customerAny}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/customers/:id", pathname); ok && r.Method == http.MethodDelete {
|
||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||
index := -1
|
||
for i, item := range d.Customers {
|
||
if item.ID == params["id"] {
|
||
index = i
|
||
break
|
||
}
|
||
}
|
||
if index < 0 {
|
||
return nil, httpErr(404, "客户不存在")
|
||
}
|
||
for _, nc := range d.NodeClients {
|
||
if nc.CustomerID != params["id"] {
|
||
continue
|
||
}
|
||
if node := findNode(d, nc.NodeID); node != nil && !node.PendingDelete {
|
||
return nil, httpErr(409, "客户仍有节点分配。请先移除全部分配")
|
||
}
|
||
}
|
||
removedCustomer := d.Customers[index]
|
||
for _, source := range removedCustomer.ExternalSources {
|
||
if d.SubSourceCache != nil {
|
||
delete(d.SubSourceCache, source.ID)
|
||
}
|
||
}
|
||
d.Customers = append(d.Customers[:index], d.Customers[index+1:]...)
|
||
access := d.SubscriptionAccess[:0]
|
||
for _, item := range d.SubscriptionAccess {
|
||
if item.CustomerID != params["id"] {
|
||
access = append(access, item)
|
||
}
|
||
}
|
||
d.SubscriptionAccess = access
|
||
clients := d.SubscriptionClients[:0]
|
||
for _, item := range d.SubscriptionClients {
|
||
if item.CustomerID != params["id"] {
|
||
clients = append(clients, item)
|
||
}
|
||
}
|
||
d.SubscriptionClients = clients
|
||
obs := d.Observations[:0]
|
||
for _, item := range d.Observations {
|
||
if item.CustomerID != params["id"] {
|
||
obs = append(obs, item)
|
||
}
|
||
}
|
||
d.Observations = obs
|
||
Audit(d, actor, "delete_customer", params["id"], nil)
|
||
return nil, nil
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"ok": true}, nil)
|
||
return true, nil
|
||
}
|
||
|
||
if r.Method == http.MethodGet && pathname == "/api/nodes" {
|
||
data := a.store.Snapshot()
|
||
nowMs := time.Now().UnixMilli()
|
||
sortedNodes := append([]*Node{}, data.Nodes...)
|
||
sort.SliceStable(sortedNodes, func(i, j int) bool { return sortedNodes[i].SortIndex < sortedNodes[j].SortIndex })
|
||
nodes := []map[string]any{}
|
||
visibleIDs := map[string]bool{}
|
||
for _, item := range sortedNodes {
|
||
if item.PendingDelete {
|
||
continue
|
||
}
|
||
visibleIDs[item.ID] = true
|
||
out := PublicNode(item)
|
||
clientCount := 0
|
||
for _, nc := range data.NodeClients {
|
||
if nc.NodeID == item.ID {
|
||
clientCount++
|
||
}
|
||
}
|
||
onlineIDs := NodeOnlineCustomerIds(data, item, nowMs)
|
||
out["clientCount"] = clientCount
|
||
out["onlineCount"] = len(onlineIDs)
|
||
out["onlineCustomerIds"] = onlineIDs
|
||
up, down := NodeSpeedBps(item, nowMs)
|
||
out["speedUpBps"] = up
|
||
out["speedDownBps"] = down
|
||
nodes = append(nodes, out)
|
||
}
|
||
nodeClients := []map[string]any{}
|
||
for _, nc := range data.NodeClients {
|
||
if !visibleIDs[nc.NodeID] {
|
||
continue
|
||
}
|
||
m := recordToMap(nc)
|
||
delete(m, "credentials")
|
||
uri := NodeClientURI(data, nc)
|
||
if uri == "" {
|
||
m["clientUri"] = nil
|
||
} else {
|
||
m["clientUri"] = uri
|
||
}
|
||
nodeClients = append(nodeClients, m)
|
||
}
|
||
SendJSON(w, 200, map[string]any{"nodes": nodes, "nodeClients": nodeClients}, nil)
|
||
return true, nil
|
||
}
|
||
if r.Method == http.MethodPost && pathname == "/api/nodes/reorder" {
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
_, err = a.store.Transaction(func(d *Data) (any, error) {
|
||
ids := asIds(body["ids"])
|
||
byID := map[string]*Node{}
|
||
for _, item := range d.Nodes {
|
||
byID[item.ID] = item
|
||
}
|
||
ordered := []*Node{}
|
||
seen := map[string]bool{}
|
||
for _, id := range ids {
|
||
if node := byID[id]; node != nil && !seen[id] {
|
||
seen[id] = true
|
||
ordered = append(ordered, node)
|
||
}
|
||
}
|
||
rest := append([]*Node{}, d.Nodes...)
|
||
sort.SliceStable(rest, func(i, j int) bool { return rest[i].SortIndex < rest[j].SortIndex })
|
||
for _, item := range rest {
|
||
if !seen[item.ID] {
|
||
ordered = append(ordered, item)
|
||
}
|
||
}
|
||
for index, item := range ordered {
|
||
item.SortIndex = index
|
||
}
|
||
Audit(d, actor, "reorder_nodes", "nodes", map[string]any{"count": len(ordered)})
|
||
return nil, nil
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"ok": true}, nil)
|
||
return true, nil
|
||
}
|
||
if r.Method == http.MethodPost && pathname == "/api/nodes" {
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
nodeAny, err := a.store.Transaction(func(d *Data) (any, error) {
|
||
normalized, err := NormalizeNode(body, nil, d)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next := normalized
|
||
next.ID = NewID("nod")
|
||
next.Status = "draft"
|
||
next.EntryReady = false
|
||
next.ExitReady = false
|
||
next.ResourceID = NewID("res")
|
||
next.ExitResourceID = NewID("res")
|
||
next.Artifacts = map[string]json.RawMessage{}
|
||
next.Error = nil
|
||
next.PendingDelete = false
|
||
next.UsageCounters = nil
|
||
next.RetiredUsageEpochs = []string{}
|
||
next.MeteredTraffic = nil
|
||
next.LastUsageAt = nil
|
||
next.CreatedAt = NowISO()
|
||
next.UpdatedAt = NowISO()
|
||
EnsureNodeSecrets(next)
|
||
maxIndex := -1
|
||
for _, item := range d.Nodes {
|
||
if item.SortIndex > maxIndex {
|
||
maxIndex = item.SortIndex
|
||
}
|
||
}
|
||
next.SortIndex = maxIndex + 1
|
||
d.Nodes = append(d.Nodes, next)
|
||
Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name})
|
||
return cloneJSONValue(next), nil
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodPatch {
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
nodeAny, err := a.store.Transaction(func(d *Data) (any, error) {
|
||
item := findNode(d, params["id"])
|
||
if item == nil {
|
||
return nil, httpErr(404, "节点不存在")
|
||
}
|
||
if item.Status == "removing" {
|
||
return nil, httpErr(409, "节点正在移除中,请等待完成后再编辑")
|
||
}
|
||
oldServerID := item.ServerID
|
||
oldExitServerID := strVal(item.ExitServerID)
|
||
oldForward := forwardNode(item)
|
||
oldProtocol := item.Protocol
|
||
next, err := NormalizeNode(body, item, d)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
*item = *next
|
||
EnsureNodeSecrets(item)
|
||
if item.Protocol != oldProtocol {
|
||
for _, nc := range d.NodeClients {
|
||
if nc.NodeID == item.ID {
|
||
nc.Credentials = NewNodeClientCredentials(item.Protocol)
|
||
nc.UpdatedAt = NowISO()
|
||
}
|
||
}
|
||
}
|
||
if item.ServerID != oldServerID {
|
||
QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"})
|
||
item.EntryReady = false
|
||
}
|
||
if oldForward && (!forwardNode(item) || strVal(item.ExitServerID) != oldExitServerID) {
|
||
QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ExitResourceID, Kind: "node-exit"})
|
||
item.ExitReady = false
|
||
}
|
||
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
|
||
if err := RebuildNode(d, item, true); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
item.UpdatedAt = NowISO()
|
||
Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name})
|
||
return cloneJSONValue(item), nil
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodDelete {
|
||
data := a.store.Snapshot()
|
||
for _, job := range data.Jobs {
|
||
if job.NodeID == params["id"] && job.Status == "running" {
|
||
return true, httpErr(409, "节点任务正在设备上执行,请稍后刷新再删除")
|
||
}
|
||
}
|
||
if _, err := a.orchestrator.RemoveNode(params["id"], actor, true); err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"ok": true}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/nodes/:id/deploy", pathname); ok && r.Method == http.MethodPost {
|
||
if _, err := a.orchestrator.DeployNode(params["id"], actor); err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 202, map[string]any{"ok": true}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/nodes/:id/remove", pathname); ok && r.Method == http.MethodPost {
|
||
if _, err := a.orchestrator.RemoveNode(params["id"], actor, false); err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 202, map[string]any{"ok": true}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/nodes/:id/repair", pathname); ok && r.Method == http.MethodPost {
|
||
if _, err := a.orchestrator.RepairNode(params["id"], actor); err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 202, map[string]any{"ok": true}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/nodes/:id/assign", pathname); ok && r.Method == http.MethodPost {
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
customerID, err := RequiredText(body["customerId"], "客户", 120)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
nodeClient, err := a.orchestrator.AssignCustomer(params["id"], customerID, actor)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
m := recordToMap(nodeClient)
|
||
delete(m, "credentials")
|
||
SendJSON(w, 200, map[string]any{"nodeClient": m}, nil)
|
||
return true, nil
|
||
}
|
||
if params, ok := RoutePattern("/api/nodes/:id/unassign", pathname); ok && r.Method == http.MethodPost {
|
||
body, err := ReadJSON(r, defaultMaxBody)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
customerID, err := RequiredText(body["customerId"], "客户", 120)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
if _, err := a.orchestrator.UnassignCustomer(params["id"], customerID, actor); err != nil {
|
||
return true, err
|
||
}
|
||
SendJSON(w, 200, map[string]any{"ok": true}, nil)
|
||
return true, nil
|
||
}
|
||
|
||
if r.Method == http.MethodGet && pathname == "/api/jobs" {
|
||
data := a.store.Snapshot()
|
||
jobs := data.Jobs
|
||
if len(jobs) > 200 {
|
||
jobs = jobs[len(jobs)-200:]
|
||
}
|
||
out := []map[string]any{}
|
||
for i := len(jobs) - 1; i >= 0; i-- {
|
||
m := recordToMap(jobs[i])
|
||
delete(m, "payload")
|
||
if s, ok := m["error"].(string); ok && s != "" {
|
||
m["error"] = RedactSecrets(s)
|
||
}
|
||
out = append(out, m)
|
||
}
|
||
SendJSON(w, 200, map[string]any{"jobs": out}, nil)
|
||
return true, nil
|
||
}
|
||
if r.Method == http.MethodGet && pathname == "/api/backup" {
|
||
body, err := marshalIndentJSON(a.store.Snapshot())
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
h := w.Header()
|
||
h.Set("Content-Type", "application/json; charset=utf-8")
|
||
h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="nexusgate-backup-%s.json"`, NowISO()[:10]))
|
||
h.Set("Content-Length", fmt.Sprint(len(body)))
|
||
w.WriteHeader(200)
|
||
_, _ = w.Write(body)
|
||
return true, nil
|
||
}
|
||
if r.Method == http.MethodPost && pathname == "/api/restore" {
|
||
body, err := ReadJSON(r, 20*1024*1024)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
dataRaw, hasData := body["data"]
|
||
if JSString(body["confirm"]) != "RESTORE" || !hasData || dataRaw == nil {
|
||
return true, httpErr(400, "恢复确认信息不正确")
|
||
}
|
||
raw, err := marshalJSON(dataRaw)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
var restored Data
|
||
if err := unmarshalJSONUseNumber(raw, &restored); err != nil {
|
||
return true, err
|
||
}
|
||
if err := a.store.Replace(&restored); err != nil {
|
||
return true, err
|
||
}
|
||
snapshot := a.store.Snapshot()
|
||
needsToken := false
|
||
for _, item := range snapshot.Customers {
|
||
if item.SubscriptionToken == "" {
|
||
needsToken = true
|
||
break
|
||
}
|
||
}
|
||
if needsToken {
|
||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||
for _, customer := range d.Customers {
|
||
if customer.SubscriptionToken == "" {
|
||
customer.SubscriptionToken = RandomToken(32)
|
||
}
|
||
}
|
||
return nil, nil
|
||
})
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
}
|
||
a.replaceSessions(NewSessionManager(a.sessionHours()))
|
||
SendJSON(w, 200, map[string]any{"ok": true, "message": "备份已恢复,请重新登录"}, clearSessionCookie())
|
||
return true, nil
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// ---------- subscription ----------
|
||
|
||
// FormatBytes is formatBytes from server.js.
|
||
func FormatBytes(bytes float64) string {
|
||
if bytes == 0 || bytes <= 0 {
|
||
return "0 B"
|
||
}
|
||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||
v := bytes
|
||
i := 0
|
||
for v >= 1024 && i < len(units)-1 {
|
||
v /= 1024
|
||
i++
|
||
}
|
||
return fmt.Sprintf("%.2f%s", v, units[i])
|
||
}
|
||
|
||
var htmlEscaper = strings.NewReplacer(
|
||
"&", "&",
|
||
"<", "<",
|
||
">", ">",
|
||
`"`, """,
|
||
"'", "'",
|
||
)
|
||
|
||
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
|
||
}
|