mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-07 18:57:14 +00:00
6f40a51d62
In "selected" sync mode the reconcile sweep built its set of managed tags verbatim from node.InboundTags. A panel-created node inbound is stored with an n<id>- prefix (composeInboundTag) and pushed to the node with that prefix stripped (wireInbound), so the tag the node reports never matched the set and the sweep skipped it. The effect is the case the sweep exists for: an operator deletes a node inbound while the node is offline, and the node keeps serving it — and its clients — indefinitely. Only unprefixed tags were unaffected, which is why the existing selected-mode test did not catch it. nodeSelectedTagSet already builds both tag forms for exactly this reason and is used by the snapshot filter; the sweep now uses it too, so the two agree.
434 lines
15 KiB
Go
434 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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,
|
|
InboundsAdoptedAt: 1,
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// A node whose pre-existing inbounds were never adopted into the central DB
|
|
// has zero local rows for legitimate reasons: reconcile before that first
|
|
// adoption must not sweep — it would delete every real inbound on the node
|
|
// right after onboarding (add node, save it again, watch it get wiped).
|
|
func TestReconcileNode_SkipsSweepBeforeFirstAdoption(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
ts, deletedIDs := fakeNodePanel(t, map[string]int{
|
|
"real-a": 1,
|
|
"real-b": 2,
|
|
"real-c": 3,
|
|
})
|
|
node := reconcileTestNode(t, ts, "fresh-node", "all", nil)
|
|
node.InboundsAdoptedAt = 0
|
|
|
|
svc := InboundService{}
|
|
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
|
|
t.Fatalf("ReconcileNode: %v", err)
|
|
}
|
|
|
|
if got := deletedIDs(); len(got) != 0 {
|
|
t.Fatalf("deleted remote ids = %v, want none before first adoption", got)
|
|
}
|
|
}
|
|
|
|
// One inbound the node rejects (e.g. a legacy protocol failing the node's
|
|
// request validation, #5685) must not abort the reconcile: the healthy inbound
|
|
// is still pushed, the delete sweep still runs, and the returned error names
|
|
// the failed tag so the caller keeps the dirty flag set for retry.
|
|
func TestReconcileNode_ContinuesPastFailedInbound(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
var mu sync.Mutex
|
|
updated := map[int]int{}
|
|
var deleted []int
|
|
tagToID := map[string]int{"legacy": 1, "healthy": 2, "gone": 3}
|
|
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, r *http.Request) {
|
|
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/update/"))
|
|
if err != nil {
|
|
http.Error(w, "bad id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if id == tagToID["legacy"] {
|
|
http.Error(w, "request body failed validation", http.StatusBadRequest)
|
|
return
|
|
}
|
|
mu.Lock()
|
|
updated[id]++
|
|
mu.Unlock()
|
|
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)
|
|
|
|
node := reconcileTestNode(t, ts, "half-broken-node", "all", nil)
|
|
seedInboundConflictNode(t, "legacy", "", 1080, model.Protocol("socks"), ``, `{"auth":"noauth"}`, &node.Id)
|
|
seedInboundConflictNode(t, "healthy", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
|
|
|
|
svc := InboundService{}
|
|
err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
|
|
if err == nil {
|
|
t.Fatal("ReconcileNode: want an error naming the rejected inbound, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), `reconcile inbound "legacy"`) {
|
|
t.Fatalf("ReconcileNode error = %q, want it to name inbound \"legacy\"", err)
|
|
}
|
|
|
|
mu.Lock()
|
|
healthyPushes := updated[tagToID["healthy"]]
|
|
gotDeleted := append([]int(nil), deleted...)
|
|
mu.Unlock()
|
|
if healthyPushes != 1 {
|
|
t.Fatalf("healthy inbound pushed %d times, want 1", healthyPushes)
|
|
}
|
|
sort.Ints(gotDeleted)
|
|
if len(gotDeleted) != 1 || gotDeleted[0] != tagToID["gone"] {
|
|
t.Fatalf("deleted remote ids = %v, want [%d] (sweep must still run past the failure)", gotDeleted, tagToID["gone"])
|
|
}
|
|
}
|
|
|
|
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()
|
|
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)
|
|
}
|
|
}
|
|
|
|
// A panel-created node inbound is stored as "n<id>-tag" and pushed to the node
|
|
// with the prefix stripped, so the sweep's selected set must match both forms.
|
|
func TestReconcileNode_SelectedModeSweepsPrefixedSelectedTag(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
ts, deletedIDs := fakeNodePanel(t, map[string]int{
|
|
"keep": 1,
|
|
"selected-gone": 2,
|
|
"unmanaged": 3,
|
|
})
|
|
node := reconcileTestNode(t, ts, "sel-prefix-node", "selected", nil)
|
|
prefix := fmt.Sprintf("n%d-", node.Id)
|
|
node.InboundTags = []string{prefix + "keep", prefix + "selected-gone"}
|
|
seedInboundConflictNode(t, prefix+"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] (prefixed selected tag must be swept, unmanaged 3 must survive)", got)
|
|
}
|
|
}
|