Files
3x-ui/internal/web/service/inbound_node_reconcile_test.go
T
Nikan Zeyaei 05ad7f417c feat(node): per node outbound routing (#5275)
* feat: add per-node outbound routing for panel-to-node connections

* feat(ui): add outbound tag selector to node form with i18n

* fix(xray): avoid potential overflow warning in node egress rule allocation

* chore: run "npm run gen"

* fix

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-14 23:10:52 +02:00

198 lines
5.9 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, nil), 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, nil), 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)
}
}