From ab44101fce3038b780907db116d15788be151eb6 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 3ac9062fcba18b3e27c81b6a00a8bee2e16c510d 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 4e640ec5dfa4f1ec0901db82fa55f85501baaab9 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 57011f5e7b6e78d9a5f5e60a8912cad25a434b2a 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 7116421cff9cd8cb29741c2a06e5a808361cc0fd 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 e35bd977fc33f0c7147eefa44af0eeda75544809 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") + } +}