P9(4): remove local-core live-test trio — outbound speed test + egress probe, restart/stop xray endpoints

Per user decision 2026-10-21 (reverses the earlier keep call in
PANEL-LITE-SPEC.md, now amended): the lite panel never runs a local
core, so measuring/probing from the panel host is meaningless.

Backend:
- delete service/outbound probe implementation (probe_http.go and its
  tests) and the TestOutbound/TestOutbounds batch prober; outbound
  traffic accounting (AddTraffic/GetOutboundsTraffic/Reset) unchanged
- drop POST /panel/api/xray/testOutbound + testOutbounds routes and the
  outboundTestUrl setting (get/update plumbing removed; stored rows in
  existing DBs are inert)
- drop POST /panel/api/server/restartXrayService + stopXrayService
  routes/handlers and the node-sync allowlist entry; the service-level
  Stop/RestartXrayService stay for internal import/restore flows
- node-sync allowlist contract test updated to the new inventory

Frontend:
- remove test buttons/mode switch/test-all from outbounds tab, card
  list and table columns; delete TestResultPopover and CountryPill;
  drop the egress/country/latency columns fed by probe results
- remove outboundTestUrl state and save plumbing from useXraySetting
  and the Basics tab field; drop OutboundTestResult schemas
- delete the 15 now-dead i18n keys from all 13 locales (incl. the
  restart/stop keys orphaned since P6/P7 — i18n-dead-keys test green)
- tests: drop the test-URL hook test, re-probe the loopback index
  mapping through Move-to-top, regenerate API types (gen)
This commit is contained in:
Muse
2026-10-21 16:35:53 +00:00
parent 6772c97f8e
commit 5031a70ccb
43 changed files with 112 additions and 3982 deletions
-1
View File
@@ -90,7 +90,6 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
"/clients/del/:email": {http.MethodPost: {}},
"/clients/:email/detach": {http.MethodPost: {}},
"/clients/update/:email": {http.MethodPost: {}},
"/server/restartXrayService": {http.MethodPost: {}},
"/server/getWebCertFiles": {http.MethodGet: {}},
"/server/descendants": {http.MethodGet: {}},
"/clients/resetTraffic/:email": {http.MethodPost: {}},
-1
View File
@@ -151,7 +151,6 @@ func TestNodeSyncScopeAllowlistMatchesRemoteInventory(t *testing.T) {
"/clients/del/:email": {http.MethodPost: {}},
"/clients/:email/detach": {http.MethodPost: {}},
"/clients/update/:email": {http.MethodPost: {}},
"/server/restartXrayService": {http.MethodPost: {}},
"/server/getWebCertFiles": {http.MethodGet: {}},
"/server/descendants": {http.MethodGet: {}},
"/clients/resetTraffic/:email": {http.MethodPost: {}},
-36
View File
@@ -56,8 +56,6 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/clientIps", a.getClientIps)
g.GET("/fail2banStatus", a.getFail2banStatus)
g.POST("/stopXrayService", a.stopXrayService)
g.POST("/restartXrayService", a.restartXrayService)
g.POST("/updatePanel", a.updatePanel)
g.POST("/setUpdateChannel", a.setUpdateChannel)
g.POST("/importDB", a.importDB)
@@ -165,40 +163,6 @@ func (a *ServerController) setUpdateChannel(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err)
}
// stopXrayService stops the Xray service.
func (a *ServerController) stopXrayService(c *gin.Context) {
err := a.serverService.StopXrayService()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
websocket.BroadcastXrayState("error", err.Error())
return
}
jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
websocket.BroadcastXrayState("stop", "")
websocket.BroadcastNotification(
I18nWeb(c, "pages.xray.stopSuccess"),
"Xray service has been stopped",
"warning",
)
}
// restartXrayService restarts the Xray service.
func (a *ServerController) restartXrayService(c *gin.Context) {
err := a.serverService.RestartXrayService()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.xray.restartError"), err)
websocket.BroadcastXrayState("error", err.Error())
return
}
jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
websocket.BroadcastXrayState("running", "")
websocket.BroadcastNotification(
I18nWeb(c, "pages.xray.restartSuccess"),
"Xray service has been restarted successfully",
"success",
)
}
// getConfigJson retrieves the Xray configuration as JSON.
func (a *ServerController) getConfigJson(c *gin.Context) {
configJson, err := a.serverService.GetConfigJson()
-79
View File
@@ -52,8 +52,6 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g.POST("/pia/:action", a.pia)
g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
g.POST("/testOutbound", a.testOutbound)
g.POST("/testOutbounds", a.testOutbounds)
g.POST("/balancerStatus", a.balancerStatus)
g.POST("/balancerOverride", a.balancerOverride)
g.POST("/routeTest", a.routeTest)
@@ -107,15 +105,10 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
if err != nil {
clientReverseTags = "[]"
}
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
if outboundTestUrl == "" {
outboundTestUrl = "https://www.google.com/generate_204"
}
xrayResponse := map[string]any{
"xraySetting": json.RawMessage(xraySetting),
"inboundTags": json.RawMessage(inboundTags),
"clientReverseTags": json.RawMessage(clientReverseTags),
"outboundTestUrl": outboundTestUrl,
}
// Surface subscription outbounds (and their tags) so the frontend can:
@@ -145,14 +138,6 @@ func (a *XraySettingController) updateSetting(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
outboundTestUrl := c.PostForm("outboundTestUrl")
if outboundTestUrl == "" {
outboundTestUrl = "https://www.google.com/generate_204"
}
if err := a.SettingService.SetXrayOutboundTestUrl(outboundTestUrl); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
// Only reconcile a running core; a manually stopped xray stays stopped.
if a.XrayService.IsXrayRunning() {
if err := a.XrayService.RestartXray(false); err != nil {
@@ -299,70 +284,6 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
jsonObj(c, "", nil)
}
// testOutbound tests an outbound configuration and returns the delay/response time.
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
// Optional form "mode": "tcp" for a fast dial-only probe, "real" for the cold
// full-request delay, anything else (default) for a full HTTP probe through a temp xray instance.
func (a *XraySettingController) testOutbound(c *gin.Context) {
outboundJSON := c.PostForm("outbound")
allOutboundsJSON := c.PostForm("allOutbounds")
mode := c.PostForm("mode")
if outboundJSON == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
return
}
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
}
// testOutbounds tests a batch of outbound configurations through one shared
// temp xray instance and returns an array of results in input order.
// Form "outbounds": JSON array of outbound configs (required).
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
// Optional form "mode": "tcp" for fast dial-only probes, "real" for the cold
// full-request delay, anything else (default) for real HTTP requests routed through each outbound.
func (a *XraySettingController) testOutbounds(c *gin.Context) {
outboundsJSON := c.PostForm("outbounds")
allOutboundsJSON := c.PostForm("allOutbounds")
mode := c.PostForm("mode")
if outboundsJSON == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbounds parameter is required"))
return
}
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
results, err := a.OutboundService.TestOutbounds(outboundsJSON, testURL, allOutboundsJSON, mode)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, results, nil)
}
// balancerStatus reports the live state (override + strategy picks) of the
// balancer tags given as a comma-separated "tags" form field.
func (a *XraySettingController) balancerStatus(c *gin.Context) {
@@ -1,17 +0,0 @@
package outbound
import "testing"
func TestParseCloudflareTrace(t *testing.T) {
values := parseCloudflareTrace("ip=104.28.1.2\nloc=NL\nwarp=on\n")
if values["ip"] != "104.28.1.2" {
t.Fatalf("ip = %q", values["ip"])
}
if values["loc"] != "NL" {
t.Fatalf("loc = %q", values["loc"])
}
if values["warp"] != "on" {
t.Fatalf("warp = %q", values["warp"])
}
}
-201
View File
@@ -1,14 +1,6 @@
package outbound
import (
"context"
"encoding/json"
"fmt"
"net"
"strconv"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -104,196 +96,3 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
return nil
}
// TestOutboundResult represents the result of testing an outbound.
// Delay is in milliseconds. Endpoints is only populated for TCP-mode
// probes; HTTP mode reports the round-trip of a real HTTP request on an
// established connection through the outbound (the cold first request
// supplies the timing breakdown).
type TestOutboundResult struct {
Tag string `json:"tag,omitempty"`
Success bool `json:"success"`
Delay int64 `json:"delay"`
Error string `json:"error,omitempty"`
Mode string `json:"mode,omitempty"`
// HTTP-mode extras. Any HTTP response counts as reachable; HTTPStatus
// records what the test URL answered. ConnectMs is the dial to the local
// test inbound; TLSMs covers outbound-chain establishment + target TLS
// (https URLs only, since xray ACKs the SOCKS CONNECT before dialing
// upstream); TTFBMs is request start → first response byte.
HTTPStatus int `json:"httpStatus,omitempty"`
ConnectMs int64 `json:"connectMs,omitempty"`
TLSMs int64 `json:"tlsMs,omitempty"`
TTFBMs int64 `json:"ttfbMs,omitempty"`
Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
Egress *TestEgressResult `json:"egress,omitempty"`
}
// TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
// dial outcome for outbounds that expose multiple servers/peers.
type TestEndpointResult struct {
Address string `json:"address"`
Success bool `json:"success"`
Delay int64 `json:"delay"`
Error string `json:"error,omitempty"`
}
// TestEgressResult is populated by HTTP-mode probes from Cloudflare's trace
// endpoint. It reports what an external service sees after the outbound chain.
type TestEgressResult struct {
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
Country string `json:"country,omitempty"`
Warp string `json:"warp,omitempty"`
}
func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
var ob map[string]any
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
return &TestOutboundResult{Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
}
tag, _ := ob["tag"].(string)
protocol, _ := ob["protocol"].(string)
if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" {
return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
}
endpoints := extractOutboundEndpoints(ob)
if len(endpoints) == 0 {
return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil
}
results := make([]TestEndpointResult, len(endpoints))
var wg sync.WaitGroup
for i := range endpoints {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
}(i)
}
wg.Wait()
var bestDelay int64 = -1
var firstErr string
for _, r := range results {
if r.Success {
if bestDelay < 0 || r.Delay < bestDelay {
bestDelay = r.Delay
}
} else if firstErr == "" {
firstErr = r.Error
}
}
out := &TestOutboundResult{Tag: tag, Mode: "tcp", Endpoints: results}
if bestDelay >= 0 {
out.Success = true
out.Delay = bestDelay
} else {
out.Error = firstErr
if out.Error == "" {
out.Error = "All endpoints unreachable"
}
}
return out, nil
}
func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
r := TestEndpointResult{Address: endpoint}
start := time.Now()
conn, err := (&net.Dialer{Timeout: timeout}).DialContext(context.Background(), "tcp", endpoint)
r.Delay = time.Since(start).Milliseconds()
if err != nil {
r.Error = err.Error()
return r
}
conn.Close()
r.Success = true
return r
}
// outboundTransportIsUDP reports whether the outbound's proxy speaks UDP
// (wireguard, hysteria, or a kcp/quic/hysteria stream transport). A bare
// UDP dial can't probe these — they ignore unauthenticated packets, so a
// dial neither proves reachability nor measures latency. Such outbounds
// must go through the real xray handshake probe instead.
func outboundTransportIsUDP(ob map[string]any) bool {
if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" {
return true
}
if stream, ok := ob["streamSettings"].(map[string]any); ok {
if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
return true
}
}
return false
}
func extractOutboundEndpoints(ob map[string]any) []string {
protocol, _ := ob["protocol"].(string)
settings, _ := ob["settings"].(map[string]any)
if settings == nil {
return nil
}
var out []string
addServer := func(addr any, port any) {
host, _ := addr.(string)
p := numAsInt(port)
if host != "" && p > 0 {
out = append(out, fmt.Sprintf("%s:%d", host, p))
}
}
switch protocol {
case "vmess":
if vnext, ok := settings["vnext"].([]any); ok {
for _, v := range vnext {
if vm, ok := v.(map[string]any); ok {
addServer(vm["address"], vm["port"])
}
}
}
case "vless":
addServer(settings["address"], settings["port"])
case "hysteria":
addServer(settings["address"], settings["port"])
case "trojan", "shadowsocks", "http", "socks":
if servers, ok := settings["servers"].([]any); ok {
for _, sv := range servers {
if sm, ok := sv.(map[string]any); ok {
addServer(sm["address"], sm["port"])
}
}
}
case "wireguard":
if peers, ok := settings["peers"].([]any); ok {
for _, p := range peers {
if pm, ok := p.(map[string]any); ok {
if ep, _ := pm["endpoint"].(string); ep != "" {
out = append(out, ep)
}
}
}
}
}
return out
}
func numAsInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case int64:
return int(n)
case string:
if i, err := strconv.Atoi(n); err == nil {
return i
}
}
return 0
}
-759
View File
@@ -1,759 +0,0 @@
package outbound
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/http/httptrace"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// HTTP-mode probing works by spinning up ONE temporary xray instance per
// batch: every outbound under test gets its own loopback SOCKS inbound plus
// an inboundTag→outboundTag routing rule, and the panel then issues a real,
// individually-timed HTTP request through each inbound. Measuring the request
// client-side (instead of polling xray's observatory) returns the moment the
// response lands, yields the actual HTTP status, and allows an httptrace
// timing breakdown — while the shared process keeps "Test All" at one xray
// spawn per batch instead of one per outbound. The reported delay comes from
// a second request on the kept-alive connection, so it reflects the tunnel's
// real per-request round-trip rather than the stacked SOCKS/proxy/TLS
// handshakes of connection establishment. Mode "real" instead reports the
// cold request's full elapsed time and skips the warm request.
const (
// httpProbeTimeout bounds each probe request end-to-end (a probe makes
// two: a cold one for the breakdown, a warm one for the delay).
httpProbeTimeout = 10 * time.Second
// probeDrainLimit caps how much response body a probe reads back to keep
// the connection reusable for the warm request.
probeDrainLimit = 256 << 10
// httpProbeConcurrency caps parallel probe requests within a batch —
// enough to keep a batch fast, low enough not to spike CPU with TLS
// handshakes on small VPSes.
httpProbeConcurrency = 16
// batchPortsReadyTimeout bounds the wait for the temp instance to open
// its test inbounds.
batchPortsReadyTimeout = 10 * time.Second
// maxBatchItems caps one batch request; the frontend chunks below this.
maxBatchItems = 50
// tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
// already dials its endpoints concurrently).
tcpBatchConcurrency = 8
// egressTraceTimeout keeps diagnostic trace metadata from extending a
// successful HTTP probe by the full reachability timeout.
egressTraceTimeout = 3 * time.Second
defaultTestURL = "https://www.google.com/generate_204"
egressTraceHost = "cloudflare.com"
egressTracePath = "/cdn-cgi/trace"
)
// httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray
// instance, which is too expensive to run in parallel). TCP-mode probes are
// dial-only and don't need the semaphore.
var httpTestSemaphore sync.Mutex
// batchProcess is the slice of xray.Process the batch engine needs; a seam
// so unit tests can stub the process without an xray binary.
type batchProcess interface {
Start() error
Stop() error
IsRunning() bool
GetResult() string
}
var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess {
return xray.NewTestProcess(cfg, configPath)
}
var egressTraceProbe = probeEgressTrace
// httpBatchItem is one outbound inside an HTTP-mode batch. result is the
// pre-allocated entry in the caller's result slice, filled in place.
type httpBatchItem struct {
index int
tag string
outbound map[string]any
result *TestOutboundResult
}
func probeModeLabel(mode string) string {
switch mode {
case "tcp", "real":
return mode
default:
return "http"
}
}
// TestOutbound probes a single outbound; legacy single-test API kept for the
// /testOutbound endpoint. Dispatch matches TestOutbounds: mode "tcp" dials
// the outbound's endpoints directly, anything else routes a real HTTP request
// through a temp xray instance (UDP-transport outbounds are always forced to
// the HTTP probe — a raw dial can't measure them).
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
var ob map[string]any
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
return &TestOutboundResult{Mode: probeModeLabel(mode), Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
}
results := s.testOutboundsParsed([]map[string]any{ob}, testURL, allOutboundsJSON, mode)
return results[0], nil
}
// TestOutbounds probes a JSON array of outbounds and returns one result per
// input, in input order, each carrying the outbound's tag. allOutboundsJSON
// supplies the config context (sockopt.dialerProxy chains); testURL falls
// back to the default probe URL when empty.
func (s *OutboundService) TestOutbounds(outboundsJSON string, testURL string, allOutboundsJSON string, mode string) ([]*TestOutboundResult, error) {
var raw []json.RawMessage
if err := json.Unmarshal([]byte(outboundsJSON), &raw); err != nil {
return nil, fmt.Errorf("invalid outbounds JSON: %w", err)
}
if len(raw) > maxBatchItems {
return nil, fmt.Errorf("too many outbounds in one request (max %d)", maxBatchItems)
}
items := make([]map[string]any, len(raw))
for i, r := range raw {
var ob map[string]any
if err := json.Unmarshal(r, &ob); err == nil {
items[i] = ob
}
}
return s.testOutboundsParsed(items, testURL, allOutboundsJSON, mode), nil
}
// testOutboundsParsed splits items into the TCP lane (direct dials, bounded
// worker pool) and the HTTP lane (one shared temp xray instance), runs both,
// and returns results aligned with items. A nil item marks unparseable input.
func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL string, allOutboundsJSON string, mode string) []*TestOutboundResult {
results := make([]*TestOutboundResult, len(items))
modeLabel := probeModeLabel(mode)
probeLabel := modeLabel
if probeLabel == "tcp" {
probeLabel = "http"
}
realDelay := mode == "real"
type tcpEntry struct {
idx int
ob map[string]any
}
var tcpLane []tcpEntry
var httpItems []*httpBatchItem
seenTags := make(map[string]bool)
for i, ob := range items {
if ob == nil {
results[i] = &TestOutboundResult{Mode: modeLabel, Success: false, Error: "Invalid outbound JSON"}
continue
}
// A bare TCP dial only proves reachability for TCP-based proxies.
// UDP protocols (wireguard, hysteria, kcp/quic transports) ignore
// unauthenticated packets, so a raw dial can't tell "reachable" from
// "dead" — route them through the real xray probe.
if mode == "tcp" && !outboundTransportIsUDP(ob) {
tcpLane = append(tcpLane, tcpEntry{idx: i, ob: ob})
continue
}
tag, _ := ob["tag"].(string)
r := &TestOutboundResult{Tag: tag, Mode: probeLabel}
results[i] = r
protocol, _ := ob["protocol"].(string)
switch {
case tag == "":
r.Error = "Outbound has no tag"
case protocol == "blackhole" || tag == "blocked":
r.Error = "Blocked/blackhole outbound cannot be tested"
case protocol == "loopback":
r.Error = "Loopback outbound cannot be tested"
case protocol == "freedom" || protocol == "dns":
// Direct/DNS outbounds aren't proxies — an HTTP probe through them
// would only measure the host's own reachability, not a tunnel.
r.Error = "Direct/DNS outbound cannot be tested"
case seenTags[tag]:
r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag)
default:
seenTags[tag] = true
httpItems = append(httpItems, &httpBatchItem{index: i, tag: tag, outbound: ob, result: r})
}
}
if len(tcpLane) > 0 {
var wg sync.WaitGroup
sem := make(chan struct{}, tcpBatchConcurrency)
for _, e := range tcpLane {
wg.Add(1)
go func(e tcpEntry) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
obJSON, err := json.Marshal(e.ob)
if err != nil {
tag, _ := e.ob["tag"].(string)
results[e.idx] = &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}
return
}
r, _ := s.testOutboundTCP(string(obJSON))
results[e.idx] = r
}(e)
}
wg.Wait()
}
if len(httpItems) == 0 {
return results
}
failAll := func(msg string) {
for _, it := range httpItems {
it.result.Success = false
it.result.Error = msg
}
}
var allOutbounds []any
if allOutboundsJSON != "" {
if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
failAll(fmt.Sprintf("Invalid allOutbounds JSON: %v", err))
return results
}
}
if testURL == "" {
testURL = defaultTestURL
}
if !httpTestSemaphore.TryLock() {
failAll("Another outbound test is already running, please wait")
return results
}
defer httpTestSemaphore.Unlock()
retryPerItem, err := runHTTPProbeBatch(httpItems, allOutbounds, testURL, realDelay)
if err == nil {
return results
}
if !retryPerItem || len(httpItems) == 1 {
failAll(err.Error())
return results
}
// The shared process never came up — one structurally-bad outbound can
// poison the whole batch config. Retry each item in its own isolated
// instance so the broken outbound reports xray's real error and the
// rest still get tested. Serial: the poisoned case fails fast (~1s).
for _, it := range httpItems {
if _, ferr := runHTTPProbeBatch([]*httpBatchItem{it}, allOutbounds, testURL, realDelay); ferr != nil {
it.result.Success = false
it.result.Error = ferr.Error()
}
}
return results
}
// runHTTPProbeBatch makes one shared-process attempt for the given items,
// writing per-request outcomes into the items' results. It returns a non-nil
// error only when the process never became usable; retryPerItem reports
// whether splitting the batch into per-item instances could help (true for
// start failures / early exits that a poisoned config would explain, false
// for environmental failures like a missing binary or no free ports).
func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL string, realDelay bool) (retryPerItem bool, err error) {
ports, release, err := reserveLoopbackPorts(len(items))
if err != nil {
return false, fmt.Errorf("Failed to reserve test ports: %w", err)
}
defer release()
cfg := buildBatchTestConfig(items, allOutbounds, ports)
configPath, err := createTestConfigPath()
if err != nil {
return false, fmt.Errorf("Failed to create test config path: %w", err)
}
defer os.Remove(configPath)
proc := newBatchProcess(cfg, configPath)
defer func() {
if proc.IsRunning() {
_ = proc.Stop()
}
}()
// Free the reserved ports just before xray binds them; the window is
// milliseconds, and a lost race makes xray exit fast, which surfaces
// below and triggers the per-item retry with fresh ports.
release()
if err := proc.Start(); err != nil {
if errors.Is(err, fs.ErrNotExist) {
// Binary missing — per-item retries would all fail the same way.
return false, fmt.Errorf("Failed to start test xray instance: %w", err)
}
return true, fmt.Errorf("Failed to start test xray instance: %w", err)
}
if err := waitForPortsReady(proc, ports, batchPortsReadyTimeout); err != nil {
return err.exited, err
}
sem := make(chan struct{}, httpProbeConcurrency)
var wg sync.WaitGroup
for i := range items {
wg.Add(1)
go func(it *httpBatchItem, port int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
probeThroughSocks(port, testURL, httpProbeTimeout, realDelay, it.result)
}(items[i], ports[i])
}
wg.Wait()
if !proc.IsRunning() {
detail := proc.GetResult()
for _, it := range items {
if !it.result.Success {
it.result.Error = "Xray process exited: " + detail
}
}
}
return false, nil
}
// portsReadyError distinguishes "process died" (a poisoned config — worth a
// per-item retry) from "ports never opened while alive" (environmental).
type portsReadyError struct {
msg string
exited bool
}
func (e *portsReadyError) Error() string { return e.msg }
// waitForPortsReady polls until every test inbound accepts connections,
// aborting as soon as the process exits.
func waitForPortsReady(proc batchProcess, ports []int, timeout time.Duration) *portsReadyError {
deadline := time.Now().Add(timeout)
for _, port := range ports {
for {
if !proc.IsRunning() {
return &portsReadyError{msg: "Xray process exited: " + proc.GetResult(), exited: true}
}
conn, err := (&net.Dialer{Timeout: 100 * time.Millisecond}).DialContext(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err == nil {
conn.Close()
break
}
if time.Now().After(deadline) {
return &portsReadyError{msg: fmt.Sprintf("Xray failed to open test inbounds: port %d not ready after %v", port, timeout)}
}
time.Sleep(50 * time.Millisecond)
}
}
return nil
}
// buildBatchTestConfig assembles the temp instance config: one loopback SOCKS
// inbound per tested outbound, a routing rule binding each inbound to its
// outbound tag, and the full outbound context so dialerProxy chains resolve.
func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []int) *xray.Config {
// allOutbounds is the template's outbound list; subscription outbounds
// are injected at runtime and aren't part of it, so append any tested
// outbound whose tag is missing. When a tested outbound's tag collides
// with a template outbound, the template version wins — same semantics
// as the pre-batch tester.
outbounds := make([]any, 0, len(allOutbounds)+len(items))
outbounds = append(outbounds, allOutbounds...)
for _, it := range items {
if !outboundsContainTag(outbounds, it.tag) {
outbounds = append(outbounds, it.outbound)
}
}
for _, ob := range outbounds {
outbound, ok := ob.(map[string]any)
if !ok {
continue
}
// The temp instance must not touch kernel WireGuard devices.
if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
if settings, ok := outbound["settings"].(map[string]any); ok {
settings["noKernelTun"] = true
} else {
outbound["settings"] = map[string]any{"noKernelTun": true}
}
}
}
outboundsJSON, _ := json.Marshal(outbounds)
inbounds := make([]xray.InboundConfig, len(items))
rules := make([]any, len(items))
for i, it := range items {
inTag := fmt.Sprintf("test-in-%d", i)
inbounds[i] = xray.InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: ports[i],
Protocol: "socks",
Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
Tag: inTag,
}
rules[i] = map[string]any{
"type": "field",
"inboundTag": []string{inTag},
"outboundTag": it.tag,
}
}
routingJSON, _ := json.Marshal(map[string]any{
"domainStrategy": "AsIs",
"rules": rules,
})
logJSON, _ := json.Marshal(map[string]any{
"loglevel": "warning",
"access": "none",
"error": "",
"dnsLog": false,
})
return &xray.Config{
LogConfig: json_util.RawMessage(logJSON),
InboundConfigs: inbounds,
OutboundConfigs: json_util.RawMessage(outboundsJSON),
RouterConfig: json_util.RawMessage(routingJSON),
Policy: json_util.RawMessage(`{}`),
Stats: json_util.RawMessage(`{}`),
}
}
// outboundsContainTag reports whether any outbound in the slice has the given tag.
func outboundsContainTag(outbounds []any, tag string) bool {
for _, ob := range outbounds {
if m, ok := ob.(map[string]any); ok {
if t, _ := m["tag"].(string); t == tag {
return true
}
}
}
return false
}
// probeThroughSocks probes the local SOCKS inbound at the given port and
// fills result. A first, cold GET proves reachability and carries the
// httptrace breakdown: any HTTP response — including 4xx/5xx and unfollowed
// redirects — counts as reachable; only transport-level failures (refused,
// reset, timeout, proxy errors) are failures. Delay is then re-measured on a
// warm request over the kept-alive connection — the real round-trip through
// the established tunnel — falling back to the cold total if the warm request
// fails. The test URL's hostname is resolved by xray (Go's SOCKS5 client
// sends the domain to the proxy), so DNS goes through the outbound too.
func probeThroughSocks(port int, testURL string, timeout time.Duration, realDelay bool, result *TestOutboundResult) {
proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
tr := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
MaxIdleConns: 1,
MaxIdleConnsPerHost: 1,
IdleConnTimeout: timeout,
}
defer tr.CloseIdleConnections()
client := &http.Client{
Transport: tr,
Timeout: timeout,
// A redirect would re-dial through the proxy and skew the timing;
// the 3xx itself already proves the outbound works.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
// Timing breakdown. ConnectStart/Done wrap the TCP dial to the local
// inbound (the SOCKS handshake isn't traced, and xray ACKs CONNECT
// before dialing upstream — so the real outbound establishment lands in
// the TLS phase for https URLs, or inside TTFB for plain http).
var (
connStart, tlsStart time.Time
connDur, tlsDur, ttfbDur time.Duration
connDone, tlsDone, gotFirstRB bool
)
start := time.Now()
trace := &httptrace.ClientTrace{
ConnectStart: func(network, addr string) {
if connStart.IsZero() {
connStart = time.Now()
}
},
ConnectDone: func(network, addr string, err error) {
if err == nil && !connDone && !connStart.IsZero() {
connDone = true
connDur = time.Since(connStart)
}
},
TLSHandshakeStart: func() {
if tlsStart.IsZero() {
tlsStart = time.Now()
}
},
TLSHandshakeDone: func(_ tls.ConnectionState, err error) {
if err == nil && !tlsDone && !tlsStart.IsZero() {
tlsDone = true
tlsDur = time.Since(tlsStart)
}
},
GotFirstResponseByte: func() {
if !gotFirstRB {
gotFirstRB = true
ttfbDur = time.Since(start)
}
},
}
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, testURL, nil)
if err != nil {
result.Error = err.Error()
return
}
resp, err := client.Do(req)
coldDelay := time.Since(start).Milliseconds()
if err != nil {
result.Error = err.Error()
return
}
drainAndClose(resp)
result.Success = true
result.HTTPStatus = resp.StatusCode
if connDone {
result.ConnectMs = max(connDur.Milliseconds(), 1)
}
if tlsDone {
result.TLSMs = max(tlsDur.Milliseconds(), 1)
}
if gotFirstRB {
result.TTFBMs = max(ttfbDur.Milliseconds(), 1)
}
delay := coldDelay
if !realDelay {
if warmDelay, ok := timedWarmGet(client, testURL); ok {
delay = warmDelay
}
}
result.Delay = max(delay, 1)
if !realDelay {
result.Egress = egressTraceProbe(proxyURL)
}
}
// probeEgressTrace fetches Cloudflare's plain-text trace endpoint through the
// same SOCKS route used by the HTTP probe. It asks one IPv4 and one IPv6
// Cloudflare address directly, when available, while keeping the TLS SNI as
// cloudflare.com. Failures are intentionally ignored by the caller: egress
// metadata is diagnostic, not reachability.
func probeEgressTrace(proxyURL *url.URL) *TestEgressResult {
ipv4, ipv6 := cloudflareTraceTargets()
if ipv4 == nil && ipv6 == nil {
return nil
}
tr := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
TLSClientConfig: &tls.Config{
ServerName: egressTraceHost,
},
}
defer tr.CloseIdleConnections()
client := &http.Client{
Transport: tr,
Timeout: egressTraceTimeout,
}
egress := &TestEgressResult{}
targets := make([]net.IP, 0, 2)
if ipv4 != nil {
targets = append(targets, ipv4)
}
if ipv6 != nil {
targets = append(targets, ipv6)
}
results := make(chan map[string]string, len(targets))
for _, target := range targets {
go func(ip net.IP) {
results <- fetchCloudflareTrace(client, ip)
}(target)
}
for range targets {
applyEgressTrace(egress, <-results)
}
if egress.IPv4 == "" && egress.IPv6 == "" && egress.Country == "" && egress.Warp == "" {
return nil
}
return egress
}
func cloudflareTraceTargets() (net.IP, net.IP) {
ctx, cancel := context.WithTimeout(context.Background(), egressTraceTimeout)
defer cancel()
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, egressTraceHost)
if err != nil {
return nil, nil
}
var ipv4, ipv6 net.IP
for _, addr := range addrs {
ip := addr.IP
if ipv4 == nil {
if v4 := ip.To4(); v4 != nil {
ipv4 = v4
continue
}
}
if ipv6 == nil && ip.To4() == nil && ip.To16() != nil {
ipv6 = ip
}
if ipv4 != nil && ipv6 != nil {
break
}
}
return ipv4, ipv6
}
func fetchCloudflareTrace(client *http.Client, ip net.IP) map[string]string {
if ip == nil {
return nil
}
traceURL := (&url.URL{
Scheme: "https",
Host: net.JoinHostPort(ip.String(), "443"),
Path: egressTracePath,
}).String()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, traceURL, nil)
if err != nil {
return nil
}
req.Host = egressTraceHost
resp, err := client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
if err != nil {
return nil
}
return parseCloudflareTrace(string(body))
}
func applyEgressTrace(egress *TestEgressResult, values map[string]string) {
if len(values) == 0 {
return
}
if ip := net.ParseIP(values["ip"]); ip != nil {
if ip.To4() != nil {
if egress.IPv4 == "" {
egress.IPv4 = values["ip"]
}
} else if egress.IPv6 == "" {
egress.IPv6 = values["ip"]
}
}
if egress.Country == "" {
egress.Country = values["loc"]
}
if values["warp"] == "on" || egress.Warp == "" {
egress.Warp = values["warp"]
}
}
func parseCloudflareTrace(body string) map[string]string {
values := make(map[string]string)
for line := range strings.SplitSeq(body, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
values[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
return values
}
// timedWarmGet re-issues the probe request over the transport's kept-alive
// connection and returns its duration — the tunnel's per-request round-trip.
func timedWarmGet(client *http.Client, testURL string) (int64, bool) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testURL, nil)
if err != nil {
return 0, false
}
start := time.Now()
resp, err := client.Do(req)
delay := time.Since(start).Milliseconds()
if err != nil {
return 0, false
}
drainAndClose(resp)
return delay, true
}
// drainAndClose consumes the body (bounded by probeDrainLimit) so the
// connection returns to the keep-alive pool for the warm request.
func drainAndClose(resp *http.Response) {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, probeDrainLimit))
resp.Body.Close()
}
// reserveLoopbackPorts grabs n free loopback ports and keeps the listeners
// open so nothing else claims them; release() frees them (idempotent — the
// caller releases right before starting xray and again via defer).
func reserveLoopbackPorts(n int) ([]int, func(), error) {
listeners := make([]net.Listener, 0, n)
release := func() {
for _, l := range listeners {
l.Close()
}
}
ports := make([]int, 0, n)
for range n {
l, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
if err != nil {
release()
return nil, nil, err
}
listeners = append(listeners, l)
ports = append(ports, l.Addr().(*net.TCPAddr).Port)
}
return ports, release, nil
}
// createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
// The temp file is created and closed so the path is reserved; Start() will overwrite it.
func createTestConfigPath() (string, error) {
tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
if err != nil {
return "", err
}
path := tmpFile.Name()
if err := tmpFile.Close(); err != nil {
os.Remove(path)
return "", err
}
return path, nil
}
@@ -1,597 +0,0 @@
package outbound
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync"
"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 withEgressTraceProbe(t *testing.T, probe func(*url.URL) *TestEgressResult) {
t.Helper()
orig := egressTraceProbe
egressTraceProbe = probe
t.Cleanup(func() { egressTraceProbe = 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) {
var mu sync.Mutex
requestsPerConn := make(map[string]int)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
requestsPerConn[r.RemoteAddr]++
mu.Unlock()
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
})
withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
return &TestEgressResult{IPv4: "198.51.100.1", Country: "ZZ", Warp: "off"}
})
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 r.Egress == nil || r.Egress.IPv4 != "198.51.100.1" {
t.Errorf("result %d egress = %+v", i, r.Egress)
}
}
if proc.IsRunning() {
t.Error("temp process not stopped after batch")
}
mu.Lock()
defer mu.Unlock()
totalRequests := 0
for addr, n := range requestsPerConn {
totalRequests += n
if n != 2 {
t.Errorf("connection %s served %d requests, want 2 (warm delay request must reuse the cold request's connection)", addr, n)
}
}
if totalRequests != 4 {
t.Errorf("test URL served %d requests, want 4 (cold + warm per probe)", totalRequests)
}
}
func TestTestOutboundsRealDelayBatchThroughStubSocks(t *testing.T) {
var mu sync.Mutex
requestsPerConn := make(map[string]int)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
requestsPerConn[r.RemoteAddr]++
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
return &stubProcess{cfg: cfg, serveSocks: true}
})
batch := mustJSON(t, []any{
map[string]any{"tag": "a", "protocol": "vless"},
map[string]any{"tag": "wg", "protocol": "wireguard"},
})
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "real")
if err != nil {
t.Fatalf("TestOutbounds: %v", err)
}
for i, r := range results {
if !r.Success {
t.Fatalf("result %d failed: %+v", i, r)
}
if r.Mode != "real" {
t.Errorf("result %d mode = %q, want %q", i, r.Mode, "real")
}
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)
}
}
mu.Lock()
defer mu.Unlock()
totalRequests := 0
for addr, n := range requestsPerConn {
totalRequests += n
if n != 1 {
t.Errorf("connection %s served %d requests, want 1 (real mode must skip the warm request)", addr, n)
}
}
if totalRequests != 2 {
t.Errorf("test URL served %d requests, want 2 (one cold request per probe)", totalRequests)
}
}
func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
return &stubProcess{cfg: cfg, serveSocks: true}
})
withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"}
})
batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "wireguard"}})
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
if err != nil {
t.Fatalf("TestOutbounds: %v", err)
}
r := results[0]
if !r.Success || r.Mode != "http" {
t.Errorf("UDP outbound in tcp mode = %+v, want success with mode %q", r, "http")
}
if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" {
t.Errorf("UDP outbound egress = %+v", r.Egress)
}
}
func TestProbeModeLabel(t *testing.T) {
cases := []struct{ mode, want string }{
{"tcp", "tcp"},
{"real", "real"},
{"http", "http"},
{"", "http"},
{"bogus", "http"},
}
for _, c := range cases {
if got := probeModeLabel(c.mode); got != c.want {
t.Errorf("probeModeLabel(%q) = %q, want %q", c.mode, got, c.want)
}
}
}
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, false, &result)
if result.Success || result.Error == "" {
t.Errorf("expected transport failure, got %+v", result)
}
}
-13
View File
@@ -106,7 +106,6 @@ var defaultValueMap = map[string]string{
"nord": "",
"pia": "",
"restartXrayOnClientDisable": "true",
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
"panelOutbound": "",
"devChannelEnable": "false",
@@ -352,18 +351,6 @@ func (s *SettingService) GetXrayConfigTemplate() (string, error) {
return s.getString("xrayTemplateConfig")
}
func (s *SettingService) GetXrayOutboundTestUrl() (string, error) {
return s.getString("xrayOutboundTestUrl")
}
func (s *SettingService) SetXrayOutboundTestUrl(url string) error {
clean, err := SanitizeHTTPURL(url)
if err != nil {
return err
}
return s.setString("xrayOutboundTestUrl", clean)
}
func (s *SettingService) GetListen() (string, error) {
return s.getString("webListen")
}
+1 -16
View File
@@ -1172,10 +1172,6 @@
},
"xray": {
"save": "احفظ",
"restartSuccess": "تم إعادة تشغيل Xray بنجاح",
"stopSuccess": "تم إيقاف Xray بنجاح",
"restartError": "حدث خطأ أثناء إعادة تشغيل Xray.",
"stopError": "حدث خطأ أثناء إيقاف Xray.",
"basicTemplate": "أساسي",
"advancedTemplate": "متقدم",
"generalConfigs": "إعدادات عامة",
@@ -1198,8 +1194,6 @@
"FreedomHappyEyeballsTryDelayDesc": "عدد المللي ثانية قبل تجربة عائلة العناوين البديلة. 150–250 مللي ثانية نقطة بداية جيدة.",
"RoutingStrategy": "استراتيجية التوجيه العامة",
"RoutingStrategyDesc": "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات.",
"outboundTestUrl": "رابط اختبار المخرج",
"outboundTestUrlDesc": "الرابط المستخدم عند اختبار اتصال المخرج",
"Torrent": "حظر بروتوكول التورنت",
"Inbounds": "الواردات",
"Outbounds": "الصادرات",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "الوسم",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"outboundStatus": "حالة المخرج",
"sendThrough": "أرسل من خلال",
"targetStrategy": "استراتيجية الوجهة",
"modeRealDelay": "التأخير الفعلي",
"testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray. التأخير الفعلي: الوقت الكامل شاملاً إنشاء الاتصال.",
"testAll": "اختبار الكل",
"httpStatus": "حالة HTTP",
"breakdownConnect": "اتصال البروكسي",
"breakdownTls": "TLS عبر الصادر",
"breakdownTtfb": "أول بايت",
"country": "الدولة",
"server": "الخادم",
"city": "المدينة",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ لا يوجد سجل IP!"
},
"email": {}
}
}
+1 -16
View File
@@ -1290,10 +1290,6 @@
},
"xray": {
"save": "Save",
"restartSuccess": "Xray has been successfully relaunched.",
"stopSuccess": "Xray has been successfully stopped.",
"restartError": "There was an error when rebooting the Xray.",
"stopError": "There was an error when stopping the Xray.",
"basicTemplate": "Basics",
"advancedTemplate": "Advanced",
"generalConfigs": "General",
@@ -1316,8 +1312,6 @@
"FreedomHappyEyeballsTryDelayDesc": "Milliseconds before trying the alternate address family. 150–250 ms is a good starting point.",
"RoutingStrategy": "Overall Routing Strategy",
"RoutingStrategyDesc": "Set the overall traffic routing strategy for resolving all requests.",
"outboundTestUrl": "Outbound Test URL",
"outboundTestUrlDesc": "URL used when testing outbound connectivity.",
"Torrent": "Block BitTorrent Protocol",
"Inbounds": "Inbounds",
"Outbounds": "Outbounds",
@@ -1473,18 +1467,9 @@
},
"outbound": {
"tag": "Tag",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"outboundStatus": "Outbound Status",
"sendThrough": "Send Through",
"targetStrategy": "Target Strategy",
"modeRealDelay": "Real delay",
"testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray. Real delay: total time including connection setup.",
"testAll": "Test all",
"httpStatus": "HTTP status",
"breakdownConnect": "Proxy connect",
"breakdownTls": "TLS via outbound",
"breakdownTtfb": "First byte",
"country": "Country",
"server": "Server",
"city": "City",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ No IP Record!"
},
"email": {}
}
}
+1 -16
View File
@@ -1172,10 +1172,6 @@
},
"xray": {
"save": "Guardar configuración",
"restartSuccess": "Xray se ha reiniciado correctamente",
"stopSuccess": "Xray se ha detenido correctamente",
"restartError": "Ocurrió un error al reiniciar Xray.",
"stopError": "Ocurrió un error al detener Xray.",
"basicTemplate": "Perfil Básico",
"advancedTemplate": "Perfil Avanzado",
"generalConfigs": "Configuraciones Generales",
@@ -1198,8 +1194,6 @@
"FreedomHappyEyeballsTryDelayDesc": "Milisegundos antes de probar la otra familia de direcciones. 150–250 ms es un buen punto de partida.",
"RoutingStrategy": "Configurar Estrategia de Enrutamiento de Dominios",
"RoutingStrategyDesc": "Establece la estrategia general de enrutamiento para la resolución de DNS.",
"outboundTestUrl": "URL de prueba de outbound",
"outboundTestUrlDesc": "URL usada al probar la conectividad del outbound",
"Torrent": "Prohibir Uso de BitTorrent",
"Inbounds": "Entradas",
"Outbounds": "Salidas",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "Etiqueta",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"outboundStatus": "Estado de Salida",
"sendThrough": "Enviar a través de",
"targetStrategy": "Estrategia de destino",
"modeRealDelay": "Retardo real",
"testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray. Retardo real: tiempo total incluyendo el establecimiento de la conexión.",
"testAll": "Probar todo",
"httpStatus": "Estado HTTP",
"breakdownConnect": "Conexión al proxy",
"breakdownTls": "TLS vía salida",
"breakdownTtfb": "Primer byte",
"country": "País",
"server": "Servidor",
"city": "Ciudad",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ ¡No hay registro de IP!"
},
"email": {}
}
}
+1 -16
View File
@@ -1172,10 +1172,6 @@
},
"xray": {
"save": "ذخیره",
"restartSuccess": "Xray با موفقیت راه‌اندازی مجدد شد",
"stopSuccess": "Xray با موفقیت متوقف شد",
"restartError": "خطا در راه‌اندازی مجدد Xray.",
"stopError": "خطا در توقف Xray.",
"basicTemplate": "پایه",
"advancedTemplate": "پیشرفته",
"generalConfigs": "استراتژی‌ کلی",
@@ -1198,8 +1194,6 @@
"FreedomHappyEyeballsTryDelayDesc": "تعداد میلی‌ثانیه پیش از امتحان خانوادهٔ آدرس دیگر. مقدار 150 تا 250 میلی‌ثانیه نقطهٔ شروع خوبی است.",
"RoutingStrategy": "استراتژی کلی مسیریابی",
"RoutingStrategyDesc": "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند",
"outboundTestUrl": "آدرس تست خروجی",
"outboundTestUrlDesc": "آدرسی که برای تست اتصال خروجی استفاده می‌شود.",
"Torrent": "مسدودسازی پروتکل بیت‌تورنت",
"Inbounds": "ورودی‌ها",
"Outbounds": "خروجی‌ها",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "تگ",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"outboundStatus": "وضعیت خروجی",
"sendThrough": "ارسال با",
"targetStrategy": "استراتژی مقصد",
"modeRealDelay": "تأخیر واقعی",
"testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray. تأخیر واقعی: کل زمان همراه با برقراری اتصال.",
"testAll": "تست همه",
"httpStatus": "وضعیت HTTP",
"breakdownConnect": "اتصال پروکسی",
"breakdownTls": "TLS از طریق خروجی",
"breakdownTtfb": "اولین بایت",
"country": "کشور",
"server": "سرور",
"city": "شهر",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ رکورد آی پی وجود ندارد!"
},
"email": {}
}
}
+1 -16
View File
@@ -1172,10 +1172,6 @@
},
"xray": {
"save": "Simpan",
"restartSuccess": "Xray berhasil diluncurkan ulang",
"stopSuccess": "Xray telah berhasil dihentikan",
"restartError": "Terjadi kesalahan saat memulai ulang Xray.",
"stopError": "Terjadi kesalahan saat menghentikan Xray.",
"basicTemplate": "Dasar",
"advancedTemplate": "Lanjutan",
"generalConfigs": "Strategi Umum",
@@ -1198,8 +1194,6 @@
"FreedomHappyEyeballsTryDelayDesc": "Milidetik sebelum mencoba keluarga alamat lainnya. 150–250 ms adalah titik awal yang baik.",
"RoutingStrategy": "Strategi Pengalihan Keseluruhan",
"RoutingStrategyDesc": "Atur strategi pengalihan lalu lintas keseluruhan untuk menyelesaikan semua permintaan.",
"outboundTestUrl": "URL tes outbound",
"outboundTestUrlDesc": "URL yang digunakan saat menguji konektivitas outbound",
"Torrent": "Blokir Protokol BitTorrent",
"Inbounds": "Inbound",
"Outbounds": "Outbound",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "Tag",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"outboundStatus": "Status Keluar",
"sendThrough": "Kirim Melalui",
"targetStrategy": "Strategi Target",
"modeRealDelay": "Delay nyata",
"testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray. Delay nyata: total waktu termasuk pembentukan koneksi.",
"testAll": "Tes semua",
"httpStatus": "Status HTTP",
"breakdownConnect": "Koneksi proxy",
"breakdownTls": "TLS melalui outbound",
"breakdownTtfb": "Byte pertama",
"country": "Negara",
"server": "Server",
"city": "Kota",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ Tidak ada Catatan IP!"
},
"email": {}
}
}
+1 -16
View File
@@ -1180,10 +1180,6 @@
"metricsListenDesc": "この アドレス:ポート で Xray の Prometheus 形式メトリクスを公開します(例: 127.0.0.1:11111)。空欄にすると無効になります。認証されないため、localhost にバインドしてリバースプロキシ経由で公開してください。",
"metricsTag": "メトリクスタグ",
"save": "保存",
"restartSuccess": "Xrayの再起動に成功しました",
"stopSuccess": "Xrayが正常に停止しました",
"restartError": "Xrayの再起動中にエラーが発生しました。",
"stopError": "Xrayの停止中にエラーが発生しました。",
"basicTemplate": "基本設定",
"advancedTemplate": "高度な設定",
"generalConfigs": "一般設定",
@@ -1206,8 +1202,6 @@
"FreedomHappyEyeballsTryDelayDesc": "別のアドレスファミリを試すまでのミリ秒。150〜250 ms が目安です。",
"RoutingStrategy": "ルーティングドメイン戦略設定",
"RoutingStrategyDesc": "DNS解決の全体的なルーティング戦略を設定する",
"outboundTestUrl": "アウトバウンドテスト URL",
"outboundTestUrlDesc": "アウトバウンド接続テストに使用する URL。既定値",
"Torrent": "BitTorrent プロトコルをブロック",
"Inbounds": "インバウンド",
"Outbounds": "アウトバウンド",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "タグ",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"outboundStatus": "アウトバウンドステータス",
"sendThrough": "送信経路",
"targetStrategy": "ターゲット解決戦略",
"modeRealDelay": "実際の遅延",
"testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。実際の遅延: 接続確立を含む合計時間。",
"testAll": "すべてテスト",
"httpStatus": "HTTPステータス",
"breakdownConnect": "プロキシ接続",
"breakdownTls": "アウトバウンド経由のTLS",
"breakdownTtfb": "最初のバイト",
"country": "国",
"server": "サーバー",
"city": "都市",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ IPレコードがありません!"
},
"email": {}
}
}
+1 -16
View File
@@ -1180,10 +1180,6 @@
"metricsListenDesc": "Expõe as métricas no estilo Prometheus do Xray neste endereço:porta (por exemplo, 127.0.0.1:11111). Deixe vazio para desativar. Vincule ao localhost e use um proxy reverso — ele não é autenticado.",
"metricsTag": "Tag de métricas",
"save": "Salvar",
"restartSuccess": "Xray foi reiniciado com sucesso",
"stopSuccess": "Xray foi interrompido com sucesso",
"restartError": "Ocorreu um erro ao reiniciar o Xray.",
"stopError": "Ocorreu um erro ao parar o Xray.",
"basicTemplate": "Básico",
"advancedTemplate": "Avançado",
"generalConfigs": "Geral",
@@ -1206,8 +1202,6 @@
"FreedomHappyEyeballsTryDelayDesc": "Milissegundos antes de tentar a outra família de endereços. 150–250 ms é um bom ponto de partida.",
"RoutingStrategy": "Estratégia Geral de Roteamento",
"RoutingStrategyDesc": "Definir a estratégia geral de roteamento de tráfego para resolver todas as solicitações.",
"outboundTestUrl": "URL de teste de outbound",
"outboundTestUrlDesc": "URL usada ao testar conectividade do outbound",
"Torrent": "Bloquear Protocolo BitTorrent",
"Inbounds": "Entradas",
"Outbounds": "Saídas",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "Tag",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"outboundStatus": "Status de Saída",
"sendThrough": "Enviar Através de",
"targetStrategy": "Estratégia de destino",
"modeRealDelay": "Latência real",
"testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray. Latência real: tempo total incluindo o estabelecimento da conexão.",
"testAll": "Testar todos",
"httpStatus": "Status HTTP",
"breakdownConnect": "Conexão do proxy",
"breakdownTls": "TLS via saída",
"breakdownTtfb": "Primeiro byte",
"country": "País",
"server": "Servidor",
"city": "Cidade",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ Nenhum registro de IP!"
},
"email": {}
}
}
+1 -16
View File
@@ -1180,10 +1180,6 @@
"metricsListenDesc": "Публикует метрики Xray в стиле Prometheus по этому адресу:порту (например, 127.0.0.1:11111). Оставьте пустым, чтобы отключить. Привяжите к localhost и проксируйте через reverse-proxy — он без аутентификации.",
"metricsTag": "Тег метрик",
"save": "Сохранить",
"restartSuccess": "Xray успешно перезапущен",
"stopSuccess": "Xray успешно остановлен",
"restartError": "Произошла ошибка при перезапуске Xray.",
"stopError": "Произошла ошибка при остановке Xray.",
"basicTemplate": "Основное",
"advancedTemplate": "Расширенный шаблон",
"generalConfigs": "Основные настройки",
@@ -1206,8 +1202,6 @@
"FreedomHappyEyeballsTryDelayDesc": "Миллисекунды перед попыткой другого семейства адресов. 150–250 мс — хорошая отправная точка.",
"RoutingStrategy": "Настройка маршрутизации доменов",
"RoutingStrategyDesc": "Установка общей стратегии маршрутизации разрешения DNS",
"outboundTestUrl": "URL для теста исходящего",
"outboundTestUrlDesc": "URL для проверки подключения исходящего",
"Torrent": "Заблокировать BitTorrent",
"Inbounds": "Входящие",
"Outbounds": "Исходящие",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "Тег",
"egress": "Выход",
"egressHint": "Запустите HTTP-тест, чтобы показать выходной IP и страну.",
"outboundStatus": "Статус исходящего подключения",
"sendThrough": "Отправить через",
"targetStrategy": "Стратегия назначения",
"modeRealDelay": "Реальная задержка",
"testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray. Реальная задержка: полное время с установлением соединения.",
"testAll": "Тестировать все",
"httpStatus": "HTTP-статус",
"breakdownConnect": "Подключение к прокси",
"breakdownTls": "TLS через исходящий",
"breakdownTtfb": "Первый байт",
"country": "Страна",
"server": "Сервер",
"city": "Город",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ Нет записей об IP-адресе."
},
"email": {}
}
}
+1 -16
View File
@@ -1172,10 +1172,6 @@
},
"xray": {
"save": "Kaydet",
"restartSuccess": "Xray başarıyla yeniden başlatıldı.",
"stopSuccess": "Xray başarıyla durduruldu.",
"restartError": "Xray yeniden başlatılırken bir hata oluştu.",
"stopError": "Xray durdurulurken bir hata oluştu.",
"basicTemplate": "Temeller",
"advancedTemplate": "Gelişmiş",
"generalConfigs": "Genel",
@@ -1198,8 +1194,6 @@
"FreedomHappyEyeballsTryDelayDesc": "Diğer adres ailesini denemeden önceki milisaniye. 150–250 ms iyi bir başlangıç noktasıdır.",
"RoutingStrategy": "Genel Yönlendirme Stratejisi",
"RoutingStrategyDesc": "Tüm istekleri çözmek için genel trafik yönlendirme stratejisini ayarlayın.",
"outboundTestUrl": "Giden Bağlantı Test URL'si",
"outboundTestUrlDesc": "Giden bağlantı bağlantı testinde kullanılan URL.",
"importRules": "Kuralları İçe Aktar",
"exportRules": "Kuralları Dışa Aktar",
"importOutbounds": "Giden Bağlantıları İçe Aktar",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "Etiket",
"egress": "Çıkış",
"egressHint": "Çıkış IP adresini ve ülkeyi göstermek için bir HTTP testi gerçekleştirin.",
"outboundStatus": "Giden Bağlantı Durumu",
"sendThrough": "Üzerinden Gönder",
"targetStrategy": "Hedef Stratejisi",
"modeRealDelay": "Gerçek gecikme",
"testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek. Gerçek gecikme: bağlantı kurulumu dahil toplam süre.",
"testAll": "Tümünü Test Et",
"httpStatus": "HTTP durumu",
"breakdownConnect": "Proxy bağlantısı",
"breakdownTls": "Giden üzerinden TLS",
"breakdownTtfb": "İlk bayt",
"country": "Ülke",
"server": "Sunucu",
"city": "Şehir",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ IP Kaydı Yok!"
},
"email": {}
}
}
+1 -16
View File
@@ -1172,10 +1172,6 @@
},
"xray": {
"save": "Зберегти",
"restartSuccess": "Xray успішно перезапущено",
"stopSuccess": "Xray успішно зупинено",
"restartError": "Виникла помилка під час перезапуску Xray.",
"stopError": "Виникла помилка під час зупинки Xray.",
"basicTemplate": "Базовий шаблон",
"advancedTemplate": "Додатково",
"generalConfigs": "Загальні конфігурації",
@@ -1198,8 +1194,6 @@
"FreedomHappyEyeballsTryDelayDesc": "Мілісекунди перед спробою іншої родини адрес. 150–250 мс — добра початкова точка.",
"RoutingStrategy": "Загальна стратегія маршрутизації",
"RoutingStrategyDesc": "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів.",
"outboundTestUrl": "URL тесту outbound",
"outboundTestUrlDesc": "URL для перевірки з'єднання outbound",
"Torrent": "Блокувати протокол BitTorrent",
"Inbounds": "Вхідні",
"Outbounds": "Вихідні",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "Тег",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"outboundStatus": "Статус виходу",
"sendThrough": "Надіслати через",
"targetStrategy": "Стратегія призначення",
"modeRealDelay": "Реальна затримка",
"testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray. Реальна затримка: повний час із встановленням з'єднання.",
"testAll": "Тестувати всі",
"httpStatus": "HTTP-статус",
"breakdownConnect": "Підключення до проксі",
"breakdownTls": "TLS через вихідний",
"breakdownTtfb": "Перший байт",
"country": "Країна",
"server": "Сервер",
"city": "Місто",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ Немає запису IP!"
},
"email": {}
}
}
+1 -16
View File
@@ -1180,10 +1180,6 @@
"metricsListenDesc": "Hiển thị các chỉ số kiểu Prometheus của Xray tại địa chỉ:cổng này (ví dụ 127.0.0.1:11111). Để trống để tắt. Hãy gắn vào localhost và reverse-proxy nó — vì nó không có xác thực.",
"metricsTag": "Metrics Tag",
"save": "Lưu cài đặt",
"restartSuccess": "Đã khởi động lại Xray thành công",
"stopSuccess": "Xray đã được dừng thành công",
"restartError": "Đã xảy ra lỗi khi khởi động lại Xray.",
"stopError": "Đã xảy ra lỗi khi dừng Xray.",
"basicTemplate": "Mẫu Cơ bản",
"advancedTemplate": "Mẫu Nâng cao",
"generalConfigs": "Cấu hình Chung",
@@ -1206,8 +1202,6 @@
"FreedomHappyEyeballsTryDelayDesc": "Số mili-giây trước khi thử họ địa chỉ còn lại. 150–250 ms là điểm khởi đầu tốt.",
"RoutingStrategy": "Cấu hình Chiến lược Định tuyến Tên miền",
"RoutingStrategyDesc": "Đặt chiến lược định tuyến tổng thể cho việc giải quyết DNS.",
"outboundTestUrl": "URL kiểm tra outbound",
"outboundTestUrlDesc": "URL dùng khi kiểm tra kết nối outbound",
"Torrent": "Cấu hình sử dụng BitTorrent",
"Inbounds": "Inbound",
"Outbounds": "Outbound",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "Tag",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"outboundStatus": "Trạng thái đầu ra",
"sendThrough": "Gửi qua",
"targetStrategy": "Chiến lược đích",
"modeRealDelay": "Độ trễ thực",
"testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray. Độ trễ thực: tổng thời gian gồm cả thiết lập kết nối.",
"testAll": "Kiểm tra tất cả",
"httpStatus": "Trạng thái HTTP",
"breakdownConnect": "Kết nối proxy",
"breakdownTls": "TLS qua outbound",
"breakdownTtfb": "Byte đầu tiên",
"country": "Quốc gia",
"server": "Máy chủ",
"city": "Thành phố",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ Không có bản ghi IP!"
},
"email": {}
}
}
+1 -16
View File
@@ -1180,10 +1180,6 @@
"metricsListenDesc": "在此 address:port 上暴露 Xray 的 Prometheus 风格指标(例如 127.0.0.1:11111)。留空则禁用。请绑定到本地回环并通过反向代理转发——它没有身份验证。",
"metricsTag": "指标标签",
"save": "保存",
"restartSuccess": "Xray 已成功重新启动",
"stopSuccess": "Xray 已成功停止",
"restartError": "重启 Xray 时发生错误。",
"stopError": "停止 Xray 时发生错误。",
"basicTemplate": "基础配置",
"advancedTemplate": "高级配置",
"generalConfigs": "常规配置",
@@ -1206,8 +1202,6 @@
"FreedomHappyEyeballsTryDelayDesc": "尝试备用地址族之前等待的毫秒数。150–250 毫秒是不错的起点。",
"RoutingStrategy": "配置路由域策略",
"RoutingStrategyDesc": "设置 DNS 解析的整体路由策略",
"outboundTestUrl": "出站测试 URL",
"outboundTestUrlDesc": "测试出站连接时使用的 URL",
"Torrent": "屏蔽 BitTorrent 协议",
"Inbounds": "入站",
"Outbounds": "出站",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "标签",
"egress": "出口",
"egressHint": "执行 HTTP 测试以显示出口 IP 和所在国家/地区。",
"outboundStatus": "出站状态",
"sendThrough": "发送通过",
"targetStrategy": "目标解析策略",
"modeRealDelay": "真实延迟",
"testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。真实延迟: 含建立连接的总耗时。",
"testAll": "全部测试",
"httpStatus": "HTTP 状态",
"breakdownConnect": "代理连接",
"breakdownTls": "经由出站的 TLS",
"breakdownTtfb": "首字节",
"country": "国家",
"server": "服务器",
"city": "城市",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ 没有 IP 记录!"
},
"email": {}
}
}
+1 -16
View File
@@ -1172,10 +1172,6 @@
},
"xray": {
"save": "儲存",
"restartSuccess": "Xray 已成功重新啟動",
"stopSuccess": "Xray 已成功停止",
"restartError": "重新啟動 Xray 時發生錯誤。",
"stopError": "停止 Xray 時發生錯誤。",
"importRules": "匯入規則",
"exportRules": "匯出規則",
"importOutbounds": "匯入出站",
@@ -1206,8 +1202,6 @@
"FreedomHappyEyeballsTryDelayDesc": "嘗試備用位址族之前等待的毫秒數。150–250 毫秒是不錯的起點。",
"RoutingStrategy": "配置路由域策略",
"RoutingStrategyDesc": "設定 DNS 解析的整體路由策略",
"outboundTestUrl": "出站測試 URL",
"outboundTestUrlDesc": "測試出站連線時使用的 URL",
"Torrent": "遮蔽 BitTorrent 協議",
"Inbounds": "入站",
"Outbounds": "出站",
@@ -1355,18 +1349,9 @@
},
"outbound": {
"tag": "標籤",
"egress": "出口",
"egressHint": "執行 HTTP 測試以顯示出口 IP 與所在國家/地區。",
"outboundStatus": "出站狀態",
"sendThrough": "傳送通過",
"targetStrategy": "目標解析策略",
"modeRealDelay": "真實延遲",
"testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。真實延遲: 含建立連線的總耗時。",
"testAll": "全部測試",
"httpStatus": "HTTP 狀態",
"breakdownConnect": "代理連線",
"breakdownTls": "經由出站的 TLS",
"breakdownTtfb": "首位元組",
"country": "國家",
"server": "伺服器",
"city": "城市",
@@ -1675,4 +1660,4 @@
"noIpRecord": "❗ 沒有 IP 記錄!"
},
"email": {}
}
}