From 98db0710c9b7ad2d554852353762e384cd46b74a Mon Sep 17 00:00:00 2001 From: Chester Fishmans Date: Sat, 3 Oct 2026 03:59:11 +0500 Subject: [PATCH] fix(runtime): reset node inbound traffic by node-side id (#6717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): reset node inbound traffic by node-side id Remote.ResetInboundTraffic posted to the master's inbound id (ib.Id), while every other node-side inbound call resolves the id assigned by the node from the tag. The reset therefore hit an unrelated inbound or failed silently (warning only), so the panel reported success either way. Resolve the id through resolveRemoteID(ctx, ib.Tag) and fail before posting when the tag cannot be resolved. Fixes #6713 * fix(job): list the inbound on the periodic-reset fake nodes Remote.ResetInboundTraffic now resolves the node-side id from the tag via panel/api/inbounds/list before posting resetTraffic. The fake nodes in periodic_traffic_reset_nodes_test.go answered that list with no obj, so the tag never resolved, no reset reached a node, and TestPeriodicResetReachesInboundNodesConcurrently failed (green on main, red after merging the node-side-id fix). Each fake node now lists the inbound it hosts, so the test again measures concurrent resets against a node shaped like a real one. --------- Co-authored-by: Кот Co-authored-by: Sanaei --- .../job/periodic_traffic_reset_nodes_test.go | 14 ++++-- internal/web/runtime/remote.go | 6 ++- internal/web/runtime/remote_reset_test.go | 49 +++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/internal/web/job/periodic_traffic_reset_nodes_test.go b/internal/web/job/periodic_traffic_reset_nodes_test.go index 3a50b996c..18619fd1b 100644 --- a/internal/web/job/periodic_traffic_reset_nodes_test.go +++ b/internal/web/job/periodic_traffic_reset_nodes_test.go @@ -40,11 +40,17 @@ func (g *resetGate) waitAll(t *testing.T, want int32) { } } -// resetNode is a node whose every traffic reset hangs until the gate opens. -func resetNode(t *testing.T, gate *resetGate, name string) int { +// resetNode is a node hosting inboundTag whose every traffic reset hangs until the +// gate opens; it lists the inbound so the master can resolve its node-side id. +func resetNode(t *testing.T, gate *resetGate, name, inboundTag string) int { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body) + if strings.HasSuffix(r.URL.Path, "/panel/api/inbounds/list") { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"success":true,"obj":[{"id":1,"tag":%q}]}`, inboundTag) + return + } if strings.Contains(r.URL.Path, "resetTraffic") { gate.entered.Add(1) select { @@ -93,7 +99,7 @@ func TestPeriodicResetReachesClientNodesConcurrently(t *testing.T) { gate := newResetFleet(t) db := database.GetDB() for i := range 3 { - nodeID := resetNode(t, gate, fmt.Sprintf("client-node-%d", i)) + nodeID := resetNode(t, gate, fmt.Sprintf("client-node-%d", i), "reset-client-"+strconv.Itoa(i)) email := fmt.Sprintf("cycle-%d@node", i) client := model.Client{Email: email, ID: fmt.Sprintf("00000000-0000-4000-8000-00000000000%d", i), Enable: true, TrafficReset: "daily"} settings, _ := json.Marshal(map[string]any{"clients": []model.Client{client}}) @@ -121,7 +127,7 @@ func TestPeriodicResetReachesClientNodesConcurrently(t *testing.T) { func TestPeriodicResetReachesInboundNodesConcurrently(t *testing.T) { gate := newResetFleet(t) for i := range 3 { - nodeID := resetNode(t, gate, fmt.Sprintf("inbound-node-%d", i)) + nodeID := resetNode(t, gate, fmt.Sprintf("inbound-node-%d", i), "reset-inbound-"+strconv.Itoa(i)) ib := model.Inbound{ UserId: 1, Enable: true, Port: 47100 + i, Protocol: model.VLESS, NodeID: &nodeID, Tag: "reset-inbound-" + strconv.Itoa(i), TrafficReset: "daily", Settings: `{"clients":[]}`, diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index 99587f28b..4d93011c3 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -713,7 +713,11 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error { } func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error { - _, err := r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", ib.Id), nil) + id, err := r.resolveRemoteID(ctx, ib.Tag) + if err != nil { + return fmt.Errorf("remote ResetInboundTraffic: resolve tag %q: %w", ib.Tag, err) + } + _, err = r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", id), nil) return err } diff --git a/internal/web/runtime/remote_reset_test.go b/internal/web/runtime/remote_reset_test.go index 4eb196036..27159e3e6 100644 --- a/internal/web/runtime/remote_reset_test.go +++ b/internal/web/runtime/remote_reset_test.go @@ -7,6 +7,8 @@ import ( "net/http/httptest" "slices" "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) // The master replays a node's reset backlog through the node's bulk endpoint. @@ -31,3 +33,50 @@ func TestRemoteResetClientTrafficsPostsEmailsToBulkEndpoint(t *testing.T) { t.Fatalf("node got %s %v, want /panel/api/clients/bulkResetTraffic [a@x b@x]", path, body.Emails) } } + +// A central inbound id need not match the node's id, so the reset must target +// the node-side id resolved from the tag, never ib.Id. +func TestRemoteResetInboundTrafficUsesNodeInboundID(t *testing.T) { + var method, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + method, path = req.Method, req.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"msg":"ok"}`)) + })) + t.Cleanup(srv.Close) + + r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) + r.cacheSet("n1-in-443", 7) + ib := &model.Inbound{Id: 42, Tag: "n1-in-443"} + if err := r.ResetInboundTraffic(context.Background(), ib); err != nil { + t.Fatalf("ResetInboundTraffic: %v", err) + } + if method != http.MethodPost || path != "/panel/api/inbounds/7/resetTraffic" { + t.Fatalf("node got %s %s, want POST /panel/api/inbounds/7/resetTraffic", method, path) + } +} + +// An unresolvable tag must fail before posting, so a reset never lands on an +// unrelated node inbound that happens to share the central id. +func TestRemoteResetInboundTrafficUnknownTagErrors(t *testing.T) { + var resetPosted bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + if req.URL.Path == "/panel/api/inbounds/list" { + _, _ = w.Write([]byte(`{"success":true,"msg":"ok","obj":[]}`)) + return + } + resetPosted = true + _, _ = w.Write([]byte(`{"success":true,"msg":"ok"}`)) + })) + t.Cleanup(srv.Close) + + r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) + ib := &model.Inbound{Id: 42, Tag: "n1-in-443"} + if err := r.ResetInboundTraffic(context.Background(), ib); err == nil { + t.Fatal("ResetInboundTraffic error = nil, want unknown-tag error") + } + if resetPosted { + t.Fatal("reset request posted to node despite an unresolved tag") + } +}