Files
3x-ui/internal/web/service/inbound_node_reconcile_test.go
T
animesha3 554d85c2f7 feat: allow selecting inbounds synchronized from nodes (#5178)
* feat: select node inbounds for synchronization

Allow node owners to import either all remote inbounds or an explicit tag-based selection. Add remote inbound discovery, persistence, snapshot filtering, API documentation, tests, and localized UI labels.

* fix

* fix: scope node reconcile and orphan sweep to selected inbound tags

In 'selected' sync mode unselected inbounds never enter the panel DB, so
ReconcileNode treated them as undesired and deleted them from the node the
first time it went config-dirty. Reconcile now only sweeps remote tags that
are part of the selection; everything else on the node is unmanaged.

Panel-created or renamed inbounds on a selected-mode node also vanished:
their tag was outside the selection, so the next traffic pull filtered them
out of the snapshot and the orphan sweep silently dropped the central row.
AddInbound/UpdateInbound now allow the tag on the node before committing.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-11 20:48:26 +02:00

198 lines
5.8 KiB
Go

package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"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"
)
// fakeNodePanel serves just enough of the node API for ReconcileNode: the
// inbound list plus update/del endpoints, recording which remote ids get
// deleted.
func fakeNodePanel(t *testing.T, tagToID map[string]int) (*httptest.Server, func() []int) {
t.Helper()
var mu sync.Mutex
var deleted []int
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"`
}
rows := make([]row, 0, len(tagToID))
for tag, id := range tagToID {
rows = append(rows, row{Id: id, Tag: tag})
}
writeOK(w, rows)
})
mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, _ *http.Request) {
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
}
mu.Lock()
deleted = append(deleted, id)
mu.Unlock()
writeOK(w, nil)
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts, func() []int {
mu.Lock()
defer mu.Unlock()
out := append([]int(nil), deleted...)
sort.Ints(out)
return out
}
}
func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tags []string) *model.Node {
t.Helper()
u, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("parse test server URL: %v", err)
}
port, err := strconv.Atoi(u.Port())
if err != nil {
t.Fatalf("parse test server port: %v", err)
}
n := &model.Node{
Name: name,
Scheme: "http",
Address: u.Hostname(),
Port: port,
BasePath: "/",
ApiToken: "tok",
Enable: true,
AllowPrivateAddress: true,
Status: "online",
InboundSyncMode: mode,
InboundTags: tags,
}
if err := database.GetDB().Create(n).Error; err != nil {
t.Fatalf("create node: %v", err)
}
return n
}
// In "selected" sync mode the panel never imports the unselected inbounds, so
// reconcile must not treat their absence from the local DB as a deletion: only
// a *selected* tag missing locally may be swept from the node.
func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T) {
setupConflictDB(t)
ts, deletedIDs := fakeNodePanel(t, map[string]int{
"keep": 1,
"selected-gone": 2,
"unmanaged": 3,
})
node := reconcileTestNode(t, ts, "sel-node", "selected", []string{"keep", "selected-gone"})
seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
svc := InboundService{}
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
t.Fatalf("ReconcileNode: %v", err)
}
got := deletedIDs()
if len(got) != 1 || got[0] != 2 {
t.Fatalf("deleted remote ids = %v, want [2] (unmanaged inbound 3 must survive)", got)
}
}
// "all" mode keeps the original anti-entropy contract: every remote inbound
// missing from the local DB is deleted on the node.
func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
setupConflictDB(t)
ts, deletedIDs := fakeNodePanel(t, map[string]int{
"keep": 1,
"gone-a": 2,
"gone-b": 3,
})
node := reconcileTestNode(t, ts, "all-node", "all", nil)
seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
svc := InboundService{}
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
t.Fatalf("ReconcileNode: %v", err)
}
got := deletedIDs()
if len(got) != 2 || got[0] != 2 || got[1] != 3 {
t.Fatalf("deleted remote ids = %v, want [2 3]", got)
}
}
func TestEnsureInboundTagAllowed(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
svc := NodeService{}
selected := &model.Node{
Name: "ensure-sel", Address: "127.0.0.1", Port: 2096, ApiToken: "tok",
InboundSyncMode: "selected", InboundTags: []string{"a"},
}
if err := db.Create(selected).Error; err != nil {
t.Fatalf("create node: %v", err)
}
if err := svc.EnsureInboundTagAllowed(selected.Id, "b"); err != nil {
t.Fatalf("EnsureInboundTagAllowed add: %v", err)
}
var got model.Node
if err := db.First(&got, selected.Id).Error; err != nil {
t.Fatalf("reload node: %v", err)
}
if len(got.InboundTags) != 2 || got.InboundTags[0] != "a" || got.InboundTags[1] != "b" {
t.Fatalf("InboundTags = %#v, want [a b]", got.InboundTags)
}
if err := svc.EnsureInboundTagAllowed(selected.Id, "a"); err != nil {
t.Fatalf("EnsureInboundTagAllowed existing: %v", err)
}
if err := db.First(&got, selected.Id).Error; err != nil {
t.Fatalf("reload node: %v", err)
}
if len(got.InboundTags) != 2 {
t.Fatalf("existing tag must not duplicate, got %#v", got.InboundTags)
}
all := &model.Node{
Name: "ensure-all", Address: "127.0.0.1", Port: 2097, ApiToken: "tok",
InboundSyncMode: "all",
}
if err := db.Create(all).Error; err != nil {
t.Fatalf("create node: %v", err)
}
if err := svc.EnsureInboundTagAllowed(all.Id, "x"); err != nil {
t.Fatalf("EnsureInboundTagAllowed all-mode: %v", err)
}
var gotAll model.Node
if err := db.First(&gotAll, all.Id).Error; err != nil {
t.Fatalf("reload node: %v", err)
}
if len(gotAll.InboundTags) != 0 {
t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags)
}
}