package main // All HTTP handlers + the server.js internal helpers (normalize/public // shapers, auth guards, login rate limiting, subscription endpoint). // Route order, status codes, error codes and message text follow server.js // line by line. import ( "crypto/subtle" "encoding/base64" "encoding/json" "fmt" "math" "net" "net/http" "os" "regexp" "sort" "strconv" "strings" "sync" "time" ) // ---------- app state ---------- type loginAttempt struct { Count int BlockedUntil int64 // epoch ms } type App struct { store *Store orchestrator *Orchestrator sessMu sync.RWMutex sessions *SessionManager loginMu sync.Mutex loginAttempts map[string]*loginAttempt sysmonMu sync.Mutex sysmon *SystemMonitor } // monitor lazily creates the system monitor (data dir = store file dir). func (a *App) monitor() *SystemMonitor { a.sysmonMu.Lock() defer a.sysmonMu.Unlock() if a.sysmon == nil { dir := "." if a.store != nil && a.store.File != "" { if idx := strings.LastIndex(a.store.File, "/"); idx >= 0 { dir = a.store.File[:idx] } } a.sysmon = NewSystemMonitor(dir) } return a.sysmon } func NewApp(store *Store, sessions *SessionManager, orch *Orchestrator) *App { return &App{ store: store, orchestrator: orch, sessions: sessions, loginAttempts: map[string]*loginAttempt{}, } } func (a *App) getSessions() *SessionManager { a.sessMu.RLock() defer a.sessMu.RUnlock() return a.sessions } // replaceSessions swaps the whole SessionManager (account PATCH / restore), // which invalidates every existing session, as in server.js. func (a *App) replaceSessions(sm *SessionManager) { a.sessMu.Lock() defer a.sessMu.Unlock() a.sessions = sm } func (a *App) sessionHours() float64 { h := a.store.Snapshot().Settings.SessionHours if h == 0 || math.IsNaN(h) { return 12 } return h } // ---------- normalize ---------- // The JS pattern anchors the total length with a lookahead // (?=.{1,253}$), which RE2 lacks; the length is checked in code instead. var tlsDomainRe = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`) func validTLSDomain(s string) bool { return len(s) >= 1 && len(s) <= 253 && tlsDomainRe.MatchString(s) } var certDomainRe = regexp.MustCompile(`^(?:[a-z0-9-]+\.)+[a-z]{2,63}$`) func numOr(v float64, fallback float64) float64 { if v == 0 || math.IsNaN(v) { return fallback } return v } // NormalizeServer is normalizeServer. Presence semantics come from map key // existence, exactly like JS `'field' in body`. func NormalizeServer(body map[string]any, current *Server) (*Server, error) { var next Server if current != nil { next = *current } if _, ok := body["name"]; ok { name, err := RequiredText(body["name"], "服务器名称", 120) if err != nil { return nil, err } next.Name = name } if v, ok := body["role"]; ok { if s, isStr := v.(string); isStr && (s == "relay" || s == "exit" || s == "hybrid") { next.Role = s } else if next.Role == "" { next.Role = "hybrid" } } else if next.Role == "" { next.Role = "hybrid" } if _, ok := body["region"]; ok { next.Region = CleanText(body["region"], 80) } if _, ok := body["publicAddress"]; ok { addr, err := RequiredText(body["publicAddress"], "公网地址", 255) if err != nil { return nil, err } next.PublicAddress = addr } if _, ok := body["publicAddressV6"]; ok { next.PublicAddressV6 = CleanText(body["publicAddressV6"], 255) } if _, ok := body["tlsDomain"]; ok { next.TLSDomain = strings.ToLower(CleanText(body["tlsDomain"], 253)) } // Port range: JS computes Number(body value) when the key is present // (Number(null) is 0), else Number(current || default). Validation is on // the float values (integer/range checks), then stored as ints. portValue := func(key string, currentVal, def int) (float64, bool) { if v, ok := body[key]; ok { if v == nil { return 0, true } return jsNumber(v) } if currentVal == 0 { return float64(def), true } return float64(currentVal), true } startF, startOk := portValue("portRangeStart", next.PortRangeStart, 20000) endF, endOk := portValue("portRangeEnd", next.PortRangeEnd, 50000) next.PortRangeStart = int(startF) next.PortRangeEnd = int(endF) if _, ok := body["labels"]; ok { next.Labels = sliceStrings(asIds(body["labels"]), 20) } else if next.Labels == nil { next.Labels = []string{} } if !startOk || !endOk || startF != math.Trunc(startF) || endF != math.Trunc(endF) || startF < 1024 || endF > 65535 || startF > endF { return nil, httpErr(400, "端口范围必须是 1024–65535 之间的整数") } if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) { return nil, httpErr(400, "TLS 域名必须是有效的公网域名") } if _, ok := body["enabled"]; ok { enabled := true if b, isBool := body["enabled"].(bool); isBool && !b { enabled = false } next.Enabled = &enabled } else if next.Enabled == nil { enabled := true next.Enabled = &enabled } return &next, nil } // NormalizeNode is normalizeNode, including its port-(re)selection rules. func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error) { var next Node if current != nil { next = *current } topology := next.Topology if _, ok := body["topology"]; ok { if s, _ := body["topology"].(string); s == "direct" { topology = "direct" } else { topology = "forward" } } else if topology == "" { topology = "forward" } direct := topology == "direct" // Exit target selection (round-2 item 8): "manual" addresses an // unmanaged landing endpoint; anything else keeps the managed server. exitMode := next.ExitMode if _, ok := body["exitMode"]; ok { if s, _ := body["exitMode"].(string); s == "manual" { exitMode = "manual" } else { exitMode = "managed" } } manual := !direct && exitMode == "manual" protocol := next.Protocol if _, ok := body["protocol"]; ok { protocol = CleanText(body["protocol"], 64) } serverID := next.ServerID if _, ok := body["serverId"]; ok { id, err := RequiredText(body["serverId"], "服务器", 120) if err != nil { return nil, err } serverID = id } var server *Server for _, item := range data.Servers { if item.ID == serverID { server = item break } } if server == nil { return nil, httpErr(404, "服务器不存在") } var exitServerID *string if direct || manual { exitServerID = nil } else if _, ok := body["exitServerId"]; ok { id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120) if err != nil { return nil, err } exitServerID = &id } else { exitServerID = next.ExitServerID } if !direct && !manual { found := false for _, item := range data.Servers { if item.ID == strVal(exitServerID) { found = true break } } if !found { return nil, httpErr(404, "出口服务器不存在") } } if _, ok := body["name"]; ok { name, err := RequiredText(body["name"], "节点名称", 120) if err != nil { return nil, err } next.Name = name } next.ServerID = serverID next.Protocol = protocol next.Topology = topology next.ExitServerID = exitServerID next.ExitMode = "" next.ExitAddress = "" if manual { next.ExitMode = "manual" address := "" if current != nil { address = current.ExitAddress } if _, ok := body["exitAddress"]; ok { s, err := RequiredText(body["exitAddress"], "落地端点地址", 255) if err != nil { return nil, err } address = s } if address == "" || strings.ContainsAny(address, " \t\r\n/") || strings.Contains(address, "://") { return nil, httpErr(400, "手动落地端点地址无效") } next.ExitAddress = address } if direct { next.ExitProtocol = nil } else if _, ok := body["exitProtocol"]; ok { s := CleanText(body["exitProtocol"], 64) next.ExitProtocol = &s } if v, ok := body["networkMode"]; ok { if s, isStr := v.(string); isStr && (s == "ipv4" || s == "ipv6" || s == "dual") { next.NetworkMode = s } else if next.NetworkMode == "" { next.NetworkMode = "ipv4" } } else if next.NetworkMode == "" { next.NetworkMode = "ipv4" } if _, ok := body["realityServerName"]; ok { next.RealityServerName = CleanText(body["realityServerName"], 255) } else if next.RealityServerName == "" { next.RealityServerName = "www.tesla.com" } if _, ok := body["realityDestPort"]; ok { f, valid := jsNumber(body["realityDestPort"]) if !valid || f == 0 { next.RealityDestPort = 443 } else { next.RealityDestPort = int(f) } } else { next.RealityDestPort = int(numOr(float64(next.RealityDestPort), 443)) } portMode := next.PortMode if _, ok := body["portMode"]; ok { if s, _ := body["portMode"].(string); s == "fixed" { portMode = "fixed" } else { portMode = "random" } } else if portMode == "" { portMode = "random" } next.PortMode = portMode currentPortMode := "random" var currentID string var currentPort int if current != nil { if current.PortMode != "" { currentPortMode = current.PortMode } currentID = current.ID currentPort = current.Port } _, portModeInBody := body["portMode"] _, portInBody := body["port"] portModeChanged := portModeInBody && portMode != currentPortMode // An already-assigned port is sticky. The browser form always submits // portMode/port/serverId keys (port is null in random mode), so key // presence alone must never trigger a re-selection — a random port is // rolled exactly once, at creation. On edit the port only moves for an // explicit addressing change: switching the mode to random, supplying a // different fixed port, or moving to a server where the port is taken. portValueChanged := false if portInBody && !jsFalsy(body["port"]) { if f, valid := anyToJSNumber(body["port"]); valid && int(f) != currentPort { portValueChanged = true } } serverChanged := current != nil && serverID != current.ServerID switch { case current == nil || currentPort == 0: var requested any if portInBody { requested = body["port"] } else if current != nil { requested = json.Number(fmt.Sprint(currentPort)) } port, err := SelectPort(data, server, portMode, requested, currentID) if err != nil { return nil, err } next.Port = port case portModeChanged && portMode == "random": // Explicit switch into random mode: roll a fresh port. port, err := SelectPort(data, server, portMode, nil, currentID) if err != nil { return nil, err } next.Port = port case portValueChanged && portMode == "fixed": port, err := SelectPort(data, server, portMode, body["port"], currentID) if err != nil { return nil, err } next.Port = port case serverChanged && NodePortInUse(data, serverID, currentPort, currentID): // The port is taken on the target server: fixed mode surfaces the // conflict, random mode rolls a replacement. (When it is free the // switch falls through and the port is kept verbatim.) var requested any if portMode == "fixed" { requested = json.Number(fmt.Sprint(currentPort)) } port, err := SelectPort(data, server, portMode, requested, currentID) if err != nil { return nil, err } next.Port = port } if manual { // A manual endpoint's port belongs to the remote service: it is // always explicit (never rolled, never checked against a panel // server's port table). fixed := "fixed" next.ExitPortMode = &fixed port := 0 if v, ok := body["exitPort"]; ok && !jsFalsy(v) { if f, valid := anyToJSNumber(v); valid { port = int(f) } } else if current != nil && current.ExitPort != nil { port = *current.ExitPort } if port < 1 || port > 65535 { return nil, httpErr(400, "手动落地端点端口无效") } next.ExitPort = &port } else if !direct { exitPortMode := strVal(next.ExitPortMode) if _, ok := body["exitPortMode"]; ok { if s, _ := body["exitPortMode"].(string); s == "fixed" { exitPortMode = "fixed" } else { exitPortMode = "random" } } else if exitPortMode == "" { exitPortMode = "random" } next.ExitPortMode = &exitPortMode var exitServer *Server for _, item := range data.Servers { if item.ID == strVal(exitServerID) { exitServer = item break } } currentExitPortMode := "random" var currentExitPort *int if current != nil { if strVal(current.ExitPortMode) != "" { currentExitPortMode = strVal(current.ExitPortMode) } currentExitPort = current.ExitPort } _, exitPortModeInBody := body["exitPortMode"] _, exitPortInBody := body["exitPort"] exitPortModeChanged := exitPortModeInBody && exitPortMode != currentExitPortMode // Same stickiness rule as the entry port: an assigned exit port is // only re-selected on creation or an explicit addressing change. exitPortValueChanged := false if exitPortInBody && !jsFalsy(body["exitPort"]) && currentExitPort != nil { if f, valid := anyToJSNumber(body["exitPort"]); valid && int(f) != *currentExitPort { exitPortValueChanged = true } } exitServerChanged := current != nil && strVal(exitServerID) != strVal(current.ExitServerID) switch { case current == nil || currentExitPort == nil: var requested any if exitPortInBody { requested = body["exitPort"] } else if currentExitPort != nil { requested = json.Number(fmt.Sprint(*currentExitPort)) } port, err := SelectPort(data, exitServer, exitPortMode, requested, currentID) if err != nil { return nil, err } next.ExitPort = &port case exitPortModeChanged && exitPortMode == "random": port, err := SelectPort(data, exitServer, exitPortMode, nil, currentID) if err != nil { return nil, err } next.ExitPort = &port case exitPortValueChanged && exitPortMode == "fixed": port, err := SelectPort(data, exitServer, exitPortMode, body["exitPort"], currentID) if err != nil { return nil, err } next.ExitPort = &port case exitServerChanged && NodePortInUse(data, strVal(exitServerID), *currentExitPort, currentID): var requested any if exitPortMode == "fixed" { requested = json.Number(fmt.Sprint(*currentExitPort)) } port, err := SelectPort(data, exitServer, exitPortMode, requested, currentID) if err != nil { return nil, err } next.ExitPort = &port } } else { next.ExitPortMode = nil next.ExitPort = nil } if direct { if herr := ValidateEntryProtocol(protocol); herr != nil { return nil, herr } } else { if herr := ValidateProtocolPair(protocol, strVal(next.ExitProtocol)); herr != nil { return nil, herr } } if manual { // Manual endpoint credentials describe a third-party service, so // they come from the operator (form fields or a pasted share link), // never generated. Fields merge over the stored set so an edit that // leaves a field blank keeps the previous value. if rawCreds, ok := body["exitCredentials"].(map[string]any); ok { creds := ExitCredentials{} if next.ExitCredentials != nil { creds = *next.ExitCredentials } if v, ok := rawCreds["clientId"]; ok { creds.ClientID = CleanText(v, 128) } if v, ok := rawCreds["password"]; ok { creds.Password = CleanText(v, 512) } if v, ok := rawCreds["socksUser"]; ok { creds.SocksUser = CleanText(v, 128) } next.ExitCredentials = &creds } creds := next.ExitCredentials exitProtocol := strVal(next.ExitProtocol) switch { case creds == nil: return nil, httpErr(400, "手动落地端点缺少凭据") case exitProtocol == "vless-tcp" && creds.ClientID == "": return nil, httpErr(400, "手动落地端点缺少 VLESS 用户 ID") case exitProtocol == "socks5-auth" && (creds.SocksUser == "" || creds.Password == ""): return nil, httpErr(400, "手动落地端点缺少 SOCKS5 用户名或密码") default: if cipher, isSS := ShadowsocksMethods[exitProtocol]; isSS { if creds.Password == "" { return nil, httpErr(400, "手动落地端点缺少 SS 密码") } // SS2022 keys are base64 key material (one PSK segment, or // serverKey:userKey); sing-box rejects anything else when // the config loads, so reject it here with a clear error. if strings.HasPrefix(cipher.Method, "2022-blake3-") { segments := strings.Split(creds.Password, ":") if len(segments) > 2 { return nil, httpErr(400, "手动落地端点 SS 密码格式无效") } for _, segment := range segments { raw, err := base64.StdEncoding.DecodeString(segment) if err != nil { if raw, err = base64.RawStdEncoding.DecodeString(segment); err != nil { return nil, httpErr(400, "手动落地端点 SS 密码必须是 base64 密钥") } } if len(raw) != cipher.KeyBytes { return nil, httpErr(400, "手动落地端点 SS 密钥长度与加密方法不符") } } } } } } // Round-3 item 21: a socks5-auth node carries its own fixed account. // Fields merge over the stored pair (blank keeps the old value, the // form's convention); a pair still missing afterwards is minted by // EnsureNodeSecrets on save. The username must not start with "ng:" // — that prefix is the per-customer identity namespace the agent // maps to usage samples. if protocol == "socks5-auth" { if v, ok := body["socksUser"]; ok { if user := CleanText(v, 128); user != "" { if strings.HasPrefix(user, "ng:") || strings.ContainsAny(user, " \t\r\n") { return nil, httpErr(400, "SOCKS5 用户名不能以 ng: 开头或包含空白字符") } next.SocksUser = user } } if v, ok := body["socksPassword"]; ok { if password := CleanText(v, 512); password != "" { next.SocksPassword = password } } } else { next.SocksUser = "" next.SocksPassword = "" } if IsRealityProtocol(protocol) && next.RealityServerName == "" { return nil, httpErr(400, "Reality SNI 不能为空") } if IsRealityProtocol(protocol) && (next.RealityDestPort < 1 || next.RealityDestPort > 65535) { return nil, httpErr(400, "Reality 目标端口无效") } return &next, nil } // ---------- public shapers ---------- // PublicNode is publicNode: credentials stripped, error redacted. Built via // the record's MarshalJSON so unknown (Extra) fields pass through. func PublicNode(item *Node) map[string]any { out := recordToMap(item) delete(out, "exitCredentials") delete(out, "ssServerKey") if s, ok := out["error"].(string); ok && s != "" { out["error"] = RedactSecrets(s) } return out } // PublicServer is publicServer: engine detail redacted, plus agentVersion // and pendingCleanup computed against data. func PublicServer(item *Server, data *Data) map[string]any { out := recordToMap(item) if eng, ok := out["engine"].(map[string]any); ok && eng != nil { copied := map[string]any{} for k, v := range eng { copied[k] = v } if d, isStr := copied["detail"].(string); isStr { copied["detail"] = RedactSecrets(d) } out["engine"] = copied } var version any for _, agent := range data.Agents { if agent.ServerID == item.ID && agent.Status == "active" { version = agent.Version break } } out["agentVersion"] = version pending := 0 for _, n := range data.Nodes { if (n.ServerID == item.ID || strVal(n.ExitServerID) == item.ID) && n.Status == "removing" { pending++ } } out["pendingCleanup"] = pending out["enabled"] = item.Enabled == nil || *item.Enabled return out } // PublicCustomer is publicCustomer plus observed/subscription counters. func PublicCustomer(item *Customer, data *Data) map[string]any { out := recordToMap(item) out["observedIpCount"] = len(ObservedIPs(data, item.ID, time.Now())) out["subscriptionClientCount"] = len(ActiveClients(data, item.ID, time.Now())) if len(item.ExternalSources) > 0 { status := map[string]any{} for _, source := range item.ExternalSources { entry := data.SubSourceCache[source.ID] summary := map[string]any{ "fetchedAt": nil, "okAt": nil, "failCount": 0, "lastError": nil, "droppedCount": 0, "count": 0, } if entry != nil { if entry.FetchedAt != nil { summary["fetchedAt"] = *entry.FetchedAt } if entry.OkAt != nil { summary["okAt"] = *entry.OkAt } summary["failCount"] = entry.FailCount if entry.LastError != nil { summary["lastError"] = *entry.LastError } summary["droppedCount"] = entry.DroppedCount summary["count"] = len(entry.Items) } status[source.ID] = summary } out["externalStatus"] = status } return out } // CustomerSummary computes the customers-page stats strip, mirroring // 3x-ui's client summary semantics (client_paging.go): total / online / // depleted / depletingSoon / disabled / enabled. "depleted" is 3x-ui's // depletedExpr — traffic exhausted (limit > 0 and used >= limit) OR expiry // reached — counted regardless of status. "depletingSoon" is its // expiringExpr — status active, not depleted, and either the expiry is // less than settings.expireDiff days away or the remaining traffic is // less than settings.trafficDiff GB. Both thresholds default to 0 (the // 3x-ui factory default), under which nobody counts as depleting soon. // "disabled" counts manual suspensions only; auto-blocks (expired / // traffic_limit / ip_limit) surface through depleted instead. func CustomerSummary(data *Data, nowMs int64) map[string]any { expireDiffMs := data.Settings.ExpireDiff * 86400000 trafficDiffBytes := data.Settings.TrafficDiff * 1073741824 total, online, depleted, depletingSoon, disabled, enabled := len(data.Customers), 0, 0, 0, 0, 0 for _, c := range data.Customers { if CustomerOnline(c, nowMs) { online++ } if c.Status == "active" { enabled++ } if c.Status == "suspended" && strVal(c.SuspendReason) == "manual" { disabled++ } var expiryMs int64 = -1 if c.ExpiresAt != nil { if ms, ok := ParseISOMs(*c.ExpiresAt); ok { expiryMs = ms } } isDepleted := (c.TrafficLimitBytes > 0 && float64(c.UsedBytes) >= c.TrafficLimitBytes) || (expiryMs >= 0 && expiryMs <= nowMs) if isDepleted { depleted++ continue } if c.Status != "active" { continue } nearExpiry := expiryMs >= 0 && float64(expiryMs-nowMs) < expireDiffMs nearLimit := c.TrafficLimitBytes > 0 && c.TrafficLimitBytes-float64(c.UsedBytes) < trafficDiffBytes if nearExpiry || nearLimit { depletingSoon++ } } return map[string]any{ "total": total, "online": online, "depleted": depleted, "depletingSoon": depletingSoon, "disabled": disabled, "enabled": enabled, } } // realityPublicKeyOf returns the node's panel-hosted Reality public key as // reported back by the agent ("" when unknown). The keypair is the server's // identity — it stays per-node even though customer credentials are shared. func realityPublicKeyOf(node *Node) string { if node == nil { return "" } if raw, ok := node.Artifacts["realityPublicKey"]; ok && len(raw) > 0 { var s string if err := json.Unmarshal(raw, &s); err == nil { return s } } return "" } // customerCredentialsView is the credentials-tab payload (round-2 item 2): // the customer's ONE credential set, plus per-binding node facts. The // Reality public key is shown per node because it belongs to the server, // not to the customer. func customerCredentialsView(d *Data, customer *Customer) map[string]any { creds := map[string]any{"clientId": nil, "password": nil, "shortId": nil} if customer.Credentials != nil { if customer.Credentials.ClientID != "" { creds["clientId"] = customer.Credentials.ClientID } if customer.Credentials.RelayPassword != "" { creds["password"] = customer.Credentials.RelayPassword } if customer.Credentials.ShortID != "" { creds["shortId"] = customer.Credentials.ShortID } } bindings := []map[string]any{} for _, nc := range d.NodeClients { if nc.CustomerID != customer.ID { continue } node := findNode(d, nc.NodeID) if node == nil { continue } username := "" if node.Protocol == "socks5-auth" { // Item 21: the socks identity lives on the node now (fixed // account), not per customer. username = node.SocksUser } var publicKey any if key := realityPublicKeyOf(node); key != "" { publicKey = key } bindings = append(bindings, map[string]any{ "nodeClientId": nc.ID, "nodeId": node.ID, "nodeName": node.Name, "protocol": node.Protocol, "status": nc.Status, "username": username, "realityPublicKey": publicKey, }) } return map[string]any{ "customerId": customer.ID, "subscriptionToken": customer.SubscriptionToken, "credentials": creds, "bindings": bindings, } } // CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" / // "ip_limit", or "" for none (JS null). func CustomerBlockReason(data *Data, customer *Customer) string { if customer.ExpiresAt != nil { if ms, ok := ParseISOMs(strVal(customer.ExpiresAt)); ok && ms <= time.Now().UnixMilli() { return "expired" } } if customer.TrafficLimitBytes > 0 && float64(customer.UsedBytes) >= customer.TrafficLimitBytes { return "traffic_limit" } if customer.IPLimit > 0 { seen := map[string]bool{} for _, obs := range ObservedIPs(data, customer.ID, time.Now()) { seen[obs.IP] = true } if int64(len(seen)) > customer.IPLimit { return "ip_limit" } } return "" } // GetIP is getIp: first x-forwarded-for segment, else the peer address. func GetIP(r *http.Request) string { first := strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0] if first == "" { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } first = host } return CleanTextStr(first, 80) } // ---------- admin auth ---------- type adminAuth struct { Session *Session User *User } // CurrentAdmin is currentAdmin. func (a *App) CurrentAdmin(r *http.Request) *adminAuth { session := a.getSessions().Get(r) if session == nil { return nil } data := a.store.Snapshot() for _, user := range data.Users { if user.ID == session.UserID && user.Status == "active" { return &adminAuth{Session: session, User: user} } } return nil } // RequireAdmin is requireAdmin: sends 401/403 itself and returns nil. func (a *App) RequireAdmin(w http.ResponseWriter, r *http.Request, csrf bool) *adminAuth { auth := a.CurrentAdmin(r) if auth == nil { SendError(w, 401, "请先登录", "unauthorized") return nil } if csrf && r.Header.Get("X-CSRF-Token") != auth.Session.CSRF { SendError(w, 403, "请求校验失败,请刷新页面后重试", "csrf_failed") return nil } return auth } // FindAgentByBearer is findAgentByBearer: fingerprint fast path, then a // full VerifySecret scan over legacy agents without a fingerprint. func (a *App) FindAgentByBearer(r *http.Request) *Agent { header := r.Header.Get("Authorization") if !strings.HasPrefix(header, "Bearer ") { return nil } token := header[len("Bearer "):] fingerprint := TokenFingerprint(token) data := a.store.Snapshot() var fast *Agent for _, agent := range data.Agents { if agent.Status == "active" && agent.KeyFingerprint != nil && *agent.KeyFingerprint == fingerprint { fast = agent break } } if fast != nil && VerifySecret(token, fast.KeyHash) { return fast } for _, agent := range data.Agents { if agent.Status == "active" && agent.KeyFingerprint == nil && VerifySecret(token, agent.KeyHash) { return agent } } return nil } func clearSessionCookie() map[string]string { return map[string]string{"Set-Cookie": "ng_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"} } // ---------- auth routes ---------- func (a *App) handleAuth(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) { if r.Method == http.MethodPost && pathname == "/api/auth/login" { ip := GetIP(r) now := time.Now().UnixMilli() a.loginMu.Lock() record := a.loginAttempts[ip] blocked := record != nil && record.BlockedUntil > now a.loginMu.Unlock() if blocked { SendError(w, 429, "尝试次数过多,请稍后再试", "rate_limited") return true, nil } body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } username := CleanText(body["username"], 64) data := a.store.Snapshot() var user *User for _, item := range data.Users { if item.Username == username && item.Status == "active" { user = item break } } if user == nil || !VerifySecret(secretString(body["password"]), user.PasswordHash) { a.loginMu.Lock() rec := a.loginAttempts[ip] if rec == nil { rec = &loginAttempt{} a.loginAttempts[ip] = rec } rec.Count++ if rec.Count >= 6 { rec.BlockedUntil = now + 15*60*1000 rec.Count = 0 } a.loginMu.Unlock() SendError(w, 401, "账号或密码不正确", "invalid_credentials") return true, nil } a.loginMu.Lock() delete(a.loginAttempts, ip) a.loginMu.Unlock() sessions := a.getSessions() session := sessions.Create(user.ID) secure := "" if cookieSecure { secure = "; Secure" } cookie := fmt.Sprintf("ng_session=%s; HttpOnly; SameSite=Strict; Path=/; Max-Age=%d%s", session.ID, int64(sessions.TTL/time.Second), secure) SendJSON(w, 200, map[string]any{ "user": map[string]any{"id": user.ID, "username": user.Username, "role": user.Role}, "csrf": session.CSRF, }, map[string]string{"Set-Cookie": cookie}) return true, nil } if r.Method == http.MethodPost && pathname == "/api/auth/logout" { auth := a.RequireAdmin(w, r, true) if auth == nil { return true, nil } a.getSessions().Delete(r) SendJSON(w, 200, map[string]any{"ok": true}, clearSessionCookie()) return true, nil } if r.Method == http.MethodGet && pathname == "/api/session" { auth := a.CurrentAdmin(r) if auth == nil { SendJSON(w, 200, map[string]any{"authenticated": false}, nil) } else { SendJSON(w, 200, map[string]any{ "authenticated": true, "csrf": auth.Session.CSRF, "user": map[string]any{"id": auth.User.ID, "username": auth.User.Username, "role": auth.User.Role}, }, nil) } return true, nil } return false, nil } // ---------- agent routes ---------- // rawSystemValue mirrors `body.system || {}` for the RawMessage system blob. func rawSystemValue(v any) json.RawMessage { switch t := v.(type) { case nil: return json.RawMessage("{}") case bool: if !t { return json.RawMessage("{}") } case string: if t == "" { return json.RawMessage("{}") } case json.Number: if t.String() == "0" { return json.RawMessage("{}") } } raw, err := marshalJSON(v) if err != nil { return json.RawMessage("{}") } return raw } func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) { if r.Method == http.MethodPost && pathname == "/api/agent/enroll" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } token := JSString(body["token"]) data := a.store.Snapshot() var enrollment *EnrollmentToken nowMs := time.Now().UnixMilli() for _, item := range data.EnrollmentTokens { if item.UsedAt != nil { continue } expMs, ok := ParseISOMs(item.ExpiresAt) if !ok || expMs <= nowMs { continue } if VerifySecret(token, item.TokenHash) { enrollment = item break } } if enrollment == nil { SendError(w, 401, "注册令牌无效或已过期", "invalid_enrollment") return true, nil } agentKey := RandomToken(40) resultAny, err := a.store.Transaction(func(d *Data) (any, error) { var target *EnrollmentToken for _, item := range d.EnrollmentTokens { if item.ID == enrollment.ID { target = item break } } if target == nil || target.UsedAt != nil { return nil, httpErr(409, "注册令牌已被使用") } now := NowISO() target.UsedAt = &now var server *Server for _, item := range d.Servers { if item.ID == target.ServerID { server = item break } } if server == nil { return nil, httpErr(404, "目标服务器不存在") } for _, old := range d.Agents { if old.ServerID == server.ID { old.Status = "revoked" } } fp := TokenFingerprint(agentKey) agent := &Agent{ ID: NewID("agt"), ServerID: server.ID, KeyHash: HashSecret(agentKey), KeyFingerprint: &fp, Status: "active", Hostname: CleanText(body["hostname"], 128), Version: CleanText(body["version"], 32), CreatedAt: now, LastSeenAt: now, } d.Agents = append(d.Agents, agent) server.Status = "online" server.LastSeenAt = &now server.System = rawSystemValue(body["system"]) server.UpdatedAt = now Audit(d, "agent:"+agent.ID, "enroll_agent", server.ID, nil) return map[string]any{"agentId": agent.ID, "serverId": server.ID, "serverName": server.Name}, nil }) if err != nil { return true, err } result := resultAny.(map[string]any) reconciled := 0 if n, rerr := a.orchestrator.ReconcileServer(result["serverId"].(string), "agent:"+result["agentId"].(string)); rerr != nil { fmt.Fprintf(os.Stderr, "Agent reconciliation failed: %v\n", rerr) } else { reconciled = n } SendJSON(w, 201, map[string]any{ "agentId": result["agentId"], "serverId": result["serverId"], "serverName": result["serverName"], "agentKey": agentKey, "reconciled": reconciled, }, nil) return true, nil } if !strings.HasPrefix(pathname, "/api/agent/") { return false, nil } agent := a.FindAgentByBearer(r) if agent == nil { SendError(w, 401, "Agent 身份校验失败", "invalid_agent") return true, nil } if r.Method == http.MethodPost && pathname == "/api/agent/heartbeat" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } a.store.Transient(func(d *Data) { now := NowISO() var current *Agent for _, item := range d.Agents { if item.ID == agent.ID { current = item break } } var server *Server for _, item := range d.Servers { if item.ID == agent.ServerID { server = item break } } if current != nil { current.LastSeenAt = now version := JSString(body["version"]) if version == "" { version = current.Version } current.Version = CleanTextStr(version, 32) } if server != nil { server.Status = "online" server.LastSeenAt = &now if sys, ok := body["system"]; ok && jsTruthy(sys) { server.System = rawSystemValue(sys) } else if len(server.System) == 0 { server.System = json.RawMessage("{}") } server.EnginePresent = true if eng, ok := body["engine"].(map[string]any); ok { status, _ := eng["status"].(string) if status == "ready" || status == "error" { certs := []string{} if arr, isArr := eng["certificates"].([]any); isArr { for _, v := range arr[:min(50, len(arr))] { c := strings.ToLower(CleanText(v, 253)) if certDomainRe.MatchString(c) { certs = append(certs, c) } } } singBoxStatus := "inactive" if s, _ := eng["singBoxStatus"].(string); s == "ready" { singBoxStatus = "ready" } installed, _ := eng["singBoxInstalled"].(bool) server.Engine = &EngineState{ Status: status, Detail: RedactSecrets(CleanText(eng["detail"], 400)), SingBoxInstalled: installed, Certificates: certs, SingBoxStatus: singBoxStatus, SingBoxVersion: CleanText(eng["singBoxVersion"], 32), At: now, } } } } }) if arr, ok := body["resources"].([]any); ok { resources := []string{} for _, v := range arr { if s, isStr := v.(string); isStr { resources = append(resources, s) } } if _, rerr := a.orchestrator.ReconcileFromHeartbeat(agent.ServerID, resources); rerr != nil { fmt.Fprintf(os.Stderr, "Heartbeat reconciliation failed: %v\n", rerr) } } SendJSON(w, 200, map[string]any{"ok": true, "serverTime": NowISO()}, nil) return true, nil } if r.Method == http.MethodPost && pathname == "/api/agent/tls-domain" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } domainText, err := RequiredText(body["domain"], "节点 TLS 域名", 253) if err != nil { return true, err } domain := strings.ToLower(domainText) _, err = a.store.Transaction(func(d *Data) (any, error) { var server *Server for _, item := range d.Servers { if item.ID == agent.ServerID { server = item break } } if server == nil { return nil, httpErr(404, "设备不存在") } if _, verr := NormalizeServer(map[string]any{"tlsDomain": domain}, server); verr != nil { return nil, verr } if server.TLSDomain != "" && server.TLSDomain != domain { for _, node := range d.Nodes { if node.ServerID != server.ID { continue } if node.Protocol != "anytls" && node.Protocol != "hysteria2" && node.Protocol != "vless-ws-tls" { continue } if node.Status == "active" || node.Status == "deploying" { return nil, httpErr(409, "设备已有使用其他域名的 TLS 节点;请先处理现有节点") } } } if server.TLSDomain != domain { server.TLSDomain = domain server.UpdatedAt = NowISO() Audit(d, "agent:"+agent.ID, "set_tls_domain", server.ID, map[string]any{"domain": domain}) } if server.Engine == nil { server.Engine = &EngineState{} } found := false for _, c := range server.Engine.Certificates { if c == domain { found = true break } } if !found { server.Engine.Certificates = append(server.Engine.Certificates, domain) } return nil, nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true, "domain": domain}, nil) return true, nil } if r.Method == http.MethodPost && pathname == "/api/agent/poll" { data := a.store.Snapshot() hasQueued := false for _, job := range data.Jobs { if job.ServerID == agent.ServerID && job.Status == "queued" { hasQueued = true break } } if !hasQueued { SendJSON(w, 200, map[string]any{"job": nil}, nil) return true, nil } pickedAny, err := a.store.Transaction(func(d *Data) (any, error) { for _, job := range d.Jobs { if job.ServerID == agent.ServerID && job.Status == "queued" { now := NowISO() job.Status = "running" job.Attempts++ job.StartedAt = &now job.UpdatedAt = now lease := ISOFromTime(time.Now().Add(5 * time.Minute)) job.LeaseUntil = &lease return cloneJSONValue(job), nil } } return nil, ErrSkip }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"job": pickedAny}, nil) return true, nil } if params, ok := RoutePattern("/api/agent/jobs/:id/complete", pathname); ok && r.Method == http.MethodPost { data := a.store.Snapshot() var job *Job for _, item := range data.Jobs { if item.ID == params["id"] && item.ServerID == agent.ServerID { job = item break } } if job == nil { SendError(w, 404, "任务不存在", "not_found") return true, nil } body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } artifacts := map[string]json.RawMessage{} if resMap, ok := body["result"].(map[string]any); ok { if artMap, ok := resMap["artifacts"].(map[string]any); ok { for k, v := range artMap { raw, merr := marshalJSON(v) if merr == nil { artifacts[k] = raw } } } } var errPtr *string if s := JSString(body["error"]); s != "" { errPtr = &s } updated, err := a.orchestrator.CompleteJob(job.ID, jsTruthy(body["success"]), CompleteJobResult{Artifacts: artifacts}, errPtr) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"job": updated}, nil) return true, nil } if r.Method == http.MethodPost && pathname == "/api/agent/usage" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } epoch := CleanText(body["epoch"], 80) samples := []UsageSample{} if arr, ok := body["samples"].([]any); ok { for _, item := range arr { m, isMap := item.(map[string]any) if !isMap { continue } sampleEpoch := epoch if s, isStr := m["epoch"].(string); isStr { sampleEpoch = truncateRunes(s, 80) } sample := UsageSample{ Kind: JSString(m["kind"]), ID: JSString(m["id"]), Uplink: toJSONNumber(m["uplink"]), Downlink: toJSONNumber(m["downlink"]), Epoch: &sampleEpoch, } samples = append(samples, sample) } } errText := RedactSecrets(CleanText(body["error"], 240)) if err := a.orchestrator.RecordUsage(agent.ServerID, samples, errText, epoch); err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true}, nil) return true, nil } if r.Method == http.MethodPost && pathname == "/api/agent/observations" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } _, err = a.store.Transaction(func(d *Data) (any, error) { ttlMinutes := d.Settings.ObservationTtlMinutes if ttlMinutes == 0 { ttlMinutes = 10 } cutoff := time.Now().UnixMilli() - int64(ttlMinutes*60000) kept := d.Observations[:0] for _, item := range d.Observations { if ms, ok := ParseISOMs(item.LastSeenAt); ok && ms > cutoff { kept = append(kept, item) } } d.Observations = kept affected := []string{} affectedSet := map[string]bool{} samples, _ := body["observations"].([]any) if len(samples) > 1000 { samples = samples[:1000] } now := NowISO() for _, rawSample := range samples { m, isMap := rawSample.(map[string]any) if !isMap { continue } customerID := CleanText(m["customerId"], 100) ip := CleanText(m["ip"], 64) ip = strings.TrimPrefix(ip, "[") ip = strings.TrimSuffix(ip, "]") if net.ParseIP(ip) == nil { continue } valid := false for _, nc := range d.NodeClients { if nc.CustomerID != customerID || nc.Status != "active" { continue } for _, node := range d.Nodes { if node.ID == nc.NodeID && node.ServerID == agent.ServerID && node.Status == "active" { valid = true break } } if valid { break } } if !valid { continue } var existing *Observation for _, item := range d.Observations { if item.CustomerID == customerID && item.IP == ip { existing = item break } } if existing != nil { existing.LastSeenAt = now } else { d.Observations = append(d.Observations, &Observation{ ID: NewID("obs"), CustomerID: customerID, IP: ip, FirstSeen: now, LastSeenAt: now, }) } if customer := findCustomer(d, customerID); customer != nil { customer.LastOnlineAt = StrPtr(now) } if !affectedSet[customerID] { affectedSet[customerID] = true affected = append(affected, customerID) } } for _, customerID := range affected { var customer *Customer for _, item := range d.Customers { if item.ID == customerID { customer = item break } } if customer == nil || customer.Status != "active" || customer.IPLimit <= 0 { continue } ips := map[string]bool{} for _, item := range d.Observations { if item.CustomerID == customerID { ips[item.IP] = true } } if int64(len(ips)) <= customer.IPLimit { continue } reason := "ip_limit" customer.Status = "suspended" customer.SuspendReason = &reason customer.SuspendReasonPresent = true customer.UpdatedAt = NowISO() _, _ = SuspendCustomerResources(d, customer, "agent:"+agent.ID) Audit(d, "agent:"+agent.ID, "suspend_ip_limit", customer.ID, map[string]any{"ipCount": len(ips), "limit": customer.IPLimit}) } return nil, nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true}, nil) return true, nil } return false, nil } // toJSONNumber normalizes a body value into a json.Number for // UsageSample fields; the orchestrator applies the JS safe-integer // validation (empty/invalid numbers are skipped there). func toJSONNumber(v any) json.Number { switch t := v.(type) { case json.Number: return t case string: return json.Number(t) case bool: if t { return json.Number("1") } return json.Number("0") case float64: return json.Number(strconv.FormatFloat(t, 'g', -1, 64)) default: return json.Number("") } } // ---------- admin routes ---------- func findServer(d *Data, id string) *Server { for _, item := range d.Servers { if item.ID == id { return item } } return nil } func findCustomer(d *Data, id string) *Customer { for _, item := range d.Customers { if item.ID == id { return item } } return nil } func findNode(d *Data, id string) *Node { for _, item := range d.Nodes { if item.ID == id { return item } } return nil } func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname string) (bool, error) { if !strings.HasPrefix(pathname, "/api/") { return false, nil } changing := r.Method != http.MethodGet && r.Method != http.MethodHead auth := a.RequireAdmin(w, r, changing) if auth == nil { return true, nil } actor := auth.User.Username if r.Method == http.MethodGet && pathname == "/api/overview" { data := a.store.Snapshot() nowMs := time.Now().UnixMilli() online := 0 for _, item := range data.Servers { if item.Status != "online" { continue } var seenMs int64 if item.LastSeenAt != nil { if ms, ok := ParseISOMs(*item.LastSeenAt); ok { seenMs = ms } } if nowMs-seenMs < 120000 { online++ } } activeNodes := 0 for _, item := range data.Nodes { if item.Status == "active" { activeNodes++ } } queuedJobs := 0 for _, item := range data.Jobs { if item.Status == "queued" || item.Status == "running" { queuedJobs++ } } activity := data.Activity if len(activity) > 12 { activity = activity[:12] } if activity == nil { activity = []*Activity{} } degraded := []map[string]any{} for _, item := range data.Nodes { if item.Status == "failed" || item.Status == "degraded" { degraded = append(degraded, PublicNode(item)) if len(degraded) == 10 { break } } } SendJSON(w, 200, map[string]any{ "counts": map[string]any{ "servers": len(data.Servers), "online": online, "customers": len(data.Customers), "nodes": len(data.Nodes), "activeNodes": activeNodes, "queuedJobs": queuedJobs, }, "version": VERSION, "activity": activity, "degraded": degraded, }, nil) return true, nil } if r.Method == http.MethodGet && pathname == "/api/system" { data := a.store.Snapshot() SendJSON(w, 200, a.monitor().Status(data, VERSION), nil) return true, nil } if r.Method == http.MethodGet && pathname == "/api/protocols" { SendJSON(w, 200, map[string]any{"profiles": ProfileCatalog, "realityPresets": RealityPresets}, nil) return true, nil } if r.Method == http.MethodPatch && pathname == "/api/account" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } if !VerifySecret(secretString(body["currentPassword"]), auth.User.PasswordHash) { return true, httpErr(400, "当前密码不正确") } username := auth.User.Username if _, ok := body["username"]; ok { name, err := RequiredText(body["username"], "登录账号", 64) if err != nil { return true, err } username = name } newPassword := "" if body["newPassword"] != nil { newPassword = truncateRunes(JSString(body["newPassword"]), 256) } if newPassword != "" && len([]rune(newPassword)) < 10 { return true, httpErr(400, "新密码至少需要 10 位") } _, err = a.store.Transaction(func(d *Data) (any, error) { var item *User for _, entry := range d.Users { if entry.ID == auth.User.ID { item = entry break } } if item == nil { return nil, httpErr(404, "账号不存在") } for _, entry := range d.Users { if entry.ID != item.ID && entry.Username == username { return nil, httpErr(409, "该登录账号已被使用") } } item.Username = username if newPassword != "" { item.PasswordHash = HashSecret(newPassword) } now := NowISO() item.UpdatedAt = &now Audit(d, actor, "update_account", item.ID, map[string]any{"username": username}) return nil, nil }) if err != nil { return true, err } a.replaceSessions(NewSessionManager(a.sessionHours())) SendJSON(w, 200, map[string]any{"ok": true, "reauthenticate": true}, clearSessionCookie()) return true, nil } if r.Method == http.MethodGet && pathname == "/api/servers" { data := a.store.Snapshot() servers := []map[string]any{} for _, item := range data.Servers { servers = append(servers, PublicServer(item, data)) } SendJSON(w, 200, map[string]any{"servers": servers}, nil) return true, nil } if r.Method == http.MethodPost && pathname == "/api/servers" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } serverAny, err := a.store.Transaction(func(d *Data) (any, error) { next, err := NormalizeServer(body, nil) if err != nil { return nil, err } next.ID = NewID("srv") next.Status = "pending" next.CreatedAt = NowISO() next.UpdatedAt = NowISO() d.Servers = append(d.Servers, next) Audit(d, actor, "create_server", next.ID, map[string]any{"name": next.Name}) return cloneJSONValue(next), nil }) if err != nil { return true, err } SendJSON(w, 201, map[string]any{"server": serverAny}, nil) return true, nil } if params, ok := RoutePattern("/api/servers/:id", pathname); ok && r.Method == http.MethodPatch { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } serverAny, err := a.store.Transaction(func(d *Data) (any, error) { item := findServer(d, params["id"]) if item == nil { return nil, httpErr(404, "服务器不存在") } next, err := NormalizeServer(body, item) if err != nil { return nil, err } *item = *next item.UpdatedAt = NowISO() Audit(d, actor, "update_server", item.ID, map[string]any{"name": item.Name}) return cloneJSONValue(item), nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"server": serverAny}, nil) return true, nil } if params, ok := RoutePattern("/api/servers/:id", pathname); ok && r.Method == http.MethodDelete { _, err := a.store.Transaction(func(d *Data) (any, error) { index := -1 for i, item := range d.Servers { if item.ID == params["id"] { index = i break } } if index < 0 { return nil, httpErr(404, "服务器不存在") } var referencing []*Node for _, item := range d.Nodes { if item.ServerID == params["id"] || strVal(item.ExitServerID) == params["id"] { referencing = append(referencing, item) } } for _, item := range referencing { if item.Status == "removing" { return nil, httpErr(409, "设备仍有待清理资源。等待 Agent 上线确认清理后,再删除设备") } } if len(referencing) > 0 { return nil, httpErr(409, "设备仍被节点引用。请先删除关联节点") } d.Servers = append(d.Servers[:index], d.Servers[index+1:]...) for _, agent := range d.Agents { if agent.ServerID == params["id"] { agent.Status = "revoked" } } Audit(d, actor, "delete_server", params["id"], nil) return nil, nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true}, nil) return true, nil } if params, ok := RoutePattern("/api/servers/:id/enrollment-token", pathname); ok && r.Method == http.MethodPost { raw := RandomToken(30) tokenAny, err := a.store.Transaction(func(d *Data) (any, error) { if findServer(d, params["id"]) == nil { return nil, httpErr(404, "服务器不存在") } next := &EnrollmentToken{ ID: NewID("enr"), ServerID: params["id"], TokenHash: HashSecret(raw), CreatedAt: NowISO(), ExpiresAt: ISOFromTime(time.Now().Add(30 * time.Minute)), UsedAt: nil, } d.EnrollmentTokens = append(d.EnrollmentTokens, next) Audit(d, actor, "create_enrollment", params["id"], nil) return next, nil }) if err != nil { return true, err } SendJSON(w, 201, map[string]any{"token": raw, "expiresAt": tokenAny.(*EnrollmentToken).ExpiresAt}, nil) return true, nil } if params, ok := RoutePattern("/api/servers/:id/cleanup", pathname); ok && r.Method == http.MethodPost { countAny, err := a.store.Transaction(func(d *Data) (any, error) { if findServer(d, params["id"]) == nil { return nil, httpErr(404, "服务器不存在") } queued := 0 for _, node := range d.Nodes { if node.ServerID != params["id"] && strVal(node.ExitServerID) != params["id"] { continue } if node.Status != "removing" { continue } type leg struct { kind, resourceID string } legs := []leg{} if node.ServerID == params["id"] { legs = append(legs, leg{"node", node.ResourceID}) } if strVal(node.ExitServerID) == params["id"] && forwardNode(node) && node.ExitResourceID != "" { legs = append(legs, leg{"node-exit", node.ExitResourceID}) } for _, l := range legs { pending := false for _, job := range d.Jobs { if job.NodeID == node.ID && job.Action == "delete_resource" && job.Payload != nil && job.Payload.ResourceID == l.resourceID && (job.Status == "queued" || job.Status == "running") { pending = true break } } if pending { continue } QueueJob(d, params["id"], node.ID, "delete_resource", &JobPayload{ResourceID: l.resourceID, Kind: l.kind}) queued++ } } Audit(d, actor, "retry_cleanup", params["id"], map[string]any{"queued": queued}) return queued, nil }) if err != nil { return true, err } SendJSON(w, 202, map[string]any{"queued": countAny}, nil) return true, nil } if params, ok := RoutePattern("/api/servers/:id/forget", pathname); ok && r.Method == http.MethodPost { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } resultAny, err := a.store.Transaction(func(d *Data) (any, error) { index := -1 for i, item := range d.Servers { if item.ID == params["id"] { index = i break } } if index < 0 { return nil, httpErr(404, "服务器不存在") } server := d.Servers[index] if JSString(body["confirm"]) != "FORGET" || JSString(body["name"]) != server.Name { return nil, httpErr(400, "请输入准确设备名称以确认") } if server.Status == "online" { return nil, httpErr(409, "在线设备不能强制遗忘,请先正常停用并清理资源") } var residual []*Node for _, item := range d.Nodes { if item.ServerID == server.ID || strVal(item.ExitServerID) == server.ID { residual = append(residual, item) } } for _, item := range residual { if item.Status != "removing" { return nil, httpErr(409, "仍有未停用的节点,请先删除关联节点") } } residualIDs := map[string]bool{} for _, item := range residual { residualIDs[item.ID] = true } for _, item := range d.Jobs { if residualIDs[item.NodeID] && item.Status == "running" { return nil, httpErr(409, "设备任务尚在执行,不能强制遗忘") } } jobs := d.Jobs[:0] for _, item := range d.Jobs { if !residualIDs[item.NodeID] { jobs = append(jobs, item) } } d.Jobs = jobs ncs := d.NodeClients[:0] for _, item := range d.NodeClients { if !residualIDs[item.NodeID] { ncs = append(ncs, item) } } d.NodeClients = ncs for _, item := range residual { DropRealityKey(d, item) } nodes := d.Nodes[:0] for _, item := range d.Nodes { if !residualIDs[item.ID] { nodes = append(nodes, item) } } d.Nodes = nodes for _, agent := range d.Agents { if agent.ServerID == server.ID { agent.Status = "revoked" } } d.Servers = append(d.Servers[:index], d.Servers[index+1:]...) Audit(d, actor, "force_forget_server", server.ID, map[string]any{"name": server.Name, "unconfirmedResources": len(residual)}) return map[string]any{"unconfirmedResources": len(residual)}, nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true, "unconfirmedResources": resultAny.(map[string]any)["unconfirmedResources"]}, nil) return true, nil } if r.Method == http.MethodGet && pathname == "/api/customers" { data := a.store.Snapshot() now := time.Now() nowMs := now.UnixMilli() customers := []map[string]any{} for _, item := range data.Customers { out := PublicCustomer(item, data) out["online"] = CustomerOnline(item, nowMs) if item.LastOnlineAt != nil { out["lastActiveAt"] = *item.LastOnlineAt } else { out["lastActiveAt"] = nil } up, down := CustomerSpeedBps(item, nowMs) out["speedUpBps"] = up out["speedDownBps"] = down customers = append(customers, out) } SendJSON(w, 200, map[string]any{"customers": customers, "summary": CustomerSummary(data, nowMs)}, nil) return true, nil } if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodGet { data := a.store.Snapshot() customer := findCustomer(data, params["id"]) if customer == nil { return true, httpErr(404, "客户不存在") } observed := []map[string]any{} for _, obs := range ObservedIPs(data, customer.ID, time.Now()) { observed = append(observed, map[string]any{"ip": obs.IP, "firstSeenAt": obs.FirstSeen, "lastSeenAt": obs.LastSeenAt}) } var events []*SubscriptionAccess for _, item := range data.SubscriptionAccess { if item.CustomerID == customer.ID { events = append(events, item) } } if len(events) > 100 { events = events[len(events)-100:] } eventList := []map[string]any{} for i := len(events) - 1; i >= 0; i-- { m := recordToMap(events[i]) delete(m, "clientKey") eventList = append(eventList, m) } SendJSON(w, 200, map[string]any{ "limits": map[string]any{"ip": customer.IPLimit, "subscriptionClients": customer.DeviceLimit}, "observedIps": observed, "subscriptionClients": len(ActiveClients(data, customer.ID, time.Now())), "events": eventList, }, nil) return true, nil } if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodDelete { _, err := a.store.Transaction(func(d *Data) (any, error) { if findCustomer(d, params["id"]) == nil { return nil, httpErr(404, "客户不存在") } access := d.SubscriptionAccess[:0] for _, item := range d.SubscriptionAccess { if item.CustomerID != params["id"] { access = append(access, item) } } d.SubscriptionAccess = access clients := d.SubscriptionClients[:0] for _, item := range d.SubscriptionClients { if item.CustomerID != params["id"] { clients = append(clients, item) } } d.SubscriptionClients = clients Audit(d, actor, "reset_subscription_access", params["id"], nil) return nil, nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true}, nil) return true, nil } if r.Method == http.MethodPost && pathname == "/api/customers" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } externalSources, err := NormalizeExternalSources(body["externalSources"]) if err != nil { return true, err } manualLinks, err := NormalizeManualLinks(body["manualLinks"]) if err != nil { return true, err } var createdID string var refreshIDs []string customerAny, err := a.store.Transaction(func(d *Data) (any, error) { name, err := RequiredText(body["name"], "客户名称", 120) if err != nil { return nil, err } traffic, err := NonNegativeNumber(body["trafficLimitBytes"], "流量上限", false) if err != nil { return nil, err } expires, err := OptionalISO(body["expiresAt"], "到期日期") if err != nil { return nil, err } ipLimit, err := NonNegativeNumber(body["ipLimit"], "IP 上限", true) if err != nil { return nil, err } deviceLimit, err := NonNegativeNumber(body["deviceLimit"], "设备上限", true) if err != nil { return nil, err } resetDay, err := ParseResetDay(body["trafficResetDay"]) if err != nil { return nil, err } next := &Customer{ ID: NewID("cus"), SubscriptionToken: RandomToken(32), Name: name, Group: CleanText(body["group"], 80), Status: "active", TrafficLimitBytes: traffic, UsedBytes: 0, UsedUplinkBytes: 0, UsedDownlinkBytes: 0, ExpiresAt: expires, IPLimit: int64(ipLimit), DeviceLimit: int64(deviceLimit), TrafficResetDay: resetDay, Tags: sliceStrings(asIds(body["tags"]), 20), Notes: CleanText(body["notes"], 1000), CreatedAt: NowISO(), UpdatedAt: NowISO(), Credentials: func() *Credentials { c := NewCustomerCredentials(); return &c }(), } // Arm the monthly-reset schedule: it starts with the next // reset instant, it never fires retroactively at creation. ArmTrafficReset(next, time.Now()) if len(externalSources) > 0 { next.ExternalSources = externalSources } if len(manualLinks) > 0 { next.ManualLinks = manualLinks } d.Customers = append(d.Customers, next) if body["nodeIds"] != nil { if err := AlignCustomerNodes(d, next, asIds(body["nodeIds"]), actor); err != nil { return nil, err } } Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name}) createdID = next.ID for _, source := range externalSources { refreshIDs = append(refreshIDs, source.ID) } return cloneJSONValue(next), nil }) if err != nil { return true, err } if len(refreshIDs) > 0 { a.RefreshSourcesInBackground(createdID, refreshIDs) } SendJSON(w, 201, map[string]any{"customer": customerAny}, nil) return true, nil } if params, ok := RoutePattern("/api/customers/:id", pathname); ok && r.Method == http.MethodPatch { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } var patchSources []ExtSource if body["externalSources"] != nil { patchSources, err = NormalizeExternalSources(body["externalSources"]) if err != nil { return true, err } } var patchLinks []ManualLink if body["manualLinks"] != nil { patchLinks, err = NormalizeManualLinks(body["manualLinks"]) if err != nil { return true, err } } var patchRefreshIDs []string customerAny, err := a.store.Transaction(func(d *Data) (any, error) { item := findCustomer(d, params["id"]) if item == nil { return nil, httpErr(404, "客户不存在") } wasActive := item.Status == "active" previousReason := strVal(item.SuspendReason) previousSources := append([]ExtSource{}, item.ExternalSources...) if _, ok := body["name"]; ok { name, err := RequiredText(body["name"], "客户名称", 120) if err != nil { return nil, err } item.Name = name } if _, ok := body["group"]; ok { item.Group = CleanText(body["group"], 80) } if _, ok := body["notes"]; ok { item.Notes = CleanText(body["notes"], 1000) } if _, ok := body["trafficLimitBytes"]; ok { v, err := NonNegativeNumber(body["trafficLimitBytes"], "流量上限", false) if err != nil { return nil, err } item.TrafficLimitBytes = v } if _, ok := body["ipLimit"]; ok { v, err := NonNegativeNumber(body["ipLimit"], "IP 上限", true) if err != nil { return nil, err } item.IPLimit = int64(v) } if _, ok := body["deviceLimit"]; ok { v, err := NonNegativeNumber(body["deviceLimit"], "设备上限", true) if err != nil { return nil, err } item.DeviceLimit = int64(v) } if _, ok := body["expiresAt"]; ok { v, err := OptionalISO(body["expiresAt"], "到期日期") if err != nil { return nil, err } item.ExpiresAt = v } if _, ok := body["tags"]; ok { item.Tags = sliceStrings(asIds(body["tags"]), 20) } if _, ok := body["trafficResetDay"]; ok { v, err := ParseResetDay(body["trafficResetDay"]) if err != nil { return nil, err } if v != item.TrafficResetDay { item.TrafficResetDay = v // A changed schedule is re-armed rather than fired: // it takes effect from the next reset instant. An // unchanged value (the UI echoes it on every edit) // leaves the marker — and any pending reset — alone. ArmTrafficReset(item, time.Now()) } } reason := CustomerBlockReason(d, item) statusWanted := JSString(body["status"]) if statusWanted == "active" && reason != "" { labels := map[string]string{"expired": "到期时间", "traffic_limit": "流量额度", "ip_limit": "节点 IP 上限"} return nil, httpErr(409, "客户仍受%s限制,请先修改限制再启用", labels[reason]) } if statusWanted == "suspended" { item.Status = "suspended" item.SuspendReason = StrPtr("manual") item.SuspendReasonPresent = true } else if reason != "" && wasActive { item.Status = "suspended" item.SuspendReason = StrPtr(reason) item.SuspendReasonPresent = true } else if statusWanted == "active" || (!wasActive && reason == "" && (previousReason == "traffic_limit" || previousReason == "expired" || previousReason == "ip_limit")) { item.Status = "active" item.SuspendReason = nil item.SuspendReasonPresent = true } if body["externalSources"] != nil { if len(patchSources) > 0 { item.ExternalSources = patchSources } else { item.ExternalSources = nil } keptIDs := map[string]bool{} for _, source := range patchSources { keptIDs[source.ID] = true } for _, old := range previousSources { if !keptIDs[old.ID] && d.SubSourceCache != nil { delete(d.SubSourceCache, old.ID) } } for _, source := range patchSources { found := false for _, old := range previousSources { if old.ID == source.ID && old.URL == source.URL { found = true break } } if !found { patchRefreshIDs = append(patchRefreshIDs, source.ID) } } } if body["manualLinks"] != nil { if len(patchLinks) > 0 { item.ManualLinks = patchLinks } else { item.ManualLinks = nil } } if wasActive && item.Status == "suspended" { _, _ = SuspendCustomerResources(d, item, actor) } if !wasActive && item.Status == "active" { if _, err := ResumeCustomerResources(d, item, actor); err != nil { return nil, err } } if body["nodeIds"] != nil { if err := AlignCustomerNodes(d, item, asIds(body["nodeIds"]), actor); err != nil { return nil, err } } item.UpdatedAt = NowISO() Audit(d, actor, "update_customer", item.ID, nil) return cloneJSONValue(item), nil }) if err != nil { return true, err } if len(patchRefreshIDs) > 0 { a.RefreshSourcesInBackground(params["id"], patchRefreshIDs) } SendJSON(w, 200, map[string]any{"customer": customerAny}, nil) return true, nil } if params, ok := RoutePattern("/api/customers/:id/credentials", pathname); ok && r.Method == http.MethodGet { data := a.store.Snapshot() customer := findCustomer(data, params["id"]) if customer == nil { return true, httpErr(404, "客户不存在") } SendJSON(w, 200, customerCredentialsView(data, customer), nil) return true, nil } if params, ok := RoutePattern("/api/customers/:id/credentials/regenerate", pathname); ok && r.Method == http.MethodPost { var view map[string]any _, err := a.store.Transaction(func(d *Data) (any, error) { customer := findCustomer(d, params["id"]) if customer == nil { return nil, httpErr(404, "客户不存在") } // One set for the whole customer: rotating it rotates every // node at once, and every deployed node is rebuilt so the new // users land on the agents. creds := NewCustomerCredentials() customer.Credentials = &creds customer.UpdatedAt = NowISO() rebuilt := map[string]bool{} for _, nc := range d.NodeClients { if nc.CustomerID != customer.ID || nc.Status != "active" { continue } node := findNode(d, nc.NodeID) if node == nil || node.Status == "draft" || node.Status == "removing" || rebuilt[node.ID] { continue } rebuilt[node.ID] = true // Credential rotation is a binding-class change: it goes // through the coalesced window (item 23), not an // immediate per-node restart. markRebuildPending(node, false) } Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodesRebuilt": len(rebuilt)}) view = customerCredentialsView(d, customer) return nil, nil }) if err != nil { return true, err } SendJSON(w, 200, view, nil) return true, nil } if params, ok := RoutePattern("/api/customers/:id/external-sources/:sid/refresh", pathname); ok && r.Method == http.MethodPost { result := a.RefreshOneSource(params["id"], params["sid"]) if result == nil { return true, httpErr(404, "订阅源不存在") } SendJSON(w, 200, map[string]any{ "ok": result.OK, "count": len(result.Items), "dropped": result.Dropped, "error": strOrNil(result.Error), }, nil) return true, nil } if params, ok := RoutePattern("/api/customers/:id/reset-usage", pathname); ok && r.Method == http.MethodPost { _, err := a.store.Transaction(func(d *Data) (any, error) { item := findCustomer(d, params["id"]) if item == nil { return nil, httpErr(404, "客户不存在") } item.UsedBytes = 0 item.UsedUplinkBytes = 0 item.UsedDownlinkBytes = 0 if item.Status == "suspended" && strVal(item.SuspendReason) == "traffic_limit" && CustomerBlockReason(d, item) == "" { item.Status = "active" item.SuspendReason = nil item.SuspendReasonPresent = true if _, err := ResumeCustomerResources(d, item, actor); err != nil { return nil, err } } item.UpdatedAt = NowISO() Audit(d, actor, "reset_customer_usage", item.ID, nil) return nil, nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true}, nil) return true, nil } if params, ok := RoutePattern("/api/customers/:id/rotate-subscription", pathname); ok && r.Method == http.MethodPost { customerAny, err := a.store.Transaction(func(d *Data) (any, error) { item := findCustomer(d, params["id"]) if item == nil { return nil, httpErr(404, "客户不存在") } item.SubscriptionToken = RandomToken(32) item.UpdatedAt = NowISO() Audit(d, actor, "rotate_subscription", item.ID, nil) return PublicCustomer(item, d), nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"customer": customerAny}, nil) return true, nil } if params, ok := RoutePattern("/api/customers/:id", pathname); ok && r.Method == http.MethodDelete { _, err := a.store.Transaction(func(d *Data) (any, error) { index := -1 for i, item := range d.Customers { if item.ID == params["id"] { index = i break } } if index < 0 { return nil, httpErr(404, "客户不存在") } for _, nc := range d.NodeClients { if nc.CustomerID != params["id"] { continue } if node := findNode(d, nc.NodeID); node != nil && !node.PendingDelete { return nil, httpErr(409, "客户仍有节点分配。请先移除全部分配") } } removedCustomer := d.Customers[index] for _, source := range removedCustomer.ExternalSources { if d.SubSourceCache != nil { delete(d.SubSourceCache, source.ID) } } d.Customers = append(d.Customers[:index], d.Customers[index+1:]...) access := d.SubscriptionAccess[:0] for _, item := range d.SubscriptionAccess { if item.CustomerID != params["id"] { access = append(access, item) } } d.SubscriptionAccess = access clients := d.SubscriptionClients[:0] for _, item := range d.SubscriptionClients { if item.CustomerID != params["id"] { clients = append(clients, item) } } d.SubscriptionClients = clients obs := d.Observations[:0] for _, item := range d.Observations { if item.CustomerID != params["id"] { obs = append(obs, item) } } d.Observations = obs Audit(d, actor, "delete_customer", params["id"], nil) return nil, nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true}, nil) return true, nil } if r.Method == http.MethodGet && pathname == "/api/nodes" { data := a.store.Snapshot() nowMs := time.Now().UnixMilli() sortedNodes := append([]*Node{}, data.Nodes...) sort.SliceStable(sortedNodes, func(i, j int) bool { return sortedNodes[i].SortIndex < sortedNodes[j].SortIndex }) nodes := []map[string]any{} visibleIDs := map[string]bool{} for _, item := range sortedNodes { if item.PendingDelete { continue } visibleIDs[item.ID] = true out := PublicNode(item) clientCount := 0 for _, nc := range data.NodeClients { if nc.NodeID == item.ID { clientCount++ } } onlineIDs := NodeOnlineCustomerIds(data, item, nowMs) out["clientCount"] = clientCount out["onlineCount"] = len(onlineIDs) out["onlineCustomerIds"] = onlineIDs up, down := NodeSpeedBps(item, nowMs) out["speedUpBps"] = up out["speedDownBps"] = down nodes = append(nodes, out) } nodeClients := []map[string]any{} for _, nc := range data.NodeClients { if !visibleIDs[nc.NodeID] { continue } m := recordToMap(nc) delete(m, "credentials") uri := NodeClientURI(data, nc) if uri == "" { m["clientUri"] = nil } else { m["clientUri"] = uri } nodeClients = append(nodeClients, m) } SendJSON(w, 200, map[string]any{"nodes": nodes, "nodeClients": nodeClients}, nil) return true, nil } if r.Method == http.MethodPost && pathname == "/api/nodes/reorder" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } _, err = a.store.Transaction(func(d *Data) (any, error) { ids := asIds(body["ids"]) byID := map[string]*Node{} for _, item := range d.Nodes { byID[item.ID] = item } ordered := []*Node{} seen := map[string]bool{} for _, id := range ids { if node := byID[id]; node != nil && !seen[id] { seen[id] = true ordered = append(ordered, node) } } rest := append([]*Node{}, d.Nodes...) sort.SliceStable(rest, func(i, j int) bool { return rest[i].SortIndex < rest[j].SortIndex }) for _, item := range rest { if !seen[item.ID] { ordered = append(ordered, item) } } for index, item := range ordered { item.SortIndex = index } Audit(d, actor, "reorder_nodes", "nodes", map[string]any{"count": len(ordered)}) return nil, nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true}, nil) return true, nil } if r.Method == http.MethodPost && pathname == "/api/nodes" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } nodeAny, err := a.store.Transaction(func(d *Data) (any, error) { normalized, err := NormalizeNode(body, nil, d) if err != nil { return nil, err } next := normalized next.ID = NewID("nod") next.Status = "draft" next.EntryReady = false next.ExitReady = false next.ResourceID = NewID("res") next.ExitResourceID = NewID("res") if manualExit(next) { // Nothing is deployed on a manual landing endpoint, so the // exit side is ready by definition and owns no resource. next.ExitReady = true next.ExitResourceID = "" } next.Artifacts = map[string]json.RawMessage{} next.Error = nil next.PendingDelete = false next.UsageCounters = nil next.RetiredUsageEpochs = []string{} next.MeteredTraffic = nil next.LastUsageAt = nil next.CreatedAt = NowISO() next.UpdatedAt = NowISO() EnsureNodeSecrets(next) maxIndex := -1 for _, item := range d.Nodes { if item.SortIndex > maxIndex { maxIndex = item.SortIndex } } next.SortIndex = maxIndex + 1 d.Nodes = append(d.Nodes, next) Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name}) return cloneJSONValue(next), nil }) if err != nil { return true, err } SendJSON(w, 201, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil) return true, nil } if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodPatch { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } nodeAny, err := a.store.Transaction(func(d *Data) (any, error) { item := findNode(d, params["id"]) if item == nil { return nil, httpErr(404, "节点不存在") } if item.Status == "removing" { return nil, httpErr(409, "节点正在移除中,请等待完成后再编辑") } oldServerID := item.ServerID oldExitServerID := strVal(item.ExitServerID) oldExitResourceID := item.ExitResourceID oldForward := forwardNode(item) next, err := NormalizeNode(body, item, d) if err != nil { return nil, err } *item = *next EnsureNodeSecrets(item) if item.ServerID != oldServerID { QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"}) item.EntryReady = false } if oldForward && oldExitResourceID != "" && (!forwardNode(item) || manualExit(item) || strVal(item.ExitServerID) != oldExitServerID) { QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: oldExitResourceID, Kind: "node-exit"}) item.ExitReady = false } if manualExit(item) { item.ExitReady = true item.ExitResourceID = "" } else if forwardNode(item) && item.ExitResourceID == "" { // Switching from a manual endpoint back to a managed exit // server needs a fresh exit resource identity. item.ExitResourceID = NewID("res") item.ExitReady = false } if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" { // A node edit ships immediately and consumes any pending // coalesced mark (item 23). clearRebuildPending(item) if err := RebuildNode(d, item, true); err != nil { return nil, err } } if manualExit(item) { item.ExitReady = true } item.UpdatedAt = NowISO() Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name}) return cloneJSONValue(item), nil }) if err != nil { return true, err } SendJSON(w, 200, map[string]any{"node": PublicNode(nodeAny.(*Node))}, nil) return true, nil } if params, ok := RoutePattern("/api/nodes/:id", pathname); ok && r.Method == http.MethodDelete { data := a.store.Snapshot() for _, job := range data.Jobs { if job.NodeID == params["id"] && job.Status == "running" { return true, httpErr(409, "节点任务正在设备上执行,请稍后刷新再删除") } } if _, err := a.orchestrator.RemoveNode(params["id"], actor, true); err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true}, nil) return true, nil } if params, ok := RoutePattern("/api/nodes/:id/deploy", pathname); ok && r.Method == http.MethodPost { if _, err := a.orchestrator.DeployNode(params["id"], actor); err != nil { return true, err } SendJSON(w, 202, map[string]any{"ok": true}, nil) return true, nil } if params, ok := RoutePattern("/api/nodes/:id/remove", pathname); ok && r.Method == http.MethodPost { if _, err := a.orchestrator.RemoveNode(params["id"], actor, false); err != nil { return true, err } SendJSON(w, 202, map[string]any{"ok": true}, nil) return true, nil } if params, ok := RoutePattern("/api/nodes/:id/repair", pathname); ok && r.Method == http.MethodPost { if _, err := a.orchestrator.RepairNode(params["id"], actor); err != nil { return true, err } SendJSON(w, 202, map[string]any{"ok": true}, nil) return true, nil } if params, ok := RoutePattern("/api/nodes/:id/assign", pathname); ok && r.Method == http.MethodPost { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } customerID, err := RequiredText(body["customerId"], "客户", 120) if err != nil { return true, err } nodeClient, err := a.orchestrator.AssignCustomer(params["id"], customerID, actor) if err != nil { return true, err } m := recordToMap(nodeClient) delete(m, "credentials") SendJSON(w, 200, map[string]any{"nodeClient": m}, nil) return true, nil } if params, ok := RoutePattern("/api/nodes/:id/unassign", pathname); ok && r.Method == http.MethodPost { body, err := ReadJSON(r, defaultMaxBody) if err != nil { return true, err } customerID, err := RequiredText(body["customerId"], "客户", 120) if err != nil { return true, err } if _, err := a.orchestrator.UnassignCustomer(params["id"], customerID, actor); err != nil { return true, err } SendJSON(w, 200, map[string]any{"ok": true}, nil) return true, nil } if r.Method == http.MethodGet && pathname == "/api/jobs" { data := a.store.Snapshot() jobs := data.Jobs if len(jobs) > 200 { jobs = jobs[len(jobs)-200:] } out := []map[string]any{} for i := len(jobs) - 1; i >= 0; i-- { m := recordToMap(jobs[i]) delete(m, "payload") if s, ok := m["error"].(string); ok && s != "" { m["error"] = RedactSecrets(s) } out = append(out, m) } SendJSON(w, 200, map[string]any{"jobs": out}, nil) return true, nil } if r.Method == http.MethodGet && pathname == "/api/backup" { body, err := marshalIndentJSON(a.store.Snapshot()) if err != nil { return true, err } h := w.Header() h.Set("Content-Type", "application/json; charset=utf-8") h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="nexusgate-backup-%s.json"`, NowISO()[:10])) h.Set("Content-Length", fmt.Sprint(len(body))) w.WriteHeader(200) _, _ = w.Write(body) return true, nil } if r.Method == http.MethodPost && pathname == "/api/restore" { body, err := ReadJSON(r, 20*1024*1024) if err != nil { return true, err } dataRaw, hasData := body["data"] if JSString(body["confirm"]) != "RESTORE" || !hasData || dataRaw == nil { return true, httpErr(400, "恢复确认信息不正确") } raw, err := marshalJSON(dataRaw) if err != nil { return true, err } var restored Data if err := unmarshalJSONUseNumber(raw, &restored); err != nil { return true, err } if err := a.store.Replace(&restored); err != nil { return true, err } snapshot := a.store.Snapshot() needsToken := false for _, item := range snapshot.Customers { if item.SubscriptionToken == "" { needsToken = true break } } if needsToken { _, err := a.store.Transaction(func(d *Data) (any, error) { for _, customer := range d.Customers { if customer.SubscriptionToken == "" { customer.SubscriptionToken = RandomToken(32) } } return nil, nil }) if err != nil { return true, err } } a.replaceSessions(NewSessionManager(a.sessionHours())) SendJSON(w, 200, map[string]any{"ok": true, "message": "备份已恢复,请重新登录"}, clearSessionCookie()) return true, nil } return false, nil } // ---------- subscription ---------- // FormatBytes is formatBytes from server.js. func FormatBytes(bytes float64) string { if bytes == 0 || bytes <= 0 { return "0 B" } units := []string{"B", "KB", "MB", "GB", "TB"} v := bytes i := 0 for v >= 1024 && i < len(units)-1 { v /= 1024 i++ } return fmt.Sprintf("%.2f%s", v, units[i]) } var htmlEscaper = strings.NewReplacer( "&", "&", "<", "<", ">", ">", `"`, """, "'", "'", ) 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 = `` + secLabel + "" } rows.WriteString("
暂无可用节点
` } token16 := escHTML(tokenPrefix16(customer.SubscriptionToken)) return `| 订阅 ID | ` + token16 + ` |
| 用户 | ` + escHTML(customer.Name) + ` |
| 状态 | ` + statusText + ` |
| 已下载 | ` + FormatBytes(down) + ` |
| 已上传 | ` + FormatBytes(up) + ` |
| 使用情况 | ` + FormatBytes(used) + ` |
| 总配额 | ` + totalText + ` |
| 剩余 | ` + remainingText + ` |
| 上次在线 | ` + escHTML(lastOnline) + ` |
| 到期 | ` + escHTML(expiresText) + ` |