feat(inbounds): add a narrow endpoint for subscription sort order (#6179)

* feat(inbounds): add a narrow endpoint for subscription sort order

Changing an inbound's position in subscription output currently goes through
/update/:id, which takes a whole inbound: the caller has to send settings and
the entire client list back, and whatever it read before the edit is what gets
written. Two people reordering and editing clients in the same inbound race on
one blob, and the reorder wins by overwriting.

Mirror the existing /setEnable/:id shape. The handler takes only the index and
the service reads the stored inbound, so nothing in the request can reach the
settings JSON. Node-owned inbounds are marked dirty in the same transaction and
pushed through the existing runtime update.

* fix(nodes): scope sub sort index updates

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
n0ctal
2026-08-16 00:48:24 +05:00
committed by GitHub
parent bab39393f1
commit b4e4478699
10 changed files with 362 additions and 125 deletions
+25 -5
View File
@@ -73,6 +73,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/bulkDel", a.bulkDelInbounds)
g.POST("/update/:id", a.updateInbound)
g.POST("/setEnable/:id", a.setInboundEnable)
g.POST("/:id/subSortIndex", a.setInboundSubSortIndex)
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
g.POST("/:id/delAllClients", a.delAllInboundClients)
g.POST("/resetAllTraffics", a.resetAllTraffics)
@@ -255,11 +256,30 @@ func (a *InboundController) updateInbound(c *gin.Context) {
notifyClientsChanged()
}
// setInboundEnable flips only the enable flag of an inbound. This is a
// dedicated endpoint because the regular update path serialises the entire
// settings JSON (every client) — far too heavy for an interactive switch
// on inbounds with thousands of clients. Frontend optimistically updates
// the UI; we just persist + sync xray + nudge other open admin sessions.
// setInboundSubSortIndex changes only subscription ordering without sending
// the inbound's settings/client payload.
func (a *InboundController) setInboundSubSortIndex(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
type form struct {
SubSortIndex int `json:"subSortIndex" form:"subSortIndex" binding:"required,min=1"`
}
var f form
if err := c.ShouldBind(&f); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.inboundService.SetInboundSubSortIndex(id, f.SubSortIndex); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
}
func (a *InboundController) setInboundEnable(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
+10
View File
@@ -458,6 +458,16 @@ func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
return nil
}
func (r *Remote) SetInboundSubSortIndex(ctx context.Context, ib *model.Inbound, index int) error {
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
return err
}
payload := url.Values{"subSortIndex": []string{strconv.Itoa(index)}}
_, err = r.do(ctx, http.MethodPost, "panel/api/inbounds/"+strconv.Itoa(id)+"/subSortIndex", payload)
return err
}
// ReconcileInbound pushes ib only when its wire payload differs from the last
// successful push, or when the node no longer reports the tag (existsOnNode
// false) — a node that dropped/restarted must still be re-seeded. Returns
+31
View File
@@ -54,6 +54,37 @@ func TestRemoteDo_AcceptsNormalResponse(t *testing.T) {
}
}
func TestRemoteSetInboundSubSortIndexSendsOnlyNarrowField(t *testing.T) {
var posted url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch req.URL.Path {
case "/panel/api/inbounds/list":
_, _ = w.Write([]byte(`{"success":true,"obj":[{"id":42,"tag":"remote-tag"}]}`))
case "/panel/api/inbounds/42/subSortIndex":
if err := req.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
posted = req.PostForm
_, _ = w.Write([]byte(`{"success":true}`))
default:
http.NotFound(w, req)
}
}))
defer srv.Close()
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
ib := &model.Inbound{Tag: "remote-tag", Settings: `{"clients":[{"email":"newer"}]}`}
if err := r.SetInboundSubSortIndex(context.Background(), ib, 7); err != nil {
t.Fatalf("SetInboundSubSortIndex: %v", err)
}
if got := posted.Get("subSortIndex"); got != "7" {
t.Fatalf("subSortIndex = %q, want 7", got)
}
if len(posted) != 1 {
t.Fatalf("posted fields = %v, want only subSortIndex", posted)
}
}
// TestReadCappedBody_Boundary pins the cap+1 contract cheaply (no large allocs):
// a body of exactly limit is accepted; limit+1 and beyond are rejected.
func TestReadCappedBody_Boundary(t *testing.T) {
+48
View File
@@ -1264,6 +1264,54 @@ func (s *InboundService) GetInboundDetail(id int) (*model.Inbound, error) {
return inbound, nil
}
// SetInboundSubSortIndex changes only the subscription sort order, so a
// reorder cannot carry a stale settings/client payload over another edit.
func (s *InboundService) SetInboundSubSortIndex(id int, index int) error {
index = normalizeSubSortIndex(index)
inbound, err := s.GetInbound(id)
if err != nil {
return err
}
if inbound.SubSortIndex == index {
return nil
}
db := database.GetDB()
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(model.Inbound{}).Where("id = ?", id).
Update("sub_sort_index", index).Error; err != nil {
return err
}
if inbound.NodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
}
return nil
}); err != nil {
return err
}
inbound.SubSortIndex = index
if inbound.NodeID == nil {
return nil
}
rt, push, _, perr := s.nodePushPlan(inbound)
if perr != nil {
return perr
}
if push {
narrow, ok := rt.(interface {
SetInboundSubSortIndex(context.Context, *model.Inbound, int) error
})
if !ok {
return fmt.Errorf("runtime %s does not support narrow subscription ordering updates", rt.Name())
}
if err := narrow.SetInboundSubSortIndex(context.Background(), inbound, index); err != nil {
logger.Warning("SetInboundSubSortIndex: remote metadata update on", rt.Name(), "failed:", err)
}
}
return nil
}
func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
inbound, err := s.GetInbound(id)
if err != nil {
@@ -0,0 +1,57 @@
package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestSetInboundSubSortIndexLeavesSettingsUntouched(t *testing.T) {
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
const settings = `{"clients":[{"email":"a@example.test","id":"11111111-1111-1111-1111-111111111111"}]}`
ib := &model.Inbound{UserId: 1, Remark: "r", Port: 21001, Protocol: model.VLESS, Settings: settings, SubSortIndex: 1, Enable: true}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("seed: %v", err)
}
svc := InboundService{}
if err := svc.SetInboundSubSortIndex(ib.Id, 7); err != nil {
t.Fatalf("set: %v", err)
}
var got model.Inbound
if err := database.GetDB().First(&got, ib.Id).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if got.SubSortIndex != 7 {
t.Fatalf("subSortIndex = %d, want 7", got.SubSortIndex)
}
if got.Settings != settings {
t.Fatalf("settings were rewritten:\n got %s\nwant %s", got.Settings, settings)
}
}
func TestSetInboundSubSortIndexUsesNarrowNodeUpdate(t *testing.T) {
setupBulkDB(t)
nodeID, fake := setupNodeRuntime(t)
ib := nodeInbound(t, nodeID, 21002, []model.Client{{Email: "a@example.test", ID: "11111111-1111-1111-1111-111111111111"}})
ib.SubSortIndex = 1
if err := database.GetDB().Model(ib).Update("sub_sort_index", 1).Error; err != nil {
t.Fatal(err)
}
if err := (&InboundService{}).SetInboundSubSortIndex(ib.Id, 7); err != nil {
t.Fatalf("set: %v", err)
}
if got := fake.updateSubSort.Load(); got != 1 {
t.Fatalf("narrow node updates = %d, want 1", got)
}
if got := fake.updateInbound.Load(); got != 0 {
t.Fatalf("full snapshot node updates = %d, want 0", got)
}
}
@@ -24,6 +24,7 @@ type fakeNodeRuntime struct {
deleteClient atomic.Int32
deleteUser atomic.Int32
updateInbound atomic.Int32
updateSubSort atomic.Int32
updateUser atomic.Int32
}
@@ -44,6 +45,11 @@ func (f *fakeNodeRuntime) UpdateInbound(context.Context, *model.Inbound, *model.
return nil
}
func (f *fakeNodeRuntime) SetInboundSubSortIndex(context.Context, *model.Inbound, int) error {
f.updateSubSort.Add(1)
return nil
}
func (f *fakeNodeRuntime) AddUser(context.Context, *model.Inbound, map[string]any) error { return nil }
func (f *fakeNodeRuntime) RemoveUser(context.Context, *model.Inbound, string) error { return nil }