diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..2114512f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,89 @@ +name: Bug report +description: Report a libXray API, build, or native integration problem. +title: "[Bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Please write your report in English and describe one problem per issue. + This tracker covers libXray. Report app-specific UI or behavior issues to the client application's repository unless you have a libXray reproduction. + Check the [README](https://github.com/XTLS/libXray#readme) and the supported Xray-core version before reporting. + Issues and attachments are public. Remove passwords, UUIDs, subscription URLs or tokens, age private keys, and other private data from examples and logs. + - type: checkboxes + id: existing-issues + attributes: + label: Existing issues + options: + - label: I searched open and closed issues and did not find an existing report of this problem. + required: true + - type: input + id: libxray-version + attributes: + label: libXray version + description: Enter the release tag, Go module version, or full commit SHA. For prebuilt artifacts, include the release or source they came from. + validations: + required: true + - type: input + id: xray-version + attributes: + label: Xray-core version + description: Enter the tag, module version, or commit if known. Mention any local replacement or custom patches. + - type: dropdown + id: integration + attributes: + label: Integration or build path + options: + - Go package + - C / FFI (CGoInvoke) + - Gomobile binding + - Desktop Core CLI + - Build script + - Other + validations: + required: true + - type: input + id: method + attributes: + label: Affected API or command + description: Name the method or build command involved, if applicable. + placeholder: "For example, testXray, runXray, pingBatch, or python3 build/main.py apple go" + - type: textarea + id: environment + attributes: + label: Environment + description: Include the target OS/version and CPU architecture. For build problems, also include the host OS, Go version, SDK/NDK or compiler versions, and exact build command. Mention simulators and other Go-based libraries loaded in the same process. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Provide a small code example, Invoke request, or command sequence. Include the relevant call order and cleanup so the problem can be reproduced outside the client app. For a build failure, provide the command and build options. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happened instead? Include the exact error or response, if any. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs or stack trace + description: Paste the relevant build output, response error, or stack trace with private data removed. Include the first error and enough surrounding context. + render: text + - type: textarea + id: context + attributes: + label: Configuration and additional context + description: Optionally attach a minimal configuration or reproducer with credentials removed. Mention whether it works with an earlier libXray version or directly with Xray-core, if already tested. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..d7c5ff48 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: libXray documentation + url: https://github.com/XTLS/libXray#readme + about: Read the API, build instructions, and integration requirements. + - name: libXray releases + url: https://github.com/XTLS/libXray/releases + about: Check release versions and prebuilt artifacts before reporting a problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..d2484a22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: Feature request +description: Propose a libXray API, build, or integration improvement. +title: "[Feature]: " +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Please write your request in English and focus on one improvement to libXray. + Client application features belong in the application's repository. Do not include private credentials. + - type: checkboxes + id: existing-issues + attributes: + label: Existing issues + options: + - label: I searched open and closed issues and did not find an existing request for this improvement. + required: true + - type: textarea + id: problem + attributes: + label: Problem or use case + description: What library capability is missing, and how does it affect your integration? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed improvement + description: Describe the intended behavior and relevant API, build target, or platform. Include an example request and response if useful. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Explain whether the existing libXray API, Xray-core configuration, or client-side code can address the use case. + - type: textarea + id: context + attributes: + label: Additional context + description: Add relevant upstream documentation, examples, or compatibility considerations. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ada6467c..650296d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,10 +11,7 @@ concurrency: cancel-in-progress: false env: - GO_VERSION: "stable" - XCODE_VERSION: "latest-stable" - PYTHON_VERSION: "3.12" - NDK_VERSION: r29 + NDK_VERSION: r30 jobs: build: @@ -22,27 +19,21 @@ jobs: fail-fast: false matrix: include: + # Only what Cloakify ships: a failed leg blocks the release. The + # per-target steps below stay as upstream wrote them, so the other + # targets come back by adding their entries. - runner: ubuntu-24.04 target: linux arch: x64 artifact: linux-x64 - - runner: ubuntu-24.04-arm - target: linux - arch: arm64 - artifact: linux-arm64 - runner: ubuntu-24.04 target: android artifact: android - - runner: windows-2022 + - runner: windows-2025-vs2026 target: windows arch: x64 + goarch: amd64 artifact: windows-x64 - - runner: macos-26 - target: apple - artifact: apple-cgo - - runner: macos-26 - target: macos - artifact: macos-dylib runs-on: ${{ matrix.runner }} timeout-minutes: 60 @@ -57,15 +48,16 @@ jobs: # Checkout # ========================= - name: Checkout source - uses: actions/checkout@v6 + uses: actions/checkout@v7 # ========================= # Python (build scripts) # ========================= - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: - python-version: ${{ env.PYTHON_VERSION }} + python-version: "3.x" + check-latest: true - name: Install Python deps run: | @@ -74,44 +66,99 @@ jobs: # ========================= # Go setup # ========================= + # go.mod's `go` line is the floor: Xray-core v26.10.21's certificate fix + # needs Go 1.27.3, and setup-go's GOTOOLCHAIN=local makes the go command + # refuse anything older. - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: - go-version: ${{ env.GO_VERSION }} - check-latest: true + go-version-file: go.mod - name: Download Go modules run: go mod download + # ========================= + # Windows ARM64 toolchain + # ========================= + - name: Install LLVM-MinGW for Windows ARM64 + if: matrix.target == 'windows' && matrix.arch == 'arm64' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $headers = @{ + Accept = "application/vnd.github+json" + Authorization = "Bearer $env:GH_TOKEN" + "X-GitHub-Api-Version" = "2022-11-28" + } + $release = Invoke-RestMethod ` + -Headers $headers ` + -Uri "https://api.github.com/repos/mstorsjo/llvm-mingw/releases/latest" + $asset = $release.assets | + Where-Object { $_.name -match "^llvm-mingw-.+-ucrt-aarch64\.zip$" } | + Select-Object -First 1 + if ($null -eq $asset) { + throw "The latest LLVM-MinGW release has no ARM64 UCRT archive" + } + + $archivePath = Join-Path $env:RUNNER_TEMP $asset.name + $extractPath = Join-Path $env:RUNNER_TEMP "llvm-mingw" + Write-Host "Downloading LLVM-MinGW $($release.tag_name): $($asset.name)" + Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $archivePath + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath + + $binPath = Get-ChildItem -LiteralPath $extractPath -Directory | + ForEach-Object { Join-Path $_.FullName "bin" } | + Where-Object { Test-Path (Join-Path $_ "gcc.exe") } | + Select-Object -First 1 + if ($null -eq $binPath) { + throw "LLVM-MinGW bin directory was not found in $extractPath" + } + $binPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Verify Windows ARM64 CGo toolchain + if: matrix.target == 'windows' && matrix.arch == 'arm64' + shell: pwsh + run: | + where.exe gcc.exe + where.exe g++.exe + + $gccTarget = (& gcc.exe -dumpmachine).Trim() + $gxxTarget = (& g++.exe -dumpmachine).Trim() + $expectedTarget = '^aarch64-w64-(windows-gnu|mingw32)$' + if ($gccTarget -notmatch $expectedTarget) { + throw "Unexpected gcc target: $gccTarget" + } + if ($gxxTarget -notmatch $expectedTarget) { + throw "Unexpected g++ target: $gxxTarget" + } + # ========================= # Apple toolchain # ========================= - - name: Select latest stable Xcode - if: matrix.target == 'apple' || matrix.target == 'macos' + - name: Select Xcode + if: matrix.target == 'apple' uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: ${{ env.XCODE_VERSION }} + xcode-version: latest-stable # ========================= # Android toolchain # ========================= - name: Setup Java (Android) if: matrix.target == 'android' - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin - java-version: "21" + java-version: latest + check-latest: true - name: Setup Android SDK if: matrix.target == 'android' - uses: android-actions/setup-android@v3 - with: - # The default adds the legacy `tools` package, which cmdline-tools - # 16 no longer carries (sdkmanager fails the whole step). gomobile - # needs only the SDK root; the NDK comes from the next step. - packages: platform-tools + uses: android-actions/setup-android@v4 - name: Setup Android NDK + id: ndk if: matrix.target == 'android' uses: nttld/setup-ndk@v1 with: @@ -129,23 +176,74 @@ jobs: - name: Build Android if: matrix.target == 'android' + env: + ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }} run: | + # gomobile at the version go.mod pins, not whatever @latest is today. + export LIBXRAY_GOMOBILE_VERSION="$(go list -m -f '{{.Version}}' golang.org/x/mobile)" python3 build/main.py android + - name: Test Windows desktop Core + if: matrix.target == 'windows' + env: + GOARCH: ${{ matrix.goarch }} + run: go test ./dns ./desktop_bin -count=1 + - name: Build Windows if: matrix.target == 'windows' + env: + GOARCH: ${{ matrix.goarch }} run: | python build/main.py windows + - name: Verify Windows architecture + if: matrix.target == 'windows' + shell: pwsh + run: | + $expected = switch ("${{ matrix.arch }}") { + "x64" { 0x8664 } + "arm64" { 0xaa64 } + default { throw "unsupported Windows architecture: ${{ matrix.arch }}" } + } + foreach ($path in @("windows_dll/libXray.dll", "bin/xray.exe")) { + $bytes = [System.IO.File]::ReadAllBytes($path) + $peOffset = [System.BitConverter]::ToInt32($bytes, 0x3c) + $machine = [System.BitConverter]::ToUInt16($bytes, $peOffset + 4) + if ($machine -ne $expected) { + throw ("unexpected PE machine for {0}: 0x{1:X4}; expected 0x{2:X4}" -f $path, $machine, $expected) + } + } + + - name: Verify Linux desktop Core CLI + if: matrix.target == 'linux' + run: ./bin/xray -h + + - name: Verify Windows desktop Core CLI + if: matrix.target == 'windows' + run: ./bin/xray.exe -h + - name: Build Apple (macOS/iOS) if: matrix.target == 'apple' run: | python3 build/main.py apple go - - name: Build macOS dylib (FFI) - if: matrix.target == 'macos' + # The certificate fix rides on both the core and the toolchain: check + # what was built, not what the runner says it has. A clean Windows 10 + # has no C++ runtime, so the DLL must not import one. + - name: Verify the built core + if: matrix.target == 'linux' || matrix.target == 'windows' || matrix.target == 'android' + shell: bash run: | - python3 build/main.py macos + set -e + case "${{ matrix.target }}" in + linux) python build/verify_core.py linux_so/libXray.so ;; + windows) + # Pinned like the actions: the check must not change under us. + python -m pip install pefile==2024.8.26 + python build/verify_core.py --no-cxx-runtime windows_dll/libXray.dll + ;; + android) python build/verify_core.py libXray.aar ;; + esac # ========================= # Collect artifacts @@ -160,11 +258,13 @@ jobs: mkdir -p "dist/${{ matrix.artifact }}" cp linux_so/libXray.so "dist/${{ matrix.artifact }}/" cp linux_so/libXray.h "dist/${{ matrix.artifact }}/" + cp bin/xray "dist/${{ matrix.artifact }}/" ;; windows) mkdir -p "dist/${{ matrix.artifact }}" cp windows_dll/libXray.dll "dist/${{ matrix.artifact }}/" cp windows_dll/libXray.h "dist/${{ matrix.artifact }}/" + cp bin/xray.exe "dist/${{ matrix.artifact }}/" ;; android) mkdir -p "dist/${{ matrix.artifact }}" @@ -175,13 +275,7 @@ jobs: mkdir -p "dist/${{ matrix.artifact }}" cp -R LibXray.xcframework "dist/${{ matrix.artifact }}/" ;; - macos) - mkdir -p "dist/${{ matrix.artifact }}" - cp macos_dylib/libXray.dylib "dist/${{ matrix.artifact }}/" - cp macos_dylib/libXray.h "dist/${{ matrix.artifact }}/" - ;; esac - # ========================= # Upload artifacts # ========================= @@ -200,7 +294,7 @@ jobs: contents: write steps: - name: Download all artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: libxray-* path: dist/ @@ -217,7 +311,7 @@ jobs: # assets afterwards. Create the release as a DRAFT (assets attach fine to # drafts), then publish it once all files are uploaded. - name: Create draft GitHub Release with assets - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: name: libXray ${{ github.ref_name }} tag_name: ${{ github.ref_name }} diff --git a/.github/workflows/libxray-test.yml b/.github/workflows/libxray-test.yml index 51f32eb2..6831cd06 100644 --- a/.github/workflows/libxray-test.yml +++ b/.github/workflows/libxray-test.yml @@ -6,15 +6,15 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache-dependency-path: go.sum diff --git a/.github/workflows/release-go-mirror.yml b/.github/workflows/release-go-mirror.yml index d45255c5..6d4223d3 100644 --- a/.github/workflows/release-go-mirror.yml +++ b/.github/workflows/release-go-mirror.yml @@ -25,12 +25,12 @@ on: jobs: mirror: name: Mirror CalVer tag to v1.YYMMDD.N - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: write steps: - name: Checkout (full history + tags) - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 00000000..ba6a70b2 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,56 @@ +name: Validate libXray + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: validate-libxray-${{ github.ref }} + cancel-in-progress: true + +jobs: + go: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true + + - name: Download modules + run: go mod download + + - name: Vet + run: go vet ./... + + - name: Test with race detector + run: go test -race ./... -count=1 -timeout 15m + + - name: Build Linux artifacts + run: | + python3 build/main.py linux + grep -q 'CGoFree' linux_so/libXray.h + nm -D linux_so/libXray.so | grep -q ' CGoFree$' + ./bin/xray -h + + - name: Verify the built core + run: | + python3 build/test_verify_core.py + python3 build/verify_core.py linux_so/libXray.so + + - name: Verify source was restored + run: git diff --exit-code + + - name: Check whitespace + run: git diff --check diff --git a/.gitignore b/.gitignore index 872c7051..c38262db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env # Binaries for programs and plugins *.exe *.exe~ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..82cd69c9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +# libXray + +Go wrapper around Xray-core for mobile and desktop clients. Keep platform-specific +App behavior out of the generic library. + +## API and runtime + +When changing a public API contract, read [README API](README.md#api), the +affected method section, and `invoke_model.go` / `invoke.go`. Internal-only +changes need the relevant implementation and tests, not the entire API guide. + +- 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` constructs an instance through `newXrayInstance` and closes it + without calling `Start`. Callers own minimal/filtered validation configs. + Process-level side effects are accepted; startup resources and connectivity + remain outside this check. See [testXray](README.md#testxray) for limitations. +- 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 the five-item + limit, input order, per-item failure isolation, and delay-only results. +- Traffic comes directly from [Xray metrics](README.md#metrics). The library + manages the Core lifecycle only; it does not sample or persist traffic. +- 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 by default, or retained when `--no-snapshot` is explicitly used. + Check the build command's success and resulting artifacts. +- Modify an adjacent Xray-core checkout only when explicitly requested. + +Targets and local-core options are documented in [build usage](README.md#usage). + +## Agent skills + +### Issue tracker + +GitHub Issues for `XTLS/libXray`. Before issue, PR or review work, read +[issue tracker](docs/agents/issue-tracker.md). + +### Triage labels + +Use the five canonical triage labels. Before triage, read +[label mapping](docs/agents/triage-labels.md). + +### Domain docs + +Single-context layout. Before codebase exploration or domain/ADR work, +read [domain guidance](docs/agents/domain.md). + +## Verification + +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. + +- Go changes: `gofmt` changed files and run affected tests. Use + `go test ./... -count=1` for shared lifecycle, API, or dependency changes, + or when the impact cannot be contained to specific packages. +- 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 0cfadd07..24c23344 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,8 @@ Compile script. It is recommended to always use this script to compile libXray. depends on git and go. -By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to tag `v26.6.27` (recorded by Go as the matching pseudo-version). +By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.10.21` through its pseudo-version. Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`. -Linux and Windows builds only produce the libXray shared library. Applications that need a standalone Xray executable should use official Xray-core release binaries. ### Usage @@ -67,6 +66,58 @@ python3 build/main.py windows local ``` +Builds snapshot and restore `go.mod` and `go.sum` on success or failure by default. +Pass `--no-snapshot` to skip both the snapshot and restoration. Before initializing +Go dependencies, the script deletes existing `go.mod` and `go.sum` and creates a +fresh module. The resulting files are retained even if the build fails. +This option is supported by every target +and can be combined with `local` in either order: + +```shell +python3 build/main.py android --no-snapshot +python3 build/main.py apple go local --no-snapshot +``` + +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 [-error-file ] +``` + +The `-dns`, `-interface`, and `-config` options are required. `-dns` must be an IP endpoint, and `-config` +points directly to the Xray JSON configuration. + +The optional `-error-file` also writes command failures to a UTF-8 file before +exiting, preserving the same error printed to stderr. The file is cleared before +running; a successful run leaves it empty. Its parent directory must exist. +Callers launching an elevated Core should create the file first under their own +account so they retain read access. This is an error-return channel, not Xray's +access/error log configuration, and it does not add a separate validation pass. +Applications using this option must bundle a desktop Core built with its support. + +> [!WARNING] +> **Use only one Go runtime per process.** Go does not support loading multiple +> independently built Go runtimes into one process. Every native libXray +> artifact embeds a Go runtime, whether it is produced through cgo or gomobile. +> Do not load libXray together with another independently built Go, cgo, or +> gomobile library in the same executable or process. Doing so can fail during +> build, link, or load, or crash during runtime initialization before +> application code runs. +> If one process needs Go packages from several libraries, include those +> packages in the same Go build or `gomobile bind` invocation and produce one +> native artifact so they share a runtime. Merely repackaging or merging +> independently built frameworks, archives, AARs, shared libraries, or DLLs is +> not sufficient. Separate OS processes may each load one Go runtime, so apply +> this rule independently to each process. See +> [Go #18976](https://github.com/golang/go/issues/18976#issuecomment-308505600), +> [golang/go#15956](https://github.com/golang/go/issues/15956#issuecomment-373709423), +> and [libXray #116](https://github.com/XTLS/libXray/issues/116). + ### Android use [gomobile](https://github.com/golang/mobile) . @@ -77,7 +128,8 @@ use [gomobile](https://github.com/golang/mobile) . Need "iOS Simulator Runtime". -This is the best choice for general scenarios and will not conflict with other frameworks. +This is the best choice for general scenarios. The cross-platform single-runtime +restriction above still applies when linking other Go-based libraries. Supports iOS, iOSSimulator, macOS, macCatalyst. @@ -102,13 +154,10 @@ depend on gcc and g++. ### Windows -depend on LLVM MinGW. +Depends on gcc and g++ in `PATH`. -you can use winget to install [LLVM MinGW](https://github.com/mstorsjo/llvm-mingw). - -```shell -winget install MartinStorsjo.LLVM-MinGW.UCRT -``` +Native amd64 and arm64 builds are supported. The release workflow builds each +architecture on its matching GitHub-hosted Windows runner. ## API @@ -122,22 +171,20 @@ The C export is: ```c char* CGoInvoke(char* requestJSON); +void CGoFree(char* value); ``` +`CGoInvoke` allocates its response. The caller must release every non-null +response with `CGoFree`; do not use a platform allocator directly. + The request is a JSON object: ```json { - "apiVersion": 1, + "apiVersion": 3, "method": "runXray", - "env": { - "xray.location.config": "/path/to/config.json", - "xray.location.asset": "/path/to/dat", - "xray.location.cert": "/path/to/dat", - "xray.tun.fd": "123" - }, "payload": { - "configPath": "/path/to/config.json" + "xrayJson": "{\"outbounds\":[...]}" } } ``` @@ -152,45 +199,57 @@ The response is a JSON object: } ``` -`env` is optional and only supports Xray-core environment variables that are -explicitly modeled by libXray: - -| JSON key | Meaning | -| --- | --- | -| `xray.location.config` | Xray config file location | -| `xray.location.confdir` | Xray config directory location | -| `xray.location.asset` | Directory containing `geosite.dat`, `geoip.dat`, and custom GeoData files | -| `xray.location.cert` | Certificate directory used by Xray-core | -| `xray.buf.readv` | Xray-core readv buffer switch | -| `xray.buf.splice` | Xray-core splice buffer switch | -| `xray.vmess.padding` | VMess padding switch | -| `xray.cone.disabled` | Cone behavior switch | -| `xray.json.strict` | Strict JSON parsing switch | -| `xray.ray.buffer.size` | Ray buffer size | -| `xray.browser.dialer` | Browser dialer address | -| `xray.xudp.show` | XUDP log display switch | -| `xray.xudp.basekey` | XUDP base key | -| `xray.tun.fd` | TUN file descriptor for Android, iOS, and macOS packet tunnel integrations | - Design notes: -1. `env` is modeled as fixed fields in Go and Dart. It is not a free-form map. -2. Unknown `env` keys are ignored and are not written to the process environment. -3. `env` only sets modeled, non-empty fields. Missing fields are not unset. -4. libXray does not restore previous environment values after a method returns. Callers must pass the required env fields on every request that depends on them. This avoids concurrent calls restoring stale values over newer values. -5. `SetTunFd` has been removed. Pass `xray.tun.fd` in the `env` object of the `runXray` request. +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 + `xray.tun.fd` into the Xray config root `env` object before calling + `runXray`. +4. `countGeoData` is not backed by an Xray config, so its `datDir` is passed in + the method payload. +5. The complete UTF-8 encoded Invoke request and response JSON envelopes are + limited to 16 MiB. If either limit is exceeded, Invoke returns a failure + response with `success: false`, `data: null`, and a size-limit error. +6. `convertShareLinksToXrayJson` validates each parsed outbound with the current + Xray-core config builder. Invalid outbounds are omitted, and the method fails + if none remain. Validation does not create or start an Xray instance. + Xray JSON input is treated as a node source: only its root `outbounds` are + retained, and all other root fields are ignored. The response contains only + 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 contains only the projected `outbounds` list. + 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. `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. +8. `decodeSubscriptionText` returns the complete plaintext after optional Age + decryption. It does not parse subscription formats, project outbounds or + construct an Xray instance; callers own classification and validation. Supported methods: ```text getFreePorts +decodeSubscriptionText convertShareLinksToXrayJson convertXrayJsonToShareLinks +generateAgeKeyPair countGeoData -ping +pingBatch testXray runXray -runXrayFromJson stopXray xrayVersion getXrayState @@ -198,8 +257,48 @@ getXrayState ## controller +### Socket protect + Used to solve the socket protect problem on Android. +### DNS resolver + +Android may expose a loopback DNS server to Go's resolver while a VPN is +active. Call `SetDNS` before `runXray` to make Go use the DNS server selected by +the VPN configuration and protect the DNS socket from the VPN tunnel. The +server must be an IP endpoint with a port, such as `8.8.8.8:53` or +`[2001:4860:4860::8888]:53`. + +Call `ResetDNS` after Xray has stopped. These APIs are available only in the +Android artifact and change the process-wide Go resolver. + +```java +LibXray.setDNS(controller, "8.8.8.8:53"); +LibXray.invoke(runXrayRequest); + +// Later, when stopping the core: +LibXray.invoke(stopXrayRequest); +LibXray.resetDNS(); +``` + +### Process finder (per-app routing) + +`ConnectivityManager.getConnectionOwnerUid()` is API 30+. On older Android +libXray falls back to parsing `/proc/net/{tcp,udp}{,6}` in pure Go. + +Usage (Java/Kotlin): + +```java +ProcessFinder finder = new ProcessFinder() { + @Override + public long findProcessByConnection(String network, String srcIP, long srcPort, + String destIP, long destPort) { + return -1; // return UID or -1 + } +}; +LibXray.registerProcessFinder(finder, Build.VERSION.SDK_INT); +``` + ## geo ### count @@ -226,29 +325,174 @@ Speed ​​test the Xray configuration. ### port -Get free ports. +`getFreePorts` returns distinct free TCP ports on localhost. Its payload accepts +`count` and an optional `excludePorts` list of caller-reserved port numbers: -## share +```json +{ + "apiVersion": 3, + "method": "getFreePorts", + "payload": {"count": 2, "excludePorts": [18587, 9000]} +} +``` -libXray uses `sendThrough` to store outbound names. +The response keeps the existing `data.ports` integer array. Omit `excludePorts` +or pass an empty list for no exclusions; duplicates have no additional effect. +Excluded ports must be between 1 and 65535. Negative or impossible counts fail; +zero returns no ports. The Go entrypoint is +`nodep.GetFreePorts(count int, excludePorts []int)`; pass `nil` for no exclusions. -### clash_meta +Listeners remain open during selection to avoid duplicates or repeatedly picking +an excluded port, and close before the function returns, including on failure. +The returned ports are therefore candidates, not reservations: another process +can claim them before the caller binds. This checks TCP only, not UDP. -Parse Clash.Meta configuration. +## share + +libXray stores outbound names in `tag`. `sendThrough` keeps its native Xray +meaning as the local bind address. ### generate_share -convert Xray Json to VMessAEAD/VLESS sharing protocol. +Convert Xray JSON to VMessAEAD / VLESS share links following +[Xray-core discussion #716](https://github.com/XTLS/Xray-core/discussions/716). +Hysteria2, SS, SOCKS and Trojan share links are also supported. VMess always generates +an AEAD URI, not the legacy QR-code format. + +Outbounds without a supported share-link format are skipped. Conversion fails +if no share links can be generated. ### parse_share -convert VMessAEAD/VLESS sharing protocol to Xray Json. +Parse VMessAEAD / VLESS, Hysteria2, SS, SOCKS and Trojan share links into Xray JSON. +Legacy VMessQrCode links (`vmess://Base64(JSON)`) are not supported. + +Xray JSON node input and Base64 / Age subscription wrappers remain supported. +Clash/Mihomo configurations remain unsupported. + +Hysteria2 accepts `hysteria2://` and `hy2://` using the +[official URI scheme](https://v2.hysteria.network/docs/developers/URI-Scheme/): +optional authentication, default port 443, IPv6, SNI, Salamander obfuscation, +multi-port authorities and fragment names. Export uses `hysteria2://`. +Port hopping uses Core's `finalmask.udp` with both local and remote interval +hopping (30 seconds by default; `hop-interval` must be at least 5 seconds). +Legacy `ports` / `mport` queries are accepted and exported as authority ports. +Legacy `up` / `down` values are imported as client-local QUIC tuning, not exported. + +TLS is mandatory. `insecure=true` / `allowInsecure=true` and `pinSHA256` are +rejected: the bundled Core cannot preserve those Hysteria TLS semantics. +Explicit Xray `fp`, `alpn`, `ech`, `pcs` and `vcn` extensions retain their Xray +meaning. Export rejects TLS/mask settings that cannot be represented, rather +than silently dropping security or obfuscation. Client-local QUIC tuning is +not part of the URI. Realm and Gecko are not supported. Hopping requires a +direct UDP socket; this does not add hopping support over chained proxies. + +VMessAEAD / VLESS field mappings follow the +[Xray share-link proposal](https://github.com/XTLS/Xray-core/discussions/716) +where supported by the bundled Core: + +- mKCP `mtu` and `tti` are preserved in both directions. Omitted values use + Core defaults; Core validates their ranges. Legacy KCP `seed` and `headerType` + are not imported or exported. +- XHTTP `extra` keeps its complete JSON content, including nested settings. + `fm` carries FinalMask masks and all Core-supported `quicParams` fields. +- TLS `ech`, `pcs`, `vcn` and REALITY `pbk`, `sid`, `pqv`, `spx` are preserved + alongside `sni`, `fp` and TLS `alpn`. An omitted `sni` uses the remote host, + not the WebSocket HTTP host. +- Native transport aliases retain their settings when exported. RAW/TCP links + use `type=tcp`; query values use percent-encoded spaces. +- gRPC supports `gun` and `multi`. The bundled Core does not support `guna` + mode or the removed HTTP/QUIC transports; these are not silently substituted. + +#### Parsing result + +`convertShareLinksToXrayJson` has one response shape. Its payload contains +`text` and optional `age`. Every successful conversion returns +`data: {"outbounds":[...]}`. There is no statistics or nested config wrapper. + +Invalid individual elements are skipped without discarding other valid nodes. +The list preserves source order and includes only projected, buildable outbounds. +No per-node hash comparison, deduplication or failed-node counting is performed. + +No usable nodes, an unrecognized format, a malformed document, an invalid +container or a decryption failure returns `success: false` with `data: null`. +Error text never includes rejected candidates or decrypted subscription text. +Callers must not import/replace a subscription when no usable nodes remain. + +### decodeSubscriptionText + +Returns the complete subscription text, including full Xray JSON objects or +arrays and their DNS, routing, balancers and unknown fields. This method only +unwraps Age armor: unencrypted text, including URI lists and Base64 strings, +is returned exactly as supplied. It does not validate JSON or decode Base64. + +```json +{ + "apiVersion": 3, + "method": "decodeSubscriptionText", + "payload": { + "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", + "age": { + "secretKey": "AGE-SECRET-KEY-1..." + } + } +} +``` + +Success returns `data: {"text": ""}`; the `text` field is +present even for empty text. Errors return `data: null` and the existing Age +key, armor, decryption or plaintext-size error. Both unencrypted and decrypted +plaintext are limited to 16 MiB of UTF-8 bytes. The complete Invoke request and +encoded response retain their separate 16 MiB limits, so envelope or escaping +overhead can reject text below the plaintext limit. The Go helper is +`share.DecodeSubscriptionText(text, secretKey)`. + +The caller must classify the returned text before importing nodes or storing a +complete configuration. This method does not create a temporary Core and can +be called while a managed instance is running. + +### age-encrypted subscriptions -convert VMessQRCode to Xray Json. +`decodeSubscriptionText` and `convertShareLinksToXrayJson` accept an optional +native age secret key. Only +X25519 (`AGE-SECRET-KEY-1...`) and ML-KEM-768 + X25519 hybrid +(`AGE-SECRET-KEY-PQ-1...`) identities are accepted. Recognized age armor is +decrypted in memory and limited to 16 MiB of plaintext. -### vmess +```json +{ + "apiVersion": 3, + "method": "convertShareLinksToXrayJson", + "payload": { + "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", + "age": { + "secretKey": "AGE-SECRET-KEY-1..." + } + } +} +``` -convert VMessQRCode to Xray Json. +Generate a new keypair with `keyType` set to `x25519` or `hybrid`. An omitted +`keyType` defaults to `x25519`. The `hybrid` option matches Mihomo +`age keygen-pq` and produces an `AGE-SECRET-KEY-PQ-1...` identity with an +`age1pq1...` recipient. + +```json +{ + "apiVersion": 3, + "method": "generateAgeKeyPair", + "payload": { + "keyType": "x25519" + } +} +``` + +The response contains both `secretKey` and `publicKey`. The integrating +application must persist the pair and send only `publicKey` as +`X-Age-Public-Key`. libXray does not perform the subscription HTTP request, +persist keys, or add headers. Applications must never send the secret key over +HTTP or log secret keys or decrypted subscription text. Configuration +persistence belongs to the integrating application. ### xray_json @@ -256,9 +500,89 @@ Some tools used to parse shared links. ## xray -### ping +### pingBatch -Latency testing. +Tests multiple outbound configurations concurrently in one temporary Xray +instance. Each `xrayJson` string is parsed only for its `outbounds`; all other +root fields are ignored. The target outbound is selected by `outboundTag`, then +by the `proxy` tag, and finally by the first outbound. + +```json +{ + "apiVersion": 3, + "method": "pingBatch", + "payload": { + "configs": [ + { + "xrayJson": "{\"outbounds\":[...]}" + }, + { + "xrayJson": "{\"outbounds\":[...]}", + "outboundTag": "media" + } + ], + "timeout": 5, + "url": "https://cp.cloudflare.com/" + } +} +``` + +Each request accepts at most five configurations and tests all accepted +configurations concurrently. Requests containing more than five configurations +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` are +included automatically. Xray-core rejects the removed `proxySettings` field. + +Each prepared item sends one latency HEAD through its selected outbound and +dependencies, using the configured timeout. Its result contains only +`success`, `delay` and an optional `error`. Invalid outbound configurations +retain their per-item failure and do not send a request. + +### testXray + +Loads the supplied JSON text and constructs a temporary Xray instance. The +payload contains only `xrayJson`; success returns `data: {}`: + +```json +{ + "apiVersion": 3, + "method": "testXray", + "payload": { + "xrayJson": "{\"outbounds\":[...]}" + } +} +``` + +The Go entrypoint `TestXray` calls `newXrayInstance` (`core.LoadConfig` followed +by `core.New`) and closes the successfully constructed instance before returning. +It never calls `Start` or publishes a managed instance. Core construction errors, +including invalid routing matchers and missing balancers, and close errors are +returned through the normal error response. + +This is not a sandbox. Builders and constructors may read local GeoData and +certificates, apply `env`, replace process-global logging/DNS state, create log +files, or initialize protocol-specific resources and background tasks. Process +state is not restored. In particular, WireGuard can acquire a TUN during +construction and VLESS reverse can schedule background work. The current Core +does not return its partial instance when construction fails, so libXray cannot +close that partial instance. Core-managed geodata cron does not run without +`Start`, although its asset declarations still check local files. + +Callers may provide a minimal configuration or remove App-managed fields from a +disposable validation copy; libXray applies no App-specific filtering. Only the +supplied configuration is checked. Success establishes instance construction and +close, not listener/TUN startup, system permissions, or network connectivity. +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. ### metrics @@ -288,11 +612,9 @@ 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. Counters belong +to the current Xray instance; libXray does not sample or persist traffic. ### validation @@ -310,6 +632,8 @@ Start and stop Xray instances. [FreePort](https://github.com/phayes/freeport) +[MetaCubeX age](https://github.com/MetaCubeX/age) (BSD 3-Clause) + # License This repository is based on the MIT License. diff --git a/android_wrapper.go b/android_wrapper.go index 3000f2fc..9cfae632 100644 --- a/android_wrapper.go +++ b/android_wrapper.go @@ -2,50 +2,64 @@ package libXray -import c "github.com/xtls/libxray/controller" +import ( + "errors" + + c "github.com/xtls/libxray/controller" + "github.com/xtls/libxray/dns" +) type DialerController interface { ProtectFd(int) bool } -// ProcessFinder is an interface for Android process finding functionality. -// Apps should implement FindProcessByConnection() -// and pass the implementation to RegisterProcessFinder() before starting the core. +// SetDNS installs an Android VPN-aware process resolver. server must be an IP +// endpoint such as 8.8.8.8:53 or [2001:4860:4860::8888]:53. +func SetDNS(controller DialerController, server string) error { + if controller == nil { + return errors.New("dns dialer controller is nil") + } + return dns.SetDNS(server, func(fd uintptr) bool { + return controller.ProtectFd(int(fd)) + }) +} + +// ResetDNS restores the resolver that was active before SetDNS. +func ResetDNS() { + dns.ResetDNS() +} + +// ProcessFinder -> implemented by apps to resolve UID from connection details. +// See RegisterProcessFinder. type ProcessFinder interface { - // FindProcessByConnection finds the UID of the process that owns the given connection. - // - // network: Protocol type: "tcp" or "udp" - // srcIP: Source IP address - // srcPort: Source port - // destIP: Destination IP address - // destPort: Destination port - // Returns the UID of the owning process, or -1 if not found. + // FindProcessByConnection -> returns UID owning the connection, or -1. FindProcessByConnection(network, srcIP string, srcPort int, destIP string, destPort int) int } func RegisterDialerController(controller DialerController) { - c.RegisterDialerController(func(fd uintptr) { - controller.ProtectFd(int(fd)) + c.RegisterDialerController(func(fd uintptr) bool { + return controller.ProtectFd(int(fd)) }) } func RegisterListenerController(controller DialerController) { - c.RegisterListenerController(func(fd uintptr) { - controller.ProtectFd(int(fd)) + c.RegisterListenerController(func(fd uintptr) bool { + return controller.ProtectFd(int(fd)) }) } -// RegisterProcessFinder registers an Android process finder with Xray-core, -// enabling per-app routing based on UID. Must be called before starting the -// core for process-based routing rules to work. -// Pass nil to unregister a previously registered finder. -func RegisterProcessFinder(finder ProcessFinder) { +// RegisterProcessFinder -> registers process finder for per-app routing. +// Pass nil to unregister. +// sdkVersion = Build.VERSION.SDK_INT. +// On API < 30, /proc/net/* parsing is used automatically; the Java callback +// is only called on API 30+. +func RegisterProcessFinder(finder ProcessFinder, sdkVersion int) { if finder == nil { - c.RegisterProcessFinder(nil) + c.RegisterProcessFinder(nil, 0) return } c.RegisterProcessFinder(func(network, srcIP string, srcPort int, destIP string, destPort int) int { return finder.FindProcessByConnection(network, srcIP, srcPort, destIP, destPort) - }) + }, sdkVersion) } diff --git a/build/app/android.py b/build/app/android.py index d8fdd708..7be74dca 100644 --- a/build/app/android.py +++ b/build/app/android.py @@ -12,26 +12,28 @@ def before_build(self): self.prepare_gomobile() def build(self): - self.before_build() - - clean_files = ["libXray-sources.jar", "libXray.aar"] - self.clean_lib_files(clean_files) - os.chdir(self.lib_dir) - # keep same with flutter - ret = subprocess.run( - [ - "gomobile", - "bind", - "-target", - "android", - "-androidapi", - "21", - "-ldflags=-checklinkname=0 -extldflags=-Wl,-z,max-page-size=16384", - ] - ) - if ret.returncode != 0: - raise Exception("build failed") - - self.after_build() - - self.revert_go_env() + self.snapshot_go_env() + try: + self.before_build() + + clean_files = ["libXray-sources.jar", "libXray.aar"] + self.clean_lib_files(clean_files) + os.chdir(self.lib_dir) + # keep same with flutter + ret = subprocess.run( + [ + "gomobile", + "bind", + "-target", + "android", + "-androidapi", + "21", + "-trimpath", + "-ldflags=-checklinkname=0 -s -w -buildid= " + "-extldflags=-Wl,-z,max-page-size=16384", + ] + ) + if ret.returncode != 0: + raise Exception("build failed") + finally: + self.restore_go_env() diff --git a/build/app/apple_go.py b/build/app/apple_go.py index df6c5869..a36d8835 100644 --- a/build/app/apple_go.py +++ b/build/app/apple_go.py @@ -1,8 +1,6 @@ import os.path import shutil import subprocess -import random -import time from app.build import Builder from app.cmd import create_dir_if_not_exists, delete_dir_if_exists @@ -23,8 +21,13 @@ def __init__( class AppleGoBuilder(Builder): - def __init__(self, build_dir: str, use_local_xray_core: bool = False): - super().__init__(build_dir, use_local_xray_core) + def __init__( + self, + build_dir: str, + use_local_xray_core: bool = False, + keep_go_env_snapshot: bool = True, + ): + super().__init__(build_dir, use_local_xray_core, keep_go_env_snapshot) self.framework_dir = os.path.join(self.lib_dir, "apple_xcframework") delete_dir_if_exists(self.framework_dir) create_dir_if_not_exists(self.framework_dir) @@ -56,21 +59,20 @@ def __init__(self, build_dir: str, use_local_xray_core: bool = False): ), ] - # keep same with flutter self.macos_targets = [ AppleTarget( "darwin", "amd64", "x86_64", "macosx", - "10.14", + "12.0", ), AppleTarget( "darwin", "arm64", "arm64", "macosx", - "10.14", + "12.0", ), ] @@ -104,32 +106,31 @@ def before_build(self): self.prepare_static_lib() def build(self): - self.before_build() - # build ios - self.build_targets(self.ios_targets) - self.merge_static_lib( - self.ios_targets[1].sdk, - [self.ios_targets[1].apple_arch, self.ios_targets[2].apple_arch], - ) - # build macos - self.build_targets(self.macos_targets) - self.merge_static_lib( - self.macos_targets[0].sdk, - [self.macos_targets[0].apple_arch, self.macos_targets[1].apple_arch], - ) - # build tvos - self.build_targets(self.tvos_targets) - self.merge_static_lib( - self.tvos_targets[1].sdk, - [self.tvos_targets[1].apple_arch, self.tvos_targets[2].apple_arch], - ) - - self.after_build() - - self.create_include_dir() - self.create_framework() - - self.revert_go_env() + self.snapshot_go_env() + try: + self.before_build() + # build ios + self.build_targets(self.ios_targets) + self.merge_static_lib( + self.ios_targets[1].sdk, + [self.ios_targets[1].apple_arch, self.ios_targets[2].apple_arch], + ) + # build macos + self.build_targets(self.macos_targets) + self.merge_static_lib( + self.macos_targets[0].sdk, + [self.macos_targets[0].apple_arch, self.macos_targets[1].apple_arch], + ) + # build tvos + self.build_targets(self.tvos_targets) + self.merge_static_lib( + self.tvos_targets[1].sdk, + [self.tvos_targets[1].apple_arch, self.tvos_targets[2].apple_arch], + ) + self.create_include_dir() + self.create_framework() + finally: + self.restore_go_env() def build_targets(self, targets: list[AppleTarget]): for target in targets: @@ -162,51 +163,7 @@ def run_build_cmd( run_env["CGO_ENABLED"] = "1" run_env["DARWIN_SDK"] = sdk - # Enhanced obfuscation for uniqueness of each build - ldflags = "-s -w" # Strip symbol table and DWARF debug info - - # Add build timestamp with microseconds for greater uniqueness - build_timestamp = time.strftime("%Y%m%d%H%M%S") + str( - int(time.time() * 1000) % 1000 - ) - ldflags += f" -X 'main.buildTime={build_timestamp}'" - - # Add random identifier for each build - random_id = "".join( - random.choices( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", k=16 - ) - ) - ldflags += f" -X 'main.buildID={random_id}'" - - # Add git commit hash if available - try: - git_hash = ( - subprocess.check_output( - ["git", "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL - ) - .decode() - .strip() - ) - ldflags += f" -X 'main.buildVersion={git_hash}'" - except: - # If git is not available, use a random hash - fake_hash = "".join(random.choices("0123456789abcdef", k=7)) - ldflags += f" -X 'main.buildVersion={fake_hash}'" - - # Add random unused variables to change binary fingerprint - for i in range(5): - var_name = f"unusedVar{i}" - var_value = "".join( - random.choices( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", - k=8, - ) - ) - ldflags += f" -X 'main.{var_name}={var_value}'" - - # Use compatible optimization flags - gcflags = "all=-l=4" # Limit inlining but keep other optimizations + ldflags = "-s -w -buildid=" cmd = [ "go", @@ -214,10 +171,9 @@ def run_build_cmd( "-trimpath", # Remove all file system paths "-ldflags", ldflags, - "-gcflags", - gcflags, f"-o={output_file}", "-buildmode=c-archive", + self.main_package(), ] os.chdir(self.lib_dir) print(cmd) @@ -298,27 +254,3 @@ def create_framework(self): ret = subprocess.run(cmd) if ret.returncode != 0: raise Exception(f"create_framework failed") - - def after_build(self): - super().after_build() - self.reset_files() - - # Modify framework metadata for additional uniqueness - try: - framework_path = os.path.join(self.lib_dir, "LibXray.xcframework") - info_plist = os.path.join(framework_path, "Info.plist") - if os.path.exists(info_plist): - # Add random comment to Info.plist - random_comment = f"" - with open(info_plist, "r") as f: - content = f.read() - - if " str: + return "./cgo_bridge" + + def build_desktop_bin(self, file_name: str): + output_dir = os.path.join(self.lib_dir, "bin") + create_dir_if_not_exists(output_dir) + output_file = os.path.join(output_dir, file_name) + run_env = os.environ.copy() + run_env["CGO_ENABLED"] = "0" + cmd = [ + "go", + "build", + "-trimpath", + "-buildvcs=false", + "-ldflags", + "-s -w -buildid=", + f"-o={output_file}", + "./desktop_bin", + ] + print(cmd) + ret = subprocess.run(cmd, cwd=self.lib_dir, env=run_env) + if ret.returncode != 0: + raise Exception("build_desktop_bin failed") def before_build(self): self.prepare_xray_core() @@ -141,44 +202,3 @@ def before_build(self): def build(self): pass - - def after_build(self): - pass - - def reset_files(self): - self.clean_lib_files(["main.go"]) - files = os.listdir(self.lib_dir) - for file in files: - if file.endswith(".go"): - self.reset_package_name(file) - - def reset_package_name(self, file_name: str): - file_path = os.path.join(self.lib_dir, file_name) - new_lines = [] - with open(file_path, "r") as f: - lines = f.readlines() - for line in lines: - new_line = line - if re.match(r"^package\s+main", line): - new_line = "package libXray\n" - new_lines.append(new_line) - with open(file_path, "w") as f: - f.writelines(new_lines) - - def revert_go_env(self): - os.chdir(self.lib_dir) - ret = subprocess.run( - ["go", "mod", "edit", f"-dropreplace={XRAY_CORE_MOD_NAME}"] - ) - if ret.returncode != 0: - raise Exception("go mod edit dropreplace failed") - - ret = subprocess.run( - ["go", "get", f"{XRAY_CORE_MOD_NAME}@{DEFAULT_XRAY_CORE_VERSION}"] - ) - if ret.returncode != 0: - raise Exception("go get xray-core failed") - - ret = subprocess.run(["go", "mod", "tidy"]) - if ret.returncode != 0: - raise Exception("go mod tidy failed") diff --git a/build/app/linux.py b/build/app/linux.py index d0d69ee5..704f01c5 100644 --- a/build/app/linux.py +++ b/build/app/linux.py @@ -6,23 +6,32 @@ class LinuxBuilder(Builder): - def __init__(self, build_dir: str, use_local_xray_core: bool = False): - super().__init__(build_dir, use_local_xray_core) + def __init__( + self, + build_dir: str, + use_local_xray_core: bool = False, + keep_go_env_snapshot: bool = True, + ): + super().__init__(build_dir, use_local_xray_core, keep_go_env_snapshot) self.framework_dir = os.path.join(self.lib_dir, "linux_so") delete_dir_if_exists(self.framework_dir) create_dir_if_not_exists(self.framework_dir) self.lib_file = "libXray.so" self.lib_header_file = "libXray.h" + self.bin_file = "xray" def before_build(self): super().before_build() self.prepare_static_lib() def build(self): - self.before_build() - self.build_linux() - self.after_build() - self.revert_go_env() + self.snapshot_go_env() + try: + self.before_build() + self.build_linux() + self.build_desktop_bin(self.bin_file) + finally: + self.restore_go_env() def build_linux(self): output_dir = self.framework_dir @@ -41,13 +50,10 @@ def build_linux(self): "-s -w", f"-o={output_file}", "-buildmode=c-shared", + self.main_package(), ] os.chdir(self.lib_dir) print(cmd) ret = subprocess.run(cmd, env=run_env) if ret.returncode != 0: raise Exception(f"build_linux failed") - - def after_build(self): - super().after_build() - self.reset_files() diff --git a/build/app/macos.py b/build/app/macos.py deleted file mode 100644 index deaeaf60..00000000 --- a/build/app/macos.py +++ /dev/null @@ -1,76 +0,0 @@ -import os.path -import subprocess - -from app.build import Builder -from app.cmd import create_dir_if_not_exists, delete_dir_if_exists - - -class MacOSBuilder(Builder): - """Builds a c-shared dylib (like the Windows DLL / Linux .so) so desktop - apps can drive libXray over plain FFI instead of linking the static - xcframework. Produces a universal (arm64 + x86_64) libXray.dylib.""" - - def __init__(self, build_dir: str, use_local_xray_core: bool = False): - super().__init__(build_dir, use_local_xray_core) - self.framework_dir = os.path.join(self.lib_dir, "macos_dylib") - delete_dir_if_exists(self.framework_dir) - create_dir_if_not_exists(self.framework_dir) - self.lib_file = "libXray.dylib" - self.lib_header_file = "libXray.h" - - def before_build(self): - super().before_build() - self.prepare_static_lib() - - def build(self): - self.before_build() - self.build_macos() - self.after_build() - self.revert_go_env() - - def build_macos(self): - output_dir = self.framework_dir - create_dir_if_not_exists(output_dir) - os.chdir(self.lib_dir) - - arch_files = [] - for go_arch, cc_arch in (("arm64", "arm64"), ("amd64", "x86_64")): - output_file = os.path.join(output_dir, f"libXray-{go_arch}.dylib") - run_env = os.environ.copy() - run_env["CGO_ENABLED"] = "1" - run_env["GOOS"] = "darwin" - run_env["GOARCH"] = go_arch - run_env["CC"] = f"clang -arch {cc_arch}" - - cmd = [ - "go", - "build", - "-trimpath", - "-ldflags", - "-s -w", - f"-o={output_file}", - "-buildmode=c-shared", - ] - print(cmd, f"GOARCH={go_arch}") - ret = subprocess.run(cmd, env=run_env) - if ret.returncode != 0: - raise Exception(f"build_macos {go_arch} failed") - arch_files.append(output_file) - - universal_file = os.path.join(output_dir, self.lib_file) - ret = subprocess.run(["lipo", "-create", *arch_files, "-output", universal_file]) - if ret.returncode != 0: - raise Exception("lipo create failed") - - # Keep one header (identical across arches), drop per-arch leftovers. - header_src = arch_files[0].replace(".dylib", ".h") - os.replace(header_src, os.path.join(output_dir, self.lib_header_file)) - for f in arch_files: - os.remove(f) - per_arch_header = f.replace(".dylib", ".h") - if os.path.exists(per_arch_header): - os.remove(per_arch_header) - - def after_build(self): - super().after_build() - self.reset_files() diff --git a/build/app/windows.py b/build/app/windows.py index 7b0353ae..3ed8df18 100644 --- a/build/app/windows.py +++ b/build/app/windows.py @@ -6,23 +6,32 @@ class WindowsBuilder(Builder): - def __init__(self, build_dir: str, use_local_xray_core: bool = False): - super().__init__(build_dir, use_local_xray_core) + def __init__( + self, + build_dir: str, + use_local_xray_core: bool = False, + keep_go_env_snapshot: bool = True, + ): + super().__init__(build_dir, use_local_xray_core, keep_go_env_snapshot) self.framework_dir = os.path.join(self.lib_dir, "windows_dll") delete_dir_if_exists(self.framework_dir) create_dir_if_not_exists(self.framework_dir) self.lib_file = "libXray.dll" self.lib_header_file = "libXray.h" + self.bin_file = "xray.exe" def before_build(self): super().before_build() self.prepare_static_lib() def build(self): - self.before_build() - self.build_windows() - self.after_build() - self.revert_go_env() + self.snapshot_go_env() + try: + self.before_build() + self.build_windows() + self.build_desktop_bin(self.bin_file) + finally: + self.restore_go_env() def build_windows(self): output_dir = self.framework_dir @@ -41,13 +50,10 @@ def build_windows(self): "-s -w", f"-o={output_file}", "-buildmode=c-shared", + self.main_package(), ] os.chdir(self.lib_dir) print(cmd) ret = subprocess.run(cmd, env=run_env) if ret.returncode != 0: raise Exception(f"run_build_cmd failed") - - def after_build(self): - super().after_build() - self.reset_files() diff --git a/build/main.py b/build/main.py index eb1b6111..d3d056b8 100644 --- a/build/main.py +++ b/build/main.py @@ -6,10 +6,10 @@ from app.apple_go import AppleGoBuilder from app.apple_gomobile import AppleGoMobileBuilder from app.linux import LinuxBuilder -from app.macos import MacOSBuilder from app.windows import WindowsBuilder LOCAL_ARG = "local" +NO_SNAPSHOT_ARG = "--no-snapshot" def build_dir_path(): @@ -18,12 +18,12 @@ def build_dir_path(): return dir_path -def parse_local_arg(args: list[str]) -> bool: - if not args: - return False - if args == [LOCAL_ARG]: - return True - raise Exception(f"unsupported args: {args}") +def parse_build_args(args: list[str]) -> tuple[bool, bool]: + if len(args) != len(set(args)) or any( + arg not in (LOCAL_ARG, NO_SNAPSHOT_ARG) for arg in args + ): + raise Exception(f"unsupported args: {args}") + return LOCAL_ARG in args, NO_SNAPSHOT_ARG not in args if __name__ == "__main__": @@ -32,34 +32,39 @@ def parse_local_arg(args: list[str]) -> bool: if platform == "apple": tool = sys.argv[2] - use_local_xray_core = parse_local_arg(sys.argv[3:]) + use_local_xray_core, keep_go_env_snapshot = parse_build_args(sys.argv[3:]) if tool == "go": - builder = AppleGoBuilder(build_dir_path(), use_local_xray_core) + builder = AppleGoBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) builder.build() elif tool == "gomobile": - builder = AppleGoMobileBuilder(build_dir_path(), use_local_xray_core) + builder = AppleGoMobileBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) builder.build() else: raise Exception(f"platform {platform} tool {tool} not supported") elif platform == "android": - use_local_xray_core = parse_local_arg(sys.argv[2:]) - builder = AndroidBuilder(build_dir_path(), use_local_xray_core) + use_local_xray_core, keep_go_env_snapshot = parse_build_args(sys.argv[2:]) + builder = AndroidBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) builder.build() elif platform == "linux": - use_local_xray_core = parse_local_arg(sys.argv[2:]) - builder = LinuxBuilder(build_dir_path(), use_local_xray_core) - builder.build() - - elif platform == "macos": - use_local_xray_core = parse_local_arg(sys.argv[2:]) - builder = MacOSBuilder(build_dir_path(), use_local_xray_core) + use_local_xray_core, keep_go_env_snapshot = parse_build_args(sys.argv[2:]) + builder = LinuxBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) builder.build() elif platform == "windows": - use_local_xray_core = parse_local_arg(sys.argv[2:]) - builder = WindowsBuilder(build_dir_path(), use_local_xray_core) + use_local_xray_core, keep_go_env_snapshot = parse_build_args(sys.argv[2:]) + builder = WindowsBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) builder.build() else: diff --git a/build/template/main.gotemplate b/build/template/main.gotemplate deleted file mode 100644 index a2584590..00000000 --- a/build/template/main.gotemplate +++ /dev/null @@ -1,24 +0,0 @@ -package main - -/* -#include -*/ -import "C" - -import "unsafe" - -func main() {} - -//export CGoInvoke -func CGoInvoke(requestJSON *C.char) *C.char { - text := C.GoString(requestJSON) - return C.CString(Invoke(text)) -} - -// CGoFree releases a string CGoInvoke returned. It must be freed by the C -// runtime that allocated it — this library's — which a caller cannot name. -// -//export CGoFree -func CGoFree(p *C.char) { - C.free(unsafe.Pointer(p)) -} diff --git a/build/test_build.py b/build/test_build.py new file mode 100644 index 00000000..63956632 --- /dev/null +++ b/build/test_build.py @@ -0,0 +1,212 @@ +"""Run: python3 build/test_build.py. No Go or platform build is run.""" +from pathlib import Path +import runpy +import shutil +import subprocess +import unittest +from unittest.mock import call, patch +from uuid import uuid4 + +from app.android import AndroidBuilder +from app.apple_go import AppleGoBuilder +from app.apple_gomobile import AppleGoMobileBuilder +from app.build import LIBXRAY_MOD_NAME, XRAY_CORE_MOD_NAME, DEFAULT_XRAY_CORE_VERSION +from app.linux import LinuxBuilder +from app.windows import WindowsBuilder +from main import parse_build_args + + +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_no_snapshot_retains_modules_on_success_and_failure(self): + for exists in (False, True): + for fails in (False, True): + with self.subTest(exists=exists, fails=fails): + self.builder = AndroidBuilder( + str(self.root / "build"), keep_go_env_snapshot=False + ) + for name in ("go.mod", "go.sum"): + path = self.root / name + if exists: + path.write_text("original\n") + else: + path.unlink(missing_ok=True) + + def prepare(): + self.assertIsNone(self.builder._go_env_snapshot) + (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(), "effective module\n") + self.assertEqual((self.root / "go.sum").read_text(), "effective sums\n") + self.assertIsNone(self.builder._go_env_snapshot) + + def test_build_args(self): + for args, expected in ( + ([], (False, True)), + (["local"], (True, True)), + (["--no-snapshot"], (False, False)), + (["local", "--no-snapshot"], (True, False)), + (["--no-snapshot", "local"], (True, False)), + ): + with self.subTest(args=args): + self.assertEqual(parse_build_args(args), expected) + for args in ( + ["unknown"], ["local", "local"], ["--no-snapshot", "--no-snapshot"], + ): + with self.subTest(args=args): + with self.assertRaisesRegex(Exception, "unsupported args"): + parse_build_args(args) + + def test_no_snapshot_reinitializes_modules_before_dependency_commands(self): + for use_local in (False, True): + with self.subTest(use_local=use_local): + (self.root / "go.mod").write_text("old module\n") + (self.root / "go.sum").write_text("old sums\n") + builder = AndroidBuilder(str(self.root / "build"), use_local, False) + + def run(args): + if args == ["go", "mod", "init", LIBXRAY_MOD_NAME]: + self.assertFalse((self.root / "go.mod").exists()) + self.assertFalse((self.root / "go.sum").exists()) + (self.root / "go.mod").write_text("new module\n") + else: + self.assertEqual((self.root / "go.mod").read_text(), "new module\n") + return subprocess.CompletedProcess(args, 0) + + with patch("app.build.os.chdir"), patch( + "app.build.subprocess.run", side_effect=run + ) as commands: + builder.init_go_env() + expected = [call(["go", "mod", "init", LIBXRAY_MOD_NAME])] + if use_local: + expected.append(call([ + "go", "mod", "edit", f"-replace={XRAY_CORE_MOD_NAME}=../Xray-core", + ])) + else: + expected.extend([ + call(["go", "mod", "edit", f"-dropreplace={XRAY_CORE_MOD_NAME}"]), + call(["go", "get", f"{XRAY_CORE_MOD_NAME}@{DEFAULT_XRAY_CORE_VERSION}"]), + ]) + expected.append(call(["go", "mod", "tidy"])) + self.assertEqual(commands.call_args_list, expected) + + def test_default_initialization_keeps_existing_modules(self): + (self.root / "go.mod").write_text("original module\n") + (self.root / "go.sum").write_text("original sums\n") + with patch("app.build.os.chdir"), patch( + "app.build.subprocess.run", return_value=subprocess.CompletedProcess([], 0) + ) as commands: + self.builder.init_go_env() + self.assertEqual((self.root / "go.mod").read_text(), "original module\n") + self.assertEqual((self.root / "go.sum").read_text(), "original sums\n") + self.assertNotIn(call(["go", "mod", "init", LIBXRAY_MOD_NAME]), commands.call_args_list) + + def test_all_builders_accept_snapshot_option(self): + for builder_type in ( + AndroidBuilder, AppleGoBuilder, AppleGoMobileBuilder, + LinuxBuilder, WindowsBuilder, + ): + for keep_snapshot in (False, True): + with self.subTest(builder=builder_type.__name__, keep_snapshot=keep_snapshot): + builder = builder_type( + str(self.root / "build"), True, keep_snapshot + ) + self.assertTrue(builder.use_local_xray_core) + self.assertEqual(builder.keep_go_env_snapshot, keep_snapshot) + + def test_cli_passes_options_to_every_builder(self): + script = Path(__file__).with_name("main.py") + for target, builder_path in ( + (["android"], "app.android.AndroidBuilder"), + (["apple", "go"], "app.apple_go.AppleGoBuilder"), + (["apple", "gomobile"], "app.apple_gomobile.AppleGoMobileBuilder"), + (["linux"], "app.linux.LinuxBuilder"), + (["windows"], "app.windows.WindowsBuilder"), + ): + for options in ([], ["local"], ["--no-snapshot"], ["local", "--no-snapshot"], ["--no-snapshot", "local"]): + with self.subTest(target=target, options=options), patch( + builder_path + ) as builder, patch( + "sys.argv", [str(script), *target, *options] + ), patch("builtins.print"): + runpy.run_path(str(script), run_name="__main__") + builder.assert_called_once_with( + str(script.parent), *parse_build_args(options) + ) + builder.return_value.build.assert_called_once_with() + + 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/build/test_verify_core.py b/build/test_verify_core.py new file mode 100644 index 00000000..36e45506 --- /dev/null +++ b/build/test_verify_core.py @@ -0,0 +1,83 @@ +"""Tests for build/verify_core.py's checks. + + python3 build/test_verify_core.py +""" +import unittest + +import verify_core + +GO_MOD = """module github.com/xtls/libxray + +go 1.27.3 + +require ( + github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 + github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf + golang.org/x/mobile v0.0.0-20261011034528-197770444867 +) +""" + +XRAY_CORE = "v1.260327.1-0.20261021115930-1b1647cd9ddf" + + +def report(go: str, xray_core: str = XRAY_CORE) -> str: + return ( + f"linux_so/libXray.so: go{go}\n" + "\tpath\tgithub.com/xtls/libxray/cgo_bridge\n" + "\tmod\tgithub.com/xtls/libxray\t(devel)\t\n" + f"\tdep\tgithub.com/xtls/xray-core\t{xray_core}\th1:abc=\n" + "\tbuild\t-buildmode=c-shared\n" + ) + + +class VerifyCoreTest(unittest.TestCase): + def test_go_mod_names_the_floor_and_the_core(self): + self.assertEqual(verify_core.parse_go_mod(GO_MOD), ((1, 27, 3), XRAY_CORE)) + + def test_a_single_line_require_is_read_too(self): + text = "module m\n\ngo 1.27.3\n\nrequire github.com/xtls/xray-core v1.2.3\n" + self.assertEqual(verify_core.parse_go_mod(text), ((1, 27, 3), "v1.2.3")) + + def test_go_mod_without_the_core_is_an_error(self): + with self.assertRaises(ValueError): + verify_core.parse_go_mod("module m\n\ngo 1.27.3\n") + + def test_the_floor_and_later_pass(self): + for go in ("1.27.3", "1.27.10", "1.28", "1.28.1"): + self.assertEqual(verify_core.problems("x", report(go), (1, 27, 3), XRAY_CORE), [], go) + + def test_older_go_fails_compared_as_numbers(self): + for go in ("1.27.2", "1.27", "1.26.9"): + found = verify_core.problems("x", report(go), (1, 27, 3), XRAY_CORE) + self.assertEqual(len(found), 1, go) + self.assertIn("needs go1.27.3 or later", found[0]) + + def test_another_core_fails(self): + found = verify_core.problems("x", report("1.27.3", "v1.260327.1-0.20260627131803-45cf2898ab12"), (1, 27, 3), XRAY_CORE) + self.assertEqual(len(found), 1) + self.assertIn("expected " + XRAY_CORE, found[0]) + + def test_no_build_information_fails(self): + found = verify_core.problems("x", "", (1, 27, 3), XRAY_CORE) + self.assertIn("x: no Go build information", found) + + def test_a_cxx_runtime_import_is_found(self): + names = ["KERNEL32.dll", "VCRUNTIME140.dll", "msvcrt.dll", "msvcp140.dll", "api-ms-win-crt-runtime-l1-1-0.dll"] + self.assertEqual(verify_core.cxx_runtime_imports(names), ["VCRUNTIME140.dll", "msvcp140.dll"]) + self.assertEqual(verify_core.cxx_runtime_imports(["KERNEL32.dll", "msvcrt.dll"]), []) + + def test_mingw_runtime_libraries_are_found_too(self): + names = ["KERNEL32.dll", "libwinpthread-1.dll", "libgcc_s_seh-1.dll", "libstdc++-6.dll"] + self.assertEqual( + verify_core.cxx_runtime_imports(names), ["libgcc_s_seh-1.dll", "libstdc++-6.dll", "libwinpthread-1.dll"] + ) + + def test_a_dll_with_only_system_imports_passes(self): + self.assertEqual(verify_core.dll_problems("libXray.dll", ["KERNEL32.dll", "msvcrt.dll", "WS2_32.dll"]), []) + + def test_no_imports_read_is_a_failure_not_a_pass(self): + self.assertEqual(verify_core.dll_problems("libXray.dll", []), ["libXray.dll: no imports could be read"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/verify_core.py b/build/verify_core.py new file mode 100644 index 00000000..b61155d9 --- /dev/null +++ b/build/verify_core.py @@ -0,0 +1,127 @@ +"""Checks what a build produced, not the runner it ran on. + +Every Go binary in the given artifacts must be built with at least the Go +version go.mod's `go` line names, from the xray-core version go.mod requires: +Xray-core v26.10.21's certificate fix depends on both. An Android AAR is +checked through each of its jni/*/libgojni.so. With --no-cxx-runtime, a +Windows DLL must not import a C++ runtime, which a clean Windows 10 lacks. + + python build/verify_core.py linux_so/libXray.so + python build/verify_core.py --no-cxx-runtime windows_dll/libXray.dll + python build/verify_core.py libXray.aar +""" +import argparse +import re +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +GO_MOD = Path(__file__).resolve().parent.parent / "go.mod" +XRAY_CORE = "github.com/xtls/xray-core" + + +def version_tuple(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".")) + + +def version_text(version: tuple[int, ...]) -> str: + return ".".join(str(part) for part in version) + + +def parse_go_mod(text: str) -> tuple[tuple[int, ...], str]: + """The `go` line's version and the xray-core version go.mod requires.""" + go = re.search(r"^go\s+(\d+(?:\.\d+)*)\s*$", text, re.M) + xray = re.search(rf"^\s*(?:require\s+)?{re.escape(XRAY_CORE)}\s+(\S+)", text, re.M) + if not go or not xray: + raise ValueError("go.mod has no go line or no xray-core requirement") + return version_tuple(go.group(1)), xray.group(1) + + +def parse_version_m(text: str) -> tuple[tuple[int, ...] | None, str | None]: + """The Go version and xray-core version `go version -m` reports.""" + first = text.splitlines()[0] if text.strip() else "" + go = re.search(r":\s+go(\d+(?:\.\d+)*)", first) + dep = re.search(rf"^\s*dep\s+{re.escape(XRAY_CORE)}\s+(\S+)", text, re.M) + return (version_tuple(go.group(1)) if go else None, dep.group(1) if dep else None) + + +# Runtimes a clean Windows 10 lacks: MSVC's C++ runtime and MinGW's shared +# runtime libraries. +_FOREIGN_RUNTIMES = ("VCRUNTIME", "MSVCP", "LIBSTDC++", "LIBGCC_S", "LIBWINPTHREAD") + + +def cxx_runtime_imports(dll_names: list[str]) -> list[str]: + return sorted(name for name in dll_names if name.upper().startswith(_FOREIGN_RUNTIMES)) + + +def dll_problems(artifact: str, dll_names: list[str]) -> list[str]: + # A PE with no imports parsed means the parse failed, not a clean DLL. + if not dll_names: + return [f"{artifact}: no imports could be read"] + runtime = cxx_runtime_imports(dll_names) + return [f"{artifact}: imports a runtime a clean Windows lacks: {', '.join(runtime)}"] if runtime else [] + + +def problems(binary: str, report: str, floor: tuple[int, ...], xray_core: str) -> list[str]: + go, dep = parse_version_m(report) + found = [] + if go is None: + found.append(f"{binary}: no Go build information") + elif go < floor: + found.append(f"{binary}: built with go{version_text(go)}, needs go{version_text(floor)} or later") + if dep != xray_core: + found.append(f"{binary}: xray-core {dep}, expected {xray_core}") + return found + + +def go_binaries(artifact: Path, workdir: Path) -> list[Path]: + if artifact.suffix != ".aar": + return [artifact] + with zipfile.ZipFile(artifact) as aar: + names = [n for n in aar.namelist() if n.startswith("jni/") and n.endswith("/libgojni.so")] + for name in names: + aar.extract(name, workdir) + if not names: + raise SystemExit(f"{artifact}: no jni/*/libgojni.so") + return [workdir / name for name in names] + + +def dll_imports(path: Path) -> list[str]: + import pefile # the Windows leg installs it + + pe = pefile.PE(str(path), fast_load=True) + pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"]]) + return [entry.dll.decode() for entry in getattr(pe, "DIRECTORY_ENTRY_IMPORT", [])] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--no-cxx-runtime", action="store_true") + parser.add_argument("artifacts", nargs="+", type=Path) + args = parser.parse_args(argv) + floor, xray_core = parse_go_mod(GO_MOD.read_text()) + + found = [] + with tempfile.TemporaryDirectory() as tmp: + for artifact in args.artifacts: + for binary in go_binaries(artifact, Path(tmp)): + run = subprocess.run(["go", "version", "-m", str(binary)], capture_output=True, text=True) + report = run.stdout if run.returncode == 0 else "" + these = problems(str(binary), report, floor, xray_core) + found += these + if not these: + go, _ = parse_version_m(report) + print(f"{binary}: go{version_text(go)} (floor {version_text(floor)}), xray-core {xray_core}") + if args.no_cxx_runtime and artifact.suffix.lower() == ".dll": + names = dll_imports(artifact) + found += dll_problems(str(artifact), names) + print(f"{artifact}: imports {', '.join(sorted(names))}") + for problem in found: + print(problem, file=sys.stderr) + return 1 if found else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cgo_bridge/main.go b/cgo_bridge/main.go new file mode 100644 index 00000000..2b9ed660 --- /dev/null +++ b/cgo_bridge/main.go @@ -0,0 +1,25 @@ +package main + +/* +#include +*/ +import "C" + +import ( + "unsafe" + + libXray "github.com/xtls/libxray" +) + +func main() {} + +//export CGoInvoke +func CGoInvoke(requestJSON *C.char) *C.char { + text := C.GoString(requestJSON) + return C.CString(libXray.Invoke(text)) +} + +//export CGoFree +func CGoFree(value *C.char) { + C.free(unsafe.Pointer(value)) +} diff --git a/controller/controller.go b/controller/controller.go index b0b429f8..841304bf 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -1 +1,20 @@ package controller + +import ( + "errors" + "syscall" +) + +var errProtectSocket = errors.New("protect Android VPN socket failed") + +func protectSocket(conn syscall.RawConn, controller func(fd uintptr) bool) error { + var protectErr error + if err := conn.Control(func(fd uintptr) { + if !controller(fd) { + protectErr = errProtectSocket + } + }); err != nil { + return err + } + return protectErr +} diff --git a/controller/controller_android.go b/controller/controller_android.go index 82dc0b67..b7f3d396 100644 --- a/controller/controller_android.go +++ b/controller/controller_android.go @@ -10,27 +10,34 @@ import ( xinternet "github.com/xtls/xray-core/transport/internet" ) -// Give a callback before connection beginning. Useful for Android development. -// It depends on xray:api:beta -func RegisterDialerController(controller func(fd uintptr)) { +// RegisterDialerController -> callback before connection begins. +// Depends on xray:api:beta +func RegisterDialerController(controller func(fd uintptr) bool) { xinternet.RegisterDialerController(func(network, address string, conn syscall.RawConn) error { - return conn.Control(controller) + return protectSocket(conn, controller) }) } -// Give a callback before listener beginning. Useful for Android development. -// It depends on xray:api:beta -func RegisterListenerController(controller func(fd uintptr)) { +// RegisterListenerController -> callback before listener begins. +// Depends on xray:api:beta +func RegisterListenerController(controller func(fd uintptr) bool) { xinternet.RegisterListenerController(func(network, address string, conn syscall.RawConn) error { - return conn.Control(controller) + return protectSocket(conn, controller) }) } -// RegisterProcessFinder registers an Android process finder with Xray-core, -// enabling per-app routing based on UID. Must be called before starting the -// core for process-based routing rules to work. -// Pass nil to unregister a previously registered finder. -func RegisterProcessFinder(finder func(network, srcIP string, srcPort int, destIP string, destPort int) int) { +// sdkThresholdGetConnectionOwner -> min API level for getConnectionOwnerUid(). +// Below this we use /proc/net/* parsing. +const sdkThresholdGetConnectionOwner = 30 + +var androidSdkVersion int + +// RegisterProcessFinder -> registers process finder for per-app routing. +// Pass nil to unregister. sdkVersion = Build.VERSION.SDK_INT. +// When SDK < 30, falls back to /proc/net/* parsing (pure Go). +func RegisterProcessFinder(finder func(network, srcIP string, srcPort int, destIP string, destPort int) int, sdkVersion int) { + androidSdkVersion = sdkVersion + if finder == nil { corenet.RegisterAndroidProcessFinder(nil) return @@ -41,6 +48,13 @@ func RegisterProcessFinder(finder func(network, srcIP string, srcPort int, destI return 0, "", "", fmt.Errorf("process finder: no destination for %s %s:%d", network, srcIP, srcPort) } + if androidSdkVersion > 0 && androidSdkVersion < sdkThresholdGetConnectionOwner { + uid, err := resolveUidFromProc(network, srcIP, int(srcPort), destIP, int(destPort)) + if err == nil { + return uid, fmt.Sprintf("%d", uid), "", nil + } + } + uid := finder(network, srcIP, int(srcPort), destIP, int(destPort)) return uid, fmt.Sprintf("%d", uid), "", nil }) diff --git a/controller/controller_test.go b/controller/controller_test.go new file mode 100644 index 00000000..942c7167 --- /dev/null +++ b/controller/controller_test.go @@ -0,0 +1,56 @@ +package controller + +import ( + "errors" + "testing" +) + +type rawConnStub struct { + fd uintptr + controlErr error +} + +func (conn rawConnStub) Control(callback func(fd uintptr)) error { + if conn.controlErr != nil { + return conn.controlErr + } + callback(conn.fd) + return nil +} + +func (rawConnStub) Read(func(fd uintptr) (done bool)) error { + return nil +} + +func (rawConnStub) Write(func(fd uintptr) (done bool)) error { + return nil +} + +func TestProtectSocketPropagatesControllerFailure(t *testing.T) { + err := protectSocket(rawConnStub{fd: 42}, func(fd uintptr) bool { + return fd != 42 + }) + if !errors.Is(err, errProtectSocket) { + t.Fatalf("protectSocket() error = %v, want %v", err, errProtectSocket) + } +} + +func TestProtectSocketPropagatesRawConnFailure(t *testing.T) { + controlErr := errors.New("control failed") + err := protectSocket( + rawConnStub{controlErr: controlErr}, + func(uintptr) bool { return true }, + ) + if !errors.Is(err, controlErr) { + t.Fatalf("protectSocket() error = %v, want %v", err, controlErr) + } +} + +func TestProtectSocketSucceeds(t *testing.T) { + err := protectSocket(rawConnStub{fd: 42}, func(fd uintptr) bool { + return fd == 42 + }) + if err != nil { + t.Fatalf("protectSocket() error = %v", err) + } +} diff --git a/controller/proc_resolver_android.go b/controller/proc_resolver_android.go new file mode 100644 index 00000000..928c12f3 --- /dev/null +++ b/controller/proc_resolver_android.go @@ -0,0 +1,223 @@ +//go:build android + +package controller + +import ( + "bufio" + "encoding/hex" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +// /proc/net/* UID resolver. Used when getConnectionOwnerUid() (API 30+) is +// unavailable. UID is read directly from the connection line - no inode->PID +// chain needed. Matches PCAPdroid uid_resolver.c: +// +// https://github.com/emanuele-f/PCAPdroid/blob/master/app/src/main/jni/common/uid_resolver.c +// +// /proc/net/{tcp,udp} field layout (after strings.Fields): +// +// [0] sl [1] local_address [2] rem_address [3] st +// [4] tx_queue:rx_queue [5] tr:tm->when [6] retrnsmt +// [7] uid [8] timeout [9] inode + +// resolveUidFromProc -> tries IPv4 first, falls back to IPv4-mapped IPv6 +// (matching PCAPdroid get_uid_slow). +func resolveUidFromProc(network, srcIP string, srcPort int, destIP string, destPort int) (int, error) { + ip := net.ParseIP(srcIP) + if ip == nil { + return -1, fmt.Errorf("bad src IP %s", srcIP) + } + + if ip4 := ip.To4(); ip4 != nil { + uid, err := resolve4(network, ip4, srcPort, destIP, destPort) + if err == nil { + return uid, nil + } + // PCAPdroid: sprintf("0000000000000000FFFF0000%08X", ip4) + // Catches IPv4 conns shown in tcp6/udp6. + mapped := make([]byte, 16) + copy(mapped[10:], []byte{0xFF, 0xFF}) + copy(mapped[12:], ip4) + return resolve6(network, mapped, srcPort, destIP, destPort) + } + + ip16 := ip.To16() + if ip16 != nil { + return resolve6(network, ip16, srcPort, destIP, destPort) + } + + return -1, fmt.Errorf("bad IP family for %s", srcIP) +} + +func resolve4(network string, ip4 []byte, srcPort int, destIP string, destPort int) (int, error) { + procFile := procFilePath(network, false) + if procFile == "" { + return -1, fmt.Errorf("unsupported network %s", network) + } + targetHex, _ := formatHexAddr4(ip4, srcPort) + destHex, _ := formatDestHex(destIP, destPort, false) + return findUidInProcFile(procFile, targetHex, destHex) +} + +func resolve6(network string, ip16 []byte, srcPort int, destIP string, destPort int) (int, error) { + procFile := procFilePath(network, true) + if procFile == "" { + return -1, fmt.Errorf("unsupported network %s", network) + } + targetHex, _ := formatHexAddr6(ip16, srcPort) + destHex, _ := formatDestHex(destIP, destPort, true) + return findUidInProcFile(procFile, targetHex, destHex) +} + +func procFilePath(network string, v6 bool) string { + switch network { + case "tcp": + if v6 { + return "/proc/net/tcp6" + } + return "/proc/net/tcp" + case "udp": + if v6 { + return "/proc/net/udp6" + } + return "/proc/net/udp" + default: + return "" + } +} + +// formatDestHex -> format dest IP:port for matching. Returns "" if dest +// info is missing (match any remote). +func formatDestHex(destIP string, destPort int, v6 bool) (string, error) { + ip := net.ParseIP(destIP) + if ip == nil || destPort <= 0 { + return "", nil + } + if v6 { + if ip16 := ip.To16(); ip16 != nil { + return formatHexAddr6(ip16, destPort) + } + if ip4 := ip.To4(); ip4 != nil { + mapped := make([]byte, 16) + copy(mapped[10:], []byte{0xFF, 0xFF}) + copy(mapped[12:], ip4) + return formatHexAddr6(mapped, destPort) + } + return "", nil + } + if ip4 := ip.To4(); ip4 != nil { + return formatHexAddr4(ip4, destPort) + } + return "", nil +} + +// /proc/net/* stores IPs per 32-bit word in little-endian byte order. +// PCAPdroid casts in6_addr to uint32_t[4] on LE hardware; we replicate +// that here. + +func formatHexAddr4(ip4 []byte, port int) (string, error) { + b := make([]byte, 4) + copy(b, ip4) + for i, j := 0, 3; i < j; i, j = i+1, j-1 { + b[i], b[j] = b[j], b[i] + } + return fmt.Sprintf("%s:%04X", strings.ToUpper(hex.EncodeToString(b)), port), nil +} + +func formatHexAddr6(ip16 []byte, port int) (string, error) { + words := make([]string, 4) + for w := 0; w < 4; w++ { + off := w * 4 + b := []byte{ip16[off+3], ip16[off+2], ip16[off+1], ip16[off+0]} + words[w] = strings.ToUpper(hex.EncodeToString(b)) + } + return fmt.Sprintf("%s:%04X", strings.Join(words, ""), port), nil +} + +// allZeros -> true if s is all '0' characters. +func allZeros(s string) bool { + for _, c := range s { + if c != '0' { + return false + } + } + return len(s) > 0 +} + +// findUidInProcFile -> scans /proc/net/{tcp,udp,..} for a line matching +// (targetHex, destHex). Matching per PCAPdroid get_uid_proc: +// +// sport == src_port +// (dport == dst_port || dport == 0) +// (dhex == conn_dhex || dhex == zero) +// (shex == conn_shex || shex == zero) +// +// where shex/dhex/sport/dport are from /proc and +// conn_shex/conn_dhex/src_port/dst_port are our connection args. +func findUidInProcFile(filePath, targetHex, destHex string) (int, error) { + f, err := os.Open(filePath) + if err != nil { + return -1, err + } + defer f.Close() + + s := bufio.NewScanner(f) + first := true + for s.Scan() { + if first { + first = false + continue + } + + fields := strings.Fields(s.Text()) + if len(fields) < 8 { + continue + } + + // Split hex:port into parts for independent matching. + loc := strings.SplitN(fields[1], ":", 2) + tgt := strings.SplitN(targetHex, ":", 2) + if len(loc) != 2 || len(tgt) != 2 { + continue + } + + // sport == src_port + if loc[1] != tgt[1] { + continue + } + // shex == conn_shex || shex == zero + if loc[0] != tgt[0] && !allZeros(loc[0]) { + continue + } + + // Match remote when we have a dest filter. + if destHex != "" { + rem := strings.SplitN(fields[2], ":", 2) + dst := strings.SplitN(destHex, ":", 2) + if len(rem) != 2 || len(dst) != 2 { + continue + } + + // dport == dst_port || dport == 0 + if rem[1] != dst[1] && rem[1] != "0000" { + continue + } + // dhex == conn_dhex || dhex == zero + if rem[0] != dst[0] && !allZeros(rem[0]) { + continue + } + } + + uid, err := strconv.Atoi(fields[7]) + if err != nil { + continue + } + return uid, nil + } + + return -1, fmt.Errorf("not found in %s", filePath) +} diff --git a/desktop_bin/command.go b/desktop_bin/command.go new file mode 100644 index 00000000..6ec76cd5 --- /dev/null +++ b/desktop_bin/command.go @@ -0,0 +1,79 @@ +//go:build windows || (linux && !android) + +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" +) + +type runOptions struct { + dns string + interfaceName string + configPath string + errorFile string +} + +func parseRunOptions(args []string) (runOptions, error) { + var options runOptions + if len(args) == 0 || args[0] != "run" { + return options, errors.New("expected run command") + } + + flags := flag.NewFlagSet("run", flag.ContinueOnError) + flags.SetOutput(io.Discard) + 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.errorFile, "error-file", "", "also write command errors to this file") + if err := flags.Parse(args[1:]); err != nil { + return options, err + } + if flags.NArg() != 0 { + return options, errors.New("unexpected positional arguments") + } + if options.dns == "" || options.interfaceName == "" || options.configPath == "" { + return options, errors.New("dns, interface, and config are required") + } + return options, nil +} + +func execute(args []string, run func(runOptions) error, stdout, stderr io.Writer) int { + usage := func() { + fmt.Fprintln(stdout, "Usage: xray run -dns -interface -config [-error-file ]") + } + if len(args) == 1 && (args[0] == "-h" || args[0] == "--help") { + usage() + return 0 + } + + options, err := parseRunOptions(args) + if errors.Is(err, flag.ErrHelp) { + usage() + return 0 + } + if err == nil && options.errorFile != "" { + // Clear the previous failure before starting. Reuse a caller-created file + // so an elevated process preserves the caller's read permissions. + if err := os.WriteFile(options.errorFile, nil, 0600); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + } + if err == nil { + err = run(options) + } + if err != nil { + fmt.Fprintln(stderr, err) + if options.errorFile != "" { + if writeErr := os.WriteFile(options.errorFile, []byte(err.Error()), 0600); writeErr != nil { + fmt.Fprintln(stderr, writeErr) + } + } + return 1 + } + return 0 +} diff --git a/desktop_bin/command_test.go b/desktop_bin/command_test.go new file mode 100644 index 00000000..adf2e8cf --- /dev/null +++ b/desktop_bin/command_test.go @@ -0,0 +1,116 @@ +//go:build windows || (linux && !android) + +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestParseRunOptions(t *testing.T) { + options, err := parseRunOptions([]string{ + "run", + "-dns", "8.8.8.8:53", + "-interface", "Ethernet", + "-config", `C:\run\xray.json`, + }) + if err != nil { + t.Fatal(err) + } + if options.dns != "8.8.8.8:53" || options.interfaceName != "Ethernet" || options.configPath != `C:\run\xray.json` { + t.Fatalf("unexpected options: %#v", options) + } + + if _, err := parseRunOptions([]string{"run", "-config", "xray.json"}); err == nil { + t.Fatal("missing DNS protection options were accepted") + } +} + +func TestCommandReportsActualStartupError(t *testing.T) { + directory := commandTestDirectory(t) + path := filepath.Join(directory, "xray.json.error") + if err := os.WriteFile(path, nil, 0600); err != nil { + t.Fatal(err) + } + args := []string{"run", "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", "xray.json", "-error-file", path} + want := "failed to load geosite: category TEST-MISSING not found" + var stdout, stderr bytes.Buffer + called := false + code := execute(args, func(runOptions) error { + called = true + return errors.New(want) + }, &stdout, &stderr) + if code != 1 || !called { + t.Fatalf("run was not attempted: code=%d, called=%v, stderr=%q", code, called, stderr.String()) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != want || stderr.String() != want+"\n" { + t.Fatalf("diagnostics differ: file=%q, stderr=%q", data, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("unexpected stdout: %s", &stdout) + } +} + +func TestCommandClearsStaleErrorBeforeRun(t *testing.T) { + path := filepath.Join(commandTestDirectory(t), "xray.json.error") + if err := os.WriteFile(path, []byte("previous failure"), 0600); err != nil { + t.Fatal(err) + } + args := []string{"run", "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", "xray.json", "-error-file", path} + var stdout, stderr bytes.Buffer + code := execute(args, func(runOptions) error { + data, err := os.ReadFile(path) + if err != nil || len(data) != 0 { + t.Fatalf("stale error remains before run: %q, err=%v", data, err) + } + return nil + }, &stdout, &stderr) + if code != 0 || stderr.Len() != 0 { + t.Fatalf("successful run failed: code=%d, stderr=%q", code, stderr.String()) + } +} + +func TestCommandWithoutErrorFilePreservesStderr(t *testing.T) { + args := []string{"run", "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", "xray.json"} + var stdout, stderr bytes.Buffer + code := execute(args, func(runOptions) error { + return errors.New("actual startup error") + }, &stdout, &stderr) + if code != 1 || stderr.String() != "actual startup error\n" { + t.Fatalf("unexpected error: code=%d, stderr=%q", code, stderr.String()) + } +} + +func TestCommandRejectsUnwritableErrorFileBeforeRun(t *testing.T) { + path := filepath.Join(commandTestDirectory(t), "missing", "error") + args := []string{"run", "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", "xray.json", "-error-file", path} + var stdout, stderr bytes.Buffer + code := execute(args, func(runOptions) error { + t.Fatal("run must not start when its error file cannot be prepared") + return nil + }, &stdout, &stderr) + if code != 1 || !bytes.Contains(stderr.Bytes(), []byte(path)) { + t.Fatalf("missing file error: code=%d, stderr=%q", code, stderr.String()) + } +} + +func commandTestDirectory(t *testing.T) string { + t.Helper() + root := filepath.Join("..", "..", "references", "libxray-desktop-tests") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + directory, err := os.MkdirTemp(root, "command-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(directory) }) + return directory +} diff --git a/desktop_bin/main.go b/desktop_bin/main.go new file mode 100644 index 00000000..a6433ddc --- /dev/null +++ b/desktop_bin/main.go @@ -0,0 +1,37 @@ +//go:build windows || (linux && !android) + +package main + +import ( + "os" + "os/signal" + "syscall" + + "github.com/xtls/libxray/dns" + "github.com/xtls/libxray/xray" +) + +func run(options runOptions) error { + config, err := os.ReadFile(options.configPath) + if err != nil { + return err + } + if err := dns.SetDNS(options.dns, options.interfaceName); err != nil { + return err + } + defer dns.ResetDNS() + + if err := xray.RunXray(string(config)); err != nil { + return err + } + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + <-signals + return xray.StopXray() +} + +func main() { + os.Exit(execute(os.Args[1:], run, os.Stdout, os.Stderr)) +} diff --git a/dns/dns_android.go b/dns/dns_android.go new file mode 100644 index 00000000..34ea7fae --- /dev/null +++ b/dns/dns_android.go @@ -0,0 +1,20 @@ +//go:build android + +package dns + +// SetDNS replaces Go's process-wide default resolver with an Android VPN-aware +// resolver. The caller must serialize this with the Xray lifecycle. +func SetDNS(server string, protect protectSocket) error { + resolver, err := newResolver(server, protect) + if err != nil { + return err + } + + installDefaultResolver(resolver) + return nil +} + +// ResetDNS restores the resolver that was active before SetDNS. +func ResetDNS() { + restoreDefaultResolver() +} diff --git a/dns/dns_desktop.go b/dns/dns_desktop.go new file mode 100644 index 00000000..d3001ed3 --- /dev/null +++ b/dns/dns_desktop.go @@ -0,0 +1,47 @@ +//go:build windows || (linux && !android) + +package dns + +import ( + "errors" + "net" +) + +// SetDNS installs a process-wide resolver bound to interfaceName. +func SetDNS(server, interfaceName string) error { + if interfaceName == "" { + return errors.New("dns interface is required") + } + iface, err := net.InterfaceByName(interfaceName) + if err != nil { + return err + } + if err := validateDNSInterface(iface); err != nil { + return err + } + + resolver, err := newProtectedResolver(server, func(network string, fd uintptr) error { + return bindDNSInterface(network, fd, iface) + }) + if err != nil { + return err + } + if err := preflightResolver(resolver, server); err != nil { + return err + } + + installDefaultResolver(resolver) + return nil +} + +func validateDNSInterface(iface *net.Interface) error { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + return errors.New("dns interface must be an active non-loopback interface") + } + return nil +} + +// ResetDNS restores the resolver that was active before SetDNS. +func ResetDNS() { + restoreDefaultResolver() +} diff --git a/dns/dns_desktop_test.go b/dns/dns_desktop_test.go new file mode 100644 index 00000000..d5eacda9 --- /dev/null +++ b/dns/dns_desktop_test.go @@ -0,0 +1,70 @@ +//go:build windows || (linux && !android) + +package dns + +import ( + "net" + "testing" + + "github.com/stretchr/testify/require" + xrayNet "github.com/xtls/xray-core/common/net" +) + +func TestSetDNSInstallsAndRestoresResolver(t *testing.T) { + interfaces, err := net.Interfaces() + require.NoError(t, err) + + var interfaceName string + for _, iface := range interfaces { + if iface.Flags&net.FlagUp != 0 && iface.Flags&net.FlagLoopback == 0 { + interfaceName = iface.Name + break + } + } + if interfaceName == "" { + t.Skip("no active non-loopback interface") + } + + original := net.DefaultResolver + originalXray := xrayNet.DefaultResolver + t.Cleanup(func() { + resolverMu.Lock() + defer resolverMu.Unlock() + net.DefaultResolver = original + xrayNet.DefaultResolver = originalXray + previousResolver = nil + previousXrayResolver = nil + }) + + require.NoError(t, SetDNS("8.8.8.8:53", interfaceName)) + require.NotSame(t, original, net.DefaultResolver) + require.Same(t, net.DefaultResolver, xrayNet.DefaultResolver) + require.True(t, net.DefaultResolver.PreferGo) + + ResetDNS() + require.Same(t, original, net.DefaultResolver) + require.Same(t, originalXray, xrayNet.DefaultResolver) +} + +func TestValidateDNSInterface(t *testing.T) { + tests := []struct { + name string + flags net.Flags + wantErr bool + }{ + {name: "active", flags: net.FlagUp}, + {name: "inactive", wantErr: true}, + {name: "loopback", flags: net.FlagUp | net.FlagLoopback, wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateDNSInterface(&net.Interface{Flags: test.flags}) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} diff --git a/dns/dns_linux.go b/dns/dns_linux.go new file mode 100644 index 00000000..ca571b6e --- /dev/null +++ b/dns/dns_linux.go @@ -0,0 +1,17 @@ +//go:build linux && !android + +package dns + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +func bindDNSInterface(network string, fd uintptr, iface *net.Interface) error { + if err := unix.BindToDevice(int(fd), iface.Name); err != nil { + return fmt.Errorf("bind DNS %s socket to interface %q: %w", network, iface.Name, err) + } + return nil +} diff --git a/dns/dns_windows.go b/dns/dns_windows.go new file mode 100644 index 00000000..cc4d7087 --- /dev/null +++ b/dns/dns_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package dns + +import ( + "fmt" + "math/bits" + "net" + + "golang.org/x/sys/windows" +) + +const ( + ipUnicastInterface = 31 + ipv6UnicastInterface = 31 +) + +func bindDNSInterface(network string, fd uintptr, iface *net.Interface) error { + var err error + switch network { + case "tcp4", "udp4", "ip4": + index := int(int32(bits.ReverseBytes32(uint32(iface.Index)))) + err = windows.SetsockoptInt( + windows.Handle(fd), + windows.IPPROTO_IP, + ipUnicastInterface, + index, + ) + case "tcp6", "udp6", "ip6": + err = windows.SetsockoptInt( + windows.Handle(fd), + windows.IPPROTO_IPV6, + ipv6UnicastInterface, + iface.Index, + ) + default: + return fmt.Errorf("unsupported DNS network %q for interface %q", network, iface.Name) + } + if err != nil { + return fmt.Errorf("bind DNS %s socket to interface %q: %w", network, iface.Name, err) + } + return nil +} diff --git a/dns/resolver.go b/dns/resolver.go new file mode 100644 index 00000000..990194ff --- /dev/null +++ b/dns/resolver.go @@ -0,0 +1,118 @@ +package dns + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "sync" + "syscall" + "time" + + xrayNet "github.com/xtls/xray-core/common/net" +) + +const resolverTimeout = 16 * time.Second + +var errProtectDNSConnection = errors.New("protect DNS connection failed") + +var ( + resolverMu sync.Mutex + previousResolver *net.Resolver + previousXrayResolver *net.Resolver +) + +type protectSocket func(fd uintptr) bool +type controlSocket func(network string, fd uintptr) error + +func installDefaultResolver(resolver *net.Resolver) { + resolverMu.Lock() + defer resolverMu.Unlock() + if previousResolver == nil { + previousResolver = net.DefaultResolver + previousXrayResolver = xrayNet.DefaultResolver + } + net.DefaultResolver = resolver + // Xray-core caches the standard resolver during package initialization. + xrayNet.DefaultResolver = resolver +} + +func restoreDefaultResolver() { + resolverMu.Lock() + defer resolverMu.Unlock() + if previousResolver == nil { + return + } + net.DefaultResolver = previousResolver + xrayNet.DefaultResolver = previousXrayResolver + previousResolver = nil + previousXrayResolver = nil +} + +func newResolver(server string, protect protectSocket) (*net.Resolver, error) { + var control controlSocket + if protect != nil { + control = func(_ string, fd uintptr) error { + if !protect(fd) { + return errProtectDNSConnection + } + return nil + } + } + return newProtectedResolver(server, control) +} + +func newProtectedResolver(server string, control controlSocket) (*net.Resolver, error) { + if err := validateServer(server); err != nil { + return nil, err + } + + dialer := &net.Dialer{Timeout: resolverTimeout} + if control != nil { + dialer.Control = func(network, _ string, connection syscall.RawConn) error { + var controlErr error + if err := connection.Control(func(fd uintptr) { + controlErr = control(network, fd) + }); err != nil { + return err + } + return controlErr + } + } + + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + // Always use the DNS endpoint selected by the VPN configuration. + return dialer.DialContext(ctx, network, server) + }, + }, nil +} + +func preflightResolver(resolver *net.Resolver, server string) error { + connection, err := resolver.Dial(context.Background(), "udp", server) + if err != nil { + return err + } + return connection.Close() +} + +func validateServer(server string) error { + host, portText, err := net.SplitHostPort(server) + if err != nil { + return fmt.Errorf("invalid DNS server %q: %w", server, err) + } + if zoneIndex := strings.LastIndexByte(host, '%'); zoneIndex >= 0 { + host = host[:zoneIndex] + } + if net.ParseIP(host) == nil { + return fmt.Errorf("invalid DNS server IP %q", host) + } + port, err := strconv.Atoi(portText) + if err != nil || port < 1 || port > 65535 { + return fmt.Errorf("invalid DNS server port %q", portText) + } + return nil +} diff --git a/dns/resolver_test.go b/dns/resolver_test.go new file mode 100644 index 00000000..d41934ab --- /dev/null +++ b/dns/resolver_test.go @@ -0,0 +1,137 @@ +package dns + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/stretchr/testify/require" + xrayNet "github.com/xtls/xray-core/common/net" +) + +func TestDefaultResolverLifecycle(t *testing.T) { + before := net.DefaultResolver + beforeXray := xrayNet.DefaultResolver + original := &net.Resolver{} + originalXray := &net.Resolver{} + net.DefaultResolver = original + xrayNet.DefaultResolver = originalXray + t.Cleanup(func() { + resolverMu.Lock() + defer resolverMu.Unlock() + net.DefaultResolver = before + xrayNet.DefaultResolver = beforeXray + previousResolver = nil + previousXrayResolver = nil + }) + + first := &net.Resolver{PreferGo: true} + second := &net.Resolver{StrictErrors: true} + + installDefaultResolver(first) + require.Same(t, first, net.DefaultResolver) + require.Same(t, first, xrayNet.DefaultResolver) + + installDefaultResolver(second) + require.Same(t, second, net.DefaultResolver) + require.Same(t, second, xrayNet.DefaultResolver) + + restoreDefaultResolver() + require.Same(t, original, net.DefaultResolver) + require.Same(t, originalXray, xrayNet.DefaultResolver) + + restoreDefaultResolver() + require.Same(t, original, net.DefaultResolver) + require.Same(t, originalXray, xrayNet.DefaultResolver) +} + +func TestNewResolverUsesConfiguredServerAndProtectsSocket(t *testing.T) { + server, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer server.Close() + + protected := false + resolver, err := newResolver(server.LocalAddr().String(), func(uintptr) bool { + protected = true + return true + }) + require.NoError(t, err) + + connection, err := resolver.Dial( + context.Background(), + "udp", + "127.0.0.1:53", + ) + require.NoError(t, err) + defer connection.Close() + + require.True(t, protected) + require.Equal(t, server.LocalAddr().String(), connection.RemoteAddr().String()) +} + +func TestNewResolverRejectsFailedProtection(t *testing.T) { + server, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer server.Close() + + resolver, err := newResolver(server.LocalAddr().String(), func(uintptr) bool { + return false + }) + require.NoError(t, err) + + connection, err := resolver.Dial( + context.Background(), + "udp", + "127.0.0.1:53", + ) + if connection != nil { + connection.Close() + } + require.ErrorIs(t, err, errProtectDNSConnection) +} + +func TestNewProtectedResolverReturnsControlError(t *testing.T) { + controlErr := errors.New("bind DNS interface") + controlCalled := false + resolver, err := newProtectedResolver("127.0.0.1:53", func(string, uintptr) error { + controlCalled = true + return controlErr + }) + require.NoError(t, err) + + err = preflightResolver(resolver, "127.0.0.1:53") + + require.True(t, controlCalled) + require.ErrorIs(t, err, controlErr) +} + +func TestNewResolverValidatesServer(t *testing.T) { + tests := []struct { + name string + server string + }{ + {name: "missing port", server: "8.8.8.8"}, + {name: "hostname", server: "dns.example.com:53"}, + {name: "zero port", server: "8.8.8.8:0"}, + {name: "invalid port", server: "8.8.8.8:dns"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resolver, err := newResolver(test.server, nil) + require.Nil(t, resolver) + require.Error(t, err) + }) + } + + for _, server := range []string{ + "8.8.8.8:53", + "[2001:4860:4860::8888]:53", + } { + t.Run(server, func(t *testing.T) { + resolver, err := newResolver(server, nil) + require.NotNil(t, resolver) + require.NoError(t, err) + }) + } +} diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..6c599a74 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,23 @@ +# Domain Docs + +This repository uses a single-context layout: domain terminology lives in +`CONTEXT.md` at the repository root, and architecture decisions live in +`docs/adr/`. + +## Reading rules + +- Before exploring the codebase, read the root `CONTEXT.md` when it exists. +- Before changing an implementation, read ADRs under `docs/adr/` that affect + the area being changed, alongside the current contracts linked from `AGENTS.md`. +- If these files do not exist, proceed silently without creating setup + placeholders. The `/domain-modeling` skill creates them when terminology + or decisions actually need recording. + +## Vocabulary and decisions + +- Use terms defined in `CONTEXT.md`; avoid synonyms the glossary explicitly + rejects. +- If a needed concept is missing, first check whether it is existing project + terminology. Leave genuine gaps for `/domain-modeling` to resolve. +- When a proposal contradicts an existing ADR, explicitly identify the + conflict and the reasoning instead of silently overriding the decision. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..10c8e808 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,60 @@ +# Issue tracker: GitHub + +Issues and specs live in GitHub Issues for `XTLS/libXray`. + +Use the `gh` CLI. Because this clone's `origin` uses `yiguo.dev`, +include `--repo XTLS/libXray` in every `gh issue` and `gh pr` command. +For `gh api`, use explicit `repos/XTLS/libXray/...` endpoints. + +## Conventions + +- Use English for issue and PR titles, descriptions, and GitHub comments. +- Keep PR titles, descriptions, and comments self-contained. Do not mention + or link to another repository's PR, including companion, dependency, or + merge-order references. Describe required interface or build behavior directly. +- Use [triage-labels.md](triage-labels.md) for canonical triage roles. +- Use a quoted heredoc for multiline bodies to preserve literal shell characters. + +## Reviews and authorization + +- For PR reviews, resolve the actual remote base/head and record the commit IDs + without switching the checkout. Unpushed local changes are not the PR diff. +- For local reviews, use the user's requested fixed point. Follow `code-review` + for separate Standards and Spec findings with severity, location, impact and evidence. +- Analysis and review are read-only unless the user also authorizes changes or + publication. They do not authorize code edits, GitHub comments, label changes, + closure or pushes. Check actual labels before an authorized label operation. + +## Issue operations + +- Publish a ticket: `gh issue create --repo XTLS/libXray --title "..." --body "..."` +- Fetch a ticket: `gh issue view --repo XTLS/libXray --comments`; include its labels when assessing state. +- List tickets: `gh issue list --repo XTLS/libXray --state open --json number,title,body,labels,comments` +- Comment: `gh issue comment --repo XTLS/libXray --body "..."` +- Add or remove labels: `gh issue edit --repo XTLS/libXray --add-label "..."` or `--remove-label "..."` +- Close: `gh issue close --repo XTLS/libXray --comment "..."` + +GitHub shares one number space across issues and PRs. Resolve an ambiguous +number with `gh pr view --repo XTLS/libXray`, falling back to +`gh issue view --repo XTLS/libXray`. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** + +## Wayfinding operations + +Used by `/wayfinder`. The map is one issue with child issues as tickets. + +- Map: label it `wayfinder:map`. +- Child ticket: link it as a GitHub sub-issue. If unavailable, add it to the + map's task list and put `Part of #` at the top of the child body. +- Child labels: `wayfinder:research`, `wayfinder:prototype`, + `wayfinder:grilling`, or `wayfinder:task`. +- Blocking: use GitHub issue dependencies. If unavailable, put + `Blocked by: #` at the top of the child body. +- Frontier: choose the first open, unassigned child in map order without + an open blocker. +- Claim: `gh issue edit --repo XTLS/libXray --add-assignee @me` +- Resolve: comment with the answer, close the child, then add a context + pointer to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..fe317920 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,11 @@ +# Triage Labels + +| Canonical role | GitHub label | Meaning | +| --- | --- | --- | +| `needs-triage` | `needs-triage` | Waiting for maintainer evaluation | +| `needs-info` | `needs-info` | Waiting for the reporter to provide more information | +| `ready-for-agent` | `ready-for-agent` | Requirements are clear; ready for an agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Not planned | + +When a skill mentions a canonical role, use its corresponding GitHub label. diff --git a/download_geo/main.go b/download_geo/main.go index b202c126..07ee3a5d 100644 --- a/download_geo/main.go +++ b/download_geo/main.go @@ -66,18 +66,15 @@ func makeLoadGeoDataRequest(datDir string, name string, geoType string) (string, payload, err := json.Marshal(&libXray.CountGeoDataRequest{ Name: name, GeoType: geoType, + DatDir: datDir, }) if err != nil { return "", err } request := libXray.LibXrayInvokeRequest{ - APIVersion: 1, + APIVersion: libXray.LibXrayAPIVersion, Method: libXray.LibXrayMethodCountGeoData, - Env: &libXray.LibXrayEnvJson{ - AssetLocation: datDir, - CertLocation: datDir, - }, - Payload: payload, + Payload: payload, } data, err := json.Marshal(&request) if err != nil { diff --git a/go.mod b/go.mod index df915815..37f6c722 100644 --- a/go.mod +++ b/go.mod @@ -1,58 +1,68 @@ module github.com/xtls/libxray -go 1.26.3 +go 1.27.3 require ( - github.com/stretchr/testify v1.11.1 - github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12 - google.golang.org/protobuf v1.36.11 - gopkg.in/yaml.v3 v3.0.1 + github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 + github.com/stretchr/testify v1.12.1 + github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf + golang.org/x/mobile v0.0.0-20261011034528-197770444867 + golang.org/x/sys v0.49.0 + google.golang.org/protobuf v1.36.12 ) require ( - github.com/andybalholm/brotli v1.0.6 // indirect - github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect - github.com/cloudflare/circl v1.6.4 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/andybalholm/brotli v1.2.6 // indirect + github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c // indirect github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect - github.com/google/btree v1.1.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jackpal/go-nat-pmp v1.1.0 // indirect github.com/juju/ratelimit v1.0.2 // indirect - github.com/klauspost/compress v1.17.4 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/miekg/dns v1.1.72 // indirect + github.com/klauspost/compress v1.20.3 // indirect + github.com/klauspost/cpuid/v2 v2.4.1 // indirect + github.com/koron/go-ssdp v0.9.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect + github.com/libp2p/go-netroute v0.4.0 // indirect + github.com/metacubex/hkdf v0.1.0 // indirect + github.com/metacubex/hpke v0.1.0 // indirect + github.com/metacubex/mlkem v0.1.0 // indirect + github.com/miekg/dns v1.1.73 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pion/dtls/v3 v3.1.4 // indirect + github.com/pion/dtls/v3 v3.1.14 // indirect github.com/pion/logging v0.2.4 // indirect - github.com/pion/stun/v3 v3.1.6 // indirect - github.com/pion/transport/v4 v4.0.2 // indirect - github.com/pires/go-proxyproto v0.12.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pion/stun/v3 v3.1.7 // indirect + github.com/pion/transport/v4 v4.1.1 // indirect + github.com/pion/transport/v5 v5.1.1 // indirect + github.com/pires/go-proxyproto v0.15.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect github.com/robfig/cron/v3 v3.0.1 // indirect - github.com/sagernet/sing v0.5.1 // indirect - github.com/sagernet/sing-shadowsocks v0.2.7 // indirect github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect - github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect - go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.45.0 // indirect + github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8 // indirect + github.com/yuin/gopher-lua v1.1.2 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a // indirect + golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect + golang.org/x/mod v0.42.0 // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect + golang.org/x/tools v0.52.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect - golang.zx2c4.com/wireguard/windows v1.0.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/grpc v1.81.1 // indirect + golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f // indirect + golang.zx2c4.com/wireguard/windows v1.1.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect + google.golang.org/grpc v1.84.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect + gvisor.dev/gvisor v0.0.0-20261017120855-9e9495997e29 // indirect + layeh.com/gopher-luar v1.0.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index c52062fb..4070cb20 100644 --- a/go.sum +++ b/go.sum @@ -1,140 +1,154 @@ -github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= -github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I= -github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= -github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= +github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c h1:cxK8qTA0YCsj68A7zLYDCfPgMTOyt3ePkHh+oXEmYU0= +github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c/go.mod h1:J7UZVOMF2M1LiE3iiUZWPuy8YCPAcLJOwVURrfJCvc0= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.1.0 h1:UInMLPV1VQdP860ggNiz0YxGvJH/bWzxL099y+1EdCs= +github.com/jackpal/go-nat-pmp v1.1.0/go.mod h1:m9o4DK1wHA4h2pPpErD5vwzWLf91tJcfNQ3QyUIbh5A= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= -github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4= +github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U= +github.com/klauspost/cpuid/v2 v2.4.1/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/koron/go-ssdp v0.9.1 h1:zvxbAAuJftJIZ8Jh8mda+LI7V92hYZf/sKprmOxpxwA= +github.com/koron/go-ssdp v0.9.1/go.mod h1:C43c047jWkDaeg9YuZlSh/QGqOieuWV6dbhWi/jcaLk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= -github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= +github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= +github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= +github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48= +github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c= +github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA= +github.com/metacubex/hkdf v0.1.0/go.mod h1:3seEfds3smgTAXqUGn+tgEJH3uXdsUjOiduG/2EtvZ4= +github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ= +github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U= +github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I= +github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ= +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= -github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= +github.com/pion/dtls/v3 v3.1.14 h1:GYLEMc4IKGL29lWRJux6zUf0XwwPxlsZ7RUlGk8DLsI= +github.com/pion/dtls/v3 v3.1.14/go.mod h1:iKFQNYrjsN2TiA2YKKMqB9MOZaFpjFULBI/A4sW0eyc= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= -github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= -github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= -github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= -github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM= -github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= +github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8= +github.com/pion/transport/v4 v4.1.1 h1:kEBtbp1sRad0tnkREDNJ3fmop/CR8p/YxXnpFbn9tqo= +github.com/pion/transport/v4 v4.1.1/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE= +github.com/pion/transport/v5 v5.1.1 h1:Sauq8YJqRopgRfIgnpfKpdpKG14g0N4jWlRgpWsEfN8= +github.com/pion/transport/v5 v5.1.1/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs= github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/sagernet/sing v0.5.1 h1:mhL/MZVq0TjuvHcpYcFtmSD1BFOxZ/+8ofbNZcg1k1Y= -github.com/sagernet/sing v0.5.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing-shadowsocks v0.2.7 h1:zaopR1tbHEw5Nk6FAkM05wCslV6ahVegEZaKMv9ipx8= -github.com/sagernet/sing-shadowsocks v0.2.7/go.mod h1:0rIKJZBR65Qi0zwdKezt4s57y/Tl1ofkaq6NlkzVuyE= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8= -github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= -github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12 h1:0RZBYuLmkxxlMrSsCNRrdNWHhW5zHTfkC2vAknzZ5P4= -github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12/go.mod h1:5J11wfaLGhI6HNOi90zVI6E87c1ykA8grPfUeHZYbhA= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8 h1:RjLnVvlzK4q6zWaSBPNRGsrgx2fbJDeUWffjw574uzs= +github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8/go.mod h1:ptlSkJ8k/7IDauNH0SA96JPOo+nYGLTv2Y3UMpwvAsY= +github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf h1:Eqz6sj4Up2NtymyVY2UtXNpHZqJGrMwKsXnGOySyUNI= +github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf/go.mod h1:rbK8dL0A1f4uYpNa3ljvB1U5qrcb4KQ4Pxb8yGdWZXo= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= -go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs= +go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a h1:V5oicZXS0oqZx30gY/apgSVD7YvNBfrIOdcMnlelJY4= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/mobile v0.0.0-20261011034528-197770444867 h1:rav73cdd7OQUPgt1aIFbZQTiPNnjfueKXvfOiLCY0Ss= +golang.org/x/mobile v0.0.0-20261011034528-197770444867/go.mod h1:HR/iBzuQmVTl/us733QPwrqHp43a8m9ZLgyKXD3ST6U= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= -golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= -golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= -golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8= -golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f h1:gJI6OVshWW0mvGyTVmW8mJoaw2OM69wVCeZkwpX+Jdc= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard/windows v1.1.1 h1:8/H97U1v1PNDNcBsMZgU3KFuND9MQdTsU2NOwmCXArE= +golang.zx2c4.com/wireguard/windows v1.1.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 h1:QG6hS4zFplRZvOQedzei4t2pT/zkQc3VR1u+S9/rcAw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk= -gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +gvisor.dev/gvisor v0.0.0-20261017120855-9e9495997e29 h1:78i+xrM/m4yMyXrQfZAceZuxZMiLaR3N+qQcttsRb5w= +gvisor.dev/gvisor v0.0.0-20261017120855-9e9495997e29/go.mod h1:d8j5eTxmSBFBRPmPqa/Ua4njH0ZXjx40fsTq9ximTD8= +layeh.com/gopher-luar v1.0.11 h1:8zJudpKI6HWkoh9eyyNFaTM79PY6CAPcIr6X/KTiliw= +layeh.com/gopher-luar v1.0.11/go.mod h1:TPnIVCZ2RJBndm7ohXyaqfhzjlZ+OA2SZR/YwL8tECk= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= diff --git a/invoke.go b/invoke.go index a9f94e23..112f7105 100644 --- a/invoke.go +++ b/invoke.go @@ -3,7 +3,8 @@ package libXray import ( "encoding/json" "errors" - "os" + "fmt" + "sync" "github.com/xtls/libxray/failover" "github.com/xtls/libxray/geo" @@ -11,7 +12,6 @@ import ( "github.com/xtls/libxray/probe" "github.com/xtls/libxray/share" "github.com/xtls/libxray/xray" - "github.com/xtls/xray-core/common/platform" ) type invokeResponse struct { @@ -20,7 +20,15 @@ type invokeResponse struct { Err string `json:"error"` } +const ( + maxInvokeJSONSizeMiB = 16 + maxInvokeJSONBytes = maxInvokeJSONSizeMiB * 1024 * 1024 +) + func Invoke(requestJSON string) string { + if len(requestJSON) > maxInvokeJSONBytes { + return encodeInvokeResponse(nil, errors.New(invokeJSONSizeLimitMessage("request"))) + } var request LibXrayInvokeRequest if err := json.Unmarshal([]byte(requestJSON), &request); err != nil { return encodeInvokeResponse(nil, err) @@ -28,35 +36,28 @@ func Invoke(requestJSON string) string { if err := validateAPIVersion(request.APIVersion); err != nil { return encodeInvokeResponse(nil, err) } - applyEnv(request.Env) switch request.Method { case LibXrayMethodGetFreePorts: return invokeGetFreePorts(request.Payload) + case LibXrayMethodDecodeSubscriptionText: + return invokeDecodeSubscriptionText(request.Payload) case LibXrayMethodConvertShareLinksToXrayJson: return invokeConvertShareLinksToXrayJson(request.Payload) case LibXrayMethodConvertXrayJsonToShareLinks: return invokeConvertXrayJsonToShareLinks(request.Payload) + case LibXrayMethodGenerateAgeKeyPair: + return invokeGenerateAgeKeyPair(request.Payload) case LibXrayMethodCountGeoData: return invokeCountGeoData(request.Payload) - case LibXrayMethodPing: - return invokePing(request.Payload) + case LibXrayMethodPingBatch: + return invokePingBatch(request.Payload) case LibXrayMethodTestXray: return invokeTestXray(request.Payload) case LibXrayMethodRunXray: return invokeRunXray(request.Payload) - case LibXrayMethodRunXrayFromJson: - return invokeRunXrayFromJSON(request.Payload) case LibXrayMethodStopXray: - // The controller probes through the instance: stop it first. A - // latency sweep's readings across a disconnect are discarded anyway. - failover.Stop() - probe.StopSession() - err := xray.StopXray() - // Closing the tunnel's core leaves the process-global dialer on a - // dead instance; the probe host is the live one now. - probe.PointDialerAtHost() - return encodeInvokeNoDataResponse(err) + return invokeStopXray() case LibXrayMethodGetFailoverState: return encodeInvokeResponse(failover.CurrentState(), nil) case LibXrayMethodSetFailoverOrder: @@ -79,40 +80,13 @@ func Invoke(requestJSON string) string { return encodeInvokeResponse(nil, errors.New("unknown method")) } } - func validateAPIVersion(version int) error { - if version == 0 || version == 1 { + if version == LibXrayAPIVersion { return nil } return errors.New("unsupported apiVersion") } -func applyEnv(env *LibXrayEnvJson) { - if env == nil { - return - } - setEnvIfNotEmpty(platform.ConfigLocation, env.ConfigLocation) - setEnvIfNotEmpty(platform.ConfdirLocation, env.ConfdirLocation) - setEnvIfNotEmpty(platform.AssetLocation, env.AssetLocation) - setEnvIfNotEmpty(platform.CertLocation, env.CertLocation) - setEnvIfNotEmpty(platform.UseReadV, env.UseReadV) - setEnvIfNotEmpty(platform.UseFreedomSplice, env.UseFreedomSplice) - setEnvIfNotEmpty(platform.UseVmessPadding, env.UseVmessPadding) - setEnvIfNotEmpty(platform.UseCone, env.UseCone) - setEnvIfNotEmpty(platform.UseStrictJSON, env.UseStrictJSON) - setEnvIfNotEmpty(platform.BufferSize, env.BufferSize) - setEnvIfNotEmpty(platform.BrowserDialerAddress, env.BrowserDialerAddress) - setEnvIfNotEmpty(platform.XUDPLog, env.XUDPLog) - setEnvIfNotEmpty(platform.XUDPBaseKey, env.XUDPBaseKey) - setEnvIfNotEmpty(platform.TunFdKey, env.TunFd) -} - -func setEnvIfNotEmpty(key string, value string) { - if value != "" { - _ = os.Setenv(key, value) - } -} - func decodePayload[T any](payload json.RawMessage) (T, error) { var request T if len(payload) == 0 { @@ -132,7 +106,26 @@ func encodeInvokeResponse(data any, err error) string { } raw, err := json.Marshal(&response) if err != nil { - return `{"success":false,"error":"failed to encode response"}` + return `{"success":false,"data":null,"error":"failed to encode response"}` + } + if len(raw) > maxInvokeJSONBytes { + return encodeInvokeFailure(invokeJSONSizeLimitMessage("response")) + } + return string(raw) +} + +func invokeJSONSizeLimitMessage(kind string) string { + return fmt.Sprintf( + "invoke %s exceeds the %d MiB size limit", + kind, + maxInvokeJSONSizeMiB, + ) +} + +func encodeInvokeFailure(message string) string { + raw, err := json.Marshal(&invokeResponse{Err: message}) + if err != nil { + return `{"success":false,"data":null,"error":"failed to encode response"}` } return string(raw) } @@ -149,20 +142,55 @@ func invokeGetFreePorts(payload json.RawMessage) string { if err != nil { return encodeInvokeResponse(nil, err) } - ports, err := nodep.GetFreePorts(request.Count) + ports, err := nodep.GetFreePorts(request.Count, request.ExcludePorts) if err != nil { return encodeInvokeResponse(nil, err) } return encodeInvokeResponse(&GetFreePortsResponse{Ports: ports}, nil) } +func invokeDecodeSubscriptionText(payload json.RawMessage) string { + request, err := decodePayload[DecodeSubscriptionTextRequest](payload) + if err != nil { + return encodeInvokeResponse(nil, err) + } + secretKey := "" + if request.Age != nil { + secretKey = request.Age.SecretKey + } + text, err := share.DecodeSubscriptionText(request.Text, secretKey) + if err != nil { + return encodeInvokeResponse(nil, err) + } + return encodeInvokeResponse(&DecodeSubscriptionTextResponse{Text: text}, nil) +} + func invokeConvertShareLinksToXrayJson(payload json.RawMessage) string { request, err := decodePayload[ConvertShareLinksToXrayJsonRequest](payload) if err != nil { return encodeInvokeResponse(nil, err) } - xrayJson, err := share.ConvertShareLinksToXrayJson(request.Text) - return encodeInvokeResponse(xrayJson, err) + secretKey := "" + if request.Age != nil { + secretKey = request.Age.SecretKey + } + result, err := share.ConvertShareLinksToXrayJson(request.Text, secretKey) + return encodeInvokeResponse(result, err) +} + +func invokeGenerateAgeKeyPair(payload json.RawMessage) string { + request, err := decodePayload[GenerateAgeKeyPairRequest](payload) + if err != nil { + return encodeInvokeResponse(nil, err) + } + pair, err := share.GenerateAgeKeyPair(share.AgeKeyType(request.KeyType)) + if err != nil { + return encodeInvokeResponse(nil, err) + } + return encodeInvokeResponse(&GenerateAgeKeyPairResponse{ + SecretKey: pair.SecretKey, + PublicKey: pair.PublicKey, + }, nil) } func invokeConvertXrayJsonToShareLinks(payload json.RawMessage) string { @@ -182,53 +210,101 @@ func invokeCountGeoData(payload json.RawMessage) string { if err != nil { return encodeInvokeNoDataResponse(err) } - datDir := platform.NewEnvFlag(platform.AssetLocation).GetValue(func() string { return "" }) - if datDir == "" { - return encodeInvokeNoDataResponse(errors.New("missing xray.location.asset")) + if request.DatDir == "" { + return encodeInvokeNoDataResponse(errors.New("missing datDir")) } - err = geo.CountGeoData(datDir, request.Name, request.GeoType) + err = geo.CountGeoData(request.DatDir, request.Name, request.GeoType) return encodeInvokeNoDataResponse(err) } -func invokePing(payload json.RawMessage) string { - request, err := decodePayload[PingRequest](payload) +func invokePingBatch(payload json.RawMessage) string { + request, err := decodePayload[PingBatchRequest](payload) if err != nil { return encodeInvokeResponse(nil, err) } - delay, err := xray.Ping(request.ConfigPath, request.Timeout, request.URL, request.Proxy) + + configs := make([]xray.PingBatchItem, len(request.Configs)) + for i, config := range request.Configs { + configs[i] = xray.PingBatchItem{ + XrayJSON: config.XrayJson, + OutboundTag: config.OutboundTag, + } + } + + results, err := xray.PingBatch( + configs, + request.Timeout, + request.URL, + ) if err != nil { return encodeInvokeResponse(nil, err) } - return encodeInvokeResponse(&PingResponse{Delay: delay}, nil) + + responseResults := make([]PingBatchItemResponse, len(results)) + for i, result := range results { + responseResults[i] = PingBatchItemResponse{ + Success: result.Success, + Delay: result.Delay, + Error: result.Error, + } + } + return encodeInvokeResponse(&PingBatchResponse{Results: responseResults}, nil) } func invokeTestXray(payload json.RawMessage) string { - request, err := decodePayload[RunXrayRequest](payload) + request, err := decodePayload[TestXrayRequest](payload) if err != nil { return encodeInvokeNoDataResponse(err) } - err = xray.TestXray(request.ConfigPath) + err = xray.TestXray(request.XrayJson) return encodeInvokeNoDataResponse(err) } +// lifecycle serializes runXray and stopXray end to end, so the failover +// controller always matches the core: a run and a stop can't interleave +// between RunXray and failover.Start. Core callbacks, the probe and +// getFailoverState never take it; the lock order is lifecycle → probe → +// the core's own lock. +var lifecycle sync.Mutex + func invokeRunXray(payload json.RawMessage) string { request, err := decodePayload[RunXrayRequest](payload) if err != nil { return encodeInvokeNoDataResponse(err) } - failover.Stop() - // A sweep's dials must not race the new core's construction, and its - // readings across a connect are discarded anyway. + lifecycle.Lock() + defer lifecycle.Unlock() + // A sweep's dials must not race the new core's construction (core.New + // repoints the process-global dialer), and its readings across a + // connect are discarded anyway. probe.StopSession() - err = xray.RunXray(request.ConfigPath) - if err == nil && len(request.Failover) > 0 { - // A controller that cannot start leaves the tunnel up on the - // balancer's own strategy; getFailoverState reports why. - var settings failover.Settings - if jerr := json.Unmarshal(request.Failover, &settings); jerr == nil { - _ = failover.Start(xray.Instance(), settings) - } + // A refusal — xray.ErrAlreadyRunning for a duplicate — leaves the + // running tunnel and its controller as they are. + if err = xray.RunXray(request.XrayJson); err != nil { + return encodeInvokeNoDataResponse(err) + } + var settings failover.Settings + if len(request.Failover) == 0 || json.Unmarshal(request.Failover, &settings) != nil { + failover.Stop() + return encodeInvokeNoDataResponse(nil) } + // A controller that cannot start leaves the tunnel up on the balancer's + // own strategy; getFailoverState reports why. + _ = failover.Start(xray.Instance(), settings) + return encodeInvokeNoDataResponse(nil) +} + +func invokeStopXray() string { + lifecycle.Lock() + defer lifecycle.Unlock() + // The controller probes through the instance: stop it first. A latency + // sweep's readings across a disconnect are discarded anyway. + failover.Stop() + probe.StopSession() + err := xray.StopXray() + // Closing the tunnel's core leaves the process-global dialer on a dead + // instance; the probe host is the live one now. + probe.PointDialerAtHost() return encodeInvokeNoDataResponse(err) } @@ -240,16 +316,6 @@ func invokeSetFailoverOrder(payload json.RawMessage) string { return encodeInvokeNoDataResponse(failover.SetOrder(request.Order)) } -func invokeRunXrayFromJSON(payload json.RawMessage) string { - request, err := decodePayload[RunXrayFromJSONRequest](payload) - if err != nil { - return encodeInvokeNoDataResponse(err) - } - probe.StopSession() - err = xray.RunXrayFromJSON(request.ConfigJSON) - return encodeInvokeNoDataResponse(err) -} - func invokeStartProbe(payload json.RawMessage) string { request, err := decodePayload[probe.StartRequest](payload) if err != nil { diff --git a/invoke_decode_subscription_test.go b/invoke_decode_subscription_test.go new file mode 100644 index 00000000..e6c9e960 --- /dev/null +++ b/invoke_decode_subscription_test.go @@ -0,0 +1,98 @@ +package libXray + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/metacubex/age" + "github.com/metacubex/age/armor" + "github.com/xtls/libxray/share" +) + +func TestInvokeDecodeSubscriptionTextPreservesWholeDocument(t *testing.T) { + const config = `{"remarks":"完整配置","outbounds":[{"tag":"proxy","protocol":"future-protocol","mux":{"enabled":true}}],"dns":{"servers":["fakedns"]},"routing":{"balancers":[{"tag":"pool","selector":["proxy"]}]},"observatory":{"subjectSelector":["proxy"]}}` + for _, text := range []string{"", config, "[\n" + config + ",\n" + config + "\n]\n", "not JSON"} { + response := invokeForTest(t, LibXrayMethodDecodeSubscriptionText, DecodeSubscriptionTextRequest{Text: text}) + if !response.Success || response.Err != "" { + t.Fatalf("decode failed: %s", response.Err) + } + var data map[string]json.RawMessage + if err := json.Unmarshal(response.Data, &data); err != nil { + t.Fatal(err) + } + if len(data) != 1 || data["text"] == nil { + t.Fatal("decode response must contain only text, including empty text") + } + if decoded := decodeDataObject[DecodeSubscriptionTextResponse](t, response); decoded.Text != text { + t.Fatal("decode response projected or changed the source") + } + } +} + +func TestInvokeDecodeSubscriptionTextDecryptsWithoutProjection(t *testing.T) { + pair, err := share.GenerateAgeKeyPair(share.AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + const text = `[{"outbounds":[{"protocol":"unknown","streamSettings":{"sockopt":{"dialerProxy":"entry"}}}],"routing":{"rules":[]},"dns":{"hosts":{"example.com":"203.0.113.1"}}}]` + response := invokeForTest(t, LibXrayMethodDecodeSubscriptionText, DecodeSubscriptionTextRequest{ + Text: encryptDecodeTextForTest(t, pair.PublicKey, text), + Age: &AgeDecryptConfig{SecretKey: pair.SecretKey}, + }) + if !response.Success || decodeDataObject[DecodeSubscriptionTextResponse](t, response).Text != text { + t.Fatalf("full subscription decode failed: %s", response.Err) + } + for _, payload := range []any{ + DecodeSubscriptionTextRequest{Text: "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid"}, + map[string]any{"text": []string{"invalid field type"}}, + } { + failed := invokeForTest(t, LibXrayMethodDecodeSubscriptionText, payload) + if failed.Success || string(failed.Data) != "null" || failed.Err == "" { + t.Fatal("failed decoding must return null data and an error") + } + } +} + +func TestInvokeDecodeSubscriptionTextKeepsEnvelopeLimits(t *testing.T) { + pair, err := share.GenerateAgeKeyPair(share.AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + // The armor fits the request limit; JSON's HTML escaping expands the decoded + // response beyond its envelope limit without exceeding the plaintext limit. + text := strings.Repeat("<", 3*1024*1024) + response := invokeForTest(t, LibXrayMethodDecodeSubscriptionText, DecodeSubscriptionTextRequest{ + Text: encryptDecodeTextForTest(t, pair.PublicKey, text), + Age: &AgeDecryptConfig{SecretKey: pair.SecretKey}, + }) + if response.Success || string(response.Data) != "null" || response.Err != invokeJSONSizeLimitMessage("response") { + t.Fatalf("oversize decoded envelope error = %s", response.Err) + } +} + +func encryptDecodeTextForTest(t *testing.T, publicKey, text string) string { + t.Helper() + recipient, err := age.ParseX25519Recipient(publicKey) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + armored := armor.NewWriter(&output) + writer, err := age.Encrypt(armored, recipient) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(writer, text); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := armored.Close(); err != nil { + t.Fatal(err) + } + return output.String() +} diff --git a/invoke_lifecycle_test.go b/invoke_lifecycle_test.go new file mode 100644 index 00000000..ef69d021 --- /dev/null +++ b/invoke_lifecycle_test.go @@ -0,0 +1,157 @@ +package libXray + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/xtls/libxray/failover" + "github.com/xtls/libxray/xray" +) + +// A balancer over a dead (blackhole) and a live (freedom) outbound, as the +// failover package's own tests use. +const lifecycleTestConfig = `{ + "log": {"loglevel": "none"}, + "outbounds": [ + {"tag": "proxy-00", "protocol": "blackhole"}, + {"tag": "proxy-01", "protocol": "freedom"} + ], + "routing": {"balancers": [{"tag": "smart", "selector": ["proxy-"], "strategy": {"type": "random"}}]} +}` + +func failoverSettingsForTest(t *testing.T) json.RawMessage { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + raw, err := json.Marshal(failover.Settings{ + BalancerTag: "smart", + Order: []string{"proxy-00", "proxy-01"}, + ProbeURL: srv.URL + "/generate_204", + }) + if err != nil { + t.Fatal(err) + } + return raw +} + +func failoverRunningForTest(t *testing.T) bool { + t.Helper() + response := invokeForTest(t, LibXrayMethodGetFailoverState, nil) + if !response.Success { + t.Fatalf("getFailoverState failed: %s", response.Err) + } + var state failover.State + if err := json.Unmarshal(response.Data, &state); err != nil { + t.Fatal(err) + } + return state.Running +} + +func TestInvokeRunXrayStartsFailover(t *testing.T) { + response := invokeForTest(t, LibXrayMethodRunXray, RunXrayRequest{ + XrayJson: lifecycleTestConfig, + Failover: failoverSettingsForTest(t), + }) + defer xrayStopForTest(t) + if !response.Success { + t.Fatalf("runXray failed: %s", response.Err) + } + if !failoverRunningForTest(t) { + t.Fatal("runXray with failover settings left no controller running") + } +} + +func TestInvokeRefusedDuplicateRunXrayKeepsFailover(t *testing.T) { + settings := failoverSettingsForTest(t) + first := invokeForTest(t, LibXrayMethodRunXray, RunXrayRequest{XrayJson: lifecycleTestConfig, Failover: settings}) + defer xrayStopForTest(t) + if !first.Success { + t.Fatalf("runXray failed: %s", first.Err) + } + duplicate := invokeForTest(t, LibXrayMethodRunXray, RunXrayRequest{XrayJson: lifecycleTestConfig}) + if duplicate.Success || duplicate.Err != xray.ErrAlreadyRunning.Error() { + t.Fatalf("duplicate runXray: success=%v error=%q", duplicate.Success, duplicate.Err) + } + if !failoverRunningForTest(t) { + t.Fatal("a refused duplicate runXray stopped the running tunnel's failover") + } +} + +// Two starts and a stop at once, in any order: afterwards the controller runs +// exactly when the core does. Without the lifecycle lock a stop can land +// between RunXray and failover.Start and leave a controller on a closed core. +func TestInvokeConcurrentRunAndStopKeepFailoverWithTheCore(t *testing.T) { + payload, err := json.Marshal(RunXrayRequest{XrayJson: lifecycleTestConfig, Failover: failoverSettingsForTest(t)}) + if err != nil { + t.Fatal(err) + } + runJSON, err := json.Marshal(&LibXrayInvokeRequest{APIVersion: LibXrayAPIVersion, Method: LibXrayMethodRunXray, Payload: payload}) + if err != nil { + t.Fatal(err) + } + stopJSON, err := json.Marshal(&LibXrayInvokeRequest{APIVersion: LibXrayAPIVersion, Method: LibXrayMethodStopXray}) + if err != nil { + t.Fatal(err) + } + for round := 0; round < 10; round++ { + var wg sync.WaitGroup + for _, request := range []string{string(runJSON), string(runJSON), string(stopJSON)} { + wg.Add(1) + go func() { + defer wg.Done() + Invoke(request) + }() + } + wg.Wait() + if core, controller := xray.GetXrayState(), failoverRunningForTest(t); core != controller { + t.Fatalf("round %d: core running=%v, failover running=%v", round, core, controller) + } + xrayStopForTest(t) + } +} + +// The app sends its asset directory in the config's root env (Invoke's own +// env is gone). It must be applied before the routing rules load geodata — +// proven with a geosite rule that only the env can resolve. +func TestInvokeRunXrayConfigEnvLocatesGeodata(t *testing.T) { + const assetKey = "xray.location.asset" + t.Setenv(assetKey, "") + datDir := t.TempDir() + writeGeoSiteDatForTest(t, filepath.Join(datDir, "geosite.dat")) + config := func(env map[string]string) string { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "env": env, + "log": map[string]string{"loglevel": "none"}, + "outbounds": []any{map[string]string{"tag": "direct", "protocol": "freedom"}}, + "routing": map[string]any{"rules": []any{ + map[string]any{"domain": []string{"geosite:test"}, "outboundTag": "direct"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + return string(raw) + } + + without := invokeForTest(t, LibXrayMethodRunXray, RunXrayRequest{XrayJson: config(nil)}) + if without.Success { + xrayStopForTest(t) + t.Fatal("a geosite rule loaded with no asset location: the test proves nothing") + } + with := invokeForTest(t, LibXrayMethodRunXray, RunXrayRequest{XrayJson: config(map[string]string{assetKey: datDir})}) + defer xrayStopForTest(t) + if !with.Success { + t.Fatalf("runXray with the asset location in the config's env failed: %s", with.Err) + } + if got := os.Getenv(assetKey); got != datDir { + t.Fatalf("%s = %q, want %q", assetKey, got, datDir) + } +} diff --git a/invoke_model.go b/invoke_model.go index 0d214c88..b01086d8 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -1,19 +1,24 @@ // libXray is an Xray wrapper focusing on improving the experience of Xray-core mobile development. package libXray -import "encoding/json" +import ( + "encoding/json" +) type LibXrayMethod string +const LibXrayAPIVersion = 3 + const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" + LibXrayMethodDecodeSubscriptionText LibXrayMethod = "decodeSubscriptionText" LibXrayMethodConvertShareLinksToXrayJson LibXrayMethod = "convertShareLinksToXrayJson" LibXrayMethodConvertXrayJsonToShareLinks LibXrayMethod = "convertXrayJsonToShareLinks" + LibXrayMethodGenerateAgeKeyPair LibXrayMethod = "generateAgeKeyPair" LibXrayMethodCountGeoData LibXrayMethod = "countGeoData" - LibXrayMethodPing LibXrayMethod = "ping" + LibXrayMethodPingBatch LibXrayMethod = "pingBatch" LibXrayMethodTestXray LibXrayMethod = "testXray" LibXrayMethodRunXray LibXrayMethod = "runXray" - LibXrayMethodRunXrayFromJson LibXrayMethod = "runXrayFromJson" LibXrayMethodStopXray LibXrayMethod = "stopXray" LibXrayMethodXrayVersion LibXrayMethod = "xrayVersion" LibXrayMethodGetXrayState LibXrayMethod = "getXrayState" @@ -28,37 +33,50 @@ const ( type LibXrayInvokeRequest struct { APIVersion int `json:"apiVersion,omitempty"` Method LibXrayMethod `json:"method,omitempty"` - Env *LibXrayEnvJson `json:"env,omitempty"` Payload json.RawMessage `json:"payload,omitempty"` } -type LibXrayEnvJson struct { - ConfigLocation string `json:"xray.location.config,omitempty"` - ConfdirLocation string `json:"xray.location.confdir,omitempty"` - AssetLocation string `json:"xray.location.asset,omitempty"` - CertLocation string `json:"xray.location.cert,omitempty"` - UseReadV string `json:"xray.buf.readv,omitempty"` - UseFreedomSplice string `json:"xray.buf.splice,omitempty"` - UseVmessPadding string `json:"xray.vmess.padding,omitempty"` - UseCone string `json:"xray.cone.disabled,omitempty"` - UseStrictJSON string `json:"xray.json.strict,omitempty"` - BufferSize string `json:"xray.ray.buffer.size,omitempty"` - BrowserDialerAddress string `json:"xray.browser.dialer,omitempty"` - XUDPLog string `json:"xray.xudp.show,omitempty"` - XUDPBaseKey string `json:"xray.xudp.basekey,omitempty"` - TunFd string `json:"xray.tun.fd,omitempty"` -} - type GetFreePortsRequest struct { - Count int `json:"count,omitempty"` + Count int `json:"count,omitempty"` + ExcludePorts []int `json:"excludePorts,omitempty"` } type GetFreePortsResponse struct { Ports []int `json:"ports,omitempty"` } +type AgeDecryptConfig struct { + SecretKey string `json:"secretKey,omitempty"` +} + +type DecodeSubscriptionTextRequest struct { + Text string `json:"text,omitempty"` + Age *AgeDecryptConfig `json:"age,omitempty"` +} + +type DecodeSubscriptionTextResponse struct { + Text string `json:"text"` +} + type ConvertShareLinksToXrayJsonRequest struct { - Text string `json:"text,omitempty"` + Text string `json:"text,omitempty"` + Age *AgeDecryptConfig `json:"age,omitempty"` +} + +type AgeKeyType string + +const ( + AgeKeyTypeX25519 AgeKeyType = "x25519" + AgeKeyTypeHybrid AgeKeyType = "hybrid" +) + +type GenerateAgeKeyPairRequest struct { + KeyType AgeKeyType `json:"keyType,omitempty"` +} + +type GenerateAgeKeyPairResponse struct { + SecretKey string `json:"secretKey,omitempty"` + PublicKey string `json:"publicKey,omitempty"` } type ConvertXrayJsonToShareLinksRequest struct { @@ -72,21 +90,32 @@ type ConvertXrayJsonToShareLinksResponse struct { type CountGeoDataRequest struct { Name string `json:"name,omitempty"` GeoType string `json:"geoType,omitempty"` + DatDir string `json:"datDir,omitempty"` } -type PingRequest struct { - ConfigPath string `json:"configPath,omitempty"` - Timeout int `json:"timeout,omitempty"` - URL string `json:"url,omitempty"` - Proxy string `json:"proxy,omitempty"` +type PingBatchRequest struct { + Configs []PingBatchItemRequest `json:"configs,omitempty"` + Timeout int `json:"timeout,omitempty"` + URL string `json:"url,omitempty"` } -type PingResponse struct { - Delay int64 `json:"delay,omitempty"` +type PingBatchItemRequest struct { + XrayJson string `json:"xrayJson,omitempty"` + OutboundTag string `json:"outboundTag,omitempty"` +} + +type PingBatchResponse struct { + Results []PingBatchItemResponse `json:"results,omitempty"` +} + +type PingBatchItemResponse struct { + Success bool `json:"success"` + Delay int64 `json:"delay"` + Error string `json:"error,omitempty"` } type RunXrayRequest struct { - ConfigPath string `json:"configPath,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` // Smart mode's sticky failover (failover.Settings); empty for every other // session. Raw so the gomobile-bound API stays free of the failover types. Failover json.RawMessage `json:"failover,omitempty"` @@ -96,8 +125,8 @@ type SetFailoverOrderRequest struct { Order []string `json:"order,omitempty"` } -type RunXrayFromJSONRequest struct { - ConfigJSON string `json:"configJSON,omitempty"` +type TestXrayRequest struct { + XrayJson string `json:"xrayJson,omitempty"` } type XrayVersionResponse struct { diff --git a/invoke_probe_test.go b/invoke_probe_test.go index 0492ad1b..42789943 100644 --- a/invoke_probe_test.go +++ b/invoke_probe_test.go @@ -3,27 +3,38 @@ package libXray import ( "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" ) +// probeRequest is an Invoke request in the current API version, written out as +// the app sends it. +func probeRequest(method, payload string) string { + request := `{"apiVersion":` + strconv.Itoa(LibXrayAPIVersion) + `,"method":"` + method + `"` + if payload != "" { + request += `,"payload":` + payload + } + return request + `}` +} + func TestInvokeProbeRoundTrip(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(5 * time.Millisecond) w.WriteHeader(http.StatusNoContent) })) defer srv.Close() - t.Cleanup(func() { Invoke(`{"apiVersion":1,"method":"closeProbeHost"}`) }) + t.Cleanup(func() { Invoke(probeRequest("closeProbeHost", "")) }) - start := Invoke(`{"apiVersion":1,"method":"startProbe","payload":{"url":"` + srv.URL + - `/generate_204","servers":[{"id":"a","outbound":{"protocol":"freedom"}}],"measure":["a"]}}`) + start := Invoke(probeRequest("startProbe", `{"url":"`+srv.URL+ + `/generate_204","servers":[{"id":"a","outbound":{"protocol":"freedom"}}],"measure":["a"]}`)) if !strings.HasPrefix(start, `{"success":true`) || !strings.Contains(start, `"deadlineMs":`) { t.Fatalf("start: %s", start) } var all string for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); { - r := Invoke(`{"apiVersion":1,"method":"probeResults"}`) + r := Invoke(probeRequest("probeResults", "")) all += r if strings.Contains(r, `"done":true`) { break @@ -34,11 +45,11 @@ func TestInvokeProbeRoundTrip(t *testing.T) { t.Fatalf("no reading for a: %s", all) } for _, m := range []string{"stopProbe", "closeProbeHost"} { - if r := Invoke(`{"apiVersion":1,"method":"` + m + `"}`); !strings.HasPrefix(r, `{"success":true`) { + if r := Invoke(probeRequest(m, "")); !strings.HasPrefix(r, `{"success":true`) { t.Fatalf("%s: %s", m, r) } } - if r := Invoke(`{"apiVersion":1,"method":"startProbe","payload":{"url":"https://x/"}}`); !strings.HasPrefix(r, `{"success":false`) { + if r := Invoke(probeRequest("startProbe", `{"url":"https://x/"}`)); !strings.HasPrefix(r, `{"success":false`) { t.Fatalf("a bad url was accepted: %s", r) } } diff --git a/invoke_probes_test.go b/invoke_probes_test.go new file mode 100644 index 00000000..57597840 --- /dev/null +++ b/invoke_probes_test.go @@ -0,0 +1,82 @@ +package libXray + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestInvokeShareOutboundsResponseShape(t *testing.T) { + const validLink = "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443?encryption=none&security=tls&sni=example.com" + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{ + Text: validLink + "\nvless://bad@example.com:443", + }) + if !response.Success { + t.Fatal(response.Err) + } + var root map[string]json.RawMessage + if err := json.Unmarshal(response.Data, &root); err != nil { + t.Fatal(err) + } + if len(root) != 1 || root["outbounds"] == nil { + t.Fatalf("data = %s, want only outbounds", response.Data) + } + if config := decodeShareConfig(t, response); len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } + for _, text := range []string{"vless://bad@example.com:443", `{"outbounds":[]}`, `{"outbounds":`, "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid"} { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{Text: text}) + if response.Success || string(response.Data) != "null" || response.Err == "" { + t.Fatalf("response = %+v", response) + } + } +} + +func TestInvokePingZeroDelayWireField(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) + } +} + +func TestInvokePingBatchUsesEachForcedOutboundAndOnlyLatencyHeads(t *testing.T) { + var heads, unexpected atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + heads.Add(1) + } else { + unexpected.Add(1) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + request := PingBatchRequest{ + Configs: []PingBatchItemRequest{ + {XrayJson: `{"outbounds":[{"protocol":"freedom","tag":"proxy"}]}`}, + {XrayJson: `{"outbounds":[{"protocol":"blackhole","tag":"proxy"}]}`}, + }, + Timeout: 1, URL: server.URL, + } + response := invokeForTest(t, LibXrayMethodPingBatch, request) + if !response.Success { + t.Fatalf("error = %s", response.Err) + } + results := decodeDataObject[PingBatchResponse](t, response).Results + if len(results) != 2 { + t.Fatalf("results = %d, want 2", len(results)) + } + if !results[0].Success || results[0].Error != "" { + t.Fatalf("freedom result = %+v", results[0]) + } + if results[1].Success || results[1].Error == "" { + t.Fatalf("blackhole outbound escaped through another client: %+v", results[1]) + } + if heads.Load() != 1 || unexpected.Load() != 0 { + t.Fatalf("requests = HEAD %d, other %d", heads.Load(), unexpected.Load()) + } +} diff --git a/invoke_test.go b/invoke_test.go index da6c86bc..68a9eae3 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -1,18 +1,26 @@ package libXray import ( + "bytes" "encoding/base64" "encoding/json" + "io" "os" "path/filepath" + "strings" "testing" + "github.com/metacubex/age" + "github.com/metacubex/age/armor" "github.com/xtls/libxray/failover" - "github.com/xtls/xray-core/common/platform" + "github.com/xtls/libxray/nodep" + "github.com/xtls/xray-core/common/geodata" + "github.com/xtls/xray-core/infra/conf" + "google.golang.org/protobuf/proto" ) func TestInvokeGetFailoverStateWithoutController(t *testing.T) { - resp := invokeForTest(t, LibXrayMethodGetFailoverState, nil, nil) + resp := invokeForTest(t, LibXrayMethodGetFailoverState, nil) if !resp.Success { t.Fatalf("getFailoverState failed: %s", resp.Err) } @@ -26,7 +34,7 @@ func TestInvokeGetFailoverStateWithoutController(t *testing.T) { } func TestInvokeSetFailoverOrderWithoutController(t *testing.T) { - resp := invokeForTest(t, LibXrayMethodSetFailoverOrder, nil, &SetFailoverOrderRequest{Order: []string{"proxy-00"}}) + resp := invokeForTest(t, LibXrayMethodSetFailoverOrder, &SetFailoverOrderRequest{Order: []string{"proxy-00"}}) if resp.Success { t.Fatal("setFailoverOrder succeeded with no controller") } @@ -38,16 +46,15 @@ type testResponse struct { Err string `json:"error,omitempty"` } -func invokeForTest(t *testing.T, method LibXrayMethod, env *LibXrayEnvJson, payload any) testResponse { +func invokeForTest(t *testing.T, method LibXrayMethod, payload any) testResponse { t.Helper() rawPayload, err := json.Marshal(payload) if err != nil { t.Fatal(err) } rawRequest, err := json.Marshal(&LibXrayInvokeRequest{ - APIVersion: 1, + APIVersion: LibXrayAPIVersion, Method: method, - Env: env, Payload: rawPayload, }) if err != nil { @@ -92,17 +99,41 @@ func decodeDataObject[T any](t *testing.T, response testResponse) T { return value } -func writeConfigToFile(t *testing.T, config any, path string) { +func decodeShareConfig(t *testing.T, response testResponse) conf.Config { t.Helper() - if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return decodeDataObject[conf.Config](t, response) +} + +func writeGeoSiteDatForTest(t *testing.T, path string) { + t.Helper() + data, err := proto.Marshal(&geodata.GeoSiteList{ + Entry: []*geodata.GeoSite{ + { + Code: "TEST", + Domain: []*geodata.Domain{ + { + Type: geodata.Domain_Domain, + Value: "example.com", + Attribute: []*geodata.Domain_Attribute{ + { + Key: "ads", + TypedValue: &geodata.Domain_Attribute_BoolValue{ + BoolValue: true, + }, + }, + }, + }, + }, + }, + }, + }) + if err != nil { t.Fatal(err) } - file, err := os.Create(path) - if err != nil { + if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { t.Fatal(err) } - defer file.Close() - if err := json.NewEncoder(file).Encode(config); err != nil { + if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatal(err) } } @@ -186,15 +217,15 @@ func testXrayConfig(t *testing.T) any { } func TestInvokeTestXray(t *testing.T) { - projectRoot, _ := filepath.Abs(".") - configPath := filepath.Join(projectRoot, "config", "xray_config_test.json") - writeConfigToFile(t, testXrayConfig(t), configPath) + xrayJSON, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } response := invokeForTest( t, LibXrayMethodTestXray, - &LibXrayEnvJson{AssetLocation: filepath.Join(projectRoot, "dat"), CertLocation: filepath.Join(projectRoot, "dat")}, - RunXrayRequest{ConfigPath: configPath}, + TestXrayRequest{XrayJson: string(xrayJSON)}, ) if !response.Success { t.Fatalf("TestXray failed: %s", response.Err) @@ -202,16 +233,75 @@ func TestInvokeTestXray(t *testing.T) { requireNoDataObject(t, response) } +func TestInvokeTestXrayConstructsWithoutStarting(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "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": "freedom", "tag": "direct"}, + }, + }) + if err != nil { + t.Fatal(err) + } + response := invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: string(config)}) + if !response.Success { + t.Fatalf("testXray must construct handlers without starting the TUN: %s", response.Err) + } + requireNoDataObject(t, response) + if _, err := os.Stat(logPath); err != nil { + t.Fatalf("testXray did not construct the configured file logger: %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 TestInvokeTestXrayReturnsConstructionError(t *testing.T) { + response := invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: `{ + "log":{"loglevel":"none"}, + "outbounds":[{"protocol":"freedom","tag":"direct"}], + "routing":{"rules":[{"domain":["example.com"],"balancerTag":"missing"}]} + }`}) + if response.Success || string(response.Data) != "null" || !strings.Contains(response.Err, "balancer missing not found") { + t.Fatalf("testXray must expose the core construction error: %+v", response) + } +} + +func TestInvokeTestXrayDoesNotReadConfigPath(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "xray.json") + configJSON, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, configJSON, 0o600); err != nil { + t.Fatal(err) + } + + response := invokeForTest( + t, + LibXrayMethodTestXray, + TestXrayRequest{XrayJson: configPath}, + ) + if response.Success { + t.Fatal("testXray should parse xrayJson instead of reading a path") + } +} + func TestInvokeRunXray(t *testing.T) { - projectRoot, _ := filepath.Abs(".") - configPath := filepath.Join(projectRoot, "config", "xray_config_run.json") - writeConfigToFile(t, testXrayConfig(t), configPath) + xrayJSON, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } response := invokeForTest( t, LibXrayMethodRunXray, - &LibXrayEnvJson{AssetLocation: filepath.Join(projectRoot, "dat"), CertLocation: filepath.Join(projectRoot, "dat")}, - RunXrayRequest{ConfigPath: configPath}, + RunXrayRequest{XrayJson: string(xrayJSON)}, ) defer xrayStopForTest(t) if !response.Success { @@ -220,8 +310,40 @@ func TestInvokeRunXray(t *testing.T) { requireNoDataObject(t, response) } +func TestInvokeRunXrayAppliesConfigEnv(t *testing.T) { + const key = "XRAY_LIBXRAY_CONFIG_ENV_TEST" + t.Setenv(key, "") + + rawConfig, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(rawConfig, &config); err != nil { + t.Fatal(err) + } + config["env"] = map[string]string{key: "configured"} + + xrayJSON, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + response := invokeForTest( + t, + LibXrayMethodRunXray, + RunXrayRequest{XrayJson: string(xrayJSON)}, + ) + defer xrayStopForTest(t) + if !response.Success { + t.Fatalf("RunXray failed: %s", response.Err) + } + if got := os.Getenv(key); got != "configured" { + t.Fatalf("config env = %q, want configured", got) + } +} + func TestInvokeXrayVersion(t *testing.T) { - response := invokeForTest(t, LibXrayMethodXrayVersion, nil, nil) + response := invokeForTest(t, LibXrayMethodXrayVersion, nil) if !response.Success { t.Fatalf("XrayVersion failed: %s", response.Err) } @@ -231,8 +353,34 @@ func TestInvokeXrayVersion(t *testing.T) { } } +func TestInvokeGetFreePortsExclusions(t *testing.T) { + response := invokeRawForTest(t, `{"apiVersion":3,"method":"getFreePorts","payload":{"count":2,"excludePorts":[18587,9000,18587]}}`) + if !response.Success { + t.Fatalf("GetFreePorts failed: %s", response.Err) + } + ports := decodeDataObject[GetFreePortsResponse](t, response).Ports + if len(ports) != 2 || ports[0] == ports[1] { + t.Fatalf("ports = %v, want two distinct ports", ports) + } + for _, port := range ports { + if port == 18587 || port == 9000 { + t.Fatalf("excluded port returned: %d", port) + } + } + response = invokeForTest(t, LibXrayMethodGetFreePorts, GetFreePortsRequest{ + Count: 1, ExcludePorts: []int{0}, + }) + if response.Success || !strings.Contains(response.Err, "excluded port") { + t.Fatalf("invalid exclusion response = %+v", response) + } + response = invokeRawForTest(t, `{"apiVersion":3,"method":"getFreePorts","payload":{"count":1,"excludePorts":["18587"]}}`) + if response.Success { + t.Fatal("string exclusion should fail JSON decoding") + } +} + func TestInvokeMapResponseShape(t *testing.T) { - response := invokeForTest(t, LibXrayMethodGetFreePorts, nil, GetFreePortsRequest{Count: 1}) + response := invokeForTest(t, LibXrayMethodGetFreePorts, GetFreePortsRequest{Count: 1}) if !response.Success { t.Fatalf("GetFreePorts failed: %s", response.Err) } @@ -241,7 +389,7 @@ func TestInvokeMapResponseShape(t *testing.T) { t.Fatalf("ports = %v", ports.Ports) } - response = invokeForTest(t, LibXrayMethodGetXrayState, nil, nil) + response = invokeForTest(t, LibXrayMethodGetXrayState, nil) if !response.Success { t.Fatalf("GetXrayState failed: %s", response.Err) } @@ -254,7 +402,6 @@ func TestInvokeMapResponseShape(t *testing.T) { response = invokeForTest( t, LibXrayMethodConvertXrayJsonToShareLinks, - nil, ConvertXrayJsonToShareLinksRequest{XrayJson: string(rawConfig)}, ) if !response.Success { @@ -266,99 +413,441 @@ func TestInvokeMapResponseShape(t *testing.T) { } } -func TestInvokeEnvOnlySetsProvidedFields(t *testing.T) { - t.Setenv(platform.AssetLocation, "initial-asset") - t.Setenv(platform.CertLocation, "initial-cert") +func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { + const validName = "Valid" + links := "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome\n" + + "vless://12345678-abcd-abcd-abcd-123456789abc@valid.example:443?encryption=none&security=tls&sni=valid.example&fp=chrome#" + validName + response := invokeForTest( t, - LibXrayMethodXrayVersion, - &LibXrayEnvJson{AssetLocation: "updated-asset"}, - nil, + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: links}, ) if !response.Success { - t.Fatalf("XrayVersion failed: %s", response.Err) + t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) + } + config := decodeShareConfig(t, response) + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } - if got := os.Getenv(platform.AssetLocation); got != "updated-asset" { - t.Fatalf("asset env = %q", got) + if config.OutboundConfigs[0].Tag != validName { + t.Fatalf("tag = %q, want %q", config.OutboundConfigs[0].Tag, validName) } - if got := os.Getenv(platform.CertLocation); got != "initial-cert" { - t.Fatalf("cert env = %q", got) + if config.OutboundConfigs[0].SendThrough != nil { + t.Fatalf("sendThrough = %v, want nil", config.OutboundConfigs[0].SendThrough) } } -func TestInvokeSetsAllSupportedEnvFields(t *testing.T) { - tests := []struct { - name string - key string - value string - }{ - {"config", platform.ConfigLocation, "config-value"}, - {"confdir", platform.ConfdirLocation, "confdir-value"}, - {"asset", platform.AssetLocation, "asset-value"}, - {"cert", platform.CertLocation, "cert-value"}, - {"readv", platform.UseReadV, "readv-value"}, - {"splice", platform.UseFreedomSplice, "splice-value"}, - {"vmess padding", platform.UseVmessPadding, "vmess-padding-value"}, - {"cone", platform.UseCone, "cone-value"}, - {"strict json", platform.UseStrictJSON, "strict-json-value"}, - {"buffer size", platform.BufferSize, "buffer-size-value"}, - {"browser dialer", platform.BrowserDialerAddress, "browser-dialer-value"}, - {"xudp log", platform.XUDPLog, "xudp-log-value"}, - {"xudp base key", platform.XUDPBaseKey, "xudp-base-key-value"}, - {"tun fd", platform.TunFdKey, "123"}, - } - for _, tt := range tests { - t.Setenv(tt.key, "") - t.Run(tt.name, func(t *testing.T) { - requestJSON := `{"apiVersion":1,"method":"xrayVersion","env":{"` + tt.key + `":"` + tt.value + `"}}` - var response testResponse - if err := json.Unmarshal([]byte(Invoke(requestJSON)), &response); err != nil { - t.Fatal(err) - } +func TestInvokeHysteria2ShareRoundTrip(t *testing.T) { + for _, scheme := range []string{"hysteria2", "hy2"} { + t.Run(scheme, func(t *testing.T) { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: scheme + "://password@example.com:443?obfs=salamander&obfs-password=secret#Test"}) if !response.Success { - t.Fatalf("XrayVersion failed: %s", response.Err) + t.Fatal(response.Err) + } + config := decodeShareConfig(t, response) + if len(config.OutboundConfigs) != 1 || config.OutboundConfigs[0].Protocol != "hysteria" || config.OutboundConfigs[0].Tag != "Test" { + t.Fatalf("unexpected Hysteria2 projection: %s", response.Data) + } + if result := invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: string(response.Data)}); !result.Success { + t.Fatal(result.Err) + } + result := invokeForTest(t, LibXrayMethodConvertXrayJsonToShareLinks, + ConvertXrayJsonToShareLinksRequest{XrayJson: string(response.Data)}) + if !result.Success { + t.Fatal(result.Err) } - if got := os.Getenv(tt.key); got != tt.value { - t.Fatalf("%s = %q", tt.key, got) + links := decodeDataObject[ConvertXrayJsonToShareLinksResponse](t, result) + if !strings.HasPrefix(links.Links, "hysteria2://") || !strings.HasSuffix(links.Links, "#Test") { + t.Fatalf("unexpected Hysteria2 link: %s", links.Links) } }) } } +func TestInvokeConvertShareLinksRejectsRemovedFormats(t *testing.T) { + for _, test := range []struct { + name, text string + }{ + {"Clash", "proxies:\n - {type: vless, server: example.com, port: 443, uuid: 12345678-abcd-abcd-abcd-123456789abc}"}, + {"HysteriaRealm", "hysteria2+realm://token@example.com/realm?auth=password"}, + } { + t.Run(test.name, func(t *testing.T) { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: test.text}) + if response.Success || response.Err != "unsupported share format" { + t.Fatalf("success = %v, error = %q", response.Success, response.Err) + } + if string(response.Data) != "null" { + t.Fatalf("data = %s, want null", response.Data) + } + }) + } +} + +func TestInvokeConvertShareLinksReturnsProjectedObject(t *testing.T) { + const publicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + link := "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443" + + "?encryption=none&type=xhttp&host=cdn.example.com&path=%2Fx&mode=stream-up" + + "&security=reality&sni=example.com&fp=chrome&pbk=" + publicKey + "&sid=abcd" + response := invokeForTest( + t, + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: link}, + ) + if !response.Success { + t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) + } + + config := decodeShareConfig(t, response) + var root map[string]json.RawMessage + if err := json.Unmarshal(response.Data, &root); err != nil { + t.Fatalf("config is not an object: %s", response.Data) + } + if len(root) != 1 || root["outbounds"] == nil { + t.Fatalf("config root = %s, want only outbounds", response.Data) + } + for _, field := range []string{"publicKey", "target", "dest", "proxySettings", "sockopt"} { + if bytes.Contains(response.Data, []byte(`"`+field+`"`)) { + t.Fatalf("config contains unsupported field %q: %s", field, response.Data) + } + } + if !bytes.Contains(response.Data, []byte(`"password":"`+publicKey+`"`)) { + t.Fatalf("config did not canonicalize REALITY password: %s", response.Data) + } + + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } + if _, err := config.OutboundConfigs[0].Build(); err != nil { + t.Fatalf("projected outbound does not build: %v", err) + } +} + +func TestInvokeConvertShareLinksPreservesKCPParameters(t *testing.T) { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: "vless://12345678-abcd-abcd-abcd-123456789abc@127.0.0.1:443?type=kcp&mtu=1350&tti=30"}) + if !response.Success { + t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) + } + config := decodeShareConfig(t, response) + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } + stream := config.OutboundConfigs[0].StreamSetting + if stream == nil || stream.KCPSettings == nil { + t.Fatal("KCP settings missing from Invoke response") + } + kcp := stream.KCPSettings + if kcp.Mtu == nil || *kcp.Mtu != 1350 || kcp.Tti == nil || *kcp.Tti != 30 { + t.Fatalf("unexpected KCP parameters: %s", response.Data) + } +} + +func TestInvokeAgeKeyGenerationAndConversion(t *testing.T) { + generated := invokeForTest( + t, + LibXrayMethodGenerateAgeKeyPair, + GenerateAgeKeyPairRequest{KeyType: AgeKeyTypeX25519}, + ) + if !generated.Success { + t.Fatalf("GenerateAgeKeyPair failed: %s", generated.Err) + } + pair := decodeDataObject[GenerateAgeKeyPairResponse](t, generated) + if pair.SecretKey == "" || pair.PublicKey == "" { + t.Fatalf("generated pair is incomplete: %+v", pair) + } + + recipient, err := age.ParseX25519Recipient(pair.PublicKey) + if err != nil { + t.Fatal(err) + } + var encrypted bytes.Buffer + armored := armor.NewWriter(&encrypted) + writer, err := age.Encrypt(armored, recipient) + if err != nil { + t.Fatal(err) + } + const link = "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443?encryption=none&security=tls&sni=example.com#AgeInvoke" + if _, err := io.WriteString(writer, link); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := armored.Close(); err != nil { + t.Fatal(err) + } + + converted := invokeForTest( + t, + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{ + Text: encrypted.String(), + Age: &AgeDecryptConfig{SecretKey: pair.SecretKey}, + }, + ) + if !converted.Success { + t.Fatalf("ConvertShareLinksToXrayJson failed: %s", converted.Err) + } + config := decodeShareConfig(t, converted) + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } +} + +func TestInvokeAgeFailuresHaveNullData(t *testing.T) { + for _, test := range []struct { + method LibXrayMethod + payload any + }{ + { + method: LibXrayMethodGenerateAgeKeyPair, + payload: GenerateAgeKeyPairRequest{KeyType: AgeKeyType("invalid")}, + }, + { + method: LibXrayMethodConvertShareLinksToXrayJson, + payload: ConvertShareLinksToXrayJsonRequest{ + Text: "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid", + }, + }, + } { + response := invokeForTest(t, test.method, test.payload) + if response.Success { + t.Fatalf("method %q unexpectedly succeeded", test.method) + } + if got := string(response.Data); got != "null" { + t.Fatalf("method %q data = %s, want null", test.method, got) + } + if response.Err == "" { + t.Fatalf("method %q returned no error", test.method) + } + } +} + +func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing.T) { + response := invokeForTest( + t, + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{ + Text: "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome", + }, + ) + if response.Success { + t.Fatal("ConvertShareLinksToXrayJson should fail when all outbounds are invalid") + } + if !strings.Contains(response.Err, "no valid outbound found") { + t.Fatalf("error = %q", response.Err) + } + if string(response.Data) != "null" { + t.Fatalf("data = %s, want null", response.Data) + } +} + +func TestInvokePingBatchReturnsPerItemFailures(t *testing.T) { + response := invokeForTest( + t, + LibXrayMethodPingBatch, + PingBatchRequest{ + Configs: []PingBatchItemRequest{ + { + XrayJson: "not JSON", + }, + }, + Timeout: 1, + URL: "https://example.com", + }, + ) + if !response.Success { + t.Fatalf("PingBatch failed: %s", response.Err) + } + batch := decodeDataObject[PingBatchResponse](t, response) + if len(batch.Results) != 1 { + t.Fatalf("results = %d, want 1", len(batch.Results)) + } + result := batch.Results[0] + if result.Success { + t.Fatal("missing config unexpectedly succeeded") + } + if result.Delay != nodep.PingDelayError { + t.Fatalf("delay = %d, want %d", result.Delay, nodep.PingDelayError) + } + if result.Error == "" { + t.Fatal("missing config has no error") + } +} + +func TestInvokePingBatchRejectsInvalidRequest(t *testing.T) { + response := invokeForTest( + t, + LibXrayMethodPingBatch, + PingBatchRequest{ + Timeout: 1, + URL: "https://example.com", + }, + ) + if response.Success { + t.Fatal("PingBatch should reject an empty config list") + } + if !strings.Contains(response.Err, "configs are empty") { + t.Fatalf("error = %q", response.Err) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } +} + +func TestInvokePingBatchRejectsMoreThanFiveConfigs(t *testing.T) { + configs := make([]PingBatchItemRequest, 6) + for i := range configs { + configs[i] = PingBatchItemRequest{ + XrayJson: `{"outbounds":[{"protocol":"freedom"}]}`, + } + } + response := invokeForTest( + t, + LibXrayMethodPingBatch, + PingBatchRequest{ + Configs: configs, + Timeout: 1, + URL: "https://example.com", + }, + ) + if response.Success { + t.Fatal("PingBatch should reject more than five configs") + } + if !strings.Contains(response.Err, "more than 5 configs") { + t.Fatalf("error = %q", response.Err) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } +} + +func TestInvokeCountGeoDataUsesPayloadDatDir(t *testing.T) { + datDir := t.TempDir() + writeGeoSiteDatForTest(t, filepath.Join(datDir, "geosite.dat")) + + response := invokeForTest( + t, + LibXrayMethodCountGeoData, + CountGeoDataRequest{ + Name: "geosite", + GeoType: "domain", + DatDir: datDir, + }, + ) + if !response.Success { + t.Fatalf("CountGeoData failed: %s", response.Err) + } + requireNoDataObject(t, response) + output, err := os.ReadFile(filepath.Join(datDir, "geosite.json")) + if err != nil { + t.Fatal(err) + } + if !json.Valid(output) { + t.Fatalf("geosite.json is not valid JSON: %s", output) + } + var list struct { + CategoryCount int `json:"categoryCount,omitempty"` + RuleCount int `json:"ruleCount,omitempty"` + } + if err := json.Unmarshal(output, &list); err != nil { + t.Fatal(err) + } + if list.CategoryCount != 1 || list.RuleCount != 1 { + t.Fatalf("unexpected geosite counts: %+v", list) + } +} + func TestInvokeUnknownMethod(t *testing.T) { - response := invokeForTest(t, LibXrayMethod("unknown"), nil, nil) + response := invokeForTest(t, LibXrayMethod("unknown"), nil) if response.Success { t.Fatal("unknown method should fail") } } -func TestInvokeAPIVersion(t *testing.T) { - response := invokeRawForTest(t, `{"method":"xrayVersion"}`) - if !response.Success { - t.Fatalf("omitted apiVersion should default to v1: %s", response.Err) +func TestInvokeRemovedMethods(t *testing.T) { + for _, method := range []string{"ping", "runXrayFromJson", "deriveAgePublicKey", "checkRoute"} { + response := invokeRawForTest( + t, + `{"apiVersion":3,"method":"`+method+`","payload":{}}`, + ) + if response.Success { + t.Fatalf("removed method %q should fail", method) + } + if response.Err != "unknown method" { + t.Fatalf("method %q error = %q, want unknown method", method, response.Err) + } } +} - t.Setenv(platform.AssetLocation, "initial-asset") - response = invokeRawForTest(t, `{"apiVersion":2,"method":"xrayVersion","env":{"xray.location.asset":"updated-asset"}}`) +func TestInvokeRejectsOversizedRequest(t *testing.T) { + response := invokeRawForTest(t, strings.Repeat(" ", maxInvokeJSONBytes+1)) if response.Success { - t.Fatal("unsupported apiVersion should fail") + t.Fatal("oversized request should fail") + } + if response.Err != "invoke request exceeds the 16 MiB size limit" { + t.Fatalf("error = %q", response.Err) } if got := string(response.Data); got != "null" { t.Fatalf("data = %s, want null", got) } - if got := os.Getenv(platform.AssetLocation); got != "initial-asset" { - t.Fatalf("asset env = %q", got) +} + +func TestInvokeRejectsOversizedResponse(t *testing.T) { + responseText := encodeInvokeResponse( + struct { + Text string `json:"text"` + }{Text: strings.Repeat("x", maxInvokeJSONBytes)}, + nil, + ) + var response testResponse + if err := json.Unmarshal([]byte(responseText), &response); err != nil { + t.Fatal(err) + } + if response.Success { + t.Fatal("oversized response should fail") + } + if response.Err != "invoke response exceeds the 16 MiB size limit" { + t.Fatalf("error = %q", response.Err) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } +} + +func TestInvokeAPIVersion(t *testing.T) { + response := invokeRawForTest(t, `{"method":"xrayVersion"}`) + if response.Success { + t.Fatal("omitted apiVersion should fail") + } + + 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":3,"method":"xrayVersion"}`) + if !response.Success { + t.Fatalf("v3 apiVersion should succeed: %s", response.Err) } } func TestInvokeNoDataResponseShape(t *testing.T) { - response := invokeForTest(t, LibXrayMethodStopXray, nil, nil) + response := invokeForTest(t, LibXrayMethodStopXray, nil) if !response.Success { t.Fatalf("StopXray failed: %s", response.Err) } requireNoDataObject(t, response) - response = invokeRawForTest(t, `{"apiVersion":1,"method":"runXray","payload":"invalid"}`) + response = invokeRawForTest(t, `{"apiVersion":3,"method":"runXray","payload":"invalid"}`) if response.Success { t.Fatal("invalid runXray payload should fail") } @@ -367,26 +856,26 @@ func TestInvokeNoDataResponseShape(t *testing.T) { } } -func TestInvokeIgnoresUnknownEnvField(t *testing.T) { +func TestInvokeIgnoresTopLevelEnv(t *testing.T) { const key = "XRAY_LIBXRAY_UNKNOWN_ENV_TEST" _ = os.Unsetenv(key) t.Cleanup(func() { _ = os.Unsetenv(key) }) - requestJSON := `{"apiVersion":1,"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) } if !response.Success { - t.Fatalf("unknown env field should be ignored: %s", response.Err) + t.Fatalf("top-level env should be ignored: %s", response.Err) } if _, found := os.LookupEnv(key); found { - t.Fatal("unknown env field should not be set") + t.Fatal("top-level env should not be set") } } func xrayStopForTest(t *testing.T) { t.Helper() - response := invokeForTest(t, LibXrayMethodStopXray, nil, nil) + response := invokeForTest(t, LibXrayMethodStopXray, nil) if !response.Success { t.Fatalf("StopXray failed: %s", response.Err) } diff --git a/memory/memory_ios.go b/memory/memory_ios.go index 5d6b5d67..3294e520 100644 --- a/memory/memory_ios.go +++ b/memory/memory_ios.go @@ -4,7 +4,7 @@ package memory import ( "runtime/debug" - + "sync" "time" ) @@ -14,6 +14,8 @@ const ( maxMemory = 30 * 1024 * 1024 ) +var initForceFreeOnce sync.Once + func forceFree(interval time.Duration) { go func() { for { @@ -24,8 +26,10 @@ func forceFree(interval time.Duration) { } func InitForceFree() { - debug.SetGCPercent(10) - debug.SetMemoryLimit(maxMemory) - duration := time.Duration(interval) * time.Second - forceFree(duration) + initForceFreeOnce.Do(func() { + debug.SetGCPercent(10) + debug.SetMemoryLimit(maxMemory) + duration := time.Duration(interval) * time.Second + forceFree(duration) + }) } diff --git a/nodep/measure.go b/nodep/measure.go index 648c9c16..112fde74 100644 --- a/nodep/measure.go +++ b/nodep/measure.go @@ -51,16 +51,19 @@ func CoreHTTPClient(timeout time.Duration, proxy string) (*http.Client, error) { func PingHTTPRequest(c *http.Client, url string, timeout int) (int64, error) { start := time.Now() - req, _ := http.NewRequest("HEAD", url, nil) - _, err := c.Do(req) + req, err := http.NewRequest("HEAD", url, nil) + if err != nil { + return PingDelayError, err + } + response, err := c.Do(req) delay := time.Since(start).Milliseconds() if err != nil { precision := delay - int64(timeout)*1000 if math.Abs(float64(precision)) < 50 { return PingDelayTimeout, err - } else { - return PingDelayError, err } + return PingDelayError, err } + response.Body.Close() return delay, nil } diff --git a/nodep/port.go b/nodep/port.go index c06b3bb2..af74a55f 100644 --- a/nodep/port.go +++ b/nodep/port.go @@ -1,26 +1,51 @@ package nodep import ( + "fmt" "net" ) -// https://github.com/phayes/freeport/blob/master/freeport.go -// GetFreePort asks the kernel for free open ports that are ready to use. +// Port allocation approach: https://github.com/phayes/freeport/blob/master/freeport.go +// GetFreePorts returns distinct free TCP ports, excluding caller-reserved ports. +// Listeners are released before returning, so the ports are not reserved for use. +func GetFreePorts(count int, excludePorts []int) ([]int, error) { + return getFreePorts(count, excludePorts, func() (net.Listener, error) { + return net.Listen("tcp", "localhost:0") + }) +} -func GetFreePorts(count int) ([]int, error) { - var ports []int - for range count { - addr, err := net.ResolveTCPAddr("tcp", "localhost:0") - if err != nil { - return ports, err +func getFreePorts(count int, excludePorts []int, listen func() (net.Listener, error)) ([]int, error) { + excluded := make(map[int]struct{}, len(excludePorts)) + for _, port := range excludePorts { + if port < 1 || port > 65535 { + return nil, fmt.Errorf("excluded port must be between 1 and 65535: %d", port) } + excluded[port] = struct{}{} + } + if count < 0 || count > 65535-len(excluded) { + return nil, fmt.Errorf("requested port count exceeds the available range: %d", count) + } - l, err := net.ListenTCP("tcp", addr) + var ports []int + var listeners []net.Listener + defer func() { + for _, listener := range listeners { + listener.Close() + } + }() + for len(ports) < count { + listener, err := listen() if err != nil { return ports, err } - ports = append(ports, l.Addr().(*net.TCPAddr).Port) - l.Close() + // Hold rejected ports too: closing them here could let the kernel pick + // the same excluded port indefinitely. All listeners close on return. + listeners = append(listeners, listener) + port := listener.Addr().(*net.TCPAddr).Port + if _, skip := excluded[port]; !skip { + excluded[port] = struct{}{} + ports = append(ports, port) + } } return ports, nil } diff --git a/nodep/port_test.go b/nodep/port_test.go new file mode 100644 index 00000000..66904227 --- /dev/null +++ b/nodep/port_test.go @@ -0,0 +1,111 @@ +package nodep + +import ( + "errors" + "net" + "reflect" + "testing" +) + +func TestGetFreePorts(t *testing.T) { + excluded := []int{18587, 18587, 9000} + ports, err := GetFreePorts(8, excluded) + if err != nil { + t.Fatal(err) + } + if len(ports) != 8 { + t.Fatalf("ports = %v, want 8", ports) + } + seen := map[int]bool{18587: true, 9000: true} + for _, port := range ports { + if port < 1 || port > 65535 || seen[port] { + t.Fatalf("invalid, excluded or duplicate port: %d", port) + } + seen[port] = true + } +} + +func TestGetFreePortsHoldsExcludedAndSelectedListeners(t *testing.T) { + listeners := []*portListener{{port: 18587}, {port: 50001}, {port: 50002}} + calls := 0 + ports, err := getFreePorts(2, []int{18587}, func() (net.Listener, error) { + for _, previous := range listeners[:calls] { + if previous.closed { + t.Fatal("listener released before selection completed") + } + } + listener := listeners[calls] + calls++ + return listener, nil + }) + if err != nil || !reflect.DeepEqual(ports, []int{50001, 50002}) { + t.Fatalf("ports = %v, error = %v", ports, err) + } + for _, listener := range listeners { + if !listener.closed { + t.Fatal("listener not released after selection") + } + } +} + +func TestGetFreePortsClosesListenersOnError(t *testing.T) { + for _, first := range []int{18587, 50001} { + listener := &portListener{port: first} + failure := errors.New("no more ports") + calls := 0 + _, err := getFreePorts(2, []int{18587}, func() (net.Listener, error) { + calls++ + if calls == 1 { + return listener, nil + } + return nil, failure + }) + if !errors.Is(err, failure) || !listener.closed { + t.Fatalf("error = %v, closed = %v", err, listener.closed) + } + } +} + +func TestGetFreePortsRejectsImpossibleRequestsBeforeListening(t *testing.T) { + for _, request := range []struct { + count int + exclude []int + }{ + {count: -1}, + {count: 65536}, + {count: 65535, exclude: []int{18587}}, + {count: 1, exclude: []int{0}}, + {count: 1, exclude: []int{-1}}, + {count: 1, exclude: []int{65536}}, + } { + _, err := getFreePorts(request.count, request.exclude, func() (net.Listener, error) { + t.Fatal("invalid request opened a listener") + return nil, nil + }) + if err == nil { + t.Fatalf("request %+v succeeded", request) + } + } +} + +func TestGetFreePortsZeroDoesNotListen(t *testing.T) { + ports, err := getFreePorts(0, nil, func() (net.Listener, error) { + t.Fatal("zero-port request opened a listener") + return nil, nil + }) + if err != nil || len(ports) != 0 { + t.Fatalf("ports = %v, error = %v", ports, err) + } +} + +type portListener struct { + port int + closed bool +} + +func (l *portListener) Accept() (net.Conn, error) { return nil, errors.New("unused") } +func (l *portListener) Addr() net.Addr { return &net.TCPAddr{Port: l.port} } +func (l *portListener) Close() error { + l.closed = true + return nil +} diff --git a/probe/probe_test.go b/probe/probe_test.go index c460bbfa..c4a8a2f4 100644 --- a/probe/probe_test.go +++ b/probe/probe_test.go @@ -4,8 +4,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "os" - "path/filepath" "runtime" "strconv" "sync/atomic" @@ -183,12 +181,8 @@ func TestProbeKeepsUnchangedHandlersAcrossSyncs(t *testing.T) { // closed. func TestProbeLeavesTheTunnelsDialerAlone(t *testing.T) { useFastTiming(t) - cfg := filepath.Join(t.TempDir(), "tunnel.json") tunnelConfig := `{"log":{"loglevel":"none"},"dns":{"hosts":{"pinned.test":"10.9.8.7"}},"outbounds":[{"protocol":"freedom"}]}` - if err := os.WriteFile(cfg, []byte(tunnelConfig), 0o600); err != nil { - t.Fatal(err) - } - if err := xray.RunXray(cfg); err != nil { + if err := xray.RunXray(tunnelConfig); err != nil { t.Fatal(err) } t.Cleanup(func() { xray.StopXray() }) diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 5adda226..8f58be7e 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -16,9 +16,8 @@ 依赖 git 和 go。 -默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 将 Xray-core 固定到 tag `v26.6.27`(Go 会记录为对应的 pseudo-version)。 +默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.10.21`。 传入可选参数 `local` 时,会通过 Go module `replace` 改用已有的本地仓库 `../Xray-core`。 -Linux 和 Windows 构建只输出 libXray 动态库。需要独立 Xray 可执行文件的应用应使用 Xray-core 官方发布的二进制。 ### 使用方式 @@ -35,6 +34,50 @@ python3 build/main.py windows python3 build/main.py windows local ``` +默认会为 `go.mod` 和 `go.sum` 保存快照,并在构建成功或失败后恢复。 +传入 `--no-snapshot` 可跳过快照和恢复;初始化 Go 依赖前会先删除现有的 +`go.mod` 和 `go.sum`,再重新初始化模块,即使构建失败也会保留重新生成的文件。 +所有构建目标均支持此参数,可以与 `local` 组合使用,顺序不限: + +```shell +python3 build/main.py android --no-snapshot +python3 build/main.py apple go local --no-snapshot +``` + +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 [-error-file <路径>] +``` + +`-dns`、`-interface`、`-config` 均必须提供。`-dns` 必须是 IP endpoint,`-config` 直接指向 Xray +JSON 配置。 + +可选的 `-error-file` 将命令失败时打印到 stderr 的原始错误同时写入 UTF-8 文件, +随后退出。运行前清空文件,成功时保持为空;父目录必须已经存在。通过提权启动 Core +的调用方应先以自身账号创建文件,以保留读取权限。该文件仅用于返回错误,不是 +Xray 的 access/error 日志配置,也不增加额外的预校验。使用此参数的 App 必须同时 +打包支持该参数的新版桌面 Core。 + +> [!WARNING] +> **每个进程只能使用一个 Go runtime。** Go 不支持在同一进程中加载多个独立构建的 +> Go runtime。libXray 的所有原生产物都会嵌入 Go runtime,无论它们通过 cgo 还是 +> gomobile 生成。不要在同一个可执行文件或进程中同时加载 libXray 与另一个独立构建的 +> Go、cgo 或 gomobile 库,否则可能在构建、链接或加载阶段失败,也可能在应用代码执行前 +> 的 runtime 初始化阶段崩溃。 +> 如果同一进程需要多个库中的 Go package,应将这些 package 放入同一次 Go build 或 +> `gomobile bind` 并生成一个原生产物,使其共享一个 runtime。仅重新打包或合并已经独立 +> 构建的 framework、archive、AAR、shared library 或 DLL 并不能解决问题。不同的操作系统 +> 进程可以各自加载一个 Go runtime,因此需要分别对每个进程遵守这一限制。参见 +> [Go #18976](https://github.com/golang/go/issues/18976#issuecomment-308505600)、 +> [golang/go#15956](https://github.com/golang/go/issues/15956#issuecomment-373709423) +> 和 [libXray #116](https://github.com/XTLS/libXray/issues/116)。 + ### Android 使用 [gomobile](https://github.com/golang/mobile) 。 @@ -45,7 +88,8 @@ python3 build/main.py windows local 需要 “iOS Simulator Runtime”。 -这是常规场景下的最佳选择,不会与其他 frameworks 冲突。 +这是常规场景下的最佳选择;与其他基于 Go 的库同时集成时,仍须遵守上方跨平台的 +单 runtime 限制。 支持 iOS,iOSSimulator,macOS,macCatalyst。 @@ -70,13 +114,10 @@ python3 build/main.py windows local ### Windows -依赖 LLVM MinGW 。 - -你可使用 winget 安装 [LLVM MinGW](https://github.com/mstorsjo/llvm-mingw)。 +依赖 `PATH` 中的 gcc 和 g++。 -```shell -winget install MartinStorsjo.LLVM-MinGW.UCRT -``` +支持原生 amd64 和 arm64 构建。Release workflow 会在对应架构的 GitHub +Windows runner 上分别构建产物。 ## API @@ -90,22 +131,20 @@ C 导出为: ```c char* CGoInvoke(char* requestJSON); +void CGoFree(char* value); ``` +`CGoInvoke` 会分配返回值。调用方必须使用 `CGoFree` 释放每个非空返回值, +不要直接使用平台分配器释放。 + 请求是 JSON 对象: ```json { - "apiVersion": 1, + "apiVersion": 3, "method": "runXray", - "env": { - "xray.location.config": "/path/to/config.json", - "xray.location.asset": "/path/to/dat", - "xray.location.cert": "/path/to/dat", - "xray.tun.fd": "123" - }, "payload": { - "configPath": "/path/to/config.json" + "xrayJson": "{\"outbounds\":[...]}" } } ``` @@ -120,44 +159,29 @@ char* CGoInvoke(char* requestJSON); } ``` -`env` 是可选字段,只支持 libXray 显式建模的 Xray-core 环境变量: - -| JSON key | 含义 | -| --- | --- | -| `xray.location.config` | Xray 配置文件路径 | -| `xray.location.confdir` | Xray 配置目录路径 | -| `xray.location.asset` | 保存 `geosite.dat`、`geoip.dat` 和自定义 GeoData 的目录 | -| `xray.location.cert` | Xray-core 使用的证书目录 | -| `xray.buf.readv` | Xray-core readv buffer 开关 | -| `xray.buf.splice` | Xray-core splice buffer 开关 | -| `xray.vmess.padding` | VMess padding 开关 | -| `xray.cone.disabled` | Cone 行为开关 | -| `xray.json.strict` | 严格 JSON 解析开关 | -| `xray.ray.buffer.size` | Ray buffer size | -| `xray.browser.dialer` | Browser dialer 地址 | -| `xray.xudp.show` | XUDP 日志显示开关 | -| `xray.xudp.basekey` | XUDP base key | -| `xray.tun.fd` | Android、iOS、macOS Packet Tunnel 使用的 TUN 文件描述符 | - 设计决定: -1. `env` 在 Go 和 Dart 侧都是固定字段 model,不是自由 map。 -2. 未知 `env` key 会被忽略,不会写入进程环境变量。 -3. `env` 只设置已建模的非空字段,缺失字段不会 unset。 -4. libXray 不会在 method 结束后 restore 旧环境变量。调用方必须在每次依赖环境变量的请求中显式传入对应字段。这样可以避免并发调用时,一个请求恢复旧值覆盖另一个请求的新值。 -5. `SetTunFd` 已删除。请在 `runXray` 请求的 `env` 对象中传入 `xray.tun.fd`。 +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 保持不变。每次成功响应仅包含投影后的 `outbounds` 列表。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 +7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。`pingBatch`、`testXray` 及对应导出的 Go 入口均取得受管理生命周期锁,在加载/构建配置前拒绝同进程已运行的 `runXray` instance。批量测速在全部 worker 和临时核心关闭后才释放锁,这些操作也彼此串行。由管理 API 之外创建的 instance 不在检测或恢复范围内;可能与它们重叠的调用仍须使用独立进程。 +8. `decodeSubscriptionText` 在可选的 Age 解密后返回完整明文,不解析订阅格式、不投影 outbound、不构造 Xray instance;分类和配置校验由调用方负责。 支持的 method: ```text getFreePorts +decodeSubscriptionText convertShareLinksToXrayJson convertXrayJsonToShareLinks +generateAgeKeyPair countGeoData -ping +pingBatch testXray runXray -runXrayFromJson stopXray xrayVersion getXrayState @@ -167,6 +191,25 @@ getXrayState 用于解决 Android 上 socket protect 问题。 +### DNS 解析器 + +Android VPN 运行时可能会向 Go 解析器提供回环 DNS 地址。请在调用 +`runXray` 前调用 `SetDNS`,让 Go 使用 VPN 配置指定的 DNS,并通过 +`protectFd` 将 DNS socket 排除在 VPN 隧道外。DNS 必须是包含端口的 IP +地址,例如 `8.8.8.8:53` 或 `[2001:4860:4860::8888]:53`。 + +Xray 停止后调用 `ResetDNS`。这两个 API 仅存在于 Android 产物中,并会 +修改 Go 进程级默认解析器。 + +```java +LibXray.setDNS(controller, "8.8.8.8:53"); +LibXray.invoke(runXrayRequest); + +// 稍后停止 Core 时: +LibXray.invoke(stopXrayRequest); +LibXray.resetDNS(); +``` + ## geo ### count @@ -193,29 +236,155 @@ getXrayState ### port -获取空闲端口。 +`getFreePorts` 返回 localhost 上互不重复的空闲 TCP 端口。payload 支持 +`count` 和可选的 `excludePorts`,用于避开调用方预留的固定端口: -## share +```json +{ + "apiVersion": 3, + "method": "getFreePorts", + "payload": {"count": 2, "excludePorts": [18587, 9000]} +} +``` -libXray 使用 `sendThrough` 来存储节点名称。 +响应保持现有的 `data.ports` 整数数组。省略 `excludePorts` 或传空列表表示不排除端口, +重复值不影响结果。排除端口必须在 1–65535 之间;数量为负数或超过可用范围时报错, +数量为 0 时不返回端口。Go 入口为 `nodep.GetFreePorts(count int, excludePorts []int)`, +不排除端口时传入 `nil`。 -### clash_meta +选择期间保持监听,避免返回重复端口或反复选中同一个排除端口;返回前关闭全部监听, +失败时也会关闭。因此结果仅为候选端口,不构成预留,调用方绑定前仍可能被其他进程占用。 +仅检查 TCP,不检查 UDP。 -解析 Clash.Meta 配置。 +## share + +libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义,用于指定本地绑定地址。 ### generate_share -转换 Xray Json 为 VMessAEAD/VLESS 分享协议。 +按照 [Xray-core 讨论 #716](https://github.com/XTLS/Xray-core/discussions/716) +将 Xray JSON 转换为 VMessAEAD / VLESS 分享链接,同时支持 Hysteria2、SS、SOCKS 和 Trojan。 +VMess 始终生成 AEAD URI,不生成旧版二维码格式。 + +没有对应分享格式的 outbound 会被跳过;无法生成任何分享链接时返回失败。 ### parse_share -转换 VMessAEAD/VLESS 分享协议为 Xray Json。 +将 VMessAEAD / VLESS、Hysteria2、SS、SOCKS、Trojan 分享链接解析为 Xray JSON。 +不支持旧版 VMessQrCode 链接(`vmess://Base64(JSON)`)。 + +保留 Xray JSON 节点输入以及 Base64 / Age 订阅包装,不支持 Clash/Mihomo 配置。 + +Hysteria2 接受 `hysteria2://` 和 `hy2://`,遵循 +[官方 URI 格式](https://v2.hysteria.network/docs/developers/URI-Scheme/):支持可选认证、 +默认端口 443、IPv6、SNI、Salamander 混淆、多端口 authority 与名称 fragment。 +导出统一使用 `hysteria2://`。端口跳跃映射到 Core 的 `finalmask.udp`,同时启用本地与 +远端定时跳跃,默认 30 秒,`hop-interval` 至少为 5 秒。兼容 `ports` / `mport` 查询参数, +导出时转为 authority 中的多端口。旧 `up` / `down` 仅导入为客户端 QUIC 调优,不导出。 + +TLS 必须开启。拒绝 `insecure=true` / `allowInsecure=true` 和 `pinSHA256`, +因为当前 Core 无法等价表达这些 Hysteria 证书校验语义。显式 Xray 扩展 `fp`、`alpn`、 +`ech`、`pcs`、`vcn` 保留 Xray 原有语义。无法表达的 TLS 或 mask 设置拒绝导出, +不静默丢弃安全或混淆配置。客户端 QUIC 调优不属于 URI。不支持 Realm 或 Gecko。 +端口跳跃需要直接 UDP socket;本次恢复不增加链式代理上的跳跃支持。 + +VMessAEAD / VLESS 的字段映射遵循 +[Xray 分享链接提案](https://github.com/XTLS/Xray-core/discussions/716), +以当前内置 Core 支持的能力为限: + +- 双向保留 mKCP 的 `mtu` 和 `tti`。省略时使用 Core 默认值,数值范围由 + Core 校验。不导入或导出旧版 KCP 的 `seed` 和 `headerType`。 +- XHTTP `extra` 保留完整 JSON 内容,包括嵌套配置。`fm` 保留 FinalMask + 掩饰配置和全部 Core 支持的 `quicParams` 字段。 +- 保留 TLS 的 `ech`、`pcs`、`vcn`,REALITY 的 `pbk`、`sid`、`pqv`、`spx`, + 以及 `sni`、`fp` 和 TLS `alpn`。省略 `sni` 时使用远端主机, + 不使用 WebSocket 的 HTTP host。 +- 从原生配置导出时保留传输别名对应的配置。RAW/TCP 链接使用 `type=tcp`, + 查询参数中的空格使用百分号编码。 +- gRPC 支持 `gun` 和 `multi`。当前内置 Core 不支持 `guna` 模式以及 + 已移除的 HTTP/QUIC 传输,不会将其静默替换成其他模式。 + +#### 解析结果 -转换 VMessQRCode 为 Xray Json。 +`convertShareLinksToXrayJson` 只有一种响应结构。payload 包含 `text` 和可选的 +`age`。每次转换成功均返回 +`data: {"outbounds":[...]}`,不包含统计或额外的 `config` 包装。 -### vmess +无效单项会被跳过,不丢弃其余有效节点。列表按源顺序保留投影且可构建的 +outbound,不做节点 hash 比较、去重或失败节点计数。 + +没有可用节点、无法识别格式、整份文档语法错误、容器错误或解密失败时, +返回 `success: false` 与 `data: null`。错误文案不含被拒绝的候选或解密明文。 +调用方不得在可用节点为零时导入或覆盖订阅。 + +### decodeSubscriptionText + +返回完整订阅文本,包括完整 Xray JSON 对象或数组及其中的 DNS、路由、均衡器 +和未知字段。该方法只解开 Age armor;未加密文本(包括 URI 列表和 Base64 +字符串)原样返回,不校验 JSON,也不解码 Base64。 + +```json +{ + "apiVersion": 3, + "method": "decodeSubscriptionText", + "payload": { + "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", + "age": { + "secretKey": "AGE-SECRET-KEY-1..." + } + } +} +``` -转换 VMessQRCode 为 Xray Json。 +成功返回 `data: {"text": "<完整明文>"}`,空文本也始终包含 `text`。失败返回 +`data: null` 和既有 Age 密钥、armor、解密或明文大小错误。未加密和解密后的 +明文均限制为 16 MiB UTF-8 字节;完整 Invoke 请求和编码后的响应仍各有 16 MiB +上限,因此包体或转义开销可能使未达到明文上限的文本被拒绝。Go helper 为 +`share.DecodeSubscriptionText(text, secretKey)`。 + +调用方先分类返回文本,再选择节点导入或完整配置存储流程。此方法不创建临时 +Core,可在受管理 instance 运行时调用。 + +### age 加密订阅 + +`decodeSubscriptionText` 和 `convertShareLinksToXrayJson` 接受可选的 age 原生 +私钥。仅支持 X25519 +(`AGE-SECRET-KEY-1...`)和 ML-KEM-768 + X25519 hybrid +(`AGE-SECRET-KEY-PQ-1...`)identity。识别到 age armor 后会在内存中完成 +解密,解密后明文上限为 16 MiB。 + +```json +{ + "apiVersion": 3, + "method": "convertShareLinksToXrayJson", + "payload": { + "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", + "age": { + "secretKey": "AGE-SECRET-KEY-1..." + } + } +} +``` + +`generateAgeKeyPair` 可生成新密钥对,`keyType` 支持 `x25519` 或 +`hybrid`;省略时默认为 `x25519`。`hybrid` 对应 Mihomo 的 +`age keygen-pq`,生成 `AGE-SECRET-KEY-PQ-1...` identity 和 +`age1pq1...` recipient: + +```json +{ + "apiVersion": 3, + "method": "generateAgeKeyPair", + "payload": { + "keyType": "x25519" + } +} +``` + +响应同时包含 `secretKey` 和 `publicKey`。接入 App 必须持久化该密钥对,并且 +只将 `publicKey` 作为 `X-Age-Public-Key` 发送。libXray 不负责订阅 HTTP 请求、 +密钥持久化或请求 Header;严禁通过 HTTP 发送私钥,不能记录私钥或解密后的 +订阅文本日志。配置持久化由接入 App 负责。 ### xray_json @@ -223,13 +392,83 @@ libXray 使用 `sendThrough` 来存储节点名称。 ## xray -### ping +### pingBatch + +在一个临时 Xray instance 内并发测试多份 outbound 配置。每个 `xrayJson` 文本只 +解析 `outbounds`,其他根字段全部忽略。目标 outbound 依次按 `outboundTag`、 +`proxy` tag、首个 outbound 选择。 + +```json +{ + "apiVersion": 3, + "method": "pingBatch", + "payload": { + "configs": [ + { + "xrayJson": "{\"outbounds\":[...]}" + }, + { + "xrayJson": "{\"outbounds\":[...]}", + "outboundTag": "media" + } + ], + "timeout": 5, + "url": "https://cp.cloudflare.com/" + } +} +``` + +每次请求最多接受 5 份配置,并发测试该请求中所有已接受的配置。超过 5 份配置的 +请求会在开始测试前直接失败。 -延迟测试。 +批次请求本身被接受时,顶层 response 为成功;每个配置通过自己的结果表示成功或 +失败。`delay` 为 `10000` 表示错误,`11000` 表示超时。结果数组与输入配置数组 +长度相同且顺序一致。 +`delay` 始终输出,包含成功的 0 毫秒结果。 +通过 `streamSettings.sockopt.dialerProxy` 引用的 outbound 依赖会被自动包含。 +已废弃的 `proxySettings` 字段由 Xray-core 拒绝。 + +每个完成准备的配置使用指定超时,通过其所选 outbound 及依赖发送一次延迟 +HEAD。逐项结果仅包含 `success`、`delay` 和可选的 `error`;无效 outbound +保留逐项失败,不发送请求。 + +### testXray + +加载传入的 Xray JSON 文本并构造临时 instance。payload 仅包含 `xrayJson`,成功时返回 +`data: {}`: + +```json +{ + "apiVersion": 3, + "method": "testXray", + "payload": { + "xrayJson": "{\"outbounds\":[...]}" + } +} +``` + +Go 入口 `TestXray` 调用 `newXrayInstance`(`core.LoadConfig → core.New`),成功构造后 +关闭临时 instance,再返回结果。不调用 `Start`,也不登记为受管理的运行 instance。 +路由匹配器、缺失 balancer 等构造错误以及关闭错误通过正常错误响应返回。 + +校验不是沙箱:构建器和构造器可能读取本地 GeoData/证书、应用 `env`、替换进程级 +日志/DNS 状态、创建日志文件,或初始化协议专属资源与后台任务。这些进程状态不恢复。 +WireGuard 可能在构造时创建 TUN,VLESS reverse 可能安排后台任务。当前 Core 构造失败时 +不返回部分 instance,因此 libXray 无法关闭该部分 instance。不调用 `Start` 时,内核 +Geodata cron 不运行,但 assets 声明仍会检查本地文件。 + +调用方允许构造最小配置,或在验证副本中排除 App 管理的字段;libXray 不做 App 专属裁剪。 +成功只说明传入配置可以完成实例构造和关闭,不覆盖监听/TUN 启动、系统权限与网络连通性。 +被排除的配置不在验证范围内,调用方仍须处理实际启动失败。 + +### runXray + +使用传入的 Xray JSON 文本启动由 libXray 管理的 Xray instance,并通过 +`stopXray` 停止。`runXrayFromJson` 不再作为独立 method 存在。 ### metrics -统计。 +App 直接通过 Xray metrics HTTP API 读取当前 instance 的流量计数;libXray 不采样或保存流量。 参考如下配置: @@ -257,10 +496,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 @@ -278,6 +514,8 @@ http://localhost:49227/debug/vars [FreePort](https://github.com/phayes/freeport) +[MetaCubeX age](https://github.com/MetaCubeX/age)(BSD 3-Clause) + # License 本仓库基于 MIT License 。 diff --git a/share/age.go b/share/age.go new file mode 100644 index 00000000..ee2c8208 --- /dev/null +++ b/share/age.go @@ -0,0 +1,117 @@ +package share + +import ( + "errors" + "io" + "strings" + + "github.com/metacubex/age" + "github.com/metacubex/age/armor" +) + +const ( + ageArmorHeader = "-----BEGIN AGE ENCRYPTED FILE-----" + maxAgePlaintextBytes = 16 * 1024 * 1024 +) + +var ( + ErrAgeSecretKeyMissing = errors.New("missing age secret key") + ErrAgeSecretKeyInvalid = errors.New("invalid or unsupported age secret key") + ErrAgeDecryptFailed = errors.New("unable to decrypt age subscription") + ErrAgeArmorMalformed = errors.New("malformed age armor") + ErrAgePlaintextTooLarge = errors.New("decrypted subscription exceeds the 16 MiB size limit") + ErrAgePlaintextUnsupported = errors.New("decrypted subscription is unsupported") + ErrAgeKeyTypeUnsupported = errors.New("unsupported age key type") +) + +type AgeKeyType string + +const ( + AgeKeyTypeX25519 AgeKeyType = "x25519" + AgeKeyTypeHybrid AgeKeyType = "hybrid" +) + +type AgeKeyPair struct { + SecretKey string + PublicKey string +} + +func GenerateAgeKeyPair(keyType AgeKeyType) (*AgeKeyPair, error) { + switch keyType { + case "", AgeKeyTypeX25519: + identity, err := age.GenerateX25519Identity() + if err != nil { + return nil, errors.New("failed to generate age keypair") + } + return &AgeKeyPair{ + SecretKey: identity.String(), + PublicKey: identity.Recipient().String(), + }, nil + case AgeKeyTypeHybrid: + identity, err := age.GenerateHybridIdentity() + if err != nil { + return nil, errors.New("failed to generate age keypair") + } + return &AgeKeyPair{ + SecretKey: identity.String(), + PublicKey: identity.Recipient().String(), + }, nil + default: + return nil, ErrAgeKeyTypeUnsupported + } +} + +func decryptShareText(links, secretKey string) (string, bool, error) { + text := strings.TrimSpace(FixWindowsReturn(links)) + if !strings.HasPrefix(text, ageArmorHeader) { + return links, false, nil + } + if strings.TrimSpace(secretKey) == "" { + return "", true, ErrAgeSecretKeyMissing + } + + identity, _, err := parseNativeAgeIdentity(secretKey) + if err != nil { + 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 "", true, ErrAgeDecryptFailed + } + return "", true, ErrAgeArmorMalformed + } + + plaintext, err := io.ReadAll(io.LimitReader(reader, maxAgePlaintextBytes+1)) + if err != nil { + return "", true, ErrAgeArmorMalformed + } + if len(plaintext) > maxAgePlaintextBytes { + return "", true, ErrAgePlaintextTooLarge + } + return string(plaintext), true, nil +} + +func parseNativeAgeIdentity(secretKey string) (age.Identity, age.Recipient, error) { + key := strings.TrimSpace(secretKey) + if key == "" || strings.ContainsAny(key, "\r\n") { + return nil, nil, ErrAgeSecretKeyInvalid + } + + if strings.HasPrefix(key, "AGE-SECRET-KEY-1") { + identity, err := age.ParseX25519Identity(key) + if err != nil { + return nil, nil, ErrAgeSecretKeyInvalid + } + return identity, identity.Recipient(), nil + } + if strings.HasPrefix(key, "AGE-SECRET-KEY-PQ-1") { + identity, err := age.ParseHybridIdentity(key) + if err != nil { + return nil, nil, ErrAgeSecretKeyInvalid + } + return identity, identity.Recipient(), nil + } + return nil, nil, ErrAgeSecretKeyInvalid +} diff --git a/share/age_test.go b/share/age_test.go new file mode 100644 index 00000000..77abc171 --- /dev/null +++ b/share/age_test.go @@ -0,0 +1,179 @@ +package share + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/metacubex/age" + "github.com/metacubex/age/armor" +) + +const ageTestShareLink = "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443?encryption=none&security=tls&sni=example.com#AgeTest" + +func TestGenerateAgeKeyPair(t *testing.T) { + for _, keyType := range []AgeKeyType{AgeKeyTypeX25519, AgeKeyTypeHybrid} { + t.Run(string(keyType), func(t *testing.T) { + first, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + second, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + if first.SecretKey == second.SecretKey || first.PublicKey == second.PublicKey { + t.Fatal("generated age keypairs should be unique") + } + assertAgeRoundTrip(t, first, ageTestShareLink) + }) + } +} + +func TestGenerateAgeKeyPairRejectsUnsupportedType(t *testing.T) { + _, err := GenerateAgeKeyPair(AgeKeyType("unsupported")) + if !errors.Is(err, ErrAgeKeyTypeUnsupported) { + t.Fatalf("error = %v, want %v", err, ErrAgeKeyTypeUnsupported) + } +} + +func TestConvertShareLinksToXrayJsonPlaintext(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + config, err := convertShareLinksWithKeyForTest(ageTestShareLink, pair.SecretKey) + if err != nil { + t.Fatal(err) + } + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } +} + +func TestConvertShareLinksToXrayJsonEncrypted(t *testing.T) { + for _, keyType := range []AgeKeyType{AgeKeyTypeX25519, AgeKeyTypeHybrid} { + t.Run(string(keyType), func(t *testing.T) { + pair, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest(t, pair, ageTestShareLink) + config, err := convertShareLinksWithKeyForTest(armored, pair.SecretKey) + if err != nil { + t.Fatal(err) + } + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } + }) + } +} + +func TestConvertShareLinksToXrayJsonErrors(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest(t, pair, ageTestShareLink) + + _, err = convertShareLinksWithKeyForTest(armored, "") + if !errors.Is(err, ErrAgeSecretKeyMissing) { + t.Fatalf("missing key error = %v", err) + } + + wrongPair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + _, err = convertShareLinksWithKeyForTest(armored, wrongPair.SecretKey) + if !errors.Is(err, ErrAgeDecryptFailed) { + t.Fatalf("wrong key error = %v", err) + } + if strings.Contains(err.Error(), wrongPair.SecretKey) { + t.Fatal("decryption error contains the secret key") + } + + _, err = convertShareLinksWithKeyForTest(ageArmorHeader+"\ninvalid", pair.SecretKey) + if !errors.Is(err, ErrAgeArmorMalformed) { + t.Fatalf("malformed armor error = %v", err) + } +} + +func TestConvertShareLinksToXrayJsonRejectsLargePlaintext(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest( + t, + pair, + strings.Repeat("x", maxAgePlaintextBytes+1), + ) + _, err = convertShareLinksWithKeyForTest(armored, pair.SecretKey) + if !errors.Is(err, ErrAgePlaintextTooLarge) { + t.Fatalf("large plaintext error = %v", err) + } +} + +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 = convertShareLinksWithKeyForTest(armored, pair.SecretKey) + if !errors.Is(err, ErrAgePlaintextUnsupported) { + t.Fatalf("unsupported plaintext error = %v", err) + } + if strings.Contains(err.Error(), sensitivePlaintext) || strings.Contains(err.Error(), pair.SecretKey) { + t.Fatal("age parser error contains sensitive input") + } +} + +func assertAgeRoundTrip(t *testing.T, pair *AgeKeyPair, plaintext string) { + t.Helper() + armored := encryptAgeForTest(t, pair, plaintext) + identity, _, err := parseNativeAgeIdentity(pair.SecretKey) + if err != nil { + t.Fatal(err) + } + reader, err := age.Decrypt(armor.NewReader(strings.NewReader(armored)), identity) + if err != nil { + t.Fatal(err) + } + decrypted, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if string(decrypted) != plaintext { + t.Fatalf("decrypted = %q, want %q", decrypted, plaintext) + } +} + +func encryptAgeForTest(t *testing.T, pair *AgeKeyPair, plaintext string) string { + t.Helper() + _, recipient, err := parseNativeAgeIdentity(pair.SecretKey) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + armored := armor.NewWriter(&output) + writer, err := age.Encrypt(armored, recipient) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(writer, plaintext); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := armored.Close(); err != nil { + t.Fatal(err) + } + return output.String() +} diff --git a/share/clash_meta.go b/share/clash_meta.go deleted file mode 100644 index ce8d3022..00000000 --- a/share/clash_meta.go +++ /dev/null @@ -1,706 +0,0 @@ -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"` - Server string `yaml:"server,omitempty"` - Port uint16 `yaml:"port,omitempty"` - Uuid string `yaml:"uuid,omitempty"` - Cipher string `yaml:"cipher,omitempty"` - Username string `yaml:"username,omitempty"` - Password string `yaml:"password,omitempty"` - Encryption string `yaml:"encryption,omitempty"` - - Ports string `yaml:"ports,omitempty"` - HopInterval int32 `yaml:"hop-interval,omitempty"` - Up string `yaml:"up,omitempty"` - Down string `yaml:"down,omitempty"` - Obfs string `yaml:"obfs,omitempty"` - ObfsPassword string `yaml:"obfs-password,omitempty"` - - Udp bool `yaml:"udp,omitempty"` - UdpOverTcp bool `yaml:"udp-over-tcp,omitempty"` - - Tls bool `yaml:"tls,omitempty"` - SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` - Servername string `yaml:"servername,omitempty"` - Sni string `yaml:"sni,omitempty"` - Alpn []string `yaml:"alpn,omitempty"` - - Fingerprint string `yaml:"fingerprint,omitempty"` - ClientFingerprint string `yaml:"client-fingerprint,omitempty"` - Flow string `yaml:"flow,omitempty"` - RealityOpts *ClashProxyRealityOpts `yaml:"reality-opts,omitempty"` - - Network string `yaml:"network,omitempty"` - Plugin string `yaml:"plugin,omitempty"` - PluginOpts *ClashProxyPluginOpts `yaml:"plugin-opts,omitempty"` - EchOpts *ClashProxyEchOpts `yaml:"ech-opts,omitempty"` - WsOpts *ClashProxyWsOpts `yaml:"ws-opts,omitempty"` - GrpcOpts *ClashProxyGrpcOpts `yaml:"grpc-opts,omitempty"` - XhttpOpts *ClashProxyXhttpOpts `yaml:"xhttp-opts,omitempty"` -} - -type ClashProxyEchOpts struct { - Enable bool `yaml:"enable,omitempty"` - Config string `yaml:"config,omitempty"` -} - -type ClashProxyRealityOpts struct { - PublicKey string `yaml:"public-key,omitempty"` - ShortId string `yaml:"short-id,omitempty"` -} - -type ClashProxyPluginOpts struct { - Mode string `yaml:"mode,omitempty"` - Tls bool `yaml:"tls,omitempty"` - Fingerprint string `yaml:"fingerprint,omitempty"` - EchOpts *ClashProxyEchOpts `yaml:"ech-opts,omitempty"` - SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` - Host string `yaml:"host,omitempty"` - Path string `yaml:"path,omitempty"` - Mux bool `yaml:"mux,omitempty"` -} - -type ClashProxyWsOpts struct { - Path string `yaml:"path,omitempty"` - Headers *ClashProxyWsOptsHeaders `yaml:"headers,omitempty"` -} - -type ClashProxyWsOptsHeaders struct { - Host string `yaml:"Host,omitempty"` -} - -type ClashProxyGrpcOpts struct { - GrpcServiceName string `yaml:"grpc-service-name,omitempty"` -} - -type ClashProxyXhttpOpts struct { - Path string `yaml:"path,omitempty"` - Host string `yaml:"host,omitempty"` - Mode string `yaml:"mode,omitempty"` - Headers map[string]string `yaml:"headers,omitempty"` - NoGrpcHeader bool `yaml:"no-grpc-header,omitempty"` - XPaddingBytes string `yaml:"x-padding-bytes,omitempty"` - ScMaxEachPostBytes string `yaml:"sc-max-each-post-bytes,omitempty"` - ReuseSettings *ClashProxyXhttpOptsXMUX `yaml:"reuse-settings,omitempty"` - DownloadSettings *ClashProxyXhttpOptsDownloadSettings `yaml:"download-settings,omitempty"` -} - -type ClashProxyXhttpOptsXMUX struct { - MaxConnections string `yaml:"max-connections,omitempty"` - MaxConcurrency string `yaml:"max-concurrency,omitempty"` - CMaxReuseTimes string `yaml:"c-max-reuse-times,omitempty"` - HMaxRequestTimes string `yaml:"h-max-request-times,omitempty"` - HMaxReusableSecs string `yaml:"h-max-reusable-secs,omitempty"` -} - -type ClashProxyXhttpOptsDownloadSettings struct { - Path string `yaml:"path,omitempty"` - Host string `yaml:"host,omitempty"` - Mode string `yaml:"mode,omitempty"` - Headers map[string]string `yaml:"headers,omitempty"` - NoGrpcHeader bool `yaml:"no-grpc-header,omitempty"` - XPaddingBytes string `yaml:"x-padding-bytes,omitempty"` - ScMaxEachPostBytes string `yaml:"sc-max-each-post-bytes,omitempty"` - ReuseSettings *ClashProxyXhttpOptsXMUX `yaml:"reuse-settings,omitempty"` - // proxy part - Server string `yaml:"server,omitempty"` - Port uint16 `yaml:"port,omitempty"` - Tls bool `yaml:"tls,omitempty"` - Alpn []string `yaml:"alpn,omitempty"` - EchOpts *ClashProxyEchOpts `yaml:"ech-opts,omitempty"` - RealityOpts *ClashProxyRealityOpts `yaml:"reality-opts,omitempty"` - SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` - Fingerprint string `yaml:"fingerprint,omitempty"` - Servername string `yaml:"servername,omitempty"` - 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 - - case "vmess": - outbound, err := proxy.vmessOutbound() - if err != nil { - return nil, err - } - return outbound, nil - - case "vless": - outbound, err := proxy.vlessOutbound() - if err != nil { - return nil, err - } - return outbound, nil - - case "socks5": - outbound, err := proxy.socksOutbound() - if err != nil { - return nil, err - } - return outbound, nil - case "trojan": - outbound, err := proxy.trojanOutbound() - if err != nil { - return nil, err - } - return outbound, nil - case "hysteria2": - outbound, err := proxy.hysteria2Outbound() - if err != nil { - return nil, err - } - return outbound, nil - } - return nil, fmt.Errorf("unsupported proxy type: %s", proxy.Type) -} - -func (proxy ClashProxy) shadowsocksOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "shadowsocks" - setOutboundName(outbound, proxy.Name) - - settings := conf.ShadowsocksClientConfig{} - - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settings.Cipher = proxy.Cipher - settings.Password = proxy.Password - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - if len(proxy.Plugin) != 0 { - if proxy.Plugin != "v2ray-plugin" { - return nil, fmt.Errorf("unsupport ss plugin: obfs") - } - if proxy.PluginOpts == nil { - return nil, fmt.Errorf("unsupport ss plugin-opts: nil") - } - if proxy.PluginOpts.Mode != "websocket" { - return nil, fmt.Errorf("unsupport ss plugin-opts mode: %s", proxy.PluginOpts.Mode) - } - streamSetting := &conf.StreamConfig{} - streamSetting.Network = new(conf.TransportProtocol("websocket")) - - wsSettings := &conf.WebSocketConfig{} - if len(proxy.PluginOpts.Host) > 0 { - wsSettings.Host = proxy.PluginOpts.Host - } - if len(proxy.PluginOpts.Path) > 0 { - wsSettings.Path = proxy.PluginOpts.Path - } - streamSetting.WSSettings = wsSettings - - if proxy.PluginOpts.Tls { - tlsSettings := &conf.TLSConfig{} - tlsSettings.Fingerprint = proxy.PluginOpts.Fingerprint - - if proxy.PluginOpts.EchOpts != nil { - if proxy.PluginOpts.EchOpts.Enable { - tlsSettings.ECHConfigList = proxy.PluginOpts.EchOpts.Config - } - } - - streamSetting.TLSSettings = tlsSettings - } - - outbound.StreamSetting = streamSetting - } - return outbound, nil -} - -func (proxy ClashProxy) vmessOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "vmess" - setOutboundName(outbound, proxy.Name) - - settings := conf.VMessOutboundConfig{} - - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settings.ID = proxy.Uuid - settings.Security = proxy.Cipher - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := proxy.streamSettings(*outbound) - if err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - - return outbound, nil -} - -func (proxy ClashProxy) vlessOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "vless" - setOutboundName(outbound, proxy.Name) - - settings := conf.VLessOutboundConfig{} - - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settings.Id = proxy.Uuid - settings.Flow = proxy.Flow - if len(proxy.Encryption) > 0 { - settings.Encryption = proxy.Encryption - } else { - settings.Encryption = "none" - } - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := proxy.streamSettings(*outbound) - if err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - - return outbound, nil -} - -func (proxy ClashProxy) socksOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "socks" - setOutboundName(outbound, proxy.Name) - - settings := conf.SocksClientConfig{} - - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settings.Username = proxy.Username - settings.Password = proxy.Password - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := proxy.streamSettings(*outbound) - if err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - - return outbound, nil -} - -func (proxy ClashProxy) trojanOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "trojan" - setOutboundName(outbound, proxy.Name) - - settings := conf.TrojanClientConfig{} - - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settings.Password = proxy.Password - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := proxy.streamSettings(*outbound) - if err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - - return outbound, nil -} - -func (proxy ClashProxy) hysteria2Outbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "hysteria" - setOutboundName(outbound, proxy.Name) - - settings := conf.HysteriaClientConfig{} - - settings.Version = 2 - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := proxy.streamSettings(*outbound) - if err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - - return outbound, nil -} - -func (proxy ClashProxy) streamSettings(outbound conf.OutboundDetourConfig) (*conf.StreamConfig, error) { - streamSettings := &conf.StreamConfig{} - network := proxy.Network - if len(proxy.Network) == 0 { - network = "raw" - } - transportProtocol := conf.TransportProtocol(network) - streamSettings.Network = &transportProtocol - - // fix hysteria network - if proxy.Type == "hysteria2" { - network = "hysteria" - transportProtocol = "hysteria" - streamSettings.Network = &transportProtocol - } - - switch network { - case "ws": - if proxy.WsOpts != nil { - wsSettings := &conf.WebSocketConfig{} - if proxy.WsOpts.Headers != nil { - wsSettings.Host = proxy.WsOpts.Headers.Host - } - wsSettings.Path = proxy.WsOpts.Path - streamSettings.WSSettings = wsSettings - } - case "grpc": - if proxy.GrpcOpts != nil { - grpcSettings := &conf.GRPCConfig{} - grpcSettings.ServiceName = proxy.GrpcOpts.GrpcServiceName - streamSettings.GRPCSettings = grpcSettings - } - case "xhttp": - if proxy.XhttpOpts != nil { - xhttpSettings, err := parseXHTTPOpts(*proxy.XhttpOpts) - if err != nil { - return nil, err - } - streamSettings.XHTTPSettings = xhttpSettings - } - case "hysteria": - hysteriaSettings := &conf.HysteriaConfig{} - hysteriaSettings.Version = 2 - hysteriaSettings.Auth = proxy.Password - streamSettings.HysteriaSettings = hysteriaSettings - - var hopPtr *int32 - if proxy.HopInterval > 0 { - hopPtr = new(proxy.HopInterval) - } - fm, err := buildHy2FinalMask(proxy.Up, proxy.Down, proxy.Ports, hopPtr, proxy.Obfs, proxy.ObfsPassword) - if err != nil { - return nil, err - } - streamSettings.FinalMask = fm - } - proxy.parseSecurity(streamSettings, outbound) - return streamSettings, nil -} - -func (proxy ClashProxy) parseSecurity(streamSettings *conf.StreamConfig, outbound conf.OutboundDetourConfig) { - tlsSettings := &conf.TLSConfig{} - realitySettings := &conf.REALITYConfig{} - - if proxy.Tls { - streamSettings.Security = "tls" - } - - if proxy.EchOpts != nil { - if proxy.EchOpts.Enable { - tlsSettings.ECHConfigList = proxy.EchOpts.Config - } - } - - if proxy.RealityOpts != nil { - streamSettings.Security = "reality" - realitySettings.PublicKey = proxy.RealityOpts.PublicKey - realitySettings.ShortId = proxy.RealityOpts.ShortId - } - if len(proxy.Servername) > 0 { - tlsSettings.ServerName = proxy.Servername - realitySettings.ServerName = proxy.Servername - } - if len(proxy.Sni) > 0 { - tlsSettings.ServerName = proxy.Sni - realitySettings.ServerName = proxy.Sni - } - if len(proxy.Alpn) > 0 { - tlsSettings.ALPN = new(conf.StringList(proxy.Alpn)) - } - if len(proxy.Fingerprint) > 0 { - tlsSettings.Fingerprint = proxy.Fingerprint - realitySettings.Fingerprint = proxy.Fingerprint - } - if len(proxy.ClientFingerprint) > 0 { - tlsSettings.Fingerprint = proxy.ClientFingerprint - realitySettings.Fingerprint = proxy.ClientFingerprint - } - - tlsSettings.AllowInsecure = proxy.SkipCertVerify - - if (outbound.Protocol == "trojan" || outbound.Protocol == "hysteria") && len(streamSettings.Security) == 0 { - streamSettings.Security = "tls" - } - - switch streamSettings.Security { - case "tls": - streamSettings.TLSSettings = tlsSettings - case "reality": - streamSettings.REALITYSettings = realitySettings - } -} - -func parseXHTTPOpts(xhttpOpts ClashProxyXhttpOpts) (*conf.SplitHTTPConfig, error) { - xhttpSettings := &conf.SplitHTTPConfig{} - xhttpSettings.Path = xhttpOpts.Path - xhttpSettings.Host = xhttpOpts.Host - xhttpSettings.Mode = xhttpOpts.Mode - - extra := &conf.SplitHTTPConfig{} - if len(xhttpOpts.Headers) > 0 { - extra.Headers = xhttpOpts.Headers - } - extra.NoGRPCHeader = xhttpOpts.NoGrpcHeader - if len(xhttpOpts.XPaddingBytes) > 0 { - int32Range, err := parseInt32RangeString(xhttpOpts.XPaddingBytes) - if err != nil { - return nil, err - } - extra.XPaddingBytes = *int32Range - } - if len(xhttpOpts.ScMaxEachPostBytes) > 0 { - int32Range, err := parseInt32RangeString(xhttpOpts.ScMaxEachPostBytes) - if err != nil { - return nil, err - } - extra.ScMaxEachPostBytes = *int32Range - } - if xhttpOpts.ReuseSettings != nil { - xmuxSettings, err := parseXHTTPXMUX(xhttpOpts.ReuseSettings) - if err != nil { - return nil, err - } - extra.Xmux = *xmuxSettings - } - if xhttpOpts.DownloadSettings != nil { - downloadSettings, err := parseXHTTPDownloadSettings(xhttpOpts.DownloadSettings) - if err != nil { - return nil, err - } - extra.DownloadSettings = downloadSettings - } - - extraRawMessage, err := convertJsonToRawMessage(extra) - if err != nil { - return nil, err - } - xhttpSettings.Extra = extraRawMessage - return xhttpSettings, nil -} - -func parseInt32RangeString(s string) (*conf.Int32Range, error) { - left, right, err := conf.ParseRangeString(s) - if err != nil { - return nil, err - } - return &conf.Int32Range{ - Left: int32(left), - Right: int32(right), - From: int32(left), - To: int32(right), - }, nil -} - -func parseXHTTPXMUX(reuseSettings *ClashProxyXhttpOptsXMUX) (*conf.XmuxConfig, error) { - xmuxSettings := &conf.XmuxConfig{} - if len(reuseSettings.MaxConnections) > 0 { - int32Range, err := parseInt32RangeString(reuseSettings.MaxConnections) - if err != nil { - return nil, err - } - xmuxSettings.MaxConnections = *int32Range - } - if len(reuseSettings.MaxConcurrency) > 0 { - int32Range, err := parseInt32RangeString(reuseSettings.MaxConcurrency) - if err != nil { - return nil, err - } - xmuxSettings.MaxConcurrency = *int32Range - } - if len(reuseSettings.CMaxReuseTimes) > 0 { - int32Range, err := parseInt32RangeString(reuseSettings.CMaxReuseTimes) - if err != nil { - return nil, err - } - xmuxSettings.CMaxReuseTimes = *int32Range - } - if len(reuseSettings.HMaxRequestTimes) > 0 { - int32Range, err := parseInt32RangeString(reuseSettings.HMaxRequestTimes) - if err != nil { - return nil, err - } - xmuxSettings.HMaxRequestTimes = *int32Range - } - if len(reuseSettings.HMaxReusableSecs) > 0 { - int32Range, err := parseInt32RangeString(reuseSettings.HMaxReusableSecs) - if err != nil { - return nil, err - } - xmuxSettings.HMaxReusableSecs = *int32Range - } - return xmuxSettings, nil -} - -func parseXHTTPDownloadSettings(downloadSettings *ClashProxyXhttpOptsDownloadSettings) (*conf.StreamConfig, error) { - streamSettings := &conf.StreamConfig{} - - streamSettings.Address = parseAddress(downloadSettings.Server) - streamSettings.Port = downloadSettings.Port - - streamSettings.Network = new(conf.TransportProtocol("xhttp")) - - xhttpSettings := &conf.SplitHTTPConfig{} - xhttpSettings.Path = downloadSettings.Path - xhttpSettings.Host = downloadSettings.Host - xhttpSettings.Mode = downloadSettings.Mode - - if len(downloadSettings.Headers) > 0 { - xhttpSettings.Headers = downloadSettings.Headers - } - xhttpSettings.NoGRPCHeader = downloadSettings.NoGrpcHeader - if len(downloadSettings.XPaddingBytes) > 0 { - int32Range, err := parseInt32RangeString(downloadSettings.XPaddingBytes) - if err != nil { - return nil, err - } - xhttpSettings.XPaddingBytes = *int32Range - } - if len(downloadSettings.ScMaxEachPostBytes) > 0 { - int32Range, err := parseInt32RangeString(downloadSettings.ScMaxEachPostBytes) - if err != nil { - return nil, err - } - xhttpSettings.ScMaxEachPostBytes = *int32Range - } - if downloadSettings.ReuseSettings != nil { - xmuxSettings, err := parseXHTTPXMUX(downloadSettings.ReuseSettings) - if err != nil { - return nil, err - } - xhttpSettings.Xmux = *xmuxSettings - } - streamSettings.XHTTPSettings = xhttpSettings - - parseXHTTPDownloadSettingsSecurity(streamSettings, downloadSettings) - - return streamSettings, nil -} - -func parseXHTTPDownloadSettingsSecurity(streamSettings *conf.StreamConfig, proxy *ClashProxyXhttpOptsDownloadSettings) { - tlsSettings := &conf.TLSConfig{} - realitySettings := &conf.REALITYConfig{} - - if proxy.Tls { - streamSettings.Security = "tls" - } - - if proxy.EchOpts != nil { - if proxy.EchOpts.Enable { - tlsSettings.ECHConfigList = proxy.EchOpts.Config - } - } - - if proxy.RealityOpts != nil { - streamSettings.Security = "reality" - realitySettings.PublicKey = proxy.RealityOpts.PublicKey - realitySettings.ShortId = proxy.RealityOpts.ShortId - } - if len(proxy.Servername) > 0 { - tlsSettings.ServerName = proxy.Servername - realitySettings.ServerName = proxy.Servername - } - if len(proxy.Alpn) > 0 { - tlsSettings.ALPN = new(conf.StringList(proxy.Alpn)) - } - if len(proxy.Fingerprint) > 0 { - tlsSettings.Fingerprint = proxy.Fingerprint - realitySettings.Fingerprint = proxy.Fingerprint - } - if len(proxy.ClientFingerprint) > 0 { - tlsSettings.Fingerprint = proxy.ClientFingerprint - realitySettings.Fingerprint = proxy.ClientFingerprint - } - - tlsSettings.AllowInsecure = proxy.SkipCertVerify - - switch streamSettings.Security { - case "tls": - streamSettings.TLSSettings = tlsSettings - case "reality": - streamSettings.REALITYSettings = realitySettings - } -} diff --git a/share/clash_meta_test.go b/share/clash_meta_test.go deleted file mode 100644 index 4733eccc..00000000 --- a/share/clash_meta_test.go +++ /dev/null @@ -1,375 +0,0 @@ -package share - -import ( - "encoding/json" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/xtls/xray-core/infra/conf" -) - -func clashHysteria2YAML(fields string) string { - return fmt.Sprintf(`proxies: - - name: test-hy2 - type: hysteria2 - server: host - port: 443 - password: authpass -%s`, fields) -} - -func parseClashHy2(t *testing.T, yaml string) *conf.OutboundDetourConfig { - t.Helper() - config, err := tryToParseClashYaml(yaml) - require.NoError(t, err) - require.Len(t, config.OutboundConfigs, 1) - return &config.OutboundConfigs[0] -} - -func parseClashYAML(t *testing.T, yaml string) *conf.Config { - t.Helper() - cfg, err := tryToParseClashYaml(yaml) - require.NoError(t, err) - return cfg -} - -func TestClashHysteria2_WithBandwidthPortHoppingSalamander(t *testing.T) { - yaml := clashHysteria2YAML(` up: 100 mbps - down: 200 mbps - ports: "20000-40000" - hop-interval: 30 - obfs: salamander - obfs-password: secret - sni: example.com`) - - outbound := parseClashHy2(t, yaml) - assert.Equal(t, "hysteria", outbound.Protocol) - - var settings conf.HysteriaClientConfig - require.NoError(t, json.Unmarshal(*outbound.Settings, &settings)) - assert.Equal(t, int32(2), settings.Version) - - ss := outbound.StreamSetting - require.NotNil(t, ss) - - // HysteriaSettings has version + auth only - require.NotNil(t, ss.HysteriaSettings) - assert.Equal(t, int32(2), ss.HysteriaSettings.Version) - assert.Equal(t, "authpass", ss.HysteriaSettings.Auth) - - // QuicParams - require.NotNil(t, ss.FinalMask) - require.NotNil(t, ss.FinalMask.QuicParams) - qp := ss.FinalMask.QuicParams - assert.Equal(t, "brutal", qp.Congestion) - assert.Equal(t, conf.Bandwidth("100 mbps"), qp.BrutalUp) - assert.Equal(t, conf.Bandwidth("200 mbps"), qp.BrutalDown) - - // UdpHop - assert.Equal(t, "20000-40000", qp.UdpHop.PortList.String()) - assert.Equal(t, int32(30), qp.UdpHop.Interval.From) - assert.Equal(t, int32(30), qp.UdpHop.Interval.To) - - // Salamander - require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "salamander", ss.FinalMask.Udp[0].Type) - var salamander conf.Salamander - require.NoError(t, json.Unmarshal(*ss.FinalMask.Udp[0].Settings, &salamander)) - assert.Equal(t, "secret", salamander.Password) - - // TLS default - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "example.com", ss.TLSSettings.ServerName) -} - -func TestClashHysteria2_BandwidthOnly(t *testing.T) { - yaml := clashHysteria2YAML(` up: 50 mbps - down: 100 mbps - sni: example.com`) - - outbound := parseClashHy2(t, yaml) - ss := outbound.StreamSetting - require.NotNil(t, ss) - - // HysteriaSettings - require.NotNil(t, ss.HysteriaSettings) - assert.Equal(t, "authpass", ss.HysteriaSettings.Auth) - - // QuicParams with bandwidth, no port-hopping - require.NotNil(t, ss.FinalMask) - require.NotNil(t, ss.FinalMask.QuicParams) - qp := ss.FinalMask.QuicParams - assert.Equal(t, "brutal", qp.Congestion) - assert.Equal(t, conf.Bandwidth("50 mbps"), qp.BrutalUp) - assert.Equal(t, conf.Bandwidth("100 mbps"), qp.BrutalDown) - - // No Salamander - assert.Empty(t, ss.FinalMask.Udp) - - // No UdpHop - assert.Empty(t, qp.UdpHop.PortList.Range) -} - -func TestClashHysteria2_SalamanderOnly(t *testing.T) { - yaml := clashHysteria2YAML(` obfs: salamander - obfs-password: mysecret - sni: example.com`) - - outbound := parseClashHy2(t, yaml) - ss := outbound.StreamSetting - require.NotNil(t, ss) - - require.NotNil(t, ss.FinalMask) - - // No QuicParams - assert.Nil(t, ss.FinalMask.QuicParams) - - // Salamander - require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "salamander", ss.FinalMask.Udp[0].Type) - var salamander conf.Salamander - require.NoError(t, json.Unmarshal(*ss.FinalMask.Udp[0].Settings, &salamander)) - assert.Equal(t, "mysecret", salamander.Password) -} - -func TestClashHysteria2_Minimal(t *testing.T) { - yaml := clashHysteria2YAML(` sni: example.com`) - - outbound := parseClashHy2(t, yaml) - assert.Equal(t, "hysteria", outbound.Protocol) - - ss := outbound.StreamSetting - require.NotNil(t, ss) - - // HysteriaSettings - require.NotNil(t, ss.HysteriaSettings) - assert.Equal(t, int32(2), ss.HysteriaSettings.Version) - assert.Equal(t, "authpass", ss.HysteriaSettings.Auth) - - // No FinalMask - assert.Nil(t, ss.FinalMask) - - // TLS default for hysteria - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "example.com", ss.TLSSettings.ServerName) -} - -func TestClashShadowsocks_Plain(t *testing.T) { - yaml := `proxies: - - name: ss-plain - type: ss - server: ss.host - port: 8388 - cipher: aes-128-gcm - password: secret - udp: true` - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - ob := cfg.OutboundConfigs[0] - assert.Equal(t, "shadowsocks", ob.Protocol) - var s conf.ShadowsocksClientConfig - require.NoError(t, json.Unmarshal(*ob.Settings, &s)) - assert.Equal(t, "aes-128-gcm", s.Cipher) - assert.Equal(t, "secret", s.Password) - assert.Nil(t, ob.StreamSetting) -} - -func TestClashShadowsocks_V2rayPluginWebsocketTLS(t *testing.T) { - yaml := `proxies: - - name: ss-ws - type: ss - server: ss.ws - port: 443 - cipher: aes-256-gcm - password: p - plugin: v2ray-plugin - plugin-opts: - mode: websocket - host: cloud.cdn - path: /ws - tls: true - fingerprint: chrome` - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - ss := cfg.OutboundConfigs[0].StreamSetting - require.NotNil(t, ss) - require.NotNil(t, ss.WSSettings) - assert.Equal(t, "/ws", ss.WSSettings.Path) - assert.Equal(t, "cloud.cdn", ss.WSSettings.Host) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "chrome", ss.TLSSettings.Fingerprint) -} - -func TestClashVmess_WsTLS(t *testing.T) { - yaml := fmt.Sprintf(`proxies: - - name: vm-ws - type: vmess - server: vm.host - port: 443 - uuid: %s - cipher: auto - network: ws - tls: true - servername: vm.host - alpn: - - h2 - fingerprint: firefox - ws-opts: - path: /vmws - headers: - Host: front.cdn`, testShareUUID) - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - ss := cfg.OutboundConfigs[0].StreamSetting - require.NotNil(t, ss.WSSettings) - assert.Equal(t, "/vmws", ss.WSSettings.Path) - assert.Equal(t, "front.cdn", ss.WSSettings.Host) - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "vm.host", ss.TLSSettings.ServerName) - assert.Equal(t, "firefox", ss.TLSSettings.Fingerprint) -} - -func TestClashVless_Grpc(t *testing.T) { - yaml := fmt.Sprintf(`proxies: - - name: vl-grpc - type: vless - server: g.host - port: 443 - uuid: %s - encryption: none - network: grpc - tls: true - sni: g.host - grpc-opts: - grpc-service-name: MySvc`, testShareUUID) - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - gs := cfg.OutboundConfigs[0].StreamSetting.GRPCSettings - require.NotNil(t, gs) - assert.Equal(t, "MySvc", gs.ServiceName) -} - -func TestClashVless_XhttpExtraDownloadAndRanges(t *testing.T) { - yaml := fmt.Sprintf(`proxies: - - name: vl-xh - type: vless - server: xh.host - port: 443 - uuid: %s - encryption: none - network: xhttp - tls: true - sni: xh.host - client-fingerprint: edge - ech-opts: - enable: true - config: echcfg123 - skip-cert-verify: true - xhttp-opts: - path: /xh - host: xh.h - mode: stream-one - no-grpc-header: true - x-padding-bytes: 100-500 - sc-max-each-post-bytes: 1000-2000 - headers: - X-Custom: z - reuse-settings: - max-connections: 1-8 - max-concurrency: 2-4 - c-max-reuse-times: 0-0 - h-max-request-times: 1-1 - h-max-reusable-secs: 30-60 - download-settings: - server: dl.host - port: 8443 - tls: true - servername: dl.host - alpn: - - h3 - path: /dl - host: dl.h - fingerprint: ios - reality-opts: - public-key: AbCdEfGhIjKlMnOpQrStUvWxYz0123456789012 - short-id: "0123456789abcdef"`, testShareUUID) - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - ob := cfg.OutboundConfigs[0] - ss := ob.StreamSetting - require.NotNil(t, ss.XHTTPSettings) - x := ss.XHTTPSettings - assert.Equal(t, "/xh", x.Path) - assert.Equal(t, "stream-one", x.Mode) - require.NotNil(t, x.Extra) - var extra conf.SplitHTTPConfig - require.NoError(t, json.Unmarshal(x.Extra, &extra)) - assert.True(t, extra.NoGRPCHeader) - assert.Equal(t, "z", extra.Headers["X-Custom"]) - require.NotNil(t, extra.DownloadSettings) - dl := extra.DownloadSettings - require.NotNil(t, dl.XHTTPSettings) - assert.Equal(t, "/dl", dl.XHTTPSettings.Path) - assert.Equal(t, "reality", dl.Security) - require.NotNil(t, dl.REALITYSettings) -} - -func TestClashSocks5_WithAuth(t *testing.T) { - yaml := `proxies: - - name: sk - type: socks5 - server: 127.0.0.1 - port: 1080 - username: su - password: sp` - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - var s conf.SocksClientConfig - require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) - assert.Equal(t, "su", s.Username) - assert.Equal(t, "sp", s.Password) -} - -func TestClashTrojan_TlsFromType(t *testing.T) { - yaml := `proxies: - - name: tr - type: trojan - server: tr.host - port: 443 - password: trpass - sni: tr.host - skip-cert-verify: true` - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - ss := cfg.OutboundConfigs[0].StreamSetting - require.NotNil(t, ss) - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.True(t, ss.TLSSettings.AllowInsecure) -} - -func TestClashVless_Reality(t *testing.T) { - yaml := fmt.Sprintf(`proxies: - - name: vl-reality - type: vless - server: r.host - port: 443 - uuid: %s - encryption: none - network: tcp - reality-opts: - public-key: XYAbCdEfGhIjKlMnOpQrStUvWxYz0123456789AB - short-id: abcdef01 - client-fingerprint: qq - servername: r.host`, testShareUUID) - cfg := parseClashYAML(t, yaml) - ss := cfg.OutboundConfigs[0].StreamSetting - assert.Equal(t, "reality", ss.Security) - require.NotNil(t, ss.REALITYSettings) - assert.Equal(t, "XYAbCdEfGhIjKlMnOpQrStUvWxYz0123456789AB", ss.REALITYSettings.PublicKey) -} diff --git a/share/convert_share.go b/share/convert_share.go new file mode 100644 index 00000000..b14546d0 --- /dev/null +++ b/share/convert_share.go @@ -0,0 +1,79 @@ +package share + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/xtls/xray-core/infra/conf" +) + +// ConvertShareLinksToXrayJson parses share links or an Age-encrypted subscription. +// The returned JSON contains only projected, buildable outbounds. +func ConvertShareLinksToXrayJson(links, secretKey string) (json.RawMessage, error) { + text, encrypted, err := decryptShareText(links, secretKey) + if err != nil { + return nil, err + } + config, err := parseShareCandidates(text, true) + if err != nil { + if encrypted { + return nil, ErrAgePlaintextUnsupported + } + return nil, err + } + config, err = filterBuildableOutbounds(config) + if err == nil { + var raw json.RawMessage + raw, err = marshalShareConfigJSON(config) + if err == nil { + return raw, nil + } + } + if encrypted { + return nil, ErrAgePlaintextUnsupported + } + // Builder errors can contain credentials or whole source values. + return nil, errors.New("no valid outbound found") +} + +func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, 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, 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, nil + } + if hasShareSchemeLine(text) { + for raw := range strings.SplitSeq(text, "\n") { + line := strings.TrimSpace(raw) + // Ignore subscription comments and text headers. + scheme, _, found := strings.Cut(line, "://") + if !found || strings.ContainsAny(scheme, " \t#") { + continue + } + outbound, err := parseOutboundShareLink(line) + if err == nil { + config.OutboundConfigs = append(config.OutboundConfigs, *outbound) + } + } + return config, nil + } + if allowBase64 { + if decoded, err := decodeBase64Text(text); err == nil { + return parseShareCandidates(decoded, false) + } + } + return nil, 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..7ebdc699 --- /dev/null +++ b/share/convert_share_test.go @@ -0,0 +1,151 @@ +package share + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +const validShareOutbound = `{"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"}}}` + +const legacyVMessQRCodeJSON = `{"v":"2","ps":"Legacy","add":"vm.example","port":"443","id":"` + testShareUUID + `","aid":"0","scy":"auto","net":"ws","path":"/ws","tls":"tls"}` + +func TestConvertShareLinksSkipsInvalidCandidates(t *testing.T) { + jsonText := `{"outbounds":[` + validShareOutbound + `,{"protocol":"freedom"},{"protocol":42},null,{"protocol":"vless","settings":{"id":"invalid"}}]}` + for _, test := range []struct { + name, text string + }{ + {"links with headers", "Subscription export\n# comment\n\n" + ageTestShareLink + "\nvless://bad@example.com:443?encryption=none\nunknown://example.com"}, + {"links with unsupported Hysteria TLS", "hy2://auth@hy.example:443?insecure=1\n" + ageTestShareLink + "\nhysteria2://auth@hy.example:443?pinSHA256=bad"}, + {"links with VMessQrCode", "vmess://" + base64.StdEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON)) + "\n" + ageTestShareLink}, + {"JSON elements", jsonText}, + {"base64 JSON", base64.StdEncoding.EncodeToString([]byte(jsonText))}, + {"base64 links", base64.StdEncoding.EncodeToString([]byte(ageTestShareLink + "\nvless://bad@example.com:443"))}, + } { + t.Run(test.name, func(t *testing.T) { + result, err := ConvertShareLinksToXrayJson(test.text, "") + if err != nil { + t.Fatal(err) + } + assertShareOutbounds(t, result, 1) + }) + } +} + +func TestConvertShareLinksRejectsRemovedFormats(t *testing.T) { + for _, input := range []struct { + name, text string + }{ + {"Clash YAML", "proxies:\n - {name: Node, type: vless, server: example.com, port: 443, uuid: " + testShareUUID + "}"}, + {"Mihomo JSON", `{"proxies":[{"name":"Node","type":"vless","server":"example.com","port":443,"uuid":"` + testShareUUID + `"}]}`}, + {"Hysteria realm URI", "hysteria2+realm://token@realm.example/name?auth=password"}, + {"VMessQrCode", "vmess://" + base64.StdEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON))}, + {"VMessQrCode URL-safe", "vmess://" + base64.RawURLEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON))}, + } { + t.Run(input.name, func(t *testing.T) { + for _, encoding := range []struct { + name, text string + }{ + {"plaintext", input.text}, + {"base64", base64.StdEncoding.EncodeToString([]byte(input.text))}, + {"base64 URL", base64.RawURLEncoding.EncodeToString([]byte(input.text))}, + } { + t.Run(encoding.name, func(t *testing.T) { + result, err := ConvertShareLinksToXrayJson(encoding.text, "") + if err == nil || result != nil { + t.Fatalf("result = %s, error = %v", result, err) + } + }) + } + }) + } +} + +func TestConvertShareLinksAllInvalidReturnsNoData(t *testing.T) { + for _, input := range []string{ + `{"outbounds":[{"protocol":"freedom"}]}`, + "vless://secret-not-a-uuid@example.com:443?encryption=none", + } { + result, err := ConvertShareLinksToXrayJson(input, "") + if err == nil || err.Error() != "no valid outbound found" { + t.Fatalf("error = %v", err) + } + if result != nil { + t.Fatalf("unexpected data: %s", result) + } + } + result, err := ConvertShareLinksToXrayJson(`{"outbounds":[]}`, "") + if err == nil { + t.Fatal("empty array succeeded") + } + if result != nil { + t.Fatalf("unexpected data: %s", result) + } +} + +func TestConvertShareLinksMalformedDocumentReturnsNoData(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 TestConvertShareLinksAgeSkipsInvalidCandidatesAndRedactsErrors(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":[` + validShareOutbound + `,{"protocol":false}]}`, + "hy2://age-password@hy.example:443#Age%20Hysteria", + } { + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) + if err != nil { + t.Fatal(err) + } + assertShareOutbounds(t, result, 1) + } + for _, input := range []string{ + "vless://secret-not-a-uuid@example.com:443", + `{"outbounds":"private-source"}`, + "proxies:\n - {type: vless, server: example.com, port: 443, uuid: " + testShareUUID + "}", + "hysteria2://private-password@hy.example:443?insecure=1", + "hy2://private-password@hy.example:443?obfs=unsupported", + "vmess://" + base64.StdEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON)), + } { + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) + if err != ErrAgePlaintextUnsupported { + t.Fatalf("error = %v", err) + } + if result != nil { + t.Fatalf("unexpected data: %s", result) + } + } + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, ageTestShareLink), "private-invalid-key") + if err != ErrAgeSecretKeyInvalid || result != nil { + t.Fatalf("result = %+v, error = %v", result, err) + } +} + +func assertShareOutbounds(t *testing.T, result json.RawMessage, count int) { + t.Helper() + var config struct { + Outbounds []json.RawMessage `json:"outbounds"` + } + if err := json.Unmarshal(result, &config); err != nil { + t.Fatal(err) + } + if len(config.Outbounds) != count { + t.Fatalf("outbounds = %d, want %d", len(config.Outbounds), count) + } +} diff --git a/share/decode_subscription.go b/share/decode_subscription.go new file mode 100644 index 00000000..cc1f941c --- /dev/null +++ b/share/decode_subscription.go @@ -0,0 +1,14 @@ +package share + +// DecodeSubscriptionText decrypts Age armor or returns the original plaintext. +// It does not parse or validate the subscription's format or Xray configuration. +func DecodeSubscriptionText(text, secretKey string) (string, error) { + plaintext, _, err := decryptShareText(text, secretKey) + if err != nil { + return "", err + } + if len(plaintext) > maxAgePlaintextBytes { + return "", ErrAgePlaintextTooLarge + } + return plaintext, nil +} diff --git a/share/decode_subscription_test.go b/share/decode_subscription_test.go new file mode 100644 index 00000000..08c54495 --- /dev/null +++ b/share/decode_subscription_test.go @@ -0,0 +1,115 @@ +package share + +import ( + "errors" + "strings" + "testing" +) + +const fullSubscriptionText = `[ + { + "remarks": "ordinary", + "inbounds": [{"tag":"socks","protocol":"socks","port":10808}], + "outbounds": [{"tag":"proxy","protocol":"vless","settings":{"vnext":[{"address":"example.com","port":443,"users":[{"id":"provider-value"}]}]},"mux":{"enabled":true}}], + "dns": {"servers":["https://dns.example/dns-query"]}, + "routing": {"rules":[{"inboundTag":["socks"],"outboundTag":"proxy"}]} + }, + { + "remarks": "virtual pool", + "outbounds": [{"tag":"pool-1","protocol":"vless","streamSettings":{"sockopt":{"dialerProxy":"entry"}}},{"tag":"entry","protocol":"trojan"}], + "routing": {"balancers":[{"tag":"pool","selector":["pool-"]}],"rules":[{"balancerTag":"pool"}]}, + "observatory": {"subjectSelector":["pool-"]}, + "futureOptions": {"preserve":true} + } +] +` + +func TestDecodeSubscriptionTextPreservesPlaintextWithoutParsing(t *testing.T) { + for _, text := range []string{"", fullSubscriptionText, " \r\nnot a config\r\n", "dmxlc3M6Ly9leGFtcGxl"} { + decoded, err := DecodeSubscriptionText(text, "unused-invalid-key") + if err != nil || decoded != text { + t.Fatalf("plaintext was changed: input length %d, output length %d, error %v", len(text), len(decoded), err) + } + } +} + +func TestDecodeSubscriptionTextPreservesEncryptedFullConfig(t *testing.T) { + for _, keyType := range []AgeKeyType{AgeKeyTypeX25519, AgeKeyTypeHybrid} { + t.Run(string(keyType), func(t *testing.T) { + pair, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest(t, pair, fullSubscriptionText) + decoded, err := DecodeSubscriptionText(" \r\n"+armored+" \r\n", pair.SecretKey) + if err != nil || decoded != fullSubscriptionText { + t.Fatalf("complete config was changed: output length %d, error %v", len(decoded), err) + } + }) + } +} + +func TestDecodeSubscriptionTextRejectsAgeFailuresWithoutReturningText(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + wrong, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest(t, pair, fullSubscriptionText) + for _, test := range []struct { + name, text, key string + want error + }{ + {"missing", armored, "", ErrAgeSecretKeyMissing}, + {"invalid", armored, "invalid-key", ErrAgeSecretKeyInvalid}, + {"wrong", armored, wrong.SecretKey, ErrAgeDecryptFailed}, + {"armor", ageArmorHeader + "\ninvalid", pair.SecretKey, ErrAgeArmorMalformed}, + } { + t.Run(test.name, func(t *testing.T) { + decoded, err := DecodeSubscriptionText(test.text, test.key) + if decoded != "" || !errors.Is(err, test.want) { + t.Fatalf("output length %d, error %v, want %v", len(decoded), err, test.want) + } + if test.key != "" && strings.Contains(err.Error(), test.key) { + t.Fatal("error exposed the secret key") + } + }) + } +} + +func TestDecodeSubscriptionTextLimitsUTF8PlaintextBytes(t *testing.T) { + // The last ASCII byte fills the remainder after the three-byte characters. + text := strings.Repeat("界", maxAgePlaintextBytes/3) + "a" + if len(text) != maxAgePlaintextBytes { + t.Fatal("fixture must fill the exact byte limit") + } + decoded, err := DecodeSubscriptionText(text, "") + if err != nil || decoded != text { + t.Fatalf("exact byte limit was rejected: %v", err) + } + decoded, err = DecodeSubscriptionText(text+"b", "") + if decoded != "" || !errors.Is(err, ErrAgePlaintextTooLarge) { + t.Fatalf("oversize plaintext output length %d, error %v", len(decoded), err) + } +} + +func TestDecodeSubscriptionTextLimitsDecryptedBytes(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + for _, size := range []int{maxAgePlaintextBytes, maxAgePlaintextBytes + 1} { + text := strings.Repeat("x", size) + decoded, err := DecodeSubscriptionText(encryptAgeForTest(t, pair, text), pair.SecretKey) + if size == maxAgePlaintextBytes { + if err != nil || decoded != text { + t.Fatalf("exact decrypted byte limit was rejected: %v", err) + } + } else if decoded != "" || !errors.Is(err, ErrAgePlaintextTooLarge) { + t.Fatalf("oversize decrypted output length %d, error %v", len(decoded), err) + } + } +} diff --git a/share/generate_share.go b/share/generate_share.go index d695ab94..c4ba0537 100644 --- a/share/generate_share.go +++ b/share/generate_share.go @@ -1,6 +1,7 @@ package share import ( + "bytes" "encoding/base64" "encoding/json" "fmt" @@ -14,10 +15,8 @@ import ( // Convert XrayJson to share links. // VMess will generate VMessAEAD link. func ConvertXrayJsonToShareLinks(xrayBytes []byte) (string, error) { - var xray *conf.Config - - err := json.Unmarshal(xrayBytes, &xray) - if err != nil { + var xray conf.Config + if err := json.Unmarshal(xrayBytes, &xray); err != nil { return "", err } @@ -29,8 +28,12 @@ func ConvertXrayJsonToShareLinks(xrayBytes []byte) (string, error) { links := make([]string, 0, len(outbounds)) for _, outbound := range outbounds { link, err := shareLink(outbound) - if err == nil { - links = append(links, link.String()) + if err != nil || link == nil { + continue + } + text := link.String() + if text != "" { + links = append(links, text) } } if len(links) == 0 { @@ -41,6 +44,9 @@ func ConvertXrayJsonToShareLinks(xrayBytes []byte) (string, error) { } func shareLink(proxy conf.OutboundDetourConfig) (*url.URL, error) { + if proxy.Protocol == "hysteria" { + return hysteria2ShareLink(proxy) + } shareUrl := &url.URL{} switch proxy.Protocol { @@ -69,20 +75,38 @@ func shareLink(proxy conf.OutboundDetourConfig) (*url.URL, error) { if err != nil { return nil, err } - case "hysteria": - err := hysteriaLink(proxy, shareUrl) - if err != nil { - return nil, err - } + default: + return nil, fmt.Errorf("unsupported outbound protocol %q", proxy.Protocol) } streamSettingsQuery(proxy, shareUrl) return shareUrl, nil } +func decodeOutboundSettings[T any]( + proxy conf.OutboundDetourConfig, +) (*T, error) { + if proxy.Settings == nil { + return nil, fmt.Errorf("missing %s outbound settings", proxy.Protocol) + } + raw := bytes.TrimSpace(*proxy.Settings) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, fmt.Errorf("missing %s outbound settings", proxy.Protocol) + } + + var settings T + if err := json.Unmarshal(raw, &settings); err != nil { + return nil, fmt.Errorf( + "invalid %s outbound settings: %w", + proxy.Protocol, + err, + ) + } + return &settings, nil +} + func shadowsocksLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.ShadowsocksClientConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.ShadowsocksClientConfig](proxy) if err != nil { return err } @@ -91,16 +115,37 @@ func shadowsocksLink(proxy conf.OutboundDetourConfig, link *url.URL) error { link.Scheme = "ss" link.Host = fmt.Sprintf("%s:%d", settings.Address, settings.Port) - password := fmt.Sprintf("%s:%s", settings.Cipher, settings.Password) - username := base64.StdEncoding.EncodeToString([]byte(password)) - link.User = url.User(username) + if isShadowsocksAEAD2022(settings.Cipher) { + userInfo := escapeShadowsocksUserInfo(settings.Cipher) + ":" + + escapeShadowsocksUserInfo(settings.Password) + link.Opaque = "//" + userInfo + "@" + link.Host + link.Host = "" + } else { + password := fmt.Sprintf("%s:%s", settings.Cipher, settings.Password) + username := base64.StdEncoding.EncodeToString([]byte(password)) + link.User = url.User(username) + } return nil } +func isShadowsocksAEAD2022(cipher string) bool { + switch strings.ToLower(cipher) { + case "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305": + return true + default: + return false + } +} + +func escapeShadowsocksUserInfo(value string) string { + return strings.ReplaceAll(url.QueryEscape(value), "+", "%20") +} + func vmessLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.VMessOutboundConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.VMessOutboundConfig](proxy) if err != nil { return err } @@ -118,8 +163,7 @@ func vmessLink(proxy conf.OutboundDetourConfig, link *url.URL) error { } func vlessLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.VLessOutboundConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.VLessOutboundConfig](proxy) if err != nil { return err } @@ -140,8 +184,7 @@ func vlessLink(proxy conf.OutboundDetourConfig, link *url.URL) error { } func socksLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.SocksClientConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.SocksClientConfig](proxy) if err != nil { return err } @@ -158,8 +201,7 @@ func socksLink(proxy conf.OutboundDetourConfig, link *url.URL) error { } func trojanLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.TrojanClientConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.TrojanClientConfig](proxy) if err != nil { return err } @@ -173,25 +215,6 @@ func trojanLink(proxy conf.OutboundDetourConfig, link *url.URL) error { return nil } -func hysteriaLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.HysteriaClientConfig - err := json.Unmarshal(*proxy.Settings, &settings) - if err != nil { - return err - } - - link.Fragment = getOutboundName(proxy) - link.Scheme = "hysteria2" - - link.Host = fmt.Sprintf("%s:%d", settings.Address, settings.Port) - - if proxy.StreamSetting != nil && proxy.StreamSetting.HysteriaSettings != nil { - link.User = url.User(proxy.StreamSetting.HysteriaSettings.Auth) - } - - return nil -} - func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { streamSettings := proxy.StreamSetting if streamSettings == nil { @@ -203,90 +226,40 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { if streamSettings.Network != nil { network = string(*streamSettings.Network) } - - if network == "hysteria" { - // TLS params - if streamSettings.TLSSettings != nil { - sni := streamSettings.TLSSettings.ServerName - if len(sni) > 0 { - query = addQuery(query, "sni", sni) - } - fp := streamSettings.TLSSettings.Fingerprint - if len(fp) > 0 { - query = addQuery(query, "fp", fp) - } - alpn := streamSettings.TLSSettings.ALPN - if alpn != nil && len(*alpn) > 0 { - query = addQuery(query, "alpn", strings.Join(*alpn, ",")) - } - ech := streamSettings.TLSSettings.ECHConfigList - if len(ech) > 0 { - query = addQuery(query, "ech", ech) - } - pcs := streamSettings.TLSSettings.PinnedPeerCertSha256 - if len(pcs) > 0 { - query = addQuery(query, "pcs", pcs) - } - vcn := streamSettings.TLSSettings.VerifyPeerCertByName - if len(vcn) > 0 { - query = addQuery(query, "vcn", vcn) - } - if streamSettings.TLSSettings.AllowInsecure { - query = addQuery(query, "insecure", "1") - } - } - - // QuicParams (bandwidth + port-hopping) - if streamSettings.FinalMask != nil && streamSettings.FinalMask.QuicParams != nil { - qp := streamSettings.FinalMask.QuicParams - if len(qp.BrutalUp) > 0 { - query = addQuery(query, "up", string(qp.BrutalUp)) - } - if len(qp.BrutalDown) > 0 { - query = addQuery(query, "down", string(qp.BrutalDown)) - } - if len(qp.UdpHop.PortList.Range) > 0 { - query = addQuery(query, "ports", qp.UdpHop.PortList.String()) - } - if qp.UdpHop.Interval.From != 0 || qp.UdpHop.Interval.To != 0 { - query = addQuery(query, "hop-interval", strconv.FormatInt(int64(qp.UdpHop.Interval.From), 10)) - } - } - - // Salamander - if streamSettings.FinalMask != nil && len(streamSettings.FinalMask.Udp) > 0 { - mask := streamSettings.FinalMask.Udp[0] - if mask.Settings != nil { - var obfs *conf.Salamander - err := json.Unmarshal(*mask.Settings, &obfs) - if err == nil { - query = addQuery(query, "obfs", "salamander") - query = addQuery(query, "obfs-password", obfs.Password) - } - } - } - link.RawQuery = query - return + if streamSettings.Method != nil { + network = string(*streamSettings.Method) + } + if canonical, ok := canonicalShareNetwork(network); ok { + network = canonical } - query = addQuery(query, "type", network) + shareNetwork := network + if shareNetwork == "raw" { + shareNetwork = "tcp" + } + query = addQuery(query, "type", shareNetwork) - if len(streamSettings.Security) == 0 { - streamSettings.Security = "none" + security := streamSettings.Security + if security == "" { + security = "none" } - query = addQuery(query, "security", streamSettings.Security) + query = addQuery(query, "security", security) switch network { case "raw": - if streamSettings.RAWSettings == nil { + rawSettings := streamSettings.RAWSettings + if rawSettings == nil { + rawSettings = streamSettings.TCPSettings + } + if rawSettings == nil { break } - headerConfig := streamSettings.RAWSettings.HeaderConfig + headerConfig := rawSettings.HeaderConfig if headerConfig == nil { break } - var header *XrayRawSettingsHeader + var header XrayRawSettingsHeader err := json.Unmarshal(headerConfig, &header) if err != nil { break @@ -311,27 +284,13 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { } } case "kcp": - if streamSettings.KCPSettings == nil { - break - } - seed := streamSettings.KCPSettings.Seed - if seed != nil && len(*seed) > 0 { - query = addQuery(query, "seed", *seed) - } - - headerConfig := streamSettings.KCPSettings.HeaderConfig - if headerConfig == nil { - break - } - var header *XrayFakeHeader - err := json.Unmarshal(headerConfig, &header) - if err != nil { - break - } - - headerType := header.Type - if len(headerType) > 0 { - query = addQuery(query, "headerType", headerType) + if settings := streamSettings.KCPSettings; settings != nil { + if settings.Mtu != nil { + query = addQuery(query, "mtu", strconv.FormatUint(uint64(*settings.Mtu), 10)) + } + if settings.Tti != nil { + query = addQuery(query, "tti", strconv.FormatUint(uint64(*settings.Tti), 10)) + } } case "ws": if streamSettings.WSSettings == nil { @@ -376,31 +335,28 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { query = addQuery(query, "path", path) } case "xhttp": - if streamSettings.XHTTPSettings == nil { + xhttpSettings := streamSettings.XHTTPSettings + if xhttpSettings == nil { + xhttpSettings = streamSettings.SplitHTTPSettings + } + if xhttpSettings == nil { break } - host := streamSettings.XHTTPSettings.Host + host := xhttpSettings.Host if len(host) > 0 { query = addQuery(query, "host", host) } - path := streamSettings.XHTTPSettings.Path + path := xhttpSettings.Path if len(path) > 0 { query = addQuery(query, "path", path) } - mode := streamSettings.XHTTPSettings.Mode + mode := xhttpSettings.Mode if len(mode) > 0 { query = addQuery(query, "mode", mode) } - extra := streamSettings.XHTTPSettings.Extra + extra := xhttpSettings.Extra if extra != nil { - var extraConfig *conf.SplitHTTPConfig - err := json.Unmarshal(extra, &extraConfig) - if err == nil { - extraBytes, err := json.Marshal(extraConfig) - if err == nil { - query = addQuery(query, "extra", string(extraBytes)) - } - } + query = addQuery(query, "extra", string(extra)) } } @@ -433,9 +389,6 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { if len(vcn) > 0 { query = addQuery(query, "vcn", vcn) } - if streamSettings.TLSSettings.AllowInsecure { - query = addQuery(query, "insecure", "1") - } case "reality": if streamSettings.REALITYSettings == nil { break @@ -449,6 +402,9 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { query = addQuery(query, "sni", sni) } pbk := streamSettings.REALITYSettings.Password + if pbk == "" { + pbk = streamSettings.REALITYSettings.PublicKey + } if len(pbk) > 0 { query = addQuery(query, "pbk", pbk) } @@ -480,12 +436,12 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { func addQuery(rawQuery string, key, value string) string { v, err := url.ParseQuery(rawQuery) if err != nil { - newPart := key + "=" + url.QueryEscape(value) + newPart := key + "=" + strings.ReplaceAll(url.QueryEscape(value), "+", "%20") if rawQuery == "" { return newPart } return rawQuery + "&" + newPart } v.Add(key, value) - return v.Encode() + return strings.ReplaceAll(v.Encode(), "+", "%20") } diff --git a/share/generate_share_test.go b/share/generate_share_test.go index 0435d8e2..d0f78adf 100644 --- a/share/generate_share_test.go +++ b/share/generate_share_test.go @@ -3,7 +3,6 @@ package share import ( "encoding/base64" "encoding/json" - "net/url" "testing" "github.com/stretchr/testify/assert" @@ -11,166 +10,102 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -func buildHy2Outbound(t *testing.T, auth string, host string, port uint16, streamSetting *conf.StreamConfig) conf.OutboundDetourConfig { - t.Helper() - settings := &conf.HysteriaClientConfig{ - Address: parseAddress(host), - Port: port, +func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { + cases := []string{ + "vless://" + testShareUUID + "@r1.example:443?encryption=none&security=tls&sni=r1.example&type=ws&path=%2Fr&host=h.r1", + "trojan://trpass@r2.example:443?sni=r2.example", + "ss://" + ssUserB64("aes-128-gcm", "pw") + "@r3.example:8389", + "vmess://" + testShareUUID + "@r4.example:443?encryption=none&type=tcp", + "socks://" + base64.StdEncoding.EncodeToString([]byte("u:p")) + "@127.0.0.1:1090", + "vmess://" + testShareUUID + "@vm.example:443?encryption=auto&type=ws&path=%2Fws&security=tls#VMessAEAD", + "hy2://user:password@hy.example/?sni=hy.example#Hysteria2", } - settingsJSON, err := json.Marshal(settings) - require.NoError(t, err) - return conf.OutboundDetourConfig{ - Protocol: "hysteria", - Settings: new(json.RawMessage(settingsJSON)), - StreamSetting: streamSetting, + for _, link := range cases { + t.Run(link[:12], func(t *testing.T) { + cfg, err := convertShareLinksWithKeyForTest(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) + if cfg.OutboundConfigs[0].Protocol == "vmess" { + assert.Contains(t, text, "vmess://"+testShareUUID+"@") + } + again, err := convertShareLinksWithKeyForTest(text, "") + require.NoError(t, err) + require.Len(t, again.OutboundConfigs, 1) + assert.Equal(t, cfg.OutboundConfigs[0].Protocol, again.OutboundConfigs[0].Protocol) + }) } } -func buildHy2StreamSettings(auth string, tls *conf.TLSConfig, finalMask *conf.FinalMask) *conf.StreamConfig { - ss := &conf.StreamConfig{ - Network: new(conf.TransportProtocol("hysteria")), - Security: "tls", - } - ss.TLSSettings = tls - ss.HysteriaSettings = &conf.HysteriaConfig{ - Version: 2, - Auth: auth, - } - ss.FinalMask = finalMask - return ss -} +func TestGenerate_KCPIgnoresSeedAndHeader(t *testing.T) { + config, err := convertShareLinksForTest( + "vless://" + testShareUUID + "@kcp.example:443?encryption=none&type=kcp", + ) + require.NoError(t, err) -func TestGenerate_Hy2_WithBandwidth(t *testing.T) { - tls := &conf.TLSConfig{ServerName: "example.com"} - fm := &conf.FinalMask{ - QuicParams: &conf.QuicParamsConfig{ - Congestion: "brutal", - BrutalUp: conf.Bandwidth("100 mbps"), - BrutalDown: conf.Bandwidth("200 mbps"), - }, + seed := "legacy-seed" + header := json.RawMessage(`{"type":"srtp"}`) + config.OutboundConfigs[0].StreamSetting.KCPSettings = &conf.KCPConfig{ + Mtu: new(uint32(1350)), + Tti: new(uint32(30)), + Seed: &seed, + HeaderConfig: header, } - ss := buildHy2StreamSettings("auth", tls, fm) - outbound := buildHy2Outbound(t, "auth", "host", 443, ss) + config.OutboundConfigs[0].StreamSetting.Network = new(conf.TransportProtocol("mkcp")) - link, err := shareLink(outbound) + link, err := shareLink(config.OutboundConfigs[0]) require.NoError(t, err) - - assert.Equal(t, "hysteria2", link.Scheme) - q := link.Query() - assert.Equal(t, "example.com", q.Get("sni")) - assert.Equal(t, "100 mbps", q.Get("up")) - assert.Equal(t, "200 mbps", q.Get("down")) + assert.Equal(t, "kcp", link.Query().Get("type")) + assert.Equal(t, "1350", link.Query().Get("mtu")) + assert.Equal(t, "30", link.Query().Get("tti")) + assert.Empty(t, link.Query().Get("seed")) + assert.Empty(t, link.Query().Get("headerType")) } -func TestGenerate_Hy2_WithSalamanderBandwidthPortHopping(t *testing.T) { - tls := &conf.TLSConfig{ServerName: "example.com"} - - var portList conf.PortList - require.NoError(t, json.Unmarshal([]byte(`"20000-40000"`), &portList)) - - fm := &conf.FinalMask{ - QuicParams: &conf.QuicParamsConfig{ - Congestion: "brutal", - BrutalUp: conf.Bandwidth("50 mbps"), - BrutalDown: conf.Bandwidth("100 mbps"), - UdpHop: conf.UdpHop{ - PortList: portList, - Interval: conf.Int32Range{Left: 30, Right: 30, From: 30, To: 30}, - }, - }, - } - - salamander := &conf.Salamander{Password: "secret"} - salJSON, err := json.Marshal(salamander) +func TestGenerate_REALITYPublicKey(t *testing.T) { + const publicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + text, err := ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds":[{ + "protocol":"vless", + "settings":{"address":"example.com","port":443,"id":"` + testShareUUID + `","encryption":"none"}, + "streamSettings":{"security":"reality","realitySettings":{"fingerprint":"chrome","publicKey":"` + publicKey + `"}} + }] + }`)) require.NoError(t, err) - fm.Udp = []conf.Mask{{Type: "salamander", Settings: new(json.RawMessage(salJSON))}} - - ss := buildHy2StreamSettings("auth", tls, fm) - outbound := buildHy2Outbound(t, "auth", "host", 443, ss) - - link, err := shareLink(outbound) + config, err := convertShareLinksWithKeyForTest(text, "") require.NoError(t, err) - - q := link.Query() - assert.Equal(t, "example.com", q.Get("sni")) - assert.Equal(t, "50 mbps", q.Get("up")) - assert.Equal(t, "100 mbps", q.Get("down")) - assert.Equal(t, "20000-40000", q.Get("ports")) - assert.Equal(t, "30", q.Get("hop-interval")) - assert.Equal(t, "salamander", q.Get("obfs")) - assert.Equal(t, "secret", q.Get("obfs-password")) + require.Len(t, config.OutboundConfigs, 1) + assert.Equal(t, publicKey, config.OutboundConfigs[0].StreamSetting.REALITYSettings.Password) } -func TestGenerate_Hy2_WithFullTLSParams(t *testing.T) { - alpn := conf.StringList{"h3"} - tls := &conf.TLSConfig{ - ServerName: "example.com", - Fingerprint: "chrome", - ALPN: &alpn, - } - ss := buildHy2StreamSettings("auth", tls, nil) - outbound := buildHy2Outbound(t, "auth", "host", 443, ss) +func TestGenerate_ShadowsocksAEAD2022PlainUserInfo(t *testing.T) { + const original = "ss://2022-blake3-aes-256-gcm:" + + "YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D" + + "@192.168.100.1:8888#Example3" - link, err := shareLink(outbound) + config, err := convertShareLinksForTest(original) require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) - q := link.Query() - assert.Equal(t, "example.com", q.Get("sni")) - assert.Equal(t, "chrome", q.Get("fp")) - assert.Equal(t, "h3", q.Get("alpn")) + generated, err := shareLink(config.OutboundConfigs[0]) + require.NoError(t, err) + assert.Equal(t, original, generated.String()) } -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" +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) generated, err := shareLink(config.OutboundConfigs[0]) require.NoError(t, err) - - // Parse both URLs to compare query params (order may differ) - origURL, err := url.Parse(original) - require.NoError(t, err) - origQ := origURL.Query() - genQ := generated.Query() - - assert.Equal(t, "hysteria2", generated.Scheme) - assert.Equal(t, origQ.Get("sni"), genQ.Get("sni")) - assert.Equal(t, origQ.Get("fp"), genQ.Get("fp")) - assert.Equal(t, origQ.Get("alpn"), genQ.Get("alpn")) - assert.Equal(t, origQ.Get("up"), genQ.Get("up")) - assert.Equal(t, origQ.Get("down"), genQ.Get("down")) - assert.Equal(t, origQ.Get("ports"), genQ.Get("ports")) - assert.Equal(t, origQ.Get("hop-interval"), genQ.Get("hop-interval")) - assert.Equal(t, origQ.Get("obfs"), genQ.Get("obfs")) - assert.Equal(t, origQ.Get("obfs-password"), genQ.Get("obfs-password")) -} - -func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { - cases := []string{ - "vless://" + testShareUUID + "@r1.example:443?encryption=none&security=tls&sni=r1.example&type=ws&path=%2Fr&host=h.r1", - "trojan://trpass@r2.example:443?sni=r2.example", - "ss://" + ssUserB64("aes-128-gcm", "pw") + "@r3.example:8389", - "vmess://" + testShareUUID + "@r4.example:443?encryption=none&type=tcp", - "socks://" + base64.StdEncoding.EncodeToString([]byte("u:p")) + "@127.0.0.1:1090", - } - for _, link := range cases { - t.Run(link[:12], func(t *testing.T) { - cfg, err := ConvertShareLinksToXrayJson(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) - require.NoError(t, err) - require.Len(t, again.OutboundConfigs, 1) - assert.Equal(t, cfg.OutboundConfigs[0].Protocol, again.OutboundConfigs[0].Protocol) - }) - } + assert.Equal(t, original, generated.String()) } func TestConvertXrayJsonToShareLinks_Errors(t *testing.T) { @@ -179,14 +114,66 @@ func TestConvertXrayJsonToShareLinks_Errors(t *testing.T) { _, err = ConvertXrayJsonToShareLinks([]byte(`{`)) require.Error(t, err) + + _, err = ConvertXrayJsonToShareLinks([]byte(`null`)) + require.Error(t, err) + + _, err = ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds": [{"protocol": "shadowsocks"}] + }`)) + require.Error(t, err) + + _, err = ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds": [{"protocol": "shadowsocks", "settings": null}] + }`)) + require.Error(t, err) + + _, err = ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds": [{"protocol": "freedom", "tag": "direct"}] + }`)) + require.Error(t, err) +} + +func TestConvertXrayJsonToShareLinksSupportsHysteria(t *testing.T) { + links, err := ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds": [{ + "protocol": "hysteria", + "settings": {"version": 2, "address": "example.com", "port": 443}, + "streamSettings": { + "network": "hysteria", "security": "tls", + "hysteriaSettings": {"version": 2, "auth": "password"} + } + }] + }`)) + require.NoError(t, err) + assert.Equal(t, "hysteria2://password@example.com:443#hysteria", links) +} + +func TestConvertXrayJsonToShareLinksSkipsUnsupportedOutbounds(t *testing.T) { + links, err := ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds": [ + { + "protocol": "trojan", + "settings": { + "address": "example.com", + "port": 443, + "password": "password" + } + }, + {"protocol": "freedom", "tag": "direct"}, + {"protocol": "hysteria", "settings": {"version": 2, "address": "example.com", "port": 443}} + ] + }`)) + require.NoError(t, err) + 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/hysteria2.go b/share/hysteria2.go new file mode 100644 index 00000000..f6af5ccd --- /dev/null +++ b/share/hysteria2.go @@ -0,0 +1,267 @@ +package share + +import ( + "encoding/json" + "errors" + "net" + "net/url" + "reflect" + "strconv" + "strings" + + "github.com/xtls/xray-core/infra/conf" +) + +var errHysteria2Link = errors.New("unsupported or invalid Hysteria2 share link") + +// Hysteria permits port lists in the authority, which net/url rejects. Replace +// only that port component before parsing; let net/url handle escaping/userinfo. +func hysteria2URL(text string) (*url.URL, conf.PortList, error) { + scheme, rest, _ := strings.Cut(text, "://") + end := strings.IndexAny(rest, "/?#") + if end < 0 { + end = len(rest) + } + authority, suffix := rest[:end], rest[end:] + user, hostPort := "", authority + if at := strings.LastIndexByte(authority, '@'); at >= 0 { + user, hostPort = authority[:at+1], authority[at+1:] + } + host, portText := hostPort, "443" + if strings.HasPrefix(hostPort, "[") { + close := strings.IndexByte(hostPort, ']') + if close < 0 { + return nil, conf.PortList{}, errHysteria2Link + } + host = hostPort[:close+1] + if net.ParseIP(host[1:len(host)-1]) == nil { + return nil, conf.PortList{}, errHysteria2Link + } + if tail := hostPort[close+1:]; tail != "" { + if !strings.HasPrefix(tail, ":") { + return nil, conf.PortList{}, errHysteria2Link + } + portText = tail[1:] + } + } else if strings.Contains(hostPort, ":") { + host, portText, _ = strings.Cut(hostPort, ":") + } + ports, err := hysteria2Ports(portText) + if err != nil { + return nil, conf.PortList{}, err + } + link, err := url.Parse(scheme + "://" + user + host + ":" + strconv.Itoa(int(ports.Range[0].From)) + suffix) + if err != nil || link.Hostname() == "" || (link.Path != "" && link.Path != "/") { + return nil, conf.PortList{}, errHysteria2Link + } + return link, ports, nil +} + +func hysteria2Ports(text string) (conf.PortList, error) { + ports := conf.PortList{} + for _, item := range strings.Split(text, ",") { + from, to, ranged := strings.Cut(item, "-") + if !ranged { + to = from + } + left, leftErr := strconv.ParseUint(from, 10, 16) + right, rightErr := strconv.ParseUint(to, 10, 16) + if leftErr != nil || rightErr != nil || left == 0 || left > right { + return conf.PortList{}, errHysteria2Link + } + ports.Range = append(ports.Range, conf.PortRange{From: uint32(left), To: uint32(right)}) + } + return ports, nil +} + +func parseHysteria2Link(text string) (*conf.OutboundDetourConfig, error) { + link, ports, err := hysteria2URL(text) + if err != nil { + return nil, err + } + query, err := url.ParseQuery(link.RawQuery) + if err != nil { + return nil, errHysteria2Link + } + for _, values := range query { + if len(values) != 1 { + return nil, errHysteria2Link + } + } + // Core no longer supports skipping verification. Its certificate pin is not + // equivalent to Hysteria's leaf pin + ordinary PKI. Retain explicit Xray pcs + // and vcn extensions, but never reinterpret the official TLS flags as those. + for _, key := range []string{"insecure", "allowInsecure"} { + if query.Has(key) { + insecure, err := strconv.ParseBool(query.Get(key)) + if err != nil || insecure { + return nil, errHysteria2Link + } + } + } + if query.Get("pinSHA256") != "" || (query.Get("security") != "" && query.Get("security") != "tls") { + return nil, errHysteria2Link + } + // Preserve old port-query links, while exporting standard multi-port authorities. + for _, key := range []string{"ports", "mport"} { + if query.Has(key) { + if len(ports.Range) != 1 || ports.Range[0].From != ports.Range[0].To || (key == "mport" && query.Has("ports")) { + return nil, errHysteria2Link + } + ports, err = hysteria2Ports(query.Get(key)) + if err != nil { + return nil, err + } + } + } + settings, err := convertJsonToRawMessage(&conf.HysteriaClientConfig{ + Version: 2, Address: parseAddress(link.Hostname()), Port: uint16(ports.Range[0].From), + }) + if err != nil { + return nil, err + } + auth := "" + if link.User != nil { + auth = link.User.Username() + if password, present := link.User.Password(); present { + auth += ":" + password + } + } + stream := &conf.StreamConfig{ + Network: new(conf.TransportProtocol("hysteria")), + HysteriaSettings: &conf.HysteriaConfig{Version: 2, Auth: auth}, + } + if err := (xrayShareLink{link: link}).parseSecurityFromURL(link, stream); err != nil { + return nil, err + } + mask := &conf.FinalMask{} + if obfs := query.Get("obfs"); obfs != "" && obfs != "none" { + password := query.Get("obfs-password") + if obfs != "salamander" || len(password) < 4 { + return nil, errHysteria2Link + } + raw, err := convertJsonToRawMessage(&conf.Salamander{Password: password}) + if err != nil { + return nil, err + } + mask.Udp = append(mask.Udp, conf.Mask{Type: "salamander", Settings: &raw}) + } else if query.Get("obfs-password") != "" { + return nil, errHysteria2Link + } + if len(ports.Range) > 1 || ports.Range[0].From != ports.Range[0].To { + interval := int64(30) + if query.Has("hop-interval") { + interval, err = strconv.ParseInt(query.Get("hop-interval"), 10, 32) + if err != nil || interval < 5 { + return nil, errHysteria2Link + } + } + raw, err := convertJsonToRawMessage(&conf.UDPHop{ + Mode: "intervalLocal,intervalRemote", RemotePorts: ports, + Interval: conf.Int32Range{Left: int32(interval), Right: int32(interval), From: int32(interval), To: int32(interval)}, + }) + if err != nil { + return nil, err + } + // The core wraps UDP masks in reverse order; hopping must wrap the raw socket. + mask.Udp = append(mask.Udp, conf.Mask{Type: "udphop", Settings: &raw}) + } else if query.Has("hop-interval") { + return nil, errHysteria2Link + } + if query.Get("up") != "" || query.Get("down") != "" { + mask.QuicParams = &conf.QuicParamsConfig{ + Congestion: "brutal", BrutalUp: conf.Bandwidth(query.Get("up")), BrutalDown: conf.Bandwidth(query.Get("down")), + } + } + if len(mask.Udp) > 0 || mask.QuicParams != nil { + stream.FinalMask = mask + } + return &conf.OutboundDetourConfig{ + Protocol: "hysteria", Tag: link.Fragment, Settings: &settings, StreamSetting: stream, + }, nil +} + +func hysteria2ShareLink(outbound conf.OutboundDetourConfig) (*url.URL, error) { + settings, err := decodeOutboundSettings[conf.HysteriaClientConfig](outbound) + if err != nil { + return nil, err + } + stream := outbound.StreamSetting + if settings.Version != 2 || settings.Address == nil || settings.Port == 0 || stream == nil || + stream.Security != "tls" || stream.HysteriaSettings == nil || stream.HysteriaSettings.Version != 2 { + return nil, errHysteria2Link + } + network := stream.Network + if stream.Method != nil { + network = stream.Method + } + if network == nil || *network != "hysteria" { + return nil, errHysteria2Link + } + host := strings.Trim(settings.Address.String(), "[]") + link := &url.URL{ + Scheme: "hysteria2", User: url.User(stream.HysteriaSettings.Auth), + Host: net.JoinHostPort(host, strconv.Itoa(int(settings.Port))), Fragment: getOutboundName(outbound), + } + // Reuse the existing TLS field projection; Hysteria does not use type/security. + streamSettingsQuery(outbound, link) + query := link.Query() + query.Del("type") + query.Del("security") + query.Del("fm") + if tls := stream.TLSSettings; tls != nil { + remaining := *tls + remaining.ServerName, remaining.Fingerprint, remaining.ECHConfigList = "", "", "" + remaining.PinnedPeerCertSha256, remaining.VerifyPeerCertByName = "", "" + remaining.ALPN = nil + if !reflect.DeepEqual(remaining, conf.TLSConfig{}) { + return nil, errHysteria2Link + } + if _, err := tls.Build(); err != nil { + return nil, errHysteria2Link + } + } + if mask := stream.FinalMask; mask != nil { + if len(mask.Tcp) > 0 { + return nil, errHysteria2Link + } + for index, entry := range mask.Udp { + if entry.Settings == nil { + return nil, errHysteria2Link + } + switch entry.Type { + case "salamander": + var obfs conf.Salamander + if err := json.Unmarshal(*entry.Settings, &obfs); err != nil || index != 0 || + len(obfs.Password) < 4 || obfs.PacketSize != (conf.Int32Range{}) { + return nil, errHysteria2Link + } + query.Set("obfs", "salamander") + query.Set("obfs-password", obfs.Password) + case "udphop": + var hop struct { + conf.UDPHop + Sockopt *json.RawMessage `json:"sockopt"` + } + if err := json.Unmarshal(*entry.Settings, &hop); err != nil || index != len(mask.Udp)-1 || + hop.Mode != "intervalLocal,intervalRemote" || len(hop.RemoteIPs) > 0 || hop.Sockopt != nil || + hop.Interval.From < 5 || hop.Interval.From != hop.Interval.To { + return nil, errHysteria2Link + } + ports, err := hysteria2Ports(hop.RemotePorts.String()) + if err != nil || (len(ports.Range) == 1 && ports.Range[0].From == ports.Range[0].To) { + return nil, errHysteria2Link + } + link.Host = net.JoinHostPort(host, ports.String()) + if hop.Interval.From != 30 { + query.Set("hop-interval", strconv.Itoa(int(hop.Interval.From))) + } + default: + return nil, errHysteria2Link + } + } + // QUIC bandwidth/tuning is client-local, not part of a standard share URI. + } + link.RawQuery = strings.ReplaceAll(query.Encode(), "+", "%20") + return link, nil +} diff --git a/share/hysteria2_test.go b/share/hysteria2_test.go new file mode 100644 index 00000000..1b0cb780 --- /dev/null +++ b/share/hysteria2_test.go @@ -0,0 +1,234 @@ +package share + +import ( + "context" + "encoding/base64" + "encoding/json" + "net" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + xnet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/infra/conf" + "github.com/xtls/xray-core/transport/internet/finalmask" +) + +func TestHysteria2ImportAndRoundTrip(t *testing.T) { + for _, test := range []struct { + name, text, host, auth string + port uint16 + }{ + {"default TLS and port", "hy2://password@example.com", "example.com", "password", 443}, + {"empty auth", "hysteria2://example.com/", "example.com", "", 443}, + {"userpass", "hysteria2://user:p%3Aa%25ss%40word@example.com:8443/", "example.com", "user:p:a%ss@word", 8443}, + {"literal plus", "hy2://a+b%2Bc%252B@example.com#Test%20%E6%B5%8B%E8%AF%95", "example.com", "a+b+c%2B", 443}, + {"IPv6 default", "hy2://auth@[2001:db8::1]", "2001:db8::1", "auth", 443}, + {"IPv6 port", "hysteria2://auth@[2001:db8::2]:8443?sni=server.example", "2001:db8::2", "auth", 8443}, + } { + t.Run(test.name, func(t *testing.T) { + data, err := ConvertShareLinksToXrayJson(test.text, "") + require.NoError(t, err) + var config conf.Config + require.NoError(t, json.Unmarshal(data, &config)) + require.Len(t, config.OutboundConfigs, 1) + outbound := config.OutboundConfigs[0] + settings, err := decodeOutboundSettings[conf.HysteriaClientConfig](outbound) + require.NoError(t, err) + assert.Equal(t, parseAddress(test.host), settings.Address) + assert.Equal(t, test.port, settings.Port) + assert.Equal(t, int32(2), settings.Version) + assert.Equal(t, "hysteria", outbound.Protocol) + assert.Equal(t, "tls", outbound.StreamSetting.Security) + assert.Equal(t, test.auth, outbound.StreamSetting.HysteriaSettings.Auth) + assert.Equal(t, int32(2), outbound.StreamSetting.HysteriaSettings.Version) + requireProjectedOutboundsBuild(t, data) + generated, err := ConvertXrayJsonToShareLinks(data) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(generated, "hysteria2://")) + again, err := ConvertShareLinksToXrayJson(generated, "") + require.NoError(t, err) + // Nameless nodes acquire the existing protocol-name fallback on export. + if outbound.Tag == "" { + outbound.Tag = "hysteria" + config.OutboundConfigs[0] = outbound + data, err = marshalShareConfigJSON(&config) + require.NoError(t, err) + } + assert.JSONEq(t, string(data), string(again)) + }) + } +} + +func TestHysteria2TLSAndMaskRoundTrip(t *testing.T) { + for _, authority := range []string{"example.com:443,5000-5002", "[2001:db8::1]:5000-5002"} { + input := "hy2://auth@" + authority + "/?sni=server.example&obfs=salamander&obfs-password=pass%2Bword&hop-interval=5&fp=chrome&alpn=h3&ech=aGVsbG8%3D&pcs=" + strings.Repeat("ab", 32) + "&vcn=server.example#Node" + data, err := ConvertShareLinksToXrayJson(input, "") + require.NoError(t, err) + generated, err := ConvertXrayJsonToShareLinks(data) + require.NoError(t, err) + assert.Contains(t, generated, "@"+authority) + assert.NotContains(t, generated, "fm=") + assert.NotContains(t, generated, "security=") + assert.NotContains(t, generated, "type=") + again, err := ConvertShareLinksToXrayJson(generated, "") + require.NoError(t, err) + assert.JSONEq(t, string(data), string(again)) + } +} + +func TestHysteria2LegacyPortQueriesAndBandwidth(t *testing.T) { + for _, key := range []string{"ports", "mport"} { + config, err := convertShareLinksWithKeyForTest("hy2://auth@host:443?"+key+"=5000-5002&up=50%20mbps&down=100%20mbps", "") + require.NoError(t, err) + mask := config.OutboundConfigs[0].StreamSetting.FinalMask + require.NotNil(t, mask) + assert.Equal(t, conf.Bandwidth("50 mbps"), mask.QuicParams.BrutalUp) + var hop conf.UDPHop + require.NoError(t, json.Unmarshal(*mask.Udp[0].Settings, &hop)) + assert.Equal(t, "intervalLocal,intervalRemote", hop.Mode) + assert.Equal(t, int32(30), hop.Interval.From) + link, err := shareLink(config.OutboundConfigs[0]) + require.NoError(t, err) + assert.Contains(t, link.String(), "host:5000-5002") + assert.Empty(t, link.Query().Get("up")) + assert.Empty(t, link.Query().Get("down")) + } +} + +func TestHysteria2RejectsInvalidOrUnsupportedLinks(t *testing.T) { + for _, suffix := range []string{ + "", ":443", "example.com:", "example.com:0", "example.com:65536", "example.com:-1", + "example.com:5002-5000", "example.com:443,", "example.com:443,,444", "example.com:env:PORT", + "2001:db8::1", "[example.com]:443", "[2001:db8::1", "example.com/path", + "example.com?insecure=1", "example.com?allowInsecure=true", "example.com?insecure=invalid", + "example.com?insecure=0&insecure=1", "example.com?pinSHA256=" + strings.Repeat("ab", 32), + "example.com?insecure=1&pinSHA256=" + strings.Repeat("ab", 32), + "example.com?security=none", "example.com?security=reality", + "example.com?obfs=gecko&obfs-password=secret", "example.com?obfs=salamander", + "example.com?obfs=salamander&obfs-password=abc", "example.com?obfs-password=secret", + "example.com?pcs=not-a-pin", "example.com?hop-interval=5", + "example.com:443,444?hop-interval=0", "example.com:443,444?hop-interval=4", + "example.com:443,444?hop-interval=no", "example.com:443,444?ports=445,446", + "example.com?ports=443,444&mport=445,446", "example.com?ports=", + "example.com?obfs=salamander&obfs-password=%zz", "example.com?up=not-bandwidth", + } { + t.Run(suffix, func(t *testing.T) { + data, err := ConvertShareLinksToXrayJson("hy2://private-password@"+suffix, "") + require.Error(t, err) + assert.Nil(t, data) + assert.NotContains(t, err.Error(), "private-password") + }) + } + for _, value := range []string{"0", "false"} { + _, err := ConvertShareLinksToXrayJson("hy2://auth@host?insecure="+value, "") + require.NoError(t, err) + } +} + +func TestHysteria2SubscriptionsKeepOrder(t *testing.T) { + text := "hy2://one@first.example#First\n" + ageTestShareLink + "\nhy2://bad@bad.example?insecure=1\nhysteria2://last@last.example#Last" + for _, input := range []string{text, base64.StdEncoding.EncodeToString([]byte(text)), base64.RawURLEncoding.EncodeToString([]byte(text))} { + config, err := convertShareLinksWithKeyForTest(input, "") + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 3) + assert.Equal(t, "First", config.OutboundConfigs[0].Tag) + assert.Equal(t, "vless", config.OutboundConfigs[1].Protocol) + assert.Equal(t, "Last", config.OutboundConfigs[2].Tag) + } +} + +func TestHysteria2ExportRejectsUnrepresentableSecurityAndMasks(t *testing.T) { + for _, mutate := range []func(*conf.StreamConfig){ + func(s *conf.StreamConfig) { s.TLSSettings.AllowInsecure = true }, + func(s *conf.StreamConfig) { s.TLSSettings.DisableSystemRoot = true }, + func(s *conf.StreamConfig) { s.TLSSettings.MinVersion = "1.3" }, + func(s *conf.StreamConfig) { s.Method = new(conf.TransportProtocol("ws")) }, + func(s *conf.StreamConfig) { s.FinalMask = &conf.FinalMask{Udp: []conf.Mask{{Type: "noise"}}} }, + func(s *conf.StreamConfig) { + raw := json.RawMessage(`{"password":"secret","packetSize":"100-200"}`) + s.FinalMask = &conf.FinalMask{Udp: []conf.Mask{{Type: "salamander", Settings: &raw}}} + }, + func(s *conf.StreamConfig) { + raw := json.RawMessage(`{"mode":"intervalRemote","interval":30,"remotePorts":"443,444"}`) + s.FinalMask = &conf.FinalMask{Udp: []conf.Mask{{Type: "udphop", Settings: &raw}}} + }, + func(s *conf.StreamConfig) { + raw := json.RawMessage(`{"mode":"intervalLocal,intervalRemote","interval":30,"remotePorts":"443,444","sockopt":{}}`) + s.FinalMask = &conf.FinalMask{Udp: []conf.Mask{{Type: "udphop", Settings: &raw}}} + }, + } { + config, err := convertShareLinksForTest("hy2://auth@host?sni=host") + require.NoError(t, err) + mutate(config.OutboundConfigs[0].StreamSetting) + _, err = shareLink(config.OutboundConfigs[0]) + require.Error(t, err) + } +} + +// Exercise packet replies, not just mask construction: the remote-only hopping +// mode used by the former converter never starts the pinned core's read loop. +func TestHysteria2PortHoppingReceivesAfterHop(t *testing.T) { + if testing.Short() { + t.Skip("waits for a real local UDP hop") + } + server, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = server.Close() }) + go func() { + buf := make([]byte, 64) + for { + n, addr, err := server.ReadFrom(buf) + if err != nil { + return + } + _, _ = server.WriteTo([]byte(addr.String()+":"+string(buf[:n])), addr) + } + }() + port := server.LocalAddr().(*net.UDPAddr).Port + config, err := convertShareLinksForTest("hy2://auth@127.0.0.1:" + strconv.Itoa(port) + "," + strconv.Itoa(port) + "?hop-interval=5") + require.NoError(t, err) + mask := config.OutboundConfigs[0].StreamSetting.FinalMask.Udp[0] + built, err := mask.Build(false) + require.NoError(t, err) + manager := finalmask.NewFinalMask(nil, []finalmask.UDPMask{built.(finalmask.UDPMask)}, nil, nil, + func(context.Context, xnet.Destination) (net.PacketConn, net.Addr, error) { + raw, err := net.ListenPacket("udp", "127.0.0.1:0") + return raw, server.LocalAddr(), err + }, nil) + dest := xnet.UDPDestination(xnet.IPAddress(net.IPv4(127, 0, 0, 1)), xnet.Port(port)) + conn, err := manager.DialUDP(context.Background(), dest) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + exchange := func() string { + t.Helper() + require.NoError(t, conn.SetDeadline(time.Now().Add(2*time.Second))) + _, err := conn.Write([]byte("echo")) + require.NoError(t, err) + buf := make([]byte, 128) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.True(t, strings.HasSuffix(string(buf[:n]), ":echo")) + return string(buf[:n]) + } + first := exchange() + // Clear the first exchange's deadline before the replacement socket inherits it. + require.NoError(t, conn.SetDeadline(time.Time{})) + time.Sleep(5500 * time.Millisecond) + second := exchange() + assert.NotEqual(t, first, second, "the local UDP socket should change after hopping") +} + +func TestHysteria2ShareDoesNotUseQueryEscapingForUserinfo(t *testing.T) { + data, err := ConvertShareLinksToXrayJson("hy2://user%3Apassword%2B%25@example.com", "") + require.NoError(t, err) + text, err := ConvertXrayJsonToShareLinks(data) + require.NoError(t, err) + u, err := url.Parse(text) + require.NoError(t, err) + assert.Equal(t, "user:password+%", u.User.Username()) +} diff --git a/share/hysteria_mask.go b/share/hysteria_mask.go deleted file mode 100644 index cbdc5e5b..00000000 --- a/share/hysteria_mask.go +++ /dev/null @@ -1,58 +0,0 @@ -package share - -import ( - "encoding/json" - - "github.com/xtls/xray-core/infra/conf" -) - -// buildHy2FinalMask builds Hysteria2 QUIC hop / bandwidth / salamander mask (shared by URI and Clash). -func buildHy2FinalMask(up, down, ports string, hopInterval *int32, obfsType, obfsPassword string) (*conf.FinalMask, error) { - var quicParams *conf.QuicParamsConfig - if up != "" || down != "" || ports != "" { - quicParams = &conf.QuicParamsConfig{} - if up != "" || down != "" { - quicParams.Congestion = "brutal" - } - if up != "" { - quicParams.BrutalUp = conf.Bandwidth(up) - } - if down != "" { - quicParams.BrutalDown = conf.Bandwidth(down) - } - if ports != "" { - var portList conf.PortList - udpHop := conf.UdpHop{} - portListJSON, err := json.Marshal(ports) - if err != nil { - return nil, err - } - if err := json.Unmarshal(portListJSON, &portList); err != nil { - return nil, err - } - udpHop.PortList = portList - if hopInterval != nil { - i := *hopInterval - udpHop.Interval = conf.Int32Range{Left: i, Right: i, From: i, To: i} - } - quicParams.UdpHop = udpHop - } - } - - var udpMasks []conf.Mask - if obfsType == "salamander" && obfsPassword != "" { - obfs := conf.Mask{Type: "salamander"} - salamander := &conf.Salamander{Password: obfsPassword} - salamanderRawMessage, err := convertJsonToRawMessage(salamander) - if err != nil { - return nil, err - } - obfs.Settings = &salamanderRawMessage - udpMasks = []conf.Mask{obfs} - } - - if quicParams == nil && len(udpMasks) == 0 { - return nil, nil - } - return &conf.FinalMask{QuicParams: quicParams, Udp: udpMasks}, nil -} diff --git a/share/marshal_share.go b/share/marshal_share.go new file mode 100644 index 00000000..31236bd2 --- /dev/null +++ b/share/marshal_share.go @@ -0,0 +1,345 @@ +package share + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/xtls/xray-core/infra/conf" +) + +func marshalShareConfigJSON(config *conf.Config) (json.RawMessage, error) { + if config == nil { + return nil, fmt.Errorf("no valid outbound found") + } + + outbounds := make([]map[string]any, 0, len(config.OutboundConfigs)) + for _, outbound := range config.OutboundConfigs { + source, err := marshalShareJSONObject(outbound) + if err != nil { + return nil, err + } + projected, supported := projectShareOutbound(source) + if !supported { + continue + } + if err := validateProjectedShareOutbound(projected); err != nil { + continue + } + outbounds = append(outbounds, projected) + } + + if len(outbounds) == 0 { + return nil, 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 raw, nil +} + +func marshalShareJSONObject(value any) (map[string]any, error) { + raw, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("failed to marshal share config: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var object map[string]any + if err := decoder.Decode(&object); err != nil { + return nil, fmt.Errorf("failed to marshal share config: %w", err) + } + return object, nil +} + +func projectShareOutbound(source map[string]any) (map[string]any, bool) { + protocol := strings.ToLower(shareString(source["protocol"])) + settings, _ := shareObject(source["settings"]) + + projectedSettings := map[string]any{} + switch protocol { + case "shadowsocks": + copyShareFields(projectedSettings, settings, "address", "port", "method", "password") + case "vmess": + copyShareFields(projectedSettings, settings, "address", "port", "id", "security") + case "vless": + copyShareFields(projectedSettings, settings, "address", "port", "id", "flow", "encryption") + case "socks": + copyShareFields(projectedSettings, settings, "address", "port", "user", "pass") + case "trojan": + copyShareFields(projectedSettings, settings, "address", "port", "password") + case "hysteria": + copyShareFields(projectedSettings, settings, "version", "address", "port") + default: + return nil, false + } + + projected := map[string]any{ + "protocol": protocol, + "settings": projectedSettings, + } + copyShareFields(projected, source, "sendThrough", "tag") + + if stream, ok := shareObject(source["streamSettings"]); ok { + projectedStream, supported := projectShareStream(stream) + if !supported { + return nil, false + } + if len(projectedStream) > 0 { + projected["streamSettings"] = projectedStream + } + } + return projected, true +} + +func projectShareStream(source map[string]any) (map[string]any, bool) { + networkValue := shareString(source["network"]) + if method := shareString(source["method"]); method != "" { + networkValue = method + } + network, supported := canonicalShareNetwork(networkValue) + if !supported { + return nil, false + } + + securityValue := shareString(source["security"]) + security := strings.ToLower(securityValue) + if security != "" && security != "none" && security != "tls" && security != "reality" { + return nil, false + } + + projected := map[string]any{} + if networkValue != "" { + projected["network"] = network + } + if securityValue != "" { + projected["security"] = security + } + + switch network { + case "raw": + if settings, ok := firstShareObject(source, "rawSettings", "tcpSettings"); ok { + if value := projectShareRAWSettings(settings); len(value) > 0 { + projected["rawSettings"] = value + } + } + case "kcp": + projectShareTransportSettings(projected, source, "kcpSettings", "mtu", "tti") + case "ws": + projectShareTransportSettings(projected, source, "wsSettings", "host", "path") + case "grpc": + projectShareTransportSettings(projected, source, "grpcSettings", "authority", "serviceName", "multiMode") + case "httpupgrade": + projectShareTransportSettings(projected, source, "httpupgradeSettings", "host", "path") + case "xhttp": + if settings, ok := firstShareObject(source, "xhttpSettings", "splithttpSettings"); ok { + value := map[string]any{} + copyShareFields(value, settings, "host", "path", "mode", "extra") + if len(value) > 0 { + projected["xhttpSettings"] = value + } + } + case "hysteria": + projectShareTransportSettings(projected, source, "hysteriaSettings", "version", "auth") + } + + switch security { + case "tls": + if settings, ok := shareObject(source["tlsSettings"]); ok { + value := map[string]any{} + copyShareFields(value, settings, + "serverName", "alpn", "fingerprint", "echConfigList", + "pinnedPeerCertSha256", "verifyPeerCertByName", + ) + if len(value) > 0 { + projected["tlsSettings"] = value + } + } + case "reality": + if settings, ok := shareObject(source["realitySettings"]); ok { + value := map[string]any{} + copyShareFields(value, settings, + "serverName", "fingerprint", "shortId", "mldsa65Verify", "spiderX", + ) + if password := shareString(settings["password"]); password != "" { + value["password"] = password + } else if publicKey := shareString(settings["publicKey"]); publicKey != "" { + value["password"] = publicKey + } + if len(value) > 0 { + projected["realitySettings"] = value + } + } + } + + if finalMask, ok := shareObject(source["finalmask"]); ok { + if value := projectShareFinalMask(finalMask); len(value) > 0 { + projected["finalmask"] = value + } + } + return projected, true +} + +func canonicalShareNetwork(network string) (string, bool) { + switch strings.ToLower(network) { + case "", "raw", "tcp": + return "raw", true + case "kcp", "mkcp": + return "kcp", true + case "ws", "websocket": + return "ws", true + case "grpc": + return "grpc", true + case "httpupgrade": + return "httpupgrade", true + case "xhttp", "splithttp": + return "xhttp", true + case "hysteria": + return "hysteria", true + default: + return "", false + } +} + +func projectShareRAWSettings(source map[string]any) map[string]any { + header, ok := shareObject(source["header"]) + if !ok || strings.ToLower(shareString(header["type"])) != "http" { + return nil + } + + projectedHeader := map[string]any{"type": "http"} + if request, ok := shareObject(header["request"]); ok { + projectedRequest := map[string]any{} + copyShareFields(projectedRequest, request, "path") + if headers, ok := shareObject(request["headers"]); ok { + projectedHeaders := map[string]any{} + copyShareFields(projectedHeaders, headers, "Host") + if len(projectedHeaders) > 0 { + projectedRequest["headers"] = projectedHeaders + } + } + if len(projectedRequest) > 0 { + projectedHeader["request"] = projectedRequest + } + } + return map[string]any{"header": projectedHeader} +} + +func projectShareTransportSettings(projected, source map[string]any, field string, keys ...string) { + settings, ok := shareObject(source[field]) + if !ok { + return + } + value := map[string]any{} + copyShareFields(value, settings, keys...) + if len(value) > 0 { + projected[field] = value + } +} + +func projectShareFinalMask(source map[string]any) map[string]any { + projected := map[string]any{} + for _, field := range []string{"tcp", "udp"} { + masks, ok := source[field].([]any) + if !ok { + continue + } + projectedMasks := make([]map[string]any, 0, len(masks)) + for _, item := range masks { + mask, ok := shareObject(item) + if !ok { + continue + } + maskType := shareString(mask["type"]) + if maskType == "" { + continue + } + projectedMask := map[string]any{"type": maskType} + if settings, exists := mask["settings"]; exists && !emptyShareValue(settings) { + projectedMask["settings"] = settings + } + projectedMasks = append(projectedMasks, projectedMask) + } + if len(projectedMasks) > 0 { + projected[field] = projectedMasks + } + } + + if params, ok := shareObject(source["quicParams"]); ok { + projectedParams := map[string]any{} + // fm carries the full FinalMask config, including all Core QUIC parameters. + for field, value := range params { + if !emptyShareValue(value) { + projectedParams[field] = value + } + } + if len(projectedParams) > 0 { + projected["quicParams"] = projectedParams + } + } + return projected +} + +func validateProjectedShareOutbound(projected map[string]any) error { + raw, err := json.Marshal(projected) + if err != nil { + return err + } + var outbound conf.OutboundDetourConfig + if err := json.Unmarshal(raw, &outbound); err != nil { + return err + } + _, err = outbound.Build() + return err +} + +func copyShareFields(target, source map[string]any, fields ...string) { + for _, field := range fields { + if value, ok := source[field]; ok && !emptyShareValue(value) { + target[field] = value + } + } +} + +func firstShareObject(source map[string]any, fields ...string) (map[string]any, bool) { + for _, field := range fields { + if object, ok := shareObject(source[field]); ok { + return object, true + } + } + return nil, false +} + +func shareObject(value any) (map[string]any, bool) { + object, ok := value.(map[string]any) + return object, ok +} + +func shareString(value any) string { + text, _ := value.(string) + return text +} + +func emptyShareValue(value any) bool { + switch value := value.(type) { + case nil: + return true + case string: + return value == "" + case bool: + return !value + case json.Number: + number, err := strconv.ParseFloat(value.String(), 64) + return err == nil && number == 0 + case []any: + return len(value) == 0 + case map[string]any: + return len(value) == 0 + default: + return false + } +} diff --git a/share/marshal_share_test.go b/share/marshal_share_test.go new file mode 100644 index 00000000..0eb2bab3 --- /dev/null +++ b/share/marshal_share_test.go @@ -0,0 +1,152 @@ +package share + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xtls/xray-core/infra/conf" +) + +func TestMarshalShareConfigProjectsSupportedFields(t *testing.T) { + const input = `{ + "log":{"loglevel":"warning"}, + "outbounds":[ + {"protocol":"freedom","settings":{}}, + { + "protocol":"vless", + "settings":{"address":"drop.example","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"}, + "streamSettings":{"network":"quic","security":"tls","tlsSettings":{"fingerprint":"chrome"}} + }, + { + "protocol":"vless", + "settings":{"address":"drop.example","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"}, + "streamSettings":{"network":"raw","security":"xtls"} + }, + { + "protocol":"VLESS", + "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":{} + }, + "streamSettings":{ + "address":"drop.example","port":8443,"network":"splithttp","security":"REALITY", + "xhttpSettings":{ + "host":"cdn.example.com","path":"/xhttp","mode":"stream-up","headers":{"X-Drop":"yes"}, + "extra":{"maxConcurrency":"1-2","empty":null} + }, + "realitySettings":{ + "show":false,"target":null,"dest":null,"serverNames":null,"privateKey":"", + "serverName":"example.com","fingerprint":"chrome","password":"", + "publicKey":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","shortId":"abcd","spiderX":"/" + }, + "sockopt":{"dialerProxy":"drop"} + }, + "proxySettings":{"tag":"drop"},"mux":{"enabled":true},"targetStrategy":"UseIP" + } + ] + }` + + var config conf.Config + require.NoError(t, json.Unmarshal([]byte(input), &config)) + raw, err := marshalShareConfigJSON(&config) + require.NoError(t, err) + + const expected = `{ + "outbounds":[{ + "protocol":"vless", + "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", + "xhttpSettings":{"host":"cdn.example.com","path":"/xhttp","mode":"stream-up","extra":{"maxConcurrency":"1-2","empty":null}}, + "realitySettings":{"serverName":"example.com","fingerprint":"chrome","password":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","shortId":"abcd","spiderX":"/"} + } + }] + }` + assert.JSONEq(t, expected, string(raw)) + requireProjectedOutboundsBuild(t, raw) +} + +func TestConvertShareLinksNativeJSONPreservesHysteriaPortHopping(t *testing.T) { + raw, err := ConvertShareLinksToXrayJson(`{ + "outbounds": [{ + "protocol": "hysteria", + "settings": {"version": 2, "address": "host", "port": 443}, + "streamSettings": { + "network": "hysteria", "security": "tls", + "tlsSettings": {"serverName": "example.com", "fingerprint": "chrome"}, + "hysteriaSettings": {"version": 2, "auth": "auth"}, + "finalmask": { + "quicParams": {"congestion": "brutal", "brutalUp": "50 mbps", "brutalDown": "100 mbps"}, + "udp": [{"type": "udphop", "settings": {"mode": "intervalRemote", "remotePorts": "20000-40000", "interval": "30"}}] + } + } + }] + }`, "") + require.NoError(t, err) + + var document map[string]any + require.NoError(t, json.Unmarshal(raw, &document)) + outbound := document["outbounds"].([]any)[0].(map[string]any) + stream := outbound["streamSettings"].(map[string]any) + finalMask := stream["finalmask"].(map[string]any) + quicParams := finalMask["quicParams"].(map[string]any) + assert.NotContains(t, quicParams, "udpHop") + mask := finalMask["udp"].([]any)[0].(map[string]any) + assert.Equal(t, "udphop", mask["type"]) + udpHop := mask["settings"].(map[string]any) + assert.Equal(t, "intervalRemote", udpHop["mode"]) + assert.Equal(t, "20000-40000", udpHop["remotePorts"]) + assert.Equal(t, "30", udpHop["interval"]) + requireProjectedOutboundsBuild(t, raw) +} + +func TestMarshalShareConfigKeepsKCPWithoutSettings(t *testing.T) { + link := "vmess://" + testShareUUID + "@kcp.host:8391?type=kcp&seed=seedval&headerType=wireguard#k" + config, err := convertShareLinksForTest(link) + require.NoError(t, err) + raw, err := marshalShareConfigJSON(config) + require.NoError(t, err) + + assert.Contains(t, string(raw), `"network":"kcp"`) + assert.NotContains(t, string(raw), "kcpSettings") + assert.NotContains(t, string(raw), "seedval") + assert.NotContains(t, string(raw), "wireguard") + requireProjectedOutboundsBuild(t, raw) +} + +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", + "vless": "vless://" + testShareUUID + "@10.0.0.1:443?encryption=none&security=none", + "socks": "socks://" + base64.StdEncoding.EncodeToString([]byte("user:password")) + "@127.0.0.1:1080", + "trojan": "trojan://password@trojan.example:443?sni=trojan.example", + } + for protocol, link := range tests { + t.Run(protocol, func(t *testing.T) { + config, err := convertShareLinksForTest(link) + require.NoError(t, err) + raw, err := marshalShareConfigJSON(config) + require.NoError(t, err) + requireProjectedOutboundsBuild(t, raw) + }) + } +} + +func requireProjectedOutboundsBuild(t *testing.T, raw json.RawMessage) { + t.Helper() + var config conf.Config + require.NoError(t, json.Unmarshal(raw, &config)) + require.NotEmpty(t, config.OutboundConfigs) + for index := range config.OutboundConfigs { + _, err := config.OutboundConfigs[index].Build() + require.NoError(t, err) + } +} diff --git a/share/parse_share.go b/share/parse_share.go index 55436a01..0ef5bf6f 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" @@ -11,7 +10,7 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -// FixWindowsReturn normalizes CRLF to LF (v2rayN exports on Windows). +// FixWindowsReturn normalizes CRLF to LF. func FixWindowsReturn(text string) string { return strings.ReplaceAll(text, "\r\n", "\n") } @@ -29,7 +28,7 @@ func decodeBase64Text(text string) (string, error) { if b, err := base64.URLEncoding.DecodeString(text); err == nil { return string(b), nil } - // URL-safe raw + manual padding (legacy v2rayN) + // Normalize the URL-safe alphabet and restore missing padding. s := strings.ReplaceAll(strings.ReplaceAll(text, "-", "+"), "_", "/") if pad := len(s) % 4; pad != 0 { s += strings.Repeat("=", 4-pad) @@ -43,122 +42,21 @@ func decodeBase64Text(text string) (string, error) { // https://github.com/XTLS/Xray-core/discussions/716 -// ConvertShareLinksToXrayJson parses: -// - a single Xray JSON object (starts with '{') -// - 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) { - return convertShareLinksToXrayJson(links, true) -} - -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 xray, 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 -} - -func hasTopLevelClashProxiesKey(text string) bool { - found := false - forEachLine(text, func(raw string) bool { - line := strings.TrimRight(raw, " \t") - trimmed := strings.TrimSpace(line) - if trimmed == "" || trimmed == "---" || strings.HasPrefix(trimmed, "#") { - return true - } - 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 { @@ -166,6 +64,17 @@ type xrayShareLink struct { rawText string } +func parseOutboundShareLink(text string) (*conf.OutboundDetourConfig, error) { + if strings.HasPrefix(text, "hysteria2://") || strings.HasPrefix(text, "hy2://") { + return parseHysteria2Link(text) + } + link, err := url.Parse(text) + if err != nil { + return nil, err + } + return (xrayShareLink{link: link, rawText: text}).outbound() +} + func (proxy xrayShareLink) outbound() (*conf.OutboundDetourConfig, error) { switch proxy.link.Scheme { case "ss": @@ -178,8 +87,6 @@ func (proxy xrayShareLink) outbound() (*conf.OutboundDetourConfig, error) { return proxy.socksOutbound() case "trojan": return proxy.trojanOutbound() - case "hysteria2", "hy2": - return proxy.hysteriaOutbound() default: return nil, fmt.Errorf("unsupported link: %s", proxy.rawText) } @@ -198,17 +105,12 @@ func (proxy xrayShareLink) shadowsocksOutbound() (*conf.OutboundDetourConfig, er } settings.Port = uint16(port) - user := proxy.link.User.String() - passwordText, err := decodeBase64Text(user) + cipher, password, err := parseShadowsocksUserInfo(proxy.link.User) if err != nil { return nil, err } - pwConfig := strings.SplitN(passwordText, ":", 2) - if len(pwConfig) != 2 { - return nil, fmt.Errorf("unsupported shadowsocks link password: %s", passwordText) - } - settings.Cipher = pwConfig[0] - settings.Password = pwConfig[1] + settings.Cipher = cipher + settings.Password = password settingsRawMessage, err := convertJsonToRawMessage(settings) if err != nil { @@ -224,12 +126,33 @@ func (proxy xrayShareLink) shadowsocksOutbound() (*conf.OutboundDetourConfig, er return outbound, nil } -func (proxy xrayShareLink) vmessOutbound() (*conf.OutboundDetourConfig, error) { - text := strings.ReplaceAll(proxy.rawText, "vmess://", "") - if base64Text, err := decodeBase64Text(text); err == nil { - return parseVMessQrCode(base64Text) +func parseShadowsocksUserInfo(user *url.Userinfo) (string, string, error) { + if user == nil { + return "", "", fmt.Errorf("missing shadowsocks user info") } + // SIP002 plain user info uses method:password. net/url decodes the + // percent-encoded method and password independently. + if password, ok := user.Password(); ok { + cipher := user.Username() + if cipher == "" { + return "", "", fmt.Errorf("missing shadowsocks cipher") + } + return cipher, password, nil + } + + decoded, err := decodeBase64Text(user.String()) + if err != nil { + return "", "", err + } + cipher, password, ok := strings.Cut(decoded, ":") + if !ok || cipher == "" { + return "", "", fmt.Errorf("unsupported shadowsocks user info") + } + return cipher, password, nil +} + +func (proxy xrayShareLink) vmessOutbound() (*conf.OutboundDetourConfig, error) { outbound := &conf.OutboundDetourConfig{} outbound.Protocol = "vmess" setOutboundName(outbound, proxy.link.Fragment) @@ -380,60 +303,3 @@ func (proxy xrayShareLink) trojanOutbound() (*conf.OutboundDetourConfig, error) outbound.StreamSetting = streamSettings return outbound, nil } - -func (proxy xrayShareLink) hysteriaOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "hysteria" - setOutboundName(outbound, proxy.link.Fragment) - - settings := &conf.HysteriaClientConfig{} - settings.Version = 2 - settings.Address = parseAddress(proxy.link.Hostname()) - port, err := strconv.Atoi(proxy.link.Port()) - if err != nil { - return nil, err - } - settings.Port = uint16(port) - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings := &conf.StreamConfig{} - streamSettings.Network = new(conf.TransportProtocol("hysteria")) - - hysteriaSettings := &conf.HysteriaConfig{} - hysteriaSettings.Version = 2 - auth, err := url.QueryUnescape(proxy.link.User.String()) - if err != nil { - return nil, err - } - hysteriaSettings.Auth = auth - streamSettings.HysteriaSettings = hysteriaSettings - - query := proxy.link.Query() - var hopPtr *int32 - if hopStr := query.Get("hop-interval"); hopStr != "" { - interval, pErr := strconv.ParseInt(hopStr, 10, 32) - if pErr != nil { - return nil, pErr - } - hopPtr = new(int32(interval)) - } - finalMask, mErr := buildHy2FinalMask( - query.Get("up"), query.Get("down"), query.Get("ports"), - hopPtr, query.Get("obfs"), query.Get("obfs-password"), - ) - if mErr != nil { - return nil, mErr - } - streamSettings.FinalMask = finalMask - - if err := proxy.parseSecurityFromURL(proxy.link, streamSettings); err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - return outbound, nil -} diff --git a/share/parse_share_test.go b/share/parse_share_test.go index 748b0210..2b4afd11 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -17,148 +17,13 @@ func ssUserB64(cipher, password string) string { return base64.StdEncoding.EncodeToString([]byte(cipher + ":" + password)) } -func parseHy2Link(t *testing.T, link string) *conf.OutboundDetourConfig { - t.Helper() - config, err := ConvertShareLinksToXrayJson(link) - require.NoError(t, err) - require.Len(t, config.OutboundConfigs, 1) - return &config.OutboundConfigs[0] -} - -func TestHysteria2_Minimal(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?sni=example.com") - - assert.Equal(t, "hysteria", outbound.Protocol) - - var settings conf.HysteriaClientConfig - require.NoError(t, json.Unmarshal(*outbound.Settings, &settings)) - assert.Equal(t, int32(2), settings.Version) - assert.Equal(t, uint16(443), settings.Port) - - ss := outbound.StreamSetting - require.NotNil(t, ss) - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "example.com", ss.TLSSettings.ServerName) - - require.NotNil(t, ss.HysteriaSettings) - assert.Equal(t, int32(2), ss.HysteriaSettings.Version) - assert.Equal(t, "auth", ss.HysteriaSettings.Auth) - - // No FinalMask for minimal link - assert.Nil(t, ss.FinalMask) -} - -func TestHysteria2_WithBandwidth(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?up=100+mbps&down=200+mbps&sni=example.com") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - require.NotNil(t, ss.FinalMask) - require.NotNil(t, ss.FinalMask.QuicParams) - - qp := ss.FinalMask.QuicParams - assert.Equal(t, "brutal", qp.Congestion) - assert.Equal(t, conf.Bandwidth("100 mbps"), qp.BrutalUp) - assert.Equal(t, conf.Bandwidth("200 mbps"), qp.BrutalDown) - - // No Salamander - assert.Empty(t, ss.FinalMask.Udp) -} - -func TestHysteria2_WithSalamander(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?obfs=salamander&obfs-password=secret&sni=example.com") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - require.NotNil(t, ss.FinalMask) - - // No QuicParams - assert.Nil(t, ss.FinalMask.QuicParams) - - // Has Salamander - require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "salamander", ss.FinalMask.Udp[0].Type) - - var salamander conf.Salamander - require.NoError(t, json.Unmarshal(*ss.FinalMask.Udp[0].Settings, &salamander)) - assert.Equal(t, "secret", salamander.Password) -} - -func TestHysteria2_WithEverything(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?up=50+mbps&down=100+mbps&obfs=salamander&obfs-password=secret&ports=20000-40000&hop-interval=30&sni=example.com") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - require.NotNil(t, ss.FinalMask) - - // QuicParams with bandwidth and port-hopping - require.NotNil(t, ss.FinalMask.QuicParams) - qp := ss.FinalMask.QuicParams - assert.Equal(t, "brutal", qp.Congestion) - assert.Equal(t, conf.Bandwidth("50 mbps"), qp.BrutalUp) - assert.Equal(t, conf.Bandwidth("100 mbps"), qp.BrutalDown) - - // UdpHop - assert.Equal(t, "20000-40000", qp.UdpHop.PortList.String()) - assert.Equal(t, int32(30), qp.UdpHop.Interval.From) - assert.Equal(t, int32(30), qp.UdpHop.Interval.To) - - // Salamander - require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "salamander", ss.FinalMask.Udp[0].Type) -} - -func TestHysteria2_WithTLSParams(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?sni=example.com&alpn=h3&fp=chrome") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "example.com", ss.TLSSettings.ServerName) - assert.Equal(t, "chrome", ss.TLSSettings.Fingerprint) - require.NotNil(t, ss.TLSSettings.ALPN) - assert.Equal(t, conf.StringList{"h3"}, *ss.TLSSettings.ALPN) -} - -func TestHysteria2_PortsOnlyNoCongestion(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?ports=20000-40000&hop-interval=10&sni=example.com") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - require.NotNil(t, ss.FinalMask) - require.NotNil(t, ss.FinalMask.QuicParams) - - qp := ss.FinalMask.QuicParams - // No Congestion when only ports are set (no bandwidth) - assert.Empty(t, qp.Congestion) - assert.Empty(t, string(qp.BrutalUp)) - assert.Empty(t, string(qp.BrutalDown)) - - // UdpHop is set - assert.Equal(t, "20000-40000", qp.UdpHop.PortList.String()) - assert.Equal(t, int32(10), qp.UdpHop.Interval.From) - assert.Equal(t, int32(10), qp.UdpHop.Interval.To) -} - -func TestHysteria2_TLSDefaultWhenSecurityOmitted(t *testing.T) { - // When no security= param is present, hysteria2 should default to TLS - outbound := parseHy2Link(t, "hy2://auth@host:443?sni=example.com") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) -} - func TestFixWindowsReturn(t *testing.T) { in := "a\r\nb\r\nc" assert.Equal(t, "a\nb\nc", FixWindowsReturn(in)) } 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,39 +32,81 @@ 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 := convertShareLinksForTest(`{ + "env": {"TEST_ENV": "value"}, + "log": {"loglevel": "debug"}, + "routing": {"rules": []}, + "dns": {"servers": ["8.8.8.8"]}, + "inbounds": [{"protocol": "http", "listen": "127.0.0.1", "port": 1080, "settings": {}}], + "outbounds": [{"protocol": "freedom", "tag": "direct", "settings": {}}], + "policy": {"levels": {}}, + "metrics": {"tag": "metrics"}, + "stats": {}, + "fakeDns": {"ipPool": "198.18.0.0/15", "poolSize": 65535} + }`) + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) + assert.Equal(t, "direct", config.OutboundConfigs[0].Tag) + assert.Equal(t, &conf.Config{OutboundConfigs: config.OutboundConfigs}, config) +} + 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") } +func TestConvertShareLinksToXrayJson_FiltersBuildInvalidOutbounds(t *testing.T) { + links := "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid-id.example:443?encryption=none&security=tls&sni=invalid-id.example&fp=chrome\n" + + "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 := convertShareLinksForTest(links) + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) + assert.Equal(t, "Valid", config.OutboundConfigs[0].Tag) + assert.Nil(t, config.OutboundConfigs[0].SendThrough) +} + +func TestConvertShareLinksToXrayJson_AllBuildInvalidOutbounds(t *testing.T) { + _, 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.EqualError(t, err, "no valid outbound found") +} + func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { lines := "trojan://secret@trojan.example.com:443?sni=trojan.example.com\n" + - "ss://" + ssUserB64("aes-128-gcm", "pwd") + "@ss.example.com:8388#ssn" + "ss://" + ssUserB64("aes-128-gcm", "pwd") + "@ss.example.com:8388#ssn\n" + + "vmess://" + testShareUUID + "@vm.example:443?encryption=auto&type=ws#VMessAEAD" blob := base64.StdEncoding.EncodeToString([]byte(lines)) - cfg, err := ConvertShareLinksToXrayJson(blob) + cfg, err := convertShareLinksForTest(blob) require.NoError(t, err) - require.Len(t, cfg.OutboundConfigs, 2) + require.Len(t, cfg.OutboundConfigs, 3) assert.Equal(t, "trojan", cfg.OutboundConfigs[0].Protocol) assert.Equal(t, "shadowsocks", cfg.OutboundConfigs[1].Protocol) + assert.Equal(t, "vmess", cfg.OutboundConfigs[2].Protocol) } func TestConvertShareLinksToXrayJson_Base64URLSafeBlob(t *testing.T) { - inner := "vless://" + testShareUUID + "@v.example.com:443?encryption=none&security=none" + 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) @@ -207,7 +114,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] @@ -219,9 +126,40 @@ func TestConvertShareLinksToXrayJson_Shadowsocks(t *testing.T) { assert.Equal(t, uint16(8388), s.Port) } +func TestConvertShareLinksToXrayJson_ShadowsocksPlainUserInfo(t *testing.T) { + const password = "YctPZ6U7xPPcU+gp3u+0tx/tRizJN9K8y+uKlW2qjlI=" + link := "ss://2022-blake3-aes-256-gcm:" + + url.QueryEscape(password) + + "@192.168.100.1:8888#Example3" + + cfg, err := convertShareLinksForTest(link) + require.NoError(t, err) + require.Len(t, cfg.OutboundConfigs, 1) + + ob := cfg.OutboundConfigs[0] + assert.Equal(t, "shadowsocks", ob.Protocol) + var settings conf.ShadowsocksClientConfig + require.NoError(t, json.Unmarshal(*ob.Settings, &settings)) + assert.Equal(t, "2022-blake3-aes-256-gcm", settings.Cipher) + assert.Equal(t, password, settings.Password) + assert.Equal(t, uint16(8888), settings.Port) +} + +func TestParseShadowsocksUserInfo_PlainPercentEncoding(t *testing.T) { + link, err := url.Parse( + "ss://aes-128-gcm:p%40ss%3Aword%2Fwith%2Bsymbols@ss.example.com:8388", + ) + require.NoError(t, err) + + cipher, password, err := parseShadowsocksUserInfo(link.User) + require.NoError(t, err) + assert.Equal(t, "aes-128-gcm", cipher) + assert.Equal(t, "p@ss:word/with+symbols", password) +} + 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&insecure=1" - cfg, err := ConvertShareLinksToXrayJson(link) + 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 := convertShareLinksForTest(link) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) ss := cfg.OutboundConfigs[0].StreamSetting @@ -233,16 +171,16 @@ func TestConvertShareLinksToXrayJson_VlessWSAndTLS(t *testing.T) { require.NotNil(t, ss.TLSSettings) assert.Equal(t, "edge.example", ss.TLSSettings.ServerName) assert.Equal(t, "chrome", ss.TLSSettings.Fingerprint) - assert.True(t, ss.TLSSettings.AllowInsecure) + assert.Equal(t, "edge.example", ss.TLSSettings.VerifyPeerCertByName) require.NotNil(t, ss.TLSSettings.ALPN) assert.Contains(t, []string(*ss.TLSSettings.ALPN), "h2") } func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) { - pbk := "ZXYAbCdEfGhIjKlMnOpQrStUvWxYz0123456789ABCD" + pbk := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" link := "vless://" + testShareUUID + "@reality.example:443?encryption=none&security=reality&type=tcp&sni=reality.example&pbk=" + - pbk + "&sid=abcd&fp=qq&pqv=pqv1&spx=%2F" - cfg, err := ConvertShareLinksToXrayJson(link) + pbk + "&sid=abcd&fp=chrome&spx=%2F" + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting require.NotNil(t, ss) @@ -250,14 +188,13 @@ func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) { require.NotNil(t, ss.REALITYSettings) assert.Equal(t, pbk, ss.REALITYSettings.PublicKey) assert.Equal(t, "abcd", ss.REALITYSettings.ShortId) - assert.Equal(t, "qq", ss.REALITYSettings.Fingerprint) - assert.Equal(t, "pqv1", ss.REALITYSettings.Mldsa65Verify) + assert.Equal(t, "chrome", ss.REALITYSettings.Fingerprint) assert.Equal(t, "/", ss.REALITYSettings.SpiderX) } 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)) @@ -271,7 +208,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)) @@ -281,7 +218,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)) @@ -292,12 +229,12 @@ func TestConvertShareLinksToXrayJson_VmessPlainURL(t *testing.T) { require.NotNil(t, ss.RAWSettings) } -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) +func TestConvertShareLinksToXrayJson_VmessAEADWebSocketTLS(t *testing.T) { + link := "vmess://" + testShareUUID + "@vm.example:8443?encryption=auto&type=ws&host=ws.host&path=%2Fw&security=tls&sni=tls.sni&alpn=h2%2Ch3&fp=safari#VMessAEAD" + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) + assert.Equal(t, "VMessAEAD", 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) @@ -313,17 +250,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) - ss := cfg.OutboundConfigs[0].StreamSetting - require.NotNil(t, ss.KCPSettings) - require.NotNil(t, ss.KCPSettings.Seed) - assert.Equal(t, "myseed", *ss.KCPSettings.Seed) + 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) @@ -334,7 +268,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) @@ -346,35 +280,31 @@ 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) + assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.SplitHTTPSettings) assert.Equal(t, "stream-up", x.Mode) require.NotNil(t, x.Extra) }) } func TestConvertShareLinksToXrayJson_FinalMaskQuery(t *testing.T) { - fm := `{"udp":[{"type":"test-mask"}]}` + 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) require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "test-mask", ss.FinalMask.Udp[0].Type) -} - -func TestConvertShareLinksToXrayJson_Hysteria2InvalidHop(t *testing.T) { - _, err := ConvertShareLinksToXrayJson("hy2://auth@host:443?hop-interval=notint&sni=x.com") - require.Error(t, err) + assert.Equal(t, "noise", ss.FinalMask.Udp[0].Type) } 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) @@ -390,7 +320,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) @@ -398,75 +328,35 @@ 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) } -func TestConvertShareLinksToXrayJson_Base64EncodedClashYAML(t *testing.T) { - yaml := `proxies: - - name: clash-ss - type: ss - server: c.example - port: 8390 - cipher: aes-256-gcm - password: yamlpw` - cfg, err := ConvertShareLinksToXrayJson(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") } -func TestConvertShareLinksToXrayJson_RawClashYAML(t *testing.T) { - yaml := `proxies: - - name: clash-ss - type: ss - server: c.example - port: 8390 - cipher: aes-256-gcm - password: yamlpw` - cfg, err := ConvertShareLinksToXrayJson(yaml) - require.NoError(t, err) - require.Len(t, cfg.OutboundConfigs, 1) - assert.Equal(t, "shadowsocks", cfg.OutboundConfigs[0].Protocol) -} - -func TestConvertShareLinksToXrayJson_ClashYAMLNoValidOutbound(t *testing.T) { - yaml := `proxies: - - name: unsupported - type: unsupported - server: c.example - port: 8390` - _, err := ConvertShareLinksToXrayJson(yaml) - require.Error(t, err) - assert.Contains(t, err.Error(), "no valid outbound found") -} - -func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { +func TestConvertShareLinksToXrayJson_VmessAEADGrpcAndKcp(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) + link := "vmess://" + testShareUUID + "@grpc.host:443?type=grpc&serviceName=svcname&mode=multi#g" + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) gs := cfg.OutboundConfigs[0].StreamSetting.GRPCSettings require.NotNil(t, gs) @@ -475,20 +365,16 @@ 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) + link := "vmess://" + testShareUUID + "@kcp.host:8391?type=kcp#k" + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) - ks := cfg.OutboundConfigs[0].StreamSetting.KCPSettings - require.NotNil(t, ks) - require.NotNil(t, ks.Seed) - assert.Equal(t, "seedval", *ks.Seed) + assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.KCPSettings) }) } 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/share_fields_test.go b/share/share_fields_test.go new file mode 100644 index 00000000..417adb59 --- /dev/null +++ b/share/share_fields_test.go @@ -0,0 +1,193 @@ +package share + +import ( + "encoding/base64" + "encoding/json" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xtls/xray-core/infra/conf" +) + +func TestShareKCPFieldsRoundTrip(t *testing.T) { + for _, protocol := range []string{"vmess", "vless"} { + for _, params := range []string{"", "&mtu=1350", "&tti=30", "&mtu=1350&tti=30"} { + t.Run(protocol+params, func(t *testing.T) { + original := protocol + "://" + testShareUUID + "@kcp.example:443?type=kcp" + params + config, link := roundTripShareFields(t, original) + assert.Equal(t, "kcp", link.Query().Get("type")) + settings := config.OutboundConfigs[0].StreamSetting.KCPSettings + if params == "" { + assert.Nil(t, settings) + assert.NotContains(t, link.Query(), "mtu") + assert.NotContains(t, link.Query(), "tti") + return + } + require.NotNil(t, settings) + if strings.Contains(params, "mtu=") { + require.NotNil(t, settings.Mtu) + assert.EqualValues(t, 1350, *settings.Mtu) + assert.Equal(t, "1350", link.Query().Get("mtu")) + } else { + assert.Nil(t, settings.Mtu) + assert.NotContains(t, link.Query(), "mtu") + } + if strings.Contains(params, "tti=") { + require.NotNil(t, settings.Tti) + assert.EqualValues(t, 30, *settings.Tti) + assert.Equal(t, "30", link.Query().Get("tti")) + } else { + assert.Nil(t, settings.Tti) + assert.NotContains(t, link.Query(), "tti") + } + }) + } + } +} + +func TestShareRejectsInvalidTransportFields(t *testing.T) { + good := "vless://" + testShareUUID + "@valid.example:443" + for _, query := range []string{ + "type=kcp&mtu=abc", "type=kcp&mtu=-1", "type=kcp&mtu=4294967296", + "type=kcp&mtu=20", "type=kcp&tti=9", "type=kcp&tti=1001", "type=kcp&tti=1.5", + "type=grpc&mode=guna", "type=xhttp&extra=%7B", "type=xhttp&extra=123", + } { + t.Run(query, func(t *testing.T) { + bad := "vless://" + testShareUUID + "@invalid.example:443?" + query + _, err := ConvertShareLinksToXrayJson(bad, "") + require.Error(t, err) + config, err := convertShareLinksWithKeyForTest(bad+"\n"+good, "") + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) + assert.Contains(t, string(*config.OutboundConfigs[0].Settings), "valid.example") + }) + } +} + +func TestShareXHTTPExtraRoundTrip(t *testing.T) { + const extra = `{ + "headers":{"X-Test":"a + b / c"},"noGRPCHeader":true, + "downloadSettings":{"network":"xhttp","xhttpSettings":{"path":"/download","headers":{"X-Download":"yes"}}}, + "futureOption":{"number":9007199254740993,"empty":null,"enabled":false} + }` + for _, protocol := range []string{"vmess", "vless"} { + t.Run(protocol, func(t *testing.T) { + query := url.Values{ + "type": {"xhttp"}, "host": {"cdn.example"}, "path": {"/path with + space"}, + "mode": {"stream-up"}, "extra": {extra}, + } + config, link := roundTripShareFields(t, protocol+"://"+testShareUUID+"@xhttp.example:443?"+query.Encode()) + assert.JSONEq(t, extra, string(config.OutboundConfigs[0].StreamSetting.XHTTPSettings.Extra)) + assert.JSONEq(t, extra, link.Query().Get("extra")) + assert.Contains(t, link.Query().Get("extra"), "9007199254740993") + assert.Equal(t, query.Get("path"), link.Query().Get("path")) + assert.NotContains(t, link.RawQuery, "+", "query values must use percent-encoded spaces") + }) + } +} + +func TestShareFinalMaskRoundTrip(t *testing.T) { + // Core validation enables these when quicParams.debug is true. + t.Setenv("HYSTERIA_BBR_DEBUG", "") + t.Setenv("HYSTERIA_BRUTAL_DEBUG", "") + const fm = `{ + "tcp":null, + "udp":[{"type":"noise","settings":{"futureOption":{"enabled":false,"empty":null}}}], + "quicParams":{ + "congestion":"bbr","debug":true,"bbrProfile":"conservative", + "brutalUp":"50 mbps","brutalDown":"100 mbps","brutalDisableLossCompensation":true, + "initStreamReceiveWindow":65536,"maxStreamReceiveWindow":131072, + "initConnectionReceiveWindow":262144,"maxConnectionReceiveWindow":524288, + "maxIdleTimeout":45,"keepAlivePeriod":10,"disablePathMTUDiscovery":true, + "disableChromeParrot":true,"disableGSO":true,"maxIncomingStreams":32,"disableStatelessReset":true + } + }` + config, link := roundTripShareFields(t, "vless://"+testShareUUID+"@mask.example:443?fm="+url.QueryEscape(fm)) + projected, err := json.Marshal(config.OutboundConfigs[0].StreamSetting.FinalMask) + require.NoError(t, err) + assert.JSONEq(t, fm, string(projected)) + assert.JSONEq(t, fm, link.Query().Get("fm")) +} + +func TestShareSecurityFieldsRoundTrip(t *testing.T) { + for name, query := range map[string]url.Values{ + "tls": { + "security": {"tls"}, "fp": {"chrome"}, "sni": {"tls.example"}, "alpn": {"h2,http/1.1"}, + "ech": {"config+with/padding="}, "pcs": {strings.Repeat("01", 32) + "," + strings.Repeat("02", 32)}, + "vcn": {"one.example,two.example"}, + }, + "reality": { + "security": {"reality"}, "fp": {"chrome"}, "sni": {"reality.example"}, + "pbk": {base64.RawURLEncoding.EncodeToString(make([]byte, 32))}, "sid": {"abcd"}, + "pqv": {base64.RawURLEncoding.EncodeToString(make([]byte, 1952))}, "spx": {"/path?key=a + b"}, + "flow": {"xtls-rprx-vision"}, + }, + } { + t.Run(name, func(t *testing.T) { + _, link := roundTripShareFields(t, "vless://"+testShareUUID+"@security.example:443?"+query.Encode()) + for key, values := range query { + assert.Equal(t, values, link.Query()[key], key) + } + }) + } +} + +func TestShareDefaultSNIUsesRemoteHost(t *testing.T) { + for _, protocol := range []string{"vmess", "vless"} { + t.Run(protocol, func(t *testing.T) { + config, link := roundTripShareFields(t, protocol+"://"+testShareUUID+"@remote.example:443?type=ws&host=cdn.example&security=tls") + assert.Equal(t, "remote.example", config.OutboundConfigs[0].StreamSetting.TLSSettings.ServerName) + assert.Equal(t, "remote.example", link.Query().Get("sni")) + }) + } +} + +func TestGenerateShareTransportAliases(t *testing.T) { + for name, stream := range map[string]string{ + "tcp": `{"network":"tcp","tcpSettings":{"header":{"type":"http","request":{"path":["/a"],"headers":{"Host":["cdn.example"]}}}}}`, + "raw": `{"network":"raw","rawSettings":{"header":{"type":"http","request":{"path":["/a"],"headers":{"Host":["cdn.example"]}}}}}`, + "websocket": `{"network":"websocket","wsSettings":{"path":"/a","host":"cdn.example"}}`, + "splithttp": `{"network":"splithttp","splithttpSettings":{"path":"/a","host":"cdn.example","mode":"stream-up","extra":{"futureOption":true}}}`, + "method": `{"network":"ws","method":"tcp","rawSettings":{"header":{"type":"http","request":{"path":["/a"],"headers":{"Host":["cdn.example"]}}}}}`, + } { + t.Run(name, func(t *testing.T) { + input := `{"outbounds":[{"protocol":"vless","settings":{"address":"remote.example","port":443,"id":"` + testShareUUID + `","encryption":"none"},"streamSettings":` + stream + `}]}` + text, err := ConvertXrayJsonToShareLinks([]byte(input)) + require.NoError(t, err) + link, err := url.Parse(text) + require.NoError(t, err) + assert.Equal(t, "/a", link.Query().Get("path")) + assert.Equal(t, "cdn.example", link.Query().Get("host")) + if name == "tcp" || name == "raw" || name == "method" { + assert.Equal(t, "tcp", link.Query().Get("type")) + assert.Equal(t, "http", link.Query().Get("headerType")) + } + if name == "splithttp" { + assert.Equal(t, "xhttp", link.Query().Get("type")) + assert.JSONEq(t, `{"futureOption":true}`, link.Query().Get("extra")) + } + _, err = ConvertShareLinksToXrayJson(text, "") + require.NoError(t, err) + }) + } +} + +func roundTripShareFields(t *testing.T, original string) (*conf.Config, *url.URL) { + t.Helper() + raw, err := ConvertShareLinksToXrayJson(original+"#Field%20test", "") + require.NoError(t, err) + text, err := ConvertXrayJsonToShareLinks(raw) + require.NoError(t, err) + link, err := url.Parse(text) + require.NoError(t, err) + again, err := ConvertShareLinksToXrayJson(text, "") + require.NoError(t, err) + assert.JSONEq(t, string(raw), string(again)) + var config conf.Config + require.NoError(t, json.Unmarshal(raw, &config)) + require.Len(t, config.OutboundConfigs, 1) + return &config, link +} diff --git a/share/stream.go b/share/stream.go index b488f272..89852603 100644 --- a/share/stream.go +++ b/share/stream.go @@ -36,6 +36,9 @@ func (proxy xrayShareLink) parseSecurityFromURL(link *url.URL, streamSettings *c realitySettings.Fingerprint = fp sni := query.Get("sni") + if sni == "" && (link.Scheme == "vmess" || link.Scheme == "vless") { + sni = link.Hostname() + } tlsSettings.ServerName = sni realitySettings.ServerName = sni @@ -47,10 +50,6 @@ func (proxy xrayShareLink) parseSecurityFromURL(link *url.URL, streamSettings *c tlsSettings.ALPN = new(conf.StringList(strings.Split(alpn, ","))) } - if query.Get("insecure") == "1" { - tlsSettings.AllowInsecure = true - } - pbk := query.Get("pbk") realitySettings.Password = pbk realitySettings.PublicKey = pbk diff --git a/share/test_helpers_test.go b/share/test_helpers_test.go new file mode 100644 index 00000000..0d2f015e --- /dev/null +++ b/share/test_helpers_test.go @@ -0,0 +1,31 @@ +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) { + return parseShareCandidates(links, true) +} + +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); err != nil { + return nil, err + } + return &config, nil +} diff --git a/share/transport_build.go b/share/transport_build.go index 11f9d6ba..00d06c4d 100644 --- a/share/transport_build.go +++ b/share/transport_build.go @@ -2,25 +2,28 @@ package share import ( "encoding/json" + "fmt" "net/url" + "strconv" "strings" "github.com/xtls/xray-core/infra/conf" ) -// shareTransportFields is the normalized transport slice of v2rayN share links and VMess QR JSON. +// shareTransportFields holds transport parameters from share-link queries. type shareTransportFields struct { Network string HeaderType string Path string Host string - Seed string // KCP seed (URL query or VMess QR path-as-seed) + KCPMTU string + KCPTTI string GrpcAuthority string GrpcServiceName string - GrpcMultiMode bool + GrpcMode string XHTTPMode string - ExtraJSON string // SplitHTTP extra (URL only) - FMJSON string // serialized FinalMask (URL only) + ExtraJSON string // SplitHTTP extra + FMJSON string // serialized FinalMask } func transportFieldsFromURLQuery(q url.Values) shareTransportFields { @@ -28,43 +31,23 @@ func transportFieldsFromURLQuery(q url.Values) shareTransportFields { if network == "" { network = "raw" } - return shareTransportFields{ + fields := shareTransportFields{ Network: network, - HeaderType: q.Get("headerType"), Path: q.Get("path"), Host: q.Get("host"), - Seed: q.Get("seed"), + KCPMTU: q.Get("mtu"), + KCPTTI: q.Get("tti"), GrpcAuthority: q.Get("authority"), GrpcServiceName: q.Get("serviceName"), - GrpcMultiMode: q.Get("mode") == "multi", + GrpcMode: q.Get("mode"), XHTTPMode: q.Get("mode"), ExtraJSON: q.Get("extra"), FMJSON: q.Get("fm"), } -} - -func transportFieldsFromVmessQR(p vmessQrCode) shareTransportFields { - network := p.Net - if network == "" { - network = "raw" - } - t := shareTransportFields{ - Network: network, - HeaderType: p.Type, - Path: p.Path, - Host: p.Host, + if network == "raw" || network == "tcp" { + fields.HeaderType = q.Get("headerType") } - switch network { - case "grpc", "gun": - t.GrpcServiceName = p.Path - t.GrpcMultiMode = p.Type == "multi" - case "kcp", "mkcp": - t.Seed = p.Path - } - if network == "xhttp" || network == "splithttp" { - t.XHTTPMode = p.Type - } - return t + return fields } // buildStreamFromTransportFields builds StreamConfig from normalized share fields (no TLS). @@ -96,39 +79,36 @@ func buildStreamFromTransportFields(t shareTransportFields) (*conf.StreamConfig, streamSettings.RAWSettings = rawSettings } case "kcp", "mkcp": - kcpSettings := &conf.KCPConfig{} - if t.HeaderType != "" { - header := XrayFakeHeader{Type: t.HeaderType} - headerRawMessage, err := convertJsonToRawMessage(header) + if t.KCPMTU != "" || t.KCPTTI != "" { + mtu, err := parseKCPParameter(t.KCPMTU) if err != nil { return nil, err } - kcpSettings.HeaderConfig = headerRawMessage + tti, err := parseKCPParameter(t.KCPTTI) + if err != nil { + return nil, err + } + streamSettings.KCPSettings = &conf.KCPConfig{Mtu: mtu, Tti: tti} } - kcpSettings.Seed = new(t.Seed) - streamSettings.KCPSettings = kcpSettings case "ws", "websocket": streamSettings.WSSettings = &conf.WebSocketConfig{Path: t.Path, Host: t.Host} case "grpc", "gun": + if t.GrpcMode != "" && t.GrpcMode != "gun" && t.GrpcMode != "multi" { + return nil, fmt.Errorf("unsupported gRPC share mode %q", t.GrpcMode) + } streamSettings.GRPCSettings = &conf.GRPCConfig{ Authority: t.GrpcAuthority, ServiceName: t.GrpcServiceName, - MultiMode: t.GrpcMultiMode, + MultiMode: t.GrpcMode == "multi", } case "httpupgrade": streamSettings.HTTPUPGRADESettings = &conf.HttpUpgradeConfig{Host: t.Host, Path: t.Path} case "xhttp", "splithttp": xhttpSettings := &conf.SplitHTTPConfig{Host: t.Host, Path: t.Path, Mode: t.XHTTPMode} if t.ExtraJSON != "" { - var extraConfig *conf.SplitHTTPConfig - if err := json.Unmarshal([]byte(t.ExtraJSON), &extraConfig); err != nil { + if err := json.Unmarshal([]byte(t.ExtraJSON), &xhttpSettings.Extra); err != nil { return nil, err } - extraRawMessage, err := convertJsonToRawMessage(extraConfig) - if err != nil { - return nil, err - } - xhttpSettings.Extra = extraRawMessage } streamSettings.XHTTPSettings = xhttpSettings } @@ -143,3 +123,14 @@ func buildStreamFromTransportFields(t shareTransportFields) (*conf.StreamConfig, return streamSettings, nil } + +func parseKCPParameter(value string) (*uint32, error) { + if value == "" { + return nil, nil + } + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return nil, err + } + return new(uint32(parsed)), nil +} diff --git a/share/validate_outbound.go b/share/validate_outbound.go new file mode 100644 index 00000000..d0fa295f --- /dev/null +++ b/share/validate_outbound.go @@ -0,0 +1,76 @@ +package share + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + + "github.com/xtls/xray-core/infra/conf" +) + +func filterBuildableOutbounds(config *conf.Config) (*conf.Config, error) { + raw, err := json.Marshal(config.OutboundConfigs) + if err != nil { + return nil, fmt.Errorf("failed to copy outbounds for validation: %w", err) + } + + var validationOutbounds []conf.OutboundDetourConfig + if err := json.Unmarshal(raw, &validationOutbounds); err != nil { + return nil, fmt.Errorf("failed to copy outbounds for validation: %w", err) + } + restoreNilRawMessages(reflect.ValueOf(&validationOutbounds)) + + validOutbounds := make([]conf.OutboundDetourConfig, 0, len(config.OutboundConfigs)) + for index := range validationOutbounds { + if _, err := validationOutbounds[index].Build(); err != nil { + continue + } + validOutbounds = append(validOutbounds, config.OutboundConfigs[index]) + } + if len(validOutbounds) == 0 { + return nil, fmt.Errorf("no valid outbound found") + } + + config.OutboundConfigs = validOutbounds + return config, nil +} + +var rawMessageType = reflect.TypeOf(json.RawMessage{}) + +func restoreNilRawMessages(value reflect.Value) { + if !value.IsValid() { + return + } + if value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface { + if !value.IsNil() { + restoreNilRawMessages(value.Elem()) + } + return + } + if value.Type() == rawMessageType { + if value.CanSet() && bytes.Equal(bytes.TrimSpace(value.Bytes()), []byte("null")) { + value.SetZero() + } + return + } + + switch value.Kind() { + case reflect.Struct: + for index := range value.NumField() { + restoreNilRawMessages(value.Field(index)) + } + case reflect.Slice, reflect.Array: + for index := range value.Len() { + restoreNilRawMessages(value.Index(index)) + } + case reflect.Map: + iterator := value.MapRange() + for iterator.Next() { + item := reflect.New(iterator.Value().Type()).Elem() + item.Set(iterator.Value()) + restoreNilRawMessages(item) + value.SetMapIndex(iterator.Key(), item) + } + } +} diff --git a/share/vmess.go b/share/vmess.go deleted file mode 100644 index 18becd9c..00000000 --- a/share/vmess.go +++ /dev/null @@ -1,99 +0,0 @@ -package share - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/xtls/xray-core/infra/conf" -) - -// https://github.com/2dust/v2rayN/wiki/%E5%88%86%E4%BA%AB%E9%93%BE%E6%8E%A5%E6%A0%BC%E5%BC%8F%E8%AF%B4%E6%98%8E(ver-2) -type vmessQrCode struct { - Ps string `json:"ps,omitempty"` - Add string `json:"add,omitempty"` - Port interface{} `json:"port,omitempty"` - Id string `json:"id,omitempty"` - Scy string `json:"scy,omitempty"` - Net string `json:"net,omitempty"` - Type string `json:"type,omitempty"` - Host string `json:"host,omitempty"` - Path string `json:"path,omitempty"` - Tls string `json:"tls,omitempty"` - Sni string `json:"sni,omitempty"` - Alpn string `json:"alpn,omitempty"` - Fp string `json:"fp,omitempty"` -} - -func parseVMessQrCode(text string) (*conf.OutboundDetourConfig, error) { - var qrcode vmessQrCode - if err := json.Unmarshal([]byte(text), &qrcode); err != nil { - return nil, err - } - return qrcode.outbound() -} - -func (proxy vmessQrCode) outbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "vmess" - setOutboundName(outbound, proxy.Ps) - - settings := conf.VMessOutboundConfig{} - settings.Address = parseAddress(proxy.Add) - portStr := fmt.Sprintf("%v", proxy.Port) - port, err := strconv.Atoi(portStr) - if err != nil { - return nil, err - } - settings.Port = uint16(port) - settings.ID = proxy.Id - settings.Security = proxy.Scy - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := buildStreamFromTransportFields(transportFieldsFromVmessQR(proxy)) - if err != nil { - return nil, err - } - if err := proxy.parseSecurity(streamSettings); err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - return outbound, nil -} - -func (proxy vmessQrCode) parseSecurity(streamSettings *conf.StreamConfig) error { - tlsSettings := &conf.TLSConfig{} - tlsSettings.Fingerprint = proxy.Fp - tlsSettings.ServerName = proxy.Sni - if proxy.Alpn != "" { - tlsSettings.ALPN = new(conf.StringList(strings.Split(proxy.Alpn, ","))) - } - - if proxy.Tls == "" { - streamSettings.Security = "none" - } else { - streamSettings.Security = proxy.Tls - } - - network, err := streamSettings.Network.Build() - if err != nil { - return err - } - // some link omits too many params, here is some fixing - if network == "websocket" && tlsSettings.ServerName == "" && - streamSettings.WSSettings != nil && streamSettings.WSSettings.Host != "" { - tlsSettings.ServerName = streamSettings.WSSettings.Host - } - - switch streamSettings.Security { - case "tls": - streamSettings.TLSSettings = tlsSettings - } - return nil -} diff --git a/share/xray_json.go b/share/xray_json.go index fd29bbd0..449c7c42 100644 --- a/share/xray_json.go +++ b/share/xray_json.go @@ -21,20 +21,11 @@ type XrayRawSettingsHeaderRequestHeaders struct { Host []string `json:"Host,omitempty"` } -type XrayFakeHeader struct { - Type string `json:"type,omitempty"` -} - 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/tools.go b/tools.go new file mode 100644 index 00000000..65da148b --- /dev/null +++ b/tools.go @@ -0,0 +1,10 @@ +//go:build tools +// +build tools + +// See https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module +package tools + +import ( + _ "golang.org/x/mobile/cmd/gobind" + _ "golang.org/x/mobile/cmd/gomobile" +) diff --git a/xray/instance_test.go b/xray/instance_test.go new file mode 100644 index 00000000..0924de7d --- /dev/null +++ b/xray/instance_test.go @@ -0,0 +1,32 @@ +package xray + +import "testing" + +// Instance is read by the failover and the probe while the tunnel starts and +// stops. Run with -race (validate.yml does): an unlocked read of the core +// fails here. +func TestInstanceRacesRunAndStop(t *testing.T) { + const config = `{"log": {"loglevel": "none"}, "outbounds": [{"protocol": "freedom"}]}` + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 20; i++ { + if err := RunXray(config); err != nil { + t.Error(err) + return + } + if err := StopXray(); err != nil { + t.Error(err) + return + } + } + }() + for { + select { + case <-done: + return + default: + _ = Instance() + } + } +} diff --git a/xray/ping.go b/xray/ping.go deleted file mode 100644 index 0aaaae07..00000000 --- a/xray/ping.go +++ /dev/null @@ -1,29 +0,0 @@ -package xray - -import ( - "github.com/xtls/libxray/nodep" -) - -// Ping Xray config and find the delay and country code of its outbound. -// configPath means the config.json file path. -// timeout means how long the http request will be cancelled if no response, in units of seconds. -// url means the website we use to test speed. "https://www.google.com" is a good choice for most cases. -// proxy means the local http/socks5 proxy, like "socks5://[::1]:1080". -func Ping(configPath string, timeout int, url string, proxy string) (int64, error) { - server, err := StartXray(configPath) - if err != nil { - return nodep.PingDelayError, err - } - - if err := server.Start(); err != nil { - return nodep.PingDelayError, err - } - defer server.Close() - - delay, err := nodep.MeasureDelay(timeout, url, proxy) - if err != nil { - return delay, err - } - - return delay, nil -} diff --git a/xray/ping_batch.go b/xray/ping_batch.go new file mode 100644 index 00000000..c663598c --- /dev/null +++ b/xray/ping_batch.go @@ -0,0 +1,399 @@ +package xray + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/xtls/libxray/nodep" + xrayNet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/common/session" + "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/infra/conf" + confJSON "github.com/xtls/xray-core/infra/conf/json" +) + +const ( + maxPingBatchConfigs = 5 +) + +type PingBatchItem struct { + XrayJSON string + OutboundTag string +} + +type PingBatchResult struct { + Success bool + Delay int64 + Error string +} + +type pingOutboundConfig struct { + Outbounds []conf.OutboundDetourConfig `json:"outbounds"` +} + +type preparedPingItem struct { + resultIndex int + outboundTag string +} + +func PingBatch( + items []PingBatchItem, + timeout int, + targetURL string, +) ([]PingBatchResult, error) { + if err := validatePingBatchRequest(items, timeout, targetURL); err != nil { + return nil, err + } + 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)) + mergedOutbounds := make([]conf.OutboundDetourConfig, 0, len(items)) + + for index, item := range items { + outbounds, err := readPingOutbounds(item.XrayJSON) + if err != nil { + results[index] = failedPingBatchResult(nodep.PingDelayError, err) + continue + } + + namespaced, outboundTag, err := preparePingOutbounds( + outbounds, + item.OutboundTag, + index, + ) + if err != nil { + results[index] = failedPingBatchResult(nodep.PingDelayError, err) + continue + } + prepared = append(prepared, preparedPingItem{ + resultIndex: index, + outboundTag: outboundTag, + }) + mergedOutbounds = append(mergedOutbounds, namespaced...) + } + + if len(prepared) == 0 { + return results, nil + } + + server, err := startPingBatchServer(mergedOutbounds) + if err != nil { + return nil, err + } + defer server.Close() + + workerCount := len(prepared) + jobs := make(chan preparedPingItem) + var workers sync.WaitGroup + workers.Add(workerCount) + for range workerCount { + go func() { + defer workers.Done() + for item := range jobs { + results[item.resultIndex] = probeOutbound( + server, + item.outboundTag, + timeout, + targetURL, + ) + } + }() + } + + for _, item := range prepared { + jobs <- item + } + close(jobs) + workers.Wait() + + return results, nil +} + +func validatePingBatchRequest( + items []PingBatchItem, + timeout int, + targetURL string, +) error { + if len(items) == 0 { + return errors.New("ping batch configs are empty") + } + if len(items) > maxPingBatchConfigs { + return fmt.Errorf( + "ping batch contains more than %d configs", + maxPingBatchConfigs, + ) + } + if timeout <= 0 { + return errors.New("ping batch timeout must be greater than zero") + } + + parsedURL, err := url.ParseRequestURI(targetURL) + if err != nil || parsedURL.Host == "" || + (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { + return errors.New("ping batch URL must be an absolute HTTP or HTTPS URL") + } + return nil +} + +func readPingOutbounds(xrayJSON string) ([]conf.OutboundDetourConfig, error) { + if xrayJSON == "" { + return nil, errors.New("ping Xray JSON is empty") + } + + var config pingOutboundConfig + decoder := json.NewDecoder(&confJSON.Reader{Reader: strings.NewReader(xrayJSON)}) + if err := decoder.Decode(&config); err != nil { + return nil, err + } + if err := ensureJSONEOF(decoder); err != nil { + return nil, err + } + if len(config.Outbounds) == 0 { + return nil, errors.New("ping config has no outbounds") + } + return config.Outbounds, nil +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err == nil { + return errors.New("ping config contains multiple JSON values") + } else if !errors.Is(err, io.EOF) { + return err + } + return nil +} + +func preparePingOutbounds( + outbounds []conf.OutboundDetourConfig, + requestedTag string, + itemIndex int, +) ([]conf.OutboundDetourConfig, string, error) { + tagIndexes := make(map[string]int, len(outbounds)) + duplicateTags := make(map[string]struct{}) + for index, outbound := range outbounds { + if outbound.Tag == "" { + continue + } + if _, found := tagIndexes[outbound.Tag]; found { + duplicateTags[outbound.Tag] = struct{}{} + continue + } + tagIndexes[outbound.Tag] = index + } + + targetIndex := 0 + if requestedTag != "" { + if _, duplicate := duplicateTags[requestedTag]; duplicate { + return nil, "", fmt.Errorf("duplicate outbound tag %q", requestedTag) + } + var found bool + targetIndex, found = tagIndexes[requestedTag] + if !found { + return nil, "", fmt.Errorf("outbound tag %q not found", requestedTag) + } + } else if proxyIndex, found := tagIndexes["proxy"]; found { + if _, duplicate := duplicateTags["proxy"]; duplicate { + return nil, "", errors.New(`duplicate outbound tag "proxy"`) + } + targetIndex = proxyIndex + } + + selectedIndexes, err := collectPingOutboundDependencies( + outbounds, + tagIndexes, + duplicateTags, + targetIndex, + ) + if err != nil { + return nil, "", err + } + + namespacedTags := make(map[int]string, len(selectedIndexes)) + for order, index := range selectedIndexes { + namespacedTags[index] = fmt.Sprintf( + "__libxray_ping_%d_%d", + itemIndex, + order, + ) + } + + prepared := make([]conf.OutboundDetourConfig, 0, len(selectedIndexes)) + for _, index := range selectedIndexes { + outbound := outbounds[index] + outbound.Tag = namespacedTags[index] + + if outbound.StreamSetting != nil && + outbound.StreamSetting.SocketSettings != nil { + dialerProxy := outbound.StreamSetting.SocketSettings.DialerProxy + if dialerProxy != "" { + dependencyIndex := tagIndexes[dialerProxy] + outbound.StreamSetting.SocketSettings.DialerProxy = + namespacedTags[dependencyIndex] + } + } + + if _, err := outbound.Build(); err != nil { + return nil, "", fmt.Errorf( + "failed to build outbound %q: %w", + outbounds[index].Tag, + err, + ) + } + prepared = append(prepared, outbound) + } + return prepared, namespacedTags[targetIndex], nil +} + +func collectPingOutboundDependencies( + outbounds []conf.OutboundDetourConfig, + tagIndexes map[string]int, + duplicateTags map[string]struct{}, + targetIndex int, +) ([]int, error) { + const ( + unvisited = iota + visiting + visited + ) + states := make([]int, len(outbounds)) + selected := make([]int, 0, len(outbounds)) + + var visit func(int) error + visit = func(index int) error { + switch states[index] { + case visiting: + return fmt.Errorf( + "outbound dependency cycle detected at tag %q", + outbounds[index].Tag, + ) + case visited: + return nil + } + states[index] = visiting + selected = append(selected, index) + + for _, dependencyTag := range pingOutboundDependencyTags(outbounds[index]) { + if _, duplicate := duplicateTags[dependencyTag]; duplicate { + return fmt.Errorf( + "outbound %q depends on duplicate outbound tag %q", + outbounds[index].Tag, + dependencyTag, + ) + } + dependencyIndex, found := tagIndexes[dependencyTag] + if !found { + return fmt.Errorf( + "outbound %q depends on missing outbound %q", + outbounds[index].Tag, + dependencyTag, + ) + } + if err := visit(dependencyIndex); err != nil { + return err + } + } + states[index] = visited + return nil + } + + if err := visit(targetIndex); err != nil { + return nil, err + } + return selected, nil +} + +func pingOutboundDependencyTags(outbound conf.OutboundDetourConfig) []string { + dependencies := make([]string, 0, 1) + if outbound.StreamSetting != nil && + outbound.StreamSetting.SocketSettings != nil && + outbound.StreamSetting.SocketSettings.DialerProxy != "" { + dependencies = append( + dependencies, + outbound.StreamSetting.SocketSettings.DialerProxy, + ) + } + return dependencies +} + +func startPingBatchServer( + outbounds []conf.OutboundDetourConfig, +) (*core.Instance, error) { + config, err := (&conf.Config{OutboundConfigs: outbounds}).Build() + if err != nil { + return nil, fmt.Errorf("failed to build ping batch config: %w", err) + } + server, err := core.New(config) + if err != nil { + return nil, fmt.Errorf("failed to create ping batch instance: %w", err) + } + if err := server.Start(); err != nil { + _ = server.Close() + return nil, fmt.Errorf("failed to start ping batch instance: %w", err) + } + return server, nil +} + +func probeOutbound( + server *core.Instance, + outboundTag string, + timeout int, + targetURL string, +) PingBatchResult { + httpTimeout := time.Second * time.Duration(timeout) + transport := &http.Transport{ + DisableKeepAlives: true, + DialContext: func( + ctx context.Context, + network string, + address string, + ) (net.Conn, error) { + if network != "tcp" && network != "tcp4" && network != "tcp6" { + return nil, fmt.Errorf("unsupported ping network %q", network) + } + destination, err := xrayNet.ParseDestination("tcp:" + address) + if err != nil { + return nil, err + } + ctx = session.SetForcedOutboundTagToContext(ctx, outboundTag) + return core.Dial(ctx, server, destination) + }, + } + defer transport.CloseIdleConnections() + + client := &http.Client{ + Transport: transport, + Timeout: httpTimeout, + } + delay, err := nodep.PingHTTPRequest(client, targetURL, timeout) + result := PingBatchResult{Success: true, Delay: delay} + if err != nil { + result = failedPingBatchResult(delay, err) + } + return result +} + +func failedPingBatchResult(delay int64, err error) PingBatchResult { + if delay != nodep.PingDelayTimeout { + delay = nodep.PingDelayError + } + return PingBatchResult{ + Success: false, + Delay: delay, + Error: err.Error(), + } +} diff --git a/xray/ping_batch_test.go b/xray/ping_batch_test.go new file mode 100644 index 00000000..40c16ece --- /dev/null +++ b/xray/ping_batch_test.go @@ -0,0 +1,335 @@ +package xray + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/xtls/xray-core/infra/conf" +) + +func TestReadPingOutboundsIgnoresOtherRootFields(t *testing.T) { + xrayJSON := `{ + // These fields are deliberately invalid for the full Xray schema. + "inbounds": "ignored", + "routing": 42, + "dns": false, + "outbounds": [ + {"protocol": "freedom", "tag": "proxy"} + ] + }` + + outbounds, err := readPingOutbounds(xrayJSON) + if err != nil { + t.Fatal(err) + } + if len(outbounds) != 1 { + t.Fatalf("outbounds = %d, want 1", len(outbounds)) + } + if outbounds[0].Tag != "proxy" { + t.Fatalf("tag = %q, want proxy", outbounds[0].Tag) + } +} + +func TestPreparePingOutboundsPreservesRealityClientConfig(t *testing.T) { + xrayJSON := `{ + "outbounds": [{ + "tag": "proxy", + "protocol": "vless", + "settings": { + "address": "example.com", + "port": 443, + "id": "41b20a0c-b56f-4bb8-97e1-e5ceb0ab1ba4", + "encryption": "none" + }, + "streamSettings": { + "network": "xhttp", + "xhttpSettings": { + "host": "example.com", + "path": "/xhttp", + "mode": "auto" + }, + "security": "reality", + "realitySettings": { + "fingerprint": "chrome", + "serverName": "example.com", + "password": "itSUyWpOsw4n3_Rc-hK0r_ryzTBGRqHdrPJI-OFuAwM" + } + } + }] + }` + + outbounds, err := readPingOutbounds(xrayJSON) + if err != nil { + t.Fatal(err) + } + prepared, targetTag, err := preparePingOutbounds(outbounds, "proxy", 0) + if err != nil { + t.Fatal(err) + } + if len(prepared) != 1 { + t.Fatalf("outbounds = %d, want 1", len(prepared)) + } + if targetTag != "__libxray_ping_0_0" { + t.Fatalf("target tag = %q", targetTag) + } +} + +func TestPreparePingOutboundsSelectsAndRewritesDependencies(t *testing.T) { + outbounds := []conf.OutboundDetourConfig{ + { + Protocol: "blackhole", + Tag: "unused", + }, + { + Protocol: "not-a-protocol", + Tag: "unused", + }, + { + Protocol: "freedom", + Tag: "proxy", + StreamSetting: &conf.StreamConfig{ + SocketSettings: &conf.SocketConfig{DialerProxy: "fragment"}, + }, + }, + { + Protocol: "freedom", + Tag: "fragment", + }, + } + + prepared, targetTag, err := preparePingOutbounds(outbounds, "", 7) + if err != nil { + t.Fatal(err) + } + if len(prepared) != 2 { + t.Fatalf("prepared outbounds = %d, want 2", len(prepared)) + } + if targetTag != "__libxray_ping_7_0" { + t.Fatalf("target tag = %q", targetTag) + } + if prepared[0].Tag != targetTag { + t.Fatalf("root tag = %q, want %q", prepared[0].Tag, targetTag) + } + if prepared[1].Tag != "__libxray_ping_7_1" { + t.Fatalf("dependency tag = %q", prepared[1].Tag) + } + if got := prepared[0].StreamSetting.SocketSettings.DialerProxy; got != prepared[1].Tag { + t.Fatalf("dialerProxy = %q, want %q", got, prepared[1].Tag) + } + for _, outbound := range prepared { + if outbound.Tag == "unused" { + t.Fatal("unrelated outbound was included") + } + } +} + +func TestPreparePingOutboundsRejectsRemovedProxySettings(t *testing.T) { + outbounds, err := readPingOutbounds(`{"outbounds":[ + {"protocol":"freedom","tag":"proxy","proxySettings":{"tag":"transport"}}, + {"protocol":"freedom","tag":"transport"} + ]}`) + if err != nil { + t.Fatal(err) + } + _, _, err = preparePingOutbounds(outbounds, "proxy", 1) + if err == nil || !strings.Contains(err.Error(), "proxySettings") { + t.Fatalf("expected Xray-core to reject proxySettings, got %v", err) + } +} + +func TestPreparePingOutboundsRejectsDependencyCycle(t *testing.T) { + outbounds := []conf.OutboundDetourConfig{ + { + Protocol: "freedom", + Tag: "proxy", + StreamSetting: &conf.StreamConfig{ + SocketSettings: &conf.SocketConfig{DialerProxy: "next"}, + }, + }, + { + Protocol: "freedom", + Tag: "next", + StreamSetting: &conf.StreamConfig{ + SocketSettings: &conf.SocketConfig{DialerProxy: "proxy"}, + }, + }, + } + + _, _, err := preparePingOutbounds(outbounds, "", 0) + if err == nil || !strings.Contains(err.Error(), "cycle") { + t.Fatalf("error = %v, want dependency cycle", err) + } +} + +func TestPingBatchRunsRequestsConcurrently(t *testing.T) { + requestStarted := make(chan struct{}, 2) + releaseRequests := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func( + response http.ResponseWriter, + request *http.Request, + ) { + requestStarted <- struct{}{} + <-releaseRequests + response.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + config := `{"outbounds":[{"protocol":"freedom","tag":"proxy"}]}` + type batchResult struct { + results []PingBatchResult + err error + } + done := make(chan batchResult, 1) + go func() { + results, err := PingBatch( + []PingBatchItem{ + {XrayJSON: config}, + {XrayJSON: config}, + }, + 2, + server.URL, + ) + done <- batchResult{results: results, err: err} + }() + + for range 2 { + select { + case <-requestStarted: + case <-time.After(2 * time.Second): + t.Fatal("requests did not run concurrently") + } + } + close(releaseRequests) + + result := <-done + if result.err != nil { + t.Fatal(result.err) + } + if len(result.results) != 2 { + t.Fatalf("results = %d, want 2", len(result.results)) + } + for index := range result.results { + if !result.results[index].Success { + t.Fatalf( + "result %d failed: %s", + index, + result.results[index].Error, + ) + } + } +} + +func TestPingBatchKeepsPerItemConfigErrorsInInputOrder(t *testing.T) { + results, err := PingBatch( + []PingBatchItem{ + {XrayJSON: "not JSON"}, + {XrayJSON: `{"outbounds":[]}`}, + }, + 1, + "https://example.com", + ) + if err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Fatalf("results = %d, want 2", len(results)) + } + for index, result := range results { + if result.Success { + t.Fatalf("result %d unexpectedly succeeded", index) + } + if result.Delay != 10000 { + t.Fatalf("result %d delay = %d, want 10000", index, result.Delay) + } + if result.Error == "" { + t.Fatalf("result %d has no error", index) + } + } + if results[0].Error == "" { + t.Fatal("first result should contain a JSON parsing error") + } + if results[1].Error != "ping config has no outbounds" { + t.Fatalf("second result error = %q, want empty outbounds error", results[1].Error) + } +} + +func TestValidatePingBatchRequest(t *testing.T) { + tests := []struct { + name string + items []PingBatchItem + timeout int + targetURL string + errorText string + }{ + { + name: "empty configs", + timeout: 1, + targetURL: "https://example.com", + errorText: "configs are empty", + }, + { + name: "invalid URL", + items: []PingBatchItem{{}}, + timeout: 1, + targetURL: "example.com", + errorText: "absolute HTTP", + }, + { + name: "too many configs", + items: []PingBatchItem{ + {}, + {}, + {}, + {}, + {}, + {}, + }, + timeout: 1, + targetURL: "https://example.com", + errorText: "more than 5 configs", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validatePingBatchRequest( + test.items, + test.timeout, + test.targetURL, + ) + if err == nil || !strings.Contains(err.Error(), test.errorText) { + t.Fatalf("error = %v, want %q", err, test.errorText) + } + }) + } +} + +func TestValidatePingBatchRequestAcceptsFiveConfigs(t *testing.T) { + err := validatePingBatchRequest( + []PingBatchItem{ + {}, + {}, + {}, + {}, + {}, + }, + 1, + "https://example.com", + ) + if err != nil { + t.Fatal(err) + } +} + +func ExamplePingBatch() { + results, _ := PingBatch( + []PingBatchItem{{XrayJSON: `{"outbounds":[{"protocol":"freedom"}]}`}}, + 5, + "https://cp.cloudflare.com/", + ) + fmt.Println(len(results)) +} diff --git a/xray/validation.go b/xray/validation.go index f7e2c1e1..cd84efd5 100644 --- a/xray/validation.go +++ b/xray/validation.go @@ -1,15 +1,21 @@ package xray -// Test Xray Config. -// configPath means the config.json file path. -func TestXray(configPath string) error { - server, err := StartXray(configPath) - if err != nil { - return err +import ( + "errors" +) + +// TestXray constructs and closes an instance without calling Start. +// Constructors may change process state and acquire resources. Callers own any +// configuration projection; startup resources and connectivity are not tested. +func TestXray(xrayJSON string) error { + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return errors.New("testXray requires an isolated process without a managed Xray instance") } - err = server.Close() + server, err := newXrayInstance(xrayJSON) if err != nil { return err } - return nil + return server.Close() } diff --git a/xray/validation_test.go b/xray/validation_test.go new file mode 100644 index 00000000..749d9584 --- /dev/null +++ b/xray/validation_test.go @@ -0,0 +1,53 @@ +package xray + +import ( + "fmt" + "net" + "strings" + "testing" + + "github.com/xtls/xray-core/core" +) + +func TestTestXrayRejectsConstructionErrors(t *testing.T) { + for name, rule := range map[string]string{ + "missing balancer": `{"domain":["example.com"],"balancerTag":"missing"}`, + "invalid regexp": `{"domain":["regexp:["],"outboundTag":"direct"}`, + } { + t.Run(name, func(t *testing.T) { + config := `{"log":{"loglevel":"none"},"outbounds":[{"protocol":"freedom","tag":"direct"}],"routing":{"rules":[` + rule + `]}}` + if _, err := core.LoadConfig("json", strings.NewReader(config)); err != nil { + t.Fatalf("fixture must pass the old build-only check: %v", err) + } + if err := TestXray(config); err == nil { + t.Fatal("construction error was accepted") + } + if GetXrayState() { + t.Fatal("validation published a managed instance") + } + }) + } +} + +func TestTestXrayDoesNotBindPorts(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + port := listener.Addr().(*net.TCPAddr).Port + config := fmt.Sprintf(`{ + "log":{"loglevel":"none"}, + "stats":{},"metrics":{"listen":"127.0.0.1:%d"}, + "inbounds":[{"listen":"127.0.0.1","port":%d,"protocol":"socks"}], + "outbounds":[{"protocol":"freedom","tag":"direct"}] + }`, port, port) + for range 2 { + if err := TestXray(config); err != nil { + t.Fatalf("validation must not start listeners: %v", err) + } + if GetXrayState() { + t.Fatal("validation left a managed instance") + } + } +} diff --git a/xray/xray.go b/xray/xray.go index d266ec12..80534886 100644 --- a/xray/xray.go +++ b/xray/xray.go @@ -1,39 +1,30 @@ package xray import ( + "errors" "runtime/debug" + "strings" + "sync" "github.com/xtls/libxray/memory" - "github.com/xtls/xray-core/common/cmdarg" "github.com/xtls/xray-core/core" _ "github.com/xtls/xray-core/main/distro/all" ) var ( - coreServer *core.Instance + coreServerMu sync.Mutex + coreServer *core.Instance ) -func StartXray(configPath string) (*core.Instance, error) { - file := cmdarg.Arg{configPath} - config, err := core.LoadConfig("json", file) - if err != nil { - return nil, err - } +var ErrAlreadyRunning = errors.New("xray is already running") - server, err := core.New(config) +func newXrayInstance(xrayJSON string) (*core.Instance, error) { + config, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) if err != nil { return nil, err } - return server, nil -} - -func StartXrayFromJSON(configJSON string) (*core.Instance, error) { - // Convert JSON string to bytes - configBytes := []byte(configJSON) - - // Use core.StartInstance which can load configuration directly from bytes - server, err := core.StartInstance("json", configBytes) + server, err := core.New(config) if err != nil { return nil, err } @@ -42,30 +33,23 @@ func StartXrayFromJSON(configJSON string) (*core.Instance, error) { } // Run Xray instance. -// configPath means the config.json file path. -func RunXray(configPath string) (err error) { - memory.InitForceFree() - coreServer, err = StartXray(configPath) - if err != nil { - return - } - - if err = coreServer.Start(); err != nil { - return +// xrayJSON is the serialized Xray JSON configuration. +func RunXray(xrayJSON string) error { + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return ErrAlreadyRunning } - - debug.FreeOSMemory() - return nil -} - -// Run Xray instance with JSON configuration string. -// configJSON means the JSON configuration string. -func RunXrayFromJSON(configJSON string) (err error) { memory.InitForceFree() - coreServer, err = StartXrayFromJSON(configJSON) + server, err := newXrayInstance(xrayJSON) if err != nil { - return + return err + } + if err = server.Start(); err != nil { + _ = server.Close() + return err } + coreServer = server debug.FreeOSMemory() return nil @@ -73,16 +57,25 @@ func RunXrayFromJSON(configJSON string) (err error) { // Get Xray State func GetXrayState() bool { + coreServerMu.Lock() + defer coreServerMu.Unlock() return coreServer != nil && coreServer.IsRunning() } // Instance is the running core, or nil — for components that attach to it. +// RunXray holds coreServerMu across the core's construction and start, so +// this must never be reached from code the core runs while starting (log +// handlers, dialer or observatory callbacks). func Instance() *core.Instance { + coreServerMu.Lock() + defer coreServerMu.Unlock() return coreServer } // Stop Xray instance. func StopXray() error { + coreServerMu.Lock() + defer coreServerMu.Unlock() if coreServer != nil { err := coreServer.Close() coreServer = nil diff --git a/xray/xray_test.go b/xray/xray_test.go new file mode 100644 index 00000000..f766b029 --- /dev/null +++ b/xray/xray_test.go @@ -0,0 +1,146 @@ +package xray + +import ( + "errors" + "os" + "strings" + "sync" + "testing" +) + +const minimalConfig = `{ + "log": {"loglevel": "none"}, + "inbounds": [], + "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 { + t.Errorf("stop xray: %v", err) + } + }) + + if err := StopXray(); err != nil { + t.Fatalf("reset xray state: %v", err) + } + if err := RunXray(minimalConfig); err != nil { + t.Fatalf("start xray: %v", err) + } + if !GetXrayState() { + t.Fatal("xray should be running") + } + if err := RunXray(minimalConfig); !errors.Is(err, ErrAlreadyRunning) { + t.Fatalf("duplicate start error = %v, want %v", err, ErrAlreadyRunning) + } +} + +func TestXrayLifecycleConcurrentStateReads(t *testing.T) { + if err := StopXray(); err != nil { + t.Fatalf("reset xray state: %v", err) + } + if err := RunXray(minimalConfig); err != nil { + t.Fatalf("start xray: %v", err) + } + + var readers sync.WaitGroup + for range 32 { + readers.Add(1) + go func() { + defer readers.Done() + _ = GetXrayState() + }() + } + readers.Wait() + + if err := StopXray(); err != nil { + t.Fatalf("stop xray: %v", err) + } + if GetXrayState() { + t.Fatal("xray should be stopped") + } +} + +func TestRunXrayFailureDoesNotPublishInstance(t *testing.T) { + if err := StopXray(); err != nil { + t.Fatalf("reset xray state: %v", err) + } + if err := RunXray(`{"outbounds":[`); err == nil { + t.Fatal("invalid config should fail") + } + if GetXrayState() { + t.Fatal("failed start must not publish an instance") + } + if err := RunXray(minimalConfig); err != nil { + t.Fatalf("start after failure: %v", err) + } + if err := StopXray(); err != nil { + t.Fatalf("stop xray: %v", err) + } +} + +func TestRunXraySerializesConcurrentStarts(t *testing.T) { + if err := StopXray(); err != nil { + t.Fatalf("reset xray state: %v", err) + } + t.Cleanup(func() { + if err := StopXray(); err != nil { + t.Errorf("stop xray: %v", err) + } + }) + + const starts = 8 + errorsByStart := make(chan error, starts) + var starters sync.WaitGroup + for range starts { + starters.Add(1) + go func() { + defer starters.Done() + errorsByStart <- RunXray(minimalConfig) + }() + } + starters.Wait() + close(errorsByStart) + + successes := 0 + duplicates := 0 + for err := range errorsByStart { + switch { + case err == nil: + successes++ + case errors.Is(err, ErrAlreadyRunning): + duplicates++ + default: + t.Fatalf("unexpected start error: %v", err) + } + } + if successes != 1 || duplicates != starts-1 { + t.Fatalf("successes=%d duplicates=%d", successes, duplicates) + } +}