mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-05 12:24:20 +00:00
554d85c2f7
* 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>
198 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|