mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-06 21:04:20 +00:00
5716ae5987
Replace the per-outbound burstObservatory polling (one temp xray spawn + up to 15s of /debug/vars polling per outbound, serialised) with one shared temp xray instance per batch: every tested outbound gets its own loopback SOCKS inbound plus an inboundTag->outboundTag routing rule, and the panel times a real HTTP request through each one in parallel. The probe returns as soon as the response lands and records the HTTP status plus an httptrace breakdown (proxy connect / TLS via outbound / first byte) shown in the result popover. New POST /panel/api/xray/testOutbounds endpoint (array in, results in input order, max 50); the legacy /testOutbound endpoint now delegates to the same engine. Test All chunks HTTP probes 16 per request, and a batch whose shared process never comes up (one structurally-broken outbound poisons the config) retries each item in an isolated instance so the broken outbound reports xray's real error while the rest still test.
471 lines
13 KiB
Go
471 lines
13 KiB
Go
package outbound
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
// stubProcess implements batchProcess without an xray binary. When serveSocks
|
|
// is set, Start opens a minimal SOCKS5 server on every inbound port from the
|
|
// config, so probes run against a real tunnel.
|
|
type stubProcess struct {
|
|
cfg *xray.Config
|
|
startErr error
|
|
result string
|
|
serveSocks bool
|
|
|
|
running bool
|
|
listeners []net.Listener
|
|
}
|
|
|
|
func (p *stubProcess) Start() error {
|
|
if p.startErr != nil {
|
|
return p.startErr
|
|
}
|
|
for _, in := range p.cfg.InboundConfigs {
|
|
l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", in.Port))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.listeners = append(p.listeners, l)
|
|
if p.serveSocks {
|
|
go serveStubSocks(l)
|
|
}
|
|
}
|
|
p.running = true
|
|
return nil
|
|
}
|
|
|
|
func (p *stubProcess) Stop() error {
|
|
for _, l := range p.listeners {
|
|
l.Close()
|
|
}
|
|
p.running = false
|
|
return nil
|
|
}
|
|
|
|
func (p *stubProcess) IsRunning() bool { return p.running }
|
|
func (p *stubProcess) GetResult() string {
|
|
if p.result != "" {
|
|
return p.result
|
|
}
|
|
return "stub exited"
|
|
}
|
|
|
|
// serveStubSocks answers SOCKS5 no-auth CONNECTs and pipes to the requested
|
|
// target — just enough protocol for net/http's socks5 client.
|
|
func serveStubSocks(l net.Listener) {
|
|
for {
|
|
conn, err := l.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func(c net.Conn) {
|
|
defer c.Close()
|
|
hello := make([]byte, 2)
|
|
if _, err := io.ReadFull(c, hello); err != nil {
|
|
return
|
|
}
|
|
methods := make([]byte, hello[1])
|
|
if _, err := io.ReadFull(c, methods); err != nil {
|
|
return
|
|
}
|
|
c.Write([]byte{0x05, 0x00})
|
|
hdr := make([]byte, 4)
|
|
if _, err := io.ReadFull(c, hdr); err != nil {
|
|
return
|
|
}
|
|
var host string
|
|
switch hdr[3] {
|
|
case 0x01:
|
|
b := make([]byte, 4)
|
|
io.ReadFull(c, b)
|
|
host = net.IP(b).String()
|
|
case 0x03:
|
|
lb := make([]byte, 1)
|
|
io.ReadFull(c, lb)
|
|
b := make([]byte, lb[0])
|
|
io.ReadFull(c, b)
|
|
host = string(b)
|
|
case 0x04:
|
|
b := make([]byte, 16)
|
|
io.ReadFull(c, b)
|
|
host = net.IP(b).String()
|
|
default:
|
|
return
|
|
}
|
|
pb := make([]byte, 2)
|
|
if _, err := io.ReadFull(c, pb); err != nil {
|
|
return
|
|
}
|
|
port := int(pb[0])<<8 | int(pb[1])
|
|
upstream, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
|
if err != nil {
|
|
c.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
|
return
|
|
}
|
|
defer upstream.Close()
|
|
c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
|
go io.Copy(upstream, c)
|
|
io.Copy(c, upstream)
|
|
}(conn)
|
|
}
|
|
}
|
|
|
|
func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath string) batchProcess) {
|
|
t.Helper()
|
|
// createTestConfigPath writes into the bin folder, which doesn't exist
|
|
// when running tests from the package directory.
|
|
t.Setenv("XUI_BIN_FOLDER", t.TempDir())
|
|
orig := newBatchProcess
|
|
newBatchProcess = factory
|
|
t.Cleanup(func() { newBatchProcess = orig })
|
|
}
|
|
|
|
func mustJSON(t *testing.T, v any) string {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func TestBuildBatchTestConfig(t *testing.T) {
|
|
items := []*httpBatchItem{
|
|
{tag: "wg-sub", outbound: map[string]any{"tag": "wg-sub", "protocol": "wireguard"}},
|
|
{tag: "proxy-a", outbound: map[string]any{"tag": "proxy-a", "protocol": "vless"}},
|
|
}
|
|
allOutbounds := []any{
|
|
map[string]any{"tag": "direct", "protocol": "freedom", "settings": map[string]any{}},
|
|
map[string]any{"tag": "proxy-a", "protocol": "vless", "settings": map[string]any{"address": "a.example.com"}},
|
|
}
|
|
ports := []int{61001, 61002}
|
|
|
|
cfg := buildBatchTestConfig(items, allOutbounds, ports)
|
|
raw, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatalf("marshal config: %v", err)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatalf("unmarshal config: %v", err)
|
|
}
|
|
|
|
inbounds, _ := m["inbounds"].([]any)
|
|
if len(inbounds) != 2 {
|
|
t.Fatalf("expected 2 inbounds, got %d", len(inbounds))
|
|
}
|
|
for i, raw := range inbounds {
|
|
in := raw.(map[string]any)
|
|
if got := in["tag"]; got != fmt.Sprintf("test-in-%d", i) {
|
|
t.Errorf("inbound %d tag = %v", i, got)
|
|
}
|
|
if got := int(in["port"].(float64)); got != ports[i] {
|
|
t.Errorf("inbound %d port = %d, want %d", i, got, ports[i])
|
|
}
|
|
if got := in["protocol"]; got != "socks" {
|
|
t.Errorf("inbound %d protocol = %v", i, got)
|
|
}
|
|
if got := in["listen"]; got != "127.0.0.1" {
|
|
t.Errorf("inbound %d listen = %v", i, got)
|
|
}
|
|
settings := in["settings"].(map[string]any)
|
|
if settings["auth"] != "noauth" || settings["udp"] != false {
|
|
t.Errorf("inbound %d settings = %v", i, settings)
|
|
}
|
|
}
|
|
|
|
routing := m["routing"].(map[string]any)
|
|
rules, _ := routing["rules"].([]any)
|
|
if len(rules) != 2 {
|
|
t.Fatalf("expected 2 routing rules, got %d", len(rules))
|
|
}
|
|
wantTags := []string{"wg-sub", "proxy-a"}
|
|
for i, raw := range rules {
|
|
rule := raw.(map[string]any)
|
|
inTags := rule["inboundTag"].([]any)
|
|
if len(inTags) != 1 || inTags[0] != fmt.Sprintf("test-in-%d", i) {
|
|
t.Errorf("rule %d inboundTag = %v", i, inTags)
|
|
}
|
|
if rule["outboundTag"] != wantTags[i] {
|
|
t.Errorf("rule %d outboundTag = %v, want %s", i, rule["outboundTag"], wantTags[i])
|
|
}
|
|
}
|
|
|
|
outbounds, _ := m["outbounds"].([]any)
|
|
if len(outbounds) != 3 {
|
|
t.Fatalf("expected 3 outbounds (wg-sub appended once, proxy-a deduped), got %d", len(outbounds))
|
|
}
|
|
var wg map[string]any
|
|
for _, raw := range outbounds {
|
|
ob := raw.(map[string]any)
|
|
if ob["tag"] == "wg-sub" {
|
|
wg = ob
|
|
}
|
|
}
|
|
if wg == nil {
|
|
t.Fatal("wg-sub not appended to outbounds")
|
|
}
|
|
if settings, _ := wg["settings"].(map[string]any); settings == nil || settings["noKernelTun"] != true {
|
|
t.Errorf("wireguard settings missing noKernelTun: %v", wg["settings"])
|
|
}
|
|
|
|
if m["burstObservatory"] != nil {
|
|
t.Errorf("burstObservatory should not be set, got %v", m["burstObservatory"])
|
|
}
|
|
if m["metrics"] != nil {
|
|
t.Errorf("metrics should not be set, got %v", m["metrics"])
|
|
}
|
|
}
|
|
|
|
func TestTestOutboundsPrevalidationAndOrdering(t *testing.T) {
|
|
calls := 0
|
|
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
|
calls++
|
|
return &stubProcess{cfg: cfg, startErr: errors.New("boom")}
|
|
})
|
|
|
|
batch := mustJSON(t, []any{
|
|
map[string]any{"protocol": "vless"}, // no tag
|
|
map[string]any{"tag": "bh", "protocol": "blackhole"}, // blackhole
|
|
map[string]any{"tag": "loop", "protocol": "loopback"}, // loopback
|
|
map[string]any{"tag": "a", "protocol": "socks"}, // valid
|
|
map[string]any{"tag": "a", "protocol": "vless"}, // duplicate
|
|
})
|
|
results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
|
|
if err != nil {
|
|
t.Fatalf("TestOutbounds: %v", err)
|
|
}
|
|
if len(results) != 5 {
|
|
t.Fatalf("expected 5 results, got %d", len(results))
|
|
}
|
|
wantErrs := []string{
|
|
"Outbound has no tag",
|
|
"Blocked/blackhole outbound cannot be tested",
|
|
"Loopback outbound cannot be tested",
|
|
"Failed to start test xray instance: boom",
|
|
"Duplicate outbound tag in batch: a",
|
|
}
|
|
for i, want := range wantErrs {
|
|
if results[i].Success {
|
|
t.Errorf("result %d unexpectedly succeeded", i)
|
|
}
|
|
if results[i].Error != want {
|
|
t.Errorf("result %d error = %q, want %q", i, results[i].Error, want)
|
|
}
|
|
}
|
|
if results[3].Tag != "a" || results[4].Tag != "a" || results[1].Tag != "bh" {
|
|
t.Errorf("tags not propagated: %+v", results)
|
|
}
|
|
// Single valid item → no per-item fallback round.
|
|
if calls != 1 {
|
|
t.Errorf("process spawned %d times, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func TestTestOutboundsFallbackOnStartFailure(t *testing.T) {
|
|
calls := 0
|
|
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
|
calls++
|
|
return &stubProcess{cfg: cfg, startErr: errors.New("boom")}
|
|
})
|
|
|
|
batch := mustJSON(t, []any{
|
|
map[string]any{"tag": "a", "protocol": "socks"},
|
|
map[string]any{"tag": "b", "protocol": "vless"},
|
|
})
|
|
results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
|
|
if err != nil {
|
|
t.Fatalf("TestOutbounds: %v", err)
|
|
}
|
|
for i, r := range results {
|
|
if r.Success || r.Error != "Failed to start test xray instance: boom" {
|
|
t.Errorf("result %d = %+v, want start failure", i, r)
|
|
}
|
|
}
|
|
// 1 shared attempt + 2 isolated fallback attempts.
|
|
if calls != 3 {
|
|
t.Errorf("process spawned %d times, want 3", calls)
|
|
}
|
|
}
|
|
|
|
func TestTestOutboundsNoFallbackWhenBinaryMissing(t *testing.T) {
|
|
calls := 0
|
|
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
|
calls++
|
|
return &stubProcess{cfg: cfg, startErr: &fs.PathError{Op: "exec", Path: "xray", Err: fs.ErrNotExist}}
|
|
})
|
|
|
|
batch := mustJSON(t, []any{
|
|
map[string]any{"tag": "a", "protocol": "socks"},
|
|
map[string]any{"tag": "b", "protocol": "vless"},
|
|
})
|
|
results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
|
|
if err != nil {
|
|
t.Fatalf("TestOutbounds: %v", err)
|
|
}
|
|
for i, r := range results {
|
|
if r.Success || !strings.HasPrefix(r.Error, "Failed to start test xray instance:") {
|
|
t.Errorf("result %d = %+v, want start failure", i, r)
|
|
}
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("process spawned %d times, want 1 (no fallback for missing binary)", calls)
|
|
}
|
|
}
|
|
|
|
func TestTestOutboundsSemaphoreBusy(t *testing.T) {
|
|
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
|
t.Fatal("process must not be spawned while semaphore is held")
|
|
return nil
|
|
})
|
|
|
|
httpTestSemaphore.Lock()
|
|
defer httpTestSemaphore.Unlock()
|
|
|
|
batch := mustJSON(t, []any{map[string]any{"tag": "a", "protocol": "socks"}})
|
|
results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "http")
|
|
if err != nil {
|
|
t.Fatalf("TestOutbounds: %v", err)
|
|
}
|
|
if results[0].Success || results[0].Error != "Another outbound test is already running, please wait" {
|
|
t.Errorf("result = %+v, want busy error", results[0])
|
|
}
|
|
}
|
|
|
|
func TestTestOutboundsInputValidation(t *testing.T) {
|
|
s := &OutboundService{}
|
|
if _, err := s.TestOutbounds("not json", "", "", "tcp"); err == nil {
|
|
t.Error("expected error for invalid JSON")
|
|
}
|
|
|
|
big := make([]any, maxBatchItems+1)
|
|
for i := range big {
|
|
big[i] = map[string]any{"tag": fmt.Sprintf("t%d", i), "protocol": "socks"}
|
|
}
|
|
if _, err := s.TestOutbounds(mustJSON(t, big), "", "", "tcp"); err == nil {
|
|
t.Error("expected error for oversized batch")
|
|
}
|
|
|
|
results, err := s.TestOutbounds("[]", "", "", "tcp")
|
|
if err != nil || len(results) != 0 {
|
|
t.Errorf("empty batch: results=%v err=%v", results, err)
|
|
}
|
|
}
|
|
|
|
func TestTestOutboundsTCPLane(t *testing.T) {
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
defer l.Close()
|
|
go func() {
|
|
for {
|
|
conn, err := l.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
conn.Close()
|
|
}
|
|
}()
|
|
port := l.Addr().(*net.TCPAddr).Port
|
|
|
|
batch := mustJSON(t, []any{map[string]any{
|
|
"tag": "t1",
|
|
"protocol": "socks",
|
|
"settings": map[string]any{"servers": []any{map[string]any{"address": "127.0.0.1", "port": port}}},
|
|
}})
|
|
results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp")
|
|
if err != nil {
|
|
t.Fatalf("TestOutbounds: %v", err)
|
|
}
|
|
r := results[0]
|
|
if !r.Success || r.Mode != "tcp" || r.Tag != "t1" || len(r.Endpoints) != 1 {
|
|
t.Errorf("unexpected tcp result: %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
var proc *stubProcess
|
|
calls := 0
|
|
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
|
calls++
|
|
proc = &stubProcess{cfg: cfg, serveSocks: true}
|
|
return proc
|
|
})
|
|
|
|
batch := mustJSON(t, []any{
|
|
map[string]any{"tag": "a", "protocol": "vless"},
|
|
map[string]any{"tag": "b", "protocol": "trojan"},
|
|
})
|
|
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "http")
|
|
if err != nil {
|
|
t.Fatalf("TestOutbounds: %v", err)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("process spawned %d times, want 1", calls)
|
|
}
|
|
for i, r := range results {
|
|
if !r.Success {
|
|
t.Fatalf("result %d failed: %+v", i, r)
|
|
}
|
|
if r.HTTPStatus != http.StatusNoContent {
|
|
t.Errorf("result %d status = %d, want 204", i, r.HTTPStatus)
|
|
}
|
|
if r.Delay < 1 || r.ConnectMs < 1 || r.TTFBMs < 1 {
|
|
t.Errorf("result %d timing not populated: %+v", i, r)
|
|
}
|
|
if r.TLSMs != 0 {
|
|
t.Errorf("result %d TLSMs = %d, want 0 for plain http", i, r.TLSMs)
|
|
}
|
|
if r.Mode != "http" {
|
|
t.Errorf("result %d mode = %q", i, r.Mode)
|
|
}
|
|
}
|
|
if proc.IsRunning() {
|
|
t.Error("temp process not stopped after batch")
|
|
}
|
|
}
|
|
|
|
func TestProbeThroughSocksTransportFailure(t *testing.T) {
|
|
// A listener that accepts and immediately closes — SOCKS handshake dies.
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
defer l.Close()
|
|
go func() {
|
|
for {
|
|
conn, err := l.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
conn.Close()
|
|
}
|
|
}()
|
|
|
|
var result TestOutboundResult
|
|
probeThroughSocks(l.Addr().(*net.TCPAddr).Port, "http://127.0.0.1:9/", 2*time.Second, &result)
|
|
if result.Success || result.Error == "" {
|
|
t.Errorf("expected transport failure, got %+v", result)
|
|
}
|
|
}
|