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") + } +}