Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions libv2ray_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@ package libv2ray

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"

corenet "github.com/xtls/xray-core/common/net"
"github.com/xtls/xray-core/common/serial"
coresession "github.com/xtls/xray-core/common/session"
core "github.com/xtls/xray-core/core"
corerouting "github.com/xtls/xray-core/features/routing"
corestats "github.com/xtls/xray-core/features/stats"
coreserial "github.com/xtls/xray-core/infra/conf/serial"
)
Expand Down Expand Up @@ -62,6 +67,158 @@ func (x *CoreController) MeasureDelay(url string) (int64, error) {
return measureInstDelay(ctx, x.coreInstance, url)
}

// GetBalancerPrincipleTarget returns the strategy's current first-choice
// outbound. An empty result means the observatory has not produced a viable
// target yet or the running profile has no compatible balancer.
func (x *CoreController) GetBalancerPrincipleTarget(balancerTag string) (string, error) {
x.coreMutex.Lock()
defer x.coreMutex.Unlock()

if !x.IsRunning || x.coreInstance == nil {
return "", nil
}
return firstBalancerPrincipleTarget(x.coreInstance, balancerTag)
}

func firstBalancerPrincipleTarget(inst *core.Instance, balancerTag string) (string, error) {
if balancerTag == "" {
return "", nil
}
if inst == nil {
return "", errors.New("core instance is nil")
}
principle, ok := inst.GetFeature(corerouting.RouterType()).(corerouting.BalancerPrincipleTarget)
if !ok {
return "", errors.New("router does not expose balancer principle targets")
}
targets, err := principle.GetPrincipleTarget(balancerTag)
if err != nil {
return "", err
}
for _, target := range targets {
if target != "" {
return target, nil
}
}
return "", nil
}

// GetUrlContent retrieves a URL through the requested outbound of the current core instance.
func (x *CoreController) GetUrlContent(url string, outboundTag string) (string, error) {
resp, err := x.getURL(url, outboundTag, "", 5*time.Second)
if err != nil {
return "", err
}
defer resp.Body.Close()

content, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
return string(content), nil
}

// DownloadUrlToFile downloads a URL through the requested outbound of the
// current core instance. Headers are supplied as a JSON object.
func (x *CoreController) DownloadUrlToFile(url string, outboundTag string, headersJSON string, filePath string, timeoutMillis int64) (err error) {
if filePath == "" {
return errors.New("file path is empty")
}
timeout := time.Duration(timeoutMillis) * time.Millisecond
if timeout <= 0 {
timeout = 15 * time.Second
}

resp, err := x.getURL(url, outboundTag, headersJSON, timeout)
if err != nil {
return err
}
defer resp.Body.Close()

file, err := os.CreateTemp(filepath.Dir(filePath), "."+filepath.Base(filePath)+".*")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
temporaryPath := file.Name()
closed := false
defer func() {
if !closed {
_ = file.Close()
}
_ = os.Remove(temporaryPath)
}()

written, err := io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("failed to write response body: %w", err)
}
if resp.ContentLength >= 0 && written != resp.ContentLength {
return fmt.Errorf("response length mismatch: expected %d bytes, got %d", resp.ContentLength, written)
}
if err = file.Close(); err != nil {
return fmt.Errorf("failed to close temporary file: %w", err)
}
closed = true
if err = os.Rename(temporaryPath, filePath); err != nil {
return fmt.Errorf("failed to replace destination file: %w", err)
}
return nil
}

func (x *CoreController) getURL(url string, outboundTag string, headersJSON string, timeout time.Duration) (*http.Response, error) {
x.coreMutex.Lock()
inst := x.coreInstance
running := x.IsRunning
x.coreMutex.Unlock()

if !running || inst == nil {
return nil, errors.New("core is not running")
}
if outboundTag == "" {
return nil, errors.New("outbound tag is empty")
}

tr := &http.Transport{
TLSHandshakeTimeout: 5 * time.Second,
DisableKeepAlives: true,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
dest, err := corenet.ParseDestination(fmt.Sprintf("%s:%s", network, addr))
if err != nil {
return nil, err
}
ctx = coresession.SetForcedOutboundTagToContext(ctx, outboundTag)
return core.Dial(ctx, inst, dest)
},
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if headersJSON != "" {
headers := make(map[string]string)
if err := json.Unmarshal([]byte(headersJSON), &headers); err != nil {
return nil, fmt.Errorf("failed to parse request headers: %w", err)
}
for key, value := range headers {
req.Header.Set(key, value)
}
}

resp, err := (&http.Client{Transport: tr, Timeout: timeout}).Do(req)
if err != nil {
return nil, err
}

if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices ||
resp.StatusCode == http.StatusPartialContent {
resp.Body.Close()
return nil, fmt.Errorf("invalid status: %s", resp.Status)
}

return resp, nil
}

// MeasureOutboundDelay measures the outbound delay for a given configuration and URL
func MeasureOutboundDelay(ConfigureFileContent string, url string) (int64, error) {
config, err := coreserial.LoadJSONConfig(strings.NewReader(ConfigureFileContent))
Expand Down