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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user