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.
1507 lines
40 KiB
Go
1507 lines
40 KiB
Go
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
|
||
})
|
||
}
|