diff --git a/AGENTS.md b/AGENTS.md index fe403bbe..883d63a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,215 +1,70 @@ -# Project Overview - -libXray is a Go wrapper around Xray-core for mobile and desktop applications. -It exposes one structured JSON entrypoint, provides share-link and GeoData -utilities, and builds native artifacts for Android, Apple platforms, Linux, and -Windows. - -The public Invoke contract is intentionally small. Platform applications should -construct typed request models, serialize them at the native boundary, and call -`Invoke` or `CGoInvoke`. Do not add platform-specific application behavior to -the generic API. - -# Repository Layout - -| Path | Purpose | -| --- | --- | -| `invoke.go` | Invoke request validation, method dispatch, and response encoding. | -| `invoke_model.go` | Public method enum and typed request/response models. | -| `xray/` | Xray instance lifecycle, configuration validation, and batch latency testing. | -| `share/` | Share-link parsing, validation, and generation. | -| `geo/` | GeoData inspection helpers. | -| `controller/` | Android socket protection and process lookup integration. | -| `dns/` | VPN-aware process DNS resolver and desktop interface binding. | -| `memory/` | Platform-specific memory-pressure handling. | -| `nodep/` | Small utilities that do not depend on the managed Xray instance. | -| `cgo_bridge/` | C ABI exports used by Apple, Linux, Windows, and Dart FFI. | -| `android_wrapper.go` | Android-only gomobile interfaces and controller registration. | -| `build/` | Cross-platform build scripts and artifact assembly. | -| `.github/workflows/` | CI builds and release artifact publication. | -| `README.md` | English integration documentation. | -| `readme/README.zh_CN.md` | Chinese integration documentation. | - -# Invoke API Contract - -The current API version is `2`. Requests using an omitted or different -`apiVersion` are rejected. - -```json -{ - "apiVersion": 2, - "method": "runXray", - "payload": { - "xrayJson": "{\"outbounds\":[...]}" - } -} -``` - -Every response uses the same envelope: - -```json -{ - "success": true, - "data": {}, - "error": "" -} -``` - -`data` must be a JSON object for successful methods that return data, `{}` for -successful methods without data, or `null` for failures without structured -failure data. Do not return scalar values directly from `data`. - -Supported methods: - -- `getFreePorts` -- `convertShareLinksToXrayJson` -- `convertXrayJsonToShareLinks` -- `generateAgeKeyPair` -- `countGeoData` -- `pingBatch` -- `testXray` -- `runXray` -- `stopXray` -- `xrayVersion` -- `getXrayState` - -Age-encrypted subscription support is part of the share boundary. libXray owns -native key generation, in-memory armor decryption, and parsing. Integrating -applications own HTTP headers, persistence of both generated keys, and refresh -behavior. Never log age secret keys, decrypted subscription text, or complete -Invoke requests containing those values. - -`pingBatch`, `testXray`, and `runXray` receive serialized Xray configuration -text through `xrayJson`. They must not accept or read an application-provided -configuration file path. `countGeoData` is the exception because it operates on -GeoData files directly and receives `datDir` in its payload. - -The complete UTF-8 Invoke request and response envelopes are limited to 16 MiB. -`pingBatch` accepts at most five configurations. It parses only `outbounds`, -ignores other root fields, and includes outbound dependencies referenced by -`streamSettings.sockopt.dialerProxy` or `proxySettings.tag`. - -# Runtime Semantics - -`runXray` manages one package-level Xray instance. A second `runXray` call fails -until `stopXray` closes the current instance. - -`testXray` and `pingBatch` create temporary Xray instances. Xray-core contains -process-wide state, including the system dialer DNS client and outbound manager. -Running temporary instances while another Xray instance is active may replace -that state, and closing a temporary instance does not restore it. libXray does -not serialize or isolate these calls. Integrators that require independent -concurrent instances must place them in separate processes. - -Xray runtime environment values belong in the root `env` object of `xrayJson`. -A top-level `env` field on the Invoke request is ignored. Missing root env fields -are governed by Xray-core behavior. - -# Platform Integration - -## C ABI - -`cgo_bridge/main.go` exports: - -```c -char* CGoInvoke(char* requestJSON); -void CGoFree(char* value); -``` - -`CGoInvoke` returns C-allocated memory. Every non-null response must be released -exactly once with `CGoFree`. Do not release it with a platform allocator or from -Go directly. - -## Android - -Android uses gomobile and produces `libXray.aar` plus -`libXray-sources.jar`. Android-only APIs include socket protection, process -lookup registration, `SetDNS`, and `ResetDNS`. - -`SetDNS` changes Go's process-wide resolver and requires a protected IP endpoint -such as `8.8.8.8:53`. Call `ResetDNS` after the managed Xray instance stops. -Keep Android-only code behind the `android` build tag. - -## Apple Platforms - -The CGo build produces `LibXray.xcframework` for iOS, iOS Simulator, macOS, -tvOS, and tvOS Simulator. Swift callers use `CGoInvoke` and `CGoFree`; the Xray -configuration and runtime TUN fd are supplied by the application through the -typed JSON contract. - -## Linux and Windows - -Linux produces `linux_so/libXray.so` and `bin/xray`; Windows produces -`windows_dll/libXray.dll` and `bin/xray.exe`. The libraries expose the C ABI. -The session Core accepts only `run -dns -interface -config -`, installs a process-wide protected Go resolver, and runs one Xray -instance until termination. - -# Building - -Build scripts use the Xray-core version pinned by `go.mod` by default. Linux -and Windows builds produce both the native library and session Core: - -```shell +# libXray + +Go wrapper around Xray-core for mobile and desktop clients. Keep platform-specific +App behavior out of the generic library. + +## API and runtime + +Before changing a method, read [README API](README.md#api), its method section, +and the models/dispatch in `invoke_model.go` and `invoke.go`. + +- Keep `LibXrayAPIVersion` fixed at `3`; do not increment it within this release. + Synchronize contract changes with typed models, downstream consumers, tests, + and both `README.md` and `readme/README.zh_CN.md`. +- Applications use `Invoke`/`CGoInvoke` with typed requests. Config methods receive + `xrayJson` text, not configuration file paths. Runtime `env` belongs inside + that Xray JSON. File-oriented APIs and the desktop Core CLI retain file access. +- `TestXray` only loads/builds configuration with `core.LoadConfig`; it neither + constructs nor starts an instance. Success does not guarantee startup or + connectivity. Builders may still read local assets/certificates and apply `env`. +- Manage one running instance. Validation and temporary instances must reject + managed-instance overlap before loading configuration and hold the lifecycle + lock through worker completion and instance cleanup. Close temporary instances + on every exit path; unmanaged overlaps require caller-owned process isolation. +- When changing [batch probes](README.md#pingbatch), preserve input order, + per-item failure isolation, and raw `locationJson`; provider parsing belongs + to the App. +- Before changing persistence or HTTP access, read + [managed runtime accounting](README.md#managed-runtime-accounting). + Save only the current session's inbound counters. Native metrics provides live + readings; runtime HTTP provides saved snapshots. The App owns totals and reset. +- When changing [age subscriptions](README.md#age-encrypted-subscriptions), + keep key generation/decryption in libXray and HTTP/persistence in the App. + Never log secret keys, decrypted subscriptions, or requests containing them. + +## Native integration and builds + +Before changing platform bridges or build scripts, read [build](README.md#build) +and the relevant platform/controller section in README. + +- Free each non-null `CGoInvoke` response exactly once with `CGoFree`. + Load only one independently built Go runtime per process. +- Keep Android-only APIs behind the `android` build tag. When changing DNS + integration, read [DNS resolver](README.md#dns-resolver): `SetDNS` affects the + process resolver and `ResetDNS` follows managed-instance shutdown. +- Use `build/main.py` to generate native artifacts; do not edit generated + headers, archives, or binaries. Verify temporary module edits are restored + after a build and check the build command's success and resulting artifacts. +- Modify an adjacent Xray-core checkout only when explicitly requested. + +Common builds: + +```sh python3 build/main.py android python3 build/main.py apple go -python3 build/main.py linux -python build/main.py windows -``` - -Apple also has a gomobile build path: - -```shell -python3 build/main.py apple gomobile ``` -To test an adjacent Xray-core checkout, place it at `../Xray-core` and append -`local`: - -```shell -python3 build/main.py android local -python3 build/main.py apple go local -``` +Other targets and local-core options are documented in [build usage](README.md#usage). -The build scripts temporarily adjust the Go module graph and restore `go.mod` -and `go.sum` when the build finishes. Generated native artifacts, downloaded -GeoData, and intermediate build directories are ignored by Git. Do not edit -generated headers, archives, frameworks, AARs, JARs, DLLs, or shared libraries -manually. +## Verification -# Development Rules - -1. Keep `Invoke` as the single cross-platform API entrypoint. Platform-only - controller APIs must remain isolated by build tags. -2. Define request and response fields as typed Go models in `invoke_model.go`. - Do not pass unstructured maps into package business logic. -3. Treat method names, JSON keys, response shapes, and `apiVersion` as a public - wire contract. Breaking changes require an API version increment and - synchronized integration documentation. -4. Xray configuration APIs accept `xrayJson` text, not file paths. File access - remains limited to APIs whose purpose is operating on files. -5. Keep the English and Chinese README API sections synchronized. -6. Preserve per-item ordering in `pingBatch`; one invalid configuration should - produce an item failure without discarding other accepted items. -7. Close every temporary Xray instance on success and error paths. Do not add - hidden serialization or state restoration that changes existing runtime - semantics. -8. Do not modify the adjacent Xray-core checkout as part of a libXray change - unless the task explicitly requires it. -9. Use `gofmt` for Go source and keep changes narrowly scoped to the owning - package. - -# Validation - -Run the checks appropriate to the change scope: - -```shell -gofmt -w -go test ./... -count=1 -git diff --check -``` +Run `git diff --check` for all changes. Match further verification to the change; +expand or repeat checks only for new changes, failures, or unresolved concerns. -Changes to build scripts or platform bridges should also build the affected -artifact. Changes to the Invoke wire contract must include dispatch/model tests, -unknown or removed method tests where relevant, response-shape tests, and -synchronized consumer model updates in downstream applications. +- Go changes: format changed files with `gofmt`, then `go test ./... -count=1`. +- Invoke changes: cover dispatch/models, response shapes, and removed methods + where relevant; verify downstream request models against the same contract. +- Bridge/build changes: build the affected artifact where supported. Report + unsupported targets or unbuilt artifacts explicitly. +- Documentation-only changes: check referenced paths/anchors; no Go tests or + native builds are needed. diff --git a/README.md b/README.md index bab9ab30..6f965e7c 100644 --- a/README.md +++ b/README.md @@ -66,15 +66,21 @@ python3 build/main.py windows local ``` +Builds restore `go.mod` and `go.sum` on success or failure. Gomobile builds +resolve `latest` by default; set `LIBXRAY_GOMOBILE_VERSION` to select a Go module +version. Both `gomobile` and `gobind` use that resolved version. + Linux and Windows builds also produce `bin/xray` or `bin/xray.exe`. This session Core protects Go DNS lookups from the VPN route and accepts only: ```shell -xray run -dns -interface -config +xray run -dns -interface -config [-runtime ] ``` All three options are required. `-dns` must be an IP endpoint, and `-config` points directly to the Xray JSON configuration. +Optional `-runtime` reads the host metadata object described under "Managed +runtime accounting", without a wrapping `runtime` key; it does not replace `-config`. > [!WARNING] > **Use only one Go runtime per process.** Go does not support loading multiple @@ -157,7 +163,7 @@ The request is a JSON object: ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -177,9 +183,10 @@ The response is a JSON object: Design notes: -1. Invoke currently accepts only `apiVersion: 2`. Xray configurations are - passed as UTF-8 JSON text in `xrayJson`; libXray does not read configuration - file paths. +1. Invoke accepts only `apiVersion: 3`; the API version remains fixed at 3. + Contract changes require synchronized consumers and documentation within + that version. Xray configurations are passed as UTF-8 JSON text in + `xrayJson`; libXray does not read configuration file paths. 2. A top-level `env` field is ignored and has no effect. Xray-core runtime environment options belong in the root `env` object of the Xray config. 3. `SetTunFd` has been removed. When the fd is only known at runtime, write @@ -198,16 +205,18 @@ Design notes: fields supported by libXray share links; unsupported and generated empty fields are omitted. Opaque XHTTP `extra` and FinalMask mask `settings` JSON remain unchanged. + Every successful response returns the projected config together with + `usableCount` and `failedCount`. Its optional `age.secretKey` decrypts official age ASCII armor in memory before the existing parser runs. Plaintext input remains unchanged. 7. Xray-core keeps its system dialer DNS client and outbound manager in - process-wide state. Creating another Xray instance through `pingBatch`, - `testXray`, or the exported Go APIs while `runXray` is active may replace - that state and affect the running - instance. Closing the temporary instance does not restore the previous - state. libXray does not serialize, isolate, or restore concurrent instances; - callers that require overlapping instances must place them in separate - processes. + process-wide state. `pingBatch`, `testXray`, and their exported Go + entrypoints take the managed lifecycle lock and reject an active `runXray` + instance before loading/building config. + A batch holds the lock through all workers and temporary-core close. This + also serializes these operations with one another. Instances + created outside the managed APIs are not detected or restored; callers + requiring overlap with them must still use separate processes. Supported methods: @@ -299,7 +308,8 @@ Get free ports. ## share -libXray uses `sendThrough` to store outbound names. +libXray stores outbound names in `tag`. `sendThrough` keeps its native Xray +meaning as the local bind address. ### clash_meta @@ -315,6 +325,28 @@ convert VMessAEAD/VLESS sharing protocol to Xray Json. convert VMessQRCode to Xray Json. +#### Parsing result + +`convertShareLinksToXrayJson` has one response shape. Its payload contains +`text` and optional `age`. Every successful conversion returns +`data: {"config":{"outbounds":[...]},"usableCount":2,"failedCount":1}`. + +Counts describe this input only, not added/changed nodes. Each root JSON +`outbounds` element or YAML `proxies` element is one candidate. In detected +share-link lists, each URI-like row is one candidate; blank lines, comments and +text headers are ignored. Base64 and age wrappers use the inner format's +candidates. Malformed individual elements are skipped without discarding other +valid elements. `usableCount` equals the final projected, buildable +outbound count; parse, build and unsupported-projection failures count toward +`failedCount`. No per-node hash comparison or deduplication is performed. + +A recognized container with zero usable nodes returns `success: false` with +structured counts and `config: {"outbounds":[]}`. An unrecognized format, +malformed whole document, invalid container or decryption failure returns +`data: null`; counts are not guessed. Error text never includes rejected +candidates or decrypted subscription text. Callers must not import/replace a +subscription when no usable nodes remain. + ### age-encrypted subscriptions `convertShareLinksToXrayJson` accepts an optional native age secret key. Only @@ -324,7 +356,7 @@ decrypted in memory and limited to 16 MiB of plaintext. ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -342,7 +374,7 @@ Generate a new keypair with `keyType` set to `x25519` or `hybrid`. An omitted ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -375,7 +407,7 @@ by the `proxy` tag, and finally by the first outbound. ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "pingBatch", "payload": { "configs": [ @@ -388,7 +420,8 @@ by the `proxy` tag, and finally by the first outbound. } ], "timeout": 5, - "url": "https://cp.cloudflare.com/" + "url": "https://cp.cloudflare.com/", + "locationUrl": "https://ip-check-perf.radar.cloudflare.com/" } } ``` @@ -399,19 +432,37 @@ fail before any configuration is tested. The top-level response succeeds when the batch itself was accepted. Each item has its own result; `delay` is `10000` for an error and `11000` for a timeout. +`delay` is always present, including a successful zero-millisecond result. The result array has the same length and order as the input config array. Outbound dependencies referenced by `streamSettings.sockopt.dialerProxy` or `proxySettings.tag` are included automatically. +`locationUrl` is optional and must be an absolute HTTP(S) URL. When omitted, +no location request is made and no location fields are returned. When supplied, +each prepared item sends its latency HEAD and then its location GET using the +same client forced through that item's selected outbound and dependencies. +Each request has the configured timeout (so an item may take up to twice it). +Location time is not included in `delay`, and the two results are independent: +`success`, `delay` and `error` describe latency only; a location failure does not +invalidate a successful latency result, and GET is still attempted after a +latency failure. + +A successful GET adds the unmodified response body as the `locationJson` +string. The App owns JSON parsing and provider-specific field handling. The +provider must return HTTP 200 and at most 64 KiB; transport or body-read +failures instead add `locationError`. Errors do not echo the URL, credentials +or response body. Invalid outbound configs retain their ordinary per-item +failure and do not perform either request. + ### testXray -Validates an Xray configuration from the supplied JSON text without reading a -configuration file: +Loads and builds the complete configuration from the supplied JSON text. The +payload contains only `xrayJson`; success returns `data: {}`: ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -419,11 +470,122 @@ configuration file: } ``` +The Go entrypoint `TestXray` uses `core.LoadConfig` without constructing or +starting an Xray instance or runtime handlers. It validates configuration +structure, including TUN/WireGuard definitions, without creating devices, +listeners, log files, or background connections. The builder can still read +local GeoData/certificates and apply the root `env` to the current process. +Geodata asset declarations validate HTTPS URLs and existing local files; their +downloader/cron does not run during validation. + +A successful check establishes that the configuration builds. It does not +prove that runtime resources are available, that an instance can start, or +that the network is reachable. Callers must handle actual startup failures. + ### runXray Starts the managed Xray instance from the supplied JSON text. Use `stopXray` to stop that instance. `runXrayFromJson` is no longer a separate method. +### Managed runtime accounting + +`runXray.payload.runtime` is optional API v3 host metadata. Omitting it +preserves the original lifecycle and writes no runtime snapshots. Hosts opt in +with this object (also the complete content of the desktop `-runtime` file): + +```json +{ + "statePath": "/private/app/run/runtime.json", + "inboundTag": "tunIn", + "listen": "127.0.0.1:49228", + "token": "538fc3253a3e433491bc2d653fc74214" +} +``` + +The host supplies an existing private directory and an absolute `statePath`. +`inboundTag` must be nonempty and at most 256 bytes. Metadata stays separate +from Xray JSON, so user configuration cannot override it. The named inbound +must exist, with uplink/downlink system statistics and a statistics manager enabled. +`listen` and `token` may both be omitted to save snapshots without HTTP. When +enabled, `listen` must be `127.0.0.1:` with port 1–65535, and the host must +generate a fresh random 32-character lowercase hex `token`. Keep it private; +do not reuse the example token. Invalid metadata, an occupied HTTP port, or an +initial save failure rejects startup; any constructed core and statistics +listener are closed. + +The saved file contains only the current session's raw inbound counter values: + +```json +{ + "version": 1, + "session": { + "id": "2a7e2e49b947a802d8b39af4fbc48f52", + "startedAtMs": 1788300000000, + "endedAtMs": 0, + "uplink": 120, + "downlink": 800 + }, + "available": true, + "sampledAtMs": 1788300030000, + "savedAtMs": 1788300030000, + "error": "" +} +``` + +Timestamps are Unix milliseconds. Each new start generates a random +32-character lowercase hex session ID, even when replaying identical metadata. +`endedAtMs: 0` means no final stop was saved; it is not proof that the VPN is +running. The host saves an initial snapshot, samples/saves every 30 seconds, +and attempts a final sample/save before closing the core on `stopXray`. + +Sampling reads the named inbound's `Value()`, never resets it and never adds +outbound/node counters. Repeated samples do not accumulate bytes. A nonnegative +counter rollback is recorded as the smaller raw value, not a synthetic delta. +Missing or negative counters set `available: false` and +`error: "counters_unavailable"`, retaining the last valid nonnegative values. +Idle valid counters report available zero. There are no application-wide totals, +reset generations, or VPN control HTTP methods. +`resetRuntime` is not an Invoke method. Applications may read existing Xray +metrics for live rates; their own totals/reset policy stays outside libXray. + +Starting a new session atomically replaces the previous `runtime.json`; libXray +does not archive or merge earlier sessions. Traffic not read by the App before +replacement is intentionally lost. Each session starts from zero and receives a +new ID. + +Snapshot files use a mode-0600 same-directory temporary file, sync, and atomic +replacement (Windows uses `MoveFileEx` with replace-existing and write-through). +The private parent directory/Windows ACL remains the host's responsibility. +Failed saves leave the previous complete disk snapshot for later retry; a final +save error is returned but never prevents core shutdown. An error after rename +can have an uncertain persistence outcome, so consumers must re-read saved +snapshots through HTTP when available. +This is reference data, not billing: crashes, forced termination, or replacement +before the App reads the file can lose traffic, with no strict loss bound. + +A nonblocking OS lock on `statePath + ".lock"` is held until core close, +preventing another process from writing the current session. +Hosts must use one consistent canonical path and leave the lock file in place. +App code reads snapshots through HTTP instead of opening the host's files, so +macOS System Extension files can remain root-owned. This does not provide +graceful final settlement when Windows forcibly terminates a job. + +#### Snapshot HTTP + +The optional statistics listener starts with the managed session and closes on +stop, including when the final save fails. It uses a separate loopback port +from Xray's native metrics; it provides no VPN start/stop/configuration methods. +Every request requires `Authorization: Bearer `. Responses use +`Cache-Control: no-store`; CORS is not enabled. + +- `GET /runtime` returns the current saved snapshot directly. + +Requests read the host's saved atomic snapshot without sampling, resetting +counters, or updating the save time. Use native metrics for live rates. A +missing, corrupt, or non-regular snapshot returns service unavailable. Requests +have bounded read/write timeouts. While stopped, HTTP is unavailable; libXray +never owns App totals or clear/reset policy. + ### metrics Refer to the following configuration: @@ -452,11 +614,8 @@ when `listen` is `127.0.0.1:49227`, read: http://localhost:49227/debug/vars ``` -Note: - -1. When testing latency or validating configuration, make sure `metrics` is `null`. - -2. Metrics only needs the `listen` field in this wrapper. Query `/debug/vars` directly with an HTTP client instead of going through libXray. +Metrics only needs the `listen` field in this wrapper. Query `/debug/vars` +directly with an HTTP client instead of going through libXray. ### validation diff --git a/build/app/android.py b/build/app/android.py index 9ab3c3bd..4d62b7f7 100644 --- a/build/app/android.py +++ b/build/app/android.py @@ -34,7 +34,4 @@ def build(self): if ret.returncode != 0: raise Exception("build failed") finally: - try: - self.after_build() - finally: - self.restore_go_env() + self.restore_go_env() diff --git a/build/app/apple_go.py b/build/app/apple_go.py index 38ff8c3a..bf4def26 100644 --- a/build/app/apple_go.py +++ b/build/app/apple_go.py @@ -125,10 +125,7 @@ def build(self): self.create_include_dir() self.create_framework() finally: - try: - self.after_build() - finally: - self.restore_go_env() + self.restore_go_env() def build_targets(self, targets: list[AppleTarget]): for target in targets: diff --git a/build/app/apple_gomobile.py b/build/app/apple_gomobile.py index 50819e51..38a6498b 100644 --- a/build/app/apple_gomobile.py +++ b/build/app/apple_gomobile.py @@ -29,7 +29,4 @@ def build(self): if ret.returncode != 0: raise Exception("build failed") finally: - try: - self.after_build() - finally: - self.restore_go_env() + self.restore_go_env() diff --git a/build/app/build.py b/build/app/build.py index 8d467f8e..d516d3d8 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -115,6 +115,7 @@ def download_geo(self): raise Exception("download_geo failed") def prepare_gomobile(self): + requested_version = os.environ.get("LIBXRAY_GOMOBILE_VERSION") or "latest" result = subprocess.run( [ "go", @@ -122,14 +123,14 @@ def prepare_gomobile(self): "-m", "-f", "{{.Version}}", - "golang.org/x/mobile@latest", + f"golang.org/x/mobile@{requested_version}", ], capture_output=True, text=True, ) version = result.stdout.strip() if result.returncode != 0 or not version: - raise Exception("resolve latest gomobile version failed") + raise Exception("resolve gomobile version failed") ret = subprocess.run( [ @@ -191,6 +192,3 @@ def before_build(self): def build(self): pass - - def after_build(self): - pass diff --git a/build/app/linux.py b/build/app/linux.py index c05130d2..79051b6b 100644 --- a/build/app/linux.py +++ b/build/app/linux.py @@ -26,10 +26,7 @@ def build(self): self.build_linux() self.build_desktop_bin(self.bin_file) finally: - try: - self.after_build() - finally: - self.restore_go_env() + self.restore_go_env() def build_linux(self): output_dir = self.framework_dir diff --git a/build/app/windows.py b/build/app/windows.py index 31d01127..88870111 100644 --- a/build/app/windows.py +++ b/build/app/windows.py @@ -26,10 +26,7 @@ def build(self): self.build_windows() self.build_desktop_bin(self.bin_file) finally: - try: - self.after_build() - finally: - self.restore_go_env() + self.restore_go_env() def build_windows(self): output_dir = self.framework_dir diff --git a/build/test_build.py b/build/test_build.py new file mode 100644 index 00000000..47d61033 --- /dev/null +++ b/build/test_build.py @@ -0,0 +1,74 @@ +"""Run: python3 build/test_build.py. No Go or platform build is run.""" +from pathlib import Path +import shutil +import subprocess +import unittest +from unittest.mock import call, patch +from uuid import uuid4 + +from app.android import AndroidBuilder + + +class BuildTest(unittest.TestCase): + def setUp(self): + self.root = ( + Path(__file__).resolve().parents[2] + / "references" + / "onexray-refactor-validation" + / "build-scripts" + / uuid4().hex + ) + (self.root / "build").mkdir(parents=True) + self.addCleanup(shutil.rmtree, self.root) + self.builder = AndroidBuilder(str(self.root / "build")) + + def test_build_restores_modules_on_success_and_failure(self): + for fails in (False, True): + with self.subTest(fails=fails): + (self.root / "go.mod").write_text("original module\n") + (self.root / "go.sum").write_text("original sums\n") + + def prepare(): + (self.root / "go.mod").write_text("effective module\n") + (self.root / "go.sum").write_text("effective sums\n") + if fails: + raise RuntimeError("original build failed") + + with ( + patch.object(self.builder, "before_build", side_effect=prepare), + patch("app.android.os.chdir"), + patch("app.android.subprocess.run", return_value=subprocess.CompletedProcess([], 0)), + ): + if fails: + with self.assertRaisesRegex(RuntimeError, "original build failed"): + self.builder.build() + else: + self.builder.build() + + self.assertEqual((self.root / "go.mod").read_text(), "original module\n") + self.assertEqual((self.root / "go.sum").read_text(), "original sums\n") + self.assertEqual(list((self.root / "build").iterdir()), []) + self.assertIsNone(self.builder._go_env_snapshot) + + def test_gomobile_and_gobind_use_the_same_resolved_version(self): + version = "v0.0.0-20260821190718-4776eadac327" + for requested in ("", version): + with self.subTest(requested=requested), patch.dict( + "app.build.os.environ", {"LIBXRAY_GOMOBILE_VERSION": requested} + ), patch( + "app.build.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, version + "\n", ""), + ) as run: + self.builder.prepare_gomobile() + self.assertEqual(run.call_args_list, [ + call(["go", "list", "-m", "-f", "{{.Version}}", + f"golang.org/x/mobile@{requested or 'latest'}"], + capture_output=True, text=True), + call(["go", "get", "-tool", f"golang.org/x/mobile/cmd/gobind@{version}"]), + call(["go", "install", f"golang.org/x/mobile/cmd/gomobile@{version}"]), + call(["gomobile", "init"]), + ]) + + +if __name__ == "__main__": + unittest.main() diff --git a/desktop_bin/main.go b/desktop_bin/main.go index 33ba90af..8ea0a6f6 100644 --- a/desktop_bin/main.go +++ b/desktop_bin/main.go @@ -3,6 +3,7 @@ package main import ( + "encoding/json" "errors" "flag" "fmt" @@ -19,6 +20,7 @@ type runOptions struct { dns string interfaceName string configPath string + runtimePath string } func parseRunOptions(args []string) (runOptions, error) { @@ -32,6 +34,7 @@ func parseRunOptions(args []string) (runOptions, error) { flags.StringVar(&options.dns, "dns", "", "DNS server IP endpoint") flags.StringVar(&options.interfaceName, "interface", "", "outbound network interface") flags.StringVar(&options.configPath, "config", "", "Xray JSON configuration path") + flags.StringVar(&options.runtimePath, "runtime", "", "optional host runtime metadata JSON path") if err := flags.Parse(args[1:]); err != nil { return options, err } @@ -49,12 +52,31 @@ func run(options runOptions) error { if err != nil { return err } + var runtime *xray.RuntimeConfig + if options.runtimePath != "" { + file, err := os.Open(options.runtimePath) + if err != nil { + return err + } + data, readErr := io.ReadAll(io.LimitReader(file, 64*1024+1)) + closeErr := file.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return err + } + if len(data) > 64*1024 { + return errors.New("runtime metadata exceeds 64 KiB") + } + runtime = new(xray.RuntimeConfig) + if err := json.Unmarshal(data, runtime); err != nil { + return errors.New("invalid runtime metadata") + } + } if err := dns.SetDNS(options.dns, options.interfaceName); err != nil { return err } defer dns.ResetDNS() - if err := xray.RunXray(string(config)); err != nil { + if err := xray.RunXrayWithRuntime(string(config), runtime); err != nil { return err } @@ -66,7 +88,7 @@ func run(options runOptions) error { } func printUsage() { - fmt.Fprintln(os.Stdout, "Usage: xray run -dns -interface -config ") + fmt.Fprintln(os.Stdout, "Usage: xray run -dns -interface -config [-runtime ]") } func main() { diff --git a/desktop_bin/main_test.go b/desktop_bin/main_test.go index 8d77f4d0..becb4cf8 100644 --- a/desktop_bin/main_test.go +++ b/desktop_bin/main_test.go @@ -10,6 +10,7 @@ func TestParseRunOptions(t *testing.T) { "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", `C:\run\xray.json`, + "-runtime", `C:\run\runtime.json`, }) if err != nil { t.Fatal(err) @@ -17,6 +18,9 @@ func TestParseRunOptions(t *testing.T) { if options.dns != "8.8.8.8:53" || options.interfaceName != "Ethernet" || options.configPath != `C:\run\xray.json` { t.Fatalf("unexpected options: %#v", options) } + if options.runtimePath != `C:\run\runtime.json` { + t.Fatalf("unexpected runtime path: %q", options.runtimePath) + } if _, err := parseRunOptions([]string{"run", "-config", "xray.json"}); err == nil { t.Fatal("missing DNS protection options were accepted") diff --git a/go.mod b/go.mod index d258b2fa..434c640d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/xtls/libxray -go 1.26.3 +go 1.27.1 require ( github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 diff --git a/invoke.go b/invoke.go index f2134fe3..4ae1a147 100644 --- a/invoke.go +++ b/invoke.go @@ -139,12 +139,8 @@ func invokeConvertShareLinksToXrayJson(payload json.RawMessage) string { if request.Age != nil { secretKey = request.Age.SecretKey } - config, err := share.ConvertShareLinksToXrayJsonWithAge(request.Text, secretKey) - if err != nil { - return encodeInvokeResponse(nil, err) - } - xrayJSON, err := share.MarshalShareConfigJSON(config) - return encodeInvokeResponse(xrayJSON, err) + result, err := share.ConvertShareLinksToXrayJson(request.Text, secretKey) + return encodeInvokeResponse(result, err) } func invokeGenerateAgeKeyPair(payload json.RawMessage) string { @@ -200,10 +196,11 @@ func invokePingBatch(payload json.RawMessage) string { } } - results, err := xray.PingBatch( + results, err := xray.PingBatchWithLocation( configs, request.Timeout, request.URL, + request.LocationURL, ) if err != nil { return encodeInvokeResponse(nil, err) @@ -212,9 +209,11 @@ func invokePingBatch(payload json.RawMessage) string { responseResults := make([]PingBatchItemResponse, len(results)) for i, result := range results { responseResults[i] = PingBatchItemResponse{ - Success: result.Success, - Delay: result.Delay, - Error: result.Error, + Success: result.Success, + Delay: result.Delay, + Error: result.Error, + LocationJSON: result.LocationJSON, + LocationError: result.LocationError, } } return encodeInvokeResponse(&PingBatchResponse{Results: responseResults}, nil) @@ -234,6 +233,6 @@ func invokeRunXray(payload json.RawMessage) string { if err != nil { return encodeInvokeNoDataResponse(err) } - err = xray.RunXray(request.XrayJson) + err = xray.RunXrayWithRuntime(request.XrayJson, request.Runtime) return encodeInvokeNoDataResponse(err) } diff --git a/invoke_model.go b/invoke_model.go index f317f0ea..05fb45b4 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -1,11 +1,16 @@ // libXray is an Xray wrapper focusing on improving the experience of Xray-core mobile development. package libXray -import "encoding/json" +import ( + "encoding/json" + + "github.com/xtls/libxray/share" + "github.com/xtls/libxray/xray" +) type LibXrayMethod string -const LibXrayAPIVersion = 2 +const LibXrayAPIVersion = 3 const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" @@ -44,6 +49,8 @@ type ConvertShareLinksToXrayJsonRequest struct { Age *AgeDecryptConfig `json:"age,omitempty"` } +type ConvertShareLinksToXrayJsonResponse = share.ConvertShareLinksResult + type AgeKeyType string const ( @@ -75,9 +82,10 @@ type CountGeoDataRequest struct { } type PingBatchRequest struct { - Configs []PingBatchItemRequest `json:"configs,omitempty"` - Timeout int `json:"timeout,omitempty"` - URL string `json:"url,omitempty"` + Configs []PingBatchItemRequest `json:"configs,omitempty"` + Timeout int `json:"timeout,omitempty"` + URL string `json:"url,omitempty"` + LocationURL string `json:"locationUrl,omitempty"` } type PingBatchItemRequest struct { @@ -90,15 +98,20 @@ type PingBatchResponse struct { } type PingBatchItemResponse struct { - Success bool `json:"success"` - Delay int64 `json:"delay,omitempty"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + Delay int64 `json:"delay"` + Error string `json:"error,omitempty"` + LocationJSON *string `json:"locationJson,omitempty"` + LocationError string `json:"locationError,omitempty"` } type RunXrayRequest struct { - XrayJson string `json:"xrayJson,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` + Runtime *RuntimeConfig `json:"runtime,omitempty"` } +type RuntimeConfig = xray.RuntimeConfig + type TestXrayRequest struct { XrayJson string `json:"xrayJson,omitempty"` } diff --git a/invoke_probes_test.go b/invoke_probes_test.go new file mode 100644 index 00000000..56f795f6 --- /dev/null +++ b/invoke_probes_test.go @@ -0,0 +1,93 @@ +package libXray + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestInvokeShareStatsResponseShape(t *testing.T) { + const validLink = "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443?encryption=none&security=tls&sni=example.com" + for _, test := range []struct { + text string + usable, failed int + success bool + }{ + {validLink + "\nvless://bad@example.com:443", 1, 1, true}, + {"vless://bad@example.com:443", 0, 1, false}, + } { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{Text: test.text}) + if response.Success != test.success { + t.Fatalf("success = %v, error = %s", response.Success, response.Err) + } + var result ConvertShareLinksToXrayJsonResponse + if err := json.Unmarshal(response.Data, &result); err != nil { + t.Fatal(err) + } + if result.UsableCount != test.usable || result.FailedCount != test.failed || len(result.Config) == 0 { + t.Fatalf("result = %+v", result) + } + var root map[string]json.RawMessage + if err := json.Unmarshal(response.Data, &root); err != nil { + t.Fatal(err) + } + if len(root) != 3 || root["config"] == nil || root["usableCount"] == nil || root["failedCount"] == nil { + t.Fatalf("data = %s", response.Data) + } + } + for _, text := range []string{`{"outbounds":`, "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid"} { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{Text: text}) + if response.Success || string(response.Data) != "null" { + t.Fatalf("response = %+v", response) + } + } +} + +func TestInvokePingLocationAndZeroDelayWireFields(t *testing.T) { + raw, err := json.Marshal(PingBatchItemResponse{Success: true, Delay: 0}) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"success":true,"delay":0}` { + t.Fatalf("zero latency response = %s", raw) + } + empty := "" + raw, err = json.Marshal(PingBatchItemResponse{Success: true, Delay: 0, LocationJSON: &empty}) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"success":true,"delay":0,"locationJson":""}` { + t.Fatalf("empty location response = %s", raw) + } + locationBody := `{"ip_address":"203.0.113.1","country":"SG"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, locationBody) + })) + defer server.Close() + request := PingBatchRequest{ + Configs: []PingBatchItemRequest{{XrayJson: `{"outbounds":[{"protocol":"freedom"}]}`}}, + Timeout: 1, URL: server.URL, LocationURL: server.URL, + } + response := invokeForTest(t, LibXrayMethodPingBatch, request) + if !response.Success { + t.Fatalf("error = %s", response.Err) + } + result := decodeDataObject[PingBatchResponse](t, response).Results[0] + if !result.Success || result.LocationJSON == nil || *result.LocationJSON != `{"ip_address":"203.0.113.1","country":"SG"}` { + t.Fatalf("result = %+v", result) + } + locationBody = "" + response = invokeForTest(t, LibXrayMethodPingBatch, request) + result = decodeDataObject[PingBatchResponse](t, response).Results[0] + if !response.Success || result.LocationJSON == nil || *result.LocationJSON != "" { + t.Fatalf("empty location response = %+v", response) + } + request.LocationURL = "" + response = invokeForTest(t, LibXrayMethodPingBatch, request) + if !response.Success || strings.Contains(string(response.Data), "location") { + t.Fatalf("latency-only response = %+v", response) + } +} diff --git a/invoke_test.go b/invoke_test.go index 78cd23f3..76527de0 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -77,6 +77,16 @@ func decodeDataObject[T any](t *testing.T, response testResponse) T { return value } +func decodeShareConfig(t *testing.T, response testResponse) (ConvertShareLinksToXrayJsonResponse, conf.Config) { + t.Helper() + result := decodeDataObject[ConvertShareLinksToXrayJsonResponse](t, response) + var config conf.Config + if err := json.Unmarshal(result.Config, &config); err != nil { + t.Fatal(err) + } + return result, config +} + func writeGeoSiteDatForTest(t *testing.T, path string) { t.Helper() data, err := proto.Marshal(&geodata.GeoSiteList{ @@ -206,6 +216,38 @@ func TestInvokeTestXray(t *testing.T) { requireNoDataObject(t, response) } +func TestInvokeTestXrayDoesNotCreateRuntimeResources(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "not-created", "error.log") + config, err := json.Marshal(map[string]any{ + "log": map[string]any{"error": logPath, "loglevel": "debug"}, + "inbounds": []any{ + map[string]any{"tag": "tunIn", "protocol": "tun", "settings": map[string]any{"name": "TestXrayMustNotCreate", "mtu": 1500}}, + }, + "outbounds": []any{ + map[string]any{"protocol": "wireguard", "settings": map[string]any{ + "secretKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "address": []string{"10.0.0.2/32"}, + "peers": []any{map[string]any{"publicKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "endpoint": "127.0.0.1:9"}}, + }}, + }, + }) + if err != nil { + t.Fatal(err) + } + response := invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: string(config)}) + if !response.Success { + t.Fatalf("testXray must accept structurally valid TUN/WireGuard without construction: %s", response.Err) + } + requireNoDataObject(t, response) + if _, err := os.Stat(filepath.Dir(logPath)); !os.IsNotExist(err) { + t.Fatalf("testXray created a runtime log directory: %v", err) + } + response = invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: `{"outbounds":[{"protocol":"unknown"}]}`}) + if response.Success || string(response.Data) != "null" { + t.Fatalf("testXray must still reject invalid core configuration: %+v", response) + } +} + func TestInvokeTestXrayDoesNotReadConfigPath(t *testing.T) { configPath := filepath.Join(t.TempDir(), "xray.json") configJSON, err := json.Marshal(testXrayConfig(t)) @@ -244,6 +286,43 @@ func TestInvokeRunXray(t *testing.T) { requireNoDataObject(t, response) } +func TestInvokeRunXrayRuntimeIsOptionalTypedMetadata(t *testing.T) { + defer xrayStopForTest(t) + request := RunXrayRequest{ + XrayJson: `{"log":{"loglevel":"none"},"outbounds":[{"protocol":"freedom"}]}`, + Runtime: &RuntimeConfig{ + StatePath: "relative.json", InboundTag: "tunIn", + }, + } + encoded, err := json.Marshal(request) + if err != nil || !strings.Contains(string(encoded), `"runtime":{"statePath":`) { + t.Fatalf("runtime metadata is missing from the request: %v", err) + } + response := invokeForTest(t, LibXrayMethodRunXray, request) + if response.Success || !strings.Contains(response.Err, "absolute statePath") { + t.Fatalf("runtime validation was bypassed: %+v", response) + } + response = invokeRawForTest(t, `{"apiVersion":3,"method":"runXray","payload":{"xrayJson":"{}","runtime":"invalid"}}`) + if response.Success { + t.Fatal("untyped runtime metadata was accepted") + } +} + +func TestInvokeRejectsRemovedRuntimeControl(t *testing.T) { + path := filepath.Join(t.TempDir(), "runtime.json") + data := []byte(`{"fixture":"must not change"}`) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + response := invokeForTest(t, LibXrayMethod("resetRuntime"), map[string]string{"statePath": path}) + if response.Success || response.Err != "unknown method" || string(response.Data) != "null" { + t.Fatalf("removed runtime control was accepted: %+v", response) + } + if saved, err := os.ReadFile(path); err != nil || !bytes.Equal(saved, data) { + t.Fatalf("unknown method modified a file: %s %v", saved, err) + } +} + func TestInvokeRunXrayAppliesConfigEnv(t *testing.T) { const key = "XRAY_LIBXRAY_CONFIG_ENV_TEST" t.Setenv(key, "") @@ -334,12 +413,18 @@ func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { if !response.Success { t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) } - config := decodeDataObject[conf.Config](t, response) + result, config := decodeShareConfig(t, response) + if result.UsableCount != 1 || result.FailedCount != 1 { + t.Fatalf("result = %+v, want 1 usable and 1 failed", result) + } if len(config.OutboundConfigs) != 1 { t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } - if config.OutboundConfigs[0].SendThrough == nil || *config.OutboundConfigs[0].SendThrough != validName { - t.Fatalf("sendThrough = %v, want %q", config.OutboundConfigs[0].SendThrough, validName) + if config.OutboundConfigs[0].Tag != validName { + t.Fatalf("tag = %q, want %q", config.OutboundConfigs[0].Tag, validName) + } + if config.OutboundConfigs[0].SendThrough != nil { + t.Fatalf("sendThrough = %v, want nil", config.OutboundConfigs[0].SendThrough) } } @@ -357,27 +442,26 @@ func TestInvokeConvertShareLinksReturnsProjectedObject(t *testing.T) { t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) } + result, config := decodeShareConfig(t, response) var root map[string]json.RawMessage - if err := json.Unmarshal(response.Data, &root); err != nil { - t.Fatalf("data is not an object: %s", response.Data) + if err := json.Unmarshal(result.Config, &root); err != nil { + t.Fatalf("config is not an object: %s", result.Config) } if len(root) != 1 || root["outbounds"] == nil { - t.Fatalf("data root = %s, want only outbounds", response.Data) + t.Fatalf("config root = %s, want only outbounds", result.Config) } for _, field := range []string{"publicKey", "target", "dest", "proxySettings", "sockopt"} { - if bytes.Contains(response.Data, []byte(`"`+field+`"`)) { - t.Fatalf("data contains unsupported field %q: %s", field, response.Data) + if bytes.Contains(result.Config, []byte(`"`+field+`"`)) { + t.Fatalf("config contains unsupported field %q: %s", field, result.Config) } } - if !bytes.Contains(response.Data, []byte(`"password":"`+publicKey+`"`)) { - t.Fatalf("data did not canonicalize REALITY password: %s", response.Data) + if !bytes.Contains(result.Config, []byte(`"password":"`+publicKey+`"`)) { + t.Fatalf("config did not canonicalize REALITY password: %s", result.Config) } - config := decodeDataObject[conf.Config](t, response) if len(config.OutboundConfigs) != 1 { t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } - config.OutboundConfigs[0].SendThrough = nil if _, err := config.OutboundConfigs[0].Build(); err != nil { t.Fatalf("projected outbound does not build: %v", err) } @@ -429,7 +513,10 @@ func TestInvokeAgeKeyGenerationAndConversion(t *testing.T) { if !converted.Success { t.Fatalf("ConvertShareLinksToXrayJson failed: %s", converted.Err) } - config := decodeDataObject[conf.Config](t, converted) + result, config := decodeShareConfig(t, converted) + if result.UsableCount != 1 || result.FailedCount != 0 { + t.Fatalf("result = %+v, want 1 usable and 0 failed", result) + } if len(config.OutboundConfigs) != 1 { t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } @@ -478,8 +565,9 @@ func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing. if !strings.Contains(response.Err, "no valid outbound found") { t.Fatalf("error = %q", response.Err) } - if got := string(response.Data); got != "null" { - t.Fatalf("data = %s, want null", got) + result, config := decodeShareConfig(t, response) + if result.UsableCount != 0 || result.FailedCount != 1 || len(config.OutboundConfigs) != 0 { + t.Fatalf("result = %+v, config = %+v", result, config) } } @@ -607,10 +695,10 @@ func TestInvokeUnknownMethod(t *testing.T) { } func TestInvokeRemovedMethods(t *testing.T) { - for _, method := range []string{"ping", "runXrayFromJson", "deriveAgePublicKey"} { + for _, method := range []string{"ping", "runXrayFromJson", "deriveAgePublicKey", "checkRoute"} { response := invokeRawForTest( t, - `{"apiVersion":2,"method":"`+method+`","payload":{}}`, + `{"apiVersion":3,"method":"`+method+`","payload":{}}`, ) if response.Success { t.Fatalf("removed method %q should fail", method) @@ -662,17 +750,19 @@ func TestInvokeAPIVersion(t *testing.T) { t.Fatal("omitted apiVersion should fail") } - response = invokeRawForTest(t, `{"apiVersion":1,"method":"xrayVersion"}`) - if response.Success { - t.Fatal("v1 apiVersion should fail") - } - if got := string(response.Data); got != "null" { - t.Fatalf("data = %s, want null", got) + for _, version := range []string{"2", "4", "5"} { + response = invokeRawForTest(t, `{"apiVersion":`+version+`,"method":"xrayVersion"}`) + if response.Success { + t.Fatalf("v%s apiVersion should fail", version) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } } - response = invokeRawForTest(t, `{"apiVersion":2,"method":"xrayVersion"}`) + response = invokeRawForTest(t, `{"apiVersion":3,"method":"xrayVersion"}`) if !response.Success { - t.Fatalf("v2 apiVersion should succeed: %s", response.Err) + t.Fatalf("v3 apiVersion should succeed: %s", response.Err) } } @@ -683,7 +773,7 @@ func TestInvokeNoDataResponseShape(t *testing.T) { } requireNoDataObject(t, response) - response = invokeRawForTest(t, `{"apiVersion":2,"method":"runXray","payload":"invalid"}`) + response = invokeRawForTest(t, `{"apiVersion":3,"method":"runXray","payload":"invalid"}`) if response.Success { t.Fatal("invalid runXray payload should fail") } @@ -696,7 +786,7 @@ func TestInvokeIgnoresTopLevelEnv(t *testing.T) { const key = "XRAY_LIBXRAY_UNKNOWN_ENV_TEST" _ = os.Unsetenv(key) t.Cleanup(func() { _ = os.Unsetenv(key) }) - requestJSON := `{"apiVersion":2,"method":"xrayVersion","env":{"` + key + `":"/tmp"}}` + requestJSON := `{"apiVersion":3,"method":"xrayVersion","env":{"` + key + `":"/tmp"}}` var response testResponse if err := json.Unmarshal([]byte(Invoke(requestJSON)), &response); err != nil { t.Fatal(err) diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 1c75b6fc..41d3f310 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -34,15 +34,19 @@ python3 build/main.py windows python3 build/main.py windows local ``` +构建成功或失败后都会恢复 `go.mod` 和 `go.sum`。gomobile 默认解析 `latest`, +也可通过环境变量 `LIBXRAY_GOMOBILE_VERSION` 指定 Go 模块版本;`gomobile` 与 +`gobind` 使用同一个解析版本。 + Linux 和 Windows 构建还会生成 `bin/xray` 或 `bin/xray.exe`。该会话 Core 会保护 Go DNS 查询不被 VPN 路由重新捕获,并且只接受以下命令: ```shell -xray run -dns -interface <网卡名> -config +xray run -dns -interface <网卡名> -config [-runtime ] ``` -三个参数都必须提供。`-dns` 必须是 IP endpoint,`-config` 直接指向 Xray -JSON 配置。 +前三个参数都必须提供。`-dns` 必须是 IP endpoint,`-config` 直接指向 Xray +JSON 配置。可选 `-runtime` 的 JSON 对象见“托管运行统计”,不含外层 `runtime`。 > [!WARNING] > **每个进程只能使用一个 Go runtime。** Go 不支持在同一进程中加载多个独立构建的 @@ -121,7 +125,7 @@ void CGoFree(char* value); ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -141,13 +145,13 @@ void CGoFree(char* value); 设计决定: -1. Invoke 当前只接受 `apiVersion: 2`。Xray 配置通过 `xrayJson` 传递 UTF-8 JSON 文本;libXray 不读取配置文件路径。 +1. Invoke 只接受 `apiVersion: 3`,API 版本固定为 3。合同变更在该版本内同步消费方与接入文档。Xray 配置通过 `xrayJson` 传递 UTF-8 JSON 文本;libXray 不读取配置文件路径。 2. 顶层 `env` 字段会被忽略且不会生效。Xray-core 运行时环境项应写入 Xray 配置根 `env` 对象。 3. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 4. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 5. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 -6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。Xray JSON 输入仅作为节点来源,只保留根级 `outbounds`,忽略其他根字段。响应仅包含 libXray 分享链接支持的字段,不支持的字段和生成的空字段会被省略;XHTTP `extra` 与 FinalMask mask `settings` 中的原始 JSON 保持不变。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 -7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。当 `runXray` 正在运行时,通过 `pingBatch`、`testXray` 或导出的 Go API 创建另一个 Xray instance,可能覆盖这些状态并影响正在运行的 instance。关闭临时 instance 不会恢复之前的状态。libXray 不对并发 instance 进行串行化、隔离或状态恢复;调用方如需同时运行多个 instance,必须将它们放在不同进程中。 +6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。Xray JSON 输入仅作为节点来源,只保留根级 `outbounds`,忽略其他根字段。响应仅包含 libXray 分享链接支持的字段,不支持的字段和生成的空字段会被省略;XHTTP `extra` 与 FinalMask mask `settings` 中的原始 JSON 保持不变。每次成功响应都会返回投影后的配置及 `usableCount` 和 `failedCount`。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 +7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。`pingBatch`、`testXray` 及对应导出的 Go 入口均取得受管理生命周期锁,在加载/构建配置前拒绝同进程已运行的 `runXray` instance。批量测速在全部 worker 和临时核心关闭后才释放锁,这些操作也彼此串行。由管理 API 之外创建的 instance 不在检测或恢复范围内;可能与它们重叠的调用仍须使用独立进程。 支持的 method: @@ -218,7 +222,7 @@ LibXray.resetDNS(); ## share -libXray 使用 `sendThrough` 来存储节点名称。 +libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义,用于指定本地绑定地址。 ### clash_meta @@ -234,6 +238,24 @@ libXray 使用 `sendThrough` 来存储节点名称。 转换 VMessQRCode 为 Xray Json。 +#### 解析结果 + +`convertShareLinksToXrayJson` 只有一种响应结构。payload 包含 `text` 和可选的 +`age`。每次转换成功均返回 +`data: {"config":{"outbounds":[...]},"usableCount":2,"failedCount":1}`。 + +数量只描述本次输入,不区分新增和更新。JSON 根 `outbounds` 的每个元素、YAML +`proxies` 的每个元素各算一个候选。已识别的分享链接列表中,每条 URI 形式的行 +算一个候选,空行、注释和文本标题忽略。Base64 / age 包装使用内部格式的候选 +数量。类型错误的单项会被跳过,不丢弃其余有效元素。`usableCount` 与最终投影且 +可构建的 outbound 数量相同;解析失败、构建失败和投影不支持的候选均计入 +`failedCount`。不做节点 hash 比较或去重。 + +已识别容器中没有可用节点时,返回 `success: false`,保留结构化数量和 +`config: {"outbounds":[]}`。无法识别格式、整份文档语法错误、容器错误或解密 +失败时返回 `data: null`,不猜测数量。错误文案不含被拒绝的候选或解密明文。 +调用方不得在可用节点为零时导入或覆盖订阅。 + ### age 加密订阅 `convertShareLinksToXrayJson` 接受可选的 age 原生私钥。仅支持 X25519 @@ -243,7 +265,7 @@ libXray 使用 `sendThrough` 来存储节点名称。 ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -261,7 +283,7 @@ libXray 使用 `sendThrough` 来存储节点名称。 ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -292,7 +314,7 @@ libXray 使用 `sendThrough` 来存储节点名称。 ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "pingBatch", "payload": { "configs": [ @@ -305,7 +327,8 @@ libXray 使用 `sendThrough` 来存储节点名称。 } ], "timeout": 5, - "url": "https://cp.cloudflare.com/" + "url": "https://cp.cloudflare.com/", + "locationUrl": "https://ip-check-perf.radar.cloudflare.com/" } } ``` @@ -316,16 +339,30 @@ libXray 使用 `sendThrough` 来存储节点名称。 批次请求本身被接受时,顶层 response 为成功;每个配置通过自己的结果表示成功或 失败。`delay` 为 `10000` 表示错误,`11000` 表示超时。结果数组与输入配置数组 长度相同且顺序一致。 +`delay` 始终输出,包含成功的 0 毫秒结果。 通过 `streamSettings.sockopt.dialerProxy` 或 `proxySettings.tag` 引用的 outbound 依赖会被自动包含。 +`locationUrl` 为可选的绝对 HTTP(S) 地址。省略时不请求位置、不返回位置字段; +传入时,每个完成准备的配置先执行测速 HEAD,再执行位置 GET,两者使用同一个 +强制经过当前所选 outbound 及其依赖的 client。每个请求各有一次配置的超时, +因此单项最多可能使用两倍超时时间。位置请求时间不计入 `delay`;两个结果独立: +`success`、`delay`、`error` 只表示延迟结果,位置失败不影响成功的延迟,延迟 +失败后仍尝试位置 GET。 + +GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析和数据源专属字段 +处理由 App 负责。数据源必须返回 HTTP 200,正文最大 64 KiB;传输或正文读取失败 +改为返回 `locationError`。错误不回显 URL、凭据或响应正文。无效 outbound 保留 +原有逐项失败结果,不发出这两个请求。 + ### testXray -直接校验传入的 Xray JSON 文本,不读取配置文件: +加载并构建传入的完整 Xray JSON 文本。payload 仅包含 `xrayJson`,成功时返回 +`data: {}`: ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -333,11 +370,100 @@ outbound 依赖会被自动包含。 } ``` +Go 入口 `TestXray` 只调用 `core.LoadConfig`,不构造或启动 Xray instance 及运行时 +handler。它校验包括 TUN/WireGuard 定义在内的配置结构,不创建设备、监听、日志文件 +或后台连接。构建器仍可能读取本地 GeoData/证书,并将根 `env` 应用到当前进程。 +Geodata assets 声明只校验 HTTPS URL 和已存在的本地文件,下载器及 cron 不在校验时运行。 + +校验成功只说明配置可以构建,不保证运行资源可用、instance 可以启动或网络可以连接。 +调用方仍须处理实际启动失败。 + ### runXray 使用传入的 Xray JSON 文本启动由 libXray 管理的 Xray instance,并通过 `stopXray` 停止。`runXrayFromJson` 不再作为独立 method 存在。 +### 托管运行统计 + +API v3 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留原生命周期, +不写运行快照。宿主传入以下对象;Desktop 的 `-runtime` 文件也直接使用此对象, +不含外层 `runtime`,原始 Xray 配置仍通过独立的 `-config` 传入。 + +```json +{ + "statePath": "/private/app/run/runtime.json", + "inboundTag": "tunIn", + "listen": "127.0.0.1:49228", + "token": "538fc3253a3e433491bc2d653fc74214" +} +``` + +宿主提供已存在的私有目录和绝对 `statePath`。`inboundTag` 非空且不超过 256 字节。 +元数据独立于 Xray JSON,用户配置不能覆盖。指定入站必须存在,并启用上下行系统统计 +和 stats manager。 +`listen` / `token` 可同时省略,保留仅落盘、不启用 HTTP 的行为。启用时 `listen` +只能是 `127.0.0.1:`,端口范围 1–65535;宿主须生成新的 32 位小写十六进制 +随机 `token` 并保密,不能复用示例值。元数据无效、HTTP 端口被占用或首次保存失败 +均拒绝启动,并关闭已构建的核心和统计监听器。 + +落盘文件仅包含本次会话的原始入站计数: + +```json +{ + "version": 1, + "session": { + "id": "2a7e2e49b947a802d8b39af4fbc48f52", + "startedAtMs": 1788300000000, + "endedAtMs": 0, + "uplink": 120, + "downlink": 800 + }, + "available": true, + "sampledAtMs": 1788300030000, + "savedAtMs": 1788300030000, + "error": "" +} +``` + +时间为 Unix 毫秒。每次新启动生成 32 位小写十六进制随机 session ID,即使重放相同 +元数据也不复用。`endedAtMs: 0` 只表示没有保存最终停止快照,不能用来判断 VPN +仍在运行。宿主启动时先保存新快照,此后每 30 秒采样保存,`stopXray` 在关闭核心前 +尽力完成最终采样保存。 + +采样直接读取指定入站的 `Value()`,不重置计数,不叠加节点或 outbound 计数。 +重复采样不累加字节;非负计数回退时保存实际较小值,不合成差额。计数缺失或为负时, +`available: false`、`error: "counters_unavailable"`,保留上次合法的非负值。 +有效入站尚无流量时为可用的 0。不维护 App 总量、重置代次,也不提供 VPN 控制 HTTP +方法。`resetRuntime` 不是 Invoke method。App 可通过已有 Xray metrics +读取实时速率;App 累计与重置策略由 App 自行管理,不属于 libXray。 + +启动新会话时会原子覆盖之前的 `runtime.json`;libXray 不归档或合并旧会话。 +若 App 未在覆盖前读取流量,该数据将直接丢失。每个会话都从零开始,并生成新的 ID。 + +快照文件使用同目录 0600 临时文件,sync 后原子替换;Windows 使用 +`MoveFileEx` 的替换和 write-through 标志。私有父目录/Windows ACL 由宿主管理。 +保存失败保留上次完整磁盘快照供后续重试;最终保存失败向调用方报告,但仍关闭核心。 +rename 后发生 I/O 错误时结果可能不确定,消费者应在 HTTP 可用时重新读取已保存的快照。这是参考数据, +不是计费账本:崩溃、强杀或 App 读取前被新会话覆盖都可能丢失流量,不承诺严格的 +丢失上限。 + +`statePath + ".lock"` 的非阻塞操作系统文件锁保持至核心关闭,防止跨进程同时 +改写当前会话。宿主须使用一致的规范路径并保留锁文件。App 经 HTTP 读取快照, +无需打开宿主文件,因此 macOS System Extension 文件可继续归 root 所有。此能力 +不能让 Windows Job 强制终止获得正常最终结算。 + +#### 快照 HTTP + +可选统计监听器随托管会话启动,在停止时关闭,最终保存失败也会关闭。它使用独立于 +Xray 原生 metrics 的回环端口,不提供 VPN 启停或配置方法。所有请求必须携带 +`Authorization: Bearer `;响应使用 `Cache-Control: no-store`,不启用 CORS。 + +- `GET /runtime` 直接返回当前已保存的快照。 + +请求只读取宿主已保存的原子快照,不触发采样、计数重置或保存时间更新;实时速率仍使用 +原生 metrics。快照缺失、损坏或不是常规文件时返回服务不可用。请求有读写超时限制。 +停止期间 HTTP 不可用;libXray 不维护 App 累计值或清零策略。 + ### metrics 统计。 @@ -368,10 +494,7 @@ metrics 服务通过 HTTP 暴露 Xray 运行时计数。例如 `listen` 为 http://localhost:49227/debug/vars ``` -注意: - -1. 当进行测试延迟或验证配置时,确保 `metrics` 为 `null`。 -2. libXray 这里只需要 `listen` 字段。直接用 HTTP 客户端查询 `/debug/vars`,不再通过 libXray 包装。 +libXray 这里只需要 `listen` 字段。直接用 HTTP 客户端查询 `/debug/vars`,不再通过 libXray 包装。 ### validation diff --git a/share/age.go b/share/age.go index 1057911f..ee2c8208 100644 --- a/share/age.go +++ b/share/age.go @@ -7,7 +7,6 @@ import ( "github.com/metacubex/age" "github.com/metacubex/age/armor" - "github.com/xtls/xray-core/infra/conf" ) const ( @@ -62,40 +61,36 @@ func GenerateAgeKeyPair(keyType AgeKeyType) (*AgeKeyPair, error) { } } -func ConvertShareLinksToXrayJsonWithAge(links, secretKey string) (*conf.Config, error) { +func decryptShareText(links, secretKey string) (string, bool, error) { text := strings.TrimSpace(FixWindowsReturn(links)) if !strings.HasPrefix(text, ageArmorHeader) { - return ConvertShareLinksToXrayJson(links) + return links, false, nil } if strings.TrimSpace(secretKey) == "" { - return nil, ErrAgeSecretKeyMissing + return "", true, ErrAgeSecretKeyMissing } identity, _, err := parseNativeAgeIdentity(secretKey) if err != nil { - return nil, err + return "", true, err } reader, err := age.Decrypt(armor.NewReader(strings.NewReader(text)), identity) if err != nil { var noMatch *age.NoIdentityMatchError if errors.As(err, &noMatch) { - return nil, ErrAgeDecryptFailed + return "", true, ErrAgeDecryptFailed } - return nil, ErrAgeArmorMalformed + return "", true, ErrAgeArmorMalformed } plaintext, err := io.ReadAll(io.LimitReader(reader, maxAgePlaintextBytes+1)) if err != nil { - return nil, ErrAgeArmorMalformed + return "", true, ErrAgeArmorMalformed } if len(plaintext) > maxAgePlaintextBytes { - return nil, ErrAgePlaintextTooLarge + return "", true, ErrAgePlaintextTooLarge } - config, err := ConvertShareLinksToXrayJson(string(plaintext)) - if err != nil { - return nil, ErrAgePlaintextUnsupported - } - return config, nil + return string(plaintext), true, nil } func parseNativeAgeIdentity(secretKey string) (age.Identity, age.Recipient, error) { diff --git a/share/age_test.go b/share/age_test.go index 9b14a701..77abc171 100644 --- a/share/age_test.go +++ b/share/age_test.go @@ -39,12 +39,12 @@ func TestGenerateAgeKeyPairRejectsUnsupportedType(t *testing.T) { } } -func TestConvertShareLinksToXrayJsonWithAgePlaintext(t *testing.T) { +func TestConvertShareLinksToXrayJsonPlaintext(t *testing.T) { pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) if err != nil { t.Fatal(err) } - config, err := ConvertShareLinksToXrayJsonWithAge(ageTestShareLink, pair.SecretKey) + config, err := convertShareLinksWithKeyForTest(ageTestShareLink, pair.SecretKey) if err != nil { t.Fatal(err) } @@ -53,7 +53,7 @@ func TestConvertShareLinksToXrayJsonWithAgePlaintext(t *testing.T) { } } -func TestConvertShareLinksToXrayJsonWithAgeEncrypted(t *testing.T) { +func TestConvertShareLinksToXrayJsonEncrypted(t *testing.T) { for _, keyType := range []AgeKeyType{AgeKeyTypeX25519, AgeKeyTypeHybrid} { t.Run(string(keyType), func(t *testing.T) { pair, err := GenerateAgeKeyPair(keyType) @@ -61,7 +61,7 @@ func TestConvertShareLinksToXrayJsonWithAgeEncrypted(t *testing.T) { t.Fatal(err) } armored := encryptAgeForTest(t, pair, ageTestShareLink) - config, err := ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + config, err := convertShareLinksWithKeyForTest(armored, pair.SecretKey) if err != nil { t.Fatal(err) } @@ -72,14 +72,14 @@ func TestConvertShareLinksToXrayJsonWithAgeEncrypted(t *testing.T) { } } -func TestConvertShareLinksToXrayJsonWithAgeErrors(t *testing.T) { +func TestConvertShareLinksToXrayJsonErrors(t *testing.T) { pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) if err != nil { t.Fatal(err) } armored := encryptAgeForTest(t, pair, ageTestShareLink) - _, err = ConvertShareLinksToXrayJsonWithAge(armored, "") + _, err = convertShareLinksWithKeyForTest(armored, "") if !errors.Is(err, ErrAgeSecretKeyMissing) { t.Fatalf("missing key error = %v", err) } @@ -88,7 +88,7 @@ func TestConvertShareLinksToXrayJsonWithAgeErrors(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = ConvertShareLinksToXrayJsonWithAge(armored, wrongPair.SecretKey) + _, err = convertShareLinksWithKeyForTest(armored, wrongPair.SecretKey) if !errors.Is(err, ErrAgeDecryptFailed) { t.Fatalf("wrong key error = %v", err) } @@ -96,13 +96,13 @@ func TestConvertShareLinksToXrayJsonWithAgeErrors(t *testing.T) { t.Fatal("decryption error contains the secret key") } - _, err = ConvertShareLinksToXrayJsonWithAge(ageArmorHeader+"\ninvalid", pair.SecretKey) + _, err = convertShareLinksWithKeyForTest(ageArmorHeader+"\ninvalid", pair.SecretKey) if !errors.Is(err, ErrAgeArmorMalformed) { t.Fatalf("malformed armor error = %v", err) } } -func TestConvertShareLinksToXrayJsonWithAgeRejectsLargePlaintext(t *testing.T) { +func TestConvertShareLinksToXrayJsonRejectsLargePlaintext(t *testing.T) { pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) if err != nil { t.Fatal(err) @@ -112,20 +112,20 @@ func TestConvertShareLinksToXrayJsonWithAgeRejectsLargePlaintext(t *testing.T) { pair, strings.Repeat("x", maxAgePlaintextBytes+1), ) - _, err = ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + _, err = convertShareLinksWithKeyForTest(armored, pair.SecretKey) if !errors.Is(err, ErrAgePlaintextTooLarge) { t.Fatalf("large plaintext error = %v", err) } } -func TestConvertShareLinksToXrayJsonWithAgeSanitizesParserErrors(t *testing.T) { +func TestConvertShareLinksToXrayJsonSanitizesParserErrors(t *testing.T) { pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) if err != nil { t.Fatal(err) } sensitivePlaintext := "unsupported://user:password@example.com" armored := encryptAgeForTest(t, pair, sensitivePlaintext) - _, err = ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + _, err = convertShareLinksWithKeyForTest(armored, pair.SecretKey) if !errors.Is(err, ErrAgePlaintextUnsupported) { t.Fatalf("unsupported plaintext error = %v", err) } diff --git a/share/clash_meta.go b/share/clash_meta.go index 315f9ae7..d746d060 100644 --- a/share/clash_meta.go +++ b/share/clash_meta.go @@ -3,17 +3,11 @@ package share import ( "fmt" - "gopkg.in/yaml.v3" - "github.com/xtls/xray-core/infra/conf" ) // https://github.com/MetaCubeX/mihomo/blob/Alpha/docs/config.yaml -type ClashYaml struct { - Proxies []ClashProxy `yaml:"proxies,omitempty"` -} - type ClashProxy struct { Name string `yaml:"name,omitempty"` Type string `yaml:"type,omitempty"` @@ -128,71 +122,20 @@ type ClashProxyXhttpOptsDownloadSettings struct { ClientFingerprint string `yaml:"client-fingerprint,omitempty"` } -func tryToParseClashYaml(text string) (*conf.Config, error) { - var clash ClashYaml - if err := yaml.Unmarshal([]byte(text), &clash); err != nil { - return nil, err - } - config := clash.toXrayConfig() - if len(config.OutboundConfigs) == 0 { - return nil, fmt.Errorf("no valid outbound found") - } - return config, nil -} - -func (clash ClashYaml) toXrayConfig() *conf.Config { - outbounds := make([]conf.OutboundDetourConfig, 0, len(clash.Proxies)) - for _, proxy := range clash.Proxies { - outbound, err := proxy.outbound() - if err != nil { - continue - } - outbounds = append(outbounds, *outbound) - } - return &conf.Config{OutboundConfigs: outbounds} -} - func (proxy ClashProxy) outbound() (*conf.OutboundDetourConfig, error) { switch proxy.Type { case "ss": - outbound, err := proxy.shadowsocksOutbound() - if err != nil { - return nil, err - } - return outbound, nil - + return proxy.shadowsocksOutbound() case "vmess": - outbound, err := proxy.vmessOutbound() - if err != nil { - return nil, err - } - return outbound, nil - + return proxy.vmessOutbound() case "vless": - outbound, err := proxy.vlessOutbound() - if err != nil { - return nil, err - } - return outbound, nil - + return proxy.vlessOutbound() case "socks5": - outbound, err := proxy.socksOutbound() - if err != nil { - return nil, err - } - return outbound, nil + return proxy.socksOutbound() case "trojan": - outbound, err := proxy.trojanOutbound() - if err != nil { - return nil, err - } - return outbound, nil + return proxy.trojanOutbound() case "hysteria2": - outbound, err := proxy.hysteria2Outbound() - if err != nil { - return nil, err - } - return outbound, nil + return proxy.hysteria2Outbound() } return nil, fmt.Errorf("unsupported proxy type: %s", proxy.Type) } diff --git a/share/clash_meta_test.go b/share/clash_meta_test.go index 9563f448..d24b0e2d 100644 --- a/share/clash_meta_test.go +++ b/share/clash_meta_test.go @@ -22,15 +22,17 @@ func clashHysteria2YAML(fields string) string { func parseClashHy2(t *testing.T, yaml string) *conf.OutboundDetourConfig { t.Helper() - config, err := tryToParseClashYaml(yaml) + config, err := parseShareCandidatesForTest(yaml) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) + assert.Equal(t, "test-hy2", config.OutboundConfigs[0].Tag) + assert.Nil(t, config.OutboundConfigs[0].SendThrough) return &config.OutboundConfigs[0] } func parseClashYAML(t *testing.T, yaml string) *conf.Config { t.Helper() - cfg, err := tryToParseClashYaml(yaml) + cfg, err := parseShareCandidatesForTest(yaml) require.NoError(t, err) return cfg } diff --git a/share/convert_share.go b/share/convert_share.go new file mode 100644 index 00000000..74c19b73 --- /dev/null +++ b/share/convert_share.go @@ -0,0 +1,120 @@ +package share + +import ( + "encoding/json" + "errors" + "net/url" + "strings" + + "github.com/xtls/xray-core/infra/conf" + "gopkg.in/yaml.v3" +) + +// ConvertShareLinksResult counts source candidates, not lines or changes to a subscription. +// Config contains exactly the projected, buildable outbounds counted as usable. +type ConvertShareLinksResult struct { + Config json.RawMessage `json:"config"` + UsableCount int `json:"usableCount"` + FailedCount int `json:"failedCount"` +} + +// ConvertShareLinksToXrayJson parses share links or an Age-encrypted subscription. +// A recognized candidate container with no usable nodes returns both its counts +// and an error. Whole-document/decryption failures return no invented counts. +func ConvertShareLinksToXrayJson(links, secretKey string) (*ConvertShareLinksResult, error) { + text, encrypted, err := decryptShareText(links, secretKey) + if err != nil { + return nil, err + } + config, candidates, err := parseShareCandidates(text, true) + if err != nil { + if encrypted { + return nil, ErrAgePlaintextUnsupported + } + return nil, err + } + result := &ConvertShareLinksResult{Config: json.RawMessage(`{"outbounds":[]}`), FailedCount: candidates} + config, err = filterBuildableOutbounds(config) + if err == nil { + var raw json.RawMessage + var usable int + raw, usable, err = marshalShareConfigJSON(config) + if err == nil { + result.Config, result.UsableCount, result.FailedCount = raw, usable, candidates-usable + return result, nil + } + } + if encrypted { + return result, ErrAgePlaintextUnsupported + } + // Builder errors can contain credentials or whole source values. Counts do + // not require those diagnostics; never echo rejected candidates. + return result, errors.New("no valid outbound found") +} + +func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, int, error) { + text := strings.TrimSpace(FixWindowsReturn(links)) + config := &conf.Config{} + if strings.HasPrefix(text, "{") { + var document struct { + Outbounds []json.RawMessage `json:"outbounds"` + } + if err := json.Unmarshal([]byte(text), &document); err != nil || document.Outbounds == nil { + return nil, 0, errors.New("invalid share JSON outbounds") + } + for _, raw := range document.Outbounds { + var outbound conf.OutboundDetourConfig + if err := json.Unmarshal(raw, &outbound); err == nil { + config.OutboundConfigs = append(config.OutboundConfigs, outbound) + } + } + return config, len(document.Outbounds), nil + } + if hasShareSchemeLine(text) { + candidates := 0 + for raw := range strings.SplitSeq(text, "\n") { + line := strings.TrimSpace(raw) + // Subscription comments/headers are not node candidates. A URI-like + // row is one candidate, including an unsupported or malformed URI. + scheme, _, found := strings.Cut(line, "://") + if !found || strings.ContainsAny(scheme, " \t#") { + continue + } + candidates++ + parsed, err := url.Parse(line) + if err != nil { + continue + } + outbound, err := (xrayShareLink{link: parsed, rawText: line}).outbound() + if err == nil { + config.OutboundConfigs = append(config.OutboundConfigs, *outbound) + } + } + return config, candidates, nil + } + if allowBase64 { + if decoded, err := decodeBase64Text(text); err == nil { + return parseShareCandidates(decoded, false) + } + } + if hasTopLevelClashProxiesKey(text) { + var document struct { + Proxies []yaml.Node `yaml:"proxies"` + } + if err := yaml.Unmarshal([]byte(text), &document); err != nil || document.Proxies == nil { + return nil, 0, errors.New("invalid share YAML proxies") + } + for _, node := range document.Proxies { + var proxy ClashProxy + if err := node.Decode(&proxy); err != nil { + continue + } + outbound, err := proxy.outbound() + if err == nil { + config.OutboundConfigs = append(config.OutboundConfigs, *outbound) + } + } + return config, len(document.Proxies), nil + } + return nil, 0, errors.New("unsupported share format") +} diff --git a/share/convert_share_test.go b/share/convert_share_test.go new file mode 100644 index 00000000..7a96fc91 --- /dev/null +++ b/share/convert_share_test.go @@ -0,0 +1,129 @@ +package share + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +const statsValidOutbound = `{"protocol":"vless","tag":"Keep","settings":{"address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"},"streamSettings":{"security":"tls","tlsSettings":{"serverName":"example.com"}}}` + +func TestConvertShareLinksCountActualCandidates(t *testing.T) { + jsonText := `{"outbounds":[` + statsValidOutbound + `,{"protocol":"freedom"},{"protocol":42},null,{"protocol":"vless","settings":{"id":"invalid"}}]}` + yamlText := `proxies: + - {name: Keep, type: vless, server: example.com, port: 443, uuid: 12345678-abcd-abcd-abcd-123456789abc, tls: true, servername: example.com} + - {name: Unsupported, type: unknown} + - {name: InvalidPort, type: vless, port: invalid} + - {name: InvalidId, type: vless, server: example.com, port: 443, uuid: invalid} + - null +` + for _, test := range []struct { + name, text string + usable, failed int + }{ + {"links with headers", "Subscription export\n# comment\n\n" + ageTestShareLink + "\nvless://bad@example.com:443?encryption=none\nunknown://example.com", 1, 2}, + {"JSON elements", jsonText, 1, 4}, + {"YAML elements", yamlText, 1, 4}, + {"base64 JSON", base64.StdEncoding.EncodeToString([]byte(jsonText)), 1, 4}, + {"base64 YAML", base64.RawURLEncoding.EncodeToString([]byte(yamlText)), 1, 4}, + {"base64 links", base64.StdEncoding.EncodeToString([]byte(ageTestShareLink + "\nvless://bad@example.com:443")), 1, 1}, + } { + t.Run(test.name, func(t *testing.T) { + result, err := ConvertShareLinksToXrayJson(test.text, "") + if err != nil { + t.Fatal(err) + } + assertShareStats(t, result, test.usable, test.failed) + }) + } +} + +func TestConvertShareLinksAllInvalidRetainsCountsWithoutSource(t *testing.T) { + for _, input := range []string{ + `{"outbounds":[{"protocol":"freedom"}]}`, + "vless://secret-not-a-uuid@example.com:443?encryption=none", + "proxies:\n - {type: unsupported, password: private-password}", + } { + result, err := ConvertShareLinksToXrayJson(input, "") + if err == nil || err.Error() != "no valid outbound found" { + t.Fatalf("error = %v", err) + } + assertShareStats(t, result, 0, 1) + } + result, err := ConvertShareLinksToXrayJson(`{"outbounds":[]}`, "") + if err == nil { + t.Fatal("empty array succeeded") + } + assertShareStats(t, result, 0, 0) +} + +func TestConvertShareLinksMalformedDocumentHasNoCounts(t *testing.T) { + for _, input := range []string{ + `{"outbounds":[`, `{"outbounds":"private-source"}`, `{"outbounds":null}`, + "proxies: [", "proxies: private-source", "not a subscription", + } { + result, err := ConvertShareLinksToXrayJson(input, "") + if err == nil || result != nil { + t.Fatalf("result = %+v, error = %v", result, err) + } + if strings.Contains(err.Error(), "private-source") { + t.Fatal("error leaked source") + } + } +} + +func TestConvertShareLinksAgeCountsInnerCandidatesAndRedactsErrors(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + for _, input := range []string{ + ageTestShareLink + "\nvless://secret-not-a-uuid@example.com:443", + `{"outbounds":[` + statsValidOutbound + `,{"protocol":false}]}`, + } { + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) + if err != nil { + t.Fatal(err) + } + assertShareStats(t, result, 1, 1) + } + for _, test := range []struct { + text string + hasCounts bool + }{ + {"vless://secret-not-a-uuid@example.com:443", true}, + {`{"outbounds":"private-source"}`, false}, + } { + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, test.text), pair.SecretKey) + if err != ErrAgePlaintextUnsupported { + t.Fatalf("error = %v", err) + } + if (result != nil) != test.hasCounts { + t.Fatalf("counts = %+v", result) + } + if result != nil { + assertShareStats(t, result, 0, 1) + } + } + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, ageTestShareLink), "private-invalid-key") + if err != ErrAgeSecretKeyInvalid || result != nil { + t.Fatalf("result = %+v, error = %v", result, err) + } +} + +func assertShareStats(t *testing.T, result *ConvertShareLinksResult, usable, failed int) { + t.Helper() + if result == nil || result.UsableCount != usable || result.FailedCount != failed { + t.Fatalf("stats = %+v, want usable %d failed %d", result, usable, failed) + } + var config struct { + Outbounds []json.RawMessage `json:"outbounds"` + } + if err := json.Unmarshal(result.Config, &config); err != nil { + t.Fatal(err) + } + if config.Outbounds == nil || len(config.Outbounds) != usable { + t.Fatalf("config count = %d, want %d", len(config.Outbounds), usable) + } +} diff --git a/share/generate_share_test.go b/share/generate_share_test.go index f8b8e21e..01ba4a81 100644 --- a/share/generate_share_test.go +++ b/share/generate_share_test.go @@ -123,7 +123,7 @@ func TestGenerate_Hy2_WithFullTLSParams(t *testing.T) { func TestGenerate_Hy2_RoundTrip(t *testing.T) { original := "hy2://auth@host:443?up=50+mbps&down=100+mbps&obfs=salamander&obfs-password=secret&ports=20000-40000&hop-interval=30&sni=example.com&alpn=h3&fp=chrome" - config, err := ConvertShareLinksToXrayJson(original) + config, err := convertShareLinksForTest(original) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) @@ -158,14 +158,14 @@ func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { } for _, link := range cases { t.Run(link[:12], func(t *testing.T) { - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) out, err := json.Marshal(cfg) require.NoError(t, err) text, err := ConvertXrayJsonToShareLinks(out) require.NoError(t, err) assert.NotEmpty(t, text) - again, err := ConvertShareLinksToXrayJson(text) + again, err := convertShareLinksForTest(text) require.NoError(t, err) require.Len(t, again.OutboundConfigs, 1) assert.Equal(t, cfg.OutboundConfigs[0].Protocol, again.OutboundConfigs[0].Protocol) @@ -174,7 +174,7 @@ func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { } func TestGenerate_KCPIgnoresSeedAndHeader(t *testing.T) { - config, err := ConvertShareLinksToXrayJson( + config, err := convertShareLinksForTest( "vless://" + testShareUUID + "@kcp.example:443?encryption=none&type=kcp", ) require.NoError(t, err) @@ -198,7 +198,7 @@ func TestGenerate_ShadowsocksAEAD2022PlainUserInfo(t *testing.T) { "YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D" + "@192.168.100.1:8888#Example3" - config, err := ConvertShareLinksToXrayJson(original) + config, err := convertShareLinksForTest(original) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) @@ -211,7 +211,7 @@ func TestGenerate_ShadowsocksLegacyBase64UserInfo(t *testing.T) { original := "ss://" + ssUserB64("aes-128-gcm", "password") + "@ss.example.com:8388#Legacy" - config, err := ConvertShareLinksToXrayJson(original) + config, err := convertShareLinksForTest(original) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) @@ -264,12 +264,12 @@ func TestConvertXrayJsonToShareLinksSkipsUnsupportedOutbounds(t *testing.T) { assert.Equal(t, "trojan://password@example.com:443#trojan", links) } -func TestConvertXrayJsonToShareLinks_PrefersTagWhenSendThroughEmpty(t *testing.T) { - cfg, err := ConvertShareLinksToXrayJson(`trojan://pw@tag.example:443`) +func TestConvertXrayJsonToShareLinks_IgnoresSendThroughForName(t *testing.T) { + cfg, err := convertShareLinksForTest(`trojan://pw@tag.example:443`) require.NoError(t, err) ob := cfg.OutboundConfigs[0] - empty := "" - ob.SendThrough = &empty + sendThrough := "127.0.0.1" + ob.SendThrough = &sendThrough ob.Tag = "named-by-tag" out, err := json.Marshal(&conf.Config{OutboundConfigs: []conf.OutboundDetourConfig{ob}}) require.NoError(t, err) diff --git a/share/marshal_share.go b/share/marshal_share.go index 729cd64d..835f425f 100644 --- a/share/marshal_share.go +++ b/share/marshal_share.go @@ -10,44 +10,36 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -// MarshalShareConfigJSON returns the Xray JSON subset supported by share links. -func MarshalShareConfigJSON(config *conf.Config) (json.RawMessage, error) { +func marshalShareConfigJSON(config *conf.Config) (json.RawMessage, int, error) { if config == nil { - return nil, fmt.Errorf("no valid outbound found") + return nil, 0, fmt.Errorf("no valid outbound found") } outbounds := make([]map[string]any, 0, len(config.OutboundConfigs)) - var firstBuildError error for _, outbound := range config.OutboundConfigs { source, err := marshalShareJSONObject(outbound) if err != nil { - return nil, err + return nil, 0, err } projected, supported := projectShareOutbound(source) if !supported { continue } if err := validateProjectedShareOutbound(projected); err != nil { - if firstBuildError == nil { - firstBuildError = err - } continue } outbounds = append(outbounds, projected) } if len(outbounds) == 0 { - if firstBuildError != nil { - return nil, fmt.Errorf("no valid outbound found: %w", firstBuildError) - } - return nil, fmt.Errorf("no valid outbound found") + return nil, 0, fmt.Errorf("no valid outbound found") } raw, err := json.Marshal(map[string]any{"outbounds": outbounds}) if err != nil { - return nil, fmt.Errorf("failed to marshal share config: %w", err) + return nil, 0, fmt.Errorf("failed to marshal share config: %w", err) } - return raw, nil + return raw, len(outbounds), nil } func marshalShareJSONObject(value any) (map[string]any, error) { @@ -306,8 +298,6 @@ func validateProjectedShareOutbound(projected map[string]any) error { if err := json.Unmarshal(raw, &outbound); err != nil { return err } - // sendThrough stores the node display name during share conversion. - outbound.SendThrough = nil _, err = outbound.Build() return err } diff --git a/share/marshal_share_test.go b/share/marshal_share_test.go index e915bcba..9b8dff6a 100644 --- a/share/marshal_share_test.go +++ b/share/marshal_share_test.go @@ -10,7 +10,7 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -func TestMarshalShareConfigJSONProjectsSupportedFields(t *testing.T) { +func TestMarshalShareConfigProjectsSupportedFields(t *testing.T) { const input = `{ "log":{"loglevel":"warning"}, "outbounds":[ @@ -27,8 +27,8 @@ func TestMarshalShareConfigJSONProjectsSupportedFields(t *testing.T) { }, { "protocol":"VLESS", - "sendThrough":"Node name", - "tag":"tag fallback", + "sendThrough":"127.0.0.1", + "tag":"Node name", "settings":{ "address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc", "flow":"","encryption":"none","level":1,"email":"drop@example.com","seed":"drop","reverse":{} @@ -53,14 +53,14 @@ func TestMarshalShareConfigJSONProjectsSupportedFields(t *testing.T) { var config conf.Config require.NoError(t, json.Unmarshal([]byte(input), &config)) - raw, err := MarshalShareConfigJSON(&config) + raw, _, err := marshalShareConfigJSON(&config) require.NoError(t, err) const expected = `{ "outbounds":[{ "protocol":"vless", - "sendThrough":"Node name", - "tag":"tag fallback", + "sendThrough":"127.0.0.1", + "tag":"Node name", "settings":{"address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"}, "streamSettings":{ "network":"xhttp","security":"reality", @@ -73,12 +73,12 @@ func TestMarshalShareConfigJSONProjectsSupportedFields(t *testing.T) { requireProjectedOutboundsBuild(t, raw) } -func TestMarshalShareConfigJSONPreservesHysteriaPortHopping(t *testing.T) { - config, err := ConvertShareLinksToXrayJson( +func TestMarshalShareConfigPreservesHysteriaPortHopping(t *testing.T) { + config, err := convertShareLinksForTest( "hy2://auth@host:443?up=50+mbps&down=100+mbps&ports=20000-40000&hop-interval=30&sni=example.com&fp=chrome", ) require.NoError(t, err) - raw, err := MarshalShareConfigJSON(config) + raw, _, err := marshalShareConfigJSON(config) require.NoError(t, err) var document map[string]any @@ -93,12 +93,12 @@ func TestMarshalShareConfigJSONPreservesHysteriaPortHopping(t *testing.T) { requireProjectedOutboundsBuild(t, raw) } -func TestMarshalShareConfigJSONKeepsKCPWithoutSettings(t *testing.T) { +func TestMarshalShareConfigKeepsKCPWithoutSettings(t *testing.T) { qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}` link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) - config, err := ConvertShareLinksToXrayJson(link) + config, err := convertShareLinksForTest(link) require.NoError(t, err) - raw, err := MarshalShareConfigJSON(config) + raw, _, err := marshalShareConfigJSON(config) require.NoError(t, err) assert.Contains(t, string(raw), `"network":"kcp"`) @@ -108,7 +108,7 @@ func TestMarshalShareConfigJSONKeepsKCPWithoutSettings(t *testing.T) { requireProjectedOutboundsBuild(t, raw) } -func TestMarshalShareConfigJSONSupportedProtocolsBuild(t *testing.T) { +func TestMarshalShareConfigSupportedProtocolsBuild(t *testing.T) { tests := map[string]string{ "shadowsocks": "ss://" + ssUserB64("chacha20-ietf-poly1305", "password") + "@10.0.0.1:8388", "vmess": "vmess://" + testShareUUID + "@vm.example:443?encryption=auto&type=raw", @@ -119,9 +119,9 @@ func TestMarshalShareConfigJSONSupportedProtocolsBuild(t *testing.T) { } for protocol, link := range tests { t.Run(protocol, func(t *testing.T) { - config, err := ConvertShareLinksToXrayJson(link) + config, err := convertShareLinksForTest(link) require.NoError(t, err) - raw, err := MarshalShareConfigJSON(config) + raw, _, err := marshalShareConfigJSON(config) require.NoError(t, err) requireProjectedOutboundsBuild(t, raw) }) @@ -134,7 +134,6 @@ func requireProjectedOutboundsBuild(t *testing.T, raw json.RawMessage) { require.NoError(t, json.Unmarshal(raw, &config)) require.NotEmpty(t, config.OutboundConfigs) for index := range config.OutboundConfigs { - config.OutboundConfigs[index].SendThrough = nil _, err := config.OutboundConfigs[index].Build() require.NoError(t, err) } diff --git a/share/parse_share.go b/share/parse_share.go index 7a24af6e..0b2bac8d 100644 --- a/share/parse_share.go +++ b/share/parse_share.go @@ -2,7 +2,6 @@ package share import ( "encoding/base64" - "encoding/json" "fmt" "net/url" "strconv" @@ -43,126 +42,35 @@ func decodeBase64Text(text string) (string, error) { // https://github.com/XTLS/Xray-core/discussions/716 -// ConvertShareLinksToXrayJson parses: -// - a single Xray JSON object (starts with '{'; only root outbounds are retained) -// - plain v2rayN-style lines (vless/vmess/ss/socks/trojan/hy2…) -// - one base64 blob that decodes to Xray JSON, share lines, or Clash YAML -// - Clash / Clash.Meta YAML (proxies:) -func ConvertShareLinksToXrayJson(links string) (*conf.Config, error) { - config, err := convertShareLinksToXrayJson(links, true) - if err != nil { - return nil, err - } - return filterBuildableOutbounds(config) -} - -func convertShareLinksToXrayJson(links string, allowBase64 bool) (*conf.Config, error) { - text := strings.TrimSpace(FixWindowsReturn(links)) - if text == "" { - return nil, fmt.Errorf("unsupported share format") - } - if strings.HasPrefix(text, "{") { - return parseXrayJSONConfig(text) - } - if hasShareSchemeLine(text) { - return parsePlainShareLines(text) - } - if allowBase64 { - decoded, err := decodeBase64Text(text) - if err == nil { - return convertShareLinksToXrayJson(decoded, false) - } - } - if hasTopLevelClashProxiesKey(text) { - return tryToParseClashYaml(text) - } - return nil, fmt.Errorf("unsupported share format") -} - -func parseXrayJSONConfig(text string) (*conf.Config, error) { - var xray *conf.Config - if err := json.Unmarshal([]byte(text), &xray); err != nil { - return nil, err - } - if len(xray.OutboundConfigs) == 0 { - return nil, fmt.Errorf("no valid outbounds") - } - return &conf.Config{OutboundConfigs: xray.OutboundConfigs}, nil -} - var shareSchemes = []string{ "vless://", "vmess://", "socks://", "ss://", "trojan://", "hysteria2://", "hy2://", } func hasShareSchemeLine(text string) bool { - found := false - forEachLine(text, func(raw string) bool { + for raw := range strings.SplitSeq(text, "\n") { line := strings.TrimSpace(raw) for _, p := range shareSchemes { if strings.HasPrefix(line, p) { - found = true - return false + return true } } - return true - }) - return found + } + return false } func hasTopLevelClashProxiesKey(text string) bool { - found := false - forEachLine(text, func(raw string) bool { + for raw := range strings.SplitSeq(text, "\n") { line := strings.TrimRight(raw, " \t") trimmed := strings.TrimSpace(line) if trimmed == "" || trimmed == "---" || strings.HasPrefix(trimmed, "#") { - return true + continue } if strings.HasPrefix(line, "proxies:") { - found = true - return false - } - return true - }) - return found -} - -func forEachLine(text string, visit func(string) bool) { - for { - line, rest, ok := strings.Cut(text, "\n") - if !visit(line) { - return - } - if !ok { - return - } - text = rest - } -} - -func parsePlainShareLines(text string) (*conf.Config, error) { - outbounds := make([]conf.OutboundDetourConfig, 0) - forEachLine(text, func(raw string) bool { - line := strings.TrimSpace(raw) - if line == "" { - return true - } - u, err := url.Parse(line) - if err != nil { - return true - } - sl := xrayShareLink{link: u, rawText: line} - ob, err := sl.outbound() - if err != nil { return true } - outbounds = append(outbounds, *ob) - return true - }) - if len(outbounds) == 0 { - return nil, fmt.Errorf("no valid outbound found") } - return &conf.Config{OutboundConfigs: outbounds}, nil + return false } type xrayShareLink struct { diff --git a/share/parse_share_test.go b/share/parse_share_test.go index 6ccdc1ee..89f3dd5c 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -19,7 +19,7 @@ func ssUserB64(cipher, password string) string { func parseHy2Link(t *testing.T, link string) *conf.OutboundDetourConfig { t.Helper() - config, err := ConvertShareLinksToXrayJson(link) + config, err := convertShareLinksForTest(link) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) return &config.OutboundConfigs[0] @@ -158,7 +158,7 @@ func TestFixWindowsReturn(t *testing.T) { } func TestConvertShareLinksToXrayJson_XrayJSONRoundTrip(t *testing.T) { - orig, err := ConvertShareLinksToXrayJson( + orig, err := convertShareLinksForTest( "vless://" + testShareUUID + "@example.com:443?encryption=none&security=tls&sni=example.com&type=ws&path=%2Fp&host=cdn.example.com#tag1", ) require.NoError(t, err) @@ -167,14 +167,14 @@ func TestConvertShareLinksToXrayJson_XrayJSONRoundTrip(t *testing.T) { raw, err := json.Marshal(orig) require.NoError(t, err) - again, err := ConvertShareLinksToXrayJson(string(raw)) + again, err := convertShareLinksForTest(string(raw)) require.NoError(t, err) require.Len(t, again.OutboundConfigs, 1) assert.Equal(t, orig.OutboundConfigs[0].Protocol, again.OutboundConfigs[0].Protocol) } func TestConvertShareLinksToXrayJson_XrayJSONKeepsOnlyOutbounds(t *testing.T) { - config, err := ConvertShareLinksToXrayJson(`{ + config, err := convertShareLinksForTest(`{ "env": {"TEST_ENV": "value"}, "log": {"loglevel": "debug"}, "routing": {"rules": []}, @@ -193,12 +193,12 @@ func TestConvertShareLinksToXrayJson_XrayJSONKeepsOnlyOutbounds(t *testing.T) { } func TestConvertShareLinksToXrayJson_XrayJSONInvalid(t *testing.T) { - _, err := ConvertShareLinksToXrayJson("{not json") + _, err := convertShareLinksForTest("{not json") require.Error(t, err) } func TestConvertShareLinksToXrayJson_XrayJSONNoOutbounds(t *testing.T) { - _, err := ConvertShareLinksToXrayJson(`{"outbounds":[]}`) + _, err := convertShareLinksForTest(`{"outbounds":[]}`) require.Error(t, err) assert.Contains(t, err.Error(), "outbound") } @@ -208,21 +208,20 @@ func TestConvertShareLinksToXrayJson_FiltersBuildInvalidOutbounds(t *testing.T) "vless://" + testShareUUID + "@invalid-reality.example:443?encryption=none&security=reality&sni=invalid-reality.example&pbk=invalid&fp=chrome\n" + "vless://" + testShareUUID + "@valid.example:443?encryption=none&security=tls&sni=valid.example&fp=chrome#Valid" - config, err := ConvertShareLinksToXrayJson(links) + config, err := convertShareLinksForTest(links) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) - require.NotNil(t, config.OutboundConfigs[0].SendThrough) - assert.Equal(t, "Valid", *config.OutboundConfigs[0].SendThrough) + assert.Equal(t, "Valid", config.OutboundConfigs[0].Tag) + assert.Nil(t, config.OutboundConfigs[0].SendThrough) } func TestConvertShareLinksToXrayJson_AllBuildInvalidOutbounds(t *testing.T) { - _, err := ConvertShareLinksToXrayJson( + _, err := convertShareLinksForTest( "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome", ) require.Error(t, err) - assert.Contains(t, err.Error(), "no valid outbound found") - assert.Contains(t, err.Error(), "invalid byte") + assert.EqualError(t, err, "no valid outbound found") } func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { @@ -230,7 +229,7 @@ func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { "ss://" + ssUserB64("aes-128-gcm", "pwd") + "@ss.example.com:8388#ssn" blob := base64.StdEncoding.EncodeToString([]byte(lines)) - cfg, err := ConvertShareLinksToXrayJson(blob) + cfg, err := convertShareLinksForTest(blob) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 2) assert.Equal(t, "trojan", cfg.OutboundConfigs[0].Protocol) @@ -240,7 +239,7 @@ func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { func TestConvertShareLinksToXrayJson_Base64URLSafeBlob(t *testing.T) { inner := "vless://" + testShareUUID + "@10.0.0.1:443?encryption=none&security=none" b := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(inner)) - cfg, err := ConvertShareLinksToXrayJson(b) + cfg, err := convertShareLinksForTest(b) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "vless", cfg.OutboundConfigs[0].Protocol) @@ -248,7 +247,7 @@ func TestConvertShareLinksToXrayJson_Base64URLSafeBlob(t *testing.T) { func TestConvertShareLinksToXrayJson_Shadowsocks(t *testing.T) { link := "ss://" + ssUserB64("chacha20-ietf-poly1305", "mypass") + "@10.0.0.1:8388#frag" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) ob := cfg.OutboundConfigs[0] @@ -266,7 +265,7 @@ func TestConvertShareLinksToXrayJson_ShadowsocksPlainUserInfo(t *testing.T) { url.QueryEscape(password) + "@192.168.100.1:8888#Example3" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) @@ -293,7 +292,7 @@ func TestParseShadowsocksUserInfo_PlainPercentEncoding(t *testing.T) { func TestConvertShareLinksToXrayJson_VlessWSAndTLS(t *testing.T) { link := "vless://" + testShareUUID + "@edge.example:443?encryption=none&type=ws&path=%2Fws&host=cdn.edge&security=tls&sni=edge.example&alpn=h2%2Ch3&fp=chrome&vcn=edge.example" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) ss := cfg.OutboundConfigs[0].StreamSetting @@ -314,7 +313,7 @@ func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) { pbk := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" link := "vless://" + testShareUUID + "@reality.example:443?encryption=none&security=reality&type=tcp&sni=reality.example&pbk=" + pbk + "&sid=abcd&fp=chrome&spx=%2F" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting require.NotNil(t, ss) @@ -328,7 +327,7 @@ func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) { func TestConvertShareLinksToXrayJson_Trojan(t *testing.T) { link := "trojan://tpw@trojan.host:4443?sni=trojan.host#tname" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) var s conf.TrojanClientConfig require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) @@ -342,7 +341,7 @@ func TestConvertShareLinksToXrayJson_Trojan(t *testing.T) { func TestConvertShareLinksToXrayJson_SocksWithAuth(t *testing.T) { u := base64.StdEncoding.EncodeToString([]byte("socksuser:sockspass")) link := "socks://" + u + "@127.0.0.1:1081#sk" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) var s conf.SocksClientConfig require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) @@ -352,7 +351,7 @@ func TestConvertShareLinksToXrayJson_SocksWithAuth(t *testing.T) { func TestConvertShareLinksToXrayJson_VmessPlainURL(t *testing.T) { link := "vmess://" + testShareUUID + "@vm.example:443?encryption=auto&type=tcp&headerType=http&path=%2Fpath1%2C%2Fpath2&host=h1%2Ch2" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) var s conf.VMessOutboundConfig require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) @@ -367,8 +366,10 @@ func TestConvertShareLinksToXrayJson_VmessBase64QR(t *testing.T) { qr := `{"ps":"qrname","add":"vm.add","port":"8443","id":"` + testShareUUID + `","scy":"auto","net":"ws","host":"ws.host","path":"/w","tls":"tls","sni":"tls.sni","alpn":"h2,h3","fp":"safari"}` b64 := base64.StdEncoding.EncodeToString([]byte(qr)) link := "vmess://" + b64 - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) + assert.Equal(t, "qrname", cfg.OutboundConfigs[0].Tag) + assert.Nil(t, cfg.OutboundConfigs[0].SendThrough) var s conf.VMessOutboundConfig require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) assert.Equal(t, testShareUUID, s.ID) @@ -384,14 +385,14 @@ func TestConvertShareLinksToXrayJson_VmessBase64QR(t *testing.T) { func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing.T) { t.Run("kcp", func(t *testing.T) { link := "vless://" + testShareUUID + "@k.example:443?encryption=none&type=kcp&headerType=srtp&seed=myseed" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.KCPSettings) }) t.Run("grpc", func(t *testing.T) { link := "vless://" + testShareUUID + "@g.example:443?encryption=none&type=grpc&serviceName=svc&authority=auth.here&mode=multi" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) gs := cfg.OutboundConfigs[0].StreamSetting.GRPCSettings require.NotNil(t, gs) @@ -402,7 +403,7 @@ func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing t.Run("httpupgrade", func(t *testing.T) { link := "vless://" + testShareUUID + "@hu.example:443?encryption=none&type=httpupgrade&path=%2Fup&host=hu.host" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) h := cfg.OutboundConfigs[0].StreamSetting.HTTPUPGRADESettings require.NotNil(t, h) @@ -414,7 +415,7 @@ func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing extra := `{"host":"xh.extra"}` link := "vless://" + testShareUUID + "@xh.example:443?encryption=none&type=xhttp&path=%2Fx&host=xh.host&mode=stream-up&extra=" + url.QueryEscape(extra) - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) x := cfg.OutboundConfigs[0].StreamSetting.XHTTPSettings require.NotNil(t, x) @@ -427,7 +428,7 @@ func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing func TestConvertShareLinksToXrayJson_FinalMaskQuery(t *testing.T) { fm := `{"udp":[{"type":"noise","settings":{}}]}` link := "vless://" + testShareUUID + "@fm.example:443?encryption=none&type=tcp&fm=" + url.QueryEscape(fm) - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting require.NotNil(t, ss.FinalMask) @@ -436,14 +437,14 @@ func TestConvertShareLinksToXrayJson_FinalMaskQuery(t *testing.T) { } func TestConvertShareLinksToXrayJson_Hysteria2InvalidHop(t *testing.T) { - _, err := ConvertShareLinksToXrayJson("hy2://auth@host:443?hop-interval=notint&sni=x.com") + _, err := convertShareLinksForTest("hy2://auth@host:443?hop-interval=notint&sni=x.com") require.Error(t, err) } func TestConvertShareLinksToXrayJson_MultiLineSkipsBad(t *testing.T) { bad := "vmess://" + testShareUUID + "@bad.example:notaport?encryption=none" good := "vless://" + testShareUUID + "@ok.example:443?encryption=none" - cfg, err := ConvertShareLinksToXrayJson(bad + "\n\n" + good) + cfg, err := convertShareLinksForTest(bad + "\n\n" + good) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "vless", cfg.OutboundConfigs[0].Protocol) @@ -459,7 +460,7 @@ func TestConvertShareLinksToXrayJson_TextHeaderBeforeShareLines(t *testing.T) { "---\n" + good - cfg, err := ConvertShareLinksToXrayJson(text) + cfg, err := convertShareLinksForTest(text) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "vless", cfg.OutboundConfigs[0].Protocol) @@ -467,20 +468,20 @@ func TestConvertShareLinksToXrayJson_TextHeaderBeforeShareLines(t *testing.T) { func TestConvertShareLinksToXrayJson_DetectedShareLinesAllInvalid(t *testing.T) { bad := "vmess://" + testShareUUID + "@bad.example:notaport?encryption=none" - _, err := ConvertShareLinksToXrayJson("Subscription export\n" + bad) + _, err := convertShareLinksForTest("Subscription export\n" + bad) require.Error(t, err) assert.Contains(t, err.Error(), "no valid outbound found") } func TestConvertShareLinksToXrayJson_Base64EncodedJSON(t *testing.T) { - orig, err := ConvertShareLinksToXrayJson( + orig, err := convertShareLinksForTest( "vless://" + testShareUUID + "@json.example:443?encryption=none", ) require.NoError(t, err) raw, err := json.Marshal(orig) require.NoError(t, err) - cfg, err := ConvertShareLinksToXrayJson(base64.StdEncoding.EncodeToString(raw)) + cfg, err := convertShareLinksForTest(base64.StdEncoding.EncodeToString(raw)) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "vless", cfg.OutboundConfigs[0].Protocol) @@ -494,14 +495,14 @@ func TestConvertShareLinksToXrayJson_Base64EncodedClashYAML(t *testing.T) { port: 8390 cipher: aes-256-gcm password: yamlpw` - cfg, err := ConvertShareLinksToXrayJson(base64.StdEncoding.EncodeToString([]byte(yaml))) + cfg, err := convertShareLinksForTest(base64.StdEncoding.EncodeToString([]byte(yaml))) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "shadowsocks", cfg.OutboundConfigs[0].Protocol) } func TestConvertShareLinksToXrayJson_UnsupportedFormat(t *testing.T) { - _, err := ConvertShareLinksToXrayJson("this is not a supported subscription format") + _, err := convertShareLinksForTest("this is not a supported subscription format") require.Error(t, err) assert.Contains(t, err.Error(), "unsupported share format") } @@ -514,7 +515,7 @@ func TestConvertShareLinksToXrayJson_RawClashYAML(t *testing.T) { port: 8390 cipher: aes-256-gcm password: yamlpw` - cfg, err := ConvertShareLinksToXrayJson(yaml) + cfg, err := convertShareLinksForTest(yaml) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "shadowsocks", cfg.OutboundConfigs[0].Protocol) @@ -526,7 +527,7 @@ func TestConvertShareLinksToXrayJson_ClashYAMLNoValidOutbound(t *testing.T) { type: unsupported server: c.example port: 8390` - _, err := ConvertShareLinksToXrayJson(yaml) + _, err := convertShareLinksForTest(yaml) require.Error(t, err) assert.Contains(t, err.Error(), "no valid outbound found") } @@ -535,7 +536,7 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { t.Run("grpc", func(t *testing.T) { qr := `{"ps":"g","add":"grpc.host","port":"443","id":"` + testShareUUID + `","net":"grpc","path":"svcname","type":"multi"}` link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) gs := cfg.OutboundConfigs[0].StreamSetting.GRPCSettings require.NotNil(t, gs) @@ -546,7 +547,7 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { t.Run("kcp", func(t *testing.T) { qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}` link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.KCPSettings) }) @@ -554,7 +555,7 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { func TestConvertShareLinksToXrayJson_ShadowsocksWithStreamQuery(t *testing.T) { link := "ss://" + ssUserB64("aes-128-gcm", "p") + "@ss-ws.example:443?type=ws&path=%2Fws&host=cdn.ws&security=tls&sni=ss-ws.example" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting require.NotNil(t, ss.WSSettings) diff --git a/share/test_helpers_test.go b/share/test_helpers_test.go new file mode 100644 index 00000000..139ec322 --- /dev/null +++ b/share/test_helpers_test.go @@ -0,0 +1,32 @@ +package share + +import ( + "encoding/json" + + "github.com/xtls/xray-core/infra/conf" +) + +func convertShareLinksForTest(links string) (*conf.Config, error) { + config, err := parseShareCandidatesForTest(links) + if err != nil { + return nil, err + } + return filterBuildableOutbounds(config) +} + +func parseShareCandidatesForTest(links string) (*conf.Config, error) { + config, _, err := parseShareCandidates(links, true) + return config, err +} + +func convertShareLinksWithKeyForTest(links, secretKey string) (*conf.Config, error) { + result, err := ConvertShareLinksToXrayJson(links, secretKey) + if err != nil { + return nil, err + } + var config conf.Config + if err := json.Unmarshal(result.Config, &config); err != nil { + return nil, err + } + return &config, nil +} diff --git a/share/validate_outbound.go b/share/validate_outbound.go index 4f89d292..d0fa295f 100644 --- a/share/validate_outbound.go +++ b/share/validate_outbound.go @@ -22,23 +22,13 @@ func filterBuildableOutbounds(config *conf.Config) (*conf.Config, error) { restoreNilRawMessages(reflect.ValueOf(&validationOutbounds)) validOutbounds := make([]conf.OutboundDetourConfig, 0, len(config.OutboundConfigs)) - var firstBuildError error for index := range validationOutbounds { - // Share conversion stores the display name in sendThrough because Xray - // has no outbound name field. It is metadata here, not a bind address. - validationOutbounds[index].SendThrough = nil if _, err := validationOutbounds[index].Build(); err != nil { - if firstBuildError == nil { - firstBuildError = err - } continue } validOutbounds = append(validOutbounds, config.OutboundConfigs[index]) } if len(validOutbounds) == 0 { - if firstBuildError != nil { - return nil, fmt.Errorf("no valid outbound found: %w", firstBuildError) - } return nil, fmt.Errorf("no valid outbound found") } diff --git a/share/xray_json.go b/share/xray_json.go index 78589109..449c7c42 100644 --- a/share/xray_json.go +++ b/share/xray_json.go @@ -22,15 +22,10 @@ type XrayRawSettingsHeaderRequestHeaders struct { } func setOutboundName(outbound *conf.OutboundDetourConfig, name string) { - outbound.SendThrough = &name + outbound.Tag = name } func getOutboundName(outbound conf.OutboundDetourConfig) string { - if outbound.SendThrough != nil { - if len(*outbound.SendThrough) > 0 { - return *outbound.SendThrough - } - } if len(outbound.Tag) > 0 { return outbound.Tag } diff --git a/xray/ping_batch.go b/xray/ping_batch.go index f40f24d9..2014dc2d 100644 --- a/xray/ping_batch.go +++ b/xray/ping_batch.go @@ -31,9 +31,11 @@ type PingBatchItem struct { } type PingBatchResult struct { - Success bool - Delay int64 - Error string + Success bool + Delay int64 + Error string + LocationJSON *string + LocationError string } type pingOutboundConfig struct { @@ -50,9 +52,25 @@ func PingBatch( timeout int, targetURL string, ) ([]PingBatchResult, error) { + return PingBatchWithLocation(items, timeout, targetURL, "") +} + +// PingBatchWithLocation uses the same forced-outbound client for latency and +// optional location probes. The two probe results are independent. +func PingBatchWithLocation(items []PingBatchItem, timeout int, targetURL, locationURL string) ([]PingBatchResult, error) { if err := validatePingBatchRequest(items, timeout, targetURL); err != nil { return nil, err } + if locationURL != "" { + if err := validatePingBatchRequest(items, timeout, locationURL); err != nil { + return nil, errors.New("ping batch location URL must be an absolute HTTP or HTTPS URL") + } + } + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return nil, errors.New("pingBatch requires an isolated process without a managed Xray instance") + } results := make([]PingBatchResult, len(items)) prepared := make([]preparedPingItem, 0, len(items)) @@ -99,23 +117,13 @@ func PingBatch( go func() { defer workers.Done() for item := range jobs { - delay, err := measureOutboundDelay( + results[item.resultIndex] = probeOutbound( server, item.outboundTag, timeout, targetURL, + locationURL, ) - if err != nil { - results[item.resultIndex] = failedPingBatchResult( - delay, - err, - ) - continue - } - results[item.resultIndex] = PingBatchResult{ - Success: true, - Delay: delay, - } } }() } @@ -361,12 +369,13 @@ func startPingBatchServer( return server, nil } -func measureOutboundDelay( +func probeOutbound( server *core.Instance, outboundTag string, timeout int, targetURL string, -) (int64, error) { + locationURL string, +) PingBatchResult { httpTimeout := time.Second * time.Duration(timeout) transport := &http.Transport{ DisableKeepAlives: true, @@ -392,7 +401,38 @@ func measureOutboundDelay( Transport: transport, Timeout: httpTimeout, } - return nodep.PingHTTPRequest(client, targetURL, timeout) + delay, err := nodep.PingHTTPRequest(client, targetURL, timeout) + result := PingBatchResult{Success: true, Delay: delay} + if err != nil { + result = failedPingBatchResult(delay, err) + } + if locationURL != "" { + locationJSON, err := probeLocation(client, locationURL) + if err != nil { + result.LocationError = err.Error() + } else { + result.LocationJSON = &locationJSON + } + } + return result +} + +func probeLocation(client *http.Client, locationURL string) (string, error) { + response, err := client.Get(locationURL) + if err != nil { + // Do not include a provider URL, credentials or response body in errors. + return "", errors.New("location request failed") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("location request returned HTTP %d", response.StatusCode) + } + const maxLocationBytes = 64 * 1024 + body, err := io.ReadAll(io.LimitReader(response.Body, maxLocationBytes+1)) + if err != nil || len(body) > maxLocationBytes { + return "", errors.New("unable to read location response") + } + return string(body), nil } func failedPingBatchResult(delay int64, err error) PingBatchResult { diff --git a/xray/ping_location_test.go b/xray/ping_location_test.go new file mode 100644 index 00000000..ad4418dc --- /dev/null +++ b/xray/ping_location_test.go @@ -0,0 +1,131 @@ +package xray + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestPingBatchLocationUsesEachForcedOutbound(t *testing.T) { + var heads, gets atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + heads.Add(1) + return + } + gets.Add(1) + fmt.Fprint(w, `{"ip_address":"203.0.113.9","country":"jp"}`) + })) + defer server.Close() + results, err := PingBatchWithLocation([]PingBatchItem{ + {XrayJSON: `{"outbounds":[{"protocol":"freedom","tag":"proxy"}]}`}, + {XrayJSON: `{"outbounds":[{"protocol":"blackhole","tag":"proxy"}]}`}, + }, 1, server.URL, server.URL) + if err != nil { + t.Fatal(err) + } + first, second := results[0], results[1] + if !first.Success || first.LocationJSON == nil || *first.LocationJSON != `{"ip_address":"203.0.113.9","country":"jp"}` || first.LocationError != "" { + t.Fatalf("first result = %+v", first) + } + if second.Success || second.LocationJSON != nil || second.LocationError == "" { + t.Fatalf("blocked outbound escaped through another client: %+v", second) + } + if heads.Load() != 1 || gets.Load() != 1 { + t.Fatalf("requests = HEAD %d GET %d", heads.Load(), gets.Load()) + } +} + +func TestPingBatchLocationFailureDoesNotFailLatency(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusServiceUnavailable) + } + })) + defer server.Close() + items := []PingBatchItem{{XrayJSON: `{"outbounds":[{"protocol":"freedom"}]}`}} + results, err := PingBatchWithLocation(items, 1, server.URL, server.URL) + if err != nil { + t.Fatal(err) + } + result := results[0] + if !result.Success || result.Error != "" || result.LocationJSON != nil || result.LocationError != "location request returned HTTP 503" { + t.Fatalf("result = %+v", result) + } + results, err = PingBatch(items, 1, server.URL) + if err != nil { + t.Fatal(err) + } + if !results[0].Success || results[0].LocationJSON != nil || results[0].LocationError != "" { + t.Fatalf("latency-only result = %+v", results[0]) + } +} + +func TestPingBatchLocationCanSucceedWhenLatencyFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + conn, _, err := w.(http.Hijacker).Hijack() + if err == nil { + conn.Close() + } + return + } + fmt.Fprint(w, `{"ip_address":"2001:db8::1","country":"US"}`) + })) + defer server.Close() + results, err := PingBatchWithLocation([]PingBatchItem{{XrayJSON: `{"outbounds":[{"protocol":"freedom"}]}`}}, 1, server.URL, server.URL) + if err != nil { + t.Fatal(err) + } + if results[0].Success || results[0].LocationJSON == nil || *results[0].LocationJSON != `{"ip_address":"2001:db8::1","country":"US"}` || results[0].LocationError != "" { + t.Fatalf("result = %+v", results[0]) + } +} + +func TestProbeLocationReturnsOriginalBody(t *testing.T) { + for _, body := range []string{"", " \n{\"provider_specific\": true}\n", strings.Repeat("x", 64*1024)} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) })) + locationJSON, err := probeLocation(server.Client(), server.URL) + server.Close() + if err != nil || locationJSON != body { + t.Fatalf("location JSON length = %d, error = %v", len(locationJSON), err) + } + } +} + +func TestProbeLocationRejectsOversizedResponseWithoutLeakingInput(t *testing.T) { + body := "private-source" + strings.Repeat("x", 64*1024+1-len("private-source")) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, body) + })) + defer server.Close() + locationJSON, err := probeLocation(server.Client(), server.URL) + if err == nil || locationJSON != "" { + t.Fatalf("location JSON = %q, error = %v", locationJSON, err) + } + if strings.Contains(err.Error(), "private-source") { + t.Fatal("response body leaked") + } +} + +func TestProbeLocationHidesURLAndCredentialsOnRequestFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) + defer server.Close() + client := server.Client() + client.Timeout = 5 * time.Millisecond + _, err := probeLocation(client, strings.Replace(server.URL, "://", "://private-user:private-password@", 1)) + if err == nil || err.Error() != "location request failed" { + t.Fatalf("error = %v", err) + } +} + +func TestPingBatchRejectsInvalidLocationURL(t *testing.T) { + _, err := PingBatchWithLocation([]PingBatchItem{{}}, 1, "https://example.com", "file:///private/file") + if err == nil || !strings.Contains(err.Error(), "location URL") { + t.Fatalf("error = %v", err) + } +} diff --git a/xray/runtime.go b/xray/runtime.go new file mode 100644 index 00000000..24483d6b --- /dev/null +++ b/xray/runtime.go @@ -0,0 +1,263 @@ +package xray + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/features/inbound" + "github.com/xtls/xray-core/features/policy" + "github.com/xtls/xray-core/features/stats" +) + +// RuntimeConfig is host metadata, never part of the Xray configuration. +type RuntimeConfig struct { + StatePath string `json:"statePath"` + InboundTag string `json:"inboundTag"` + Listen string `json:"listen,omitempty"` + Token string `json:"token,omitempty"` +} + +type runtimeSession struct { + ID string `json:"id"` + StartedAtMs int64 `json:"startedAtMs"` + EndedAtMs int64 `json:"endedAtMs"` + Uplink int64 `json:"uplink"` + Downlink int64 `json:"downlink"` +} + +// runtimeSnapshot contains only this session's raw inbound counter values. +// It contains no application totals, configuration, credentials, or control API. +type runtimeSnapshot struct { + Version int `json:"version"` + Session runtimeSession `json:"session"` + Available bool `json:"available"` + SampledAtMs int64 `json:"sampledAtMs"` + SavedAtMs int64 `json:"savedAtMs"` + Error string `json:"error"` +} + +type managedRuntime struct { + config RuntimeConfig + snapshot runtimeSnapshot + manager stats.Manager + stateLock *os.File + stopTicker, tickDone chan struct{} + httpServer *http.Server + httpListener net.Listener +} + +func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { + if config == nil { + return nil, nil + } + if !filepath.IsAbs(config.StatePath) || strings.TrimSpace(config.InboundTag) == "" || len(config.InboundTag) > 256 { + return nil, errors.New("runtime requires an absolute statePath and inboundTag") + } + if err := validateRuntimeHTTP(config); err != nil { + return nil, err + } + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + return nil, err + } + stateLock, err := lockRuntimeState(config.StatePath) + if err != nil { + return nil, err + } + return &managedRuntime{ + config: *config, stateLock: stateLock, + snapshot: runtimeSnapshot{ + Version: 1, + Session: runtimeSession{ID: hex.EncodeToString(id[:]), StartedAtMs: time.Now().UnixMilli()}, + }, + }, nil +} + +func lockRuntimeState(path string) (*os.File, error) { + if !filepath.IsAbs(path) { + return nil, errors.New("runtime statePath must be absolute") + } + lockPath := path + ".lock" + if info, err := os.Lstat(lockPath); err == nil && !info.Mode().IsRegular() || err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, errors.New("runtime state lock is unavailable") + } + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, errors.New("runtime state lock is unavailable") + } + if err := lockRuntimeFile(file); err != nil { + _ = file.Close() + return nil, errors.New("runtime state is in use") + } + return file, nil +} + +func (r *managedRuntime) attach(server *core.Instance) error { + manager, ok := server.GetFeature(stats.ManagerType()).(stats.Manager) + policies, policyOK := server.GetFeature(policy.ManagerType()).(policy.Manager) + inbounds, inboundOK := server.GetFeature(inbound.ManagerType()).(inbound.Manager) + if !ok || !policyOK || !inboundOK || !policies.ForSystem().Stats.InboundUplink || !policies.ForSystem().Stats.InboundDownlink { + return errors.New("runtime requires inbound uplink and downlink statistics") + } + if _, err := inbounds.GetHandler(context.Background(), r.config.InboundTag); err != nil { + return errors.New("runtime inboundTag does not exist") + } + // Registering zero counters handles an idle inbound without treating disabled statistics as zero. + for _, direction := range []string{"uplink", "downlink"} { + counter, err := manager.GetOrRegisterCounter(r.counterName(direction)) + if err != nil || counter == nil { + return errors.New("runtime requires a statistics manager") + } + } + r.manager = manager + return nil +} + +func (r *managedRuntime) counterName(direction string) string { + return "inbound>>>" + r.config.InboundTag + ">>>traffic>>>" + direction +} + +func (r *managedRuntime) start() error { + listener, err := r.listenHTTP() + if err != nil { + return err + } + r.sample() + if err := r.save(); err != nil { + if listener != nil { + _ = listener.Close() + } + return err + } + if listener != nil { + r.serveHTTP(listener) + } + r.stopTicker, r.tickDone = make(chan struct{}), make(chan struct{}) + go func() { + defer close(r.tickDone) + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + r.sample() + _ = r.save() + case <-r.stopTicker: + return + } + } + }() + return nil +} + +func (r *managedRuntime) sample() { + r.snapshot.SampledAtMs = time.Now().UnixMilli() + up, down := r.manager.GetCounter(r.counterName("uplink")), r.manager.GetCounter(r.counterName("downlink")) + r.snapshot.Available = up != nil && down != nil + if r.snapshot.Available { + u, d := up.Value(), down.Value() + r.snapshot.Available = u >= 0 && d >= 0 + if r.snapshot.Available { + // Preserve raw Value semantics, including a nonnegative counter rollback. + // Never reset counters or synthesize deltas/application totals here. + r.snapshot.Session.Uplink, r.snapshot.Session.Downlink = u, d + } + } +} + +func (r *managedRuntime) save() error { + candidate := r.snapshot + candidate.SavedAtMs = time.Now().UnixMilli() + candidate.Error = "" + if !candidate.Available { + candidate.Error = "counters_unavailable" + } + if err := writeRuntimeState(r.config.StatePath, candidate); err != nil { + r.snapshot.Error = "state_write_failed" + return errors.New("runtime state_write_failed") + } + r.snapshot = candidate + return nil +} + +func (r *managedRuntime) stop() error { + if r.stopTicker == nil { + return nil + } + var httpErr error + if r.httpServer != nil { + httpErr = r.httpServer.Close() + // Close also covers an immediate stop before Serve registers the listener. + _ = r.httpListener.Close() + r.httpServer = nil + r.httpListener = nil + } + close(r.stopTicker) + <-r.tickDone + r.stopTicker = nil + // The ticker has exited, so the final sample/write cannot race a periodic one. + r.sample() + r.snapshot.Session.EndedAtMs = time.Now().UnixMilli() + return errors.Join(httpErr, r.save()) +} + +func readRuntimeState(path string) (runtimeSnapshot, error) { + var state runtimeSnapshot + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > 64*1024 { + return state, errors.New("runtime state is not a readable regular file") + } + file, err := os.Open(path) + if err != nil { + return state, err + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, 64*1024)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&state); err != nil { + return state, errors.New("runtime state is invalid") + } + var extra any + id, idErr := hex.DecodeString(state.Session.ID) + if decoder.Decode(&extra) != io.EOF || state.Version != 1 || idErr != nil || len(id) != 16 || + state.Session.ID != strings.ToLower(state.Session.ID) || + state.Session.StartedAtMs <= 0 || state.Session.EndedAtMs < 0 || state.SampledAtMs <= 0 || state.SavedAtMs <= 0 || + state.Session.Uplink < 0 || state.Session.Downlink < 0 { + return state, errors.New("runtime state is invalid") + } + return state, nil +} + +func writeRuntimeState(path string, state runtimeSnapshot) error { + if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() || err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.New("runtime state is not a regular file") + } + data, err := json.Marshal(state) + if err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".runtime-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + err = errors.Join(err, file.Close()) + if err != nil { + return err + } + return replaceRuntimeState(file.Name(), path) +} diff --git a/xray/runtime_file.go b/xray/runtime_file.go new file mode 100644 index 00000000..fa3029a5 --- /dev/null +++ b/xray/runtime_file.go @@ -0,0 +1,26 @@ +//go:build !windows + +package xray + +import ( + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +func lockRuntimeFile(file *os.File) error { + return unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) +} + +func replaceRuntimeState(source, target string) error { + directory, err := os.Open(filepath.Dir(target)) + if err != nil { + return err + } + defer directory.Close() + if err := os.Rename(source, target); err != nil { + return err + } + return directory.Sync() +} diff --git a/xray/runtime_file_windows.go b/xray/runtime_file_windows.go new file mode 100644 index 00000000..29791af3 --- /dev/null +++ b/xray/runtime_file_windows.go @@ -0,0 +1,23 @@ +package xray + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func lockRuntimeFile(file *os.File) error { + return windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) +} + +func replaceRuntimeState(source, target string) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(target) + if err != nil { + return err + } + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/xray/runtime_http.go b/xray/runtime_http.go new file mode 100644 index 00000000..86d9a9d8 --- /dev/null +++ b/xray/runtime_http.go @@ -0,0 +1,81 @@ +package xray + +import ( + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "net" + "net/http" + "strconv" + "strings" + "time" +) + +func validRuntimeToken(value string) bool { + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == 16 && value == strings.ToLower(value) +} + +func validateRuntimeHTTP(config *RuntimeConfig) error { + if config.Listen == "" && config.Token == "" { + return nil + } + host, portText, err := net.SplitHostPort(config.Listen) + port, portErr := strconv.Atoi(portText) + if err != nil || host != "127.0.0.1" || portErr != nil || port < 1 || port > 65535 || + strconv.Itoa(port) != portText || !validRuntimeToken(config.Token) { + return errors.New("runtime HTTP requires listen 127.0.0.1:1..65535 and a 32-character lowercase hex token") + } + return nil +} + +func (r *managedRuntime) listenHTTP() (net.Listener, error) { + if r.config.Listen == "" { + return nil, nil + } + listener, err := net.Listen("tcp4", r.config.Listen) + if err != nil { + return nil, errors.New("runtime HTTP listener is unavailable") + } + return listener, nil +} + +func (r *managedRuntime) serveHTTP(listener net.Listener) { + r.httpListener = listener + r.httpServer = &http.Server{ + Handler: http.HandlerFunc(r.handleHTTP), + ReadHeaderTimeout: 2 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + IdleTimeout: 30 * time.Second, + MaxHeaderBytes: 8 * 1024, + } + server := r.httpServer + go func() { _ = server.Serve(listener) }() +} + +func (r *managedRuntime) handleHTTP(w http.ResponseWriter, request *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if subtle.ConstantTimeCompare([]byte(request.Header.Get("Authorization")), []byte("Bearer "+r.config.Token)) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if request.URL.Path != "/runtime" { + http.NotFound(w, request) + return + } + if request.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + snapshot, err := readRuntimeState(r.config.StatePath) + if err != nil { + http.Error(w, "runtime snapshot unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(snapshot) +} diff --git a/xray/runtime_http_test.go b/xray/runtime_http_test.go new file mode 100644 index 00000000..43df1378 --- /dev/null +++ b/xray/runtime_http_test.go @@ -0,0 +1,189 @@ +package xray + +import ( + "encoding/json" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func runtimeHTTPConfig(t *testing.T) RuntimeConfig { + t.Helper() + config := runtimeConfig(t) + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + config.Listen, config.Token = listener.Addr().String(), strings.Repeat("a", 32) + _ = listener.Close() + return config +} + +func requestRuntime(t *testing.T, config RuntimeConfig, method, path, token string, status int) runtimeSnapshot { + t.Helper() + request, err := http.NewRequest(method, "http://"+config.Listen+path, nil) + if err != nil { + t.Fatal(err) + } + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + client := &http.Client{Timeout: 3 * time.Second} + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil || response.StatusCode != status { + t.Fatalf("runtime HTTP status %d, want %d: %s %v", response.StatusCode, status, data, err) + } + if response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("Access-Control-Allow-Origin") != "" { + t.Fatal("runtime HTTP must disable caching without enabling CORS") + } + if strings.Contains(string(data), config.StatePath) || strings.Contains(string(data), config.Token) { + t.Fatal("runtime HTTP exposed host metadata") + } + var snapshot runtimeSnapshot + if status == http.StatusOK { + if response.Header.Get("Content-Type") != "application/json" || json.Unmarshal(data, &snapshot) != nil || snapshot.Version != 1 { + t.Fatalf("invalid runtime response: %s", data) + } + } + return snapshot +} + +func TestRuntimeHTTPAuthenticationCurrentSessionAndStop(t *testing.T) { + config := runtimeHTTPConfig(t) + runtime, up, down := runtimeFixture(t, config) + up.Add(17) + down.Add(23) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + requestRuntime(t, config, http.MethodGet, "/runtime", "", http.StatusUnauthorized) + requestRuntime(t, config, http.MethodGet, "/runtime", strings.Repeat("b", 32), http.StatusUnauthorized) + current := requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusOK) + if current.Session.Uplink != 17 || current.Session.Downlink != 23 { + t.Fatalf("wrong current snapshot: %+v", current) + } + up.Add(100) + if saved := requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusOK); saved != current || up.Value() != 117 { + t.Fatal("HTTP must read the saved snapshot without sampling or resetting metrics") + } + requestRuntime(t, config, http.MethodPost, "/runtime", config.Token, http.StatusMethodNotAllowed) + requestRuntime(t, config, http.MethodGet, "/runtime/ack", config.Token, http.StatusNotFound) + requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, http.StatusNotFound) + requestRuntime(t, config, http.MethodGet, "/control", config.Token, http.StatusNotFound) + if err := os.Remove(config.StatePath); err != nil { + t.Fatal(err) + } + requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusServiceUnavailable) + runtime.sample() + if err := runtime.save(); err != nil { + t.Fatal(err) + } + if err := runtime.stop(); err != nil { + t.Fatal(err) + } + stopped := savedRuntime(t, config.StatePath) + if stopped.Session.Uplink != 117 || stopped.Session.EndedAtMs == 0 { + t.Fatal("HTTP shutdown lost the final saved sample") + } + connection, err := net.DialTimeout("tcp4", config.Listen, time.Second) + if err == nil { + _ = connection.Close() + t.Fatal("runtime HTTP listener survived stop") + } +} + +func TestRuntimeHTTPRejectsCurrentSnapshotSymlink(t *testing.T) { + config := runtimeHTTPConfig(t) + runtime, _, _ := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + state := savedRuntime(t, config.StatePath) + outside := filepath.Join(t.TempDir(), "runtime.json") + if err := writeRuntimeState(outside, state); err != nil { + t.Fatal(err) + } + if err := os.Remove(config.StatePath); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, config.StatePath); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } + requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusServiceUnavailable) + if savedRuntime(t, outside) != state { + t.Fatal("HTTP changed a snapshot outside its state path") + } +} + +func TestRuntimeHTTPStartFailureClosesListener(t *testing.T) { + config := runtimeHTTPConfig(t) + runtime, _, _ := runtimeFixture(t, config) + occupied, err := net.Listen("tcp4", config.Listen) + if err != nil { + t.Fatal(err) + } + defer occupied.Close() + if err := runtime.start(); err == nil { + t.Fatal("occupied statistics port did not reject startup") + } + if _, err := os.Lstat(config.StatePath); !os.IsNotExist(err) { + t.Fatal("failed bind replaced the saved state") + } + _ = occupied.Close() + runtime.config.StatePath = filepath.Join(filepath.Dir(config.StatePath), "missing", "runtime.json") + if err := runtime.start(); err == nil { + t.Fatal("initial save failure did not reject startup") + } + listener, err := net.Listen("tcp4", config.Listen) + if err != nil { + t.Fatalf("initial save failure leaked the HTTP listener: %v", err) + } + _ = listener.Close() + runtime.config.StatePath = config.StatePath + if err := runtime.start(); err != nil { + t.Fatal(err) + } + if err := runtime.stop(); err != nil { + t.Fatal(err) + } + listener, err = net.Listen("tcp4", config.Listen) + if err != nil { + t.Fatalf("immediate stop leaked the HTTP listener: %v", err) + } + _ = listener.Close() +} + +func TestRuntimeHTTPConcurrentReads(t *testing.T) { + config := runtimeHTTPConfig(t) + runtime, up, _ := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + var workers sync.WaitGroup + workers.Go(func() { + for range 30 { + up.Add(1) + runtime.sample() + if err := runtime.save(); err != nil { + t.Error(err) + } + } + }) + workers.Go(func() { + for range 30 { + requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusOK) + } + }) + workers.Wait() +} diff --git a/xray/runtime_test.go b/xray/runtime_test.go new file mode 100644 index 00000000..a8206d2c --- /dev/null +++ b/xray/runtime_test.go @@ -0,0 +1,385 @@ +package xray + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + appstats "github.com/xtls/xray-core/app/stats" + "github.com/xtls/xray-core/features/stats" +) + +func runtimeConfig(t *testing.T) RuntimeConfig { + t.Helper() + return RuntimeConfig{ + StatePath: filepath.Join(t.TempDir(), "runtime.json"), + InboundTag: "tunIn", + } +} + +func runtimeFixture(t *testing.T, config RuntimeConfig) (*managedRuntime, stats.Counter, stats.Counter) { + t.Helper() + runtime, err := prepareRuntime(&config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = runtime.stop() + _ = runtime.stateLock.Close() + }) + manager, err := appstats.NewManager(context.Background(), &appstats.Config{}) + if err != nil { + t.Fatal(err) + } + runtime.manager = manager + up, _ := manager.RegisterCounter(runtime.counterName("uplink")) + down, _ := manager.RegisterCounter(runtime.counterName("downlink")) + return runtime, up, down +} + +func saveRuntimeSample(t *testing.T, runtime *managedRuntime) runtimeSnapshot { + t.Helper() + runtime.sample() + if err := runtime.save(); err != nil { + t.Fatal(err) + } + return savedRuntime(t, runtime.config.StatePath) +} + +func savedRuntime(t *testing.T, path string) runtimeSnapshot { + t.Helper() + snapshot, err := readRuntimeState(path) + if err != nil || snapshot.Version != 1 { + t.Fatalf("read snapshot: %+v %v", snapshot, err) + } + return snapshot +} + +func TestRuntimeStoresRawCountersWithoutResetOrTotals(t *testing.T) { + config := runtimeConfig(t) + runtime, up, down := runtimeFixture(t, config) + up.Add(100) + down.Add(200) + first := saveRuntimeSample(t, runtime) + repeated := saveRuntimeSample(t, runtime) + if first.Session.Uplink != 100 || first.Session.Downlink != 200 || first.Session != repeated.Session || up.Value() != 100 || down.Value() != 200 { + t.Fatalf("sampling duplicated or reset raw counters: %+v %+v", first, repeated) + } + // A nonnegative rollback remains a raw value, not a synthetic accumulated delta. + up.Set(2) + rollback := saveRuntimeSample(t, runtime) + up.Add(3) + resumed := saveRuntimeSample(t, runtime) + if rollback.Session.Uplink != 2 || resumed.Session.Uplink != 5 || !resumed.Available { + t.Fatalf("rollback changed raw counter semantics: %+v %+v", rollback, resumed) + } + up.Set(-1) + negative := saveRuntimeSample(t, runtime) + if negative.Available || negative.Error != "counters_unavailable" || negative.Session != resumed.Session { + t.Fatalf("negative counter corrupted the last valid sample: %+v", negative) + } + _ = runtime.manager.UnregisterCounter(runtime.counterName("uplink")) + missing := saveRuntimeSample(t, runtime) + if missing.Available || missing.Error != "counters_unavailable" || missing.Session != resumed.Session { + t.Fatalf("missing counter became a fabricated zero: %+v", missing) + } + if info, err := os.Stat(config.StatePath); err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("private file mode: %v %v", info, err) + } + encoded, _ := json.Marshal(first) + var fields map[string]json.RawMessage + _ = json.Unmarshal(encoded, &fields) + if len(fields) != 6 || fields["ledger"] != nil || fields["totalUplink"] != nil || fields["resetGeneration"] != nil || strings.Contains(string(encoded), config.StatePath) { + t.Fatalf("unexpected runtime snapshot fields: %s", encoded) + } + metadata, _ := json.Marshal(config) + var metadataFields map[string]json.RawMessage + _ = json.Unmarshal(metadata, &metadataFields) + if len(metadataFields) != 2 || metadataFields["planId"] != nil || metadataFields["controlAddress"] != nil || metadataFields["controlToken"] != nil { + t.Fatalf("runtime metadata exposed a control surface: %s", metadata) + } +} + +func TestRuntimeReplacesPreviousSessionOnStart(t *testing.T) { + config := runtimeConfig(t) + runtime, up, down := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + initial := savedRuntime(t, config.StatePath) + if initial.Session.Uplink != 0 || initial.Session.Downlink != 0 || !initial.Available || initial.Session.EndedAtMs != 0 { + t.Fatalf("new session was not saved at start: %+v", initial) + } + up.Add(17) + down.Add(23) + if err := runtime.stop(); err != nil { + t.Fatal(err) + } + stopped := savedRuntime(t, config.StatePath) + if stopped.Session.Uplink != 17 || stopped.Session.Downlink != 23 || stopped.Session.EndedAtMs == 0 { + t.Fatalf("stop did not save final raw counters: %+v", stopped) + } + _ = runtime.stateLock.Close() + // Preparation alone cannot overwrite the current snapshot. + for range 2 { + next, err := prepareRuntime(&config) + if err != nil { + t.Fatal(err) + } + _ = next.stateLock.Close() + if savedRuntime(t, config.StatePath) != stopped { + t.Fatal("preparation replaced the previous current snapshot") + } + } + next, _, _ := runtimeFixture(t, config) + if err := next.start(); err != nil { + t.Fatal(err) + } + current := savedRuntime(t, config.StatePath) + if current.Session.ID == stopped.Session.ID || current.Session.Uplink != 0 || current.Session.Downlink != 0 { + t.Fatalf("restart reused a session or inherited totals: %+v", current) + } + if _, err := os.Lstat(filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions")); !os.IsNotExist(err) { + t.Fatal("restart created a runtime session archive") + } +} + +func TestRuntimeWriteFailuresPreserveSavedFile(t *testing.T) { + config := runtimeConfig(t) + runtime, up, _ := runtimeFixture(t, config) + up.Add(20) + committed := saveRuntimeSample(t, runtime) + runtime.config.StatePath = filepath.Join(filepath.Dir(config.StatePath), "missing", "runtime.json") + up.Add(5) + runtime.sample() + if err := runtime.save(); err == nil || runtime.snapshot.Error != "state_write_failed" || runtime.snapshot.SavedAtMs != committed.SavedAtMs { + t.Fatalf("failed save incorrectly advanced the watermark: %+v %v", runtime.snapshot, err) + } + if savedRuntime(t, config.StatePath) != committed { + t.Fatal("failed save changed the last saved file") + } + runtime.config.StatePath = config.StatePath + up.Add(8) + recovered := saveRuntimeSample(t, runtime) + if recovered.Session.Uplink != 33 || recovered.Error != "" { + t.Fatalf("retry synthesized or lost raw bytes: %+v", recovered) + } +} + +func TestRuntimeConfigAndStateBoundary(t *testing.T) { + config := runtimeConfig(t) + for _, mutate := range []func(*RuntimeConfig){ + func(c *RuntimeConfig) { c.StatePath = "relative.json" }, + func(c *RuntimeConfig) { c.InboundTag = "" }, + func(c *RuntimeConfig) { c.Listen = "127.0.0.1:12345" }, + func(c *RuntimeConfig) { c.Token = strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "0.0.0.0:12345", strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "localhost:12345", strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "[::1]:12345", strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:0", strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:65536", strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:12345", strings.Repeat("A", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:12345", "secret" }, + } { + invalid := config + mutate(&invalid) + if r, err := prepareRuntime(&invalid); err == nil { + _ = r.stateLock.Close() + t.Fatal("invalid runtime metadata accepted") + } + } + for _, text := range []string{ + `{`, `{"version":9}`, + `{"version":1,"session":{"id":"../../outside","startedAtMs":1},"sampledAtMs":1,"savedAtMs":1}`, + `{"version":1,"session":{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","startedAtMs":1,"uplink":-1},"sampledAtMs":1,"savedAtMs":1}`, + `{"version":1,"session":{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","planId":"old","startedAtMs":1},"sampledAtMs":1,"savedAtMs":1}`, + } { + if err := os.WriteFile(config.StatePath, []byte(text), 0600); err != nil { + t.Fatal(err) + } + if _, err := readRuntimeState(config.StatePath); err == nil { + t.Fatal("invalid saved session was accepted") + } + if saved, err := os.ReadFile(config.StatePath); err != nil || string(saved) != text { + t.Fatal("invalid saved session was overwritten") + } + } + runtime, err := prepareRuntime(&config) + if err != nil { + t.Fatalf("saved state unnecessarily blocked preparation: %v", err) + } + _ = runtime.stateLock.Close() +} + +func TestManagedRuntimeStartFailureAndStop(t *testing.T) { + t.Cleanup(func() { _ = StopXray() }) + config := runtimeHTTPConfig(t) + if err := RunXrayWithRuntime(minimalConfig, &config); err == nil || GetXrayState() { + t.Fatal("missing statistics were accepted") + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + _, port, _ := net.SplitHostPort(listener.Addr().String()) + xrayJSON := fmt.Sprintf(`{"log":{"loglevel":"none"},"stats":{},"policy":{"system":{"statsInboundUplink":true,"statsInboundDownlink":true}},"inbounds":[{"tag":"tunIn","listen":"127.0.0.1","port":%s,"protocol":"socks","settings":{"udp":false}}],"outbounds":[{"protocol":"freedom","tag":"direct"}]}`, port) + if err := RunXrayWithRuntime(xrayJSON, &config); err == nil || GetXrayState() { + t.Fatal("occupied inbound port did not fail startup") + } + _ = listener.Close() + statisticsListener, err := net.Listen("tcp4", config.Listen) + if err != nil { + t.Fatal(err) + } + if err := RunXrayWithRuntime(xrayJSON, &config); err == nil || GetXrayState() { + t.Fatal("occupied statistics port did not close the constructed core") + } + _ = statisticsListener.Close() + validPath := config.StatePath + config.StatePath = filepath.Join(filepath.Dir(validPath), "missing", "runtime.json") + if err := RunXrayWithRuntime(xrayJSON, &config); err == nil || GetXrayState() { + t.Fatal("unwritable runtime directory did not fail startup") + } + config.StatePath = validPath + if err := RunXrayWithRuntime(xrayJSON, &config); err != nil { + t.Fatal(err) + } + snapshot := savedRuntime(t, config.StatePath) + if !snapshot.Available || snapshot.Session.Uplink != 0 || snapshot.Session.EndedAtMs != 0 { + t.Fatalf("idle statistics should be available zero: %+v", snapshot) + } + // Even a final persistence error must close the core and release the owner lock. + coreRuntime.config.StatePath = filepath.Join(filepath.Dir(validPath), "missing", "runtime.json") + if err := StopXray(); err == nil || GetXrayState() { + t.Fatalf("failed final save did not close core: %v", err) + } + statisticsListener, err = net.Listen("tcp4", config.Listen) + if err != nil { + t.Fatalf("failed final save leaked the statistics listener: %v", err) + } + _ = statisticsListener.Close() + next, err := prepareRuntime(&config) + if err != nil { + t.Fatalf("stop did not release ownership: %v", err) + } + _ = next.stateLock.Close() +} + +func TestRuntimePeriodicSaveAndSingleOwner(t *testing.T) { + config := runtimeConfig(t) + runtime, up, down := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + initial := savedRuntime(t, config.StatePath) + if other, err := prepareRuntime(&config); err == nil || err.Error() != "runtime state is in use" { + if other != nil { + _ = other.stateLock.Close() + } + t.Fatalf("two writers acquired the same path: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRuntimeChild$") + command.Env = append(os.Environ(), "LIBXRAY_TEST_RUNTIME_PATH="+config.StatePath, "LIBXRAY_TEST_RUNTIME_ACTION=lock") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("cross-process owner lock failed: %v: %s", err, output) + } + up.Add(31) + down.Add(47) + // Exercise the real 30s timer without adding a production interval option. + deadline := time.Now().Add(35 * time.Second) + for time.Now().Before(deadline) { + snapshot := savedRuntime(t, config.StatePath) + if snapshot.Session.Uplink == 31 && snapshot.Session.Downlink == 47 && snapshot.SavedAtMs > initial.SavedAtMs { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("host timer did not save counters without any UI/control request") +} + +func TestRuntimeKilledOwnerStateIsReplacedOnRestart(t *testing.T) { + config := runtimeConfig(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRuntimeChild$") + command.Env = append(os.Environ(), "LIBXRAY_TEST_RUNTIME_PATH="+config.StatePath, "LIBXRAY_TEST_RUNTIME_ACTION=kill") + pipe, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := command.Start(); err != nil { + t.Fatal(err) + } + scanner := bufio.NewScanner(pipe) + ready := false + for scanner.Scan() { + if scanner.Text() == "READY" { + ready = true + break + } + } + if !ready { + _ = command.Process.Kill() + _ = command.Wait() + t.Fatalf("child did not reach unsaved tail: %v", scanner.Err()) + } + if err := command.Process.Kill(); err != nil { + t.Fatal(err) + } + if err := command.Wait(); err == nil { + t.Fatal("child was not forcibly terminated") + } + saved := savedRuntime(t, config.StatePath) + if saved.Session.Uplink != 17 || saved.Session.Downlink != 23 || saved.Session.EndedAtMs != 0 { + t.Fatalf("kill invented a final sample or ending: %+v", saved) + } + runtime, _, _ := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + if current := savedRuntime(t, config.StatePath); current.Session.ID == saved.Session.ID || current.Session.Uplink != 0 || current.Session.Downlink != 0 { + t.Fatalf("restart reused killed owner's counters: %+v", current) + } + if _, err := os.Lstat(filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions")); !os.IsNotExist(err) { + t.Fatal("restart archived the killed owner's saved state") + } +} + +func TestRuntimeChild(t *testing.T) { + path := os.Getenv("LIBXRAY_TEST_RUNTIME_PATH") + if path == "" { + return + } + config := RuntimeConfig{StatePath: path, InboundTag: "tunIn"} + if os.Getenv("LIBXRAY_TEST_RUNTIME_ACTION") == "lock" { + if other, err := prepareRuntime(&config); err == nil || err.Error() != "runtime state is in use" { + if other != nil { + _ = other.stateLock.Close() + } + t.Fatalf("another process acquired active session ownership: %v", err) + } + return + } + runtime, up, down := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + up.Add(17) + down.Add(23) + saveRuntimeSample(t, runtime) + up.Add(101) + down.Add(103) + fmt.Fprintln(os.Stdout, "READY") + select {} +} diff --git a/xray/validation.go b/xray/validation.go index 3d0a442f..23c6ff4c 100644 --- a/xray/validation.go +++ b/xray/validation.go @@ -1,15 +1,21 @@ package xray -// Test Xray Config. -// xrayJSON is the serialized Xray JSON configuration. +import ( + "errors" + "strings" + + "github.com/xtls/xray-core/core" +) + +// TestXray only builds the configuration; it does not instantiate handlers. +// The core builder can read local assets/certificates and apply root env values. +// Success does not guarantee that the configuration can start. func TestXray(xrayJSON string) error { - server, err := newXrayInstance(xrayJSON) - if err != nil { - return err - } - err = server.Close() - if err != nil { - return err + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return errors.New("testXray requires an isolated process without a managed Xray instance") } - return nil + _, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) + return err } diff --git a/xray/xray.go b/xray/xray.go index ec3f2145..fc39bff1 100644 --- a/xray/xray.go +++ b/xray/xray.go @@ -14,6 +14,7 @@ import ( var ( coreServerMu sync.Mutex coreServer *core.Instance + coreRuntime *managedRuntime ) var ErrAlreadyRunning = errors.New("xray is already running") @@ -35,23 +36,52 @@ func newXrayInstance(xrayJSON string) (*core.Instance, error) { // Run Xray instance. // xrayJSON is the serialized Xray JSON configuration. func RunXray(xrayJSON string) (err error) { + return RunXrayWithRuntime(xrayJSON, nil) +} + +// RunXrayWithRuntime optionally saves this session's raw inbound counters. +func RunXrayWithRuntime(xrayJSON string, config *RuntimeConfig) (err error) { coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { return ErrAlreadyRunning } + runtime, err := prepareRuntime(config) + if err != nil { + return err + } + if runtime != nil { + defer func() { + if err != nil { + _ = runtime.stateLock.Close() + } + }() + } memory.InitForceFree() server, err := newXrayInstance(xrayJSON) if err != nil { return } + if runtime != nil { + if err = runtime.attach(server); err != nil { + _ = server.Close() + return err + } + } if err = server.Start(); err != nil { _ = server.Close() return } + if runtime != nil { + if err = runtime.start(); err != nil { + _ = server.Close() + return err + } + } coreServer = server + coreRuntime = runtime debug.FreeOSMemory() return nil @@ -69,7 +99,13 @@ func StopXray() error { coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { - err := coreServer.Close() + var runtimeErr error + if coreRuntime != nil { + defer coreRuntime.stateLock.Close() + runtimeErr = coreRuntime.stop() + coreRuntime = nil + } + err := errors.Join(runtimeErr, coreServer.Close()) coreServer = nil if err != nil { return err diff --git a/xray/xray_test.go b/xray/xray_test.go index 192f825e..f766b029 100644 --- a/xray/xray_test.go +++ b/xray/xray_test.go @@ -2,6 +2,8 @@ package xray import ( "errors" + "os" + "strings" "sync" "testing" ) @@ -12,6 +14,32 @@ const minimalConfig = `{ "outbounds": [{"protocol": "freedom", "tag": "direct"}] }` +func TestTemporaryOperationsRejectManagedOverlap(t *testing.T) { + if err := RunXray(minimalConfig); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = StopXray() }) + const key = "XRAY_LIBXRAY_TEMPORARY_OVERLAP_TEST" + t.Setenv(key, "original") + config := `{"env":{"` + key + `":"changed"},"outbounds":[{"protocol":"freedom"}]}` + for name, operation := range map[string]func() error{ + "testXray": func() error { return TestXray(config) }, + "pingBatch": func() error { + _, err := PingBatch([]PingBatchItem{{XrayJSON: config}}, 10, "http://127.0.0.1:1/") + return err + }, + } { + t.Run(name, func(t *testing.T) { + if err := operation(); err == nil || !strings.Contains(err.Error(), "isolated process") { + t.Fatalf("managed overlap was not rejected: %v", err) + } + if os.Getenv(key) != "original" || !GetXrayState() { + t.Fatal("temporary operation changed the active instance or process environment") + } + }) + } +} + func TestRunXrayRejectsDuplicateStart(t *testing.T) { t.Cleanup(func() { if err := StopXray(); err != nil {