diff --git a/libv2ray_utils.go b/libv2ray_utils.go index 342e516f..244fbca7 100644 --- a/libv2ray_utils.go +++ b/libv2ray_utils.go @@ -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" ) @@ -62,6 +67,166 @@ 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) + } + applyRequestHeaders(req, headers) + } + + 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 +} + +func applyRequestHeaders(req *http.Request, headers map[string]string) { + for key, value := range headers { + if strings.EqualFold(key, "Host") { + req.Host = value + continue + } + req.Header.Set(key, value) + } +} + // 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)) diff --git a/libv2ray_utils_test.go b/libv2ray_utils_test.go new file mode 100644 index 00000000..7b183616 --- /dev/null +++ b/libv2ray_utils_test.go @@ -0,0 +1,28 @@ +package libv2ray + +import ( + "net/http" + "testing" +) + +func TestApplyRequestHeaders(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatal(err) + } + + applyRequestHeaders(req, map[string]string{ + "host": "origin.example", + "X-Test": "value", + }) + + if req.Host != "origin.example" { + t.Fatalf("Host = %q, want %q", req.Host, "origin.example") + } + if got := req.Header.Get("Host"); got != "" { + t.Fatalf("Header[Host] = %q, want empty", got) + } + if got := req.Header.Get("X-Test"); got != "value" { + t.Fatalf("X-Test = %q, want %q", got, "value") + } +}