From 3583b86543b7af35f032c8152b4b7faf43b93a20 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Tue, 18 Aug 2026 17:10:41 +0300 Subject: [PATCH 1/6] Expose URL fetching through the active core Add CoreController.GetUrlContent to perform bounded HTTP GET requests with core.Dial. This lets Android callers retrieve response bodies through the running Xray instance without depending on a local proxy inbound. The API is intended for xray-tun connection metadata and can also support resource or subscription downloads that should follow the active core route. --- libv2ray_utils.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/libv2ray_utils.go b/libv2ray_utils.go index 342e516f..bc3420d2 100644 --- a/libv2ray_utils.go +++ b/libv2ray_utils.go @@ -62,6 +62,42 @@ func (x *CoreController) MeasureDelay(url string) (int64, error) { return measureInstDelay(ctx, x.coreInstance, url) } +// GetUrlContent retrieves a URL through the current core instance. +func (x *CoreController) GetUrlContent(url string) (string, error) { + if x.coreInstance == nil { + return "", errors.New("core instance is nil") + } + + 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 + } + return core.Dial(ctx, x.coreInstance, dest) + }, + } + defer tr.CloseIdleConnections() + + resp, err := (&http.Client{Transport: tr, Timeout: 5 * time.Second}).Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", fmt.Errorf("invalid status: %s", resp.Status) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + return string(content), 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)) From 07b9d85b98e2f822ba16d6ca3813514d01766ca8 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Tue, 18 Aug 2026 17:56:11 +0300 Subject: [PATCH 2/6] Force core URL requests through an outbound GetUrlContent previously passed requests to core.Dial without an outbound override, allowing normal routing rules to send platform-initiated downloads through direct or another detour. Accept an explicit outbound tag and attach it to every dial with Xray session metadata. This preserves dispatcher-managed connections while ensuring redirects and retries continue through the caller-selected outbound. --- libv2ray_utils.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libv2ray_utils.go b/libv2ray_utils.go index bc3420d2..6dba48ac 100644 --- a/libv2ray_utils.go +++ b/libv2ray_utils.go @@ -13,6 +13,7 @@ import ( 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" corestats "github.com/xtls/xray-core/features/stats" coreserial "github.com/xtls/xray-core/infra/conf/serial" @@ -62,11 +63,14 @@ func (x *CoreController) MeasureDelay(url string) (int64, error) { return measureInstDelay(ctx, x.coreInstance, url) } -// GetUrlContent retrieves a URL through the current core instance. -func (x *CoreController) GetUrlContent(url string) (string, error) { +// GetUrlContent retrieves a URL through the requested outbound of the current core instance. +func (x *CoreController) GetUrlContent(url string, outboundTag string) (string, error) { if x.coreInstance == nil { return "", errors.New("core instance is nil") } + if outboundTag == "" { + return "", errors.New("outbound tag is empty") + } tr := &http.Transport{ TLSHandshakeTimeout: 5 * time.Second, @@ -76,6 +80,7 @@ func (x *CoreController) GetUrlContent(url string) (string, error) { if err != nil { return nil, err } + ctx = coresession.SetForcedOutboundTagToContext(ctx, outboundTag) return core.Dial(ctx, x.coreInstance, dest) }, } From daeeb01ec2645f95d372a28ea43821f188879078 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Tue, 18 Aug 2026 18:32:33 +0300 Subject: [PATCH 3/6] Expose the active balancer principle target Add CoreController.GetBalancerPrincipleTarget using Xray's existing routing feature and return the first viable strategy target. This gives Android callers a concrete outbound for policy-group requests without requiring a custom Xray-core API. --- libv2ray_utils.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/libv2ray_utils.go b/libv2ray_utils.go index 6dba48ac..e48e561e 100644 --- a/libv2ray_utils.go +++ b/libv2ray_utils.go @@ -15,6 +15,7 @@ import ( "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" ) @@ -63,6 +64,42 @@ 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) { if x.coreInstance == nil { From c805e05572b6f4eb0a7364b259711f56b0926372 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Thu, 20 Aug 2026 14:13:44 +0300 Subject: [PATCH 4/6] Stream core URL downloads to files Add a binary-safe URL download API that uses the active core and an explicitly selected outbound. Accept request headers and a caller-defined timeout, stream successful responses directly to disk, and remove partial files on failure. --- libv2ray_utils.go | 93 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 14 deletions(-) diff --git a/libv2ray_utils.go b/libv2ray_utils.go index e48e561e..17187ae7 100644 --- a/libv2ray_utils.go +++ b/libv2ray_utils.go @@ -2,11 +2,13 @@ package libv2ray import ( "context" + "encoding/json" "errors" "fmt" "io" "net" "net/http" + "os" "strconv" "strings" "time" @@ -102,11 +104,66 @@ func firstBalancerPrincipleTarget(inst *core.Instance, balancerTag string) (stri // GetUrlContent retrieves a URL through the requested outbound of the current core instance. func (x *CoreController) GetUrlContent(url string, outboundTag string) (string, error) { - if x.coreInstance == nil { - return "", errors.New("core instance is nil") + 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.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer func() { + if closeErr := file.Close(); err == nil && closeErr != nil { + err = fmt.Errorf("failed to close destination file: %w", closeErr) + } + if err != nil { + _ = os.Remove(filePath) + } + }() + + if _, err = io.Copy(file, resp.Body); err != nil { + return fmt.Errorf("failed to write response body: %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 "", errors.New("outbound tag is empty") + return nil, errors.New("outbound tag is empty") } tr := &http.Transport{ @@ -118,26 +175,34 @@ func (x *CoreController) GetUrlContent(url string, outboundTag string) (string, return nil, err } ctx = coresession.SetForcedOutboundTagToContext(ctx, outboundTag) - return core.Dial(ctx, x.coreInstance, dest) + return core.Dial(ctx, inst, dest) }, } - defer tr.CloseIdleConnections() + 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: 5 * time.Second}).Get(url) + resp, err := (&http.Client{Transport: tr, Timeout: timeout}).Do(req) if err != nil { - return "", err + return nil, err } - defer resp.Body.Close() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return "", fmt.Errorf("invalid status: %s", resp.Status) + resp.Body.Close() + return nil, fmt.Errorf("invalid status: %s", resp.Status) } - content, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response body: %w", err) - } - return string(content), nil + return resp, nil } // MeasureOutboundDelay measures the outbound delay for a given configuration and URL From d6074946f6e56d7aaa1602935005713e0f51a55f Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Sat, 22 Aug 2026 00:45:34 +0300 Subject: [PATCH 5/6] Make core downloads corruption-safe Write responses to a sibling temporary file and replace the destination only after a complete transfer. Reject unsolicited partial-content responses and verify declared response lengths so truncation or write failures leave existing files intact. --- libv2ray_utils.go | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/libv2ray_utils.go b/libv2ray_utils.go index 17187ae7..925e34c2 100644 --- a/libv2ray_utils.go +++ b/libv2ray_utils.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "os" + "path/filepath" "strconv" "strings" "time" @@ -134,22 +135,33 @@ func (x *CoreController) DownloadUrlToFile(url string, outboundTag string, heade } defer resp.Body.Close() - file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + file, err := os.CreateTemp(filepath.Dir(filePath), "."+filepath.Base(filePath)+".*") if err != nil { - return fmt.Errorf("failed to create destination file: %w", err) + return fmt.Errorf("failed to create temporary file: %w", err) } + temporaryPath := file.Name() + closed := false defer func() { - if closeErr := file.Close(); err == nil && closeErr != nil { - err = fmt.Errorf("failed to close destination file: %w", closeErr) - } - if err != nil { - _ = os.Remove(filePath) + if !closed { + _ = file.Close() } + _ = os.Remove(temporaryPath) }() - if _, err = io.Copy(file, resp.Body); err != nil { + 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 } @@ -197,7 +209,9 @@ func (x *CoreController) getURL(url string, outboundTag string, headersJSON stri return nil, err } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + 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) } From b4a7cadc8a6e4353452ccbb15ba9f0fe7968e9f4 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Sat, 5 Sep 2026 13:40:24 +0300 Subject: [PATCH 6/6] Fix custom Host headers in core downloads Go's HTTP client sends the request authority from Request.Host rather than from the ordinary header map. Apply Host overrides to that dedicated field while preserving normal handling for every other forwarded header. Cover both the authority override and an ordinary header with a focused regression test. --- libv2ray_utils.go | 14 +++++++++++--- libv2ray_utils_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 libv2ray_utils_test.go diff --git a/libv2ray_utils.go b/libv2ray_utils.go index 925e34c2..244fbca7 100644 --- a/libv2ray_utils.go +++ b/libv2ray_utils.go @@ -199,9 +199,7 @@ func (x *CoreController) getURL(url string, outboundTag string, headersJSON stri 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) - } + applyRequestHeaders(req, headers) } resp, err := (&http.Client{Transport: tr, Timeout: timeout}).Do(req) @@ -219,6 +217,16 @@ func (x *CoreController) getURL(url string, outboundTag string, headersJSON stri 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") + } +}