mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 08:10:58 +00:00
b4e4478699
* 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>
432 lines
13 KiB
Go
432 lines
13 KiB
Go
package runtime
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// TestRemoteDo_RejectsOversizeResponse: a node streaming a body larger than
|
|
// maxRemoteResponseBytes must error out instead of the master buffering it
|
|
// unbounded.
|
|
func TestRemoteDo_RejectsOversizeResponse(t *testing.T) {
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
chunk := bytes.Repeat([]byte("a"), 1<<20) // 1 MiB
|
|
for written := 0; written <= maxRemoteResponseBytes; written += len(chunk) {
|
|
if _, err := w.Write(chunk); err != nil {
|
|
return // client stopped reading at the cap
|
|
}
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
|
if _, err := r.do(context.Background(), http.MethodGet, "/probe", nil); !errors.Is(err, errRemoteResponseTooLarge) {
|
|
t.Fatalf("do() error = %v, want errRemoteResponseTooLarge", err)
|
|
}
|
|
}
|
|
|
|
// TestRemoteDo_AcceptsNormalResponse confirms the cap does not break a normal
|
|
// under-limit envelope.
|
|
func TestRemoteDo_AcceptsNormalResponse(t *testing.T) {
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"msg":"ok","obj":{"x":1}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
|
env, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
|
|
if err != nil {
|
|
t.Fatalf("do() unexpected error: %v", err)
|
|
}
|
|
if env == nil || !env.Success {
|
|
t.Fatalf("env = %+v, want Success=true", env)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const limit = 8
|
|
cases := []struct {
|
|
name string
|
|
n int
|
|
wantErr bool
|
|
}{
|
|
{"under", limit - 1, false},
|
|
{"exact", limit, false},
|
|
{"over-by-one", limit + 1, true},
|
|
{"way-over", limit * 4, true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
raw, err := readCappedBody(bytes.NewReader(bytes.Repeat([]byte("x"), c.n)), limit)
|
|
if c.wantErr {
|
|
if !errors.Is(err, errRemoteResponseTooLarge) {
|
|
t.Fatalf("n=%d: err=%v, want errRemoteResponseTooLarge", c.n, err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("n=%d: unexpected err %v", c.n, err)
|
|
}
|
|
if len(raw) != c.n {
|
|
t.Fatalf("n=%d: read %d bytes, want %d", c.n, len(raw), c.n)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRemoteDo_NonOKStatusReturnsHTTPError confirms a non-OK status is reported
|
|
// as an HTTP error (with a bounded diagnostic snippet) rather than being read as
|
|
// a success payload — i.e. status precedence over the body.
|
|
func TestRemoteDo_NonOKStatusReturnsHTTPError(t *testing.T) {
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("boom"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
|
_, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
|
|
if err == nil {
|
|
t.Fatal("do() error = nil, want HTTP 500 error")
|
|
}
|
|
if !strings.Contains(err.Error(), "HTTP 500") || !strings.Contains(err.Error(), "boom") {
|
|
t.Fatalf("error = %q, want it to mention HTTP 500 and the body snippet", err)
|
|
}
|
|
}
|
|
|
|
type stubEgress struct{ url string }
|
|
|
|
func (s stubEgress) NodeEgressProxyURL(int) string { return s.url }
|
|
|
|
// cacheGetTag must resolve a remote inbound id even when the n<id>- prefix
|
|
// sits on only one side: the node may store the bare tag while the central
|
|
// panel pushes the prefixed form, or vice versa. Without this a mismatch makes
|
|
// the push create a duplicate inbound on the node.
|
|
func TestCacheGetTag_PrefixAgnostic(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cacheTag string
|
|
lookup string
|
|
wantID int
|
|
wantFound bool
|
|
}{
|
|
{"exact", "n1-in-443-tcp", "n1-in-443-tcp", 7, true},
|
|
{"node bare, lookup prefixed", "in-443-tcp", "n1-in-443-tcp", 7, true},
|
|
{"node prefixed, lookup bare", "n1-in-443-tcp", "in-443-tcp", 7, true},
|
|
{"unrelated tag", "in-443-tcp", "in-999-tcp", 0, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
r := NewRemote(&model.Node{Id: 1, Name: "n1"}, nil)
|
|
r.cacheSet(c.cacheTag, 7)
|
|
id, ok := r.cacheGetTag(c.lookup)
|
|
if ok != c.wantFound || id != c.wantID {
|
|
t.Fatalf("cacheGetTag(%q) = (%d, %v), want (%d, %v)", c.lookup, id, ok, c.wantID, c.wantFound)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWireInboundIncludesShareAddressFields(t *testing.T) {
|
|
values := wireInbound(&model.Inbound{
|
|
ShareAddrStrategy: "custom",
|
|
ShareAddr: "edge.example.com",
|
|
}, 0)
|
|
|
|
if got := values.Get("shareAddrStrategy"); got != "custom" {
|
|
t.Fatalf("shareAddrStrategy = %q, want custom", got)
|
|
}
|
|
if got := values.Get("shareAddr"); got != "edge.example.com" {
|
|
t.Fatalf("shareAddr = %q, want edge.example.com", got)
|
|
}
|
|
}
|
|
|
|
func TestRemoteHTTPClientEgressProxy(t *testing.T) {
|
|
// OutboundTag + a resolver → a dedicated proxy client (not the shared default).
|
|
withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"})
|
|
c, err := withTag.httpClient()
|
|
if err != nil {
|
|
t.Fatalf("httpClient: %v", err)
|
|
}
|
|
if c == defaultNodeHTTPClient {
|
|
t.Fatal("OutboundTag + resolver must produce a dedicated egress client, not the shared default")
|
|
}
|
|
// No OutboundTag → no egress proxy → shared default client (verify mode).
|
|
noTag := NewRemote(&model.Node{Id: 2, Scheme: "https", TlsVerifyMode: "verify"}, stubEgress{url: "socks5://127.0.0.1:1080"})
|
|
c2, err := noTag.httpClient()
|
|
if err != nil {
|
|
t.Fatalf("httpClient: %v", err)
|
|
}
|
|
if c2 != defaultNodeHTTPClient {
|
|
t.Fatal("no OutboundTag must use the shared default client")
|
|
}
|
|
}
|
|
|
|
func TestRemoteDoSetsContentType(t *testing.T) {
|
|
var gotCT string
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotCT = r.Header.Get("Content-Type")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
|
if _, err := r.do(context.Background(), http.MethodPost, "x", url.Values{"a": {"b"}}); err != nil {
|
|
t.Fatalf("do: %v", err)
|
|
}
|
|
if gotCT != "application/x-www-form-urlencoded" {
|
|
t.Fatalf("Content-Type = %q, want application/x-www-form-urlencoded", gotCT)
|
|
}
|
|
}
|
|
|
|
func TestRemoteBaseURL(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
scheme string
|
|
port int
|
|
bp string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{"https default path", "https", 443, "", "https://example.com:443/", false},
|
|
{"http custom path gets trailing slash", "http", 8080, "/panel", "http://example.com:8080/panel/", false},
|
|
{"empty scheme defaults to https", "", 2096, "/", "https://example.com:2096/", false},
|
|
{"invalid scheme defaults to https", "ftp", 2096, "/", "https://example.com:2096/", false},
|
|
{"port zero rejected", "https", 0, "/", "", true},
|
|
{"port above range rejected", "https", 65536, "/", "", true},
|
|
{"negative port rejected", "https", -1, "/", "", true},
|
|
{"max port accepted", "https", 65535, "/", "https://example.com:65535/", false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
r := NewRemote(&model.Node{Address: "example.com", Scheme: c.scheme, Port: c.port, BasePath: c.bp}, nil)
|
|
got, err := r.baseURL()
|
|
if c.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error for scheme=%q port=%d", c.scheme, c.port)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != c.want {
|
|
t.Fatalf("baseURL = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsNonEmptySlice(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in any
|
|
want bool
|
|
}{
|
|
{"non-empty slice", []any{1}, true},
|
|
{"empty slice", []any{}, false},
|
|
{"nil slice", []any(nil), false},
|
|
{"not a slice", "x", false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := isNonEmptySlice(c.in); got != c.want {
|
|
t.Fatalf("isNonEmptySlice(%#v) = %v, want %v", c.in, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWireInboundTrafficReset(t *testing.T) {
|
|
with := wireInbound(&model.Inbound{TrafficReset: "monthly", TrafficResetDay: 15}, 0)
|
|
if got := with.Get("trafficReset"); got != "monthly" {
|
|
t.Fatalf("trafficReset = %q, want monthly", got)
|
|
}
|
|
if got := with.Get("trafficResetDay"); got != "15" {
|
|
t.Fatalf("trafficResetDay = %q, want 15", got)
|
|
}
|
|
// Empty TrafficReset must be omitted entirely, not sent as an empty field.
|
|
without := wireInbound(&model.Inbound{}, 0)
|
|
if without.Has("trafficReset") {
|
|
t.Fatalf("trafficReset must be omitted when empty, got %q", without.Get("trafficReset"))
|
|
}
|
|
}
|
|
|
|
func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) {
|
|
values := wireInbound(&model.Inbound{}, 0)
|
|
|
|
if got := values.Get("shareAddrStrategy"); got != "node" {
|
|
t.Fatalf("shareAddrStrategy = %q, want node", got)
|
|
}
|
|
|
|
values = wireInbound(&model.Inbound{ShareAddrStrategy: "auto"}, 0)
|
|
if got := values.Get("shareAddrStrategy"); got != "node" {
|
|
t.Fatalf("invalid shareAddrStrategy = %q, want node", got)
|
|
}
|
|
}
|
|
|
|
func TestStripNodeInboundTagPrefix(t *testing.T) {
|
|
cases := []struct {
|
|
nodeID int
|
|
tag string
|
|
want string
|
|
}{
|
|
{2, "n2-in-443-tcp", "in-443-tcp"},
|
|
{2, "in-443-tcp", "in-443-tcp"},
|
|
{2, "my-custom", "my-custom"},
|
|
{2, "n3-in-443-tcp", "n3-in-443-tcp"},
|
|
{0, "n2-in-443-tcp", "n2-in-443-tcp"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := stripNodeInboundTagPrefix(c.nodeID, c.tag); got != c.want {
|
|
t.Fatalf("stripNodeInboundTagPrefix(%d, %q) = %q, want %q", c.nodeID, c.tag, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWireInboundStripsNodeTagOnPush(t *testing.T) {
|
|
values := wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 2)
|
|
if got := values.Get("tag"); got != "in-443-tcp" {
|
|
t.Fatalf("tag = %q, want in-443-tcp", got)
|
|
}
|
|
values = wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 0)
|
|
if got := values.Get("tag"); got != "n2-in-443-tcp" {
|
|
t.Fatalf("nodeID 0 must not strip, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeStreamSettingsForRemote(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
// wantCertFile / wantKeyFile: expected presence after sanitize
|
|
wantCertFile bool
|
|
wantKeyFile bool
|
|
}{
|
|
{
|
|
name: "file paths only — kept intact (remote node paths)",
|
|
input: `{
|
|
"tlsSettings": {
|
|
"certificates": [{
|
|
"certificateFile": "/etc/ssl/cert.crt",
|
|
"keyFile": "/etc/ssl/key.key"
|
|
}]
|
|
}
|
|
}`,
|
|
wantCertFile: true,
|
|
wantKeyFile: true,
|
|
},
|
|
{
|
|
name: "inline content only — unchanged",
|
|
input: `{
|
|
"tlsSettings": {
|
|
"certificates": [{
|
|
"certificate": ["-----BEGIN CERTIFICATE-----"],
|
|
"key": ["-----BEGIN PRIVATE KEY-----"]
|
|
}]
|
|
}
|
|
}`,
|
|
wantCertFile: false,
|
|
wantKeyFile: false,
|
|
},
|
|
{
|
|
name: "both file paths and inline content — file paths stripped (redundant)",
|
|
input: `{
|
|
"tlsSettings": {
|
|
"certificates": [{
|
|
"certificateFile": "/etc/ssl/cert.crt",
|
|
"keyFile": "/etc/ssl/key.key",
|
|
"certificate": ["-----BEGIN CERTIFICATE-----"],
|
|
"key": ["-----BEGIN PRIVATE KEY-----"]
|
|
}]
|
|
}
|
|
}`,
|
|
wantCertFile: false,
|
|
wantKeyFile: false,
|
|
},
|
|
{
|
|
name: "empty stream settings",
|
|
input: "",
|
|
// empty input returns empty, nothing to check
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.input == "" {
|
|
if got := sanitizeStreamSettingsForRemote(tc.input); got != "" {
|
|
t.Errorf("expected empty string, got %q", got)
|
|
}
|
|
return
|
|
}
|
|
got := sanitizeStreamSettingsForRemote(tc.input)
|
|
var out map[string]any
|
|
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, got)
|
|
}
|
|
|
|
tls, _ := out["tlsSettings"].(map[string]any)
|
|
certs, _ := tls["certificates"].([]any)
|
|
if len(certs) == 0 {
|
|
t.Fatal("certificates array missing in output")
|
|
}
|
|
cert, _ := certs[0].(map[string]any)
|
|
|
|
_, hasCertFile := cert["certificateFile"]
|
|
_, hasKeyFile := cert["keyFile"]
|
|
|
|
if hasCertFile != tc.wantCertFile {
|
|
t.Errorf("certificateFile present=%v, want %v", hasCertFile, tc.wantCertFile)
|
|
}
|
|
if hasKeyFile != tc.wantKeyFile {
|
|
t.Errorf("keyFile present=%v, want %v", hasKeyFile, tc.wantKeyFile)
|
|
}
|
|
})
|
|
}
|
|
}
|