fix(node): adopt a matching deployed inbound instead of recreating it (#6197)

* fix(node): adopt compatible origin inbounds without mutation

* fix(nodes): preserve ambiguous and adopted aliases

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
n0ctal
2026-08-14 23:01:35 +05:00
committed by GitHub
parent 3b19091547
commit bb29b6afec
7 changed files with 241 additions and 10 deletions
@@ -251,6 +251,107 @@ func TestReconcileNode_ContinuesPastFailedInbound(t *testing.T) {
}
}
func TestReconcileNode_AdoptsCompatibleOriginInboundWithoutRemoteMutation(t *testing.T) {
setupConflictDB(t)
var mu sync.Mutex
mutations := 0
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) {
writeOK(w, []map[string]any{{"id": 41, "tag": "already-deployed", "listen": "", "port": 8443, "protocol": "vless"}})
})
mux.HandleFunc("/panel/api/inbounds/", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
mutations++
mu.Unlock()
writeOK(w, nil)
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
node := reconcileTestNode(t, ts, "adopt-node", "all", nil)
node.Guid = "origin-guid"
if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil {
t.Fatalf("update node guid: %v", err)
}
seedInboundConflictNode(t, "desired-name", "", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
t.Fatalf("set origin guid: %v", err)
}
svc := InboundService{}
rt := runtime.NewRemote(node, nil)
if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
t.Fatalf("first ReconcileNode: %v", err)
}
if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
t.Fatalf("second ReconcileNode: %v", err)
}
mu.Lock()
got := mutations
mu.Unlock()
if got != 0 {
t.Fatalf("remote mutations = %d, want 0 while adopting compatible deployed inbound", got)
}
}
func TestReconcileNode_AmbiguousCompatibleInboundsAreNotSwept(t *testing.T) {
setupConflictDB(t)
ts, deletedIDs := fakeNodePanel(t, map[string]int{"alias-a": 51, "alias-b": 52})
node := reconcileTestNode(t, ts, "ambiguous-node", "all", nil)
node.Guid = "origin-guid"
if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil {
t.Fatalf("update node guid: %v", err)
}
seedInboundConflictNode(t, "desired-name", "", 0, model.Protocol(""), `{}`, `{"clients":[]}`, &node.Id)
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
t.Fatalf("set origin guid: %v", err)
}
err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
if err == nil || !strings.Contains(err.Error(), "ambiguous compatible remote inbounds") {
t.Fatalf("ReconcileNode error = %v, want ambiguity error", err)
}
if got := deletedIDs(); len(got) != 0 {
t.Fatalf("deleted ambiguous candidates = %v, want none", got)
}
}
func TestReconcileNode_IncompatiblePortOccupantRemainsLoud(t *testing.T) {
setupConflictDB(t)
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) {
writeOK(w, []map[string]any{{"id": 42, "tag": "port-owner", "listen": "", "port": 9443, "protocol": "trojan"}})
})
mux.HandleFunc("/panel/api/inbounds/add", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "msg": "port already occupied", "obj": nil})
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
node := reconcileTestNode(t, ts, "drift-node", "all", nil)
node.Guid = "origin-guid"
seedInboundConflictNode(t, "desired-name", "", 9443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
t.Fatalf("set origin guid: %v", err)
}
err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
if err == nil || !strings.Contains(err.Error(), "port already occupied") {
t.Fatalf("ReconcileNode error = %v, want loud incompatible-port error", err)
}
}
func TestEnsureInboundTagAllowed(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()