updateInbound detected an explicit nodeId by probing the raw request body as JSON only (requestHasJSONKey). The panel frontend, however, posts application/x-www-form-urlencoded by default (http-init.ts form-encodes every POST without a declared JSON Content-Type), so the probe missed on every real browser save and UpdateInbound silently kept the stored assignment — the edit dialog's 部署到 picker never moved anything, while JSON API callers (and the P8 E2E, which posted JSON) worked. requestHasKey is now content-type aware: JSON bodies are probed as before; urlencoded bodies are parsed with url.ParseQuery, where the browser's null encoding (a bare "nodeId=") also counts as provided — it binds as *int(0) and the existing 0->nil normalisation turns it into the move-to-local target. Regression tests post the exact browser wire format through the real handler stack: form move between nodes, form post without nodeId (assignment kept), form post with nodeId= (move to local, no binding error), plus the JSON move at HTTP level. The two form-move tests fail on the unfixed code and pass with the fix.
335 lines
12 KiB
Go
335 lines
12 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-contrib/sessions"
|
|
"github.com/gin-contrib/sessions/cookie"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/op/go-logging"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
// Regression tests for the P8.1 hotfix: browser-driven inbound
|
|
// re-homing (the "部署到" picker in the edit dialog) silently did nothing.
|
|
//
|
|
// Root cause: the panel frontend posts application/x-www-form-urlencoded
|
|
// by default (frontend/src/api/http-init.ts form-encodes every POST that
|
|
// does not declare a JSON Content-Type), but updateInbound detected an
|
|
// explicit nodeId with a JSON-only probe of the raw body. The probe
|
|
// reported false for every real browser post, so UpdateInbound kept the
|
|
// stored assignment. JSON API callers worked — including the P8 sandbox
|
|
// E2E — which is why these tests post the *form* wire format exactly as
|
|
// the browser builds it (the field set of formValuesToWirePayload in
|
|
// frontend/src/lib/xray/inbound-form-adapter.ts, encodeForm semantics:
|
|
// every key present, a null value emitted as a bare "key=").
|
|
|
|
func newInboundWireEngine(t *testing.T) *gin.Engine {
|
|
t.Helper()
|
|
xuilogger.InitLogger(logging.ERROR)
|
|
gin.SetMode(gin.TestMode)
|
|
dbDir := t.TempDir()
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = database.CloseDB() })
|
|
|
|
engine := gin.New()
|
|
store := cookie.NewStore([]byte("inbound-wire-test-secret"))
|
|
engine.Use(sessions.Sessions("3x-ui", store))
|
|
// Logs in as the first user so updateInbound's session lookup works,
|
|
// mirroring the production session path over a cookie round-trip.
|
|
engine.GET("/test-login", func(c *gin.Context) {
|
|
u, err := (&panel.UserService{}).GetFirstUser()
|
|
if err != nil {
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := session.SetLoginUser(c, u); err != nil {
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
c.Status(http.StatusOK)
|
|
})
|
|
NewInboundController(engine.Group("/panel/api/inbounds"))
|
|
return engine
|
|
}
|
|
|
|
func inboundWireLogin(t *testing.T, engine *gin.Engine) []*http.Cookie {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/test-login", nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("test login: status %d", w.Code)
|
|
}
|
|
cookies := w.Result().Cookies()
|
|
if len(cookies) == 0 {
|
|
t.Fatal("test login: no session cookie issued")
|
|
}
|
|
return cookies
|
|
}
|
|
|
|
func seedWireNode(t *testing.T, name string) *model.Node {
|
|
t.Helper()
|
|
n := &model.Node{
|
|
Name: name,
|
|
Scheme: "http",
|
|
Address: "127.0.0.1",
|
|
Port: 1, // unreachable on purpose: UpdateInbound never dials nodes
|
|
BasePath: "/",
|
|
ApiToken: "tok",
|
|
Enable: true,
|
|
AllowPrivateAddress: true,
|
|
Status: "online",
|
|
InboundSyncMode: "all",
|
|
InboundsAdoptedAt: 1,
|
|
}
|
|
if err := database.GetDB().Create(n).Error; err != nil {
|
|
t.Fatalf("seed node %s: %v", name, err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
const wireTestSettings = `{"clients":[{"id":"11111111-2222-3333-4444-555555555555","email":"wire@example.com","enable":true}]}`
|
|
|
|
func seedWireInbound(t *testing.T, tag string, port int, nodeID *int) model.Inbound {
|
|
t.Helper()
|
|
ib := &model.Inbound{
|
|
Tag: tag,
|
|
Enable: true,
|
|
Port: port,
|
|
Protocol: model.VLESS,
|
|
Settings: wireTestSettings,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
Sniffing: `{"enabled":false}`,
|
|
NodeID: nodeID,
|
|
ShareAddrStrategy: "node",
|
|
TrafficReset: "never",
|
|
}
|
|
if err := database.GetDB().Create(ib).Error; err != nil {
|
|
t.Fatalf("seed inbound: %v", err)
|
|
}
|
|
ct := &xray.ClientTraffic{InboundId: ib.Id, Enable: true, Email: "wire@example.com", Up: 111, Down: 222}
|
|
if err := database.GetDB().Create(ct).Error; err != nil {
|
|
t.Fatalf("seed client traffic: %v", err)
|
|
}
|
|
return reloadWireInbound(t, ib.Id)
|
|
}
|
|
|
|
func reloadWireInbound(t *testing.T, id int) model.Inbound {
|
|
t.Helper()
|
|
var ib model.Inbound
|
|
if err := database.GetDB().First(&ib, id).Error; err != nil {
|
|
t.Fatalf("reload inbound %d: %v", id, err)
|
|
}
|
|
return ib
|
|
}
|
|
|
|
func wireNodeDirty(t *testing.T, id int) bool {
|
|
t.Helper()
|
|
var n model.Node
|
|
if err := database.GetDB().First(&n, id).Error; err != nil {
|
|
t.Fatalf("reload node %d: %v", id, err)
|
|
}
|
|
return n.ConfigDirty
|
|
}
|
|
|
|
func wireTrafficIntact(t *testing.T) {
|
|
t.Helper()
|
|
var ct xray.ClientTraffic
|
|
if err := database.GetDB().Where("email = ?", "wire@example.com").First(&ct).Error; err != nil {
|
|
t.Fatalf("read client traffic: %v", err)
|
|
}
|
|
if ct.Up != 111 || ct.Down != 222 {
|
|
t.Fatalf("client traffic changed: up=%d down=%d, want 111/222", ct.Up, ct.Down)
|
|
}
|
|
}
|
|
|
|
// browserFormBody renders the update body exactly as the browser sends
|
|
// it: the formValuesToWirePayload field set, url-encoded, with nodeId
|
|
// present as a number, present-but-empty (the null/local encoding), or
|
|
// omitted entirely (legacy callers), per the arguments.
|
|
func browserFormBody(ib model.Inbound, nodeID *int, omitNodeID bool) string {
|
|
v := url.Values{}
|
|
v.Set("up", strconv.FormatInt(ib.Up, 10))
|
|
v.Set("down", strconv.FormatInt(ib.Down, 10))
|
|
v.Set("total", strconv.FormatInt(ib.Total, 10))
|
|
v.Set("remark", ib.Remark)
|
|
v.Set("enable", strconv.FormatBool(ib.Enable))
|
|
v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
|
|
v.Set("trafficReset", ib.TrafficReset)
|
|
v.Set("trafficResetDay", strconv.Itoa(ib.TrafficResetDay))
|
|
v.Set("lastTrafficResetTime", strconv.FormatInt(ib.LastTrafficResetTime, 10))
|
|
v.Set("listen", ib.Listen)
|
|
v.Set("port", strconv.Itoa(ib.Port))
|
|
v.Set("protocol", string(ib.Protocol))
|
|
v.Set("settings", ib.Settings)
|
|
v.Set("streamSettings", ib.StreamSettings)
|
|
v.Set("sniffing", ib.Sniffing)
|
|
v.Set("tag", ib.Tag)
|
|
v.Set("shareAddrStrategy", ib.ShareAddrStrategy)
|
|
v.Set("shareAddr", ib.ShareAddr)
|
|
v.Set("subSortIndex", strconv.Itoa(ib.SubSortIndex))
|
|
v.Set("disableFlow", strconv.FormatBool(ib.DisableFlow))
|
|
if !omitNodeID {
|
|
if nodeID == nil {
|
|
v.Set("nodeId", "") // encodeForm renders a null as a bare "nodeId="
|
|
} else {
|
|
v.Set("nodeId", strconv.Itoa(*nodeID))
|
|
}
|
|
}
|
|
return v.Encode()
|
|
}
|
|
|
|
type wireEnvelope struct {
|
|
Success bool `json:"success"`
|
|
Msg string `json:"msg"`
|
|
}
|
|
|
|
func postInboundUpdate(t *testing.T, engine *gin.Engine, cookies []*http.Cookie, id int, body, contentType string) wireEnvelope {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/panel/api/inbounds/update/%d", id), strings.NewReader(body))
|
|
req.Header.Set("Content-Type", contentType)
|
|
for _, ck := range cookies {
|
|
req.AddCookie(ck)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
engine.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("update %d: HTTP %d, body=%s", id, w.Code, w.Body.String())
|
|
}
|
|
var env wireEnvelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatalf("update %d: decode envelope: %v body=%s", id, err, w.Body.String())
|
|
}
|
|
return env
|
|
}
|
|
|
|
// (a) The browser's headline flow: a form post carrying nodeId=<target>
|
|
// must perform the move — row reassigned, both nodes marked dirty,
|
|
// clients and traffic untouched.
|
|
func TestUpdateInboundFormWire_MoveBetweenNodes(t *testing.T) {
|
|
engine := newInboundWireEngine(t)
|
|
cookies := inboundWireLogin(t, engine)
|
|
nodeA := seedWireNode(t, "wire-a")
|
|
nodeB := seedWireNode(t, "wire-b")
|
|
seeded := seedWireInbound(t, "wire-move", 2461, &nodeA.Id)
|
|
|
|
body := browserFormBody(seeded, &nodeB.Id, false)
|
|
env := postInboundUpdate(t, engine, cookies, seeded.Id, body, "application/x-www-form-urlencoded")
|
|
if !env.Success {
|
|
t.Fatalf("form move update failed: msg=%q", env.Msg)
|
|
}
|
|
|
|
after := reloadWireInbound(t, seeded.Id)
|
|
if after.NodeID == nil || *after.NodeID != nodeB.Id {
|
|
t.Fatalf("node_id after form move = %v, want %d", after.NodeID, nodeB.Id)
|
|
}
|
|
if !wireNodeDirty(t, nodeA.Id) || !wireNodeDirty(t, nodeB.Id) {
|
|
t.Fatalf("both nodes must be dirty after a move (A=%v B=%v)",
|
|
wireNodeDirty(t, nodeA.Id), wireNodeDirty(t, nodeB.Id))
|
|
}
|
|
wireTrafficIntact(t)
|
|
}
|
|
|
|
// (b) A form post without nodeId keeps the stored assignment (the legacy
|
|
// caller contract, now exercised over the form wire format too).
|
|
func TestUpdateInboundFormWire_OmittedNodeIDKeepsAssignment(t *testing.T) {
|
|
engine := newInboundWireEngine(t)
|
|
cookies := inboundWireLogin(t, engine)
|
|
nodeA := seedWireNode(t, "wire-a")
|
|
seedWireNode(t, "wire-b")
|
|
seeded := seedWireInbound(t, "wire-keep", 2462, &nodeA.Id)
|
|
|
|
body := browserFormBody(seeded, nil, true)
|
|
env := postInboundUpdate(t, engine, cookies, seeded.Id, body, "application/x-www-form-urlencoded")
|
|
if !env.Success {
|
|
t.Fatalf("form no-nodeId update failed: msg=%q", env.Msg)
|
|
}
|
|
|
|
after := reloadWireInbound(t, seeded.Id)
|
|
if after.NodeID == nil || *after.NodeID != nodeA.Id {
|
|
t.Fatalf("node_id after omitted-nodeId form update = %v, want %d (unchanged)", after.NodeID, nodeA.Id)
|
|
}
|
|
wireTrafficIntact(t)
|
|
}
|
|
|
|
// (c) The browser encodes "back to local panel" (allowClear on the
|
|
// picker) as a bare "nodeId=". That must count as provided with a nil
|
|
// target — and must not break binding of the *int field.
|
|
func TestUpdateInboundFormWire_EmptyNodeIDMovesToLocal(t *testing.T) {
|
|
engine := newInboundWireEngine(t)
|
|
cookies := inboundWireLogin(t, engine)
|
|
nodeA := seedWireNode(t, "wire-a")
|
|
seeded := seedWireInbound(t, "wire-local", 2463, &nodeA.Id)
|
|
|
|
body := browserFormBody(seeded, nil, false)
|
|
if !strings.Contains(body, "nodeId=") {
|
|
t.Fatalf("test body must carry the bare nodeId= encoding, got %q", body)
|
|
}
|
|
env := postInboundUpdate(t, engine, cookies, seeded.Id, body, "application/x-www-form-urlencoded")
|
|
if !env.Success {
|
|
t.Fatalf("form empty-nodeId update failed: msg=%q", env.Msg)
|
|
}
|
|
|
|
after := reloadWireInbound(t, seeded.Id)
|
|
if after.NodeID != nil {
|
|
t.Fatalf("node_id after empty-nodeId form update = %v, want nil (local)", after.NodeID)
|
|
}
|
|
if !wireNodeDirty(t, nodeA.Id) {
|
|
t.Fatal("source node must be dirty after a move to local")
|
|
}
|
|
wireTrafficIntact(t)
|
|
}
|
|
|
|
// (d) The JSON caller path keeps working unchanged at the HTTP level.
|
|
func TestUpdateInboundJSONWire_MoveBetweenNodes(t *testing.T) {
|
|
engine := newInboundWireEngine(t)
|
|
cookies := inboundWireLogin(t, engine)
|
|
nodeA := seedWireNode(t, "wire-a")
|
|
nodeB := seedWireNode(t, "wire-b")
|
|
seeded := seedWireInbound(t, "wire-json", 2464, &nodeA.Id)
|
|
|
|
payload := map[string]any{
|
|
"up": seeded.Up, "down": seeded.Down, "total": seeded.Total,
|
|
"remark": seeded.Remark, "enable": seeded.Enable,
|
|
"expiryTime": seeded.ExpiryTime, "trafficReset": seeded.TrafficReset,
|
|
"trafficResetDay": seeded.TrafficResetDay, "lastTrafficResetTime": seeded.LastTrafficResetTime,
|
|
"listen": seeded.Listen, "port": seeded.Port, "protocol": string(seeded.Protocol),
|
|
"settings": seeded.Settings, "streamSettings": seeded.StreamSettings,
|
|
"sniffing": seeded.Sniffing, "tag": seeded.Tag,
|
|
"shareAddrStrategy": seeded.ShareAddrStrategy, "shareAddr": seeded.ShareAddr,
|
|
"subSortIndex": seeded.SubSortIndex, "disableFlow": seeded.DisableFlow,
|
|
"nodeId": nodeB.Id,
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("marshal payload: %v", err)
|
|
}
|
|
env := postInboundUpdate(t, engine, cookies, seeded.Id, string(raw), "application/json")
|
|
if !env.Success {
|
|
t.Fatalf("JSON move update failed: msg=%q", env.Msg)
|
|
}
|
|
|
|
after := reloadWireInbound(t, seeded.Id)
|
|
if after.NodeID == nil || *after.NodeID != nodeB.Id {
|
|
t.Fatalf("node_id after JSON move = %v, want %d", after.NodeID, nodeB.Id)
|
|
}
|
|
wireTrafficIntact(t)
|
|
}
|