Files
3x-ui/internal/web/runtime/remote_test.go
T
MHSanaei fd3fa86982 fix(node): drop a swept inbound's bookkeeping from the node runtime
The node Remote keeps a fingerprint, a remote id and possibly an alias
per inbound tag. When the master swept an inbound the node stopped
reporting, those entries stayed, so a node rotating tags grew the maps
for as long as the master ran. The sweep now hands every removed tag to
Remote.ForgetInbound, which drops all of it in both tag forms, and a
master-driven delete uses it too. The sweep's call is wiring;
TestRemoteForgetInboundDropsEveryTrace covers what it drops.
2026-10-31 09:28:39 +00:00

557 lines
19 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)
}
}
// A node that does not mirror DisableFlow re-injects Vision into its own xray
// config and share links, undoing the opt-out on every multi-node deployment.
func TestWireInboundCarriesDisableFlow(t *testing.T) {
if got := wireInbound(&model.Inbound{DisableFlow: true}, 0).Get("disableFlow"); got != "true" {
t.Fatalf("disableFlow = %q, want true", got)
}
if got := wireInbound(&model.Inbound{}, 0).Get("disableFlow"); got != "false" {
t.Fatalf("disableFlow = %q, want false", got)
}
}
func TestWireInboundCarriesExcludeFromSub(t *testing.T) {
if got := wireInbound(&model.Inbound{ExcludeFromSub: true}, 0).Get("excludeFromSub"); got != "true" {
t.Fatalf("excludeFromSub = %q, want true", got)
}
if got := wireInbound(&model.Inbound{}, 0).Get("excludeFromSub"); got != "false" {
t.Fatalf("excludeFromSub = %q, want false", 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)
}
})
}
}
// refreshRemoteIDs rebuilds the cache from node-reported tags only, so an
// adopted alias must be re-applied or every later op on that inbound misses.
func TestRemoteAdoptedAliasSurvivesRefresh(t *testing.T) {
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,"obj":[{"id":5,"tag":"legacy-in"},{"id":6,"tag":"in-2"}]}`))
return
}
http.NotFound(w, req)
}))
defer srv.Close()
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
central := &model.Inbound{Tag: "central-in", Settings: `{"clients":[]}`}
r.AdoptInboundAlias(central, RemoteInboundOption{Id: 5, Tag: "legacy-in"})
// Resolving a different tag misses the cache and forces a full refresh.
if _, err := r.resolveRemoteID(context.Background(), "in-2"); err != nil {
t.Fatalf("resolveRemoteID(in-2): %v", err)
}
id, err := r.resolveRemoteID(context.Background(), central.Tag)
if err != nil {
t.Fatalf("resolveRemoteID(%s) after refresh: %v", central.Tag, err)
}
if id != 5 {
t.Fatalf("adopted alias resolved to %d, want 5", id)
}
}
// A stale alias must never outrank the node's own report: once the node lists
// an inbound under the central tag itself, that id is the authoritative one.
func TestRemoteAdoptedAliasYieldsToNodeReportedTag(t *testing.T) {
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,"obj":[{"id":5,"tag":"central-in"},{"id":7,"tag":"legacy-in"},{"id":9,"tag":"in-2"}]}`))
return
}
http.NotFound(w, req)
}))
defer srv.Close()
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
central := &model.Inbound{Tag: "central-in", Settings: `{"clients":[]}`}
r.AdoptInboundAlias(central, RemoteInboundOption{Id: 7, Tag: "legacy-in"})
if _, err := r.resolveRemoteID(context.Background(), "in-2"); err != nil {
t.Fatalf("resolveRemoteID(in-2): %v", err)
}
id, err := r.resolveRemoteID(context.Background(), central.Tag)
if err != nil {
t.Fatalf("resolveRemoteID(%s): %v", central.Tag, err)
}
if id != 5 {
t.Fatalf("central tag resolved to %d via a stale alias, want 5 (the id the node reports)", id)
}
}
// A node's success=false message becomes an error the master logs and stores as the
// node's lastError; it must not carry the whole response body there.
func TestRemoteDo_BoundsTheNodeErrorMessage(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"success":false,"msg":"` + strings.Repeat("m", 1<<20) + `"}`))
}))
defer srv.Close()
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
_, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
if err == nil || len(err.Error()) > errBodyDiagBytes+64 {
t.Fatalf("error = %d bytes, want at most the %d-byte diagnostic", len(err.Error()), errBodyDiagBytes)
}
}
// Each "{}" of a node-reported list decodes into a whole struct, so a body under the
// byte cap could still expand into gigabytes; lists are capped by entry count.
func TestRemoteNodeListsAreCappedByEntryCount(t *testing.T) {
descendants := "[" + strings.TrimSuffix(strings.Repeat("{},", maxNodeDescendants+1), ",") + "]"
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"success":true,"obj":` + descendants + `}`))
}))
defer srv.Close()
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
if got, err := r.GetDescendants(context.Background()); !errors.Is(err, errRemoteListTooLong) {
t.Fatalf("GetDescendants = %d entries, %v; want errRemoteListTooLong", len(got), err)
}
}
// A node rotating inbound tags must not grow the per-tag maps forever: an inbound the
// master swept leaves nothing behind in either tag form, its adopted alias included.
func TestRemoteForgetInboundDropsEveryTrace(t *testing.T) {
n := &model.Node{Id: 7, Name: "n"}
r := NewRemote(n, nil)
ib := &model.Inbound{Tag: "n7-in-443", Port: 443, Protocol: model.VLESS}
r.AdoptInboundAlias(ib, RemoteInboundOption{Id: 3, Tag: "in-443"})
r.RecordAdoptedInbound(&model.Inbound{Tag: "in-443", Port: 443, Protocol: model.VLESS})
r.ForgetInbound("in-443")
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.pushedFP)+len(r.remoteIDByTag)+len(r.adoptedAliases) != 0 {
t.Fatalf("left behind: pushedFP=%v ids=%v aliases=%v", r.pushedFP, r.remoteIDByTag, r.adoptedAliases)
}
}