mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 08:10:58 +00:00
930a0ed59d
* feat(inbound): add DisableFlow to opt an inbound out of auto XTLS Vision Adds an inbound-level DisableFlow flag so operators can suppress automatic xtls-rprx-vision injection on a specific inbound even when its transport is flow-capable — e.g. a tunneled/CDN-fronted XHTTP+vlessenc inbound where Vision is not wanted, while keeping it on the same client's Reality inbounds. When set, the inbound reports tlsFlowCapable=false, the write path clamps each attached client's flow to empty (so flow_override stores ""), and share links/subscriptions never carry the flow for it. The flag is panel-only metadata and is never sent to xray. Closes part of #5689. * feat(inbound): DisableFlow toggle in the inbound form (frontend) Wire the DisableFlow field through the form schema + adapters and add a VLESS-gated switch in the inbound form, plus en-US strings. tsc --noEmit and eslint pass. * fix(inbound): honor DisableFlow in all emitters + on toggle; regen OpenAPI Addresses review on #5690: - Clash (clash_service.go) and JSON (json_service.go) subscription emitters now also skip the flow for a DisableFlow inbound — previously only the raw share-link path was gated, so those two still advertised it (blocking 1). - UpdateInbound now strips any flow already stored on a DisableFlow inbound's clients (settings.clients[].flow + client_inbounds.flow_override) so xray and the subscription agree; otherwise toggling DisableFlow on an existing Vision client left xray expecting a flow the client no longer sends. - Regenerated the OpenAPI + zod/types/examples artifacts for the new field and added an example tag (blocking 2; make gen-check is clean). - Added Clash + JSON DisableFlow suppression tests alongside the raw-link one. * fix(inbound): make DisableFlow durable, clamp on create, guard live config Addresses the review + completeness audit on #5690: - UpdateInbound now persists inbound.DisableFlow onto the saved row. It was only read to branch strip-vs-restore, so toggling the flag on an existing inbound never stuck and MigrationRestoreVisionFlow re-injected the flow — the exact #5689 path (editing a multi-inbound client's inbound) self-reverted. - DBInbound (frontend) declares + initializes disableFlow so ObjectUtil .cloneProps carries the API value through; the edit Switch previously always read false and re-saving silently reverted the opt-out. - AddInbound strips client flow (settings + parsed clients) when DisableFlow is set, so a created-disabled inbound never persists a flow xray would expect. - GetXrayConfig forces flow="" for DisableFlow inbounds (VLESS + Trojan) as defense-in-depth, keeping the live config and the subscription in agreement. - genTrojanLink share link honors DisableFlow too. - Drop the dead explicit flow_override clear in UpdateInbound (SyncInbound rebuilds it from the stripped settings). - Clear disableFlow in the inbound form when switching to a non-VLESS protocol. - Add disableFlow/disableFlowHelp to the remaining 12 locales. Tests: stripClientFlows unit cases; DB-backed AddInbound clamp; UpdateInbound persist+strip+resist-restore regression (fails without the persist fix); frontend DBInbound + adapter round-trip (fails without the model field). * style(inbound): drop // line comments per repo CLAUDE.md The DisableFlow work followed the surrounding code's commenting style; the repo CLAUDE.md forbids // line comments in committed Go/TS. Remove the comments I added (Go + frontend + tests) and regenerate OpenAPI/schemas, which drops the generated field descriptions sourced from the Go doc comments. No behavior change; full go test (service+sub, CGO) + frontend typecheck/vitest green; golangci-lint clean on the changed files. * fix(runtime): propagate disableFlow to nodes Preserve the inbound DisableFlow flag when syncing inbounds across nodes and when recreating central records from remote traffic snapshots. This keeps multi-node deployments from reintroducing VLESS Vision flow in node configs and share links, and updates the related tests to cover the wired field and VLESS JSON generation.
443 lines
14 KiB
Go
443 lines
14 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 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)
|
|
}
|
|
})
|
|
}
|
|
}
|