P8(2): inbound node reassignment (move) with backend re-homing
An inbound's 部署到 node can now be changed after creation; the row, clients, client_traffics and subscription output stay untouched (same inbound id throughout). Contract: the update payload's nodeId counts only when the key was explicitly sent — the controller probes the raw JSON body and sets Inbound.NodeIDProvided; the wire adapter now always emits nodeId (null = local) so clearing the picker is an explicit move-to-local, while callers that omit nodeId keep the stored assignment (legacy behaviour). UpdateInbound resolves the target before the port-conflict check (so conflicts are scoped to the target node), rejects moves to a missing node, re-derives auto tags under the target node's prefix, validates the tag against the target node's namespace, and marks BOTH nodes dirty: the source node's next reconcile sweeps the old tag (no silent ghost — the flag persists until a reconcile succeeds, so an offline side just defers), the target's pushes the inbound. Agent-bound nodes converge via GetXrayConfigForAgent on the agent's next config poll. A local side has no reconcile loop, so it is applied directly post-commit (Del/Add on the local runtime, restart fallback). Frontend: the deploy-to Select is no longer disabled in edit mode (protocol stays locked); candidate rules unchanged (online nodes only). Tests: panel->panel move with reconcile sweep/push assertions, auto-tag prefix regeneration, agent-config flip in both directions, omitted-nodeId legacy contract, same-node no-op, missing-node rejection, node->local move.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -222,6 +224,27 @@ func (a *InboundController) bulkDelInbounds(c *gin.Context) {
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
// requestHasJSONKey reports whether the request body is a JSON object that
|
||||
// explicitly carries key — present with a null value counts. The body is
|
||||
// restored so the normal bind can read it again. Non-JSON bodies (form
|
||||
// posts) report false for every key.
|
||||
func requestHasJSONKey(c *gin.Context, key string) bool {
|
||||
if c.Request == nil || c.Request.Body == nil {
|
||||
return false
|
||||
}
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil || len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
var probe map[string]json.RawMessage
|
||||
if json.Unmarshal(body, &probe) != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := probe[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// updateInbound updates an existing inbound configuration.
|
||||
func (a *InboundController) updateInbound(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
@@ -232,13 +255,16 @@ func (a *InboundController) updateInbound(c *gin.Context) {
|
||||
inbound := &model.Inbound{
|
||||
Id: id,
|
||||
}
|
||||
// An explicitly sent nodeId (even null) is a (re)assignment request —
|
||||
// UpdateInbound migrates the inbound when it differs from the stored
|
||||
// assignment. An omitted nodeId keeps the stored assignment, so detect
|
||||
// the key's presence on the raw body before binding consumes it.
|
||||
nodeIDProvided := requestHasJSONKey(c, "nodeId")
|
||||
if !middleware.BindAndValidateInto(c, inbound) {
|
||||
return
|
||||
}
|
||||
// Same NodeID=0 → nil normalisation as addInbound. UpdateInbound
|
||||
// loads the existing row's NodeID from DB anyway (Phase 1 doesn't
|
||||
// support migrating an inbound between nodes), but normalising here
|
||||
// keeps the wire shape consistent.
|
||||
inbound.NodeIDProvided = nodeIDProvided
|
||||
// Same NodeID=0 → nil normalisation as addInbound.
|
||||
if inbound.NodeID != nil && *inbound.NodeID == 0 {
|
||||
inbound.NodeID = nil
|
||||
}
|
||||
|
||||
@@ -1494,9 +1494,26 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
if err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
// Restore the stored NodeID before the port-conflict check so a node inbound
|
||||
// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
|
||||
inbound.NodeID = oldInbound.NodeID
|
||||
// Resolve the assignment target before the port-conflict check so the
|
||||
// inbound is scoped to the node it will live on. The payload's nodeId
|
||||
// only counts when it was explicitly sent (NodeIDProvided): callers
|
||||
// often omit it, and an omission must keep the stored assignment rather
|
||||
// than yank the inbound back to the local panel. An explicit nodeId
|
||||
// that differs from the stored one is a move — the transaction below
|
||||
// re-homes the inbound (both nodes marked dirty, local side applied
|
||||
// directly) without touching its id, clients or traffic rows.
|
||||
storedNodeID := oldInbound.NodeID
|
||||
targetNodeID := storedNodeID
|
||||
if inbound.NodeIDProvided {
|
||||
targetNodeID = inbound.NodeID
|
||||
}
|
||||
nodeChanged := !sameNode(storedNodeID, targetNodeID)
|
||||
if nodeChanged && targetNodeID != nil {
|
||||
if _, err := (&NodeService{}).GetById(*targetNodeID); err != nil {
|
||||
return inbound, false, common.NewErrorf("cannot move inbound to node %d: node not found", *targetNodeID)
|
||||
}
|
||||
}
|
||||
inbound.NodeID = targetNodeID
|
||||
if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
|
||||
return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
|
||||
}
|
||||
@@ -1505,6 +1522,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
// overwritten with the new values further down, then ensure a routed
|
||||
// inbound keeps a stable egress port (reusing the one already stored).
|
||||
oldProtocol := oldInbound.Protocol
|
||||
oldEnable := oldInbound.Enable
|
||||
oldRoutedMtproto := mtprotoRoutesThroughXray(oldInbound)
|
||||
if err := s.normalizeMtprotoXrayPort(inbound, oldInbound.Settings); err != nil {
|
||||
return inbound, false, err
|
||||
@@ -1645,7 +1663,71 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
oldInbound.Tag = resolvedTag
|
||||
inbound.Tag = oldInbound.Tag
|
||||
|
||||
if oldInbound.NodeID == nil {
|
||||
// Persist the resolved assignment with the rest of the row. From
|
||||
// here on oldInbound.NodeID holds the target; the branch below keys
|
||||
// the move handling off nodeChanged, not off the stored value.
|
||||
oldInbound.NodeID = targetNodeID
|
||||
if nodeChanged {
|
||||
// Move: validate the resolved tag against the target node's
|
||||
// namespace, exactly like a same-node edit does for its node.
|
||||
if targetNodeID != nil {
|
||||
if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, *targetNodeID, oldInbound.Tag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Node sides converge through the dirty marks written at the
|
||||
// end of this transaction (source + target): the source node's
|
||||
// next reconcile sweeps the inbound's old tag, the target's
|
||||
// pushes the inbound — the same backstop DelInbound relies on,
|
||||
// so an unreachable side defers (pending) instead of silently
|
||||
// stranding a ghost. A local side has no reconcile loop, so it
|
||||
// is applied directly post-commit like a normal local edit.
|
||||
if storedNodeID == nil || targetNodeID == nil {
|
||||
probe := *oldInbound
|
||||
probe.NodeID = nil
|
||||
rt, push, _, perr := s.nodePushPlan(&probe)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
if !push {
|
||||
needRestart = true
|
||||
} else {
|
||||
var runtimeInbound *model.Inbound
|
||||
if targetNodeID == nil && inbound.Enable {
|
||||
built, bErr := s.buildInboundForLocalRuntime(tx, oldInbound)
|
||||
if bErr != nil {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", bErr)
|
||||
needRestart = true
|
||||
} else {
|
||||
runtimeInbound = built
|
||||
}
|
||||
}
|
||||
oldSnapshot := *oldInbound
|
||||
oldSnapshot.Tag = tag
|
||||
oldSnapshot.NodeID = storedNodeID
|
||||
removeFromLocal := storedNodeID == nil && oldEnable
|
||||
postCommitApply = func() {
|
||||
if removeFromLocal {
|
||||
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
|
||||
logger.Debug("Moved inbound deleted on", rt.Name(), ":", tag)
|
||||
} else {
|
||||
logger.Debug("Unable to delete moved inbound on", rt.Name(), ":", err2)
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
if runtimeInbound == nil {
|
||||
return
|
||||
}
|
||||
if err2 := rt.AddInbound(context.Background(), runtimeInbound); err2 == nil {
|
||||
logger.Debug("Moved inbound added on", rt.Name(), ":", oldInbound.Tag)
|
||||
} else {
|
||||
logger.Debug("Unable to add moved inbound on", rt.Name(), ":", err2)
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if oldInbound.NodeID == nil {
|
||||
rt, push, _, perr := s.nodePushPlan(oldInbound)
|
||||
if perr != nil {
|
||||
return perr
|
||||
@@ -1728,6 +1810,15 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
return err
|
||||
}
|
||||
}
|
||||
if nodeChanged && storedNodeID != nil {
|
||||
// The source node must reconcile too: its sweep is what removes
|
||||
// the moved inbound's old tag from that node. The flag stays set
|
||||
// until a reconcile succeeds, so an offline source defers the
|
||||
// cleanup instead of dropping it.
|
||||
if err := (&NodeService{}).MarkNodeDirtyTx(tx, *storedNodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// (Re)generate the Xray config whenever routing was or is now enabled, so
|
||||
// the egress SOCKS bridge is added, moved, or dropped to match the new
|
||||
// settings.
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// statefulFakePanel is a node panel double with real state: added inbounds
|
||||
// appear in its list, deleted ones disappear. It records every mutation so
|
||||
// move tests can assert what reconcile actually pushed where.
|
||||
type statefulFakePanel struct {
|
||||
mu sync.Mutex
|
||||
byTag map[string]int
|
||||
byID map[int]string
|
||||
nextID int
|
||||
added []string
|
||||
updated []int
|
||||
deleted []int
|
||||
}
|
||||
|
||||
func newStatefulFakePanel(t *testing.T, seed map[string]int) (*httptest.Server, *statefulFakePanel) {
|
||||
t.Helper()
|
||||
p := &statefulFakePanel{byTag: map[string]int{}, byID: map[int]string{}, nextID: 1000}
|
||||
for tag, id := range seed {
|
||||
p.byTag[tag] = id
|
||||
p.byID[id] = tag
|
||||
}
|
||||
writeOK := func(w http.ResponseWriter, obj any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
|
||||
type row struct {
|
||||
Id int `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Listen string `json:"listen"`
|
||||
}
|
||||
p.mu.Lock()
|
||||
rows := make([]row, 0, len(p.byTag))
|
||||
for tag, id := range p.byTag {
|
||||
rows = append(rows, row{Id: id, Tag: tag})
|
||||
}
|
||||
p.mu.Unlock()
|
||||
writeOK(w, rows)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/add", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tag := r.Form.Get("tag")
|
||||
p.mu.Lock()
|
||||
p.nextID++
|
||||
id := p.nextID
|
||||
p.byTag[tag] = id
|
||||
p.byID[id] = tag
|
||||
p.added = append(p.added, tag)
|
||||
p.mu.Unlock()
|
||||
writeOK(w, map[string]any{"id": id, "tag": tag})
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/update/"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.updated = append(p.updated, id)
|
||||
p.mu.Unlock()
|
||||
writeOK(w, nil)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if tag, ok := p.byID[id]; ok {
|
||||
delete(p.byID, id)
|
||||
delete(p.byTag, tag)
|
||||
}
|
||||
p.deleted = append(p.deleted, id)
|
||||
p.mu.Unlock()
|
||||
writeOK(w, nil)
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, p
|
||||
}
|
||||
|
||||
func (p *statefulFakePanel) tags() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]string, 0, len(p.byTag))
|
||||
for tag := range p.byTag {
|
||||
out = append(out, tag)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *statefulFakePanel) addedTags() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]string(nil), p.added...)
|
||||
}
|
||||
|
||||
func (p *statefulFakePanel) deletedIDs() []int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]int(nil), p.deleted...)
|
||||
}
|
||||
|
||||
const moveTestSettings = `{"clients":[{"id":"11111111-2222-3333-4444-555555555555","email":"mv@example.com","enable":true}]}`
|
||||
|
||||
func seedMoveInbound(t *testing.T, tag string, port int, nodeID *int) model.Inbound {
|
||||
t.Helper()
|
||||
seedInboundConflictNode(t, tag, "", port, model.VLESS, `{"network":"tcp"}`, moveTestSettings, nodeID)
|
||||
var ib model.Inbound
|
||||
if err := database.GetDB().Where("tag = ?", tag).First(&ib).Error; err != nil {
|
||||
t.Fatalf("read seeded inbound: %v", err)
|
||||
}
|
||||
ct := &xray.ClientTraffic{InboundId: ib.Id, Enable: true, Email: "mv@example.com", Up: 111, Down: 222}
|
||||
if err := database.GetDB().Create(ct).Error; err != nil {
|
||||
t.Fatalf("seed client traffic: %v", err)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
func reloadInbound(t *testing.T, id int) model.Inbound {
|
||||
t.Helper()
|
||||
var ib model.Inbound
|
||||
if err := database.GetDB().First(&ib, id).Error; err != nil {
|
||||
t.Fatalf("reload inbound %d: %v", id, err)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
func nodeDirty(t *testing.T, id int) bool {
|
||||
t.Helper()
|
||||
var n model.Node
|
||||
if err := database.GetDB().First(&n, id).Error; err != nil {
|
||||
t.Fatalf("reload node %d: %v", id, err)
|
||||
}
|
||||
return n.ConfigDirty
|
||||
}
|
||||
|
||||
func clientTrafficTotals(t *testing.T, email string) (int64, int64) {
|
||||
t.Helper()
|
||||
var ct xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", email).First(&ct).Error; err != nil {
|
||||
t.Fatalf("read client traffic: %v", err)
|
||||
}
|
||||
return ct.Up, ct.Down
|
||||
}
|
||||
|
||||
// The headline move: panel node → panel node. The row keeps its id, clients
|
||||
// and traffic; both nodes are marked dirty; reconcile then deletes the
|
||||
// inbound from the source panel and pushes it to the target panel.
|
||||
func TestUpdateInbound_MoveBetweenPanelNodes(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
tsA, panelA := newStatefulFakePanel(t, map[string]int{"movable": 11})
|
||||
tsB, panelB := newStatefulFakePanel(t, nil)
|
||||
nodeA := reconcileTestNode(t, tsA, "move-a", "all", nil)
|
||||
nodeB := reconcileTestNode(t, tsB, "move-b", "all", nil)
|
||||
|
||||
seeded := seedMoveInbound(t, "movable", 2443, &nodeA.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
update := seeded
|
||||
update.NodeID = &nodeB.Id
|
||||
update.NodeIDProvided = true
|
||||
if _, _, err := svc.UpdateInbound(&update); err != nil {
|
||||
t.Fatalf("UpdateInbound move: %v", err)
|
||||
}
|
||||
|
||||
after := reloadInbound(t, seeded.Id)
|
||||
if after.NodeID == nil || *after.NodeID != nodeB.Id {
|
||||
t.Fatalf("node_id after move = %v, want %d", after.NodeID, nodeB.Id)
|
||||
}
|
||||
if after.Tag != "movable" {
|
||||
t.Fatalf("custom tag changed across move: %q", after.Tag)
|
||||
}
|
||||
if !nodeDirty(t, nodeA.Id) || !nodeDirty(t, nodeB.Id) {
|
||||
t.Fatalf("both nodes must be dirty after a move (A=%v B=%v)", nodeDirty(t, nodeA.Id), nodeDirty(t, nodeB.Id))
|
||||
}
|
||||
if up, down := clientTrafficTotals(t, "mv@example.com"); up != 111 || down != 222 {
|
||||
t.Fatalf("client traffic changed across move: up=%d down=%d, want 111/222", up, down)
|
||||
}
|
||||
|
||||
// Reconcile the source: the moved inbound must be swept from panel A.
|
||||
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(nodeA, nil), nodeA); err != nil {
|
||||
t.Fatalf("ReconcileNode source: %v", err)
|
||||
}
|
||||
if got := panelA.deletedIDs(); len(got) != 1 || got[0] != 11 {
|
||||
t.Fatalf("source panel deletions = %v, want [11]", got)
|
||||
}
|
||||
if tags := panelA.tags(); len(tags) != 0 {
|
||||
t.Fatalf("source panel still holds tags %v after reconcile", tags)
|
||||
}
|
||||
|
||||
// Reconcile the target: the inbound must be pushed to panel B.
|
||||
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(nodeB, nil), nodeB); err != nil {
|
||||
t.Fatalf("ReconcileNode target: %v", err)
|
||||
}
|
||||
if got := panelB.addedTags(); len(got) != 1 || got[0] != "movable" {
|
||||
t.Fatalf("target panel additions = %v, want [movable]", got)
|
||||
}
|
||||
if tags := panelB.tags(); len(tags) != 1 || tags[0] != "movable" {
|
||||
t.Fatalf("target panel tags = %v, want [movable]", tags)
|
||||
}
|
||||
}
|
||||
|
||||
// An auto-generated tag carries the node prefix: a move re-derives it under
|
||||
// the target node's prefix (the row — and its clients — stay the same).
|
||||
func TestUpdateInbound_MoveRegeneratesAutoTagPrefix(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
tsA, _ := newStatefulFakePanel(t, nil)
|
||||
tsB, _ := newStatefulFakePanel(t, nil)
|
||||
nodeA := reconcileTestNode(t, tsA, "tag-a", "all", nil)
|
||||
nodeB := reconcileTestNode(t, tsB, "tag-b", "all", nil)
|
||||
|
||||
oldTag := fmt.Sprintf("n%d-in-2444-tcp", nodeA.Id)
|
||||
seeded := seedMoveInbound(t, oldTag, 2444, &nodeA.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
update := seeded
|
||||
update.NodeID = &nodeB.Id
|
||||
update.NodeIDProvided = true
|
||||
if _, _, err := svc.UpdateInbound(&update); err != nil {
|
||||
t.Fatalf("UpdateInbound move: %v", err)
|
||||
}
|
||||
after := reloadInbound(t, seeded.Id)
|
||||
want := fmt.Sprintf("n%d-in-2444-tcp", nodeB.Id)
|
||||
if after.Tag != want {
|
||||
t.Fatalf("auto tag after move = %q, want %q", after.Tag, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Agent-bound nodes take their whole config from GetXrayConfigForAgent, so a
|
||||
// move must flip which node's generated config carries the inbound — in
|
||||
// both directions (agent → panel as well as panel → agent).
|
||||
func TestUpdateInbound_MoveFlipsAgentConfig(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
tsA, _ := newStatefulFakePanel(t, nil)
|
||||
nodePanel := reconcileTestNode(t, tsA, "cfg-panel", "all", nil)
|
||||
agentNode := &model.Node{Name: "cfg-agent", Enable: true, Status: "online", InboundsAdoptedAt: 1}
|
||||
if err := database.GetDB().Create(agentNode).Error; err != nil {
|
||||
t.Fatalf("create agent node row: %v", err)
|
||||
}
|
||||
|
||||
agentHasTag := func() bool {
|
||||
cfg, err := (&XrayService{}).GetXrayConfigForAgent(agentNode.Id, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetXrayConfigForAgent: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal agent config: %v", err)
|
||||
}
|
||||
return strings.Contains(string(raw), `"movable"`)
|
||||
}
|
||||
|
||||
seeded := seedMoveInbound(t, "movable", 2445, &agentNode.Id)
|
||||
if !agentHasTag() {
|
||||
t.Fatal("agent config should contain the inbound before the move")
|
||||
}
|
||||
|
||||
svc := &InboundService{}
|
||||
// agent → panel
|
||||
update := seeded
|
||||
update.NodeID = &nodePanel.Id
|
||||
update.NodeIDProvided = true
|
||||
if _, _, err := svc.UpdateInbound(&update); err != nil {
|
||||
t.Fatalf("UpdateInbound agent->panel: %v", err)
|
||||
}
|
||||
if agentHasTag() {
|
||||
t.Fatal("agent config still contains the inbound after agent->panel move")
|
||||
}
|
||||
if !nodeDirty(t, agentNode.Id) || !nodeDirty(t, nodePanel.Id) {
|
||||
t.Fatal("both nodes must be dirty after agent->panel move")
|
||||
}
|
||||
|
||||
// panel → agent (fresh read so the payload matches the stored row)
|
||||
current := reloadInbound(t, seeded.Id)
|
||||
current.NodeID = &agentNode.Id
|
||||
current.NodeIDProvided = true
|
||||
if _, _, err := svc.UpdateInbound(¤t); err != nil {
|
||||
t.Fatalf("UpdateInbound panel->agent: %v", err)
|
||||
}
|
||||
if !agentHasTag() {
|
||||
t.Fatal("agent config should contain the inbound after panel->agent move")
|
||||
}
|
||||
if up, down := clientTrafficTotals(t, "mv@example.com"); up != 111 || down != 222 {
|
||||
t.Fatalf("client traffic changed across moves: up=%d down=%d, want 111/222", up, down)
|
||||
}
|
||||
}
|
||||
|
||||
// Without NodeIDProvided the payload's nodeId is ignored entirely — both an
|
||||
// omission (nil) and a stray different value keep the stored assignment.
|
||||
// This is the legacy contract every non-form caller relies on.
|
||||
func TestUpdateInbound_OmittedNodeIdKeepsAssignment(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
nodeA := &model.Node{Name: "keep-a", Enable: true, Status: "online", InboundsAdoptedAt: 1}
|
||||
nodeB := &model.Node{Name: "keep-b", Enable: true, Status: "online", InboundsAdoptedAt: 1}
|
||||
if err := database.GetDB().Create(nodeA).Error; err != nil {
|
||||
t.Fatalf("create node A: %v", err)
|
||||
}
|
||||
if err := database.GetDB().Create(nodeB).Error; err != nil {
|
||||
t.Fatalf("create node B: %v", err)
|
||||
}
|
||||
seeded := seedMoveInbound(t, "movable", 2446, &nodeA.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
omitted := seeded
|
||||
omitted.NodeID = nil
|
||||
if _, _, err := svc.UpdateInbound(&omitted); err != nil {
|
||||
t.Fatalf("UpdateInbound omitted nodeId: %v", err)
|
||||
}
|
||||
if after := reloadInbound(t, seeded.Id); after.NodeID == nil || *after.NodeID != nodeA.Id {
|
||||
t.Fatalf("omitted nodeId moved the inbound: node_id=%v", after.NodeID)
|
||||
}
|
||||
|
||||
stray := reloadInbound(t, seeded.Id)
|
||||
stray.NodeID = &nodeB.Id // payload value without the provided flag
|
||||
if _, _, err := svc.UpdateInbound(&stray); err != nil {
|
||||
t.Fatalf("UpdateInbound stray nodeId: %v", err)
|
||||
}
|
||||
if after := reloadInbound(t, seeded.Id); after.NodeID == nil || *after.NodeID != nodeA.Id {
|
||||
t.Fatalf("unflagged nodeId moved the inbound: node_id=%v", after.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit nodeId equal to the stored one is a plain edit, not a move.
|
||||
func TestUpdateInbound_ExplicitSameNodeIsPlainEdit(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
nodeA := &model.Node{Name: "same-a", Enable: true, Status: "online", InboundsAdoptedAt: 1}
|
||||
if err := database.GetDB().Create(nodeA).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
seeded := seedMoveInbound(t, "movable", 2447, &nodeA.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
update := seeded
|
||||
update.NodeIDProvided = true
|
||||
update.Remark = "edited"
|
||||
if _, _, err := svc.UpdateInbound(&update); err != nil {
|
||||
t.Fatalf("UpdateInbound same-node edit: %v", err)
|
||||
}
|
||||
after := reloadInbound(t, seeded.Id)
|
||||
if after.NodeID == nil || *after.NodeID != nodeA.Id || after.Remark != "edited" {
|
||||
t.Fatalf("same-node edit misapplied: node=%v remark=%q", after.NodeID, after.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit move to a node that does not exist is rejected and the row is
|
||||
// left untouched.
|
||||
func TestUpdateInbound_MoveToMissingNodeRejected(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
nodeA := &model.Node{Name: "miss-a", Enable: true, Status: "online", InboundsAdoptedAt: 1}
|
||||
if err := database.GetDB().Create(nodeA).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
seeded := seedMoveInbound(t, "movable", 2448, &nodeA.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
update := seeded
|
||||
missing := 99999
|
||||
update.NodeID = &missing
|
||||
update.NodeIDProvided = true
|
||||
if _, _, err := svc.UpdateInbound(&update); err == nil {
|
||||
t.Fatal("move to a missing node must be rejected")
|
||||
}
|
||||
after := reloadInbound(t, seeded.Id)
|
||||
if after.NodeID == nil || *after.NodeID != nodeA.Id {
|
||||
t.Fatalf("rejected move changed the row: node_id=%v", after.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit null nodeId moves the inbound back to the local panel: the row
|
||||
// becomes local and the source node is marked dirty so its next reconcile
|
||||
// sweeps the inbound there.
|
||||
func TestUpdateInbound_MoveNodeToLocal(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
nodeA := &model.Node{Name: "loc-a", Enable: true, Status: "online", InboundsAdoptedAt: 1}
|
||||
if err := database.GetDB().Create(nodeA).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
seeded := seedMoveInbound(t, "movable", 2449, &nodeA.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
update := seeded
|
||||
update.NodeID = nil
|
||||
update.NodeIDProvided = true
|
||||
if _, _, err := svc.UpdateInbound(&update); err != nil {
|
||||
t.Fatalf("UpdateInbound node->local: %v", err)
|
||||
}
|
||||
after := reloadInbound(t, seeded.Id)
|
||||
if after.NodeID != nil {
|
||||
t.Fatalf("node_id after move to local = %v, want nil", *after.NodeID)
|
||||
}
|
||||
if !nodeDirty(t, nodeA.Id) {
|
||||
t.Fatal("source node must be dirty after a move to local (sweep pending)")
|
||||
}
|
||||
if up, down := clientTrafficTotals(t, "mv@example.com"); up != 111 || down != 222 {
|
||||
t.Fatalf("client traffic changed across move to local: up=%d down=%d", up, down)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user