Files
NexusGate/panel-go/orchestrator.go
T
aaaamoooo 2fafd85d1f feat(panel): build-before-bind + socks5 fixed node account (items 20+21)
Item 20: drop the zero-client deploy gate in ValidateNodeDeploy for
all protocols, stop RebuildNode from auto-removing a node whose
bindings reach zero, and let server/heartbeat reconciliation rebuild
zero-client nodes like any other. Nodes are built and deployed first;
bindings can be added and removed freely.

Item 21: socks5-auth nodes carry one node-level fixed account
(node.socksUser/socksPassword, minted at creation or by the store-load
migration MigrateSocksNodeCredentials, idempotent). The sing-box
inbound's only user is that pair regardless of bindings; subscription
links for every bound customer carry the same pair. The generated
username avoids the ng: prefix so the agent never attributes the
account's traffic to a customer — it stays on the node's inbound
counters. Edit form shows both fields (copyable) for socks nodes;
NormalizeNode validates operator-supplied usernames.
2026-10-21 10:40:58 +00:00

1507 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"sync"
"time"
)
const HeartbeatReconcileIntervalMs = 120000
// ---------- small finders / helpers ----------
func orchFindServer(d *Data, id string) *Server {
for _, s := range d.Servers {
if s.ID == id {
return s
}
}
return nil
}
func orchFindNode(d *Data, id string) *Node {
for _, n := range d.Nodes {
if n.ID == id {
return n
}
}
return nil
}
func orchFindCustomer(d *Data, id string) *Customer {
for _, c := range d.Customers {
if c.ID == id {
return c
}
}
return nil
}
func orchNotFound(label string) *HTTPError { return httpErr(404, "%s不存在", label) }
func xbTruncRunes(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
func forwardNode(node *Node) bool {
topology := node.Topology
if topology == "" {
topology = "forward"
}
return topology == "forward"
}
// manualExit reports whether a forward node's exit is an unmanaged landing
// endpoint (round-2 item 8): no exit server, no exit resource, nothing to
// deploy on the far side.
func manualExit(node *Node) bool {
return forwardNode(node) && node.ExitMode == "manual"
}
func tagForNode(node *Node) string {
var sb strings.Builder
for _, r := range node.ID {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
sb.WriteRune(r)
}
}
s := sb.String()
if len(s) > 18 {
s = s[len(s)-18:]
}
return "ng-" + s
}
func activeNodeClients(d *Data, node *Node) []*NodeClient {
out := []*NodeClient{}
for _, nc := range d.NodeClients {
if nc.NodeID == node.ID && nc.Status == "active" {
out = append(out, nc)
}
}
return out
}
func NodePortInUse(d *Data, serverID string, port int, excludeNodeID string) bool {
for _, n := range d.Nodes {
if n.ID == excludeNodeID {
continue
}
if n.ServerID == serverID && n.Port == port {
return true
}
if n.ExitServerID != nil && *n.ExitServerID == serverID && n.ExitPort != nil && *n.ExitPort == port {
return true
}
}
return false
}
// ---------- ports ----------
func AllocatePort(d *Data, server *Server) (int, error) {
start := server.PortRangeStart
if start == 0 {
start = 20000
}
end := server.PortRangeEnd
if end == 0 {
end = 50000
}
if start < 1024 || end > 65535 || start > end {
return 0, fmt.Errorf("服务器 %s 的端口范围无效", server.Name)
}
span := end - start + 1
offset := xbRandInt(span)
for i := 0; i < span; i++ {
candidate := start + ((offset + i) % span)
if !NodePortInUse(d, server.ID, candidate, "") {
return candidate, nil
}
}
return 0, fmt.Errorf("服务器 %s 没有可用端口", server.Name)
}
// anyToJSNumber converts a decoded-JSON value the way JS Number() would;
// ok=false mirrors NaN.
func anyToJSNumber(v any) (float64, bool) {
switch t := v.(type) {
case json.Number:
f, err := t.Float64()
if err != nil {
return 0, false
}
return f, true
case float64:
return t, true
case float32:
return float64(t), true
case int:
return float64(t), true
case int64:
return float64(t), true
case string:
f := orchJSNumberStr(t)
if math.IsNaN(f) {
return 0, false
}
return f, true
case bool:
if t {
return 1, true
}
return 0, true
}
return 0, false
}
func jsFalsy(v any) bool {
if v == nil {
return true
}
if f, ok := anyToJSNumber(v); ok && f == 0 {
return true
}
if s, isStr := v.(string); isStr && s == "" {
return true
}
if b, isBool := v.(bool); isBool && !b {
return true
}
return false
}
func SelectPort(d *Data, server *Server, mode string, requested any, excludeNodeID string) (int, error) {
if mode != "fixed" || jsFalsy(requested) {
return AllocatePort(d, server)
}
f, ok := anyToJSNumber(requested)
if !ok || f != math.Trunc(f) || f < 1024 || f > 65535 {
return 0, httpErr(400, "服务器 %s 的固定端口无效", server.Name)
}
port := int(f)
if NodePortInUse(d, server.ID, port, excludeNodeID) {
return 0, httpErr(409, "服务器 %s 的端口 %d 已被占用", server.Name, port)
}
return port, nil
}
// ---------- jobs / audit ----------
func QueueJob(d *Data, serverID, nodeID, action string, payload *JobPayload) *Job {
now := NowISO()
job := &Job{
ID: NewID("job"),
ServerID: serverID,
NodeID: nodeID,
Action: action,
Payload: payload,
Status: "queued",
Attempts: 0,
CreatedAt: now,
UpdatedAt: now,
Error: nil,
}
d.Jobs = append(d.Jobs, job)
return job
}
func Audit(d *Data, actor, action, target string, detail map[string]any) {
if detail == nil {
detail = map[string]any{}
}
entry := &Activity{
ID: NewID("evt"),
At: NowISO(),
Actor: actor,
Action: action,
Target: target,
Detail: detail,
}
d.Activity = append([]*Activity{entry}, d.Activity...)
if len(d.Activity) > 2000 {
d.Activity = d.Activity[:2000]
}
}
// ---------- resource assembly ----------
func payloadWithResource(resource map[string]any, kind string, keyMaterial map[string]KeyPair) *JobPayload {
raw, err := marshalJSON(resource)
if err != nil {
panic(err)
}
payload := &JobPayload{Resource: raw, Kind: kind}
if keyMaterial != nil {
payload.KeyMaterial = keyMaterial
}
return payload
}
// ensureCustomerCredentials fills a missing customer-level credential set
// (defensive: the store-load migration and customer creation provide one).
func ensureCustomerCredentials(d *Data, customerID string) {
customer := findCustomer(d, customerID)
if customer != nil && (customer.Credentials == nil || customer.Credentials.ClientID == "") {
creds := NewCustomerCredentials()
customer.Credentials = &creds
}
}
func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any, error) {
server := orchFindServer(d, node.ServerID)
if server == nil {
return nil, orchNotFound("服务器")
}
networkMode := node.NetworkMode
if networkMode == "" {
networkMode = "ipv4"
}
bindAddress, err := ResolveBindAddress(networkMode, server)
if err != nil {
return nil, err
}
var exitServer *Server
exitAddress := ""
if manualExit(node) {
exitAddress = node.ExitAddress
} else if forwardNode(node) {
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
if exitServer == nil {
return nil, orchNotFound("出口服务器")
}
}
if clients == nil {
clients = activeNodeClients(d, node)
}
// Credentials live on the customer (round-2 item 2); stored bindings
// carry none. Stamp each customer's set onto throwaway copies for the
// resource builder so the stored records stay clean.
resolved := make([]*NodeClient, len(clients))
for i, nc := range clients {
ensureCustomerCredentials(d, nc.CustomerID)
cp := *nc
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
creds := *customer.Credentials
cp.Credentials = &creds
} else {
cp.Credentials = nil
}
resolved[i] = &cp
}
return BuildNodeResource(BuildNodeResourceParams{
ResourceID: node.ResourceID,
TagPrefix: tagForNode(node),
Node: node,
NodeClients: resolved,
Server: server,
BindAddress: bindAddress,
ExitServer: exitServer,
ExitAddress: exitAddress,
})
}
func resourceForNodeExit(d *Data, node *Node) (map[string]any, error) {
server := orchFindServer(d, xbDeref(node.ExitServerID))
if server == nil {
return nil, orchNotFound("出口服务器")
}
networkMode := node.NetworkMode
if networkMode == "" {
networkMode = "ipv4"
}
bindAddress, err := ResolveBindAddress(networkMode, server)
if err != nil {
return nil, err
}
return BuildNodeExitResource(BuildNodeExitResourceParams{
ResourceID: node.ExitResourceID,
TagPrefix: tagForNode(node),
Node: node,
Server: server,
BindAddress: bindAddress,
})
}
func cancelQueuedDeletes(d *Data, nodeID, reason string) bool {
cancelled := false
for _, job := range d.Jobs {
if job.NodeID == nodeID && job.Action == "delete_resource" && job.Status == "queued" {
job.Status = "failed"
job.Error = StrPtr(reason)
job.UpdatedAt = NowISO()
cancelled = true
}
}
return cancelled
}
func RebuildNode(d *Data, node *Node, includeExit bool) error {
// Round-3 item 20: a node whose bindings drop to zero is rebuilt
// like any other (its inbound just ends up with no customer users);
// it is NOT auto-removed. The pre-item-20 auto-removal branch is
// gone; the AutoRemoving revival paths in assign/resume stay for
// records an older panel left in that state.
clients := activeNodeClients(d, node)
node.AutoRemoving = BoolPtr(false)
resource, err := resourceForNode(d, node, clients)
if err != nil {
return err
}
payload := payloadWithResource(resource, "node", KeyMaterialForNode(d, node))
QueueJob(d, node.ServerID, node.ID, "apply_resource", payload)
if forwardNode(node) && includeExit && !manualExit(node) {
exitResource, err := resourceForNodeExit(d, node)
if err != nil {
return err
}
QueueJob(d, xbDeref(node.ExitServerID), node.ID, "apply_resource", payloadWithResource(exitResource, "node-exit", nil))
}
node.Status = "deploying"
node.Error = nil
node.UpdatedAt = NowISO()
return nil
}
func DeriveNodeStatus(node *Node) {
if !node.EntryReady {
if node.Status != "failed" {
node.Status = "deploying"
}
return
}
if forwardNode(node) && !node.ExitReady && !manualExit(node) {
if node.Status != "degraded" {
node.Status = "deploying"
}
return
}
node.Status = "active"
node.Error = nil
}
// ---------- customer suspend / resume ----------
func SuspendCustomerResources(d *Data, customer *Customer, actor string) (int, error) {
if actor == "" {
actor = "system"
}
affected := []string{}
seen := map[string]bool{}
for _, nc := range d.NodeClients {
if nc.CustomerID == customer.ID && nc.Status == "active" {
nc.Status = "suspended"
nc.UpdatedAt = NowISO()
if !seen[nc.NodeID] {
seen[nc.NodeID] = true
affected = append(affected, nc.NodeID)
}
}
}
for _, nodeID := range affected {
node := orchFindNode(d, nodeID)
if node != nil && node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return len(affected), err
}
}
}
Audit(d, actor, "suspend_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
return len(affected), nil
}
func ResumeCustomerResources(d *Data, customer *Customer, actor string) (int, error) {
if actor == "" {
actor = "admin"
}
affected := []string{}
seen := map[string]bool{}
for _, nc := range d.NodeClients {
if nc.CustomerID == customer.ID && nc.Status == "suspended" {
nc.Status = "active"
nc.UpdatedAt = NowISO()
if !seen[nc.NodeID] {
seen[nc.NodeID] = true
affected = append(affected, nc.NodeID)
}
}
}
for _, nodeID := range affected {
node := orchFindNode(d, nodeID)
if node == nil {
continue
}
if node.Status == "removing" && !boolVal(node.AutoRemoving) {
continue
}
if node.Status == "draft" && !boolVal(node.AutoRemoving) {
continue
}
if node.Status == "removing" && boolVal(node.AutoRemoving) {
deleteRunning := false
for _, job := range d.Jobs {
if job.NodeID == nodeID && job.Action == "delete_resource" && job.Status == "running" {
deleteRunning = true
break
}
}
cancelQueuedDeletes(d, nodeID, "客户恢复,清理任务已取消")
node.AutoRemoving = BoolPtr(false)
if !deleteRunning {
DeriveNodeStatus(node)
node.UpdatedAt = NowISO()
continue
}
}
includeExit := forwardNode(node) && !node.ExitReady
if err := RebuildNode(d, node, includeExit); err != nil {
return len(affected), err
}
}
Audit(d, actor, "resume_customer_resources", customer.ID, map[string]any{"nodes": len(affected)})
return len(affected), nil
}
// ---------- usage delta accounting ----------
type DeltaResult struct {
UpDelta int64
DownDelta int64
}
// UsageHolder is the small interface ApplyDelta operates on; *UsageSlot and
// *Node are adapted onto it by SlotHolder / NodeHolder.
type UsageHolder interface {
GetCounters() *UsageCounters
SetCounters(c *UsageCounters)
GetRetired() []string
SetRetired(v []string)
}
type slotUsageHolder struct{ slot *UsageSlot }
func (h slotUsageHolder) GetCounters() *UsageCounters { return h.slot.UsageCounters }
func (h slotUsageHolder) SetCounters(c *UsageCounters) { h.slot.UsageCounters = c }
func (h slotUsageHolder) GetRetired() []string { return h.slot.RetiredUsageEpochs }
func (h slotUsageHolder) SetRetired(v []string) { h.slot.RetiredUsageEpochs = v }
type nodeUsageHolder struct{ node *Node }
func (h nodeUsageHolder) GetCounters() *UsageCounters { return h.node.UsageCounters }
func (h nodeUsageHolder) SetCounters(c *UsageCounters) { h.node.UsageCounters = c }
func (h nodeUsageHolder) GetRetired() []string { return h.node.RetiredUsageEpochs }
func (h nodeUsageHolder) SetRetired(v []string) { h.node.RetiredUsageEpochs = v }
func SlotHolder(s *UsageSlot) UsageHolder { return slotUsageHolder{slot: s} }
func NodeHolder(n *Node) UsageHolder { return nodeUsageHolder{node: n} }
// ApplyDelta mirrors applyDelta; a nil result means the sample is skipped.
func ApplyDelta(holder UsageHolder, uplink, downlink int64, sampleEpoch *string) *DeltaResult {
if sampleEpoch != nil && *sampleEpoch == "" {
sampleEpoch = nil
}
previous := holder.GetCounters()
if previous == nil {
previous = &UsageCounters{}
}
retired := holder.GetRetired()
if sampleEpoch != nil {
for _, e := range retired {
if e == *sampleEpoch {
return nil
}
}
}
restarted := sampleEpoch != nil && previous.Epoch != nil && *sampleEpoch != *previous.Epoch
if !restarted && sampleEpoch != nil && previous.Epoch != nil &&
!strings.HasSuffix(*sampleEpoch, ":unknown") &&
(uplink < previous.Uplink || downlink < previous.Downlink) {
return nil
}
var upDelta, downDelta int64
if !restarted && uplink >= previous.Uplink {
upDelta = uplink - previous.Uplink
} else {
upDelta = uplink
}
if !restarted && downlink >= previous.Downlink {
downDelta = downlink - previous.Downlink
} else {
downDelta = downlink
}
if restarted && previous.Epoch != nil {
next := append(append([]string{}, retired...), *previous.Epoch)
if len(next) > 8 {
next = next[len(next)-8:]
}
holder.SetRetired(next)
}
holder.SetCounters(&UsageCounters{Uplink: uplink, Downlink: downlink, Epoch: sampleEpoch})
return &DeltaResult{UpDelta: upDelta, DownDelta: downDelta}
}
// ---------- Reality key backfill ----------
func MergeKeyMaterialBackfill(d *Data, node *Node, artifacts map[string]json.RawMessage) {
if artifacts == nil {
return
}
reportedRaw, hasReported := artifacts["keyMaterial"]
delete(artifacts, "keyMaterial")
if hasReported && len(reportedRaw) > 0 {
var reported map[string]any
if err := unmarshalJSONUseNumber(reportedRaw, &reported); err == nil && reported != nil {
if d.KeyStore == nil {
d.KeyStore = map[string]*KeyEntry{}
}
for keyID, pairAny := range reported {
pair, isObj := pairAny.(map[string]any)
if !isObj {
continue
}
priv, privOK := pair["privateKey"].(string)
pub, pubOK := pair["publicKey"].(string)
if !privOK || !pubOK {
continue
}
existing := d.KeyStore[keyID]
if existing == nil {
d.KeyStore[keyID] = &KeyEntry{PrivateKey: priv, PublicKey: pub, UpdatedAt: NowISO()}
} else if existing.PrivateKey != priv || existing.PublicKey != pub {
Audit(d, "agent", "reality_key_conflict", node.ID, map[string]any{"keyId": keyID})
}
}
}
}
keyID := ""
if node.Reality != nil {
keyID = node.Reality.KeyID
}
if keyID != "" && d.KeyStore != nil {
if entry, ok := d.KeyStore[keyID]; ok && entry != nil {
raw, err := marshalJSON(entry.PublicKey)
if err == nil {
artifacts["realityPublicKey"] = raw
}
}
}
}
// ---------- deploy validation ----------
// orchJSNumberStr mirrors JS Number(string): "" is 0, unparsable is NaN.
func orchJSNumberStr(s string) float64 {
t := strings.TrimSpace(s)
if t == "" {
return 0
}
f, err := strconv.ParseFloat(t, 64)
if err != nil {
return math.NaN()
}
return f
}
func versionPart(parts []string, i int) float64 {
if i >= len(parts) {
return math.NaN()
}
return orchJSNumberStr(parts[i])
}
func ValidateNodeDeploy(d *Data, node *Node) error {
direct := !forwardNode(node)
if direct {
if herr := ValidateEntryProtocol(node.Protocol); herr != nil {
return herr
}
} else {
if herr := ValidateProtocolPair(node.Protocol, xbDeref(node.ExitProtocol)); herr != nil {
return herr
}
}
server := orchFindServer(d, node.ServerID)
if server == nil {
return orchNotFound("服务器")
}
var exitServer *Server
if !direct && !manualExit(node) {
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
if exitServer == nil {
return orchNotFound("出口服务器")
}
}
targets := []*Server{server}
if exitServer != nil {
targets = append(targets, exitServer)
}
for _, target := range targets {
if target.Enabled != nil && !*target.Enabled {
return httpErr(409, "设备 %s 已停用", target.Name)
}
}
// Round-3 item 20: no client-count gate. Nodes are built and deployed
// first and bound later (the 3x-ui model); bindings may go to zero
// and back without tearing the node down. A zero-client inbound
// simply has no users who can connect — including socks5-auth,
// whose inbound always carries its node-level fixed account
// (item 21), so it never degenerates into an open proxy.
switch node.Protocol {
case "vless-ws-tls", "hysteria2", "anytls":
if server.TLSDomain == "" {
return httpErr(400, "%s 需要先填写设备 TLS 域名并在节点上安装证书", node.Protocol)
}
certOK := false
if server.Engine != nil {
for _, c := range server.Engine.Certificates {
if c == server.TLSDomain {
certOK = true
break
}
}
}
if !certOK {
return httpErr(409, "入口证书未由 Agent 确认。在入口机运行 ng-agent update && ng-agent cert,证书申请成功后刷新设备状态")
}
}
if node.Protocol == "anytls" {
var agent *Agent
for _, item := range d.Agents {
if item.ServerID == server.ID && item.Status == "active" {
agent = item
break
}
}
version := ""
if agent != nil {
version = agent.Version
}
parts := strings.Split(version, ".")
p0, p1, p2 := versionPart(parts, 0), versionPart(parts, 1), versionPart(parts, 2)
versionOK := p0 > 0 || (p0 == 0 && (p1 > 6 || (p1 == 6 && p2 >= 1)))
if agent == nil || !versionOK {
return httpErr(409, "AnyTLS 入口需要新版 Agent:请在 %s 运行 ng-agent update", server.Name)
}
singBoxInstalled := server.Engine != nil && server.Engine.SingBoxInstalled
if !singBoxInstalled {
return httpErr(409, "AnyTLS 入口缺少 sing-box 及统计组件:请在 %s 运行 ng-agent engine install,完成后等待 Agent 心跳并刷新页面", server.Name)
}
}
servers := []*Server{server}
if exitServer != nil {
servers = []*Server{server, exitServer}
}
nowMs := time.Now().UnixMilli()
unavailable := []*Server{}
for _, s := range servers {
stale := false
if s.LastSeenAt != nil {
if ms, ok := ParseISOMs(*s.LastSeenAt); ok && nowMs-ms > 120000 {
stale = true
}
}
if s.Status != "online" || stale {
unavailable = append(unavailable, s)
}
}
if len(unavailable) > 0 {
parts := make([]string, len(unavailable))
for i, s := range unavailable {
parts[i] = fmt.Sprintf("%s(Agent 离线;请在该机器运行 ng-agent doctor)", s.Name)
}
return httpErr(409, "目标设备尚未就绪:%s", strings.Join(parts, "、"))
}
networkMode := node.NetworkMode
if networkMode == "" {
networkMode = "ipv4"
}
if networkMode == "ipv6" {
missing := []*Server{}
for _, s := range servers {
if s.PublicAddressV6 == "" {
missing = append(missing, s)
}
}
if len(missing) > 0 {
names := make([]string, len(missing))
for i, s := range missing {
names[i] = s.Name
}
return httpErr(400, "IPv6 模式需要先为这些服务器填写 IPv6 地址:%s", strings.Join(names, "、"))
}
}
if NodePortInUse(d, node.ServerID, node.Port, node.ID) {
return httpErr(409, "端口 %d 已被该服务器上的其他节点占用", node.Port)
}
if !direct && !manualExit(node) && node.ExitPort != nil && NodePortInUse(d, xbDeref(node.ExitServerID), *node.ExitPort, node.ID) {
return httpErr(409, "出口端口 %d 已被该服务器上的其他节点占用", *node.ExitPort)
}
return nil
}
// ---------- reconciliation ----------
type ReconcileOptions struct {
OnlyNodeIDs map[string]bool
FailInFlight *bool // nil mirrors the JS default: true
}
func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions) (int, error) {
server := orchFindServer(d, serverID)
if server == nil {
return 0, orchNotFound("服务器")
}
// A disabled device receives no new deployments, including reconciles.
if server.Enabled != nil && !*server.Enabled {
return 0, nil
}
failInFlight := true
if opts.FailInFlight != nil {
failInFlight = *opts.FailInFlight
}
if failInFlight {
for _, job := range d.Jobs {
if job.ServerID == serverID && (job.Status == "queued" || job.Status == "running") {
job.Status = "failed"
job.Error = StrPtr("Agent 重新注册,旧任务已由对账任务替代")
job.UpdatedAt = NowISO()
}
}
}
count := 0
for _, node := range d.Nodes {
if node.ServerID != serverID && xbDeref(node.ExitServerID) != serverID {
continue
}
if opts.OnlyNodeIDs != nil && !opts.OnlyNodeIDs[node.ID] {
continue
}
isEntry := node.ServerID == serverID
isExit := xbDeref(node.ExitServerID) == serverID && forwardNode(node)
if !isEntry && !isExit {
continue
}
if node.Status == "draft" {
continue
}
if node.Status == "removing" {
if isEntry {
QueueJob(d, serverID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
}
if isExit && node.ExitResourceID != "" {
QueueJob(d, serverID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ExitResourceID, Kind: "node-exit"})
}
} else {
clients := activeNodeClients(d, node)
if isEntry {
resource, err := resourceForNode(d, node, clients)
if err != nil {
return count, err
}
QueueJob(d, serverID, node.ID, "apply_resource", payloadWithResource(resource, "node", KeyMaterialForNode(d, node)))
}
if isExit {
resource, err := resourceForNodeExit(d, node)
if err != nil {
return count, err
}
QueueJob(d, serverID, node.ID, "apply_resource", payloadWithResource(resource, "node-exit", nil))
}
node.Status = "deploying"
}
node.UpdatedAt = NowISO()
count++
}
Audit(d, actor, "reconcile_server", server.ID, map[string]any{"nodes": count})
return count, nil
}
// ---------- Orchestrator ----------
type Orchestrator struct {
store *Store
mu sync.Mutex
heartbeatReconcileAt map[string]int64
}
// tx adapts the store's (any, error) transaction signature to the
// error-only closures used by the orchestrator methods.
func (o *Orchestrator) tx(fn func(d *Data) error) error {
_, err := o.store.Transaction(func(d *Data) (any, error) { return nil, fn(d) })
return err
}
func (o *Orchestrator) transient(fn func(d *Data) error) error {
o.store.Transient(func(d *Data) { _ = fn(d) })
return nil
}
func NewOrchestrator(store *Store) *Orchestrator {
return &Orchestrator{store: store, heartbeatReconcileAt: map[string]int64{}}
}
func cloneNodeRecord(n *Node) *Node {
raw, err := marshalJSON(n)
if err != nil {
panic(err)
}
var out Node
if err := unmarshalJSONUseNumber(raw, &out); err != nil {
panic(err)
}
return &out
}
func cloneNodeClientRecord(nc *NodeClient) *NodeClient {
raw, err := marshalJSON(nc)
if err != nil {
panic(err)
}
var out NodeClient
if err := unmarshalJSONUseNumber(raw, &out); err != nil {
panic(err)
}
return &out
}
func cloneJobRecord(j *Job) *Job {
raw, err := marshalJSON(j)
if err != nil {
panic(err)
}
var out Job
if err := unmarshalJSONUseNumber(raw, &out); err != nil {
panic(err)
}
return &out
}
func (o *Orchestrator) DeployNode(nodeID, actor string) (*Node, error) {
if actor == "" {
actor = "admin"
}
var result *Node
err := o.tx(func(d *Data) error {
node := orchFindNode(d, nodeID)
if node == nil {
return orchNotFound("节点")
}
if err := ValidateNodeDeploy(d, node); err != nil {
return err
}
if err := RebuildNode(d, node, true); err != nil {
return err
}
Audit(d, actor, "deploy_node", node.ID, map[string]any{"name": node.Name})
result = cloneNodeRecord(node)
return nil
})
return result, err
}
func (o *Orchestrator) RemoveNode(nodeID, actor string, deleteRecord bool) (bool, error) {
if actor == "" {
actor = "admin"
}
err := o.tx(func(d *Data) error {
node := orchFindNode(d, nodeID)
if node == nil {
return orchNotFound("节点")
}
node.PendingDelete = deleteRecord
node.AutoRemoving = BoolPtr(false)
if node.Status != "draft" {
QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"})
if forwardNode(node) && node.ExitResourceID != "" {
QueueJob(d, xbDeref(node.ExitServerID), node.ID, "delete_resource", &JobPayload{ResourceID: node.ExitResourceID, Kind: "node-exit"})
}
node.Status = "removing"
node.Error = nil
} else if deleteRecord {
DropRealityKey(d, node)
kept := []*Node{}
for _, n := range d.Nodes {
if n.ID != node.ID {
kept = append(kept, n)
}
}
d.Nodes = kept
keptClients := []*NodeClient{}
for _, nc := range d.NodeClients {
if nc.NodeID != node.ID {
keptClients = append(keptClients, nc)
}
}
d.NodeClients = keptClients
}
node.UpdatedAt = NowISO()
action := "remove_node"
if deleteRecord {
action = "delete_node"
}
Audit(d, actor, action, node.ID, map[string]any{"name": node.Name})
return nil
})
return err == nil, err
}
func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
if actor == "" {
actor = "admin"
}
err := o.tx(func(d *Data) error {
node := orchFindNode(d, nodeID)
if node == nil {
return orchNotFound("节点")
}
if node.Status != "failed" && node.Status != "degraded" {
return httpErr(409, "节点状态正常,无需修复")
}
if err := RebuildNode(d, node, true); err != nil {
return err
}
Audit(d, actor, "repair_node", node.ID, map[string]any{"name": node.Name})
return nil
})
return err == nil, err
}
// assignCustomerData is the transaction-free core of AssignCustomer,
// shared with the customer form's node alignment (AlignCustomerNodes).
func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient, error) {
node := orchFindNode(d, nodeID)
if node == nil {
return nil, orchNotFound("节点")
}
customer := orchFindCustomer(d, customerID)
if customer == nil {
return nil, orchNotFound("客户")
}
if customer.Status != "active" {
return nil, httpErr(409, "只能给状态正常的客户分配节点")
}
var nc *NodeClient
for _, entry := range d.NodeClients {
if entry.NodeID == nodeID && entry.CustomerID == customerID {
nc = entry
break
}
}
if nc != nil && nc.Status == "active" {
return nil, httpErr(409, "该客户已分配到此节点")
}
if nc != nil {
nc.Status = "active"
nc.UpdatedAt = NowISO()
} else {
now := NowISO()
ensureCustomerCredentials(d, customerID)
nc = &NodeClient{
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customerID,
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
d.NodeClients = append(d.NodeClients, nc)
}
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
// Record the assignment only.
} else {
if node.Status == "removing" && boolVal(node.AutoRemoving) {
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
}
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
return nc, nil
}
func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
node := orchFindNode(d, nodeID)
if node == nil {
return orchNotFound("节点")
}
index := -1
for i, entry := range d.NodeClients {
if entry.NodeID == nodeID && entry.CustomerID == customerID {
index = i
break
}
}
if index < 0 {
return httpErr(404, "该客户未分配到此节点")
}
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
if node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return err
}
}
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
return nil
}
// AlignCustomerNodes mirrors alignCustomerNodes in lib/orchestrator.js:
// full-set alignment of one customer's node bindings (customer form's
// 关联入站 multi-select). Suspended customers get suspended bindings.
func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor string) error {
desired := []string{}
seen := map[string]bool{}
for _, nodeID := range desiredIds {
if nodeID == "" || seen[nodeID] {
continue
}
seen[nodeID] = true
node := orchFindNode(d, nodeID)
if node == nil || node.PendingDelete {
return httpErr(400, "节点不存在")
}
desired = append(desired, nodeID)
}
rebuild := map[string]bool{}
kept := []*NodeClient{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customer.ID {
kept = append(kept, nc)
continue
}
if seen[nc.NodeID] {
kept = append(kept, nc)
continue
}
Audit(d, actor, "unassign_customer", nc.NodeID, map[string]any{"customerId": customer.ID})
rebuild[nc.NodeID] = true
}
d.NodeClients = kept
for _, nodeID := range desired {
exists := false
for _, nc := range d.NodeClients {
if nc.CustomerID == customer.ID && nc.NodeID == nodeID {
exists = true
break
}
}
if exists {
continue
}
node := orchFindNode(d, nodeID)
status := "suspended"
if customer.Status == "active" {
status = "active"
}
now := NowISO()
ensureCustomerCredentials(d, customer.ID)
d.NodeClients = append(d.NodeClients, &NodeClient{
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customer.ID,
Status: status,
CreatedAt: now,
UpdatedAt: now,
})
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customer.ID, "customer": customer.Name})
if status == "active" {
rebuild[nodeID] = true
}
}
for nodeID := range rebuild {
node := orchFindNode(d, nodeID)
if node == nil {
continue
}
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
continue
}
if node.Status == "removing" && boolVal(node.AutoRemoving) {
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return err
}
}
return nil
}
func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeClient, error) {
if actor == "" {
actor = "admin"
}
var result *NodeClient
err := o.tx(func(d *Data) error {
nc, err := assignCustomerData(d, nodeID, customerID, actor)
if err != nil {
return err
}
result = cloneNodeClientRecord(nc)
return nil
})
return result, err
}
func (o *Orchestrator) UnassignCustomer(nodeID, customerID, actor string) (bool, error) {
if actor == "" {
actor = "admin"
}
err := o.tx(func(d *Data) error {
return unassignCustomerData(d, nodeID, customerID, actor)
})
return err == nil, err
}
func (o *Orchestrator) ReconcileServer(serverID, actor string) (int, error) {
if actor == "" {
actor = "system"
}
var count int
err := o.tx(func(d *Data) error {
var err error
count, err = reconcileServerData(d, serverID, actor, ReconcileOptions{})
return err
})
return count, err
}
func (o *Orchestrator) ReconcileFromHeartbeat(serverID string, reportedResourceIds []string) (int, error) {
if reportedResourceIds == nil {
return 0, nil
}
o.mu.Lock()
last := o.heartbeatReconcileAt[serverID]
o.mu.Unlock()
if time.Now().UnixMilli()-last < HeartbeatReconcileIntervalMs {
return 0, nil
}
reported := map[string]bool{}
limit := len(reportedResourceIds)
if limit > 1000 {
limit = 1000
}
for _, v := range reportedResourceIds[:limit] {
if v != "" {
reported[v] = true
}
}
triggered := false
var count int
err := o.tx(func(d *Data) error {
server := orchFindServer(d, serverID)
if server == nil {
return nil
}
missing := map[string]bool{}
for _, node := range d.Nodes {
if node.Status == "draft" || node.Status == "removing" {
continue
}
if node.ServerID == serverID && node.ResourceID != "" && !reported[node.ResourceID] {
missing[node.ID] = true
}
if xbDeref(node.ExitServerID) == serverID && forwardNode(node) && node.ExitResourceID != "" && !reported[node.ExitResourceID] {
missing[node.ID] = true
}
}
if len(missing) == 0 {
return nil
}
for _, job := range d.Jobs {
if job.ServerID == serverID && (job.Status == "queued" || job.Status == "running") {
return nil
}
}
triggered = true
fail := false
var err error
count, err = reconcileServerData(d, serverID, "system:heartbeat", ReconcileOptions{OnlyNodeIDs: missing, FailInFlight: &fail})
return err
})
if err != nil {
return 0, err
}
if triggered {
o.mu.Lock()
o.heartbeatReconcileAt[serverID] = time.Now().UnixMilli()
o.mu.Unlock()
}
return count, nil
}
type CompleteJobResult struct {
Artifacts map[string]json.RawMessage
}
func (o *Orchestrator) CompleteJob(jobID string, success bool, result CompleteJobResult, errorMessage *string) (*Job, error) {
var out *Job
err := o.tx(func(d *Data) error {
var job *Job
for _, item := range d.Jobs {
if item.ID == jobID {
job = item
break
}
}
if job == nil {
return orchNotFound("任务")
}
if job.Status != "running" {
if job.Status == "completed" && success {
out = cloneJobRecord(job)
return nil
}
return httpErr(409, "任务已被重试或取消,请等待当前任务完成")
}
now := NowISO()
if success {
job.Status = "completed"
} else {
job.Status = "failed"
}
job.UpdatedAt = now
job.CompletedAt = StrPtr(now)
job.LeaseUntil = nil
if success {
job.Error = nil
} else {
msg := "Agent operation failed"
if errorMessage != nil {
msg = *errorMessage
}
job.Error = StrPtr(xbTruncRunes(RedactSecrets(msg), 1800))
}
node := orchFindNode(d, job.NodeID)
if node == nil {
out = cloneJobRecord(job)
return nil
}
kind := job.Payload.KindOf()
switch {
case success && job.Action == "apply_resource":
if kind == "node-exit" {
node.ExitReady = true
} else {
node.EntryReady = true
artifacts := result.Artifacts
if artifacts == nil {
artifacts = map[string]json.RawMessage{}
}
node.Artifacts = artifacts
MergeKeyMaterialBackfill(d, node, node.Artifacts)
}
DeriveNodeStatus(node)
case success && job.Action == "delete_resource":
if kind == "node-exit" {
node.ExitReady = false
} else {
node.EntryReady = false
}
pending := false
for _, item := range d.Jobs {
if item.NodeID == node.ID && item.Action == "delete_resource" && (item.Status == "queued" || item.Status == "running") {
pending = true
break
}
}
if !pending && node.Status == "removing" {
if node.PendingDelete {
DropRealityKey(d, node)
kept := []*Node{}
for _, n := range d.Nodes {
if n.ID != node.ID {
kept = append(kept, n)
}
}
d.Nodes = kept
keptClients := []*NodeClient{}
for _, nc := range d.NodeClients {
if nc.NodeID != node.ID {
keptClients = append(keptClients, nc)
}
}
d.NodeClients = keptClients
} else {
node.Status = "draft"
node.Error = nil
}
}
case !success:
if kind == "node-exit" {
node.Status = "degraded"
} else {
node.Status = "failed"
}
node.Error = job.Error
}
node.UpdatedAt = NowISO()
out = cloneJobRecord(job)
return nil
})
return out, err
}
// ---------- usage reporting ----------
type UsageSample struct {
Kind string `json:"kind"`
ID string `json:"id"`
Uplink json.Number `json:"uplink"`
Downlink json.Number `json:"downlink"`
Epoch *string `json:"epoch"`
}
// safeJSInt mirrors Number.isSafeInteger(v) && v >= 0 over a JSON number.
func safeJSInt(n json.Number) (int64, bool) {
if n == "" {
return 0, false
}
f, err := n.Float64()
if err != nil {
return 0, false
}
if f != math.Trunc(f) || f < 0 || math.Abs(f) > 9007199254740991 {
return 0, false
}
return int64(f), true
}
func usageErrorPtr(errText string) *string {
if errText == "" {
return nil
}
return StrPtr(xbTruncRunes(errText, 240))
}
func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errText string, epoch string) error {
if len(samples) == 0 {
return o.transient(func(d *Data) error {
server := orchFindServer(d, serverID)
if server != nil {
server.Usage = &ServerUsage{LastReportAt: NowISO(), SampleCount: 0, Error: usageErrorPtr(errText)}
}
return nil
})
}
return o.tx(func(d *Data) error {
reportedAt := NowISO()
server := orchFindServer(d, serverID)
if server != nil {
server.Usage = &ServerUsage{LastReportAt: reportedAt, SampleCount: len(samples), Error: usageErrorPtr(errText)}
}
limit := len(samples)
if limit > 1000 {
limit = 1000
}
for _, sample := range samples[:limit] {
uplink, okUp := safeJSInt(sample.Uplink)
downlink, okDown := safeJSInt(sample.Downlink)
if !okUp || !okDown {
continue
}
var sampleEpoch *string
if sample.Epoch != nil {
sampleEpoch = StrPtr(xbTruncRunes(*sample.Epoch, 80))
} else if epoch != "" {
sampleEpoch = StrPtr(epoch)
}
switch sample.Kind {
case "user":
customer := orchFindCustomer(d, sample.ID)
if customer == nil {
continue
}
key := "srv:" + serverID
if customer.ServerUsage == nil {
customer.ServerUsage = map[string]*UsageSlot{}
}
slot := customer.ServerUsage[key]
if slot == nil {
slot = &UsageSlot{}
customer.ServerUsage[key] = slot
}
prevCounters := slot.UsageCounters
prevSampleISO := slot.LastSampleAt
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
delta := ApplyDelta(SlotHolder(slot), uplink, downlink, sampleEpoch)
if delta == nil {
continue
}
customer.UsedUplinkBytes += delta.UpDelta
customer.UsedDownlinkBytes += delta.DownDelta
customer.UsedBytes += delta.UpDelta + delta.DownDelta
customer.LastUsageAt = StrPtr(reportedAt)
customer.UpdatedAt = reportedAt
slot.LastSampleAt = StrPtr(reportedAt)
if delta.UpDelta > 0 || delta.DownDelta > 0 {
slot.LastActiveAt = StrPtr(reportedAt)
customer.LastOnlineAt = StrPtr(reportedAt)
}
if !restarted && prevCounters != nil && prevSampleISO != nil {
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
dt := float64(nowMs-prevMs) / 1000
if dt >= 5 {
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
slot.SpeedUpBps = &up
slot.SpeedDownBps = &down
slot.SpeedAt = StrPtr(reportedAt)
}
}
}
}
case "inbound":
node := orchFindNode(d, sample.ID)
if node == nil || node.ServerID != serverID {
continue
}
prevCounters := node.UsageCounters
prevSampleISO := node.LastSampleAt
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
delta := ApplyDelta(NodeHolder(node), uplink, downlink, sampleEpoch)
if delta == nil {
continue
}
metered := node.MeteredTraffic
var mu, md int64
if metered != nil {
mu, md = metered.Uplink, metered.Downlink
}
node.MeteredTraffic = &MeteredTraffic{Uplink: mu + delta.UpDelta, Downlink: md + delta.DownDelta}
node.LastUsageAt = StrPtr(reportedAt)
node.LastSampleAt = StrPtr(reportedAt)
if delta.UpDelta > 0 || delta.DownDelta > 0 {
node.LastActiveAt = StrPtr(reportedAt)
}
if !restarted && prevCounters != nil && prevSampleISO != nil {
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
dt := float64(nowMs-prevMs) / 1000
if dt >= 5 {
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
node.SpeedUpBps = &up
node.SpeedDownBps = &down
node.SpeedAt = StrPtr(reportedAt)
}
}
}
}
}
}
return nil
})
}