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:
Muse
2026-10-21 13:49:57 +00:00
parent 261a1f6787
commit 7284fa6cf0
7 changed files with 572 additions and 13 deletions
+95 -4
View File
@@ -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.