mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-06 04:44:21 +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.
292 lines
7.5 KiB
Go
292 lines
7.5 KiB
Go
package outbound
|
|
|
|
import (
|
|
"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"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// OutboundService provides business logic for managing Xray outbound configurations.
|
|
// It handles outbound traffic monitoring and statistics.
|
|
type OutboundService struct{}
|
|
|
|
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
|
var err error
|
|
db := database.GetDB()
|
|
tx := db.Begin()
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
tx.Rollback()
|
|
} else {
|
|
tx.Commit()
|
|
}
|
|
}()
|
|
|
|
err = s.addOutboundTraffic(tx, traffics)
|
|
if err != nil {
|
|
return err, false
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
func (s *OutboundService) addOutboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
|
|
if len(traffics) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var err error
|
|
|
|
for _, traffic := range traffics {
|
|
if traffic.IsOutbound {
|
|
|
|
var outbound model.OutboundTraffics
|
|
|
|
err = tx.Model(&model.OutboundTraffics{}).Where("tag = ?", traffic.Tag).
|
|
FirstOrCreate(&outbound).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
outbound.Tag = traffic.Tag
|
|
outbound.Up = outbound.Up + traffic.Up
|
|
outbound.Down = outbound.Down + traffic.Down
|
|
outbound.Total = outbound.Up + outbound.Down
|
|
|
|
err = tx.Save(&outbound).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *OutboundService) GetOutboundsTraffic() ([]*model.OutboundTraffics, error) {
|
|
db := database.GetDB()
|
|
var traffics []*model.OutboundTraffics
|
|
|
|
err := db.Model(model.OutboundTraffics{}).Find(&traffics).Error
|
|
if err != nil {
|
|
logger.Warning("Error retrieving OutboundTraffics: ", err)
|
|
return nil, err
|
|
}
|
|
|
|
return traffics, nil
|
|
}
|
|
|
|
func (s *OutboundService) ResetOutboundTraffic(tag string) error {
|
|
db := database.GetDB()
|
|
|
|
whereText := "tag "
|
|
if tag == "-alltags-" {
|
|
whereText += " <> ?"
|
|
} else {
|
|
whereText += " = ?"
|
|
}
|
|
|
|
result := db.Model(model.OutboundTraffics{}).
|
|
Where(whereText, tag).
|
|
Updates(map[string]any{"up": 0, "down": 0, "total": 0})
|
|
|
|
err := result.Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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 time of a real HTTP request routed
|
|
// through the outbound, with an optional 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
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.DialTimeout("tcp", endpoint, timeout)
|
|
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
|
|
}
|