diff --git a/.github/workflows/build-WebAPI.yml b/.github/workflows/build-WebAPI.yml new file mode 100644 index 00000000..580c087a --- /dev/null +++ b/.github/workflows/build-WebAPI.yml @@ -0,0 +1,923 @@ +name: build v2rayN.WebAPI + +on: + workflow_call: + inputs: + release_tag: + required: false + type: string + default: '' + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (for example 7.x.y). Empty builds a development API package.' + required: false + default: '' + type: string + pull_request: + branches: [master] + paths: + - 'v2rayN/v2rayN.WebAPI/**' + - 'v2rayN/ServiceLib/**' + - 'v2rayN/ServiceLib.Tests/**' + - 'v2rayN/Directory.Build.*' + - 'v2rayN/Directory.Packages.props' + - 'v2rayN/v2rayN.slnx' + - 'global.json' + - '.github/workflows/build-WebAPI.yml' + - '.github/workflows/release-WebAPI.yml' + - '.github/workflows/build-all.yml' + - '.github/workflows/upload-sign.yml' + push: + branches: [master] + paths: + - 'v2rayN/v2rayN.WebAPI/**' + - 'v2rayN/ServiceLib/**' + - 'v2rayN/ServiceLib.Tests/**' + - 'v2rayN/Directory.Build.*' + - 'v2rayN/Directory.Packages.props' + - 'v2rayN/v2rayN.slnx' + - 'global.json' + - '.github/workflows/build-WebAPI.yml' + - '.github/workflows/release-WebAPI.yml' + - '.github/workflows/build-all.yml' + - '.github/workflows/upload-sign.yml' +permissions: + contents: read + +jobs: + identity: + name: Resolve API build identity + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.resolve.outputs.version }} + tag: ${{ steps.resolve.outputs.tag }} + release: ${{ steps.resolve.outputs.release }} + build_date: ${{ steps.resolve.outputs.build_date }} + steps: + - name: Resolve release identity + id: resolve + shell: bash + env: + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + if [[ -z "$RELEASE_TAG" ]]; then + { + echo "version=0.0.0-dev" + echo "tag=" + echo "release=false" + echo "build_date=$build_date" + } >> "$GITHUB_OUTPUT" + else + [[ "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Invalid release tag: $RELEASE_TAG" >&2 + exit 1 + } + { + echo "version=$RELEASE_TAG" + echo "tag=$RELEASE_TAG" + echo "release=true" + echo "build_date=$build_date" + } >> "$GITHUB_OUTPUT" + fi + + verify: + name: Backend, ServiceLib, package smoke + runs-on: ubuntu-24.04 + env: + TUNIT_MAX_PARALLEL_TESTS: 1 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.x' + - name: Verify API build, backend and ServiceLib tests, native startup and package boundaries + run: bash v2rayN/v2rayN.WebAPI/Scripts/verify.sh "$RUNNER_TEMP/v2rayN.WebAPI-verify" + - name: Check patch whitespace + if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || '' }} + run: | + set -euo pipefail + base_sha="$BASE_SHA" + if [[ -z "$base_sha" ]]; then + base_sha="$(git rev-parse HEAD^)" + fi + if [[ "$base_sha" =~ ^0+$ ]]; then + empty_tree="$(git hash-object -t tree -w /dev/null)" + git diff --check "$empty_tree" HEAD + else + git diff --check "$base_sha...HEAD" + fi + + windows: + name: Windows WebAPI build and runtime smoke + needs: identity + runs-on: windows-latest + env: + TUNIT_MAX_PARALLEL_TESTS: 1 + WEB_VERSION: ${{ needs.identity.outputs.version }} + WEB_COMMIT: ${{ github.sha }} + WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }} + WEB_REPOSITORY: ${{ github.repository }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.x' + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Restore WebAPI + shell: pwsh + run: dotnet restore v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj + - name: Restore WebAPI.Tests + shell: pwsh + run: dotnet restore v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj + - name: Restore ServiceLib.Tests + shell: pwsh + run: dotnet restore v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj + - name: Build WebAPI + shell: pwsh + run: dotnet build v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj --configuration Release --no-restore + - name: Run WebAPI.Tests + shell: pwsh + run: dotnet test --project v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj --configuration Release --no-restore + - name: Run ServiceLib.Tests + shell: pwsh + run: dotnet test --project v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj --configuration Release --no-restore + - name: Publish win-x64 self-contained single-file WebAPI + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64' + dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:WebVersion=$env:WEB_VERSION ` + -p:WebCommit=$env:WEB_COMMIT ` + -p:WebBuildDate=$env:WEB_BUILD_DATE ` + -p:WebRepository=$env:WEB_REPOSITORY ` + --output $publishDirectory + $identity = [ordered]@{ + product = 'v2rayN.WebAPI' + version = $env:WEB_VERSION + commit = $env:WEB_COMMIT + buildDate = $env:WEB_BUILD_DATE + rid = 'win-x64' + } + $json = $identity | ConvertTo-Json -Compress + [IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false)) + - name: Verify single-file publish output + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64' + $executable = Join-Path $publishDirectory 'v2rayN.WebAPI.exe' + if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { + throw "dotnet publish did not produce $executable" + } + if ((Get-Item -LiteralPath $executable).Length -eq 0) { + throw "Published executable is empty: $executable" + } + $looseDlls = @(Get-ChildItem -LiteralPath $publishDirectory -Filter '*.dll' -File -Recurse) + if ($looseDlls.Count -gt 0) { + throw "Single-file publish left loose DLLs: $($looseDlls.FullName -join ', ')" + } + - name: Smoke-test first startup, health endpoint and graceful shutdown + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64' + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $publishDirectory + - name: Publish previous Windows fixture for update regression + if: inputs.release_tag != '' + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous' + dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:WebVersion=0.0.1-regression ` + -p:WebCommit=$env:WEB_COMMIT ` + -p:WebBuildDate=$env:WEB_BUILD_DATE ` + -p:WebRepository=$env:WEB_REPOSITORY ` + --output $publishDirectory + $identity = [ordered]@{ + product = 'v2rayN.WebAPI' + version = '0.0.1-regression' + commit = $env:WEB_COMMIT + buildDate = $env:WEB_BUILD_DATE + rid = 'win-x64' + } + $json = $identity | ConvertTo-Json -Compress + [IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false)) + - name: Add the matching Windows Core bundle + if: inputs.release_tag != '' + shell: pwsh + run: | + $bundle = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64.zip' + $expanded = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64-core' + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64' + $previousDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous' + Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-windows-64.zip' -OutFile $bundle + Expand-Archive -LiteralPath $bundle -DestinationPath $expanded -Force + $sourceBin = Join-Path $expanded 'v2rayN-windows-64\bin' + if (-not (Test-Path -LiteralPath $sourceBin -PathType Container)) { + throw "Windows Core bundle is missing $sourceBin" + } + foreach ($directory in @($publishDirectory, $previousDirectory)) { + $destinationBin = Join-Path $directory 'bin' + New-Item -ItemType Directory -Path $destinationBin -Force | Out-Null + Copy-Item -Path (Join-Path $sourceBin '*') -Destination $destinationBin -Recurse -Force + } + foreach ($directory in @($publishDirectory, $previousDirectory)) { + $checkBin = Join-Path $directory 'bin' + foreach ($relative in @('xray\xray.exe','sing_box\sing-box.exe','mihomo\mihomo.exe','geoip.dat','geosite.dat','geoip.metadb','Country.mmdb')) { + $required = Join-Path $checkBin $relative + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing Windows Core asset: $required" } + } + } + $candidateBin = Join-Path $publishDirectory 'bin' + & (Join-Path $candidateBin 'xray\xray.exe') -version + if ($LASTEXITCODE -ne 0) { throw "xray.exe smoke failed: $LASTEXITCODE" } + & (Join-Path $candidateBin 'sing_box\sing-box.exe') version + if ($LASTEXITCODE -ne 0) { throw "sing-box.exe smoke failed: $LASTEXITCODE" } + & (Join-Path $candidateBin 'mihomo\mihomo.exe') -v + if ($LASTEXITCODE -ne 0) { throw "mihomo.exe smoke failed: $LASTEXITCODE" } + - name: Smoke-test Windows backup restore handoff and Core intent recovery + if: inputs.release_tag != '' + shell: pwsh + run: | + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 ` + -Mode restore ` + -PublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64') + - name: Smoke-test Windows self-update replacement and rollback + if: inputs.release_tag != '' + shell: pwsh + run: | + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 ` + -Mode update ` + -PreviousPublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous') ` + -CandidatePublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64') + - name: Package and smoke-test Windows full-install and app-only ZIPs + if: inputs.release_tag != '' + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64' + $dist = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-dist' + python v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid win-x64 --publish $publishDirectory --dist $dist + $fullZip = Join-Path $dist 'v2rayN-windows-64-WebAPI.zip' + $appOnlyZip = Join-Path $dist 'v2rayN-windows-64-WebAPI-update.zip' + if (-not (Test-Path -LiteralPath $fullZip -PathType Leaf) -or -not (Test-Path -LiteralPath $appOnlyZip -PathType Leaf)) { + throw 'Windows release ZIPs were not created.' + } + $extracted = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64-WebAPI-smoke' + Expand-Archive -LiteralPath $fullZip -DestinationPath $extracted -Force + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $extracted + "Windows full-install ZIP: $fullZip" + "Windows app-only update ZIP: $appOnlyZip" + - uses: actions/upload-artifact@v7 + if: inputs.release_tag != '' + with: + name: v2rayN.WebAPI-win-x64 + path: ${{ runner.temp }}/v2rayN.WebAPI-win-x64-dist/*.zip + if-no-files-found: error + + # The self-update replacement smoke stays on win-x64: the updater logic is RID-generic + # and the per-RID PE/ELF checks are covered by unit tests. This job proves the win-arm64 + # build, first startup, health endpoint and graceful shutdown on native ARM64 Windows. + windows-arm64: + name: Windows ARM64 WebAPI build and runtime smoke + needs: identity + runs-on: windows-11-arm + env: + TUNIT_MAX_PARALLEL_TESTS: 1 + WEB_VERSION: ${{ needs.identity.outputs.version }} + WEB_COMMIT: ${{ github.sha }} + WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }} + WEB_REPOSITORY: ${{ github.repository }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.x' + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Restore WebAPI + shell: pwsh + run: dotnet restore v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj + - name: Restore WebAPI.Tests + shell: pwsh + run: dotnet restore v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj + - name: Restore ServiceLib.Tests + shell: pwsh + run: dotnet restore v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj + - name: Build WebAPI + shell: pwsh + run: dotnet build v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj --configuration Release --no-restore + - name: Run WebAPI.Tests + shell: pwsh + run: dotnet test --project v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj --configuration Release --no-restore + - name: Run ServiceLib.Tests + shell: pwsh + run: dotnet test --project v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj --configuration Release --no-restore + - name: Publish win-arm64 self-contained single-file WebAPI + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64' + dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj ` + --configuration Release ` + --runtime win-arm64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:WebVersion=$env:WEB_VERSION ` + -p:WebCommit=$env:WEB_COMMIT ` + -p:WebBuildDate=$env:WEB_BUILD_DATE ` + -p:WebRepository=$env:WEB_REPOSITORY ` + --output $publishDirectory + $identity = [ordered]@{ + product = 'v2rayN.WebAPI' + version = $env:WEB_VERSION + commit = $env:WEB_COMMIT + buildDate = $env:WEB_BUILD_DATE + rid = 'win-arm64' + } + $json = $identity | ConvertTo-Json -Compress + [IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false)) + - name: Smoke-test first startup, health endpoint and graceful shutdown + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64' + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $publishDirectory + - name: Add the matching Windows ARM64 Core bundle + if: inputs.release_tag != '' + shell: pwsh + run: | + $bundle = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64.zip' + $expanded = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64-core' + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64' + Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-windows-arm64.zip' -OutFile $bundle + Expand-Archive -LiteralPath $bundle -DestinationPath $expanded -Force + $sourceBin = Join-Path $expanded 'v2rayN-windows-arm64\bin' + if (-not (Test-Path -LiteralPath $sourceBin -PathType Container)) { + throw "Windows ARM64 Core bundle is missing $sourceBin" + } + $destinationBin = Join-Path $publishDirectory 'bin' + New-Item -ItemType Directory -Path $destinationBin -Force | Out-Null + Copy-Item -Path (Join-Path $sourceBin '*') -Destination $destinationBin -Recurse -Force + foreach ($relative in @('xray\xray.exe','sing_box\sing-box.exe','mihomo\mihomo.exe','geoip.dat','geosite.dat','geoip.metadb','Country.mmdb')) { + $required = Join-Path $destinationBin $relative + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing Windows ARM64 Core asset: $required" } + } + & (Join-Path $destinationBin 'xray\xray.exe') -version + if ($LASTEXITCODE -ne 0) { throw "xray.exe smoke failed: $LASTEXITCODE" } + & (Join-Path $destinationBin 'sing_box\sing-box.exe') version + if ($LASTEXITCODE -ne 0) { throw "sing-box.exe smoke failed: $LASTEXITCODE" } + & (Join-Path $destinationBin 'mihomo\mihomo.exe') -v + if ($LASTEXITCODE -ne 0) { throw "mihomo.exe smoke failed: $LASTEXITCODE" } + - name: Package Windows ARM64 full-install and app-only ZIPs + if: inputs.release_tag != '' + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64' + $dist = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64-dist' + python v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid win-arm64 --publish $publishDirectory --dist $dist + $fullZip = Join-Path $dist 'v2rayN-windows-arm64-WebAPI.zip' + $appOnlyZip = Join-Path $dist 'v2rayN-windows-arm64-WebAPI-update.zip' + if (-not (Test-Path -LiteralPath $fullZip -PathType Leaf) -or -not (Test-Path -LiteralPath $appOnlyZip -PathType Leaf)) { + throw 'Windows ARM64 release ZIPs were not created.' + } + $extracted = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64-WebAPI-smoke' + Expand-Archive -LiteralPath $fullZip -DestinationPath $extracted -Force + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $extracted + "Windows ARM64 full-install ZIP: $fullZip" + "Windows ARM64 app-only update ZIP: $appOnlyZip" + - uses: actions/upload-artifact@v7 + if: inputs.release_tag != '' + with: + name: v2rayN.WebAPI-win-arm64 + path: ${{ runner.temp }}/v2rayN.WebAPI-win-arm64-dist/*.zip + if-no-files-found: error + + native: + name: Native API build (${{ matrix.rid }}) + needs: identity + strategy: + fail-fast: false + matrix: + # Development builds keep x64 coverage; releases also validate ARM64. + # Only tested Linux RIDs are enabled; native RID/runner pairs are independent + # of Desktop workflows and the container platform matrix. + include: >- + ${{ fromJSON(needs.identity.outputs.release == 'true' + && '[{"rid":"linux-x64","runner":"ubuntu-24.04"},{"rid":"linux-arm64","runner":"ubuntu-24.04-arm"}]' + || '[{"rid":"linux-x64","runner":"ubuntu-24.04"}]') }} + runs-on: ${{ matrix.runner }} + env: + RID: ${{ matrix.rid }} + V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }} + V2RAYN_WEB_REPOSITORY: ${{ github.repository }} + V2RAYN_WEB_COMMIT: ${{ github.sha }} + V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.x' + - name: Publish native API + run: bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh "$RID" + - name: Verify native executable + run: test -x "v2rayN/v2rayN.WebAPI/publish/$RID/v2rayN.WebAPI" + - name: Add the matching Linux Core bundle + if: needs.identity.outputs.release == 'true' + run: sh v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "v2rayN/v2rayN.WebAPI/publish/$RID/bin" + - name: Package API install and self-update ZIPs + if: needs.identity.outputs.release == 'true' + run: python3 v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "v2rayN/v2rayN.WebAPI/publish/$RID" --dist dist + - name: Smoke-test bundled Cores + if: needs.identity.outputs.release == 'true' + run: | + set -euo pipefail + publish="v2rayN/v2rayN.WebAPI/publish/$RID" + "$publish/bin/xray/xray" -version + "$publish/bin/sing_box/sing-box" version + "$publish/bin/mihomo/mihomo" -v + - uses: actions/upload-artifact@v7 + if: needs.identity.outputs.release == 'true' + with: + name: v2rayN.WebAPI-${{ matrix.rid }} + path: dist/*.zip + if-no-files-found: error + + # linux-riscv64 runs natively on the RISE RISC-V runners with the community + # xujiegb/dotnet-riscv SDK; single-file publish is verified to work there. + native-riscv64: + name: Native API build (linux-riscv64) + needs: identity + if: needs.identity.outputs.release == 'true' + runs-on: ubuntu-24.04-riscv + container: debian:13 + env: + RID: linux-riscv64 + V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }} + V2RAYN_WEB_REPOSITORY: ${{ github.repository }} + V2RAYN_WEB_COMMIT: ${{ github.sha }} + V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }} + steps: + - name: Prepare tools (Debian) + shell: bash + run: | + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu76 || \ + apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu72 + - name: Checkout repo + shell: bash + env: { GITHUB_TOKEN: "${{ github.token }}" } + run: | + set -euo pipefail + rm -rf ./* + git init . + git config --global --add safe.directory "$PWD" + git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git fetch --depth=1 origin "${GITHUB_SHA}" + git checkout FETCH_HEAD + - name: Install community .NET SDK (linux-riscv64) + shell: bash + run: | + set -euo pipefail + SDK=dotnet-sdk-10.0.111-linux-riscv64.tar.gz + curl -fL "https://github.com/xujiegb/dotnet-riscv/releases/download/10.0.111/$SDK" -o /tmp/$SDK + mkdir -p "$HOME/.dotnet" + tar -C "$HOME/.dotnet" -xzf /tmp/$SDK + echo "DOTNET_ROOT=$HOME/.dotnet" >> "$GITHUB_ENV" + echo "$HOME/.dotnet" >> "$GITHUB_PATH" + export DOTNET_ROOT="$HOME/.dotnet" + export PATH="$DOTNET_ROOT:$PATH" + dotnet --info + - name: Publish native API + shell: bash + run: | + set -euo pipefail + export DOTNET_ROOT="$HOME/.dotnet" + export PATH="$DOTNET_ROOT:$PATH" + bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh "$RID" + test -x "v2rayN/v2rayN.WebAPI/publish/$RID/v2rayN.WebAPI" + - name: Add the matching Linux Core bundle + shell: bash + run: sh v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "v2rayN/v2rayN.WebAPI/publish/$RID/bin" + - name: Package API install and self-update ZIPs + shell: bash + run: python3 v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "v2rayN/v2rayN.WebAPI/publish/$RID" --dist dist + - name: Smoke-test bundled Cores + shell: bash + run: | + set -euo pipefail + publish="v2rayN/v2rayN.WebAPI/publish/$RID" + "$publish/bin/xray/xray" -version + "$publish/bin/sing_box/sing-box" version + "$publish/bin/mihomo/mihomo" -v + - name: Smoke-test the published API (native riscv64) + shell: bash + run: | + set -euo pipefail + publish="v2rayN/v2rayN.WebAPI/publish/$RID" + "$publish/v2rayN.WebAPI" --help | head -n 5 + export V2RAYN_WEB_API_KEY=ci-smoke-key-0123456789abcdef + export ASPNETCORE_URLS=http://127.0.0.1:5080 + cd "$publish" + ./v2rayN.WebAPI --foreground --no-open > /tmp/smoke.log 2>&1 & + app_pid=$! + echo "$app_pid" > /tmp/smoke.pid + echo "riscv64 API smoke application PID: $app_pid" + ok=0 + for i in $(seq 1 90); do + if curl --noproxy '*' -fsS http://127.0.0.1:5080/api/health >/dev/null 2>&1; then ok=1; break; fi + if ! kill -0 "$app_pid" 2>/dev/null; then break; fi + sleep 1 + done + if [ "$ok" != 1 ]; then + echo 'riscv64 API smoke did not become healthy' + tail -n 60 /tmp/smoke.log || true + kill -TERM "$app_pid" 2>/dev/null || true + sleep 1 + kill -KILL "$app_pid" 2>/dev/null || true + exit 1 + fi + kill -TERM "$app_pid" + stopped=0 + for i in $(seq 1 30); do + if ! kill -0 "$app_pid" 2>/dev/null; then stopped=1; break; fi + sleep 1 + done + if [ "$stopped" != 1 ]; then + echo 'riscv64 API smoke did not exit within 30s after SIGTERM' + tail -n 60 /tmp/smoke.log || true + kill -KILL "$app_pid" 2>/dev/null || true + exit 1 + fi + set +e + wait "$app_pid" + exit_code=$? + set -e + if [ "$exit_code" -ne 0 ]; then + echo "riscv64 API smoke exited with status $exit_code after SIGTERM" + tail -n 60 /tmp/smoke.log || true + exit 1 + fi + echo 'linux-riscv64 native API smoke OK' + - uses: actions/upload-artifact@v7 + with: + name: v2rayN.WebAPI-linux-riscv64 + path: dist/*.zip + if-no-files-found: error + + # linux-loongarch64 publishes inside the upstream QEMU loong64 VM with the official + # Loongson .NET SDK; the VM runs the native smoke before the host packages the ZIPs. + native-loong64: + name: Native API build (linux-loongarch64) + needs: identity + if: needs.identity.outputs.release == 'true' + runs-on: ubuntu-26.04-arm + env: + RID: linux-loongarch64 + V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }} + V2RAYN_WEB_REPOSITORY: ${{ github.repository }} + V2RAYN_WEB_COMMIT: ${{ github.sha }} + V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }} + QCOW2_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/debian13-loong64.qcow2 + EFI_CODE_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/edk2-loongarch64-code.fd + EFI_VARS_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/edk2-loongarch64-vars.fd + QCOW2_IMAGE: debian13-loong64.qcow2 + EFI_CODE: edk2-loongarch64-code.fd + EFI_VARS: edk2-loongarch64-vars.fd + QEMU_VERSION: 10.2.4 + steps: + - name: Prepare host tools + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y git rsync qemu-utils qemu-system-common expect wget curl ca-certificates libfdt1 libglib2.0-0 libpixman-1-0 libslirp0 virtiofsd + sudo ln -sf /usr/libexec/virtiofsd /usr/local/bin/virtiofsd + virtiofsd --version || virtiofsd --help | head + - name: Host clone repo + shell: bash + env: { GITHUB_TOKEN: "${{ github.token }}" } + run: | + set -euo pipefail + rm -rf repo + git init repo + git -C repo config --global --add safe.directory "$PWD/repo" || true + git -C repo remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git -C repo fetch --depth=1 origin "${GITHUB_SHA}" + git -C repo checkout FETCH_HEAD + - name: Download QEMU prebuild + run: | + set -euo pipefail + wget -O qemu-linux-arm64-v8.2.tar.gz "https://github.com/xujiegb/qemu-linux-prebuild/releases/download/${QEMU_VERSION}/qemu-linux-arm64-v8.2.tar.gz" + tar -xzf qemu-linux-arm64-v8.2.tar.gz + mkdir -p "$HOME/qemu-install" + rsync -a qemu-linux-arm64-v8.2/ "$HOME/qemu-install/" + "$HOME/qemu-install/bin/qemu-system-loongarch64" --version + - name: Download loong64 qcow2 and EFI firmware + shell: bash + run: | + set -euo pipefail + wget -O "$QCOW2_IMAGE" "$QCOW2_URL" + wget -O "$EFI_CODE" "$EFI_CODE_URL" + wget -O "$EFI_VARS" "$EFI_VARS_URL" + qemu-img info "$QCOW2_IMAGE" + - name: Stage the VM build script and the matching Core bundle + shell: bash + run: | + set -euo pipefail + cat > webapi-loong.env < webapi-loong-run.sh <<'SCRIPT' + #!/usr/bin/env bash + set +e + set -o pipefail + source /workspace/webapi-loong.env + export DOTNET_ROOT=/root/.dotnet + export PATH="$DOTNET_ROOT:$PATH" + mkdir -p /root/.dotnet + SDK=dotnet-sdk-10.0.111-linux-loongarch64.tar.gz + curl -fL "https://github.com/loongson/dotnet/releases/download/v10.0.111-loongarch64/$SDK" -o /tmp/$SDK || exit 9 + tar -C /root/.dotnet -xzf /tmp/$SDK || exit 9 + dotnet --info >/dev/null 2>&1 || exit 9 + cd /root/v2rayN-src || exit 9 + mkdir -p /workspace/publish/linux-loongarch64 + V2RAYN_WEB_VERSION="$WEB_VERSION" V2RAYN_WEB_REPOSITORY="$WEB_REPOSITORY" V2RAYN_WEB_COMMIT="$WEB_COMMIT" V2RAYN_WEB_BUILD_DATE="$WEB_BUILD_DATE" \ + bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh linux-loongarch64 /workspace/publish/linux-loongarch64 2>&1 | tee /tmp/publish.log + rc=$? + if [ $rc -eq 0 ]; then + mkdir -p /workspace/publish/linux-loongarch64/bin + cp -a /workspace/core-bundle/. /workspace/publish/linux-loongarch64/bin/ + # Smoke a VM-local copy: the shared publish tree must stay exactly the packaging + # input, without runtime lock/data files owned by the VM user. + rm -rf /root/websmoke + cp -a /workspace/publish/linux-loongarch64 /root/websmoke + cd /root/websmoke || exit 9 + ./bin/xray/xray -version >/dev/null || rc=9 + ./bin/sing_box/sing-box version >/dev/null || rc=9 + ./bin/mihomo/mihomo -v >/dev/null || rc=9 + ./v2rayN.WebAPI --help >/dev/null || rc=9 + V2RAYN_WEB_API_KEY=ci-smoke-key-0123456789abcdef ASPNETCORE_URLS=http://127.0.0.1:5080 ./v2rayN.WebAPI --foreground --no-open > /tmp/smoke.log 2>&1 & + APP=$! + echo "$APP" > /tmp/smoke.pid + ok=0 + for i in $(seq 1 90); do + if curl --noproxy '*' -fsS http://127.0.0.1:5080/api/health >/dev/null 2>&1; then ok=1; break; fi + if ! kill -0 "$APP" 2>/dev/null; then break; fi + sleep 1 + done + if [ "$ok" != 1 ]; then + echo 'loongarch64 API smoke did not become healthy' + tail -n 60 /tmp/smoke.log + kill -KILL "$APP" 2>/dev/null || true + rc=9 + fi + if [ "$rc" = 0 ]; then + kill -TERM "$APP" + exited=0 + for i in $(seq 1 60); do + if ! kill -0 "$APP" 2>/dev/null; then exited=1; break; fi + sleep 1 + done + if [ "$exited" != 1 ]; then + echo 'loongarch64 API smoke did not exit within 60s after SIGTERM' + tail -n 60 /tmp/smoke.log + kill -KILL "$APP" 2>/dev/null || true + rc=9 + else + wait "$APP" + app_rc=$? + if [ "$app_rc" != 0 ]; then + echo "loongarch64 API smoke exited with status $app_rc after SIGTERM" + tail -n 60 /tmp/smoke.log + rc=9 + fi + fi + fi + fi + if [ "$rc" != 0 ]; then echo '=== publish.log ==='; tail -n 120 /tmp/publish.log 2>/dev/null; fi + echo "__WEBAPI_DONE__$rc" + SCRIPT + bash repo/v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "$GITHUB_WORKSPACE/core-bundle" + ls -la "$GITHUB_WORKSPACE/core-bundle" | head -n 15 + - name: Build WebAPI loongarch64 in the VM (publish + native smoke) + shell: bash + timeout-minutes: 180 + run: | + set -euo pipefail + mkdir -p "$GITHUB_WORKSPACE/dist" + export VIRTIOFSD_SOCKET="$RUNNER_TEMP/virtiofs-webapi-loongarch64.sock" + rm -f "$VIRTIOFSD_SOCKET" + + sudo virtiofsd --socket-path="$VIRTIOFSD_SOCKET" --shared-dir="$GITHUB_WORKSPACE" --cache=auto & + + VIRTIOFSD_PID=$! + trap 'sudo kill "$VIRTIOFSD_PID" 2>/dev/null || true; rm -f "$VIRTIOFSD_SOCKET"' EXIT + + for i in {1..100}; do + [[ -S "$VIRTIOFSD_SOCKET" ]] && break + sleep 0.1 + done + [[ -S "$VIRTIOFSD_SOCKET" ]] || { echo "virtiofsd socket not created"; exit 1; } + sudo chown "$USER:$(id -gn)" "$VIRTIOFSD_SOCKET" || true + sudo chmod 660 "$VIRTIOFSD_SOCKET" || true + expect <<'EOF' + log_user 1 + set timeout 10800 + set qemu_bin "$env(HOME)/qemu-install/bin/qemu-system-loongarch64" + set qemu_rom_dir "$env(HOME)/qemu-install/share/qemu" + set virtiofs_socket $env(VIRTIOFSD_SOCKET) + set qcow2 $env(QCOW2_IMAGE) + set efi_code $env(EFI_CODE) + set efi_vars $env(EFI_VARS) + proc wait_prompt {} { + expect { + -re "__CI_PROMPT__# " {} + timeout { exit 1 } + eof { exit 1 } + } + } + proc run_cmd {cmd} { + send -- "$cmd\r" + wait_prompt + } + spawn $qemu_bin \ + -L $qemu_rom_dir \ + -machine virt \ + -accel tcg,thread=multi,tb-size=2048 \ + -cpu la464 \ + -m 9216 \ + -object memory-backend-memfd,id=mem0,size=9216M,share=on \ + -numa node,memdev=mem0 \ + -smp 4 \ + -drive if=pflash,format=raw,unit=0,file=$efi_code,readonly=on \ + -drive if=pflash,format=raw,unit=1,file=$efi_vars \ + -device virtio-blk-pci,drive=hd0,bootindex=1 \ + -drive if=none,media=disk,id=hd0,file=$qcow2,format=qcow2 \ + -netdev user,id=net0 \ + -device virtio-net-pci-non-transitional,netdev=net0 \ + -chardev socket,id=charfs0,path=$virtiofs_socket \ + -device vhost-user-fs-pci,chardev=charfs0,tag=workspace \ + -display none \ + -serial stdio \ + -monitor none + expect -re "login:|debian login:" + send -- "root\r" + expect -re "Password:|密码:|密码:" + send -- "password\r" + expect { + -re "# " {} + timeout { exit 1 } + eof { exit 1 } + } + send -- "export TERM=dumb; export PS1='__CI_PROMPT__# '\r" + wait_prompt + run_cmd "mkdir -p /workspace" + run_cmd "mount -t virtiofs workspace /workspace" + run_cmd "IFACE=\$(ip -o link show | awk -F': ' '\$2 != \"lo\" {print \$2; exit}') ; ip link set \$IFACE up || true" + run_cmd "dhclient \$IFACE || true" + run_cmd "printf 'nameserver 10.0.2.3\nnameserver 1.1.1.1\n' >/etc/resolv.conf" + run_cmd "apt-get update || apt-get update || apt-get update" + run_cmd "DEBIAN_FRONTEND=noninteractive apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu76 || DEBIAN_FRONTEND=noninteractive apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1" + run_cmd "rm -rf /root/v2rayN-src" + run_cmd "rsync -a /workspace/repo/ /root/v2rayN-src/" + run_cmd "cd /root/v2rayN-src && git config --global --add safe.directory /root/v2rayN-src" + send -- "bash /workspace/webapi-loong-run.sh\r" + expect { + -re "__WEBAPI_DONE__0" { send -- "poweroff\r" } + -re "__WEBAPI_DONE__" { exit 1 } + timeout { exit 1 } + eof { exit 1 } + } + EOF + - name: Verify the loongarch64 publish tree + shell: bash + run: | + set -euo pipefail + publish="$GITHUB_WORKSPACE/publish/linux-loongarch64" + test -x "$publish/v2rayN.WebAPI" + test -f "$publish/v2rayN.WebAPI.build.json" + unreadable="$(find "$publish" ! -readable -print -quit)" + if [ -n "$unreadable" ]; then + echo "The loongarch64 publish tree contains unreadable entries:" + find "$publish" ! -readable -print | head -n 20 + exit 1 + fi + echo "loongarch64 publish tree is complete and readable" + - name: Package API install and self-update ZIPs + shell: bash + run: | + set -euo pipefail + python3 repo/v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "$GITHUB_WORKSPACE/publish/linux-loongarch64" --dist "$GITHUB_WORKSPACE/dist" + ls -la "$GITHUB_WORKSPACE/dist" + - uses: actions/upload-artifact@v7 + with: + name: v2rayN.WebAPI-linux-loongarch64 + path: dist/*.zip + if-no-files-found: error + + container: + name: API Containerfile (${{ matrix.rid }}) + needs: identity + strategy: + fail-fast: false + matrix: + # Development builds keep amd64 coverage; releases also validate ARM64. + include: >- + ${{ fromJSON(needs.identity.outputs.release == 'true' + && '[{"rid":"linux-x64","platform":"linux/amd64"},{"rid":"linux-arm64","platform":"linux/arm64"}]' + || '[{"rid":"linux-x64","platform":"linux/amd64"}]') }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@v3 + - name: Build API container (no push) + uses: docker/build-push-action@v6 + env: + DOCKER_BUILD_RECORD_UPLOAD: false + with: + context: . + file: ./v2rayN/v2rayN.WebAPI/Containerfile + platforms: ${{ matrix.platform }} + tags: v2rayn-webapi:ci + build-args: | + RID=${{ matrix.rid }} + WEB_VERSION=${{ needs.identity.outputs.version }} + WEB_REPOSITORY=${{ github.repository }} + WEB_COMMIT=${{ github.sha }} + WEB_BUILD_DATE=${{ needs.identity.outputs.build_date }} + push: false + + release-assets: + name: Assemble API release assets + if: needs.identity.outputs.release == 'true' + needs: [identity, verify, windows, windows-arm64, native, native-riscv64, native-loong64, container] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v7 + with: + pattern: v2rayN.WebAPI-* + path: release-assets + merge-multiple: true + - name: Build and verify WebAPI-update.json + env: + RELEASE_TAG: ${{ needs.identity.outputs.tag }} + run: | + set -euo pipefail + python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py write \ + --version "$RELEASE_TAG" \ + --repository "${{ github.repository }}" \ + --commit "${{ github.sha }}" \ + --build-date "${{ needs.identity.outputs.build_date }}" \ + --dist release-assets \ + --output release-assets/WebAPI-update.json + python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py verify \ + --manifest release-assets/WebAPI-update.json \ + --dist release-assets \ + --repository "${{ github.repository }}" + - name: Smoke-test full API ZIP + run: bash v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh release-assets/v2rayN-linux-64-WebAPI.zip + - uses: actions/upload-artifact@v7 + with: + name: WebAPI-release-packages + path: release-assets/*.zip + if-no-files-found: error + - uses: actions/upload-artifact@v7 + with: + name: WebAPI-release-manifest + path: release-assets/WebAPI-update.json + if-no-files-found: error diff --git a/.github/workflows/build-all.yml b/.github/workflows/build-all.yml index 13fdadfc..80974a29 100644 --- a/.github/workflows/build-all.yml +++ b/.github/workflows/build-all.yml @@ -20,6 +20,13 @@ jobs: secrets: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + release-WebAPI: + if: inputs.release_tag != '' + uses: ./.github/workflows/release-WebAPI.yml + with: + release_tag: ${{ inputs.release_tag }} + secrets: inherit + update: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-WebAPI.yml b/.github/workflows/release-WebAPI.yml new file mode 100644 index 00000000..03591c50 --- /dev/null +++ b/.github/workflows/release-WebAPI.yml @@ -0,0 +1,53 @@ +name: release v2rayN.WebAPI + +on: + workflow_call: + inputs: + release_tag: + required: true + type: string + secrets: + GPG_PRIVATE_KEY: + required: false + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (for example 7.x.y).' + required: true + type: string + +permissions: + contents: write + actions: read + +jobs: + build: + uses: ./.github/workflows/build-WebAPI.yml + permissions: + contents: read + with: + release_tag: ${{ inputs.release_tag }} + + release-web-packages: + name: sign and upload Web API packages + # Upload through the shared signing flow, exactly like the desktop packages. + if: inputs.release_tag != '' + needs: build + uses: ./.github/workflows/upload-sign.yml + with: + release_tag: ${{ inputs.release_tag }} + artifact_name: WebAPI-release-packages + file_pattern: 'v2rayN-*-WebAPI*.zip' + secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" } + + release-web-manifest: + name: sign and upload Web update manifest + if: inputs.release_tag != '' + # Advertise the update only after its packages and manifest have been uploaded. + needs: [build, release-web-packages] + uses: ./.github/workflows/upload-sign.yml + with: + release_tag: ${{ inputs.release_tag }} + artifact_name: WebAPI-release-manifest + file_pattern: 'WebAPI-update.json' + secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" } diff --git a/v2rayN/v2rayN.WebAPI/.NET/.gitignore b/v2rayN/v2rayN.WebAPI/.NET/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/.NET/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/v2rayN/v2rayN.WebAPI/.env.example b/v2rayN/v2rayN.WebAPI/.env.example new file mode 100644 index 00000000..1faa948f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/.env.example @@ -0,0 +1,33 @@ +# v2rayN.WebAPI environment example +# +# Copy this file to .env before starting: +# +# cp .env.example .env +# ./v2rayN.WebAPI +# +# Native local installs may leave V2RAYN_WEB_API_KEY empty ONLY with loopback +# listeners (127.0.0.1, localhost, [::1]) and complete setup in a local browser. +# LAN/NAS/headless/systemd/container deployments must preconfigure a non-empty Key. +# Any non-loopback listener requires this environment Key, even after local setup. + +V2RAYN_WEB_API_KEY= + +# Optional exact browser/client origins. Empty/unset denies cross-origin API access. +# Origin = scheme + host + port, never a path, query, fragment or wildcard. +# V2RAYN_WEB_ALLOWED_ORIGINS=https://client.example.com,http://localhost:5173 + +# Web management interface. The default listens only on loopback. +# Set V2RAYN_WEB_API_KEY before changing this to http://0.0.0.0:5080 for LAN access. +ASPNETCORE_URLS=http://127.0.0.1:5080 + +# Start the selected Core automatically after authentication setup. +V2RAYN_WEB_AUTOSTART=true + +# Optional proxy listener port. +# V2RAYN_WEB_PROXY_PORT=10808 + +# Allow proxy clients outside localhost. +# V2RAYN_WEB_PROXY_LISTEN_ALL=false + +# Optional writable data directory. +# V2RAYN_DATA_HOME=/path/to/v2rayn-data diff --git a/v2rayN/v2rayN.WebAPI/.gitignore b/v2rayN/v2rayN.WebAPI/.gitignore new file mode 100644 index 00000000..1bf59abb --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/.gitignore @@ -0,0 +1,12 @@ +/.packages/ +/.NET/* +!/.NET/.gitignore +/TestResults/ +.env +/bin/ +/obj/ +/publish/ +/core-bin/ +/Tests/bin/ +/Tests/obj/ +/Tests/TestResults/ diff --git a/v2rayN/v2rayN.WebAPI/Adapters/HeadlessWindowDialog.cs b/v2rayN/v2rayN.WebAPI/Adapters/HeadlessWindowDialog.cs new file mode 100644 index 00000000..a04e41bd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Adapters/HeadlessWindowDialog.cs @@ -0,0 +1,9 @@ +using ServiceLib.Base; + +namespace v2rayN.WebAPI.Adapters; + +public sealed class HeadlessWindowDialog : IWindowDialog +{ + public Task ShowDialogAsync(TViewModel vm) + where TViewModel : class => Task.FromResult(false); +} diff --git a/v2rayN/v2rayN.WebAPI/Api/ApiReplies.cs b/v2rayN/v2rayN.WebAPI/Api/ApiReplies.cs new file mode 100644 index 00000000..5525d2a5 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/ApiReplies.cs @@ -0,0 +1,21 @@ +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Api; + +public static class ApiReplies +{ + public static IResult Ok(T data, string messageKey = ApiMessageKeys.CommonLoaded) => + Results.Ok(ApiEnvelope.Ok(data, messageKey)); + + public static IResult Operation( + OperationView result, + int successStatus = StatusCodes.Status200OK, + int failureStatus = StatusCodes.Status400BadRequest) => + Results.Json(result, statusCode: result.Success ? successStatus : failureStatus); + + public static IResult NotFound(string code, string messageKey) => + Results.Json(ApiEnvelope.Fail(code, messageKey), statusCode: StatusCodes.Status404NotFound); + + public static IResult Conflict(string code, string messageKey, object? data = null) => + Results.Json(ApiEnvelope.Fail(code, messageKey, data), statusCode: StatusCodes.Status409Conflict); +} diff --git a/v2rayN/v2rayN.WebAPI/Api/DeleteAfterFileResult.cs b/v2rayN/v2rayN.WebAPI/Api/DeleteAfterFileResult.cs new file mode 100644 index 00000000..a2e29cfa --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/DeleteAfterFileResult.cs @@ -0,0 +1,21 @@ +namespace v2rayN.WebAPI.Api; + +public sealed class DeleteAfterFileResult(string filePath) : IResult +{ + public async Task ExecuteAsync(HttpContext httpContext) + { + try + { + await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await Results.File(stream, "application/zip", Path.GetFileName(filePath), enableRangeProcessing: false) + .ExecuteAsync(httpContext); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Api/WebApiEndpoints.cs b/v2rayN/v2rayN.WebAPI/Api/WebApiEndpoints.cs new file mode 100644 index 00000000..7b16fa9f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/WebApiEndpoints.cs @@ -0,0 +1,405 @@ +using ServiceLib.Enums; +using ServiceLib.Models.Entities; +using Microsoft.AspNetCore.Mvc; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Api; + +public static class WebApiEndpoints +{ + private static readonly System.Text.Json.JsonSerializerOptions SseJsonOptions = + new(System.Text.Json.JsonSerializerDefaults.Web); + + internal static string SerializeEventData(object? data) => data is null + ? "null" + : System.Text.Json.JsonSerializer.Serialize(data, data.GetType(), SseJsonOptions); + + public static void MapWebApi(this WebApplication app) + { + app.MapGet("/api/health", (HttpContext context, V2rayRuntime runtime) => + Results.Ok(WebHealthEndpoint.CreateResponse(context, runtime))); + app.MapGet("/api/status", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetStatusAsync(), "status.loaded")); + app.MapGet("/api/operations", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetRunningOperationsAsync(), "operations.loaded")); + app.MapGet("/api/logs", (int? limit, string? filter, V2rayRuntime runtime) => + ApiReplies.Ok(runtime.GetRecentLogs(limit ?? 200, filter), "logs.loaded")); + app.MapGet("/api/logs/page", (int? page, int? pageSize, string? filter, V2rayRuntime runtime) => + ApiReplies.Ok(runtime.GetRecentLogsPage(page ?? 1, pageSize ?? 100, filter), "logs.loaded")); + app.MapDelete("/api/logs", (V2rayRuntime runtime) => + { + var generation = runtime.ClearLogs(); + return ApiReplies.Ok(new { cleared = true, generation }, "logs.cleared"); + }); + + MapSubscriptions(app); + MapProfiles(app); + MapSettings(app); + app.MapGet("/api/editor-options", () => ApiReplies.Ok(V2rayRuntime.GetEditorOptions(), ApiMessageKeys.CommonLoaded)); + MapCore(app); + MapEvents(app); + } + + private static void MapSubscriptions(WebApplication app) + { + app.MapGet("/api/subscriptions", async (V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.GetSubscriptionsAsync(), "subscriptions.loaded")); + app.MapPost("/api/subscriptions", async (SubscriptionInput input, V2rayRuntime runtime) => + { + var result = await runtime.AddSubscriptionAsync(input); + return result.Success + ? Results.Created($"/api/subscriptions/{result.Data!.Id}", result) + : Results.BadRequest(result); + }); + app.MapPut("/api/subscriptions/{id}", async (string id, SubscriptionInput input, V2rayRuntime runtime) => + { + var result = await runtime.UpdateSubscriptionAsync(id, input); + return result.Success ? Results.Ok(result) : Results.BadRequest(result); + }); + app.MapDelete("/api/subscriptions/{id}", async (string id, V2rayRuntime runtime) => + { + var result = await runtime.DeleteSubscriptionAsync(id); + return ApiReplies.Operation(result, failureStatus: StatusCodes.Status404NotFound); + }); + app.MapGet("/api/subscriptions/{id}/share", async (string id, V2rayRuntime runtime) => + { + var url = await runtime.GetSubscriptionShareAsync(id); + return url is null + ? ApiReplies.NotFound("subscription_not_found", ApiMessageKeys.SubscriptionNotFound) + : ApiReplies.Ok(new { id, url }, "subscriptions.shareLoaded"); + }); + app.MapPost("/api/subscriptions/update", (SubscriptionUpdateRequest request, V2rayRuntime runtime) => + runtime.StartSubscriptionUpdate(request.SubscriptionId ?? string.Empty, request.UseProxy) + ? Results.Accepted("/api/operations", OperationView.Ok(ApiMessageKeys.SubscriptionUpdateStarted, + new { subscriptionId = request.SubscriptionId, useProxy = request.UseProxy })) + : ApiReplies.Operation(OperationView.Fail("subscription_update_busy", ApiMessageKeys.SubscriptionUpdateBusy), failureStatus: StatusCodes.Status409Conflict)); + app.MapPost("/api/subscriptions/{id}/update", (string id, bool? useProxy, V2rayRuntime runtime) => + runtime.StartSubscriptionUpdate(id, useProxy ?? false) + ? Results.Accepted($"/api/subscriptions/{id}", OperationView.Ok(ApiMessageKeys.SubscriptionUpdateStarted, + new { subscriptionId = id, useProxy = useProxy ?? false })) + : ApiReplies.Operation(OperationView.Fail("subscription_update_busy", ApiMessageKeys.SubscriptionUpdateBusy), failureStatus: StatusCodes.Status409Conflict)); + } + + private static void MapProfiles(WebApplication app) + { + app.MapGet("/api/profile-groups", async (V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.GetProfileGroupsAsync(), "profiles.groupsLoaded")); + app.MapPut("/api/profile-groups/current", async (ProfileGroupSelectionRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SelectProfileGroupAsync(request.SubscriptionId), failureStatus: StatusCodes.Status404NotFound)); + app.MapPost("/api/profile-groups/generate/all", async (string? subscriptionId, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.GenerateProfileGroupsAsync(subscriptionId, false))); + app.MapPost("/api/profile-groups/generate/regions", async (string? subscriptionId, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.GenerateProfileGroupsAsync(subscriptionId, true))); + + app.MapGet("/api/profiles", async (string? subscriptionId, string? filter, V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.GetProfilesAsync(subscriptionId, filter), "profiles.loaded")); + app.MapGet("/api/profiles/{id}", async (string id, V2rayRuntime runtime) => + { + var profile = await runtime.GetProfileDetailsAsync(id); + return profile is null + ? ApiReplies.NotFound("profile_not_found", ApiMessageKeys.ProfileNotFound) + : ApiReplies.Ok(profile, ApiMessageKeys.ProfileDetailLoaded); + }); + app.MapPost("/api/profiles/import", async (ProfileImportRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.ImportProfilesAsync(request))); + app.MapPost("/api/profiles", async (ProfileItem profile, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveProfileAsync(profile))); + app.MapPut("/api/profiles/{id}", async (string id, ProfileItem profile, V2rayRuntime runtime) => + { + var result = await runtime.SaveProfileAsync(profile, id); + return ApiReplies.Operation(result, + failureStatus: result.Code == "profile_not_found" ? StatusCodes.Status404NotFound : StatusCodes.Status400BadRequest); + }); + app.MapDelete("/api/profiles", async ([FromBody] ProfileIdsRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.DeleteProfilesAsync(request.ProfileIds))); + app.MapPost("/api/profiles/copy", async (ProfileIdsRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.CopyProfilesAsync(request.ProfileIds))); + app.MapPost("/api/profiles/move-to-group", async (MoveProfilesRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.MoveProfilesToGroupAsync(request))); + app.MapPost("/api/profiles/move", async (MoveProfileRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.MoveProfileAsync(request))); + app.MapPost("/api/profiles/sort", async (SortProfilesRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SortProfilesAsync(request))); + app.MapPost("/api/profiles/deduplicate", async (string? subscriptionId, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.RemoveDuplicateProfilesAsync(subscriptionId))); + app.MapDelete("/api/profiles/invalid-test-results", async (string? subscriptionId, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.RemoveInvalidProfilesAsync(subscriptionId))); + app.MapPost("/api/profiles/export", async (ProfileExportRequest request, V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.ExportProfileDataAsync(request), "profiles.exportReady")); + app.MapPost("/api/profiles/{id}/select", async (string id, V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.SelectProfileAsync(id, cancellationToken), failureStatus: StatusCodes.Status409Conflict)); + app.MapPost("/api/profiles/{id}/latency", async (string id, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.StartLatencyTestAsync(id), successStatus: StatusCodes.Status202Accepted)); + app.MapPost("/api/speedtests", async (SpeedTestRequest request, V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.StartSpeedTestAsync(request, cancellationToken), successStatus: StatusCodes.Status202Accepted)); + app.MapDelete("/api/speedtests", (V2rayRuntime runtime) => ApiReplies.Operation(runtime.StopSpeedTests())); + app.MapPost("/api/latency/stop", (V2rayRuntime runtime) => ApiReplies.Operation(runtime.StopLatencyTests())); + } + + private static void MapSettings(WebApplication app) + { + app.MapGet("/api/settings", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetSettingsAsync(), "settings.loaded")); + app.MapPut("/api/settings/apply", async (SettingsApplyInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.ApplySettingsAsync(input))); + app.MapPut("/api/settings/inbound", async (InboundSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateInboundSettingsAsync(input))); + app.MapPut("/api/settings/core", async (CoreSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateCoreSettingsAsync(input))); + app.MapPut("/api/settings/application", async (AppSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateAppSettingsAsync(input))); + app.MapPut("/api/settings/speedtest", async (SpeedTestSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateSpeedTestSettingsAsync(input))); + app.MapPut("/api/settings/core-types", async (CoreTypeMappingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateCoreTypeMappingsAsync(input.Mappings))); + app.MapPut("/api/settings/routing", async (RoutingSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateRoutingStrategiesAsync(input))); + app.MapGet("/api/settings/webdav", (V2rayRuntime runtime) => ApiReplies.Ok(runtime.GetWebDavSettings(), "backup.webdavSettingsLoaded")); + app.MapPut("/api/settings/webdav", async (WebDavSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateWebDavSettingsAsync(input))); + + app.MapGet("/api/settings/dns/simple", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetSimpleDNSAsync(), "dns.simpleLoaded")); + app.MapPut("/api/settings/dns/simple", async (ServiceLib.Models.Configs.SimpleDNSItem input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateSimpleDNSAsync(input))); + app.MapGet("/api/settings/dns/profiles", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetDnsProfilesAsync(), "dns.profilesLoaded")); + app.MapPut("/api/settings/dns/profiles/{coreType}", async (ECoreType coreType, DnsProfileInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateDnsProfileAsync(coreType, input), failureStatus: StatusCodes.Status404NotFound)); + app.MapGet("/api/settings/dns/editor-options", () => ApiReplies.Ok(V2rayRuntime.GetDnsEditorOptions(), ApiMessageKeys.CommonLoaded)); + app.MapGet("/api/settings/dns/defaults", (V2rayRuntime runtime) => ApiReplies.Ok(runtime.GetDefaultDnsProfiles(), ApiMessageKeys.CommonLoaded)); + + app.MapGet("/api/settings/routing-profiles", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetRoutingProfilesAsync(), "routing.profilesLoaded")); + app.MapPost("/api/settings/routing-profiles", async (RoutingItem input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveRoutingProfileAsync(input))); + app.MapPut("/api/settings/routing-profiles/{id}", async (string id, RoutingItem input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveRoutingProfileAsync(input, id), failureStatus: StatusCodes.Status404NotFound)); + app.MapDelete("/api/settings/routing-profiles/{id}", async (string id, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.DeleteRoutingProfileAsync(id), failureStatus: StatusCodes.Status404NotFound)); + app.MapPost("/api/settings/routing-profiles/{id}/activate", async (string id, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.ActivateRoutingProfileAsync(id), failureStatus: StatusCodes.Status404NotFound)); + app.MapGet("/api/settings/routing-profiles/{id}/rules", async (string id, V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.GetRoutingRulesAsync(id), "routing.rulesLoaded")); + app.MapPut("/api/settings/routing-profiles/{id}/rules", async (string id, List rules, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveRoutingRulesAsync(id, rules), failureStatus: StatusCodes.Status404NotFound)); + app.MapPost("/api/settings/routing-profiles/{id}/rules/import", async (string id, RouteRulesImportInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.ImportRoutingRulesAsync(id, input))); + app.MapPost("/api/settings/routing-profiles/{id}/rules/import-url", async (string id, RouteRulesUrlImportInput input, V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.ImportRoutingRulesFromUrlAsync(id, input.Append, cancellationToken))); + app.MapDelete("/api/settings/routing-profiles/{id}/rules/{ruleId}", async (string id, string ruleId, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.DeleteRoutingRuleAsync(id, ruleId), failureStatus: StatusCodes.Status404NotFound)); + app.MapPost("/api/settings/routing-profiles/{id}/rules/move", async (string id, RouteRulesMoveRequest input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.MoveRoutingRuleAsync(id, input.RuleId, input.Direction, input.Position))); + app.MapPost("/api/settings/routing-profiles/import", async (V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.ImportRoutingProfilesAsync())); + + app.MapGet("/api/settings/core-templates", async (V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.GetFullConfigTemplatesAsync(), "coreTemplates.loaded")); + app.MapPut("/api/settings/core-templates/{coreType}", async (ECoreType coreType, CoreConfigTemplateInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveFullConfigTemplateAsync(coreType, input), failureStatus: StatusCodes.Status404NotFound)); + app.MapPost("/api/settings/regional-presets/{preset}", async (EPresetType preset, V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.ApplyRegionalPresetAsync(preset, cancellationToken))); + app.MapDelete("/api/statistics", async (V2rayRuntime runtime) => ApiReplies.Operation(await runtime.ClearStatisticsAsync())); + + app.MapPost("/api/backup/webdav/check", async (V2rayRuntime runtime) => ApiReplies.Operation(await runtime.CheckWebDavAsync())); + app.MapPost("/api/backup/webdav", async (V2rayRuntime runtime) => ApiReplies.Operation(await runtime.BackupToWebDavAsync())); + app.MapPost("/api/backup/webdav/restore", async (V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.RestoreFromWebDavAsync(cancellationToken), successStatus: StatusCodes.Status202Accepted)); + app.MapGet("/api/backup/download", async (V2rayRuntime runtime) => + { + var (result, filePath) = await runtime.CreateBackupArchiveAsync(); + return result.Success && filePath is not null + ? (IResult)new DeleteAfterFileResult(filePath) + : ApiReplies.Operation(result); + }); + app.MapPost("/api/backup/restore", async (IFormFile file, V2rayRuntime runtime, CancellationToken cancellationToken) => + { + if (file.Length == 0) + { + return ApiReplies.Operation(OperationView.Fail("backup_file_empty", ApiMessageKeys.BackupArchiveInvalid)); + } + if (file.Length > 64L * 1024 * 1024) + { + return ApiReplies.Operation(OperationView.Fail("backup_archive_too_large", ApiMessageKeys.BackupArchiveInvalid)); + } + await using var input = file.OpenReadStream(); + return ApiReplies.Operation(await runtime.RestoreFromUploadAsync(input, cancellationToken), successStatus: StatusCodes.Status202Accepted); + }).DisableAntiforgery() + .WithMetadata(new RequestSizeLimitAttribute(65L * 1024 * 1024)) + .WithMetadata(new RequestFormLimitsAttribute { MultipartBodyLengthLimit = 65L * 1024 * 1024 }); + } + + private static void MapCore(WebApplication app) + { + app.MapGet("/api/core-updates", (V2rayRuntime runtime) => + ApiReplies.Ok(runtime.GetCoreUpdateSettings(), "maintenance.updatesLoaded")); + app.MapGet("/api/web-updates", (V2rayRuntime runtime) => + ApiReplies.Ok(runtime.GetWebUpdateTarget(), "maintenance.updatesLoaded")); + app.MapGet("/api/web-updates/check", async (bool? preRelease, bool? useProxy, V2rayRuntime runtime, CancellationToken cancellationToken) => + Results.Ok(await runtime.CheckWebUpdateAsync(preRelease, useProxy, cancellationToken))); + app.MapPost("/api/web-updates/update", (bool? preRelease, bool? useProxy, V2rayRuntime runtime) => + ApiReplies.Operation(runtime.StartWebUpdate(preRelease, useProxy), + successStatus: StatusCodes.Status202Accepted, + failureStatus: StatusCodes.Status409Conflict)); + app.MapGet("/api/core-updates/progress", (V2rayRuntime runtime) => + ApiReplies.Ok(runtime.GetCoreUpdateProgress(), "maintenance.updatesLoaded")); + app.MapPut("/api/core-updates/settings", async (CoreUpdateSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveCoreUpdateSettingsAsync(input))); + app.MapPost("/api/core-updates/batch", (CoreUpdateBatchInput input, V2rayRuntime runtime) => + ApiReplies.Operation(runtime.StartSelectedCoreUpdateBatch(input.Apply), + successStatus: StatusCodes.Status202Accepted, + failureStatus: StatusCodes.Status409Conflict)); + app.MapGet("/api/core-updates/{coreType}/check", async (ECoreType coreType, bool? preRelease, bool? useProxy, V2rayRuntime runtime, CancellationToken cancellationToken) => + Results.Ok(await runtime.CheckCoreUpdateAsync(coreType, preRelease, useProxy, cancellationToken))); + app.MapPost("/api/core-updates/{coreType}/update", (ECoreType coreType, bool? preRelease, bool? useProxy, V2rayRuntime runtime) => + ApiReplies.Operation( + runtime.StartCoreUpdate(coreType, preRelease, useProxy), + successStatus: StatusCodes.Status202Accepted, + failureStatus: StatusCodes.Status409Conflict)); + app.MapGet("/api/core/xray/check-update", async (bool? preRelease, bool? useProxy, V2rayRuntime runtime, CancellationToken cancellationToken) => + Results.Ok(await runtime.CheckXrayUpdateAsync(preRelease ?? false, useProxy ?? true, cancellationToken))); + app.MapPost("/api/core/xray/update", (bool? preRelease, bool? useProxy, V2rayRuntime runtime) => + ApiReplies.Operation( + runtime.StartCoreUpdate(ECoreType.Xray, preRelease ?? false, useProxy ?? true), + successStatus: StatusCodes.Status202Accepted, + failureStatus: StatusCodes.Status409Conflict)); + app.MapPost("/api/core/geo/update", (bool? useProxy, V2rayRuntime runtime) => + ApiReplies.Operation( + runtime.StartGeoUpdate(useProxy), + successStatus: StatusCodes.Status202Accepted, + failureStatus: StatusCodes.Status409Conflict)); + + app.MapPost("/api/core/start", async (V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.StartCoreAsync(null, cancellationToken), failureStatus: StatusCodes.Status409Conflict)); + app.MapPost("/api/core/stop", async (V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.StopCoreAsync(cancellationToken))); + app.MapPost("/api/core/restart", async (V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.RestartCoreAsync(cancellationToken), failureStatus: StatusCodes.Status409Conflict)); + } + + private static void MapEvents(WebApplication app) + { + app.MapGet("/api/events", async (HttpContext context, EventHub events, WebSessionService sessions, IHostApplicationLifetime lifetime) => + { + var session = context.Items.TryGetValue(WebSessionService.SessionSnapshotContextKey, out var sessionValue) + ? sessionValue as WebSessionSnapshot + : null; + if (session is null) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var revoked = session.RevocationToken; + var applicationStopping = lifetime.ApplicationStopping; + // The stream must end as soon as the host starts stopping: Kestrel's + // graceful stop waits for open requests, and an attached client would + // otherwise pin the whole host shutdown budget. + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + context.RequestAborted, + revoked, + applicationStopping); + var cancellationToken = linkedCancellation.Token; + + context.Response.ContentType = "text/event-stream"; + context.Response.Headers["Cache-Control"] = "no-cache"; + context.Response.Headers["X-Accel-Buffering"] = "no"; + + using var heartbeat = new PeriodicTimer(TimeSpan.FromMinutes(1)); + var includeLogs = string.Equals(context.Request.Query["include_logs"], "true", StringComparison.OrdinalIgnoreCase); + await using var subscription = events.Subscribe(cancellationToken, includeLogs).GetAsyncEnumerator(cancellationToken); + var nextEvent = subscription.MoveNextAsync().AsTask(); + var nextHeartbeat = heartbeat.WaitForNextTickAsync(cancellationToken).AsTask(); + try + { + while (!cancellationToken.IsCancellationRequested) + { + var completed = await Task.WhenAny(nextEvent, nextHeartbeat); + if (completed == nextHeartbeat) + { + if (!await nextHeartbeat) + { + break; + } + + if (!await TryWriteSseHeartbeatAsync(context, sessions, session, cancellationToken)) + { + break; + } + nextHeartbeat = heartbeat.WaitForNextTickAsync(cancellationToken).AsTask(); + continue; + } + + if (!await nextEvent) + { + break; + } + + var message = subscription.Current; + var payload = SerializeEventData(message.Data); + await context.Response.WriteAsync($"event: {message.Type}\ndata: {payload}\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + nextEvent = subscription.MoveNextAsync().AsTask(); + } + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested || revoked.IsCancellationRequested) + { + await WriteUnauthorizedSseResponseAsync(context); + } + catch (OperationCanceledException) when (applicationStopping.IsCancellationRequested) + { + // The host is shutting down; end the stream quietly so the web + // server can finish its graceful stop instead of waiting for it. + } + finally + { + // Task.WhenAny can leave either asynchronous wait pending when the stream is revoked. + // Cancel and drain both before disposing the async iterator and timer. + linkedCancellation.Cancel(); + try + { + await nextEvent; + } + catch (OperationCanceledException) + { + } + + try + { + await nextHeartbeat; + } + catch (OperationCanceledException) + { + } + } + }); + } + + internal static async Task TryWriteSseHeartbeatAsync( + HttpContext context, + WebSessionService sessions, + WebSessionSnapshot session, + CancellationToken cancellationToken) + { + if (!sessions.TryValidateSseSessionWithoutRenewal(session.SessionDigest, out _)) + { + await WriteUnauthorizedSseResponseAsync(context); + return false; + } + + await context.Response.WriteAsync(": keep-alive\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + return true; + } + + private static async Task WriteUnauthorizedSseResponseAsync(HttpContext context) + { + if (context.Response.HasStarted || context.RequestAborted.IsCancellationRequested) + { + return; + } + + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + ApiEnvelope.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized), + context.RequestAborted); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Api/WebAuthEndpoints.cs b/v2rayN/v2rayN.WebAPI/Api/WebAuthEndpoints.cs new file mode 100644 index 00000000..a28593e0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/WebAuthEndpoints.cs @@ -0,0 +1,53 @@ +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Api; + +public static class WebAuthEndpoints +{ + public static void MapWebAuthEndpoints(this WebApplication app) + { + app.MapPost("/api/auth/login", (WebLoginRequest request, WebAuthService auth, WebSessionService sessions) => + { + if (string.IsNullOrEmpty(request.Key) + || request.Key.Length > WebAuthService.MaximumKeyLength + || !auth.ValidateManagementKey(request.Key)) + { + return Results.Json( + ApiEnvelope.Fail("management_key_invalid", ApiMessageKeys.CommonUnauthorized), + statusCode: StatusCodes.Status401Unauthorized); + } + + var session = sessions.CreateSession(); + return Results.Ok(new + { + success = true, + data = new { token = session.Token, expiresAt = session.ExpiresAt }, + }); + }).RequireRateLimiting(WebAuthRateLimiting.LoginPolicy); + + app.MapPost("/api/auth/logout", (HttpContext context, WebSessionService sessions) => + { + sessions.Revoke(WebSessionService.ExtractPresentedToken(context)); + return Results.Ok(new { success = true }); + }); + + app.MapPost("/api/auth/sse-ticket", (HttpContext context, WebSessionService sessions) => + { + if (!sessions.TryCreateSseTicket(WebSessionService.ExtractPresentedToken(context), out var ticket)) + { + return Results.Json( + ApiEnvelope.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized), + statusCode: StatusCodes.Status401Unauthorized); + } + + return Results.Ok(new + { + success = true, + data = new { ticket = ticket!.Token, expiresAt = ticket.ExpiresAt }, + }); + }); + } + + public sealed record WebLoginRequest(string? Key); +} diff --git a/v2rayN/v2rayN.WebAPI/Api/WebHealthEndpoint.cs b/v2rayN/v2rayN.WebAPI/Api/WebHealthEndpoint.cs new file mode 100644 index 00000000..89e599cd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/WebHealthEndpoint.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Api; + +internal sealed record WebHealthResponse(string Status); + +internal static class WebHealthEndpoint +{ + public static WebHealthResponse CreateResponse(HttpContext context, V2rayRuntime runtime) + { + // Native launcher/updater probes connect directly to a loopback address. Require + // the same peer/Host pair the probe rule accepts, and no forwarding headers, so + // a local reverse proxy cannot relay internal process diagnostics to a remote + // caller. + if (WebSetupAccessPolicy.IsLocalDiagnosticSource( + context.Connection.RemoteIpAddress, context.Request.Host.Host) + && WebCorsPolicy.IsSameOriginRequest(context.Request) + && !WebSetupAccessPolicy.HasForwardedHeaders(context.Request.Headers)) + { + context.Response.Headers["X-v2rayn-WebAPI-instance-pid"] = Environment.ProcessId.ToString(CultureInfo.InvariantCulture); + context.Response.Headers["X-v2rayn-WebAPI-version"] = WebBuildIdentity.Current.Version; + context.Response.Headers["X-v2rayn-WebAPI-core-state"] = runtime.GetCoreRuntimeState(); + context.Response.Headers["X-v2rayn-WebAPI-core-process-ids"] = string.Join(',', runtime.GetCoreProcessIds()); + context.Response.Headers["X-v2rayn-WebAPI-core-profile-id"] = runtime.GetCoreRuntimeProfileId() ?? string.Empty; + if (ShutdownDiagnostics.CurrentStage is { Length: > 0 } stage) + { + context.Response.Headers["X-v2rayn-WebAPI-shutdown-stage"] = stage; + } + } + + return new WebHealthResponse("ok"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Api/WebSetupEndpoints.cs b/v2rayN/v2rayN.WebAPI/Api/WebSetupEndpoints.cs new file mode 100644 index 00000000..d081fbce --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/WebSetupEndpoints.cs @@ -0,0 +1,45 @@ +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Api; + +public static class WebSetupEndpoints +{ + public static void MapWebSetupEndpoints(this WebApplication app) + { + app.MapGet("/api/setup/status", (HttpContext context, WebAuthService auth) => Results.Ok(new + { + setupRequired = auth.SetupRequired, + environmentKeyConfigured = auth.EnvironmentKeyConfigured, + setupAllowedFromThisRequest = WebSetupAccessPolicy.IsAllowedFromRequest(context), + })); + + app.MapPost("/api/setup", async (WebSetupRequest request, HttpContext context, WebAuthService auth, WebSessionService sessions) => + { + var setupAccessAllowed = WebSetupAccessPolicy.IsAllowedFromRequest(context); + var result = await auth.SetupAsync( + request.Key, + request.ConfirmKey, + setupAccessAllowed, + context.RequestAborted); + + return result switch + { + WebSetupResult.Created => CreateSetupResponse(sessions), + WebSetupResult.Forbidden => Results.Json(new { error = "private_network_required" }, statusCode: StatusCodes.Status403Forbidden), + WebSetupResult.KeyTooShort => Results.BadRequest(new { error = "key_too_short", minimumLength = WebAuthService.MinimumKeyLength }), + WebSetupResult.KeyTooLong => Results.BadRequest(new { error = "key_too_long", maximumLength = WebAuthService.MaximumKeyLength }), + WebSetupResult.KeysDoNotMatch => Results.BadRequest(new { error = "keys_do_not_match" }), + WebSetupResult.AlreadyConfigured => Results.Conflict(new { error = "already_configured" }), + _ => Results.StatusCode(StatusCodes.Status500InternalServerError), + }; + }).RequireRateLimiting(WebAuthRateLimiting.SetupPolicy); + } + + private static IResult CreateSetupResponse(WebSessionService sessions) + { + var session = sessions.CreateSession(); + return Results.Ok(new { setupRequired = false, token = session.Token, expiresAt = session.ExpiresAt }); + } + + public sealed record WebSetupRequest(string? Key, string? ConfirmKey); +} diff --git a/v2rayN/v2rayN.WebAPI/Assets/web-assets.json b/v2rayN/v2rayN.WebAPI/Assets/web-assets.json new file mode 100644 index 00000000..9b8c526c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Assets/web-assets.json @@ -0,0 +1,40 @@ +{ + "assets": [ + { + "rid": "linux-x64", + "arch": "64", + "full": "v2rayN-linux-64-WebAPI.zip", + "update": "v2rayN-linux-64-WebAPI-update.zip" + }, + { + "rid": "linux-arm64", + "arch": "arm64", + "full": "v2rayN-linux-arm64-WebAPI.zip", + "update": "v2rayN-linux-arm64-WebAPI-update.zip" + }, + { + "rid": "win-x64", + "arch": "64", + "full": "v2rayN-windows-64-WebAPI.zip", + "update": "v2rayN-windows-64-WebAPI-update.zip" + }, + { + "rid": "win-arm64", + "arch": "arm64", + "full": "v2rayN-windows-arm64-WebAPI.zip", + "update": "v2rayN-windows-arm64-WebAPI-update.zip" + }, + { + "rid": "linux-riscv64", + "arch": "riscv64", + "full": "v2rayN-linux-riscv64-WebAPI.zip", + "update": "v2rayN-linux-riscv64-WebAPI-update.zip" + }, + { + "rid": "linux-loongarch64", + "arch": "loong64", + "full": "v2rayN-linux-loong64-WebAPI.zip", + "update": "v2rayN-linux-loong64-WebAPI-update.zip" + } + ] +} diff --git a/v2rayN/v2rayN.WebAPI/Configuration/NativeEnvironmentFile.cs b/v2rayN/v2rayN.WebAPI/Configuration/NativeEnvironmentFile.cs new file mode 100644 index 00000000..8dc0d485 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Configuration/NativeEnvironmentFile.cs @@ -0,0 +1,178 @@ +using System.Collections; +using System.Text; + +namespace v2rayN.WebAPI.Configuration; + +internal sealed class NativeEnvironmentConfigurationException : Exception +{ + public NativeEnvironmentConfigurationException(string message, Exception? innerException = null) + : base(message, innerException) + { + } +} + +internal static class NativeEnvironmentFile +{ + internal const int MaximumFileBytes = 64 * 1024; + internal const int MaximumValueBytes = 8 * 1024; + + private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + /// + /// Reads a minimal, non-shell KEY=value format. Comments occupy whole lines; the first '=' + /// separates key and value. A value may be unquoted or wrapped in one matching pair of + /// single/double quotes. Quotes cannot be escaped, multiline values and shell syntax are + /// rejected, and the first occurrence of a duplicate key wins. + /// + internal static void LoadFromExecutableDirectory(string executableDirectory) + { + var path = Path.Combine(Path.GetFullPath(executableDirectory), ".env"); + if (Directory.Exists(path)) + throw new NativeEnvironmentConfigurationException($"Invalid environment configuration in '{path}': expected a regular file."); + if (!File.Exists(path)) return; + + Dictionary values; + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length > MaximumFileBytes) + throw new NativeEnvironmentConfigurationException($"The file exceeds the {MaximumFileBytes}-byte size limit."); + + using var contents = new MemoryStream(); + var buffer = new byte[4096]; + while (true) + { + var read = stream.Read(buffer, 0, buffer.Length); + if (read == 0) break; + if (contents.Length + read > MaximumFileBytes) + throw new NativeEnvironmentConfigurationException($"The file exceeds the {MaximumFileBytes}-byte size limit."); + contents.Write(buffer, 0, read); + } + + values = Parse(StrictUtf8.GetString(contents.GetBuffer(), 0, checked((int)contents.Length))); + } + catch (NativeEnvironmentConfigurationException exception) + { + throw new NativeEnvironmentConfigurationException($"Invalid environment configuration in '{path}': {exception.Message}", exception); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DecoderFallbackException) + { + throw new NativeEnvironmentConfigurationException($"Could not read environment configuration from '{path}'.", exception); + } + + var processEnvironment = ReadProcessEnvironment(); + var mergedValues = Merge(values, processEnvironment); + foreach (var key in values.Keys) + { + // Never replace a variable inherited from the OS, systemd, or the container runtime. + if (!processEnvironment.ContainsKey(key)) + Environment.SetEnvironmentVariable(key, mergedValues[key]); + } + } + + internal static Dictionary Parse(string contents) + { + ArgumentNullException.ThrowIfNull(contents); + if (StrictUtf8.GetByteCount(contents) > MaximumFileBytes) + throw new NativeEnvironmentConfigurationException($"The file exceeds the {MaximumFileBytes}-byte size limit."); + if (contents.Contains('\0')) + throw new NativeEnvironmentConfigurationException("NUL characters are not allowed."); + + if (contents.Length > 0 && contents[0] == '\uFEFF') + contents = contents[1..]; + + var parsed = new Dictionary(StringComparer.Ordinal); + using var reader = new StringReader(contents); + var lineNumber = 0; + while (reader.ReadLine() is { } rawLine) + { + lineNumber++; + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) continue; + + var separator = line.IndexOf('='); + if (separator <= 0) + throw InvalidLine(lineNumber, "expected KEY=value"); + + var key = line[..separator]; + if (!IsValidKey(key)) + throw InvalidLine(lineNumber, "invalid key name"); + + var value = ParseValue(line[(separator + 1)..], lineNumber); + // Duplicate keys are valid but deterministic: the first occurrence wins. + if (!parsed.ContainsKey(key)) parsed.Add(key, value); + } + + return parsed; + } + + internal static Dictionary Merge( + IReadOnlyDictionary fileValues, + IReadOnlyDictionary processEnvironment) + { + var merged = new Dictionary(EnvironmentVariableComparer()); + foreach (var (key, fileValue) in fileValues) + { + // Presence wins even when the process supplied an empty value. + merged[key] = processEnvironment.TryGetValue(key, out var processValue) + ? processValue ?? string.Empty + : fileValue; + } + return merged; + } + + private static Dictionary ReadProcessEnvironment() + { + var values = new Dictionary(EnvironmentVariableComparer()); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + values[(string)entry.Key] = entry.Value?.ToString(); + return values; + } + + private static string ParseValue(string value, int lineNumber) + { + if (value.Length > 0 && value[0] is '\'' or '"') + { + var quote = value[0]; + if (value.Length < 2 || value[^1] != quote) + throw InvalidLine(lineNumber, "malformed quoted value"); + + value = value[1..^1]; + if (value.Contains(quote)) + throw InvalidLine(lineNumber, "quoted values do not support escapes or embedded matching quotes"); + } + else if (value.Contains('\'') || value.Contains('"')) + { + throw InvalidLine(lineNumber, "quotes must wrap the entire value"); + } + + if (StrictUtf8.GetByteCount(value) > MaximumValueBytes) + throw InvalidLine(lineNumber, $"value exceeds the {MaximumValueBytes}-byte size limit"); + + // This is a plain-text format, not a shell: reject shell expansion/escape syntax rather + // than interpreting it. No command or variable expansion is ever performed. + if (value.Contains('$') || value.Contains('`') || value.Contains('\\')) + throw InvalidLine(lineNumber, "shell expansion and escape syntax are not supported"); + + return value; + } + + private static bool IsValidKey(string key) + { + if (key.Length == 0 || !IsAsciiLetter(key[0]) && key[0] != '_') return false; + for (var index = 1; index < key.Length; index++) + { + var character = key[index]; + if (!IsAsciiLetter(character) && !char.IsAsciiDigit(character) && character != '_') return false; + } + return true; + } + + private static bool IsAsciiLetter(char value) => value is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + + private static StringComparer EnvironmentVariableComparer() => + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + private static NativeEnvironmentConfigurationException InvalidLine(int lineNumber, string reason) => + new($"Line {lineNumber}: {reason}."); +} diff --git a/v2rayN/v2rayN.WebAPI/Containerfile b/v2rayN/v2rayN.WebAPI/Containerfile new file mode 100644 index 00000000..5f26498a --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Containerfile @@ -0,0 +1,49 @@ +ARG RID=linux-x64 +ARG WEB_VERSION=0.0.0-dev +ARG WEB_COMMIT=unknown +ARG WEB_BUILD_DATE=unknown +ARG WEB_REPOSITORY=2dust/v2rayN + +FROM --platform=$BUILDPLATFORM docker.io/library/alpine:3.22 AS core-assets +ARG RID +RUN apk add --no-cache ca-certificates curl unzip +COPY v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh /usr/local/bin/fetch-core-bundle +RUN sh /usr/local/bin/fetch-core-bundle "$RID" /out/bin + +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS publish +ARG RID +ARG WEB_VERSION +ARG WEB_COMMIT +ARG WEB_BUILD_DATE +ARG WEB_REPOSITORY +WORKDIR /src +COPY v2rayN/ ./v2rayN/ +RUN NUGET_PACKAGES=/src/.packages/nuget dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj \ + --configuration Release \ + --runtime "$RID" \ + --self-contained true \ + -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:WebVersion="$WEB_VERSION" \ + -p:WebCommit="$WEB_COMMIT" \ + -p:WebBuildDate="$WEB_BUILD_DATE" \ + -p:WebRepository="$WEB_REPOSITORY" \ + --output /out \ + && mkdir -p /runtime-data +# Copy the complete upstream bin/ payload; do not curate or enumerate Core files here. +COPY --from=core-assets /out/bin/ /out/bin/ + +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime +WORKDIR /app +COPY --from=publish --chown=10001:10001 /out/ ./ +COPY --from=publish --chown=10001:10001 /runtime-data/ /data/ +ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \ + V2RAYN_DATA_HOME=/data \ + DOTNET_BUNDLE_EXTRACT_BASE_DIR=/data/.net-bundle \ + V2RAYN_WEB_PROXY_PORT=10808 \ + V2RAYN_WEB_PROXY_LISTEN_ALL=true \ + V2RAYN_WEB_AUTOSTART=true +VOLUME ["/data"] +EXPOSE 5080/tcp 10808/tcp 10808/udp +USER 10001:10001 +ENTRYPOINT ["/app/v2rayN.WebAPI", "--foreground"] diff --git a/v2rayN/v2rayN.WebAPI/Containerfile.dockerignore b/v2rayN/v2rayN.WebAPI/Containerfile.dockerignore new file mode 100644 index 00000000..73a839d6 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Containerfile.dockerignore @@ -0,0 +1,19 @@ +.git +.env +**/.env +.packages +**/.packages +**/.NET +**/bin +**/obj +**/node_modules +**/dist +**/publish +**/.npm-cache +**/TestResults +**/guiConfigs +**/guiLogs +**/guiTemps +**/guiBackups +v2rayN/v2rayN.WebAPI/Tests +v2rayN/v2rayN.WebAPI/core-bin diff --git a/v2rayN/v2rayN.WebAPI/Contracts/ApiModels.cs b/v2rayN/v2rayN.WebAPI/Contracts/ApiModels.cs new file mode 100644 index 00000000..bfb6a402 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Contracts/ApiModels.cs @@ -0,0 +1,572 @@ +using ServiceLib.Enums; + +namespace v2rayN.WebAPI.Contracts; + +public sealed record SubscriptionInput( + string Remarks, + string Url, + string? MoreUrl = null, + bool? Enabled = null, + string? UserAgent = null, + string? RequestHeaders = null, + string? Filter = null, + int? AutoUpdateInterval = null, + string? ConvertTarget = null, + string? Memo = null, + int? Sort = null, + string? PrevProfile = null, + string? NextProfile = null, + int? PreSocksPort = null, + ECoreType? CustomCoreType = null); + +public sealed record SubscriptionView( + string Id, + string Remarks, + string Url, + bool Enabled, + string? MoreUrl, + string? Filter, + int AutoUpdateInterval, + long UpdateTime, + string UserAgent, + string? RequestHeaders, + string? ConvertTarget, + string? Memo, + int Sort, + string? PrevProfile, + string? NextProfile, + int? PreSocksPort, + string? CustomCoreType); + +public sealed record SubscriptionMutationResult(bool Success, string Code, string MessageKey, SubscriptionView? Data); + +public sealed record ProfileView( + string IndexId, + string Remarks, + string Protocol, + string Address, + int Port, + string Network, + string StreamSecurity, + string? SubscriptionId, + string? SubscriptionName, + string CoreType, + int Sort, + int Delay, + decimal Speed, + string? IpInfo, + long TodayUp, + long TodayDown, + long TotalUp, + long TotalDown, + bool IsCurrent, + bool CanTest); + +public sealed record ProfileGroupView(string Id, string Code, string? Name, string? MessageKey, int ProfileCount, bool IsCurrent); + +public sealed record ProfileImportRequest(string Content, string? SubscriptionId = null, bool IsSubscription = false); + +public sealed record ProfileIdsRequest(string[] ProfileIds); + +public sealed record ProfileExportRequest( + string[] ProfileIds, + bool IncludeShareUris = true, + bool Base64ShareUris = false, + bool IncludeInnerUri = false, + bool IncludeClientConfig = true); + +public sealed record MoveProfilesRequest(string[] ProfileIds, string SubscriptionId); + +public sealed record SortProfilesRequest(string? SubscriptionId, string Column, bool Ascending); + +public sealed record MoveProfileRequest(string ProfileId, EMove Direction, int Position = -1); + +public sealed record ProfileGroupSelectionRequest(string? SubscriptionId); + +public sealed record SpeedTestRequest(ESpeedActionType Action, string[]? ProfileIds = null); + +public sealed record SubscriptionUpdateRequest(string? SubscriptionId = null, bool UseProxy = false); + +public sealed record InboundSettingsView( + int LocalPort, + bool SecondLocalPortEnabled, + bool UdpEnabled, + bool SniffingEnabled, + string[] DestOverride, + bool RouteOnly, + bool AllowLANConn, + bool NewPort4LAN, + string? User, + string? Pass); + +public sealed record CoreSettingsView( + bool LogEnabled, + string? Loglevel, + string? DefFingerprint, + string? DefUserAgent, + string? SendThrough, + string? BindInterface, + int? Mux4RayConcurrency, + int? Mux4RayXudpConcurrency, + string? Mux4RayXudpProxyUDP443, + string? Mux4SboxProtocol, + bool? Mux4SboxPadding, + bool EnableCacheFile4Sbox, + int Hy2UpMbps, + int Hy2DownMbps, + bool EnableFragment, + bool EnableFinalFragment, + string? FragmentPackets, + string[] FragmentLengths, + string[] FragmentDelays, + string? FragmentMaxSplit); + +public sealed record AppSettingsView( + bool EnableStatistics, + bool DisplayRealTimeSpeed, + bool KeepOlderDedupl, + int GeoAutoUpdateInterval, + string? RootCertProvider, + string? GeoSourceUrl, + string? SrsSourceUrl, + string? RouteRulesTemplateSourceUrl, + string? SubConvertUrl); + +public sealed record SpeedTestSettingsView( + int SpeedTestTimeout, + string? SpeedTestUrl, + string? SpeedPingTestUrl, + int MixedConcurrencyCount, + string? IPAPIUrl, + string? UdpTestTarget); + +public sealed record WebSettingsOptionsView( + string[] LogLevels, + string[] RootCertProviders, + string[] Fingerprints, + string[] UserAgents, + string[] Mux4SboxProtocols, + string[] Mux4RayXudpProxyUDP443Options, + string[] FragmentPacketsOptions, + string[] DestOverrideProtocols, + string[] RoutingBasicDomainStrategies, + string[] RoutingBasicDomainStrategies4Singbox, + string[] RoutingProfileDomainStrategies, + string[] RoutingProfileDomainStrategies4Singbox, + int[] MixedConcurrencyCounts, + int[] SpeedTestTimeouts, + string[] SpeedTestUrls, + string[] SpeedPingTestUrls, + string[] UdpTestTargets, + string[] IPAPIUrls, + string[] SubConvertUrls, + string[] GeoFilesSources, + string[] SingboxRulesetSources, + string[] RoutingRulesSources); + +public sealed record CoreTypeMapping(EConfigType ConfigType, ECoreType CoreType); + +public sealed record WebSettingsView( + InboundSettingsView Inbound, + CoreSettingsView Core, + AppSettingsView App, + SpeedTestSettingsView SpeedTest, + string? DomainStrategy, + string? DomainStrategy4Singbox, + IReadOnlyList CoreTypes, + WebSettingsOptionsView Options, + bool ShowIpInfoColumn); + +public sealed record InboundSettingsInput( + int LocalPort, + bool SecondLocalPortEnabled, + bool UdpEnabled, + bool SniffingEnabled, + string[]? DestOverride, + bool RouteOnly, + bool AllowLANConn, + bool NewPort4LAN, + string? User, + string? Pass); + +public sealed record CoreSettingsInput( + bool LogEnabled, + string? Loglevel, + string? DefFingerprint, + string? DefUserAgent, + string? SendThrough, + string? BindInterface, + int? Mux4RayConcurrency, + int? Mux4RayXudpConcurrency, + string? Mux4RayXudpProxyUDP443, + string? Mux4SboxProtocol, + bool? Mux4SboxPadding, + bool EnableCacheFile4Sbox, + int Hy2UpMbps, + int Hy2DownMbps, + bool EnableFragment, + bool EnableFinalFragment, + string? FragmentPackets, + string[]? FragmentLengths, + string[]? FragmentDelays, + string? FragmentMaxSplit); + +public sealed record AppSettingsInput( + bool EnableStatistics, + bool DisplayRealTimeSpeed, + bool KeepOlderDedupl, + int GeoAutoUpdateInterval, + string? RootCertProvider, + string? GeoSourceUrl, + string? SrsSourceUrl, + string? RouteRulesTemplateSourceUrl, + string? SubConvertUrl); + +public sealed record SpeedTestSettingsInput( + int SpeedTestTimeout, + string SpeedTestUrl, + string SpeedPingTestUrl, + int MixedConcurrencyCount, + string IPAPIUrl, + string UdpTestTarget); + +public sealed record RoutingSettingsInput(string? DomainStrategy, string? DomainStrategy4Singbox); + +public sealed record CoreConfigTemplateView( + string Id, + string Remarks, + bool Enabled, + ECoreType CoreType, + string? Config, + string? TunConfig, + bool? AddProxyOnly, + string? ProxyDetour); + +public sealed record CoreConfigTemplateInput( + string? Remarks, + bool Enabled, + string? Config, + string? TunConfig, + bool? AddProxyOnly, + string? ProxyDetour); + +public sealed record DnsProfileView( + string Id, + string Remarks, + bool Enabled, + ECoreType CoreType, + bool UseSystemHosts, + string? NormalDNS, + string? TunDNS, + string? DomainStrategy4Freedom, + string? DomainDNSAddress); + +public sealed record DnsProfileInput( + string? Remarks, + bool Enabled, + bool UseSystemHosts, + string? NormalDNS, + string? TunDNS, + string? DomainStrategy4Freedom, + string? DomainDNSAddress); + +public sealed record DnsDefaultProfileView(ECoreType CoreType, string NormalDNS, string TunDNS); + +public sealed record DnsEditorOptionsView( + string[] CoreTypes, + string[] DomainStrategies4Freedom, + string[] DomainStrategies4Singbox, + string[] DomainDnsAddresses, + string[] DirectDnsAddresses, + string[] RemoteDnsAddresses, + string[] BootstrapDnsAddresses, + string[] FakeIpRanges, + string[] ExpectedIps); + +public sealed record SubscriptionEditorOptionsView(string[] ConvertTargets, string[] CustomCoreTypes); + +public sealed record ProfileEditorOptionsView( + string[] CoreTypes, + string[] ConfigTypes, + string[] SingboxOnlyConfigTypes, + string[] Networks, + string DefaultNetwork, + string DefaultStreamSecurity, + string DefaultSecurity, + string DefaultVlessEncryption, + string DefaultXhttpMode, + string DefaultGrpcMode, + int DefaultWireGuardMtu, + string[] RawHeaderTypes, + string[] XhttpModes, + string[] KcpHeaderTypes, + string[] GrpcModes, + string[] VmessSecurities, + string[] Flows, + string[] TuicCongestionControls, + string[] NaiveCongestionControls, + string[] Fingerprints, + string[] Alpns, + IReadOnlyDictionary ShadowsocksSecurities, + string[] StreamSecurityTypes, + string[] MultipleLoadStrategies); + +public sealed record WebEditorOptionsView( + SubscriptionEditorOptionsView Subscriptions, + ProfileEditorOptionsView Profiles, + DnsEditorOptionsView Dns); + +public sealed record RouteRulesImportInput(string Content, bool Append = false); + +public sealed record RouteRulesUrlImportInput(bool Append = false); + +public sealed record ProfileExportItem(string IndexId, string Remarks, string Format, string Content); + +public sealed record TrafficView(long ProxyUp, long ProxyDown, long DirectUp, long DirectDown); + +public sealed record CoreUpdateCheckView(bool UpdateAvailable, string? Version, bool IsUpToDate = false, string? Detail = null); + +public sealed record CoreUpdateTargetView( + string CoreType, + string NameKey, + bool IsSupported, + bool CanInstall, + bool SupportsPreRelease, + bool Selected, + string? UnsupportedReasonKey); + +public sealed record CoreUpdateSettingsView( + IReadOnlyList Targets, + bool GeoFilesSelected, + bool PreRelease, + bool UseProxy, + IReadOnlyList? CheckPreReleaseCoreTypes = null); + +public sealed record CoreUpdateSettingsInput( + string[] SelectedCoreTypes, + bool PreRelease, + bool UseProxy, + string[]? CheckPreReleaseCoreTypes = null); + +public sealed record CoreUpdateBatchInput(bool Apply); + +public sealed record CoreUpdateProgressView( + string CoreType, + string Phase, + bool IsComplete, + bool Success, + bool CoreWasRunning, + string? Version, + string? Detail, + bool Batch = false, + bool? RollbackSucceeded = null); + +public sealed record WebUpdateTargetView( + string Name, + string Version, + string Commit, + string BuildDate, + string Rid, + string Deployment, + bool IsSupported, + bool CanCheck, + bool CanInstall, + bool Selected, + string? InstallReasonKey, + string? LatestVersion = null); + +public sealed record WebUpdateCheckView( + bool UpdateAvailable, + string CurrentVersion, + string CurrentCommit, + string Rid, + string? LatestVersion, + string? LatestCommit, + bool CanInstall, + string? InstallReasonKey, + string? Detail = null); + +public sealed record WebDavSettingsView(string? Url, string? UserName, string? DirName, bool HasPassword); + +public sealed record WebDavSettingsInput(string? Url, string? UserName, string? Password, string? DirName); + +public sealed record RouteRulesMoveRequest(string RuleId, EMove Direction, int Position = -1); + +public sealed record CoreTypeMappingsInput(IReadOnlyList Mappings); + +public sealed record SettingsApplyInput( + InboundSettingsInput Inbound, + CoreSettingsInput Core, + AppSettingsInput Application, + SpeedTestSettingsInput SpeedTest, + IReadOnlyList CoreTypes, + string? DomainStrategy, + string? DomainStrategy4Singbox); + +public sealed record ApiEnvelope(bool Success, string Code, string MessageKey, T? Data) +{ + public static ApiEnvelope Ok(T? data, string messageKey = ApiMessageKeys.CommonLoaded) => new(true, "ok", messageKey, data); + + public static ApiEnvelope Fail(string code, string messageKey, T? data = default) => new(false, code, messageKey, data); +} + +public sealed record OperationView(bool Success, string Code, string MessageKey, object? Data = null) +{ + public static OperationView Ok(string messageKey, object? data = null) => new(true, "ok", messageKey, data); + + public static OperationView Fail(string code, string messageKey, object? data = null) => new(false, code, messageKey, data); +} + +public static class ApiMessageKeys +{ + public const string CommonLoaded = "common.loaded"; + public const string CommonCompleted = "common.completed"; + public const string CommonSaved = "common.saved"; + public const string CommonDeleted = "common.deleted"; + public const string CommonNotFound = "errors.notFound"; + public const string CommonInvalidInput = "errors.invalidInput"; + public const string CommonUnauthorized = "errors.unauthorized"; + public const string CommonInternal = "errors.internal"; + public const string CommonRouteNotFound = "errors.routeNotFound"; + public const string CommonCoreRestartRequired = "settings.savedRestartRequired"; + public const string CoreStarted = "core.started"; + public const string CoreStopped = "core.stopped"; + public const string CoreRestarted = "core.restarted"; + public const string CoreStartFailed = "core.startFailed"; + public const string CoreStopFailed = "core.stopFailed"; + public const string CoreNotRunning = "core.notRunning"; + public const string CoreRuntimeBusy = "core.runtimeBusy"; + public const string WebUpdateCheckFailed = "maintenance.webUpdateCheckFailed"; + public const string CoreBinaryMissing = "core.binaryMissing"; + public const string CorePortInUse = "core.portInUse"; + public const string CoreTunNotSupported = "core.tunNotSupported"; + public const string CoreUnsupported = "core.unsupported"; + public const string ProfileNotFound = "profiles.notFound"; + public const string ProfileDetailLoaded = "profiles.detailLoaded"; + public const string ProfileNoneSelected = "profiles.noneSelected"; + public const string ProfileInvalid = "profiles.invalid"; + public const string ProfileUnsupportedCore = "profiles.unsupportedCore"; + public const string ProfileSelectRequired = "profiles.selectRequired"; + public const string ProfileNotInGroup = "profiles.notInGroup"; + public const string ProfileImported = "profiles.imported"; + public const string ProfileDeleted = "profiles.deleted"; + public const string ProfileCopied = "profiles.copied"; + public const string ProfileMoved = "profiles.moved"; + public const string ProfileOrderSaved = "profiles.orderSaved"; + public const string ProfileDeduplicated = "profiles.deduplicated"; + public const string ProfileInvalidRemoved = "profiles.invalidRemoved"; + public const string ProfileGrouped = "profiles.grouped"; + public const string ProfileSaved = "profiles.saved"; + public const string ProfileSaveFailed = "profiles.saveFailed"; + public const string SubscriptionNotFound = "subscriptions.notFound"; + public const string SubscriptionAdded = "subscriptions.added"; + public const string SubscriptionSaved = "subscriptions.saved"; + public const string SubscriptionDeleted = "subscriptions.deleted"; + public const string SubscriptionUpdateStarted = "subscriptions.updateStarted"; + public const string SubscriptionUpdateBusy = "subscriptions.updateBusy"; + public const string SubscriptionInvalidUrl = "subscriptions.invalidUrl"; + public const string SubscriptionNameRequired = "subscriptions.nameRequired"; + public const string SubscriptionSaveFailed = "subscriptions.saveFailed"; + public const string SpeedTestStarted = "speedtest.started"; + public const string SpeedTestBusy = "speedtest.busy"; + public const string SpeedTestUnsupportedCore = "speedtest.unsupportedCore"; + public const string SpeedTestSelectRequired = "speedtest.selectRequired"; + public const string SpeedTestInvalidProfile = "speedtest.invalidProfile"; + public const string SpeedTestSettingsSaved = "speedtest.settingsSaved"; + public const string SpeedTestResult = "speedtest.result"; + public const string SettingsInvalidPort = "settings.invalidPort"; + public const string SettingsInvalidCoreLogLevel = "settings.invalidCoreLogLevel"; + public const string SettingsInvalidCoreValue = "settings.invalidCoreValue"; + public const string SettingsInvalidFragment = "settings.invalidFragment"; + public const string SettingsInvalidSpeedTest = "settings.invalidSpeedTest"; + public const string SettingsCoreApplyFailed = "settings.coreApplyFailed"; + public const string SettingsInvalidRoutingStrategy = "settings.invalidRoutingStrategy"; + public const string DnsProfileNotFound = "dns.profileNotFound"; + public const string DnsSaveFailed = "dns.saveFailed"; + public const string RoutingNameRequired = "routing.nameRequired"; + public const string RoutingProfileNotFound = "routing.profileNotFound"; + public const string RoutingRulesInvalid = "routing.rulesInvalid"; + public const string RoutingRuleNotFound = "routing.ruleNotFound"; + public const string RoutingSaveFailed = "routing.saveFailed"; + public const string RegionalPresetInvalid = "settings.invalidRegionalPreset"; + public const string RegionalPresetFailed = "settings.regionalPresetFailed"; + public const string XrayUpdateCheckFailed = "core.updateCheckFailed"; + public const string XrayUpdateStarted = "core.updateStarted"; + public const string XrayUpdateBusy = "core.updateBusy"; + public const string XrayUpdateFailed = "core.updateFailed"; + public const string XrayUpdateCompleted = "core.updateCompleted"; + public const string XrayUpdateAvailable = "core.updateAvailable"; + public const string XrayUpdateCurrent = "core.updateCurrent"; + public const string CoreUpdateCheckFailed = "maintenance.updateCheckFailed"; + public const string CoreUpdateStarted = "maintenance.updateStarted"; + public const string CoreUpdateBatchStarted = "maintenance.batchStarted"; + public const string CoreUpdateBusy = "maintenance.updateBusy"; + public const string CoreUpdateFailed = "maintenance.updateFailed"; + public const string CoreUpdateCompleted = "maintenance.updateCompleted"; + public const string CoreUpdateSettingsSaved = "maintenance.updateSettingsSaved"; + public const string CoreUpdateUnsupported = "maintenance.updateUnsupported"; + public const string CoreUpdateAvailable = "maintenance.updateAvailableGeneric"; + public const string CoreUpdateCurrent = "maintenance.upToDateGeneric"; + public const string GeoUpdateStarted = "updates.geoStarted"; + public const string GeoUpdateBusy = "updates.geoBusy"; + public const string GeoUpdateProgress = "updates.geoProgress"; + public const string GeoUpdateNotSelected = "maintenance.geoNotSelected"; + public const string SubscriptionUpdateProgress = "subscriptions.updateProgress"; + public const string ProfilesAllGroup = "profiles.allGroup"; + public const string BackupCreated = "backup.created"; + public const string BackupRestoreStarted = "backup.restoreStarted"; + public const string BackupRestoreFailed = "backup.restoreFailed"; + public const string BackupRestoreProbeUnavailable = "backup.restoreProbeUnavailable"; + public const string BackupRestoreSetSidUnavailable = "backup.restoreSetSidUnavailable"; + public const string BackupRestoreExecutableUnavailable = "backup.restoreExecutableUnavailable"; + public const string BackupArchiveInvalid = "backup.archiveInvalid"; + public const string BackupDatabaseIncompatible = "backup.databaseIncompatible"; + public const string WebDavSettingsSaved = "backup.webdavSettingsSaved"; + public const string WebDavCheckSucceeded = "backup.webdavCheckSucceeded"; + public const string WebDavCheckFailed = "backup.webdavCheckFailed"; + public const string WebDavBackupSucceeded = "backup.webdavBackupSucceeded"; + public const string WebDavRestoreFailed = "backup.webdavRestoreFailed"; +} + +public sealed record ListenerView( + string Name, + string[] Protocols, + string ListenAddress, + int Port, + bool Listening); + +public sealed record StatusView( + bool CoreRunning, + string? CoreType, + string? CurrentProfileId, + string? CurrentProfileName, + DateTimeOffset? CoreStartedAt, + ListenerView[] Listeners, + bool XrayAvailable, + string Runtime, + int ProfileCount, + int SubscriptionCount, + DateTimeOffset CheckedAt, + bool StatisticsEnabled, + TrafficView? Traffic, + string RuntimeState = "stopped", + int? ConfiguredProxyPort = null, + int? RunningProxyPort = null, + string? RunningProfileId = null, + string? RunningProfileName = null, + int? ApiPort = null, + int[]? CoreProcessIds = null, + string? RuntimeFailure = null, + string? WebVersion = null, + string? GitCommit = null, + string? RuntimeIdentifier = null, + string? WebBuildDate = null, + bool SniffingEnabled = false, + string[]? DestOverride = null, + string[]? Capabilities = null); + +public sealed record LogView(DateTimeOffset Timestamp, string Source, string Message, long Generation = 0); + +public sealed record LogPageView(IReadOnlyList Items, int Page, int PageSize, int Total, int TotalPages); + +public sealed record WebEvent(string Type, object? Data, DateTimeOffset Timestamp); diff --git a/v2rayN/v2rayN.WebAPI/Contracts/WebApiCapabilities.cs b/v2rayN/v2rayN.WebAPI/Contracts/WebApiCapabilities.cs new file mode 100644 index 00000000..8541886d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Contracts/WebApiCapabilities.cs @@ -0,0 +1,20 @@ +namespace v2rayN.WebAPI.Contracts; + +public static class WebApiCapabilities +{ + public static string[] Current => + [ + "auth.sessions", + "events.sse", + "editor.options", + "profiles", + "subscriptions", + "routing", + "dns", + "settings", + "backup.restore", + "core.runtime", + "core.updates", + "WebAPI.self-update", + ]; +} diff --git a/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.env.example b/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.env.example new file mode 100644 index 00000000..5d9f33cd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.env.example @@ -0,0 +1 @@ +V2RAYN_WEB_API_KEY= diff --git a/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.service b/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.service new file mode 100644 index 00000000..cd173b44 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.service @@ -0,0 +1,32 @@ +[Unit] +Description=v2rayN Web API +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=v2rayn +Group=v2rayn +WorkingDirectory=/opt/v2rayn-web +ExecStart=/opt/v2rayn-web/v2rayN.WebAPI --foreground +EnvironmentFile=/etc/v2rayn-web.env +Environment=ASPNETCORE_URLS=http://0.0.0.0:5080 +Environment=V2RAYN_WEB_AUTOSTART=true +Environment=V2RAYN_DATA_HOME=/var/lib/v2rayn-web +Environment=DOTNET_BUNDLE_EXTRACT_BASE_DIR=/var/lib/v2rayn-web/.net-bundle +StateDirectory=v2rayn-web +StateDirectoryMode=0750 +UMask=0077 +Restart=always +RestartSec=3 +KillSignal=SIGTERM +SendSIGKILL=no +TimeoutStopSec=30s +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/v2rayn-web + +[Install] +WantedBy=multi-user.target diff --git a/v2rayN/v2rayN.WebAPI/FEATURE-MAP.md b/v2rayN/v2rayN.WebAPI/FEATURE-MAP.md new file mode 100644 index 00000000..699901f0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/FEATURE-MAP.md @@ -0,0 +1,56 @@ +# v2rayN.WebAPI feature map + +The API uses existing public ServiceLib capabilities without changing desktop or Core behavior. +API scheduling and cancellation live in `V2rayRuntime.Scheduling.cs`; the desktop `TaskManager` +is not registered. `RuntimeMutationGate` serializes API-side writes to configuration and SQLite. + +## ServiceLib → WebAPI capabilities + +| Capability | Existing ServiceLib capability | WebAPI | +|---|---|---| +| Runtime status and operations | `MainWindowViewModel`; `StatusBarViewModel`; `CoreManager` | `GET /api/status`; `GET /api/operations` | +| Subscription groups and current group | `AppManager.SubItems`; `Config.SubIndexId`; `ProfilesViewModel.RefreshSubscriptions` | `GET /api/profile-groups`; `PUT /api/profile-groups/current` | +| Profiles and filtering | `AppManager.ProfileModels/ProfileItems`; `ProfileExManager`; `StatisticsManager`; `Utils.IsRegexMatch` | `GET /api/profiles?subscriptionId=&filter=`; filtering uses ServiceLib's regex guard | +| Current profile and Core lifecycle | `ConfigHandler.SetDefaultServerIndex`; `CoreConfigContextBuilder.BuildAll`; `CoreManager.LoadCore` | `POST /api/profiles/{id}/select`; `POST /api/core/start`, `/stop`, `/restart` | +| Add/edit profiles and group profiles | `ConfigHandler.AddServer/AddServerCommon`; protocol-specific `Add*Server`; `ProfileItem.IsValid`; `GroupProfileManager` | `GET /api/profiles/{id}`; `POST /api/profiles`; `PUT /api/profiles/{id}` | +| Import profiles and custom configuration | `ConfigHandler.AddBatchServers`; `FmtHandler.ResolveConfig`; custom-config parsers | `POST /api/profiles/import` | +| Remove/copy profiles | `ConfigHandler.RemoveServers/CopyServer` | `DELETE /api/profiles`; `POST /api/profiles/copy` | +| Deduplicate and remove failed test results | `ConfigHandler.DedupServerList/RemoveInvalidServerResult` | `POST /api/profiles/deduplicate`; `DELETE /api/profiles/invalid-test-results` | +| Move/reorder profiles | `ConfigHandler.MoveToGroup/MoveServer`; `ProfileExManager.SetSort` | `POST /api/profiles/move-to-group`; `POST /api/profiles/move` | +| Sort profiles | `ConfigHandler.SortServers`; `EServerColName` | `POST /api/profiles/sort` | +| Latency, UDP and speed tests | `SpeedtestService.RunLoop/ExitLoop`; `ESpeedActionType` | `POST /api/profiles/{id}/latency`; `POST/DELETE /api/speedtests`; progress/results through SSE | +| Share/export profiles and Core configuration | `FmtHandler.GetShareUri`; `InnerFmt.ToUri`; `CoreConfigContextBuilder.Build`; `CoreConfigHandler.GenerateClientConfig` | `POST /api/profiles/export` | +| Generate all-node/regional groups | `ConfigHandler.AddGroupAllServer/AddGroupRegionServer` | `POST /api/profile-groups/generate/all`; `POST /api/profile-groups/generate/regions` | +| Manage/share subscriptions | `SubItem`; `ConfigHandler.AddSubItem/DeleteSubItem`; `SubSettingViewModel` | `GET/POST/PUT/DELETE /api/subscriptions`; `GET /api/subscriptions/{id}/share` | +| Update subscriptions | `SubscriptionHandler.UpdateProcess` | `POST /api/subscriptions/update`; group/proxy options; progress through `GET /api/operations` and SSE | +| Scheduled subscription updates | `SubItem.AutoUpdateInterval/UpdateTime`; `SubscriptionHandler.UpdateProcess` | `V2rayRuntime.Scheduling.RunScheduledSubscriptionUpdatesAsync` checks intervals and starts API-owned background tasks | +| Core/GeoFiles updates | `CoreInfoManager`; `UpdateService`; `CoreManager` | `GET/POST /api/core-updates/{coreType}/check`, `/update`; `POST /api/core-updates/batch`; `POST /api/core/geo/update`; GeoFiles backup/verify/rollback | +| API self-update (separate from Desktop) | API-owned release manifest, package validation and native helper | `GET /api/web-updates`; `GET /api/web-updates/check`; `POST /api/web-updates/update`; protected systemd/container deployments are check-only | +| Inbound listeners | `Config.Inbound`; `StatusBarViewModel.InboundDisplayStatus`; `AppManager.GetLocalPort` | `GET /api/status`; `GET/PUT /api/settings/inbound` | +| Traffic/statistics | `StatisticsManager`; `ServerStatItem`; `ServerSpeedItem` | Profile byte counters; `GET /api/status`; SSE `traffic`; `DELETE /api/statistics` | +| Logs | `MsgViewModel`; `AppEvents.SendMsgViewRequested`; `CoreManager` callback | `GET/DELETE /api/logs`; `GET /api/logs/page?page=&pageSize=&filter=`; `/api/events` log/state/progress events | +| General settings | `OptionSettingViewModel`; `Config`; `ConfigHandler.SaveConfig` | `GET /api/settings`; atomic `PUT /api/settings/apply`; section settings APIs | +| Routing profiles | `AppManager.RoutingItems`; `ConfigHandler.SaveRoutingItem/RemoveRoutingItem/SetDefaultRouting/InitRouting` | `GET/POST/PUT/DELETE /api/settings/routing-profiles`; `/activate`; `/import`; `PUT /api/settings/routing` | +| Routing rules | `RoutingItem.RuleSet`; `RulesItem`; `ConfigHandler.AddBatchRoutingRules/MoveRoutingRule` | `GET/PUT /api/settings/routing-profiles/{id}/rules`; `/rules/import`; `/rules/move`; `/rules/{ruleId}` | +| Simple/per-Core DNS | `Config.SimpleDNSItem`; `DNSItem`; `ConfigHandler.SaveDNSItems/GetExternalDNSItem` | `GET/PUT /api/settings/dns/simple`; `GET/PUT /api/settings/dns/profiles` | +| Core configuration templates | `FullConfigTemplateItem`; `ConfigHandler.SaveFullConfigTemplate` | `GET /api/settings/core-templates`; `PUT /api/settings/core-templates/{coreType}`; stored TUN fields are preserved, not editable | +| Regional presets | `ConfigHandler.ApplyRegionalPreset/InitRouting` | `POST /api/settings/regional-presets/{preset}` | +| WebDAV and backup/restore | `WebDavManager`; existing `guiConfigs/` ZIP format; `FileUtils` | `GET/PUT /api/settings/webdav`; `/api/backup/webdav/check`; `/webdav`; `/webdav/restore`; `GET /api/backup/download`; multipart `POST /api/backup/restore` | + +Core selection/startup remain delegated to ServiceLib's `CoreInfoManager` and `CoreManager`, +and require the selected Core executable. Generated speed-test configurations support Xray +and sing-box; TCPing is available for any profile with a port. Release/container artifacts +copy the complete architecture-matched official `2dust/v2rayN-core-bin` payload. + +## Unsupported desktop capabilities + +TUN settings/control, desktop system proxy, Clash Proxies/Connections, tray icons, global +hotkeys, window placement, desktop startup integration and native dialogs/scanners are outside +the headless API boundary. Core start/restart/autostart rejects generated TUN configurations +without changing saved settings. These exclusions are not claims of full desktop parity. + +## Response contract + +JSON field names and enum/state codes are stable identifiers. Operation/error responses use +`{ success, code, messageKey, data }`; SSE uses stable event/type codes plus data. User-provided +names, URLs, remarks and log lines remain data. The API does not render HTML or translate responses. diff --git a/v2rayN/v2rayN.WebAPI/Launcher/Locales/en-US.json b/v2rayN/v2rayN.WebAPI/Launcher/Locales/en-US.json new file mode 100644 index 00000000..0eb42913 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/Locales/en-US.json @@ -0,0 +1 @@ +{"launcher":{"started":"v2rayN.WebAPI is running in the background.\nAPI health endpoint: {url}\nThe service is detached from this terminal; Ctrl+C will not stop it.\nStop it with: {command} --stop\nRun in the foreground with: {command} --foreground","alreadyRunningOpening":"v2rayN.WebAPI is already running.\nOpening the API health endpoint: {url}\nStop it with: {command} --stop","alreadyRunning":"v2rayN.WebAPI is already running.\nAPI health endpoint: {url}\nStop it with: {command} --stop","foregroundStarted":"v2rayN.WebAPI is running in the foreground.\nAPI health endpoint: {url}\nPress Ctrl+C to stop the service gracefully.","stopNotRunning":"v2rayN.WebAPI is not running for this data directory.","stopSucceeded":"v2rayN.WebAPI stopped.","stopIdentityUnverified":"Cannot safely stop v2rayN.WebAPI: instance identity could not be verified; no signal was sent.","stopUnprobeable":"Cannot safely stop v2rayN.WebAPI: the configured listeners have no loopback HTTP health endpoint to verify the instance (for example HTTPS-only or an explicit non-loopback address); no signal was sent.","stopSupervisorManaged":"This instance is managed by systemd. Use systemctl stop for its unit; --stop will not compete with the supervisor.","stopCoreStillRunning":"The Web host stopped, but a tracked Core child process is still running. Check the service log before force-stopping it.","stopSignalFailed":"Could not send SIGTERM to v2rayN.WebAPI.","stopTimedOut":"v2rayN.WebAPI received SIGTERM but did not stop within the expected time.","stopTimedOutStage":"Last observed cleanup stage: {stage}.","stopUnsupported":"--stop is supported only on Linux.","startFailed":"v2rayN.WebAPI failed to start; run with --foreground to see details.","startUnprobeable":"v2rayN.WebAPI cannot start in the background: the configured listeners have no loopback HTTP health endpoint to verify the instance (for example HTTPS-only or an explicit non-loopback address). Run with --foreground instead.","existingUnhealthy":"An instance for this data directory is starting but is not ready yet; try again shortly.","instanceInUse":"A v2rayN.WebAPI instance is already using this data directory.","help":"v2rayN.WebAPI - the headless Web API service of v2rayN\n\nUsage: v2rayN.WebAPI [options]\n\nOptions:\n --foreground Run in the foreground; Ctrl+C stops it safely and logs stay on the terminal\n --background Run in the background, detached from the terminal (Linux default in a terminal)\n --no-open Do not open the browser on startup\n --stop Stop the instance running for the same data directory (Linux only)\n --help, -h Show this help and exit\n\nOther arguments (for example --urls http://0.0.0.0:5080) are passed to the web host.\n\nCommon configuration (environment variables; can live in an .env file next to the executable):\n V2RAYN_WEB_API_KEY Management Key; required when listening beyond 127.0.0.1\n ASPNETCORE_URLS Listen address, default http://127.0.0.1:5080\n V2RAYN_WEB_ALLOWED_ORIGINS Allowed cross-origin sources\n V2RAYN_WEB_AUTOSTART=true Start the Core together with the web service\n V2RAYN_WEB_PROXY_PORT Local proxy port (used by update checks)\n\nDocs: https://github.com/2dust/v2rayN/tree/master/v2rayN/v2rayN.WebAPI"}} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-CN.json b/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-CN.json new file mode 100644 index 00000000..e71a7954 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-CN.json @@ -0,0 +1 @@ +{"launcher":{"started":"v2rayN.WebAPI 已在后台启动。\nAPI 健康端点:{url}\n服务已脱离当前终端,Ctrl+C 不会停止它。\n停止服务:{command} --stop\n前台运行:{command} --foreground","alreadyRunningOpening":"v2rayN.WebAPI 已在运行。\n正在打开 API 健康端点:{url}\n停止服务:{command} --stop","alreadyRunning":"v2rayN.WebAPI 已在运行。\nAPI 健康端点:{url}\n停止服务:{command} --stop","foregroundStarted":"v2rayN.WebAPI 正在前台运行。\nAPI 健康端点:{url}\n按 Ctrl+C 可安全停止服务。","stopNotRunning":"v2rayN.WebAPI 未在此数据目录运行。","stopSucceeded":"v2rayN.WebAPI 已停止。","stopIdentityUnverified":"无法安全停止 v2rayN.WebAPI:无法确认实例身份,未发送信号。","stopUnprobeable":"无法安全停止 v2rayN.WebAPI:当前监听配置没有可供验证实例身份的回环 HTTP 健康端点(例如仅 HTTPS 或仅绑定非回环地址),未发送信号。","stopSupervisorManaged":"此实例由 systemd 管理,请使用 systemctl stop 停止对应服务;--stop 不会与服务管理器争抢生命周期。","stopCoreStillRunning":"Web 主进程已停止,但检测到 Core 子进程仍在运行。请先检查服务日志,再决定是否强制结束。","stopSignalFailed":"无法向 v2rayN.WebAPI 发送 SIGTERM。","stopTimedOut":"v2rayN.WebAPI 已收到 SIGTERM,但未能在预期时间内停止。","stopTimedOutStage":"最后观察到的清理阶段:{stage}。","stopUnsupported":"--stop 仅支持 Linux。","startFailed":"v2rayN.WebAPI 启动失败;请使用 --foreground 查看详细日志。","startUnprobeable":"v2rayN.WebAPI 无法在后台启动:当前监听配置没有可供验证实例身份的回环 HTTP 健康端点(例如仅 HTTPS 或仅绑定非回环地址)。请改用 --foreground 运行。","existingUnhealthy":"检测到同一数据目录的实例正在启动,但尚未就绪;请稍后重试。","instanceInUse":"同一数据目录下已有 v2rayN.WebAPI 实例正在运行。","help":"v2rayN.WebAPI - v2rayN 的 Web API 服务(无界面)\n\n用法:v2rayN.WebAPI [选项]\n\n选项:\n --foreground 前台运行;Ctrl+C 可安全停止,日志直接输出到终端\n --background 后台运行并脱离终端(Linux 终端下的默认行为)\n --no-open 启动时不自动打开浏览器\n --stop 停止同一数据目录下正在运行的实例(仅 Linux)\n --help, -h 显示本帮助并退出\n\n其余参数(例如 --urls http://0.0.0.0:5080)会原样传递给 Web 主机。\n\n常用配置(环境变量,可写入可执行文件同目录的 .env 文件):\n V2RAYN_WEB_API_KEY 管理密钥;监听非 127.0.0.1 地址时必填\n ASPNETCORE_URLS 监听地址,默认 http://127.0.0.1:5080\n V2RAYN_WEB_ALLOWED_ORIGINS 允许跨域的来源列表\n V2RAYN_WEB_AUTOSTART=true 启动 Web 服务时自动启动 Core\n V2RAYN_WEB_PROXY_PORT 本地代理端口(供更新检查等使用)\n\n文档:https://github.com/2dust/v2rayN/tree/master/v2rayN/v2rayN.WebAPI"}} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-TW.json b/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-TW.json new file mode 100644 index 00000000..806ebe2d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-TW.json @@ -0,0 +1 @@ +{"launcher":{"started":"v2rayN.WebAPI 已在背景啟動。\nAPI 健康端點:{url}\n服務已脫離目前終端,Ctrl+C 不會停止它。\n停止服務:{command} --stop\n前台執行:{command} --foreground","alreadyRunningOpening":"v2rayN.WebAPI 已在執行。\n正在開啟 API 健康端點:{url}\n停止服務:{command} --stop","alreadyRunning":"v2rayN.WebAPI 已在執行。\nAPI 健康端點:{url}\n停止服務:{command} --stop","foregroundStarted":"v2rayN.WebAPI 正在前台執行。\nAPI 健康端點:{url}\n按 Ctrl+C 可安全停止服務。","stopNotRunning":"v2rayN.WebAPI 未在此資料目錄執行。","stopSucceeded":"v2rayN.WebAPI 已停止。","stopIdentityUnverified":"無法安全停止 v2rayN.WebAPI:無法確認執行個體身分,未傳送訊號。","stopUnprobeable":"無法安全停止 v2rayN.WebAPI:目前監聽設定沒有可供驗證執行個體身分的回送 HTTP 健康端點(例如僅 HTTPS 或僅繫結非回送位址),未傳送訊號。","stopSupervisorManaged":"此執行個體由 systemd 管理,請使用 systemctl stop 停止對應服務;--stop 不會與服務管理器爭搶生命週期。","stopCoreStillRunning":"Web 主程序已停止,但偵測到 Core 子程序仍在執行。請先檢查服務記錄,再決定是否強制結束。","stopSignalFailed":"無法向 v2rayN.WebAPI 傳送 SIGTERM。","stopTimedOut":"v2rayN.WebAPI 已收到 SIGTERM,但未能在預期時間內停止。","stopTimedOutStage":"最後觀察到的清理階段:{stage}。","stopUnsupported":"--stop 僅支援 Linux。","startFailed":"v2rayN.WebAPI 啟動失敗;請使用 --foreground 查看詳細記錄。","startUnprobeable":"v2rayN.WebAPI 無法在背景啟動:目前監聽設定沒有可供驗證執行個體身分的回送 HTTP 健康端點(例如僅 HTTPS 或僅繫結非回送位址)。請改用 --foreground 執行。","existingUnhealthy":"偵測到相同資料目錄的執行個體正在啟動,但尚未就緒;請稍後重試。","instanceInUse":"相同資料目錄下已有 v2rayN.WebAPI 執行個體正在執行。","help":"v2rayN.WebAPI - v2rayN 的 Web API 服務(無介面)\n\n用法:v2rayN.WebAPI [選項]\n\n選項:\n --foreground 前台執行;Ctrl+C 可安全停止,記錄直接輸出到終端\n --background 背景執行並脫離終端(Linux 終端下的預設行為)\n --no-open 啟動時不自動開啟瀏覽器\n --stop 停止同一資料目錄下正在執行的執行個體(僅 Linux)\n --help, -h 顯示本說明並結束\n\n其餘參數(例如 --urls http://0.0.0.0:5080)會原樣傳遞給 Web 主機。\n\n常用設定(環境變數,可寫入執行檔同目錄的 .env 檔案):\n V2RAYN_WEB_API_KEY 管理金鑰;監聽非 127.0.0.1 位址時必填\n ASPNETCORE_URLS 監聽位址,預設 http://127.0.0.1:5080\n V2RAYN_WEB_ALLOWED_ORIGINS 允許跨來源的來源清單\n V2RAYN_WEB_AUTOSTART=true 啟動 Web 服務時自動啟動 Core\n V2RAYN_WEB_PROXY_PORT 本機代理連接埠(供更新檢查等使用)\n\n文件:https://github.com/2dust/v2rayN/tree/master/v2rayN/v2rayN.WebAPI"}} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/NativeWebRestorePreflight.cs b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebRestorePreflight.cs new file mode 100644 index 00000000..10faf126 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebRestorePreflight.cs @@ -0,0 +1,155 @@ +using Microsoft.Extensions.Configuration; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Launcher; + +public sealed record NativeWebRestoreReplacement( + Uri HealthUri, + WebReplacementCommand Command, + bool CoreWasRunning); + +public sealed record NativeWebRestorePreflightResult( + bool Allowed, + WebLifecycleOwner Owner, + NativeWebRestoreReplacement? Replacement, + string? FailureCode = null, + string? MessageKey = null) +{ + public OperationView ToFailureOperation() + { + if (Allowed || string.IsNullOrWhiteSpace(FailureCode) || string.IsNullOrWhiteSpace(MessageKey)) + { + throw new InvalidOperationException("A successful native restore preflight has no failure response."); + } + + return OperationView.Fail(FailureCode, MessageKey); + } +} + +/// +/// Resolves everything needed to safely restart a native restore before Core or config state +/// is touched. Supervised deployments deliberately leave restart ownership to their supervisor. +/// +public static class NativeWebRestorePreflight +{ + public static NativeWebRestorePreflightResult Evaluate( + IConfiguration configuration, + bool isLinux, + bool isWindows, + bool externalSupervisor, + bool containerEnvironment, + string? setsidPath, + string? processPath, + string? managedEntryPoint, + IEnumerable hostArguments, + Func? fileExists = null, + Func? isUnixExecutable = null, + bool coreWasRunning = false) + { + var lifecycle = WebLifecyclePlanner.Create( + WebLifecycleAction.RestoreAndRestart, + supportsNativeRestart: isLinux || isWindows, + daemonEnvironment: externalSupervisor, + containerEnvironment); + if (lifecycle.Owner != WebLifecycleOwner.NativeProcess) + { + return new(true, lifecycle.Owner, null); + } + + if (!WebProbeUriResolver.TryResolve(configuration, out var endpoints)) + { + return Refused( + WebLifecycleOwner.NativeProcess, + "backup_restore_health_probe_unavailable", + ApiMessageKeys.BackupRestoreProbeUnavailable); + } + + var exists = fileExists ?? File.Exists; + if (string.IsNullOrWhiteSpace(processPath) || !exists(processPath)) + { + return Refused( + WebLifecycleOwner.NativeProcess, + "backup_restore_executable_unavailable", + ApiMessageKeys.BackupRestoreExecutableUnavailable); + } + + var isDotnet = Path.GetFileNameWithoutExtension(processPath) + .Equals("dotnet", StringComparison.OrdinalIgnoreCase); + if (isDotnet + && (string.IsNullOrWhiteSpace(managedEntryPoint) + || !managedEntryPoint.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + || !exists(managedEntryPoint))) + { + return Refused( + WebLifecycleOwner.NativeProcess, + "backup_restore_executable_unavailable", + ApiMessageKeys.BackupRestoreExecutableUnavailable); + } + + if (isLinux) + { + var executable = isUnixExecutable ?? IsUnixExecutable; + if (string.IsNullOrWhiteSpace(setsidPath) + || !exists(setsidPath) + || !executable(setsidPath)) + { + return Refused( + WebLifecycleOwner.NativeProcess, + "backup_restore_setsid_unavailable", + ApiMessageKeys.BackupRestoreSetSidUnavailable); + } + } + + WebReplacementCommand? command; + try + { + command = WebReplacementCommand.CreateNative( + isLinux, + isWindows, + setsidPath, + processPath, + managedEntryPoint, + hostArguments); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + command = null; + } + + if (command is null) + { + return Refused( + WebLifecycleOwner.NativeProcess, + "backup_restore_executable_unavailable", + ApiMessageKeys.BackupRestoreExecutableUnavailable); + } + + return new(true, WebLifecycleOwner.NativeProcess, + new NativeWebRestoreReplacement(endpoints.HealthUri, command, coreWasRunning)); + } + + private static NativeWebRestorePreflightResult Refused( + WebLifecycleOwner owner, + string code, + string messageKey) => new(false, owner, null, code, messageKey); + + private static bool IsUnixExecutable(string path) + { + if (!OperatingSystem.IsLinux()) + { + return false; + } + + try + { + const UnixFileMode executeBits = UnixFileMode.UserExecute + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute; + return (File.GetUnixFileMode(path) & executeBits) != 0; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateHelper.cs b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateHelper.cs new file mode 100644 index 00000000..ddca95b4 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateHelper.cs @@ -0,0 +1,824 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ServiceLib.Common; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Launcher; + +internal sealed record NativeWebUpdatePlan( + string InstallDirectory, + string CandidateDirectory, + string BackupDirectory, + string InstanceLockPath, + string HealthUri, + string[] HostArguments, + string ExpectedVersion, + string ExpectedCommit, + string Rid, + string PreviousVersion, + string RuntimeIntentPath, + string ProgressPath, + bool CoreWasRunning = false); + +internal sealed record WebUpdateProgressState( + string Phase, + bool IsComplete, + bool Success, + string? Version, + string? Detail, + bool? RollbackSucceeded = null, + bool CoreWasRunning = false); + +internal static class NativeWebUpdateHelper +{ + private static readonly TimeSpan OwnerReleaseTimeout = TimeSpan.FromSeconds(45); + private static readonly TimeSpan HealthTimeout = TimeSpan.FromSeconds(60); + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(200); + private static readonly TimeSpan FileReplaceRetryWindow = TimeSpan.FromSeconds(15); + private static readonly TimeSpan FileReplaceRetryDelay = TimeSpan.FromMilliseconds(250); + private const string IsolatedHelperPrefix = ".v2rayn-WebAPI-update-helper-"; + + public static async Task RunAsync(string planPath) + { + var executable = Environment.ProcessPath; + try + { + return await RunCoreAsync(planPath); + } + finally + { + ScheduleWindowsHelperCleanup(executable); + } + } + + private static async Task RunCoreAsync(string planPath) + { + NativeWebUpdatePlan? plan = null; + try + { + plan = JsonSerializer.Deserialize(await File.ReadAllTextAsync(planPath), JsonOptions); + ValidatePlan(plan); + if (OperatingSystem.IsLinux() && Path.GetFileName(Environment.ProcessPath) == "v2rayN.WebAPI") + { + return RunFromIsolatedBundle(planPath, Environment.ProcessPath!); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException or InvalidDataException or ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + Console.Error.WriteLine($"Could not prepare the native Web update: {exception.Message}"); + var previousRestarted = false; + if (plan is not null && CanRestartPreviousWithoutApplying(plan)) + { + try + { + WriteProgress(plan, new WebUpdateProgressState("rolling-back", false, false, plan.ExpectedVersion, + "The update could not be prepared; restarting the unchanged application.")); + using var oldProcess = StartWebInstance(plan); + previousRestarted = await WaitForExpectedHealthAsync(plan, oldProcess, plan.PreviousVersion, HealthTimeout); + if (!previousRestarted) StopOwnedProcess(oldProcess, plan.InstanceLockPath); + if (previousRestarted) DeleteRuntimeIntent(plan.RuntimeIntentPath); + } + catch (Exception restartException) + { + Console.Error.WriteLine($"Could not restart the unchanged Web application: {restartException.Message}"); + } + WriteProgress(plan, new WebUpdateProgressState("failed", true, false, plan.ExpectedVersion, + previousRestarted + ? $"The update could not be prepared ({exception.Message}); the unchanged Web build is healthy." + : $"The update could not be prepared ({exception.Message}); the unchanged Web build could not be verified.", + previousRestarted)); + } + TryDeleteFile(planPath); + return previousRestarted ? 1 : 2; + } + + Process? windowsOwnerProcess; + try + { + windowsOwnerProcess = CaptureWindowsOwnerProcess(plan!); + } + catch (Exception exception) when (exception is InvalidDataException or IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Could not identify the previous Windows Web process: {exception.Message}"); + DeleteRuntimeIntent(plan!.RuntimeIntentPath); + TryDeleteDirectory(plan.CandidateDirectory); + WriteProgress(plan, new WebUpdateProgressState("failed", true, false, plan.ExpectedVersion, + "The previous Windows Web process could not be identified; the installed application was not modified.")); + TryDeleteFile(planPath); + return 1; + } + using var ownedWindowsProcess = windowsOwnerProcess; + WriteProgress(plan!, new WebUpdateProgressState("waiting", false, false, plan!.ExpectedVersion, + "Waiting for the previous Web instance to stop gracefully.")); + if (!await WaitForLockReleaseAsync(plan.InstanceLockPath, OwnerReleaseTimeout)) + { + DeleteRuntimeIntent(plan.RuntimeIntentPath); + TryDeleteDirectory(plan.CandidateDirectory); + WriteProgress(plan, new WebUpdateProgressState("failed", true, false, plan.ExpectedVersion, + "The previous Web instance did not release its instance lock; the old application was not modified.", false)); + TryDeleteFile(planPath); + return 1; + } + if (windowsOwnerProcess is not null + && !await WaitForProcessExitAsync(windowsOwnerProcess, OwnerReleaseTimeout)) + { + DeleteRuntimeIntent(plan.RuntimeIntentPath); + TryDeleteDirectory(plan.CandidateDirectory); + WriteProgress(plan, new WebUpdateProgressState("failed", true, false, plan.ExpectedVersion, + "The previous Windows Web process did not exit after releasing its instance lock; the old application was not modified.", false)); + TryDeleteFile(planPath); + return 1; + } + + var backupCreated = false; + Process? replacementProcess = null; + try + { + WriteProgress(plan, new WebUpdateProgressState("installing", false, false, plan.ExpectedVersion, null)); + CopyCurrentAppToBackup(plan); + backupCreated = true; + var transaction = await NativeWebUpdateWorkflow.ApplyAsync( + () => + { + SwapCandidateAppIntoPlace(plan); + return Task.CompletedTask; + }, + async () => + { + replacementProcess = StartWebInstance(plan); + var healthy = await WaitForExpectedHealthAsync(plan, replacementProcess, plan.ExpectedVersion, HealthTimeout); + if (!healthy) StopOwnedProcess(replacementProcess, plan.InstanceLockPath); + return healthy; + }, + () => + { + StopOwnedProcess(replacementProcess, plan.InstanceLockPath); + RestorePreviousApp(plan); + return Task.CompletedTask; + }, + async () => + { + replacementProcess = StartWebInstance(plan); + var healthy = await WaitForExpectedHealthAsync(plan, replacementProcess, plan.PreviousVersion, HealthTimeout); + if (!healthy) StopOwnedProcess(replacementProcess, plan.InstanceLockPath); + return healthy; + }, + phase => WriteProgress(plan, new WebUpdateProgressState(phase, false, false, plan.ExpectedVersion, null)), + () => + { + TryDeleteDirectory(plan.BackupDirectory); + TryDeleteDirectory(plan.CandidateDirectory); + return Task.CompletedTask; + }); + + if (transaction.Success || transaction.RollbackSucceeded) + DeleteRuntimeIntent(plan.RuntimeIntentPath); + WriteProgress(plan, new WebUpdateProgressState( + transaction.Success ? "completed" : "failed", + true, + transaction.Success, + plan.ExpectedVersion, + transaction.Success + ? "The new v2rayN.WebAPI build is healthy." + : transaction.RollbackSucceeded + ? transaction.Detail + : $"{transaction.Detail} Previous application backup retained at {plan.BackupDirectory}.", + transaction.RollbackSucceeded)); + return transaction.Success ? 0 : transaction.RollbackSucceeded ? 1 : 2; + } + catch (Exception exception) + { + Console.Error.WriteLine($"Native Web update failed: {exception}"); + StopOwnedProcess(replacementProcess, plan.InstanceLockPath); + var rollbackSucceeded = false; + if (backupCreated) + { + try + { + RestorePreviousApp(plan); + var restoredProcess = StartWebInstance(plan); + rollbackSucceeded = await WaitForExpectedHealthAsync(plan, restoredProcess, plan.PreviousVersion, HealthTimeout); + if (rollbackSucceeded) + { + DeleteRuntimeIntent(plan.RuntimeIntentPath); + TryDeleteDirectory(plan.BackupDirectory); + } + } + catch (Exception rollbackException) + { + Console.Error.WriteLine($"Native Web update rollback failed: {rollbackException}"); + } + } + else + { + try + { + var restoredProcess = StartWebInstance(plan); + rollbackSucceeded = await WaitForExpectedHealthAsync(plan, restoredProcess, plan.PreviousVersion, HealthTimeout); + if (rollbackSucceeded) DeleteRuntimeIntent(plan.RuntimeIntentPath); + } + catch (Exception restartException) + { + Console.Error.WriteLine($"Could not restart the unchanged Web application: {restartException}"); + } + TryDeleteDirectory(plan.BackupDirectory); + TryDeleteDirectory(plan.CandidateDirectory); + } + WriteProgress(plan, new WebUpdateProgressState("failed", true, false, plan.ExpectedVersion, + rollbackSucceeded + ? $"{exception.Message} The previous Web build was restored and is healthy." + : $"{exception.Message} The previous Web build could not be confirmed healthy; backup: {plan.BackupDirectory}.", + rollbackSucceeded)); + return rollbackSucceeded ? 1 : 2; + } + finally + { + replacementProcess?.Dispose(); + TryDeleteFile(planPath); + } + } + + private static int RunFromIsolatedBundle(string planPath, string executable) + { + var helperPath = CreateIsolatedHelperExecutable(executable); + try + { + // The worker must keep an unchanged executable path for the entire + // transaction: the single-file loader resolves assemblies lazily + // from that path, even after the application executable is swapped. + var startInfo = new ProcessStartInfo + { + FileName = helperPath, + WorkingDirectory = Path.GetDirectoryName(helperPath)!, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("--apply-WebAPI-update"); + startInfo.ArgumentList.Add(planPath); + using var worker = Process.Start(startInfo) + ?? throw new InvalidOperationException("The isolated native Web update helper could not be started."); + worker.WaitForExit(); + return worker.ExitCode; + } + finally + { + // The outer helper waits for exit before deleting the bundle. The + // worker never deletes its own executable while it can still load it. + TryDeleteFile(helperPath); + } + } + + internal static string CreateIsolatedHelperExecutable(string executable) + { + if (new FileInfo(executable).LinkTarget is not null) + throw new InvalidDataException("The native update helper cannot be copied from a symlink."); + var extension = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + var helperPath = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(executable))!, + $"{IsolatedHelperPrefix}{Guid.NewGuid():N}{extension}"); + try + { + File.Copy(executable, helperPath, overwrite: false); + if (OperatingSystem.IsLinux()) + File.SetUnixFileMode(helperPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return helperPath; + } + catch + { + TryDeleteFile(helperPath); + throw; + } + } + + internal static bool IsUpdateHelperExecutableName(string name) + { + if (name is "v2rayN.WebAPI" or "v2rayN.WebAPI.exe") return true; + return IsIsolatedHelperExecutableName(name); + } + + private static bool IsIsolatedHelperExecutableName(string name) + { + var helperName = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name; + return helperName.StartsWith(IsolatedHelperPrefix, StringComparison.Ordinal) + && Guid.TryParseExact(helperName[IsolatedHelperPrefix.Length..], "N", out _); + } + + internal static void ScheduleWindowsHelperCleanup(string? executable) + { + if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(executable) + || !Path.GetFileName(executable).StartsWith(IsolatedHelperPrefix, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + var powershell = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"); + var escapedPath = Path.GetFullPath(executable).Replace("'", "''", StringComparison.Ordinal); + var script = $"$targetPid={Environment.ProcessId};$targetPath='{escapedPath}';" + + "for($i=0;$i -lt 600;$i++){if(-not(Get-Process -Id $targetPid -ErrorAction SilentlyContinue)){break};Start-Sleep -Milliseconds 100};" + + "Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue"; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + var startInfo = new ProcessStartInfo + { + FileName = powershell, + WorkingDirectory = Path.GetTempPath(), + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-ExecutionPolicy"); + startInfo.ArgumentList.Add("Bypass"); + startInfo.ArgumentList.Add("-EncodedCommand"); + startInfo.ArgumentList.Add(encoded); + Process.Start(startInfo)?.Dispose(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or InvalidOperationException or System.ComponentModel.Win32Exception) + { + Console.Error.WriteLine($"Could not schedule cleanup of the Windows update helper: {exception.Message}"); + _ = MoveFileEx(Path.GetFullPath(executable), null, MoveFileDelayUntilReboot); + } + } + + private const int MoveFileDelayUntilReboot = 0x00000004; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool MoveFileEx(string existingFileName, string? newFileName, int flags); + + private static void ValidatePlan(NativeWebUpdatePlan? plan) + { + var isLinux = OperatingSystem.IsLinux(); + var isWindows = OperatingSystem.IsWindows(); + var isManagedLinuxService = isLinux && LauncherEnvironment.IsSystemdManagedDeployment(); + if ((!isLinux && !isWindows) || plan is null + || File.Exists("/.dockerenv") || File.Exists("/run/.containerenv") + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("container")) + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")) + || isManagedLinuxService + || !WebUpdatePackageStager.IsValidVersion(plan.ExpectedVersion) + || !WebUpdatePackageStager.IsValidVersion(plan.PreviousVersion) + || !WebUpdatePackageStager.IsSupportedRid(plan.Rid) + || plan.Rid != System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier + || string.IsNullOrWhiteSpace(plan.ExpectedCommit) + || !WebProbeUriResolver.IsProbeableLoopbackUri(plan.HealthUri)) + { + throw new InvalidDataException("The update plan has an unsupported target or identity."); + } + + var executable = Environment.ProcessPath + ?? throw new InvalidDataException("The update helper process path is unavailable."); + var install = Path.GetFullPath(plan.InstallDirectory); + var executableName = WebUpdatePackageStager.ExecutableNameForRid(plan.Rid); + var processFileName = Path.GetFileName(executable); + var pathComparison = isWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!string.Equals(Path.GetFullPath(Path.GetDirectoryName(executable) ?? string.Empty), install, pathComparison) + || !IsUpdateHelperExecutableName(processFileName) + || (isWindows && !IsIsolatedHelperExecutableName(processFileName)) + || new FileInfo(executable).LinkTarget is not null) + { + throw new InvalidDataException("The update plan does not target this native v2rayN.WebAPI installation."); + } + var parent = Path.GetDirectoryName(install) + ?? throw new InvalidDataException("The installation directory has no parent."); + EnsureSiblingDirectory(plan.CandidateDirectory, parent, ".v2rayn-WebAPI-candidate-"); + EnsureSiblingDirectory(plan.BackupDirectory, parent, ".v2rayn-WebAPI-backup-"); + if (!Path.IsPathFullyQualified(plan.InstanceLockPath) + || !Path.IsPathFullyQualified(plan.RuntimeIntentPath) + || !Path.IsPathFullyQualified(plan.ProgressPath) + || !string.Equals(Path.GetFullPath(plan.InstanceLockPath), + Path.GetFullPath(Path.Combine(Utils.StartupPath(), "v2rayN.WebAPI.instance.lock")), pathComparison) + || !string.Equals(Path.GetFullPath(plan.RuntimeIntentPath), + Path.GetFullPath(V2rayRuntime.WebUpdateRuntimeStatePath), pathComparison) + || !string.Equals(Path.GetFullPath(plan.ProgressPath), + Path.GetFullPath(Utils.GetTempPath("WebAPI-update-progress.json")), pathComparison) + || !Directory.Exists(plan.CandidateDirectory) + || !File.Exists(Path.Combine(plan.CandidateDirectory, executableName)) + || !File.Exists(Path.Combine(plan.CandidateDirectory, "v2rayN.WebAPI.build.json"))) + { + throw new InvalidDataException("The update plan paths or staged application files are invalid."); + } + EnsureNoLinks(plan.CandidateDirectory); + WebUpdatePackageStager.RequireNativeExecutable(Path.Combine(plan.CandidateDirectory, executableName), plan.Rid); + var identity = JsonSerializer.Deserialize( + File.ReadAllText(Path.Combine(plan.CandidateDirectory, "v2rayN.WebAPI.build.json")), JsonOptions); + if (identity is null || identity.Product != "v2rayN.WebAPI" + || identity.Version != plan.ExpectedVersion || identity.Commit != plan.ExpectedCommit + || identity.Rid != plan.Rid) + { + throw new InvalidDataException("The staged executable identity does not match the verified update plan."); + } + var writableProbe = Path.Combine(install, $".v2rayn-WebAPI-helper-write-probe-{Guid.NewGuid():N}"); + using (new FileStream(writableProbe, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { } + File.Delete(writableProbe); + if (new DirectoryInfo(install).LinkTarget is not null + || new FileInfo(Path.Combine(install, executableName)).LinkTarget is not null) + { + throw new InvalidDataException("Self-update is disabled for symlink-managed installations."); + } + } + + private static bool CanRestartPreviousWithoutApplying(NativeWebUpdatePlan plan) + { + try + { + var executable = Environment.ProcessPath; + var isLinux = OperatingSystem.IsLinux(); + var isWindows = OperatingSystem.IsWindows(); + var pathComparison = isWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return (isLinux || isWindows) + && (!isLinux || !LauncherEnvironment.IsSystemdManagedDeployment()) + && !File.Exists("/.dockerenv") + && !File.Exists("/run/.containerenv") + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("container")) + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")) + && !string.IsNullOrWhiteSpace(executable) + && IsUpdateHelperExecutableName(Path.GetFileName(executable)) + && new FileInfo(executable).LinkTarget is null + && string.Equals(Path.GetFullPath(Path.GetDirectoryName(executable) ?? string.Empty), + Path.GetFullPath(plan.InstallDirectory), pathComparison) + && Path.IsPathFullyQualified(plan.InstanceLockPath) + && Path.IsPathFullyQualified(plan.ProgressPath) + && Path.IsPathFullyQualified(plan.RuntimeIntentPath) + && string.Equals(Path.GetFullPath(plan.InstanceLockPath), + Path.GetFullPath(Path.Combine(Utils.StartupPath(), "v2rayN.WebAPI.instance.lock")), pathComparison) + && string.Equals(Path.GetFullPath(plan.RuntimeIntentPath), + Path.GetFullPath(V2rayRuntime.WebUpdateRuntimeStatePath), pathComparison) + && string.Equals(Path.GetFullPath(plan.ProgressPath), + Path.GetFullPath(Utils.GetTempPath("WebAPI-update-progress.json")), pathComparison) + && WebProbeUriResolver.IsProbeableLoopbackUri(plan.HealthUri) + && WebUpdatePackageStager.IsValidVersion(plan.PreviousVersion) + && plan.HostArguments is not null + && !plan.HostArguments.Any(argument => argument is "--stop" or "--apply-WebAPI-update" or "--background" or "--background-child"); + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) + { + return false; + } + } + + private static void EnsureSiblingDirectory(string value, string parent, string prefix) + { + var full = Path.GetFullPath(value); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!string.Equals(Path.GetDirectoryName(full), parent, comparison) + || !Path.GetFileName(full).StartsWith(prefix, StringComparison.Ordinal)) + { + throw new InvalidDataException("A staged update directory is outside the installation parent."); + } + } + + // Windows can transiently deny replacing a freshly written executable while + // antivirus or the search indexer holds it open. Retry for a bounded window so + // a transient lock cannot fail the update or its rollback; permanent failures + // surface with the operation and cause instead of a bare access-denied code. + internal static void WithTransientFileRetry( + Action operation, + string description, + TimeSpan? retryWindow = null, + TimeSpan? retryDelay = null) + { + var window = retryWindow ?? FileReplaceRetryWindow; + var delay = retryDelay ?? FileReplaceRetryDelay; + var deadline = DateTime.UtcNow + window; + while (true) + { + try + { + operation(); + return; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (DateTime.UtcNow >= deadline) + { + throw new IOException( + $"{description} failed after retrying for {window.TotalSeconds:0.#}s: {exception.Message}", + exception); + } + + Thread.Sleep(delay); + } + } + } + + internal static void CopyCurrentAppToBackup(NativeWebUpdatePlan plan) + { + var executableName = WebUpdatePackageStager.ExecutableNameForRid(plan.Rid); + Directory.CreateDirectory(plan.BackupDirectory); + if (new FileInfo(Path.Combine(plan.InstallDirectory, executableName)).LinkTarget is not null) + throw new InvalidDataException("The installed Web executable is a symlink and cannot be transactionally replaced."); + File.Copy(Path.Combine(plan.InstallDirectory, executableName), Path.Combine(plan.BackupDirectory, executableName)); + var oldIdentity = Path.Combine(plan.InstallDirectory, "v2rayN.WebAPI.build.json"); + if (File.Exists(oldIdentity)) + { + if (new FileInfo(oldIdentity).LinkTarget is not null) + throw new InvalidDataException("The installed Web build identity is a symlink."); + File.Copy(oldIdentity, Path.Combine(plan.BackupDirectory, "v2rayN.WebAPI.build.json")); + } + } + + internal static void SwapCandidateAppIntoPlace(NativeWebUpdatePlan plan) + { + var install = plan.InstallDirectory; + var candidate = plan.CandidateDirectory; + var executableName = WebUpdatePackageStager.ExecutableNameForRid(plan.Rid); + var installedExecutable = Path.Combine(install, executableName); + var installedIdentity = Path.Combine(install, "v2rayN.WebAPI.build.json"); + + WithTransientFileRetry( + () => File.Move(Path.Combine(candidate, executableName), installedExecutable, overwrite: true), + $"Replacing the installed Web executable '{installedExecutable}'"); + WithTransientFileRetry( + () => File.Move(Path.Combine(candidate, "v2rayN.WebAPI.build.json"), installedIdentity, overwrite: true), + $"Replacing the installed Web build identity '{installedIdentity}'"); + SetExecutableMode(installedExecutable); + } + + internal static void RestorePreviousApp(NativeWebUpdatePlan plan) + { + var install = plan.InstallDirectory; + var backup = plan.BackupDirectory; + var executableName = WebUpdatePackageStager.ExecutableNameForRid(plan.Rid); + if (!Directory.Exists(backup)) throw new DirectoryNotFoundException("The previous Web application backup is missing."); + + var previousIdentity = Path.Combine(backup, "v2rayN.WebAPI.build.json"); + var restoredExecutable = Path.Combine(install, $".v2rayn-WebAPI-restore-{Guid.NewGuid():N}{Path.GetExtension(executableName)}"); + var restoredIdentity = restoredExecutable + ".build.json"; + var installedExecutable = Path.Combine(install, executableName); + var installedIdentity = Path.Combine(install, "v2rayN.WebAPI.build.json"); + try + { + // Never truncate an executable inode: the single-file helper may still + // have bundled assemblies mapped from it. Restore by atomic rename, + // just like the forward swap, so existing mappings remain unchanged. + WithTransientFileRetry( + () => File.Copy(Path.Combine(backup, executableName), restoredExecutable), + $"Staging the previous Web executable '{restoredExecutable}'"); + if (File.Exists(previousIdentity)) + { + WithTransientFileRetry( + () => File.Copy(previousIdentity, restoredIdentity), + $"Staging the previous Web build identity '{restoredIdentity}'"); + } + + SetExecutableMode(restoredExecutable); + WithTransientFileRetry( + () => File.Move(restoredExecutable, installedExecutable, overwrite: true), + $"Restoring the previous Web executable '{installedExecutable}'"); + if (File.Exists(restoredIdentity)) + { + WithTransientFileRetry( + () => File.Move(restoredIdentity, installedIdentity, overwrite: true), + $"Restoring the previous Web build identity '{installedIdentity}'"); + } + else + { + TryDeleteFile(installedIdentity); + } + } + finally + { + TryDeleteFile(restoredExecutable); + TryDeleteFile(restoredIdentity); + } + } + + private static Process StartWebInstance(NativeWebUpdatePlan plan) + { + var executable = Path.Combine(plan.InstallDirectory, WebUpdatePackageStager.ExecutableNameForRid(plan.Rid)); + ProcessStartInfo startInfo; + if (OperatingSystem.IsLinux()) + { + var setsid = LinuxXdgBrowserOpener.FindExecutable("setsid") + ?? throw new InvalidOperationException("The setsid executable is required for a detached native Web restart."); + startInfo = new ProcessStartInfo + { + FileName = setsid, + WorkingDirectory = plan.InstallDirectory, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(executable); + startInfo.ArgumentList.Add(WebLaunchOptions.BackgroundChildFlag); + } + else if (OperatingSystem.IsWindows()) + { + startInfo = new ProcessStartInfo + { + FileName = executable, + WorkingDirectory = plan.InstallDirectory, + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + } + else + { + throw new PlatformNotSupportedException("Native Web self-update is supported on Linux and Windows x64."); + } + foreach (var argument in plan.HostArguments) startInfo.ArgumentList.Add(argument); + return Process.Start(startInfo) ?? throw new InvalidOperationException("Could not start the updated Web application."); + } + + private static async Task WaitForExpectedHealthAsync( + NativeWebUpdatePlan plan, + Process process, + string expectedVersion, + TimeSpan timeout) + { + var healthProbe = new HttpWebHealthProbe(); + var healthUri = new Uri(plan.HealthUri); + var deadline = DateTimeOffset.UtcNow + timeout; + int? lastOwnerProcessId = null; + var lastHealth = new WebHealthProbeResult(false, null); + var lastRuntimeIntentSatisfied = false; + while (DateTimeOffset.UtcNow < deadline) + { + if (WebInstanceLock.IsHeld(plan.InstanceLockPath)) + { + lastOwnerProcessId = WebInstanceLock.ReadOwnerProcessId(plan.InstanceLockPath); + lastHealth = await healthProbe.ProbeAsync(healthUri, CancellationToken.None); + lastRuntimeIntentSatisfied = RuntimeIntentIsSatisfied(plan.RuntimeIntentPath, lastHealth); + if (lastOwnerProcessId.HasValue && lastHealth.IsHealthy && lastHealth.InstanceProcessId == lastOwnerProcessId + && string.Equals(lastHealth.WebVersion, expectedVersion, StringComparison.Ordinal) + && lastRuntimeIntentSatisfied) + { + return true; + } + } + if (process.HasExited && !WebInstanceLock.IsHeld(plan.InstanceLockPath)) + { + WriteHealthMismatchDiagnostic(plan, expectedVersion, lastOwnerProcessId, lastHealth, lastRuntimeIntentSatisfied); + return false; + } + await Task.Delay(PollInterval); + } + WriteHealthMismatchDiagnostic(plan, expectedVersion, lastOwnerProcessId, lastHealth, lastRuntimeIntentSatisfied); + return false; + } + + private static void WriteHealthMismatchDiagnostic( + NativeWebUpdatePlan plan, + string expectedVersion, + int? ownerProcessId, + WebHealthProbeResult health, + bool runtimeIntentSatisfied) => + Console.Error.WriteLine( + $"Native Web update health mismatch: expectedVersion={expectedVersion}; lockOwner={ownerProcessId}; " + + $"healthy={health.IsHealthy}; healthPid={health.InstanceProcessId}; observedVersion={health.WebVersion}; " + + $"coreState={health.CoreState}; corePids={string.Join(',', health.CoreProcessIds ?? [])}; " + + $"runtimeIntentSatisfied={runtimeIntentSatisfied}; progress={plan.ProgressPath}."); + + private static bool RuntimeIntentIsSatisfied(string intentPath, WebHealthProbeResult health) + { + try + { + if (!File.Exists(intentPath)) return false; + var intent = JsonSerializer.Deserialize(File.ReadAllText(intentPath), JsonOptions); + return intent is not null && NativeWebUpdateWorkflow.RuntimeIntentIsSatisfied(intent, health); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + return false; + } + } + + private static async Task WaitForLockReleaseAsync(string lockPath, TimeSpan timeout) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + if (!WebInstanceLock.IsHeld(lockPath)) return true; + await Task.Delay(PollInterval); + } + return !WebInstanceLock.IsHeld(lockPath); + } + + private static Process? CaptureWindowsOwnerProcess(NativeWebUpdatePlan plan) + { + if (!OperatingSystem.IsWindows() || !WebInstanceLock.IsHeld(plan.InstanceLockPath)) return null; + var ownerProcessId = WebInstanceLock.ReadOwnerProcessId(plan.InstanceLockPath) + ?? throw new InvalidDataException("The running Windows Web process identity could not be read from its instance lock."); + try + { + return Process.GetProcessById(ownerProcessId); + } + catch (ArgumentException exception) + { + throw new InvalidDataException("The running Windows Web process no longer matches its instance lock.", exception); + } + } + + private static async Task WaitForProcessExitAsync(Process process, TimeSpan timeout) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + try + { + if (process.HasExited) return true; + } + catch (InvalidOperationException) + { + return true; + } + await Task.Delay(PollInterval); + } + try { return process.HasExited; } + catch (InvalidOperationException) { return true; } + } + + private static void StopOwnedProcess(Process? process, string lockPath) + { + if (process is null) return; + try + { + if (process.HasExited) return; + if (WebInstanceLock.ReadOwnerProcessId(lockPath) == process.Id) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(5000); + } + } + catch (Exception exception) when (exception is InvalidOperationException or System.ComponentModel.Win32Exception) + { + // The managed update child may exit during health verification. + } + } + + private static void SetExecutableMode(string path) + { + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + } + + private static void EnsureNoLinks(string directory) + { + if (new DirectoryInfo(directory).LinkTarget is not null) + throw new InvalidDataException("The staged update directory is a symlink."); + foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly)) + { + if (new FileInfo(file).LinkTarget is not null) + throw new InvalidDataException("The staged Web update contains a symbolic link."); + } + foreach (var childDirectory in Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly)) + { + if (new DirectoryInfo(childDirectory).LinkTarget is not null) + throw new InvalidDataException("The staged Web update contains a symbolic link."); + EnsureNoLinks(childDirectory); + } + } + + private static void WriteProgress(NativeWebUpdatePlan plan, WebUpdateProgressState state) + { + try + { + var temporary = plan.ProgressPath + $".{Environment.ProcessId}.tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(state with { CoreWasRunning = plan.CoreWasRunning }, JsonOptions)); + File.Move(temporary, plan.ProgressPath, overwrite: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Could not persist native Web update progress: {exception.Message}"); + } + } + + private static void DeleteRuntimeIntent(string path) => TryDeleteFile(path); + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) Directory.Delete(path, recursive: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Could not clean a Web update directory {path}: {exception.Message}"); + } + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Could not clean a Web update file {path}: {exception.Message}"); + } + } + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateWorkflow.cs b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateWorkflow.cs new file mode 100644 index 00000000..17583ead --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateWorkflow.cs @@ -0,0 +1,90 @@ +namespace v2rayN.WebAPI.Launcher; + +internal sealed record NativeWebUpdateTransactionResult(bool Success, bool RollbackSucceeded, string? Detail); + +internal static class NativeWebUpdateWorkflow +{ + public static bool RuntimeIntentIsSatisfied(v2rayN.WebAPI.Services.RuntimeRestartIntent intent, WebHealthProbeResult health) + { + var hasCore = (health.CoreProcessIds?.Count ?? 0) > 0; + if (!intent.WasRunning) + return string.Equals(health.CoreState, "stopped", StringComparison.OrdinalIgnoreCase) && !hasCore; + return string.Equals(health.CoreState, "running", StringComparison.OrdinalIgnoreCase) + && hasCore + && !string.IsNullOrWhiteSpace(health.CoreProfileId); + } + + public static async Task ApplyAsync( + Func installCandidateAsync, + Func> startCandidateAndVerifyHealthAsync, + Func restorePreviousFilesAsync, + Func> startPreviousAndVerifyHealthAsync, + Action setPhase, + Func cleanBackupsAsync) + { + try + { + setPhase("installing"); + await installCandidateAsync(); + setPhase("restarting"); + setPhase("verifying-health"); + if (await startCandidateAndVerifyHealthAsync()) + { + await cleanBackupsAsync(); + return new(true, false, null); + } + } + catch (Exception installException) + { + setPhase("rolling-back"); + return await TryRestoreAndVerifyAsync( + restorePreviousFilesAsync, + startPreviousAndVerifyHealthAsync, + cleanBackupsAsync, + setPhase, + installException.Message); + } + + setPhase("rolling-back"); + return await TryRestoreAndVerifyAsync( + restorePreviousFilesAsync, + startPreviousAndVerifyHealthAsync, + cleanBackupsAsync, + setPhase, + "The new Web application did not become healthy."); + } + + private static async Task TryRestoreAndVerifyAsync( + Func restorePreviousFilesAsync, + Func> startPreviousAndVerifyHealthAsync, + Func cleanBackupsAsync, + Action setPhase, + string failure) + { + string? restoreFailure = null; + try + { + await restorePreviousFilesAsync(); + } + catch (Exception rollbackException) + { + restoreFailure = rollbackException.Message; + } + + try + { + setPhase("restarting"); + setPhase("verifying-health"); + if (!await startPreviousAndVerifyHealthAsync()) + return new(false, false, $"{failure} The previous Web build did not become healthy. {restoreFailure}"); + if (restoreFailure is not null) + return new(false, false, $"{failure} The previous Web process is responding, but restoring all previous app files failed: {restoreFailure}"); + await cleanBackupsAsync(); + return new(false, true, $"{failure} The previous Web build was restored and is healthy."); + } + catch (Exception restartException) + { + return new(false, false, $"{failure} Rollback/restart failed: {restoreFailure} {restartException.Message}"); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs new file mode 100644 index 00000000..cbf058e4 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs @@ -0,0 +1,206 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace v2rayN.WebAPI.Launcher; + +public sealed class WebInstanceLock : IDisposable +{ + private readonly FileStream _stream; + private bool _disposed; + + private WebInstanceLock(FileStream stream) + { + _stream = stream; + } + + public static bool TryAcquire(string path, bool writeOwner, out WebInstanceLock? instanceLock) + { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("Instance lock path has no parent directory."); + Directory.CreateDirectory(directory); + + FileStream stream; + if (OperatingSystem.IsLinux()) + { + var descriptor = Open(fullPath, OpenReadWrite | OpenCreate | OpenCloseOnExec, UserReadWriteMode); + if (descriptor < 0) + { + throw new IOException("Unable to open the v2rayN.WebAPI instance lock.", new Win32Exception(Marshal.GetLastPInvokeError())); + } + stream = new FileStream(new SafeFileHandle((IntPtr)descriptor, ownsHandle: true), FileAccess.ReadWrite); + } + else + { + var options = new FileStreamOptions + { + Mode = FileMode.OpenOrCreate, + Access = FileAccess.ReadWrite, + // Windows uses a byte-range lock below, leaving the owner PID at the start + // of the file readable by the detached update helper while still excluding + // every competing lock acquisition. + Share = OperatingSystem.IsWindows() ? FileShare.ReadWrite : FileShare.None, + }; + try + { + stream = new FileStream(fullPath, options); + } + catch (IOException) + { + instanceLock = null; + return false; + } + } + + var acquired = OperatingSystem.IsLinux() + ? TryFlock(stream, LockExclusive | LockNonBlocking) + : OperatingSystem.IsWindows() ? TryWindowsLock(stream) : true; + if (!acquired) + { + stream.Dispose(); + instanceLock = null; + return false; + } + + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + if (writeOwner) + { + var owner = Encoding.ASCII.GetBytes($"{Environment.ProcessId}\n"); + stream.SetLength(0); + stream.Position = 0; + stream.Write(owner); + stream.Flush(flushToDisk: true); + } + + instanceLock = new WebInstanceLock(stream); + return true; + } + + public static int? ReadOwnerProcessId(string path) + { + try + { + string ownerText; + if (OperatingSystem.IsLinux()) + { + var descriptor = Open(Path.GetFullPath(path), OpenReadOnly | OpenCloseOnExec, 0); + if (descriptor < 0) + { + return null; + } + using var stream = new FileStream(new SafeFileHandle((IntPtr)descriptor, ownsHandle: true), FileAccess.Read); + using var reader = new StreamReader(stream, Encoding.ASCII); + ownerText = reader.ReadToEnd().Trim(); + } + else + { + if (OperatingSystem.IsWindows()) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream, Encoding.ASCII); + ownerText = reader.ReadToEnd().Trim(); + } + else + { + ownerText = File.ReadAllText(path, Encoding.ASCII).Trim(); + } + } + return int.TryParse(ownerText, out var processId) && processId > 0 ? processId : null; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + public static bool IsHeld(string path) + { + if (!TryAcquire(path, writeOwner: false, out var probe)) + { + return true; + } + + probe!.Dispose(); + return false; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (OperatingSystem.IsLinux()) + { + var descriptor = _stream.SafeFileHandle.DangerousGetHandle().ToInt32(); + _ = Flock(descriptor, LockUnlock); + } + else if (OperatingSystem.IsWindows()) + { + try { _stream.Unlock(WindowsLockOffset, WindowsLockLength); } + catch (IOException) { } + } + _stream.Dispose(); + } + + private const int LockExclusive = 2; + private const int LockNonBlocking = 4; + private const int LockUnlock = 8; + private const int OpenReadOnly = 0; + private const int OpenReadWrite = 2; + private const int OpenCreate = 64; + private const int OpenCloseOnExec = 524288; + private const uint UserReadWriteMode = 0x180; + private const long WindowsLockOffset = 1024 * 1024; + private const long WindowsLockLength = 1; + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int Open(string path, int flags, uint mode); + + private static bool TryFlock(FileStream stream, int operation) + { + var descriptor = stream.SafeFileHandle.DangerousGetHandle().ToInt32(); + if (Flock(descriptor, operation) == 0) + { + return true; + } + + var error = Marshal.GetLastPInvokeError(); + if (error is 11 or 35) + { + return false; + } + + throw new IOException("Unable to acquire the v2rayN.WebAPI instance lock.", new Win32Exception(error)); + } + + [SupportedOSPlatform("windows")] + private static bool TryWindowsLock(FileStream stream) + { + try + { + stream.Lock(WindowsLockOffset, WindowsLockLength); + return true; + } + catch (IOException) + { + return false; + } + } + + [DllImport("libc", EntryPoint = "flock", SetLastError = true)] + private static extern int Flock(int fileDescriptor, int operation); +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs new file mode 100644 index 00000000..60a02cc5 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs @@ -0,0 +1,223 @@ +namespace v2rayN.WebAPI.Launcher; + +public enum WebLaunchMode +{ + Stop, + Foreground, + BackgroundLauncher, + BackgroundChild, +} + +public sealed record WebLaunchOptions(WebLaunchMode Mode, bool NoOpen, string[] HostArguments) +{ + public const string StopFlag = "--stop"; + public const string ForegroundFlag = "--foreground"; + public const string BackgroundFlag = "--background"; + public const string NoOpenFlag = "--no-open"; + public const string BackgroundChildFlag = "--background-child"; + public const string HelpFlag = "--help"; + public const string HelpShortFlag = "-h"; + + public static WebLaunchOptions Parse( + string[] arguments, + bool isLinux, + bool daemonEnvironment, + bool containerEnvironment, + bool interactiveLaunch) + { + var mode = arguments.Contains(StopFlag, StringComparer.Ordinal) + ? WebLaunchMode.Stop + : arguments.Contains(BackgroundChildFlag, StringComparer.Ordinal) + ? WebLaunchMode.BackgroundChild + : arguments.Contains(ForegroundFlag, StringComparer.Ordinal) + ? WebLaunchMode.Foreground + : arguments.Contains(BackgroundFlag, StringComparer.Ordinal) + ? WebLaunchMode.BackgroundLauncher + : isLinux && !containerEnvironment && (!daemonEnvironment || interactiveLaunch) + ? WebLaunchMode.BackgroundLauncher + : WebLaunchMode.Foreground; + + var hostArguments = arguments + .Where(argument => argument is not StopFlag + and not ForegroundFlag + and not BackgroundFlag + and not NoOpenFlag + and not BackgroundChildFlag + and not HelpFlag + and not HelpShortFlag) + .ToArray(); + return new WebLaunchOptions( + mode, + arguments.Contains(NoOpenFlag, StringComparer.Ordinal), + hostArguments); + } +} + +public static class LauncherEnvironment +{ + // Set when the launch chain started in an interactive terminal and inherited + // by every later child, helper, and replacement process. Terminals that were + // started by systemd export INVOCATION_ID/JOURNAL_STREAM to everything they + // run, so the launch context is recorded once instead of being probed again. + public const string InteractiveLaunchEnvironmentVariable = "V2RAYN_WEB_INTERACTIVE_LAUNCH"; + + public static bool IsDaemonEnvironment(IReadOnlyDictionary environment) + { + return HasValue(environment, "INVOCATION_ID") + || HasValue(environment, "JOURNAL_STREAM") + || HasValue(environment, "NOTIFY_SOCKET"); + } + + public static bool IsContainerEnvironment(IReadOnlyDictionary environment) => + HasValue(environment, "DOTNET_RUNNING_IN_CONTAINER") + || HasValue(environment, "container"); + + public static bool IsCurrentContainerEnvironment() + { + var environment = ReadCurrentEnvironment(); + return IsContainerEnvironment(environment) + || File.Exists("/.dockerenv") + || File.Exists("/run/.containerenv"); + } + + public static bool IsInteractiveLaunchMarker(IReadOnlyDictionary environment) => + HasValue(environment, InteractiveLaunchEnvironmentVariable); + + /// + /// True when this process runs with an interactive terminal attached or the + /// launch chain was marked as interactive by the process that had one. + /// + public static bool IsInteractiveLaunch(IReadOnlyDictionary environment) => + !Console.IsInputRedirected || IsInteractiveLaunchMarker(environment); + + /// + /// True when systemd markers or the service cgroup claim this process, unless + /// the markers merely leaked into a terminal (or its detached children), + /// where no external supervisor exists. + /// + public static bool IsSystemdManagedDeployment() + { + if (WebStopper.IsManagedBySystemd(Environment.ProcessId)) + { + return true; + } + + var environment = ReadCurrentEnvironment(); + return IsDaemonEnvironment(environment) && !IsInteractiveLaunch(environment); + } + + public static Dictionary ReadCurrentEnvironment() => + Environment.GetEnvironmentVariables() + .Cast() + .ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString(), StringComparer.OrdinalIgnoreCase); + + private static bool HasValue(IReadOnlyDictionary environment, string key) => + environment.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value); +} + +public enum LauncherLocale +{ + SimplifiedChinese, + TraditionalChinese, + English, +} + +public static class LauncherMessages +{ + private static readonly System.Collections.Concurrent.ConcurrentDictionary MessageCache = new(StringComparer.Ordinal); + + public static LauncherLocale ResolveLocale(params string?[] localeValues) + { + var locale = localeValues.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; + var normalized = locale.Replace('_', '-'); + if (normalized.StartsWith("zh-TW", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("zh-HK", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("zh-MO", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("zh-Hant", StringComparison.OrdinalIgnoreCase)) + { + return LauncherLocale.TraditionalChinese; + } + + return normalized.StartsWith("zh", StringComparison.OrdinalIgnoreCase) + ? LauncherLocale.SimplifiedChinese + : LauncherLocale.English; + } + + public static string Started(string url, string command, LauncherLocale locale) => + Format("started", locale, url, command); + + public static string AlreadyRunning(string url, string command, bool browserOpened, LauncherLocale locale) => + Format(browserOpened ? "alreadyRunningOpening" : "alreadyRunning", locale, url, command); + + public static string ForegroundStarted(string url, LauncherLocale locale) => Format("foregroundStarted", locale, url); + + public static string StopMessage(WebStopResult result, LauncherLocale locale, string? lastStage = null) + { + var message = Get(result switch + { + WebStopResult.NotRunning => "stopNotRunning", + WebStopResult.Stopped => "stopSucceeded", + WebStopResult.ProbeUnavailable => "stopUnprobeable", + WebStopResult.IdentityUnverified => "stopIdentityUnverified", + WebStopResult.SupervisorManaged => "stopSupervisorManaged", + WebStopResult.CoreProcessStillRunning => "stopCoreStillRunning", + WebStopResult.SignalFailed => "stopSignalFailed", + WebStopResult.TimedOut => "stopTimedOut", + _ => throw new ArgumentOutOfRangeException(nameof(result)), + }, locale); + + return result == WebStopResult.TimedOut && !string.IsNullOrWhiteSpace(lastStage) + ? $"{message} {Get("stopTimedOutStage", locale).Replace("{stage}", lastStage, StringComparison.Ordinal)}" + : message; + } + + public static string StopUnsupported(LauncherLocale locale) => Get("stopUnsupported", locale); + + public static string Help(LauncherLocale locale) => Get("help", locale); + + public static string StartFailed(LauncherLocale locale) => Get("startFailed", locale); + + public static string StartUnprobeable(LauncherLocale locale) => Get("startUnprobeable", locale); + + public static string ExistingUnhealthy(LauncherLocale locale) => Get("existingUnhealthy", locale); + + public static string InstanceInUse(LauncherLocale locale) => Get("instanceInUse", locale); + + public static string ExecutableCommand(string executablePath, string? managedEntryPoint = null) + { + if (Path.GetFileNameWithoutExtension(executablePath).Equals("dotnet", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(managedEntryPoint) + && managedEntryPoint.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + return $"\"{executablePath}\" \"{Path.GetFullPath(managedEntryPoint)}\""; + } + + return $"./{Path.GetFileName(executablePath)}"; + } + + private static string Format(string key, LauncherLocale locale, string url, string? command = null) => + Get(key, locale) + .Replace("{url}", url, StringComparison.Ordinal) + .Replace("{command}", command ?? string.Empty, StringComparison.Ordinal); + + private static string Get(string key, LauncherLocale locale) + { + var localeName = locale switch + { + LauncherLocale.SimplifiedChinese => "zh-CN", + LauncherLocale.TraditionalChinese => "zh-TW", + _ => "en-US", + }; + return MessageCache.GetOrAdd($"{localeName}:{key}", _ => ReadMessage(localeName, key)); + } + + private static string ReadMessage(string localeName, string key) + { + var resourceName = $"v2rayN.WebAPI.Launcher.Locales.{localeName}.json"; + using var stream = typeof(LauncherMessages).Assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Missing embedded locale resource: {resourceName}"); + using var document = System.Text.Json.JsonDocument.Parse(stream); + return document.RootElement.GetProperty("launcher").GetProperty(key).GetString() + ?? throw new InvalidOperationException($"Missing launcher locale key: {localeName}.{key}"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebLauncher.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebLauncher.cs new file mode 100644 index 00000000..1bf65067 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebLauncher.cs @@ -0,0 +1,381 @@ +using System.Diagnostics; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +namespace v2rayN.WebAPI.Launcher; + +public interface IWebHealthProbe +{ + Task ProbeAsync(Uri healthUri, CancellationToken cancellationToken); +} + +public sealed record WebHealthProbeResult( + bool IsHealthy, + int? InstanceProcessId, + string? ShutdownStage = null, + IReadOnlyList? CoreProcessIds = null, + string? CoreState = null, + string? WebVersion = null, + string? CoreProfileId = null); + +public interface IBrowserOpener +{ + bool TryOpen(Uri apiUri); +} + +public sealed class HttpWebHealthProbe : IWebHealthProbe +{ + private readonly HttpClient _client = new(new SocketsHttpHandler + { + ConnectTimeout = TimeSpan.FromMilliseconds(500), + UseProxy = false, + }) + { + Timeout = TimeSpan.FromSeconds(1), + }; + + public async Task ProbeAsync(Uri healthUri, CancellationToken cancellationToken) + { + try + { + using var response = await _client.GetAsync(healthUri, cancellationToken); + var instanceProcessId = response.Headers.TryGetValues("X-v2rayn-WebAPI-instance-pid", out var values) + && int.TryParse(values.FirstOrDefault(), out var parsedProcessId) + ? parsedProcessId + : (int?)null; + var shutdownStage = response.Headers.TryGetValues("X-v2rayn-WebAPI-shutdown-stage", out var stageValues) + ? stageValues.FirstOrDefault() + : null; + var coreProcessIds = response.Headers.TryGetValues("X-v2rayn-WebAPI-core-process-ids", out var processValues) + ? processValues.FirstOrDefault()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(value => int.TryParse(value, out var processId) ? processId : 0) + .Where(processId => processId > 0) + .Distinct() + .ToArray() + : []; + var coreState = response.Headers.TryGetValues("X-v2rayn-WebAPI-core-state", out var stateValues) + ? stateValues.FirstOrDefault() + : null; + var webVersion = response.Headers.TryGetValues("X-v2rayn-WebAPI-version", out var versionValues) + ? versionValues.FirstOrDefault() + : null; + var coreProfileId = response.Headers.TryGetValues("X-v2rayn-WebAPI-core-profile-id", out var profileValues) + ? profileValues.FirstOrDefault() + : null; + return new WebHealthProbeResult(response.IsSuccessStatusCode, instanceProcessId, shutdownStage, + coreProcessIds, coreState, webVersion, coreProfileId); + } + catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException) + { + return new WebHealthProbeResult(false, null); + } + } +} + +public sealed class LinuxXdgBrowserOpener : IBrowserOpener +{ + public bool TryOpen(Uri apiUri) + { + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DISPLAY")) + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))) + { + return false; + } + + var xdgOpen = FindExecutable("xdg-open"); + if (xdgOpen is null) + { + return false; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = xdgOpen, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(apiUri.ToString()); + return Process.Start(startInfo) is not null; + } + catch (Exception exception) when (exception is InvalidOperationException or IOException or System.ComponentModel.Win32Exception) + { + return false; + } + } + + internal static string? FindExecutable(string name) + { + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var candidate = Path.Combine(directory, name); + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } +} + +public static class ExistingInstanceHandler +{ + public static async Task TryReuseAsync( + string lockPath, + Uri healthUri, + Uri apiUri, + IWebHealthProbe healthProbe, + IBrowserOpener browserOpener, + bool noOpen, + TimeSpan waitTimeout, + CancellationToken cancellationToken = default) + { + if (WebInstanceLock.TryAcquire(lockPath, writeOwner: false, out var probe)) + { + probe!.Dispose(); + return new ExistingInstanceResult(false, false); + } + + var timeout = Stopwatch.StartNew(); + do + { + var ownerProcessId = WebInstanceLock.ReadOwnerProcessId(lockPath); + var health = await healthProbe.ProbeAsync(healthUri, cancellationToken); + if (health.IsHealthy && ownerProcessId.HasValue && health.InstanceProcessId == ownerProcessId) + { + if (!noOpen) + { + var browserOpened = browserOpener.TryOpen(apiUri); + return new ExistingInstanceResult(true, browserOpened); + } + return new ExistingInstanceResult(true, false); + } + + if (WebInstanceLock.TryAcquire(lockPath, writeOwner: false, out probe)) + { + probe!.Dispose(); + return new ExistingInstanceResult(false, false); + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + } + while (timeout.Elapsed < waitTimeout); + + throw new TimeoutException("The existing v2rayN.WebAPI instance holds its lock but did not become healthy."); + } +} + +public sealed record ExistingInstanceResult(bool Existing, bool BrowserOpened); + +public sealed class WebLauncher +{ + private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan HealthPollInterval = TimeSpan.FromMilliseconds(200); + + private readonly IWebHealthProbe _healthProbe; + private readonly IBrowserOpener _browserOpener; + private readonly LauncherLocale _locale; + + public WebLauncher(IWebHealthProbe healthProbe, IBrowserOpener browserOpener, LauncherLocale locale) + { + _healthProbe = healthProbe; + _browserOpener = browserOpener; + _locale = locale; + } + + public async Task RunAsync( + string executablePath, + string[] hostArguments, + string instanceLockPath, + Uri healthUri, + Uri apiUri, + bool noOpen, + CancellationToken cancellationToken = default) + { + var commandLine = Environment.GetCommandLineArgs(); + var launcherCommand = LauncherMessages.ExecutableCommand(executablePath, commandLine.FirstOrDefault()); + try + { + var existing = await ExistingInstanceHandler.TryReuseAsync( + instanceLockPath, + healthUri, + apiUri, + _healthProbe, + _browserOpener, + noOpen, + StartupTimeout, + cancellationToken); + if (existing.Existing) + { + Print(LauncherMessages.AlreadyRunning( + apiUri.ToString(), + launcherCommand, + existing.BrowserOpened, + _locale)); + return 0; + } + } + catch (TimeoutException) + { + Print(LauncherMessages.ExistingUnhealthy(_locale)); + return 1; + } + + using var child = StartDetachedChild(executablePath, hostArguments); + if (child is null) + { + Print(LauncherMessages.StartFailed(_locale)); + return 1; + } + + var timeout = Stopwatch.StartNew(); + while (timeout.Elapsed < StartupTimeout && !cancellationToken.IsCancellationRequested) + { + var ownerProcessId = WebInstanceLock.ReadOwnerProcessId(instanceLockPath); + var lockHeld = WebInstanceLock.IsHeld(instanceLockPath); + var health = lockHeld + ? await _healthProbe.ProbeAsync(healthUri, cancellationToken) + : new WebHealthProbeResult(false, null); + if (lockHeld && ownerProcessId.HasValue + && health.IsHealthy && health.InstanceProcessId == ownerProcessId) + { + var opened = !noOpen && _browserOpener.TryOpen(apiUri); + if (ownerProcessId.Value == child.Id) + { + Print(LauncherMessages.Started( + apiUri.ToString(), + launcherCommand, + _locale)); + return 0; + } + + Print(LauncherMessages.AlreadyRunning( + apiUri.ToString(), + launcherCommand, + opened, + _locale)); + return 0; + } + + if (child.HasExited) + { + // A competing launcher for the same data scope may have won the startup race. + if (lockHeld && ownerProcessId.HasValue && ownerProcessId != child.Id + && health.IsHealthy && health.InstanceProcessId == ownerProcessId) + { + var opened = !noOpen && _browserOpener.TryOpen(apiUri); + Print(LauncherMessages.AlreadyRunning( + apiUri.ToString(), + launcherCommand, + opened, + _locale)); + return 0; + } + + Print(LauncherMessages.StartFailed(_locale)); + return 1; + } + + await Task.Delay(HealthPollInterval, cancellationToken); + } + + try + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + // The child can exit between the check and Kill. + } + + Print(LauncherMessages.StartFailed(_locale)); + return 1; + } + + private static Process? StartDetachedChild(string executablePath, string[] hostArguments) + { + var setsid = LinuxXdgBrowserOpener.FindExecutable("setsid"); + var processPath = Environment.ProcessPath ?? executablePath; + var commandLine = Environment.GetCommandLineArgs(); + var command = WebReplacementCommand.Create( + setsid, + processPath, + WebReplacementCommand.GetManagedEntryPoint(processPath, commandLine), + hostArguments); + if (command is null) return null; + + try + { + return Process.Start(command.ToProcessStartInfo()); + } + catch (Exception exception) when (exception is InvalidOperationException or IOException or System.ComponentModel.Win32Exception) + { + return null; + } + } + + private static void Print(string message) => Console.Out.WriteLine(message); +} + +public static class BackgroundChildProcess +{ + public static void DetachStandardHandles() + { + if (OperatingSystem.IsWindows()) + { + _ = FreeConsole(); + Console.SetIn(TextReader.Null); + Console.SetOut(TextWriter.Null); + Console.SetError(TextWriter.Null); + return; + } + + if (!OperatingSystem.IsLinux()) + { + return; + } + + var nullDescriptor = Open("/dev/null", OpenReadWrite); + if (nullDescriptor < 0) + { + return; + } + + _ = Dup2(nullDescriptor, 0); + _ = Dup2(nullDescriptor, 1); + _ = Dup2(nullDescriptor, 2); + if (nullDescriptor > 2) + { + _ = Close(nullDescriptor); + } + + Console.SetIn(TextReader.Null); + Console.SetOut(TextWriter.Null); + Console.SetError(TextWriter.Null); + } + + private const int OpenReadWrite = 2; + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int Open(string path, int flags); + + [DllImport("libc", EntryPoint = "dup2", SetLastError = true)] + private static extern int Dup2(int oldDescriptor, int newDescriptor); + + [DllImport("libc", EntryPoint = "close", SetLastError = true)] + private static extern int Close(int descriptor); + + [DllImport("kernel32.dll", EntryPoint = "FreeConsole", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FreeConsole(); +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebProbeUriResolver.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebProbeUriResolver.cs new file mode 100644 index 00000000..87161017 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebProbeUriResolver.cs @@ -0,0 +1,121 @@ +using System.Net; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Launcher; + +/// +/// Resolves the loopback HTTP health-probe endpoint that matches an instance's effective +/// Kestrel listeners. The diagnostic endpoint is loopback-only, so a probe is produced +/// only for listeners that can be reached over a loopback address; listeners that cannot +/// (HTTPS-only, an explicit non-loopback address, or malformed configuration) are +/// rejected instead of being rewritten to a guessed address or port. The rule set is +/// shared with the health diagnostics and the update helper: every produced URI passes +/// . +/// +public static class WebProbeUriResolver +{ + public const string HealthPath = "/api/health"; + + public sealed record WebProbeEndpoints(Uri HealthUri, Uri ApiUri); + + /// + /// True when a URI can carry a verifiable health probe: plain HTTP to a host the + /// health diagnostics accept as local (localhost, 127.0.0.1 or ::1). The update + /// helper and the produced probe URIs both use this single rule. + /// + public static bool IsProbeableLoopbackUri(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttp + && WebSetupAccessPolicy.IsLocalHost(uri.Host); + + /// + /// Resolves the health and API URLs for the first effective listener that can be probed + /// over loopback HTTP. Returns false when no listener can be probed reliably. + /// + public static bool TryResolve(IConfiguration configuration, out WebProbeEndpoints endpoints) + { + foreach (var listenUrl in WebListenerSecurityPolicy.GetEffectiveUrls(configuration)) + { + if (!TryCreateHealthUri(listenUrl, out var healthUri)) + { + continue; + } + + endpoints = new WebProbeEndpoints(healthUri, new UriBuilder(healthUri) { Path = "/" }.Uri); + return true; + } + + endpoints = null!; + return false; + } + + /// + /// Maps one effective listen URL to its loopback health-probe URL. Kestrel treats + /// non-loopback host names as wildcard binds, so those probe the local host name to + /// cover both address families; explicit wildcards keep their address family. + /// + public static bool TryCreateHealthUri(string listenUrl, out Uri healthUri) + { + healthUri = null!; + var trimmed = listenUrl?.Trim() ?? string.Empty; + var schemeSeparator = trimmed.IndexOf("://", StringComparison.Ordinal); + if (schemeSeparator <= 0) + { + return false; + } + + // Kestrel's "*" and "+" wildcard hosts are not valid System.Uri hosts; replace the + // host token so the URL can be parsed, and remember to probe the local host name. + var authorityStart = schemeSeparator + 3; + var wildcard = authorityStart < trimmed.Length && trimmed[authorityStart] is '*' or '+'; + var normalized = wildcard + ? string.Concat(trimmed.AsSpan(0, authorityStart), "localhost", trimmed.AsSpan(authorityStart + 1)) + : trimmed; + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttp) + { + return false; + } + + var probeHost = wildcard ? "localhost" : MapToLoopbackHost(uri.Host); + if (probeHost is null) + { + return false; + } + + healthUri = new UriBuilder(Uri.UriSchemeHttp, probeHost, uri.Port, HealthPath).Uri; + // Gate the generated probe through the same rule the health diagnostics and the + // update helper use; loopback aliases such as 127.0.0.2 are not verifiable there. + return IsProbeableLoopbackUri(healthUri.ToString()); + } + + private static string? MapToLoopbackHost(string host) + { + if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + { + // Keep the configured name: resolving "localhost" covers both families and + // avoids forcing a configured name onto one IPv4 literal. + return "localhost"; + } + + // System.Uri keeps the brackets around IPv6 literals. + var literal = host.Trim('[', ']'); + if (!IPAddress.TryParse(literal, out var address)) + { + // Kestrel binds a host name that is not "localhost" as a wildcard, which + // includes both loopback addresses where the platform provides them. + return "localhost"; + } + + if (address.Equals(IPAddress.Any)) + { + return IPAddress.Loopback.ToString(); + } + + if (address.Equals(IPAddress.IPv6Any)) + { + return IPAddress.IPv6Loopback.ToString(); + } + + return IPAddress.IsLoopback(address) ? literal : null; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebRestartCoordinator.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebRestartCoordinator.cs new file mode 100644 index 00000000..219ae16d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebRestartCoordinator.cs @@ -0,0 +1,332 @@ +using System.Diagnostics; +using System.ComponentModel; +using System.IO; + +namespace v2rayN.WebAPI.Launcher; + +public enum WebLifecycleAction +{ + Stop, + Restart, + RestoreAndRestart, + Shutdown, +} + +public enum WebLifecycleOwner +{ + None, + ExternalSupervisor, + NativeProcess, +} + +public sealed record WebLifecyclePlan(WebLifecycleAction Action, WebLifecycleOwner Owner) +{ + public bool ShouldStartReplacement => Owner == WebLifecycleOwner.NativeProcess + && Action is WebLifecycleAction.Restart or WebLifecycleAction.RestoreAndRestart; + + public bool ExitsForSupervisor => Owner == WebLifecycleOwner.ExternalSupervisor + && Action is WebLifecycleAction.Restart or WebLifecycleAction.RestoreAndRestart; +} + +public static class WebLifecyclePlanner +{ + public static WebLifecyclePlan Create( + WebLifecycleAction action, + bool supportsNativeRestart, + bool daemonEnvironment, + bool containerEnvironment) + { + var owner = action is not (WebLifecycleAction.Restart or WebLifecycleAction.RestoreAndRestart) + ? WebLifecycleOwner.None + : daemonEnvironment || containerEnvironment + ? WebLifecycleOwner.ExternalSupervisor + : supportsNativeRestart + ? WebLifecycleOwner.NativeProcess + : WebLifecycleOwner.None; + return new WebLifecyclePlan(action, owner); + } +} + +public sealed record WebReplacementCommand( + string LauncherPath, + string[] Arguments, + string WorkingDirectory, + bool CreateNoWindow = false) +{ + public ProcessStartInfo ToProcessStartInfo() + { + var startInfo = new ProcessStartInfo + { + FileName = LauncherPath, + WorkingDirectory = WorkingDirectory, + UseShellExecute = false, + CreateNoWindow = CreateNoWindow, + }; + foreach (var argument in Arguments) + { + startInfo.ArgumentList.Add(argument); + } + return startInfo; + } + + public static string? GetManagedEntryPoint(string? processPath, IReadOnlyList commandLine) => + !string.IsNullOrWhiteSpace(processPath) + && Path.GetFileNameWithoutExtension(processPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase) + && commandLine.Count > 0 + && commandLine[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + ? commandLine[0] + : null; + + public static WebReplacementCommand? Create( + string? setsidPath, + string? processPath, + string? managedEntryPoint, + IEnumerable hostArguments) => + CreateNative( + isLinux: true, + isWindows: false, + setsidPath, + processPath, + managedEntryPoint, + hostArguments); + + public static WebReplacementCommand? CreateNative( + bool isLinux, + bool isWindows, + string? setsidPath, + string? processPath, + string? managedEntryPoint, + IEnumerable hostArguments) + { + if ((!isLinux && !isWindows) + || string.IsNullOrWhiteSpace(processPath) + || (isLinux && string.IsNullOrWhiteSpace(setsidPath))) + { + return null; + } + + var arguments = new List(); + var launcherPath = isLinux ? setsidPath! : processPath; + if (Path.GetFileNameWithoutExtension(processPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(managedEntryPoint) + || !managedEntryPoint.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (isLinux) + { + arguments.Add(Path.GetFullPath(processPath)); + } + arguments.Add(Path.GetFullPath(managedEntryPoint)); + } + else if (isLinux) + { + arguments.Add(Path.GetFullPath(processPath)); + } + + arguments.Add(WebLaunchOptions.BackgroundChildFlag); + arguments.AddRange(hostArguments); + return new WebReplacementCommand( + Path.GetFullPath(launcherPath), + arguments.ToArray(), + Environment.CurrentDirectory, + CreateNoWindow: isWindows); + } +} + +public interface IWebReplacementProcess : IDisposable +{ + int Id { get; } + bool HasExited { get; } + void KillTree(); +} + +public interface IWebReplacementProcessStarter +{ + IWebReplacementProcess? Start(WebReplacementCommand command); +} + +public interface IWebInstanceOwnershipProbe +{ + bool IsLockHeld(string lockPath); + int? ReadOwnerProcessId(string lockPath); +} + +public sealed class FileWebInstanceOwnershipProbe : IWebInstanceOwnershipProbe +{ + public bool IsLockHeld(string lockPath) => File.Exists(lockPath) && WebInstanceLock.IsHeld(lockPath); + + public int? ReadOwnerProcessId(string lockPath) => WebInstanceLock.ReadOwnerProcessId(lockPath); +} + +public sealed class NativeReplacementProcessStarter : IWebReplacementProcessStarter +{ + public IWebReplacementProcess? Start(WebReplacementCommand command) + { + try + { + var process = Process.Start(command.ToProcessStartInfo()); + return process is null ? null : new ReplacementProcess(process); + } + catch (Exception exception) when (exception is InvalidOperationException or IOException or Win32Exception) + { + throw new InvalidOperationException( + $"Could not start '{command.LauncherPath}' for the native Web replacement: {exception.Message}", + exception); + } + } + + private sealed class ReplacementProcess(Process process) : IWebReplacementProcess + { + public int Id => process.Id; + + public bool HasExited + { + get + { + try { return process.HasExited; } + catch (InvalidOperationException) { return true; } + } + } + + public void KillTree() + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception) + { + // A process may exit between the check and the kill request. + } + } + + public void Dispose() => process.Dispose(); + } +} + +public sealed record WebRestartHandoffResult(bool Success, string Message, int? ReplacementProcessId = null); + +public sealed class NativeWebRestartCoordinator +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(35); + private static readonly TimeSpan DefaultPollInterval = TimeSpan.FromMilliseconds(200); + + private readonly IWebHealthProbe _healthProbe; + private readonly IWebReplacementProcessStarter _processStarter; + private readonly IWebInstanceOwnershipProbe _ownershipProbe; + private readonly TimeSpan _timeout; + private readonly TimeSpan _pollInterval; + private readonly Func _delay; + + public NativeWebRestartCoordinator( + IWebHealthProbe healthProbe, + IWebReplacementProcessStarter processStarter, + IWebInstanceOwnershipProbe ownershipProbe, + TimeSpan? timeout = null, + TimeSpan? pollInterval = null, + Func? delay = null) + { + _healthProbe = healthProbe; + _processStarter = processStarter; + _ownershipProbe = ownershipProbe; + _timeout = timeout ?? DefaultTimeout; + _pollInterval = pollInterval ?? DefaultPollInterval; + _delay = delay ?? Task.Delay; + if (_timeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(timeout)); + if (_pollInterval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(pollInterval)); + } + + public async Task StartReplacementAfterOwnerReleaseAsync( + string lockPath, + Uri healthUri, + WebReplacementCommand command, + CancellationToken cancellationToken = default, + bool? expectedCoreRunning = null) + { + var timeout = Stopwatch.StartNew(); + while (_ownershipProbe.IsLockHeld(lockPath)) + { + if (timeout.Elapsed >= _timeout) + { + return new(false, "The previous Web owner did not release the current data-scope instance lock."); + } + await _delay(Min(_pollInterval, _timeout - timeout.Elapsed), cancellationToken); + } + + IWebReplacementProcess? replacement; + try + { + replacement = _processStarter.Start(command); + } + catch (Exception exception) when (exception is InvalidOperationException or IOException or Win32Exception) + { + return new(false, $"The native replacement process could not be started: {exception.Message}"); + } + if (replacement is null) + { + return new(false, "The native replacement process could not be started."); + } + + using (replacement) + { + while (timeout.Elapsed < _timeout) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_ownershipProbe.IsLockHeld(lockPath)) + { + var ownerProcessId = _ownershipProbe.ReadOwnerProcessId(lockPath); + if (ownerProcessId == replacement.Id) + { + var health = await _healthProbe.ProbeAsync(healthUri, cancellationToken); + if (health.IsHealthy && health.InstanceProcessId == replacement.Id) + { + if (expectedCoreRunning is bool wasRunning && !CoreIntentMatches(health, wasRunning)) + { + return new(false, + $"The native replacement Web instance is healthy, but the previous Core {(wasRunning ? "running" : "stopped")} intent was not restored.", + replacement.Id); + } + return new(true, "The native replacement Web instance is healthy.", replacement.Id); + } + } + } + + if (replacement.HasExited) + { + return new(false, "The native replacement process exited before its Web health endpoint became ready.", replacement.Id); + } + + await _delay(Min(_pollInterval, _timeout - timeout.Elapsed), cancellationToken); + } + + var currentOwner = _ownershipProbe.IsLockHeld(lockPath) + ? _ownershipProbe.ReadOwnerProcessId(lockPath) + : null; + if (!replacement.HasExited && (!currentOwner.HasValue || currentOwner == replacement.Id)) + { + replacement.KillTree(); + } + return new(false, "The native replacement Web instance did not become healthy before the startup timeout.", replacement.Id); + } + } + + private static bool CoreIntentMatches(WebHealthProbeResult health, bool expectedRunning) + { + if (!string.Equals(health.CoreState, expectedRunning ? "running" : "stopped", StringComparison.Ordinal)) + { + return false; + } + + return expectedRunning + ? health.CoreProcessIds is { Count: > 0 } + : health.CoreProcessIds is { Count: 0 }; + } + + private static TimeSpan Min(TimeSpan left, TimeSpan right) => left < right ? left : right; +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebStopper.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebStopper.cs new file mode 100644 index 00000000..90a2999f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebStopper.cs @@ -0,0 +1,193 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.IO; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Launcher; + +public enum WebStopResult +{ + NotRunning, + Stopped, + ProbeUnavailable, + IdentityUnverified, + SupervisorManaged, + CoreProcessStillRunning, + SignalFailed, + TimedOut, +} + +public interface IProcessSignalSender +{ + bool SendSigTerm(int processId); +} + +public sealed class LinuxProcessSignalSender : IProcessSignalSender +{ + private const int SigTerm = 15; + + public bool SendSigTerm(int processId) => OperatingSystem.IsLinux() && Kill(processId, SigTerm) == 0; + + [DllImport("libc", EntryPoint = "kill", SetLastError = true)] + private static extern int Kill(int processId, int signal); +} + +public sealed class WebStopper +{ + private static readonly TimeSpan DefaultStopTimeout = RuntimeShutdownBudgets.LauncherWait; + private static readonly TimeSpan DefaultPollInterval = TimeSpan.FromMilliseconds(200); + + private readonly IWebHealthProbe _healthProbe; + private readonly IProcessSignalSender _signalSender; + private readonly TimeSpan _stopTimeout; + private readonly TimeSpan _pollInterval; + + public string? LastObservedShutdownStage { get; private set; } + + public WebStopper( + IWebHealthProbe healthProbe, + IProcessSignalSender signalSender, + TimeSpan? stopTimeout = null, + TimeSpan? pollInterval = null) + { + _healthProbe = healthProbe; + _signalSender = signalSender; + _stopTimeout = stopTimeout ?? DefaultStopTimeout; + _pollInterval = pollInterval ?? DefaultPollInterval; + if (_stopTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(stopTimeout)); + } + if (_pollInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(pollInterval)); + } + } + + public async Task StopAsync( + string lockPath, + Uri? healthUri, + CancellationToken cancellationToken = default) + { + if (!IsLockHeld(lockPath)) + { + return WebStopResult.NotRunning; + } + + var ownerProcessId = WebInstanceLock.ReadOwnerProcessId(lockPath); + if (!ownerProcessId.HasValue) + { + return WebStopResult.IdentityUnverified; + } + + if (IsManagedBySystemd(ownerProcessId.Value)) + { + return WebStopResult.SupervisorManaged; + } + + // A null probe means the configured listeners have no loopback HTTP health + // endpoint; without it the owner identity cannot be confirmed, so no signal + // may be sent. + if (healthUri is null) + { + return WebStopResult.ProbeUnavailable; + } + + var health = await _healthProbe.ProbeAsync(healthUri, cancellationToken); + if (!health.IsHealthy || health.InstanceProcessId != ownerProcessId) + { + return WebStopResult.IdentityUnverified; + } + + // Recheck the lock and owner immediately before signaling, so a released or + // replaced instance cannot turn a stale PID into an unrelated process signal. + if (!IsLockHeld(lockPath)) + { + return WebStopResult.NotRunning; + } + if (WebInstanceLock.ReadOwnerProcessId(lockPath) != ownerProcessId) + { + return WebStopResult.IdentityUnverified; + } + + var coreProcessIds = new HashSet(health.CoreProcessIds ?? []); + + if (!_signalSender.SendSigTerm(ownerProcessId.Value)) + { + return WebStopResult.SignalFailed; + } + + var timeout = Stopwatch.StartNew(); + while (timeout.Elapsed < _stopTimeout) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Keep probing health while waiting, but only report completion once the + // instance has released its lock (after host cleanup and service shutdown). + var currentHealth = await _healthProbe.ProbeAsync(healthUri, cancellationToken); + LastObservedShutdownStage = currentHealth.ShutdownStage ?? LastObservedShutdownStage; + coreProcessIds.UnionWith(currentHealth.CoreProcessIds ?? []); + var lockHeld = IsLockHeld(lockPath); + var currentOwnerProcessId = lockHeld ? WebInstanceLock.ReadOwnerProcessId(lockPath) : null; + var endpointStillIdentifiesOwner = currentHealth.IsHealthy + && currentHealth.InstanceProcessId == ownerProcessId; + if (!endpointStillIdentifiesOwner && (!lockHeld || currentOwnerProcessId != ownerProcessId)) + { + var remainingCoreProcesses = coreProcessIds.Where(IsProcessAlive).ToArray(); + return remainingCoreProcesses.Length == 0 + ? WebStopResult.Stopped + : WebStopResult.CoreProcessStillRunning; + } + + var remaining = _stopTimeout - timeout.Elapsed; + if (remaining > TimeSpan.Zero) + { + await Task.Delay(remaining < _pollInterval ? remaining : _pollInterval, cancellationToken); + } + } + + return WebStopResult.TimedOut; + } + + private static bool IsLockHeld(string lockPath) => + File.Exists(lockPath) && WebInstanceLock.IsHeld(lockPath); + + internal static bool IsManagedBySystemd(int processId) + { + if (!OperatingSystem.IsLinux()) return false; + try + { + return IsSystemdServiceCgroup(File.ReadAllText($"/proc/{processId}/cgroup")); + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + internal static bool IsSystemdServiceCgroup(string? cgroup) => + !string.IsNullOrWhiteSpace(cgroup) + && cgroup.Contains("system.slice/", StringComparison.Ordinal) + && cgroup.Contains(".service", StringComparison.Ordinal); + + private static bool IsProcessAlive(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Program.cs b/v2rayN/v2rayN.WebAPI/Program.cs new file mode 100644 index 00000000..acbcc02c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Program.cs @@ -0,0 +1,414 @@ +using System.Collections; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using ServiceLib; +using ServiceLib.Common; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Configuration; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Launcher; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +internal static class Program +{ + private const string ManagementKeyEnvironmentVariable = "V2RAYN_WEB_API_KEY"; + + public static async Task Main(string[] args) + { + if (args.Contains(WebLaunchOptions.HelpFlag, StringComparer.Ordinal) + || args.Contains(WebLaunchOptions.HelpShortFlag, StringComparer.Ordinal)) + { + Console.Out.WriteLine(LauncherMessages.Help(GetLauncherLocale())); + return 0; + } + + try + { + NativeEnvironmentFile.LoadFromExecutableDirectory(AppContext.BaseDirectory); + } + catch (NativeEnvironmentConfigurationException exception) + { + Console.Error.WriteLine($"Invalid v2rayN.WebAPI environment configuration: {exception.Message}"); + return 1; + } + + if (args.Length == 2 && args[0] == "--apply-WebAPI-update") + { + return await NativeWebUpdateHelper.RunAsync(args[1]); + } + + var environment = ReadEnvironment(); + var interactiveTerminal = !Console.IsInputRedirected; + if (interactiveTerminal) + { + // Record the launch context for detached children and later helper + // processes, which can no longer probe the terminal themselves. + Environment.SetEnvironmentVariable(LauncherEnvironment.InteractiveLaunchEnvironmentVariable, "1"); + } + + var daemonEnvironment = LauncherEnvironment.IsDaemonEnvironment(environment); + var interactiveLaunch = interactiveTerminal || LauncherEnvironment.IsInteractiveLaunchMarker(environment); + var supervisedEnvironment = daemonEnvironment && !interactiveLaunch; + var containerEnvironment = LauncherEnvironment.IsCurrentContainerEnvironment(); + var managementKey = environment.GetValueOrDefault(ManagementKeyEnvironmentVariable); + if (!string.IsNullOrEmpty(managementKey) + && (managementKey.Length < WebAuthService.MinimumKeyLength + || managementKey.Length > WebAuthService.MaximumKeyLength)) + { + Console.Error.WriteLine( + $"{ManagementKeyEnvironmentVariable} length must be between {WebAuthService.MinimumKeyLength} and {WebAuthService.MaximumKeyLength} characters."); + return 1; + } + + if (WebDeploymentSecurityPolicy.GetStartupError(supervisedEnvironment, containerEnvironment, managementKey) is { } startupError) + { + Console.Error.WriteLine(startupError); + return 1; + } + + PrepareDataScope(); + var launchOptions = WebLaunchOptions.Parse( + args, + OperatingSystem.IsLinux(), + daemonEnvironment, + containerEnvironment, + interactiveLaunch); + + if (launchOptions.Mode == WebLaunchMode.Stop) + { + if (!OperatingSystem.IsLinux()) + { + Console.Error.WriteLine(LauncherMessages.StopUnsupported(GetLauncherLocale())); + return 1; + } + + // The stop probe must follow the same configuration sources as the running + // instance (process/.env environment and command line), so build the same + // configuration the host would have used. + var stopConfiguration = WebApplication.CreateBuilder(new WebApplicationOptions + { + Args = launchOptions.HostArguments, + ContentRootPath = AppContext.BaseDirectory, + }).Configuration; + var (stopHealthUri, _) = GetLauncherUris(stopConfiguration); + var stopper = new WebStopper(new HttpWebHealthProbe(), new LinuxProcessSignalSender()); + var result = await stopper.StopAsync(GetInstanceLockPath(), stopHealthUri); + Console.Out.WriteLine(LauncherMessages.StopMessage(result, GetLauncherLocale(), stopper.LastObservedShutdownStage)); + return result is WebStopResult.NotRunning or WebStopResult.Stopped ? 0 : 1; + } + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + Args = launchOptions.HostArguments, + ContentRootPath = AppContext.BaseDirectory, + }); + WebListenerSecurityPolicy.ApplyDefault(builder); + if (WebListenerSecurityPolicy.GetStartupError(builder.Configuration, managementKey) is { } listenerError) + { + Console.Error.WriteLine(listenerError); + return 1; + } + + WebCorsPolicy cors; + try + { + cors = new WebCorsPolicy(builder.Configuration[WebCorsPolicy.EnvironmentVariable]); + } + catch (ArgumentException exception) + { + Console.Error.WriteLine(exception.Message); + return 1; + } + builder.Services.AddSingleton(cors); + builder.Services.AddCors(options => options.AddPolicy(WebCorsPolicy.PolicyName, cors.Configure)); + + if (launchOptions.Mode == WebLaunchMode.BackgroundLauncher) + { + if (!OperatingSystem.IsLinux()) + { + Console.Error.WriteLine(LauncherMessages.StartFailed(GetLauncherLocale())); + return 1; + } + + var (healthUri, apiUri) = GetLauncherUris(builder.Configuration); + if (healthUri is null || apiUri is null) + { + Console.Error.WriteLine(LauncherMessages.StartUnprobeable(GetLauncherLocale())); + return 1; + } + + var launcher = new WebLauncher(new HttpWebHealthProbe(), new LinuxXdgBrowserOpener(), GetLauncherLocale()); + var processPath = Environment.ProcessPath; + if (string.IsNullOrWhiteSpace(processPath)) + { + Console.Error.WriteLine(LauncherMessages.StartFailed(GetLauncherLocale())); + return 1; + } + + var lockPath = GetInstanceLockPath(); + return await launcher.RunAsync( + processPath, + launchOptions.HostArguments, + lockPath, + healthUri, + apiUri, + launchOptions.NoOpen); + } + + if (launchOptions.Mode == WebLaunchMode.BackgroundChild) + { + BackgroundChildProcess.DetachStandardHandles(); + } + + var instanceLockPath = GetInstanceLockPath(); + if (!WebInstanceLock.TryAcquire(instanceLockPath, writeOwner: true, out var instanceLock)) + { + if (launchOptions.Mode == WebLaunchMode.BackgroundChild) + { + return 73; + } + + Console.Error.WriteLine(LauncherMessages.InstanceInUse(GetLauncherLocale())); + return 73; + } + + WebHostRunResult hostResult; + using (instanceLock) + { + var showForegroundPrompt = launchOptions.Mode == WebLaunchMode.Foreground + && args.Contains(WebLaunchOptions.ForegroundFlag, StringComparer.Ordinal) + && !supervisedEnvironment + && !containerEnvironment; + hostResult = await RunWebHostAsync(builder, showForegroundPrompt); + } + + // The host cleanup is complete and the old owner has disposed this lock. The + // handoff still verifies the lock state before allowing a replacement to start. + var exitAction = hostResult.RestoreAndRestartRequested + ? WebLifecycleAction.RestoreAndRestart + : WebLifecycleAction.Shutdown; + var lifecyclePlan = WebLifecyclePlanner.Create( + exitAction, + supportsNativeRestart: OperatingSystem.IsLinux() || OperatingSystem.IsWindows(), + LauncherEnvironment.IsSystemdManagedDeployment(), + containerEnvironment); + if (lifecyclePlan.ShouldStartReplacement) + { + var replacement = hostResult.NativeRestoreReplacement; + if (replacement is null) + { + WriteNativeRestartDiagnostic("Restore requested a native Web restart without a validated replacement plan."); + return 1; + } + + var coordinator = new NativeWebRestartCoordinator( + new HttpWebHealthProbe(), + new NativeReplacementProcessStarter(), + new FileWebInstanceOwnershipProbe()); + var handoff = await coordinator.StartReplacementAfterOwnerReleaseAsync( + GetInstanceLockPath(), + replacement.HealthUri, + replacement.Command, + expectedCoreRunning: replacement.CoreWasRunning); + WriteNativeRestartDiagnostic(handoff.Message, handoff.Success, handoff.ReplacementProcessId); + return handoff.Success ? hostResult.ExitCode : 1; + } + return hostResult.ExitCode; + } + + private static async Task RunWebHostAsync(WebApplicationBuilder builder, bool showForegroundPrompt) + { + // "guiConfigs/web-auth.json" is the pre-rename legacy location; WebAuthStorage + // migrates it (and the previous private location) to WebAPIData/WebAPI-auth.json. + var configPath = WebAuthStorage.MigrateAndGetPath( + Utils.StartupPath(), + Utils.GetConfigPath("web-auth.json")); + var webAuth = new WebAuthService(configPath, Environment.GetEnvironmentVariable(ManagementKeyEnvironmentVariable)); + // EventSource carries only a one-time, short-lived SSE ticket in its URL; keep + // request lifecycle logs from recording that ticket as well. + builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.Configure(options => options.ShutdownTimeout = RuntimeShutdownBudgets.HostShutdown); + builder.Services.AddHostedService(services => + new V2rayHostedService( + services.GetRequiredService(), + services.GetRequiredService())); + builder.Services.AddSingleton(webAuth); + builder.Services.AddSingleton(); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase))); + + var app = builder.Build(); + var runtime = app.Services.GetRequiredService(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseRouting(); + app.UseMiddleware(); + app.UseCors(WebCorsPolicy.PolicyName); // OPTIONS must complete before session auth. + app.UseRateLimiter(); + + app.UseMiddleware(); + + app.Use(async (context, next) => + { + try + { + await next(); + } + catch (BadHttpRequestException exception) when (context.Request.Path.StartsWithSegments("/api") && !context.Response.HasStarted) + { + app.Logger.LogInformation(exception, "Invalid API request."); + var isPayloadTooLarge = exception.StatusCode == StatusCodes.Status413PayloadTooLarge; + context.Response.StatusCode = isPayloadTooLarge ? StatusCodes.Status413PayloadTooLarge : StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(ApiEnvelope.Fail( + isPayloadTooLarge ? "payload_too_large" : "request_invalid", + ApiMessageKeys.CommonInvalidInput)); + } + catch (JsonException exception) when (context.Request.Path.StartsWithSegments("/api") && !context.Response.HasStarted) + { + app.Logger.LogInformation(exception, "Invalid API JSON payload."); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(ApiEnvelope.Fail("json_invalid", ApiMessageKeys.CommonInvalidInput)); + } + catch (OperationCanceledException) when (context.Request.Path.StartsWithSegments("/api") && !context.Response.HasStarted) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + } + catch (Exception exception) when (context.Request.Path.StartsWithSegments("/api") && !context.Response.HasStarted) + { + app.Logger.LogError(exception, "Unhandled API request failure."); + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(ApiEnvelope.Fail("internal_error", ApiMessageKeys.CommonInternal)); + } + }); + + app.Use(async (context, next) => + { + if (!context.Request.Path.StartsWithSegments("/api") + || context.Request.Path == "/api/health" + || context.Request.Path == "/api/events" + || context.Request.Path.StartsWithSegments("/api/setup") + || context.Request.Path.StartsWithSegments("/api/auth")) + { + await next(); + return; + } + + var path = context.Request.Path.Value?.TrimEnd('/') ?? string.Empty; + var leaseKind = RuntimeRequestOperationPolicy.Classify(context.Request.Method, path); + if (leaseKind == RuntimeRequestOperationKind.Background) + { + await next(); + return; + } + + var operations = context.RequestServices.GetRequiredService(); + await using var operation = leaseKind switch + { + RuntimeRequestOperationKind.Exclusive => await operations.EnterExclusiveAsync(context.RequestAborted), + RuntimeRequestOperationKind.ExclusiveReadOnly => await operations.EnterExclusiveAsync( + context.RequestAborted, + allowReadOnlyObservations: true), + RuntimeRequestOperationKind.Observation => await operations.EnterObservationAsync(context.RequestAborted), + _ => await operations.EnterOperationAsync(context.RequestAborted), + }; + context.RequestAborted = operation.Token; + await next(); + }); + + app.MapWebApi(); + app.MapWebAuthEndpoints(); + app.MapWebSetupEndpoints(); + app.MapFallback("/api/{**path}", () => + Results.NotFound(ApiEnvelope.Fail("route_not_found", ApiMessageKeys.CommonRouteNotFound))); + + if (showForegroundPrompt) + { + var (_, apiUri) = GetLauncherUris(builder.Configuration); + if (apiUri is not null) + { + var displayUri = apiUri.ToString(); + app.Lifetime.ApplicationStarted.Register(() => + Console.Out.WriteLine(LauncherMessages.ForegroundStarted(displayUri, GetLauncherLocale()))); + } + } + + await app.RunAsync(); + return new WebHostRunResult(0, runtime.RestoreAndRestartRequested, runtime.NativeRestoreReplacement); + } + + private static void WriteNativeRestartDiagnostic(string message, bool success = false, int? processId = null) + { + var line = $"{DateTimeOffset.UtcNow:O} {(success ? "INFO" : "ERROR")} {message}" + + (processId.HasValue ? $" ProcessId={processId.Value}." : string.Empty) + + Environment.NewLine; + if (!success) Console.Error.WriteLine(message); + try + { + var path = Utils.GetLogPath("native-restore-restart.log"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.AppendAllText(path, line); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (!success) Console.Error.WriteLine($"Could not persist the native restart diagnostic: {exception.Message}"); + } + } + + private sealed record WebHostRunResult( + int ExitCode, + bool RestoreAndRestartRequested, + NativeWebRestoreReplacement? NativeRestoreReplacement); + + private static void PrepareDataScope() + { + var dataHome = Environment.GetEnvironmentVariable("V2RAYN_DATA_HOME"); + if (!string.IsNullOrWhiteSpace(dataHome)) + { + var fullDataHome = Path.GetFullPath(dataHome); + Directory.CreateDirectory(fullDataHome); + Environment.SetEnvironmentVariable("XDG_DATA_HOME", fullDataHome); + Environment.SetEnvironmentVariable(Global.LocalAppData, "1"); + return; + } + + if (!Utils.HasWritePermission()) + { + Environment.SetEnvironmentVariable(Global.LocalAppData, "1"); + } + } + + private static string GetInstanceLockPath() => + Path.Combine(Utils.StartupPath(), "v2rayN.WebAPI.instance.lock"); + + internal static (Uri? HealthUri, Uri? ApiUri) GetLauncherUris(IConfiguration configuration) + { + if (WebProbeUriResolver.TryResolve(configuration, out var endpoints)) + { + return (endpoints.HealthUri, endpoints.ApiUri); + } + + // Never guess a loopback address or port: a probe that does not match the + // configured listeners would verify (or stop) the wrong process. + return (null, null); + } + + private static Dictionary ReadEnvironment() => + LauncherEnvironment.ReadCurrentEnvironment(); + + private static LauncherLocale GetLauncherLocale() + { + var environment = ReadEnvironment(); + return LauncherMessages.ResolveLocale( + environment.GetValueOrDefault("LC_ALL"), + environment.GetValueOrDefault("LC_MESSAGES"), + environment.GetValueOrDefault("LANG"), + CultureInfo.CurrentUICulture.Name); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Properties/AssemblyInfo.cs b/v2rayN/v2rayN.WebAPI/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..c4a254de --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("v2rayN.WebAPI.Tests")] diff --git a/v2rayN/v2rayN.WebAPI/README.md b/v2rayN/v2rayN.WebAPI/README.md new file mode 100644 index 00000000..cd363e5a --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/README.md @@ -0,0 +1,238 @@ +# v2rayN.WebAPI + +`v2rayN.WebAPI` is a headless ASP.NET Core host for v2rayN's existing `ServiceLib`. It exposes +REST APIs, authenticated SSE/runtime events, setup and sessions, settings and options, +profiles/subscriptions, routing and DNS, backups, updates, and the existing Core runtime bridge. +It runs without a browser or desktop session. The API has no dependency on +Vue, React, Vite, Node.js, npm, or any frontend manifest. + +The API only serves API endpoints; it does not host, inspect, or configure a WebUI. +An independent WebUI or other HTTP client manages its own hosting and configuration. + +## Start the API + +For a native install, unpack the API release ZIP and start the executable from its install +directory: + +```sh +cp .env.example .env +chmod 600 .env +# Set V2RAYN_WEB_API_KEY in .env for supervised, container, or remote deployments. +./v2rayN.WebAPI --foreground --no-open +``` + +On Linux, a terminal launch without flags starts the API in the background, detached from the +terminal, and prints its health endpoint. `--foreground` keeps it attached and stops it safely with +Ctrl+C, `--stop` stops the instance running for the same data directory, and `--help` prints every +option together with the common environment variables; `--no-open` suppresses the browser opener. +Detached children and later helper processes keep the launch context, so an instance started from a +terminal is never mistaken for a systemd-managed service (systemd exports `INVOCATION_ID` / +`JOURNAL_STREAM` to everything launched from such a terminal). + +The default listener is `http://127.0.0.1:5080`. A local native install may temporarily leave +`V2RAYN_WEB_API_KEY` empty when **all** listeners are loopback (`127.0.0.1`, `localhost`, or +`[::1]`), then complete first-run setup through a direct loopback API client. + +For LAN / NAS / headless / container / systemd deployments, preconfigure +`V2RAYN_WEB_API_KEY`. Systemd and containers require it even on loopback. Any non-loopback +listener (including `0.0.0.0`, `[::]`, wildcard/hostname bindings, and LAN IPs) fails startup +without this environment Key, even if a Key was previously saved through local setup. +The check uses the effective ASP.NET Core configuration, including `ASPNETCORE_URLS`, +command-line `--urls`, `ASPNETCORE_HTTP_PORTS` / `ASPNETCORE_HTTPS_PORTS` (wildcard bindings), +and `Kestrel:Endpoints`, respecting their precedence. With multiple URLs, one non-loopback +listener is enough to require a Key; request Host headers never determine exposure. +For remote access, set a Key before selecting e.g. `ASPNETCORE_URLS=http://0.0.0.0:5080`. +Protect it with a firewall/VPN or an HTTPS reverse proxy; do not expose it directly to the Internet. + +`GET /api/health` is a non-sensitive health probe. After authentication, `GET /api/status` +reports the running Backend version, commit, runtime identifier, and runtime capability fields. +The API option endpoints (including `/api/editor-options` and DNS editor options) are authoritative +for values derived from ServiceLib; clients should consume these instead of copying enums or +option lists. + +## Authentication + +- `V2RAYN_WEB_API_KEY` is the Management Key. Exchange it only at `POST /api/auth/login`. +- Use the returned session token as `Authorization: Bearer ` for protected REST + endpoints. The Management Key is not a REST or SSE bearer token. +- `POST /api/auth/sse-ticket` returns a short-lived, one-time ticket for `/api/events`. +- Setup, login rate limits, session expiry/revocation, LAN/setup policy, DNS rebinding checks, + and API authorization remain Backend-owned and apply equally to every client. +- The API never enables wildcard CORS. Client hosting and configuration are independent. + +## Browser/client origins and strict CORS + +An independently hosted browser client can connect directly to this API: + +```ini +V2RAYN_WEB_API_KEY= +V2RAYN_WEB_ALLOWED_ORIGINS=https://client.example.com,https://another.example.com +``` + +Unset/empty origins deny cross-origin API requests. Entries must be exact HTTP(S) origins +(scheme, host and port); paths, queries, fragments, credentials, empty list entries and wildcard +hosts are rejected at startup. Default ports are normalized. `https://good.example.com.evil.com` +does not match `https://good.example.com`. No forwarding headers are used to determine origin. + +The origin guard rejects disallowed `Origin` requests before rate limiting, session authorization, +SSE-ticket consumption or mutations. Origin-less native clients and same-origin clients retain +their existing behavior and still require session Bearer authentication for protected endpoints. +Allowed-origin OPTIONS preflights complete before session authorization. CORS supports GET, +HEAD, POST, PUT, DELETE and OPTIONS, `Authorization` / `Content-Type`, and exposes only +`Content-Disposition` for downloads. It does not enable credentials, cookies, wildcard origins +or blanket private-network preflight bypasses. SSE tickets and `/api/events` use the same policy. +The allowlist grants browser access, not authentication; trust every allowed browser/client origin. + +A same-origin browser behind a TLS/path-prefix reverse proxy retains REST access even when +Kestrel sees an internal HTTP URL: browser-controlled `Sec-Fetch-Site: same-origin` identifies +that case. It does not grant CORS response headers, setup access or internal health diagnostics. +Same-site/cross-site browser requests still require an exact allowed origin. Native clients +cannot gain privileges by forging this header: they already have origin-less access and still +must authenticate with the same Management Key/session model. + +First-run setup remains **direct loopback + localhost/loopback Host + no forwarding headers**, +and additionally requires same-origin (or an origin-less native request). Allowed CORS origins +cannot initialize a Management Key. `/api/setup/status` remains public and reports +`setupAllowedFromThisRequest=false` for standalone clients. LAN/remote/supervised deployment +must configure `V2RAYN_WEB_API_KEY` before startup. The historical private-network setup branch +was removed to align with the local-only initialization policy. + +HTTPS public dashboards connecting to HTTP localhost/LAN remain subject to browser Local +Network Access permissions and mixed-content restrictions. CORS cannot override them; verify +REST and native EventSource in the target browser. Some browsers block HTTP LAN even with +permission; use an HTTPS API or a same-origin reverse proxy. Never expose an unauthenticated/wildcard +listener or disable the existing listener/Management Key policies to work around browser limits. + +Non-API paths, including `GET /`, return 404. The launcher opens the API health endpoint, +not a client application. API packages contain no frontend installation directory or placeholder. + +## Prerelease update preferences + +Update checks and installs default to each target's entry in `CheckPreReleaseCoreTypes`; +the API self-update target is `v2rayN.WebAPI`, independent of Desktop's `v2rayN` and Core targets. +A missing list or entry defaults to stable releases. An explicit `preRelease=true` or `false` +overrides the preference only for that request; batches use each target's own preference. + +`GET /api/core-updates` returns `checkPreReleaseCoreTypes`. Pass this list to +`PUT /api/core-updates/settings` to save independent preferences (an empty list disables them). +Targets not exposed by this updater retain their stored preferences. For older clients that +omit the list, `preRelease` still applies to all exposed prerelease-capable targets, including +`v2rayN.WebAPI`; the response's legacy `preRelease` flag reflects the Web target's preference. + +## Build and test the Backend + +Requires the .NET 10 SDK. No Node.js or npm is used by the Backend build, tests, native publish, +Containerfile, or API release packaging. + +```sh +bash Scripts/publish-native.sh linux-x64 # also linux-arm64 / linux-riscv64 / linux-loongarch64 +bash Scripts/verify.sh +``` + +`Scripts/verify.sh` builds and tests the Backend, runs ServiceLib tests, checks startup/security, +and exercises API ZIP/self-update package boundaries and real different-layout Linux update, +rollback and invalid-executable rejection. Integration tests live under `Tests/Integration/` +(security defaults, release packaging dry-run, full-ZIP runtime, native update regression, and +the Windows smoke: `Test-Windows.ps1 -Mode startup|update`). The independent `build-WebAPI.yml` +workflow runs these checks, Windows x64/ARM64 build/startup smoke (the self-update +replacement/rollback smoke stays on win-x64), native Linux builds, and Linux container builds on +relevant PRs and pushes to `master`; it can also be dispatched manually or called by a release +workflow. Releases include Linux x64/ARM64/RISC-V 64/LoongArch 64 and Windows x64/ARM64 packages; +Linux containers are built for amd64 and arm64. + +`Tests/Integration/test-native-update-identity.py ` +additionally exercises real native helper replacement and health-mismatch rollback with two +distinct versions, isolated data/ports and preserved unrelated file/Core markers. It never uses +user configuration. + +### Native self-update and legacy helpers + +The update worker runs from a GUID-named copy of the current executable beside the installation +(owner-only permissions on Linux). This keeps its single-file bundle path unchanged during +replacement and rollback: .NET loads dependencies lazily, and replacing a running helper's own +executable with a different bundle layout can break later assembly loads. On Linux the outer +helper waits for the worker, propagates its result, and removes the copy; on Windows the detached +helper waits for the prior process and schedules its own copy for deletion after exit. Configuration +and data paths remain unchanged. + +`Tests/Integration/test-native-update-regression.sh ` deliberately +changes the candidate's bundle layout with test-only assembly metadata; version-only fixtures can +conceal this failure. It checks actual replacement, rollback, invalid-executable rejection and +worker cleanup, and is included in `Scripts/verify.sh`. + +**Linux builds predating helper isolation need a one-time manual reinstall.** Their already-running +old helper cannot gain this fix from the candidate package. Stop the instance, back up the +executable, build identity, data and `.env`, and install the corrected API package. For a failed +legacy update, retain the previous-application backup and verify the restored application's +health before discarding it. Systemd/container deployments remain check-only and use redeploy. + +## Pre-merge product rename and update migration + +The directory, project file, namespace, assembly/executable, embedded resource names, build +identity file/product and updater target are now `v2rayN.WebAPI`. Launcher/update helpers, +native/package scripts, Containerfile, solution registration, CI and tests use that identity. +The lock is `v2rayN.WebAPI.instance.lock`; the build identity is `v2rayN.WebAPI.build.json`; +health headers are `X-v2rayn-WebAPI-*`; the update capability is `WebAPI.self-update`; update +progress/runtime-state files are `WebAPI-update-progress.json` and +`WebAPI-update-runtime-state.json`; workflow files are `build-WebAPI.yml` and +`release-WebAPI.yml`. + +**Old experimental `v2rayN.Web` installs require a one-time manual reinstall.** Stop the old +instance first (including its systemd/container supervisor), back up data and `.env`, install +the new full package, update ExecStart/container entrypoint, and restore the retained data. +Do not run the two executables against the same data directory. There is no cross-name update +helper, executable alias or silent in-place migration. Old-product manifests/packages are not +accepted by the new updater; new `WebAPI → WebAPI` updates preserve the transactional helper, +health/PID/lock checks, atomic replacement/rollback and executable/Core/data boundaries. +Package staging checks Linux ELF64 headers/program tables (x86-64, AArch64, RISC-V, LoongArch) or +Windows PE32+ headers (AMD64, ARM64) against the declared RID before replacement; a script, +truncated image, or wrong-architecture candidate cannot reach shutdown/swap merely by carrying +the correct JSON identity. + +API-owned `SelectedCoreTypes` and `CheckPreReleaseCoreTypes` entries `v2rayN.Web` are normalized +to `v2rayN.WebAPI` at startup; legacy settings payloads are also normalized. Desktop/Core/hidden +preferences are preserved. This migrates preferences, not the executable or package identity. + +Intentional compatibility names remain: `V2RAYN_WEB_*` configuration (including the Management +Key and allowed-origin variables), `WebVersion`/`WebCommit`/`WebBuildDate`/`WebRepository` build metadata, +`webVersion` and related status fields, the `/api/web-updates` routes, generic Web host/security +class names, existing systemd unit / install-path examples, and the legacy `guiConfigs/web-auth.json` +location that `WebAuthStorage` migrates to `WebAPIData/WebAPI-auth.json`. These are not stale +executable assumptions. + +## Releases and containers + +`build-all.yml` ("release all platforms") dispatches the desktop platform workflows and calls +`release-WebAPI.yml`; the WebAPI packages ride the same `upload-sign.yml` path and land in the same +release as the desktop assets. `release-WebAPI.yml` can also be dispatched directly with a +`release_tag` (`x.y.z`) to call `build-WebAPI.yml`, assemble and validate the Linux and Windows +WebAPI assets, then sign and upload them through the existing `upload-sign.yml` workflow. Signing +uses the repository GPG key, so a fork that runs this flow publishes with its own +`GPG_PRIVATE_KEY` secret. `build-WebAPI.yml` can also be dispatched with a tag for package-only +verification. Web release orchestration and artifact names are RID-based, separately from +containers and Desktop workflows. riscv64 builds on RISC-V runners with the community .NET SDK; +loongarch64 builds inside the QEMU loong64 VM with the official Loongson SDK; both run a native +smoke before packaging. Windows x64/ARM64 have full-install and app-only update ZIPs; Windows +containers are not built. + +The API release provides architecture-specific full-install and app-only update ZIPs plus +`WebAPI-update.json`: + +- `v2rayN-linux-64-WebAPI.zip` / `v2rayN-linux-arm64-WebAPI.zip` / `v2rayN-linux-riscv64-WebAPI.zip` + / `v2rayN-linux-loong64-WebAPI.zip` — API, runtime files, `.env.example`, and Core bundle. +- `v2rayN-linux-64-WebAPI-update.zip` / `v2rayN-linux-arm64-WebAPI-update.zip` / + `v2rayN-linux-riscv64-WebAPI-update.zip` / `v2rayN-linux-loong64-WebAPI-update.zip` — API + executable and build identity only; user data, Core files, credentials, and unrelated files are + excluded. +- `v2rayN-windows-64-WebAPI.zip` / `v2rayN-windows-arm64-WebAPI.zip` — Windows API, runtime/Core + files, and `.env.example`. +- `v2rayN-windows-64-WebAPI-update.zip` / `v2rayN-windows-arm64-WebAPI-update.zip` — Windows API + executable and build identity only; user data, Core files, credentials, and unrelated files are + excluded. +- `WebAPI-update.json` — version, commit, per-RID asset names, sizes, and SHA-256 digests. A RID + entry a build does not recognize is ignored instead of rejecting the manifest. + +For Docker/Podman, the included `Containerfile` builds only the API and Core runtime. +The Compose example requires a non-empty Management Key. + +See [`FEATURE-MAP.md`](FEATURE-MAP.md) for the current ServiceLib/API feature boundary. diff --git a/v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh b/v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh new file mode 100644 index 00000000..d578f493 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +rid="$1" +destination="$2" + +case "$rid" in + linux-x64) bundle="v2rayN-linux-64" ;; + linux-arm64) bundle="v2rayN-linux-arm64" ;; + linux-riscv64) bundle="v2rayN-linux-riscv64" ;; + linux-loongarch64) bundle="v2rayN-linux-loong64" ;; + *) + echo "Unsupported runtime identifier: $rid" >&2 + exit 2 + ;; +esac + +temporary_directory="$(mktemp -d)" +trap 'rm -rf "$temporary_directory"' EXIT HUP INT TERM + +bundle_url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/$bundle.zip" +curl --fail --location --retry 3 --retry-delay 2 --silent --show-error \ + --output "$temporary_directory/core-bundle.zip" "$bundle_url" +unzip -q "$temporary_directory/core-bundle.zip" -d "$temporary_directory/unpacked" + +source_bin="$temporary_directory/unpacked/$bundle/bin" +# These files are sanity checks for the current upstream payload, never an allowlist. +for required_file in \ + xray/xray \ + sing_box/sing-box \ + mihomo/mihomo \ + Country.mmdb \ + geoip-only-cn-private.dat \ + geoip.dat \ + geoip.metadb \ + geosite.dat \ + srss/geoip-cn.srs \ + srss/geosite-cn.srs \ + srss/geosite-category-ads-all.srs; do + if [ ! -f "$source_bin/$required_file" ]; then + echo "Core bundle is missing required asset: $required_file" >&2 + exit 1 + fi +done +# The cronet payload is only present in the x64/arm64 bundles. +if [ "$rid" = "linux-x64" ] || [ "$rid" = "linux-arm64" ]; then + if [ ! -f "$source_bin/sing_box/libcronet.so" ]; then + echo "Core bundle is missing required asset: sing_box/libcronet.so" >&2 + exit 1 + fi +fi + +mkdir -p "$destination" +# Preserve the complete official bin/ tree, including future upstream assets and Cores. +cp -a "$source_bin/." "$destination/" +chmod 755 \ + "$destination/xray/xray" \ + "$destination/sing_box/sing-box" \ + "$destination/mihomo/mihomo" +if [ -f "$destination/sing_box/libcronet.so" ]; then + chmod 644 "$destination/sing_box/libcronet.so" +fi + +printf 'Integrated complete upstream %s Core/data bin payload into %s\n' "$rid" "$destination" diff --git a/v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh b/v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh new file mode 100644 index 00000000..9a044c4e --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +web_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +upstream_root="$(dirname -- "$web_root")" +repo_root="$(dirname -- "$upstream_root")" +rid="${1:-linux-x64}" +output_dir="${2:-$web_root/publish/$rid}" + +dotnet="${DOTNET:-}" +if [[ "$dotnet" != */* && -n "$dotnet" ]]; then + dotnet="$(command -v "$dotnet" || true)" +fi +if [[ -z "$dotnet" ]]; then + if [[ -x "$web_root/.NET/dotnet" ]]; then + dotnet="$web_root/.NET/dotnet" + else + dotnet="$(command -v dotnet || true)" + fi +fi +if [[ -z "$dotnet" || ! -x "$dotnet" ]]; then + printf 'Required build tool is not available: dotnet (expected .NET/dotnet or PATH)\n' >&2 + exit 1 +fi +case "$rid" in + linux-x64|linux-arm64|linux-riscv64|linux-loongarch64) ;; + *) printf 'Supported runtime identifiers: linux-x64, linux-arm64, linux-riscv64, linux-loongarch64 (received %s)\n' "$rid" >&2; exit 2 ;; +esac + +export NUGET_PACKAGES="${NUGET_PACKAGES:-$web_root/.packages/nuget}" +# Official release builds pass the upstream release tag (for example 7.25.4) so the Web +# build identity follows the same version as the v2rayN Release. Local builds keep a dev identity. +web_version="${V2RAYN_WEB_VERSION:-0.0.0-dev}" +web_repository="${V2RAYN_WEB_REPOSITORY:-${GITHUB_REPOSITORY:-2dust/v2rayN}}" +web_commit="${V2RAYN_WEB_COMMIT:-$(git -C "$repo_root" rev-parse HEAD 2>/dev/null || printf unknown)}" +web_build_date="${V2RAYN_WEB_BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +if [[ ! "$web_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$ ]]; then + printf 'Invalid Web version identity: %s\n' "$web_version" >&2 + exit 2 +fi +if [[ ! "$web_repository" =~ ^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+$ ]]; then + printf 'Invalid Web release repository: %s\n' "$web_repository" >&2 + exit 2 +fi + +"$dotnet" publish "$web_root/v2rayN.WebAPI.csproj" \ + --configuration Release \ + --runtime "$rid" \ + --self-contained true \ + -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:WebVersion="$web_version" \ + -p:WebCommit="$web_commit" \ + -p:WebBuildDate="$web_build_date" \ + -p:WebRepository="$web_repository" \ + --output "$output_dir" + +python3 - "$output_dir/v2rayN.WebAPI.build.json" "$web_version" "$web_commit" "$web_build_date" "$rid" <<'PY' +import json +import sys +from pathlib import Path + +path, version, commit, build_date, rid = sys.argv[1:] +Path(path).write_text(json.dumps({ + "product": "v2rayN.WebAPI", + "version": version, + "commit": commit, + "buildDate": build_date, + "rid": rid, +}, separators=(",", ":")) + "\n", encoding="utf-8") +PY +test -x "$output_dir/v2rayN.WebAPI" +printf 'Native publish ready: %s (version %s, repository %s)\n' "$output_dir/v2rayN.WebAPI" "$web_version" "$web_repository" diff --git a/v2rayN/v2rayN.WebAPI/Scripts/verify.sh b/v2rayN/v2rayN.WebAPI/Scripts/verify.sh new file mode 100755 index 00000000..434df1d7 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Scripts/verify.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +web_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +upstream_root="$(dirname -- "$web_root")" +output_dir="${1:-$web_root/publish/verify-linux-x64}" + +dotnet="${DOTNET:-}" +if [[ "$dotnet" != */* && -n "$dotnet" ]]; then + dotnet="$(command -v "$dotnet" || true)" +fi +if [[ -z "$dotnet" ]]; then + if [[ -x "$web_root/.NET/dotnet" ]]; then + dotnet="$web_root/.NET/dotnet" + else + dotnet="$(command -v dotnet || true)" + fi +fi +if [[ -z "$dotnet" || ! -x "$dotnet" ]]; then + printf 'Required build tool is not available: dotnet (expected .NET/dotnet or PATH)\n' >&2 + exit 1 +fi +export NUGET_PACKAGES="${NUGET_PACKAGES:-$web_root/.packages/nuget}" + +"$dotnet" restore "$web_root/v2rayN.WebAPI.csproj" +"$dotnet" restore "$web_root/Tests/v2rayN.WebAPI.Tests.csproj" +"$dotnet" restore "$upstream_root/ServiceLib.Tests/ServiceLib.Tests.csproj" +"$dotnet" build "$web_root/v2rayN.WebAPI.csproj" --configuration Release --no-restore +"$dotnet" test --project "$web_root/Tests/v2rayN.WebAPI.Tests.csproj" --configuration Release --no-restore +"$dotnet" test --project "$upstream_root/ServiceLib.Tests/ServiceLib.Tests.csproj" --configuration Release --no-restore +bash "$web_root/Scripts/publish-native.sh" linux-x64 "$output_dir" +test -x "$output_dir/v2rayN.WebAPI" +bash "$web_root/Tests/Integration/test-startup-security.sh" "$output_dir/v2rayN.WebAPI" +bash "$web_root/Tests/Integration/test-backup-restore-preflight.sh" "$output_dir/v2rayN.WebAPI" +bash "$web_root/Tests/Integration/test-release-packaging.sh" +smoke_dir="$(mktemp -d "${TMPDIR:-/tmp}/v2rayn-api-package-smoke.XXXXXX")" +trap 'rm -rf "$smoke_dir"' EXIT HUP INT TERM +mkdir -p "$smoke_dir/package/bin" +cp "$output_dir/v2rayN.WebAPI" "$output_dir/v2rayN.WebAPI.build.json" \ + "$web_root/.env.example" "$smoke_dir/package/" +touch "$smoke_dir/package/bin/.keep" +python3 - "$smoke_dir/package" "$smoke_dir/v2rayN-api-smoke.zip" <<'PY' +from pathlib import Path +import sys +import zipfile + +root, archive_path = map(Path, sys.argv[1:]) +with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in sorted(root.rglob("*")): + if path.is_file(): + archive.write(path, path.relative_to(root).as_posix()) +PY +bash "$web_root/Tests/Integration/test-full-zip-runtime.sh" "$smoke_dir/v2rayN-api-smoke.zip" +bash "$web_root/Tests/Integration/test-native-update-regression.sh" "$output_dir" +printf 'WebAPI verification passed; native publish ready: %s\n' "$output_dir/v2rayN.WebAPI" diff --git a/v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py b/v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py new file mode 100755 index 00000000..258016d1 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Package the headless v2rayN Web API release ZIPs and report their asset names. + +The RID-to-asset map lives in `../Assets/web-assets.json`. The Web assembly embeds the same +file, so the runtime updater, the release manifest, and this packager never disagree about the +release asset names: + + linux-x64 -> v2rayN-linux-64-WebAPI.zip + v2rayN-linux-64-WebAPI-update.zip + linux-arm64 -> v2rayN-linux-arm64-WebAPI.zip + v2rayN-linux-arm64-WebAPI-update.zip + linux-riscv64 -> v2rayN-linux-riscv64-WebAPI.zip + v2rayN-linux-riscv64-WebAPI-update.zip + linux-loongarch64-> v2rayN-linux-loong64-WebAPI.zip + v2rayN-linux-loong64-WebAPI-update.zip + win-x64 -> v2rayN-windows-64-WebAPI.zip + v2rayN-windows-64-WebAPI-update.zip + win-arm64 -> v2rayN-windows-arm64-WebAPI.zip + v2rayN-windows-arm64-WebAPI-update.zip + +Usage: + web-release-assets.py get --rid linux-x64 --field full + web-release-assets.py package --rid linux-x64 --publish publish/linux-x64 --dist dist +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import stat +import sys +import zipfile +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +ASSET_MAP_PATH = SCRIPT_DIR.parent / "Assets" / "web-assets.json" +FIXED_TIME = (1980, 1, 1, 0, 0, 0) +BUILD_IDENTITY_NAME = "v2rayN.WebAPI.build.json" +ENVIRONMENT_FILE_NAMES = frozenset((".env", ".env.example")) +EXECUTABLE_NAMES = { + "linux-x64": "v2rayN.WebAPI", + "linux-arm64": "v2rayN.WebAPI", + "linux-riscv64": "v2rayN.WebAPI", + "linux-loongarch64": "v2rayN.WebAPI", + "win-x64": "v2rayN.WebAPI.exe", + "win-arm64": "v2rayN.WebAPI.exe", +} +REQUIRED_FILES = { + "linux-x64": ( + "v2rayN.WebAPI", BUILD_IDENTITY_NAME, "bin/sing_box/libcronet.so", "bin/geosite.dat", + "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", "bin/srss/geosite-category-ads-all.srs", + ), + "linux-arm64": ( + "v2rayN.WebAPI", BUILD_IDENTITY_NAME, "bin/sing_box/libcronet.so", "bin/geosite.dat", + "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", "bin/srss/geosite-category-ads-all.srs", + ), + "linux-riscv64": ( + "v2rayN.WebAPI", BUILD_IDENTITY_NAME, "bin/geosite.dat", + "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", "bin/srss/geosite-category-ads-all.srs", + ), + "linux-loongarch64": ( + "v2rayN.WebAPI", BUILD_IDENTITY_NAME, "bin/geosite.dat", + "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", "bin/srss/geosite-category-ads-all.srs", + ), + "win-x64": ( + "v2rayN.WebAPI.exe", BUILD_IDENTITY_NAME, "bin/xray/xray.exe", "bin/sing_box/sing-box.exe", + "bin/mihomo/mihomo.exe", "bin/geosite.dat", "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", + ), + "win-arm64": ( + "v2rayN.WebAPI.exe", BUILD_IDENTITY_NAME, "bin/xray/xray.exe", "bin/sing_box/sing-box.exe", + "bin/mihomo/mihomo.exe", "bin/geosite.dat", "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", + ), +} +LINUX_REQUIRED_EXECUTABLES = ("bin/xray/xray", "bin/sing_box/sing-box", "bin/mihomo/mihomo") +WINDOWS_REQUIRED_EXECUTABLES = ( + "v2rayN.WebAPI.exe", "bin/xray/xray.exe", "bin/sing_box/sing-box.exe", "bin/mihomo/mihomo.exe", +) +BLOCK_SIZE = 1024 * 1024 + + +def is_windows_rid(rid: str) -> bool: + return rid.startswith("win-") + + +def fail(message: str) -> None: + print(f"web release assets error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_assets() -> dict[str, dict[str, str]]: + try: + data = json.loads(ASSET_MAP_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"could not read {ASSET_MAP_PATH}: {exc}") + if not isinstance(data, dict) or not isinstance(data.get("assets"), list): + fail(f"{ASSET_MAP_PATH} is missing its assets list") + assets: dict[str, dict[str, str]] = {} + for entry in data["assets"]: + if not isinstance(entry, dict): + fail(f"{ASSET_MAP_PATH} contains a non-object asset entry") + values = (entry.get("rid"), entry.get("arch"), entry.get("full"), entry.get("update")) + if not all(isinstance(value, str) and value for value in values) or values[0] in assets: + fail(f"{ASSET_MAP_PATH} contains an invalid or duplicate asset entry") + assets[values[0]] = {"arch": values[1], "full": values[2], "update": values[3]} + if not assets: + fail(f"{ASSET_MAP_PATH} contains no assets") + return assets + + +def normalize_entry(name: str) -> str: + """Normalize an archive entry name the same way the Web runtime package stager does.""" + if not name or name.startswith("/") or "\\" in name or "\0" in name: + fail(f"invalid archive path: {name!r}") + segments = [segment for segment in name.split("/") if segment] + if not segments or any(segment in (".", "..") or ":" in segment for segment in segments): + fail(f"invalid archive path: {name!r}") + return "/".join(segments) + + +def collect_tree(root: Path) -> list[tuple[str, Path, str]]: + entries: list[tuple[str, Path, str]] = [] + for current, dirnames, filenames in os.walk(root, followlinks=False): + dirnames.sort() + filenames.sort() + for name in list(dirnames): + path = Path(current) / name + if path.is_symlink(): + entries.append((path.relative_to(root).as_posix(), path, "symlink")) + dirnames.remove(name) + for name in filenames: + path = Path(current) / name + if path.is_symlink(): + entries.append((path.relative_to(root).as_posix(), path, "symlink")) + elif path.is_file(): + entries.append((path.relative_to(root).as_posix(), path, "file")) + else: + fail(f"unsupported file type in the Web package: {path}") + return entries + + +def write_file(handle: zipfile.ZipFile, source: Path, arcname: str, mode: int) -> None: + info = zipfile.ZipInfo(arcname, date_time=FIXED_TIME) + info.create_system = 3 + info.external_attr = ((stat.S_IFREG | mode) & 0xFFFF) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + with source.open("rb") as reader, handle.open(info, "w") as writer: + shutil.copyfileobj(reader, writer, BLOCK_SIZE) + + +def write_symlink(handle: zipfile.ZipFile, target: str, arcname: str) -> None: + info = zipfile.ZipInfo(arcname, date_time=FIXED_TIME) + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + info.compress_type = zipfile.ZIP_STORED + handle.writestr(info, target) + + +def create_full_zip(publish: Path, output: Path, rid: str) -> None: + env_example = SCRIPT_DIR.parent / ".env.example" + if env_example.is_symlink() or not env_example.is_file(): + fail(f"full package is missing the source environment example: {env_example}") + + # Only the reviewed template is shipped. A local .env (or another copied template) is + # never taken from the publish tree, even if a build tool happened to copy it there. + entries = [ + entry for entry in collect_tree(publish) + if not any(segment in ENVIRONMENT_FILE_NAMES for segment in entry[0].split("/")) + ] + names = {relative for relative, _, _ in entries} + for required in REQUIRED_FILES[rid]: + if required not in names: + fail(f"full package is missing required file: {required}") + required_executables = WINDOWS_REQUIRED_EXECUTABLES if is_windows_rid(rid) else LINUX_REQUIRED_EXECUTABLES + for required in required_executables: + if required not in names or (not is_windows_rid(rid) and not os.access(publish / required, os.X_OK)): + fail(f"full package is missing required executable: {required}") + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as handle: + for relative, path, kind in entries: + if kind == "symlink": + write_symlink(handle, os.readlink(path), relative) + else: + write_file(handle, path, relative, stat.S_IMODE(path.stat().st_mode)) + write_file(handle, env_example, ".env.example", 0o644) + + +def create_update_zip(publish: Path, output: Path, rid: str) -> None: + executable_name = EXECUTABLE_NAMES[rid] + if not (publish / executable_name).is_file() or (not is_windows_rid(rid) and not os.access(publish / executable_name, os.X_OK)): + fail(f"app-only package is missing required executable: {executable_name}") + entries: list[tuple[str, Path]] = [] + for name in (executable_name, BUILD_IDENTITY_NAME): + path = publish / name + if path.is_symlink() or not path.is_file(): + fail(f"app-only package is missing required file: {name}") + entries.append((executable_name, publish / executable_name)) + entries.append((BUILD_IDENTITY_NAME, publish / BUILD_IDENTITY_NAME)) + + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as handle: + for arcname, path in entries: + write_file(handle, path, arcname, stat.S_IMODE(path.stat().st_mode)) + verify_update_zip(output, rid) + + +def verify_update_zip(path: Path, rid: str) -> None: + executable_name = EXECUTABLE_NAMES[rid] + try: + handle = zipfile.ZipFile(path) + except (OSError, zipfile.BadZipFile) as exc: + fail(f"app-only package {path.name} could not be read: {exc}") + with handle: + seen: set[str] = set() + for info in handle.infolist(): + if (info.external_attr >> 16) & 0xF000 not in (0, 0x4000, 0x8000): + fail(f"app-only package {path.name} contains a link or special entry: {info.filename}") + name = normalize_entry(info.filename) + if any(segment in ENVIRONMENT_FILE_NAMES for segment in name.split("/")): + fail(f"app-only package {path.name} must not contain environment files: {name}") + if name in seen: + fail(f"app-only package {path.name} contains a duplicate archive path: {name}") + seen.add(name) + if name not in (executable_name, BUILD_IDENTITY_NAME): + fail(f"app-only package {path.name} contains an unexpected entry: {name}") + for required in (executable_name, BUILD_IDENTITY_NAME): + if required not in seen: + fail(f"app-only package {path.name} is missing {required}") + + +def get_command(args: argparse.Namespace) -> None: + assets = load_assets() + entry = assets.get(args.rid) + if entry is None: + fail(f"unsupported runtime identifier: {args.rid}") + print(entry[args.field]) + + +def package_command(args: argparse.Namespace) -> None: + assets = load_assets() + entry = assets.get(args.rid) + if entry is None: + fail(f"unsupported runtime identifier: {args.rid}") + publish = Path(args.publish) + dist = Path(args.dist) + if not publish.is_dir(): + fail(f"publish directory does not exist: {publish}") + dist.mkdir(parents=True, exist_ok=True) + full_archive = dist / entry["full"] + update_archive = dist / entry["update"] + create_full_zip(publish, full_archive, args.rid) + create_update_zip(publish, update_archive, args.rid) + print(f"Packaged {full_archive} and {update_archive}") + + +def main(argv: list[str]) -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + subparsers = parser.add_subparsers(dest="command", required=True) + + get_parser = subparsers.add_parser("get", help="print a shared release asset name") + get_parser.add_argument("--rid", required=True) + get_parser.add_argument("--field", required=True, choices=("arch", "full", "update")) + get_parser.set_defaults(func=get_command) + + package_parser = subparsers.add_parser("package", help="build the full and app-only release ZIPs") + package_parser.add_argument("--rid", required=True) + package_parser.add_argument("--publish", required=True) + package_parser.add_argument("--dist", required=True) + package_parser.set_defaults(func=package_command) + + args = parser.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py b/v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py new file mode 100755 index 00000000..3ba8d649 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Build or verify the v2rayN WebAPI `WebAPI-update.json` release manifest. + +The manifest is published next to the official v2rayN Release assets. Every URL points at the +official release tag in the repository that produced the assets, so the Web self-update never +depends on a fork-specific release channel. + +Verification also opens each app-only archive and cross-checks its embedded +`v2rayN.WebAPI.build.json` against the manifest. App-only archives contain only the API executable +and build identity; unrelated files are neither packaged nor touched by self-update. + +Usage: + web-update-manifest.py write --version 7.25.4 --repository 2dust/v2rayN --commit \ + --build-date 2026-09-28T00:00:00Z --dist dist --output dist/WebAPI-update.json + web-update-manifest.py verify --manifest dist/WebAPI-update.json --dist dist [--repository 2dust/v2rayN] +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import zipfile +from pathlib import Path + +PRODUCT = "v2rayN.WebAPI" +ASSET_MAP_PATH = Path(__file__).resolve().parent.parent / "Assets" / "web-assets.json" +BUILD_IDENTITY_NAME = "v2rayN.WebAPI.build.json" +APP_EXECUTABLE_NAMES = { + "linux-x64": "v2rayN.WebAPI", + "linux-arm64": "v2rayN.WebAPI", + "linux-riscv64": "v2rayN.WebAPI", + "linux-loongarch64": "v2rayN.WebAPI", + "win-x64": "v2rayN.WebAPI.exe", + "win-arm64": "v2rayN.WebAPI.exe", +} +WINDOWS_PE_MACHINES = { + "win-x64": 0x8664, + "win-arm64": 0xAA64, +} +MAX_ARCHIVE_ENTRIES = 10000 +MAX_EXPANDED_ARCHIVE_BYTES = 1024 * 1024 * 1024 +VERSION_RE = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+$") +OFFICIAL_URL_RE = re.compile( + r"^https://github\.com/[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+/releases/download/(?P[^/]+)/(?P[^/]+)$" +) + + +def fail(message: str) -> None: + print(f"web-update manifest error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_assets() -> dict[str, dict[str, str]]: + """Read the RID-to-asset map shared with the packager and the embedded runtime map.""" + try: + data = json.loads(ASSET_MAP_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"could not read {ASSET_MAP_PATH}: {exc}") + if not isinstance(data, dict) or not isinstance(data.get("assets"), list): + fail(f"{ASSET_MAP_PATH} is missing its assets list") + assets: dict[str, dict[str, str]] = {} + for entry in data["assets"]: + if not isinstance(entry, dict): + fail(f"{ASSET_MAP_PATH} contains a non-object asset entry") + values = (entry.get("rid"), entry.get("arch"), entry.get("full"), entry.get("update")) + if not all(isinstance(value, str) and value for value in values) or values[0] in assets: + fail(f"{ASSET_MAP_PATH} contains an invalid or duplicate asset entry") + assets[values[0]] = {"arch": values[1], "full": values[2], "update": values[3]} + if not assets: + fail(f"{ASSET_MAP_PATH} contains no assets") + return assets + + +ASSETS = load_assets() + + +def full_asset_name(rid: str) -> str: + return ASSETS[rid]["full"] + + +def app_asset_name(rid: str) -> str: + return ASSETS[rid]["update"] + + +def asset_url(repository: str, version: str, asset: str) -> str: + return f"https://github.com/{repository}/releases/download/{version}/{asset}" + + +def require_release_files(dist: Path) -> None: + for rid in ASSETS: + for asset in (full_asset_name(rid), app_asset_name(rid)): + path = dist / asset + if not path.is_file() or path.stat().st_size == 0: + fail(f"release asset is missing or empty: {path}") + + +def normalize_member_path(archive_name: str, member_name: str) -> str: + """Normalize an archive entry exactly like the Web runtime package stager does.""" + if not member_name or member_name.startswith("/") or "\\" in member_name or "\0" in member_name: + fail(f"app-only archive {archive_name} contains an invalid archive path: {member_name!r}") + segments = [segment for segment in member_name.split("/") if segment] + if not segments or any(segment in (".", "..") or ":" in segment for segment in segments): + fail(f"app-only archive {archive_name} contains an invalid archive path: {member_name!r}") + return "/".join(segments) + + +def app_member_allowed(path: str, rid: str) -> bool: + return path in (APP_EXECUTABLE_NAMES[rid], BUILD_IDENTITY_NAME) + + +def verify_windows_pe(stream, size: int, archive_name: str, expected_machine: int) -> None: + if size < 64: + fail(f"app-only archive {archive_name} does not contain a Windows PE executable") + dos_header = stream.read(64) + if len(dos_header) != 64 or dos_header[:2] != b"MZ": + fail(f"app-only archive {archive_name} does not contain a Windows PE executable") + pe_offset = int.from_bytes(dos_header[0x3C:0x40], "little") + if pe_offset < 64 or pe_offset + 26 > size: + fail(f"app-only archive {archive_name} has a truncated Windows PE header") + stream.seek(pe_offset) + pe_header = stream.read(26) + if len(pe_header) != 26 or pe_header[:4] != b"PE\0\0": + fail(f"app-only archive {archive_name} has a truncated Windows PE header") + machine = int.from_bytes(pe_header[4:6], "little") + sections = int.from_bytes(pe_header[6:8], "little") + optional_size = int.from_bytes(pe_header[20:22], "little") + characteristics = int.from_bytes(pe_header[22:24], "little") + optional_start = pe_offset + 24 + if (machine != expected_machine or sections == 0 or characteristics & 0x0002 == 0 + or optional_size < 2 or optional_start + optional_size > size): + fail(f"app-only archive {archive_name} executable is not a Windows PE32+ image for its declared runtime identifier") + if int.from_bytes(pe_header[24:26], "little") != 0x20B: + fail(f"app-only archive {archive_name} executable is not a Windows PE32+ image for its declared runtime identifier") + + +def verify_app_archive_identity(archive: Path, rid: str, version: str, commit: str, build_date: str) -> None: + """Cross-check the app-only ZIP layout and its embedded build identity. + + The archive must only carry the executable and its `v2rayN.WebAPI.build.json` identity. The + identity must match the release manifest exactly, mirroring the checks the Web runtime + performs before installing a self-update package. + """ + archive_name = archive.name + executable_name = APP_EXECUTABLE_NAMES[rid] + seen: set[str] = set() + identity_bytes: bytes | None = None + executable_info: zipfile.ZipInfo | None = None + entry_count = 0 + expanded_bytes = 0 + try: + handle = zipfile.ZipFile(archive) + except (OSError, zipfile.BadZipFile) as exc: + fail(f"app-only archive {archive_name} could not be read: {exc}") + with handle: + for info in handle.infolist(): + entry_count += 1 + if entry_count > MAX_ARCHIVE_ENTRIES: + fail(f"app-only archive {archive_name} has more than {MAX_ARCHIVE_ENTRIES} entries") + path = normalize_member_path(archive_name, info.filename) + if path in seen: + fail(f"app-only archive {archive_name} contains a duplicate archive path: {path}") + seen.add(path) + if not app_member_allowed(path, rid): + fail(f"app-only archive {archive_name} contains an entry outside the app-only layout: {path}") + unix_type = (info.external_attr >> 16) & 0xF000 + if unix_type not in (0, 0x4000, 0x8000): + fail(f"app-only archive {archive_name} contains a link or special entry: {path}") + if unix_type == 0x4000 or info.filename.endswith("/"): + continue + expanded_bytes += info.file_size + if expanded_bytes > MAX_EXPANDED_ARCHIVE_BYTES: + fail(f"app-only archive {archive_name} expands beyond the supported size") + if path == BUILD_IDENTITY_NAME: + identity_bytes = handle.read(info) + elif path == executable_name: + executable_info = info + if rid in WINDOWS_PE_MACHINES and executable_info is not None: + with handle.open(executable_info) as executable_stream: + verify_windows_pe(executable_stream, executable_info.file_size, archive_name, WINDOWS_PE_MACHINES[rid]) + + for required in (executable_name, BUILD_IDENTITY_NAME): + if required not in seen: + fail(f"app-only archive {archive_name} is missing {required}") + if identity_bytes is None: + fail(f"app-only archive {archive_name} has an unreadable {BUILD_IDENTITY_NAME}") + if executable_info is None: + fail(f"app-only archive {archive_name} has an unreadable {executable_name}") + try: + identity = json.loads(identity_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + fail(f"app-only archive {archive_name} has an invalid {BUILD_IDENTITY_NAME}: {exc}") + if not isinstance(identity, dict): + fail(f"app-only archive {archive_name} has an invalid {BUILD_IDENTITY_NAME}: expected a JSON object") + + expected = {"product": PRODUCT, "version": version, "commit": commit, "buildDate": build_date, "rid": rid} + for field, value in expected.items(): + actual = identity.get(field) + if actual != value: + fail( + f"app-only archive {archive_name} identity mismatch for {field}: " + f"{actual!r} does not match the manifest value {value!r}" + ) + + +def package_entry(dist: Path, repository: str, version: str, rid: str) -> dict: + path = dist / app_asset_name(rid) + data = path.read_bytes() + return { + "rid": rid, + "asset": path.name, + "url": asset_url(repository, version, path.name), + "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data), + } + + +def build_manifest(dist: Path, version: str, repository: str, commit: str, build_date: str) -> dict: + if not VERSION_RE.match(version): + fail(f"official release versions must be x.y.z, got: {version}") + if not REPOSITORY_RE.match(repository): + fail(f"invalid release repository: {repository}") + if not commit or commit == "unknown": + fail("the manifest requires a real commit identity") + if not build_date or build_date == "unknown": + fail("the manifest requires a real build date") + require_release_files(dist) + return { + "product": PRODUCT, + "version": version, + "commit": commit, + "buildDate": build_date, + "packages": [package_entry(dist, repository, version, rid) for rid in ASSETS], + } + + +def verify_manifest(manifest_path: Path, dist: Path, repository: str | None, version: str | None = None) -> None: + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"could not read {manifest_path}: {exc}") + if not isinstance(manifest, dict) or manifest.get("product") != PRODUCT: + fail(f"{manifest_path} is not a {PRODUCT} manifest") + manifest_version = manifest.get("version") + if not isinstance(manifest_version, str) or not VERSION_RE.match(manifest_version): + fail(f"{manifest_path} has an invalid version: {manifest_version!r}") + if version is not None and manifest_version != version: + fail(f"{manifest_path} version {manifest_version} does not match {version}") + if not isinstance(manifest.get("commit"), str) or not manifest["commit"]: + fail(f"{manifest_path} has no commit identity") + if not isinstance(manifest.get("buildDate"), str) or not manifest["buildDate"]: + fail(f"{manifest_path} has no build date") + + packages = manifest.get("packages") + if not isinstance(packages, list) or len(packages) != len(ASSETS): + fail(f"{manifest_path} must contain exactly {len(ASSETS)} packages") + by_rid = {} + for package in packages: + if not isinstance(package, dict): + fail(f"{manifest_path} contains a non-object package entry") + rid = package.get("rid") + if rid not in ASSETS: + fail(f"{manifest_path} contains an unsupported runtime identifier: {rid!r}") + if rid in by_rid: + fail(f"{manifest_path} contains duplicate runtime identifiers") + asset = package.get("asset") + if asset != app_asset_name(rid): + fail(f"{manifest_path} has an unexpected asset for {rid}: {asset!r}") + path = dist / asset + if not path.is_file() or path.stat().st_size == 0: + fail(f"release asset is missing or empty: {path}") + data = path.read_bytes() + if package.get("sha256") != hashlib.sha256(data).hexdigest(): + fail(f"sha256 mismatch for {asset}") + if package.get("size") != len(data): + fail(f"size mismatch for {asset}") + url = package.get("url") + if not isinstance(url, str): + fail(f"missing asset URL for {rid}") + match = OFFICIAL_URL_RE.match(url) + if not match or match.group("tag") != manifest_version or match.group("asset") != asset: + fail(f"asset URL is not an official release URL for this tag: {url}") + if repository is not None and not url.startswith(f"https://github.com/{repository}/"): + fail(f"asset URL does not use repository {repository}: {url}") + verify_app_archive_identity(path, rid, manifest_version, manifest["commit"], manifest["buildDate"]) + by_rid[rid] = package + + for rid in ASSETS: + if rid not in by_rid: + fail(f"{manifest_path} is missing runtime {rid}") + require_release_files(dist) + + +def write_manifest(args: argparse.Namespace) -> None: + dist = Path(args.dist) + manifest = build_manifest(dist, args.version, args.repository, args.commit, args.build_date) + output = Path(args.output) + output.write_text(json.dumps(manifest, separators=(",", ":")) + "\n", encoding="utf-8") + verify_manifest(output, dist, args.repository, args.version) + print(f"Wrote {output} for {PRODUCT} {args.version} ({args.repository})") + + +def verify_command(args: argparse.Namespace) -> None: + verify_manifest(Path(args.manifest), Path(args.dist), args.repository, args.version) + print(f"Verified {args.manifest}") + + +def main(argv: list[str]) -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + subparsers = parser.add_subparsers(dest="command", required=True) + + write_parser = subparsers.add_parser("write", help="write WebAPI-update.json from the packaged assets") + write_parser.add_argument("--version", required=True) + write_parser.add_argument("--repository", required=True) + write_parser.add_argument("--commit", required=True) + write_parser.add_argument("--build-date", required=True) + write_parser.add_argument("--dist", required=True) + write_parser.add_argument("--output", required=True) + write_parser.set_defaults(func=write_manifest) + + verify_parser = subparsers.add_parser("verify", help="verify WebAPI-update.json against the packaged assets") + verify_parser.add_argument("--manifest", required=True) + verify_parser.add_argument("--dist", required=True) + verify_parser.add_argument("--repository") + verify_parser.add_argument("--version", help="require the manifest to match this release version") + verify_parser.set_defaults(func=verify_command) + + args = parser.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/v2rayN/v2rayN.WebAPI/Security/WebAuthRateLimiting.cs b/v2rayN/v2rayN.WebAPI/Security/WebAuthRateLimiting.cs new file mode 100644 index 00000000..82be7281 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebAuthRateLimiting.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.RateLimiting; + +namespace v2rayN.WebAPI.Security; + +public static class WebAuthRateLimiting +{ + public const string LoginPolicy = "WebAPI-auth-login"; + public const string SetupPolicy = "WebAPI-auth-setup"; + + public static void Configure(RateLimiterOptions options) + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = (context, _) => + { + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + var seconds = Math.Ceiling(retryAfter.TotalSeconds); + context.HttpContext.Response.Headers["Retry-After"] = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + return ValueTask.CompletedTask; + }; + options.AddPolicy(LoginPolicy, context => CreateIpPartition(context, permitLimit: 5)); + options.AddPolicy(SetupPolicy, context => CreateIpPartition(context, permitLimit: 10)); + } + + private static RateLimitPartition CreateIpPartition(HttpContext context, int permitLimit) => + RateLimitPartition.GetFixedWindowLimiter( + GetPartitionKey(context.Connection.RemoteIpAddress), + _ => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = permitLimit, + QueueLimit = 0, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + Window = TimeSpan.FromMinutes(1), + }); + + private static string GetPartitionKey(IPAddress? address) + { + if (address is null) + { + return "unknown"; + } + + return IsLoopback(address) ? "loopback" : address.ToString(); + } + + private static bool IsLoopback(IPAddress address) => + IPAddress.IsLoopback(address) + || (address.IsIPv4MappedToIPv6 && IPAddress.IsLoopback(address.MapToIPv4())); +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebAuthRequestBodyLimitMiddleware.cs b/v2rayN/v2rayN.WebAPI/Security/WebAuthRequestBodyLimitMiddleware.cs new file mode 100644 index 00000000..0ca835b9 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebAuthRequestBodyLimitMiddleware.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http.Features; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Security; + +public sealed class WebAuthRequestBodyLimitMiddleware(RequestDelegate next) +{ + public const long MaximumRequestBodyBytes = 64 * 1024; + + public async Task InvokeAsync(HttpContext context) + { + if (!IsLimitedAuthRequest(context.Request)) + { + await next(context); + return; + } + + var requestBodySizeFeature = context.Features.Get(); + if (requestBodySizeFeature is { IsReadOnly: false }) + { + requestBodySizeFeature.MaxRequestBodySize = MaximumRequestBodyBytes; + } + + if (context.Request.ContentLength > MaximumRequestBodyBytes) + { + context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + await context.Response.WriteAsJsonAsync( + ApiEnvelope.Fail("payload_too_large", ApiMessageKeys.CommonInvalidInput), + context.RequestAborted); + return; + } + + await next(context); + } + + private static bool IsLimitedAuthRequest(HttpRequest request) => + HttpMethods.IsPost(request.Method) + && (request.Path == "/api/auth/login" || request.Path == "/api/setup"); +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebAuthService.cs b/v2rayN/v2rayN.WebAPI/Security/WebAuthService.cs new file mode 100644 index 00000000..e3e96ceb --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebAuthService.cs @@ -0,0 +1,241 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace v2rayN.WebAPI.Security; + +public sealed class WebAuthService +{ + private const int CurrentVersion = 1; + private const int Pbkdf2Iterations = 600_000; + private const int SaltLength = 16; + private const int VerifierLength = 32; + public const int MinimumKeyLength = 12; + public const int MaximumKeyLength = 4096; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly byte[] DummySalt = RandomNumberGenerator.GetBytes(SaltLength); + + private readonly string _configPath; + private readonly string? _environmentKey; + private readonly SemaphoreSlim _setupLock = new(1, 1); + private StoredWebAuth? _storedAuth; + + public WebAuthService(string configPath, string? environmentKey) + { + _configPath = Path.GetFullPath(configPath); + _environmentKey = string.IsNullOrWhiteSpace(environmentKey) ? null : environmentKey; + if (_environmentKey is null && File.Exists(_configPath)) + { + _storedAuth = LoadStoredAuth(_configPath); + } + } + + public bool EnvironmentKeyConfigured => _environmentKey is not null; + + public bool SetupRequired => _environmentKey is null && _storedAuth is null; + + public bool ValidateManagementKey(string? suppliedKey) + { + var key = suppliedKey ?? string.Empty; + + if (_environmentKey is not null) + { + DeriveAndDiscard(key, DummySalt, Pbkdf2Iterations); + if (string.IsNullOrEmpty(suppliedKey)) + { + return false; + } + return FixedTimeEquals(_environmentKey, suppliedKey); + } + + var stored = _storedAuth; + if (stored is null) + { + DeriveAndDiscard(key, DummySalt, Pbkdf2Iterations); + return false; + } + + var salt = Convert.FromBase64String(stored.Salt); + var expected = Convert.FromBase64String(stored.KeyVerifier); + var supplied = DeriveVerifier(key, salt, stored.Iterations); + return CryptographicOperations.FixedTimeEquals(expected, supplied); + } + + public async Task SetupAsync( + string? key, + string? confirmKey, + bool setupAccessAllowed, + CancellationToken cancellationToken = default) + { + if (!setupAccessAllowed) + { + return WebSetupResult.Forbidden; + } + + if (key is null || key.Length < MinimumKeyLength) + { + return WebSetupResult.KeyTooShort; + } + if (key.Length > MaximumKeyLength) + { + return WebSetupResult.KeyTooLong; + } + + if (!string.Equals(key, confirmKey, StringComparison.Ordinal)) + { + return WebSetupResult.KeysDoNotMatch; + } + + await _setupLock.WaitAsync(cancellationToken); + try + { + if (!SetupRequired) + { + return WebSetupResult.AlreadyConfigured; + } + + // Do not replace a config created by another process between startup and setup. + if (File.Exists(_configPath)) + { + _storedAuth = LoadStoredAuth(_configPath); + return WebSetupResult.AlreadyConfigured; + } + + var salt = RandomNumberGenerator.GetBytes(SaltLength); + var verifier = DeriveVerifier(key, salt, Pbkdf2Iterations); + var stored = new StoredWebAuth( + CurrentVersion, + "PBKDF2-HMAC-SHA256", + Pbkdf2Iterations, + Convert.ToBase64String(salt), + Convert.ToBase64String(verifier)); + await WriteAtomicallyAsync(stored, cancellationToken); + _storedAuth = stored; + return WebSetupResult.Created; + } + catch (IOException) when (File.Exists(_configPath)) + { + _storedAuth = LoadStoredAuth(_configPath); + return WebSetupResult.AlreadyConfigured; + } + finally + { + _setupLock.Release(); + } + } + + private static bool FixedTimeEquals(string expected, string actual) + { + var expectedBytes = Encoding.UTF8.GetBytes(expected); + var actualBytes = Encoding.UTF8.GetBytes(actual); + return expectedBytes.Length == actualBytes.Length + && CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); + } + + private static byte[] DeriveVerifier(string key, byte[] salt, int iterations) + { + var password = Encoding.UTF8.GetBytes(key); + try + { + return Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + iterations, + HashAlgorithmName.SHA256, + VerifierLength); + } + finally + { + CryptographicOperations.ZeroMemory(password); + } + } + + private static void DeriveAndDiscard(string key, byte[] salt, int iterations) + { + var derived = DeriveVerifier(key, salt, iterations); + CryptographicOperations.ZeroMemory(derived); + } + + private static StoredWebAuth LoadStoredAuth(string path) + { + try + { + var stored = JsonSerializer.Deserialize(File.ReadAllBytes(path), JsonOptions) + ?? throw new InvalidDataException("Web auth config is empty."); + var salt = Convert.FromBase64String(stored.Salt); + var verifier = Convert.FromBase64String(stored.KeyVerifier); + if (stored.Version != CurrentVersion + || stored.Kdf != "PBKDF2-HMAC-SHA256" + || stored.Iterations is < 100_000 or > 2_000_000 + || salt.Length != SaltLength + || verifier.Length != VerifierLength) + { + throw new InvalidDataException("Web auth config has unsupported or invalid parameters."); + } + + return stored; + } + catch (Exception exception) when (exception is JsonException or FormatException) + { + throw new InvalidDataException("Web auth config is malformed.", exception); + } + } + + private async Task WriteAtomicallyAsync(StoredWebAuth stored, CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(_configPath) + ?? throw new InvalidOperationException("Web auth config path has no parent directory."); + Directory.CreateDirectory(directory); + + var temporaryPath = Path.Combine(directory, $".WebAPI-auth-{Guid.NewGuid():N}.tmp"); + try + { + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.WriteThrough, + }; + if (OperatingSystem.IsLinux()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + + await using (var stream = new FileStream(temporaryPath, options)) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(stored, JsonOptions); + await stream.WriteAsync(bytes, cancellationToken); + await stream.FlushAsync(cancellationToken); + stream.Flush(flushToDisk: true); + } + + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(temporaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + File.Move(temporaryPath, _configPath, overwrite: false); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private sealed record StoredWebAuth(int Version, string Kdf, int Iterations, string Salt, string KeyVerifier); +} + +public enum WebSetupResult +{ + Created, + Forbidden, + KeyTooShort, + KeyTooLong, + KeysDoNotMatch, + AlreadyConfigured, +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebAuthStorage.cs b/v2rayN/v2rayN.WebAPI/Security/WebAuthStorage.cs new file mode 100644 index 00000000..ddbdf56d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebAuthStorage.cs @@ -0,0 +1,47 @@ +namespace v2rayN.WebAPI.Security; + +internal static class WebAuthStorage +{ + public static string MigrateAndGetPath(string startupPath, string legacyConfigPath) + { + var directory = Path.Combine(startupPath, "WebAPIData"); + Directory.CreateDirectory(directory); + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var authPath = Path.Combine(directory, "WebAPI-auth.json"); + // Pre-rename locations that can still hold the verifier on existing installs: + // the previous private location and the original guiConfigs location. + var previousPrivatePath = Path.Combine(startupPath, "webData", "web-auth.json"); + if (!File.Exists(authPath)) + { + if (File.Exists(previousPrivatePath)) + { + File.Move(previousPrivatePath, authPath); + } + else if (File.Exists(legacyConfigPath)) + { + File.Move(legacyConfigPath, authPath); + } + } + + // Stale pre-rename copies must not survive next to the current verifier. + if (File.Exists(previousPrivatePath)) + { + File.Delete(previousPrivatePath); + } + if (File.Exists(legacyConfigPath)) + { + File.Delete(legacyConfigPath); + } + + if (OperatingSystem.IsLinux() && File.Exists(authPath)) + { + File.SetUnixFileMode(authPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + return authPath; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebCorsPolicy.cs b/v2rayN/v2rayN.WebAPI/Security/WebCorsPolicy.cs new file mode 100644 index 00000000..b5a261ec --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebCorsPolicy.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Cors.Infrastructure; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Security; + +public sealed class WebCorsPolicy +{ + public const string EnvironmentVariable = "V2RAYN_WEB_ALLOWED_ORIGINS"; + public const string PolicyName = "WebApiOrigins"; + private readonly HashSet _origins; + + public WebCorsPolicy(string? configuration) + { + _origins = new HashSet(StringComparer.Ordinal); + if (string.IsNullOrWhiteSpace(configuration)) return; + foreach (var item in configuration.Split(',')) + { + _origins.Add(NormalizeOrigin(item.Trim())); + } + } + + public static string NormalizeOrigin(string value, string variable = EnvironmentVariable) + { + var authorityStart = value.IndexOf("://", StringComparison.Ordinal); + var slash = authorityStart < 0 ? -1 : value.IndexOf('/', authorityStart + 3); + if (string.IsNullOrWhiteSpace(value) || value.Any(char.IsWhiteSpace) + || authorityStart < 0 || (slash >= 0 && slash != value.Length - 1) + || value.IndexOfAny(['*', '\\', '?', '#']) >= 0 + || !Uri.TryCreate(value, UriKind.Absolute, out var uri) + || uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host) + || uri.UserInfo.Length != 0 || uri.AbsolutePath != "/") + { + // Do not echo configuration: an invalid value might contain credentials. + throw new ArgumentException($"{variable} must contain only comma-separated http(s) origins (scheme, host and port), without credentials, paths, queries, fragments or wildcards."); + } + return uri.GetComponents(UriComponents.SchemeAndServer, UriFormat.UriEscaped); + } + + public bool Allows(string origin) => _origins.Contains(origin); + + // A same-origin browser may reach HTTP Kestrel through a TLS/path-prefix + // reverse proxy. Its public origin need not equal Kestrel's internal URL. + // Fetch Metadata is browser-controlled (JS cannot forge Sec-Fetch-Site). + // This is NOT used to grant setup access or internal health diagnostics. + public static bool IsBrowserSameOriginRequest(HttpRequest request) + { + if (request.Headers["Sec-Fetch-Site"].Count != 1 + || request.Headers["Sec-Fetch-Site"] != "same-origin" + || request.Headers.Origin.Count != 1) return false; + try + { + var origin = request.Headers.Origin.ToString(); + return NormalizeOrigin(origin) == origin; + } + catch (ArgumentException) { return false; } + } + + public static bool IsSameOriginRequest(HttpRequest request) + { + if (!request.Headers.ContainsKey("Origin")) return true; // Native clients. + if (request.Headers.Origin.Count != 1) return false; + try + { + return string.Equals(request.Headers.Origin.ToString(), + NormalizeOrigin($"{request.Scheme}://{request.Host}"), StringComparison.Ordinal); + } + catch (ArgumentException) { return false; } + } + + public void Configure(CorsPolicyBuilder builder) => builder + .SetIsOriginAllowed(Allows) + .WithMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS") + .WithHeaders("Authorization", "Content-Type") + .WithExposedHeaders("Content-Disposition"); +} + +// CORS alone controls readability, not execution. Reject a disallowed Origin +// before authentication, rate limiting, SSE-ticket consumption or API mutations. +public sealed class WebOriginGuardMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, WebCorsPolicy cors) + { + if (context.Request.Path.StartsWithSegments("/api") + && !WebCorsPolicy.IsSameOriginRequest(context.Request) + && !WebCorsPolicy.IsBrowserSameOriginRequest(context.Request) + && (context.Request.Headers.Origin.Count != 1 || !cors.Allows(context.Request.Headers.Origin.ToString()))) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(ApiEnvelope.Fail("origin_not_allowed", ApiMessageKeys.CommonInvalidInput)); + return; + } + await next(context); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebDeploymentSecurityPolicy.cs b/v2rayN/v2rayN.WebAPI/Security/WebDeploymentSecurityPolicy.cs new file mode 100644 index 00000000..c6ac830d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebDeploymentSecurityPolicy.cs @@ -0,0 +1,12 @@ +namespace v2rayN.WebAPI.Security; + +public static class WebDeploymentSecurityPolicy +{ + public const string MissingManagementKeyMessage = + "Management Key is required in supervised/container deployments. Set V2RAYN_WEB_API_KEY before starting v2rayN.WebAPI."; + + public static string? GetStartupError(bool supervisedEnvironment, bool containerEnvironment, string? managementKey) => + (supervisedEnvironment || containerEnvironment) && string.IsNullOrWhiteSpace(managementKey) + ? MissingManagementKeyMessage + : null; +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebListenerSecurityPolicy.cs b/v2rayN/v2rayN.WebAPI/Security/WebListenerSecurityPolicy.cs new file mode 100644 index 00000000..b47d7d58 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebListenerSecurityPolicy.cs @@ -0,0 +1,67 @@ +using System.Net; +using Microsoft.AspNetCore.Hosting; + +namespace v2rayN.WebAPI.Security; + +/// Checks server binding configuration, never request Host/forwarding headers. +public static class WebListenerSecurityPolicy +{ + public const string DefaultUrl = "http://127.0.0.1:5080"; + public const string MissingManagementKeyMessage = + "Management Key is required for non-loopback Web listeners. Set V2RAYN_WEB_API_KEY before starting v2rayN.WebAPI, or bind only to 127.0.0.1, localhost, or [::1] for local first-run setup."; + + public static void ApplyDefault(WebApplicationBuilder builder) + { + if (string.IsNullOrWhiteSpace(builder.Configuration[WebHostDefaults.ServerUrlsKey]) + && string.IsNullOrWhiteSpace(builder.Configuration["http_ports"]) + && string.IsNullOrWhiteSpace(builder.Configuration["https_ports"])) + { + builder.WebHost.UseUrls(DefaultUrl); + } + } + + public static string? GetStartupError(IConfiguration configuration, string? managementKey) => + string.IsNullOrWhiteSpace(managementKey) && GetEffectiveUrls(configuration).Any(url => !IsLoopbackUrl(url)) + ? MissingManagementKeyMessage + : null; + + public static IReadOnlyList GetEffectiveUrls(IConfiguration configuration) + { + var urls = Split(configuration[WebHostDefaults.ServerUrlsKey]); + if (urls.Length == 0) + { + // ASP.NET Core expands port-only bindings to wildcard addresses, not loopback. + urls = Split(configuration["http_ports"]).Select(port => $"http://*:{port}") + .Concat(Split(configuration["https_ports"]).Select(port => $"https://*:{port}")) + .ToArray(); + } + + var endpoints = configuration.GetSection("Kestrel:Endpoints").GetChildren() + .Select(endpoint => endpoint["Url"] ?? string.Empty).ToArray(); + // Kestrel endpoints override hosting URLs unless PreferHostingUrls is enabled. + if (endpoints.Length > 0 + && !(bool.TryParse(configuration[WebHostDefaults.PreferHostingUrlsKey], out var preferUrls) + && preferUrls && urls.Length > 0)) + { + return endpoints; + } + + return urls.Length > 0 ? urls : [DefaultUrl]; + } + + public static bool IsLoopbackUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return false; + } + + var host = uri.Host.Trim('[', ']'); + return host.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || (IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address)); + } + + private static string[] Split(string? value) => + (value ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebSecurityHeadersMiddleware.cs b/v2rayN/v2rayN.WebAPI/Security/WebSecurityHeadersMiddleware.cs new file mode 100644 index 00000000..8b8c452f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebSecurityHeadersMiddleware.cs @@ -0,0 +1,17 @@ +namespace v2rayN.WebAPI.Security; + +public sealed class WebSecurityHeadersMiddleware(RequestDelegate next) +{ + public const string ContentSecurityPolicy = + "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; " + + "script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; connect-src 'self'"; + + public async Task InvokeAsync(HttpContext context) + { + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + context.Response.Headers["Content-Security-Policy"] = ContentSecurityPolicy; + await next(context); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebSessionAuthenticationMiddleware.cs b/v2rayN/v2rayN.WebAPI/Security/WebSessionAuthenticationMiddleware.cs new file mode 100644 index 00000000..87d71f75 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebSessionAuthenticationMiddleware.cs @@ -0,0 +1,49 @@ +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Security; + +public sealed class WebSessionAuthenticationMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, WebSessionService sessions) + { + var path = context.Request.Path; + if (path == "/api/events") + { + if (!sessions.TryConsumeSseTicket(context.Request.Query["sse_ticket"].ToString(), out var eventSession)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync( + ApiEnvelope.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized)); + return; + } + + context.Items[WebSessionService.SessionSnapshotContextKey] = eventSession!; + await next(context); + return; + } + + if (!path.StartsWithSegments("/api") + || path == "/api/health" + || path == "/api/setup/status" + || path == "/api/setup" + || path == "/api/auth/login") + { + await next(context); + return; + } + + var presentedToken = WebSessionService.ExtractPresentedToken(context); + var authenticated = path == "/api/auth/sse-ticket" + ? sessions.TryValidateWithoutRenewal(presentedToken, out _) + : sessions.TryValidateAndRenew(presentedToken, out _); + if (!authenticated) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync( + ApiEnvelope.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized)); + return; + } + + await next(context); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebSessionService.cs b/v2rayN/v2rayN.WebAPI/Security/WebSessionService.cs new file mode 100644 index 00000000..40fe2f0d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebSessionService.cs @@ -0,0 +1,387 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Http; + +namespace v2rayN.WebAPI.Security; + +public sealed class WebSessionService : IDisposable +{ + public const string SessionSnapshotContextKey = "v2rayn.web.session.snapshot"; + public static readonly TimeSpan SlidingLifetime = TimeSpan.FromDays(7); + public static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(30); + public static readonly TimeSpan SseTicketLifetime = TimeSpan.FromSeconds(45); + private static readonly TimeSpan CleanupInterval = TimeSpan.FromMinutes(15); + private static readonly TimeSpan SseTicketCleanupInterval = TimeSpan.FromMinutes(1); + private const int TokenByteLength = 32; + private const int TokenEncodedLength = 43; + + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _sseTickets = new(StringComparer.Ordinal); + private readonly TimeProvider _timeProvider; + private readonly Timer _cleanupTimer; + private readonly Timer _sseTicketCleanupTimer; + private bool _disposed; + + public WebSessionService(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _cleanupTimer = new Timer( + static state => ((WebSessionService)state!).CleanupExpiredSessions(), + this, + CleanupInterval, + CleanupInterval); + _sseTicketCleanupTimer = new Timer( + static state => ((WebSessionService)state!).CleanupExpiredSseTickets(), + this, + SseTicketCleanupInterval, + SseTicketCleanupInterval); + } + + public WebSessionToken CreateSession() + { + ObjectDisposedException.ThrowIf(_disposed, this); + while (true) + { + var tokenBytes = RandomNumberGenerator.GetBytes(TokenByteLength); + var token = EncodeBase64Url(tokenBytes); + var digest = GetDigest(tokenBytes); + CryptographicOperations.ZeroMemory(tokenBytes); + + var now = _timeProvider.GetUtcNow(); + var absoluteExpiresAt = now + AbsoluteLifetime; + var entry = new SessionEntry( + now, + now, + Min(now + SlidingLifetime, absoluteExpiresAt), + absoluteExpiresAt, + new CancellationTokenSource()); + if (_sessions.TryAdd(digest, entry)) + { + return new WebSessionToken(token, entry.ExpiresAt); + } + entry.Revoked.Dispose(); + } + } + + public bool TryValidateAndRenew(string? token, out WebSessionSnapshot? session) => + TryValidateSessionCore(token, renew: true, out session); + + public bool TryValidateWithoutRenewal(string? token, out WebSessionSnapshot? session) => + TryValidateSessionCore(token, renew: false, out session); + + private bool TryValidateSessionCore(string? token, bool renew, out WebSessionSnapshot? session) + { + session = null; + if (_disposed || !TryGetDigest(token, out var digest)) + { + return false; + } + + while (_sessions.TryGetValue(digest, out var current)) + { + var now = _timeProvider.GetUtcNow(); + if (now >= current.ExpiresAt || now >= current.AbsoluteExpiresAt) + { + if (TryRemove(digest, current)) + { + return false; + } + continue; + } + + if (!renew) + { + session = new WebSessionSnapshot( + current.CreatedAt, + current.LastSeenAt, + current.ExpiresAt, + current.AbsoluteExpiresAt, + current.Revoked.Token, + digest); + return true; + } + + var renewed = current with + { + LastSeenAt = now, + ExpiresAt = Min(now + SlidingLifetime, current.AbsoluteExpiresAt), + }; + if (_sessions.TryUpdate(digest, renewed, current)) + { + session = new WebSessionSnapshot( + renewed.CreatedAt, + renewed.LastSeenAt, + renewed.ExpiresAt, + renewed.AbsoluteExpiresAt, + renewed.Revoked.Token, + digest); + return true; + } + } + + return false; + } + + private bool TryValidateSessionDigestWithoutRenewal(string digest, out WebSessionSnapshot? session) + { + session = null; + if (_disposed) + { + return false; + } + + while (_sessions.TryGetValue(digest, out var current)) + { + var now = _timeProvider.GetUtcNow(); + if (now >= current.ExpiresAt || now >= current.AbsoluteExpiresAt) + { + if (TryRemove(digest, current)) + { + return false; + } + continue; + } + + session = new WebSessionSnapshot( + current.CreatedAt, + current.LastSeenAt, + current.ExpiresAt, + current.AbsoluteExpiresAt, + current.Revoked.Token, + digest); + return true; + } + + return false; + } + + internal bool TryValidateSseSessionWithoutRenewal(string sessionDigest, out WebSessionSnapshot? session) => + TryValidateSessionDigestWithoutRenewal(sessionDigest, out session); + + public bool Revoke(string? token) + { + if (_disposed || !TryGetDigest(token, out var digest)) + { + return false; + } + + while (_sessions.TryGetValue(digest, out var entry)) + { + if (TryRemove(digest, entry)) + { + return true; + } + } + return false; + } + + public bool TryCreateSseTicket(string? sessionToken, out WebSseTicket? ticket) + { + ticket = null; + if (_disposed || !TryGetDigest(sessionToken, out var sessionDigest) + || !TryValidateSessionDigestWithoutRenewal(sessionDigest, out _)) + { + return false; + } + + CleanupExpiredSseTickets(); + while (true) + { + var ticketBytes = RandomNumberGenerator.GetBytes(TokenByteLength); + var ticketToken = EncodeBase64Url(ticketBytes); + var ticketDigest = GetDigest(ticketBytes); + CryptographicOperations.ZeroMemory(ticketBytes); + + var expiresAt = _timeProvider.GetUtcNow() + SseTicketLifetime; + if (_sseTickets.TryAdd(ticketDigest, new SseTicketEntry(sessionDigest, expiresAt))) + { + // Close the race with logout/expiry while the ticket was being minted. + if (!TryValidateSessionDigestWithoutRenewal(sessionDigest, out _)) + { + _sseTickets.TryRemove(ticketDigest, out _); + return false; + } + + ticket = new WebSseTicket(ticketToken, expiresAt); + return true; + } + } + } + + public bool TryConsumeSseTicket(string? ticketToken, out WebSessionSnapshot? session) + { + session = null; + if (_disposed || !TryGetDigest(ticketToken, out var ticketDigest) + || !_sseTickets.TryRemove(ticketDigest, out var ticket)) + { + return false; + } + + if (_timeProvider.GetUtcNow() >= ticket.ExpiresAt) + { + return false; + } + + return TryValidateSessionDigestWithoutRenewal(ticket.SessionDigest, out session); + } + + public int CleanupExpiredSessions() + { + if (_disposed) + { + return 0; + } + + var now = _timeProvider.GetUtcNow(); + var removed = 0; + foreach (var (digest, entry) in _sessions) + { + if ((now >= entry.ExpiresAt || now >= entry.AbsoluteExpiresAt) + && TryRemove(digest, entry)) + { + removed++; + } + } + return removed; + } + + private void CleanupExpiredSseTickets() + { + if (_disposed) + { + return; + } + + var now = _timeProvider.GetUtcNow(); + foreach (var (digest, ticket) in _sseTickets) + { + if (now >= ticket.ExpiresAt) + { + _sseTickets.TryRemove(digest, out _); + } + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _cleanupTimer.Dispose(); + _sseTicketCleanupTimer.Dispose(); + _sseTickets.Clear(); + foreach (var entry in _sessions.Values) + { + Cancel(entry.Revoked); + } + _sessions.Clear(); + } + + public static string ExtractPresentedToken(HttpContext context) + { + var authorization = context.Request.Headers.Authorization.ToString(); + if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return authorization[7..].Trim(); + } + + return string.Empty; + } + + private bool TryRemove(string digest, SessionEntry entry) + { + var removed = ((ICollection>)_sessions) + .Remove(new KeyValuePair(digest, entry)); + if (removed) + { + Cancel(entry.Revoked); + RemoveTicketsForSession(digest); + } + return removed; + } + + private void RemoveTicketsForSession(string sessionDigest) + { + foreach (var (ticketDigest, ticket) in _sseTickets) + { + if (ticket.SessionDigest == sessionDigest) + { + _sseTickets.TryRemove(ticketDigest, out _); + } + } + } + + private static void Cancel(CancellationTokenSource source) + { + try + { + source.Cancel(); + } + catch (ObjectDisposedException) + { + } + } + + private static bool TryGetDigest(string? token, out string digest) + { + digest = string.Empty; + if (token is null || token.Length != TokenEncodedLength + || token.Any(character => !(character is >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or >= '0' and <= '9' + or '-' or '_'))) + { + return false; + } + + try + { + var base64 = token.Replace('-', '+').Replace('_', '/') + "="; + var tokenBytes = Convert.FromBase64String(base64); + if (tokenBytes.Length != TokenByteLength || EncodeBase64Url(tokenBytes) != token) + { + CryptographicOperations.ZeroMemory(tokenBytes); + return false; + } + + digest = GetDigest(tokenBytes); + CryptographicOperations.ZeroMemory(tokenBytes); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static string EncodeBase64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static string GetDigest(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)); + + private static DateTimeOffset Min(DateTimeOffset first, DateTimeOffset second) => first <= second ? first : second; + + private sealed record SessionEntry( + DateTimeOffset CreatedAt, + DateTimeOffset LastSeenAt, + DateTimeOffset ExpiresAt, + DateTimeOffset AbsoluteExpiresAt, + CancellationTokenSource Revoked); + + private sealed record SseTicketEntry(string SessionDigest, DateTimeOffset ExpiresAt); +} + +public sealed record WebSessionToken(string Token, DateTimeOffset ExpiresAt); + +public sealed record WebSseTicket(string Token, DateTimeOffset ExpiresAt); + +public sealed record WebSessionSnapshot( + DateTimeOffset CreatedAt, + DateTimeOffset LastSeenAt, + DateTimeOffset ExpiresAt, + DateTimeOffset AbsoluteExpiresAt, + CancellationToken RevocationToken, + string SessionDigest); diff --git a/v2rayN/v2rayN.WebAPI/Security/WebSetupAccessPolicy.cs b/v2rayN/v2rayN.WebAPI/Security/WebSetupAccessPolicy.cs new file mode 100644 index 00000000..c51e96b2 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebSetupAccessPolicy.cs @@ -0,0 +1,70 @@ +using System.Net; +using Microsoft.AspNetCore.Http; + +namespace v2rayN.WebAPI.Security; + +public static class WebSetupAccessPolicy +{ + public static bool IsAllowed( + IPAddress? remoteAddress, + string? host, + bool forwardedHeadersPresent) => + !forwardedHeadersPresent + && IsLoopbackAddress(remoteAddress) && IsLocalHost(host); + + public static bool IsAllowedFromRequest(HttpContext context) => + WebCorsPolicy.IsSameOriginRequest(context.Request) + && IsAllowed(context.Connection.RemoteIpAddress, context.Request.Host.Host, + HasForwardedHeaders(context.Request.Headers)); + + /// + /// The loopback peer + local Host pair required for per-instance health diagnostics. + /// The update/launcher probe rules (see WebProbeUriResolver) target exactly this pair. + /// + public static bool IsLocalDiagnosticSource(IPAddress? remoteAddress, string? host) => + IsLoopbackAddress(remoteAddress) && IsLocalHost(host); + + public static bool IsLoopbackAddress(IPAddress? remoteAddress) + { + if (remoteAddress is null) + { + return false; + } + + if (remoteAddress.Equals(IPAddress.Loopback) || remoteAddress.Equals(IPAddress.IPv6Loopback)) + { + return true; + } + + return remoteAddress.IsIPv4MappedToIPv6 + && remoteAddress.MapToIPv4().Equals(IPAddress.Loopback); + } + + public static bool IsLocalHost(string? host) + { + if (string.IsNullOrWhiteSpace(host)) + { + return false; + } + + var normalized = host.Trim(); + if (normalized.Length > 1 && normalized[0] == '[' && normalized[^1] == ']') + { + normalized = normalized[1..^1]; + } + + if (string.Equals(normalized, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return IPAddress.TryParse(normalized, out var address) + && (address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback)); + } + + public static bool HasForwardedHeaders(IHeaderDictionary headers) => + headers.Keys.Any(key => + key.Equals("Forwarded", StringComparison.OrdinalIgnoreCase) + || key.Equals("X-Real-IP", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("X-Forwarded-", StringComparison.OrdinalIgnoreCase)); +} diff --git a/v2rayN/v2rayN.WebAPI/ServiceLibBin/.keep b/v2rayN/v2rayN.WebAPI/ServiceLibBin/.keep new file mode 100644 index 00000000..d0b8ced7 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/ServiceLibBin/.keep @@ -0,0 +1,2 @@ +This directory is copied to the Web application's output as `bin/`. +ServiceLib's CoreManager copies bundled core files from that directory into its writable data path. diff --git a/v2rayN/v2rayN.WebAPI/Services/BackupDatabaseCompatibility.cs b/v2rayN/v2rayN.WebAPI/Services/BackupDatabaseCompatibility.cs new file mode 100644 index 00000000..1f240942 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/BackupDatabaseCompatibility.cs @@ -0,0 +1,81 @@ +using SQLite; + +namespace v2rayN.WebAPI.Services; + +internal static class BackupDatabaseCompatibility +{ + private static readonly IReadOnlyDictionary RequiredPrimaryKeys = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["DNSItem"] = "Id", + ["FullConfigTemplateItem"] = "Id", + ["ProfileExItem"] = "IndexId", + ["ProfileGroupItem"] = "IndexId", + ["ProfileItem"] = "IndexId", + ["RoutingItem"] = "Id", + ["ServerStatItem"] = "IndexId", + ["SubItem"] = "Id", + }; + + public static bool IsCompatible(string databasePath, out string error) + { + try + { + using var database = new SQLiteConnection(databasePath, SQLiteOpenFlags.ReadOnly, false); + if (!string.Equals(database.ExecuteScalar("PRAGMA quick_check;"), "ok", StringComparison.OrdinalIgnoreCase)) + { + error = "SQLite quick_check did not return ok."; + return false; + } + + var tableNames = database.Query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';") + .Select(item => item.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!tableNames.Any(name => RequiredPrimaryKeys.ContainsKey(name))) + { + error = "The database does not contain any known v2rayN data tables."; + return false; + } + + foreach (var (tableName, primaryKey) in RequiredPrimaryKeys) + { + // Older backups may not yet have every optional ServiceLib table; AppManager + // creates missing tables during initialization. Existing tables must retain + // their stable primary key so that SQLite-net can add newer columns safely. + if (!tableNames.Contains(tableName)) + { + continue; + } + + var columns = database.Query($"PRAGMA table_info(\"{tableName}\");"); + if (!columns.Any(column => string.Equals(column.Name, primaryKey, StringComparison.OrdinalIgnoreCase) + && column.PrimaryKey == 1)) + { + error = $"Table {tableName} is missing its expected primary key {primaryKey}."; + return false; + } + } + + error = string.Empty; + return true; + } + catch (Exception exception) + { + error = exception.Message; + return false; + } + } + + private sealed class TableName + { + public string Name { get; set; } = string.Empty; + } + + private sealed class TableColumn + { + public string Name { get; set; } = string.Empty; + + [Column("pk")] + public int PrimaryKey { get; set; } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/BackupRestorePreflightGate.cs b/v2rayN/v2rayN.WebAPI/Services/BackupRestorePreflightGate.cs new file mode 100644 index 00000000..d7f8be81 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/BackupRestorePreflightGate.cs @@ -0,0 +1,24 @@ +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Services; + +internal static class BackupRestorePreflightGate +{ + public static Task ExecuteAsync( + NativeWebRestorePreflightResult preflight, + Func> performRestore) + { + if (!preflight.Allowed) + { + return Task.FromResult(preflight.ToFailureOperation()); + } + + if (preflight.Owner == WebLifecycleOwner.NativeProcess && preflight.Replacement is null) + { + throw new InvalidOperationException("A native restore cannot proceed without a verified restart plan."); + } + + return performRestore(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/BuiltinDnsProfileBootstrap.cs b/v2rayN/v2rayN.WebAPI/Services/BuiltinDnsProfileBootstrap.cs new file mode 100644 index 00000000..13e5e2be --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/BuiltinDnsProfileBootstrap.cs @@ -0,0 +1,43 @@ +using ServiceLib; +using ServiceLib.Enums; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Entities; + +namespace v2rayN.WebAPI.Services; + +internal static class BuiltinDnsProfileBootstrap +{ + private static readonly (ECoreType CoreType, string Remarks)[] BuiltinProfiles = + [ + (ECoreType.Xray, "V2ray"), + (ECoreType.sing_box, "sing-box"), + ]; + + public static async Task EnsureAsync( + Config config, + Func>> load, + Func> save) + { + var existing = await load(); + foreach (var (coreType, remarks) in BuiltinProfiles) + { + if (existing.Any(item => item.CoreType == coreType)) + { + continue; + } + + var item = new DNSItem + { + Remarks = remarks, + CoreType = coreType, + Enabled = false, + }; + if (await save(config, item) != 0) + { + throw new IOException($"The built-in {coreType} DNS profile could not be created."); + } + + existing = [.. existing, item]; + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreExecutableVersionCheck.cs b/v2rayN/v2rayN.WebAPI/Services/CoreExecutableVersionCheck.cs new file mode 100644 index 00000000..7c0c73fc --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreExecutableVersionCheck.cs @@ -0,0 +1,15 @@ +using System.Text.RegularExpressions; + +namespace v2rayN.WebAPI.Services; + +internal static class CoreExecutableVersionCheck +{ + private static readonly Regex VersionPattern = new( + @"\b[vV]?\d+\.\d+\.\d+\b", + RegexOptions.CultureInvariant); + + public static bool IsValid(string? versionOutput, string expectedName) => + !string.IsNullOrWhiteSpace(versionOutput) + && versionOutput.Contains(expectedName, StringComparison.OrdinalIgnoreCase) + && VersionPattern.IsMatch(versionOutput); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreLaunchPreflight.cs b/v2rayN/v2rayN.WebAPI/Services/CoreLaunchPreflight.cs new file mode 100644 index 00000000..1c497308 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreLaunchPreflight.cs @@ -0,0 +1,93 @@ +using ServiceLib.Enums; +using ServiceLib.Handler.Builder; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +internal sealed record CoreLaunchPreflightResult( + ProfileItem Profile, + CoreConfigContextBuilderAllResult BuiltContext, + OperationView? Failure) +{ + public bool Success => Failure is null; +} + +internal static class CoreLaunchPreflight +{ + public static async Task BuildAndValidateAsync( + Config config, + ProfileItem profile, + Func validateCoreBinary) + { + var built = await CoreConfigContextBuilder.BuildAll(config, profile); + return Validate(profile, built, validateCoreBinary); + } + + internal static CoreLaunchPreflightResult Validate( + ProfileItem profile, + CoreConfigContextBuilderAllResult built, + Func validateCoreBinary) + { + var tunRejection = GetTunLaunchRejection(built); + if (tunRejection is not null) + { + return new CoreLaunchPreflightResult(profile, built, tunRejection); + } + + if (!built.Success) + { + return new CoreLaunchPreflightResult(profile, built, + OperationView.Fail("profile_validation_failed", ApiMessageKeys.ProfileInvalid)); + } + + var requiredCoreTypes = new[] + { + (ECoreType?)built.MainResult.Context.RunCoreType, + built.PreSocksResult?.Context.RunCoreType, + } + .Where(coreType => coreType.HasValue) + .Select(coreType => coreType!.Value) + .Distinct(); + + foreach (var coreType in requiredCoreTypes) + { + var failure = validateCoreBinary(coreType); + if (failure is not null) + { + return new CoreLaunchPreflightResult(profile, built, failure); + } + } + + return new CoreLaunchPreflightResult(profile, built, null); + } + + internal static OperationView? GetTunLaunchRejection(CoreConfigContextBuilderAllResult built) => + built.MainResult.Context.IsTunEnabled || built.PreSocksResult?.Context.IsTunEnabled == true + ? OperationView.Fail("tun_not_supported", ApiMessageKeys.CoreTunNotSupported) + : null; +} + +internal static class CoreRestartFlow +{ + public static async Task ExecuteAsync( + Func> buildAndValidate, + Func stopCore, + Action markCoreStopped, + Func> launch) + { + var preflight = await buildAndValidate(); + if (preflight.Failure is { } failure) + { + return failure; + } + + await stopCore(); + markCoreStopped(); + var startResult = await launch(preflight); + return startResult.Success + ? OperationView.Ok(ApiMessageKeys.CoreRestarted, startResult.Data) + : startResult; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreProcessTracker.cs b/v2rayN/v2rayN.WebAPI/Services/CoreProcessTracker.cs new file mode 100644 index 00000000..7a0a07d4 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreProcessTracker.cs @@ -0,0 +1,315 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace v2rayN.WebAPI.Services; + +internal sealed record CoreProcessIdentity(int ProcessId, string ProcessName, long StartTimeUtcTicks, string ExecutablePath); + +/// +/// Tracks Core processes started by this Web host without depending on ServiceLib internals. +/// CoreManager remains responsible for launching/stopping; Web snapshots process identities +/// around its public LoadCore call and validates those identities during runtime transitions. +/// +internal static class CoreProcessTracker +{ + public static HashSet CaptureExistingProcessIds() + { + var ids = new HashSet(); + foreach (var process in Process.GetProcesses()) + { + using (process) + { + try { ids.Add(process.Id); } + catch (InvalidOperationException) { } + } + } + return ids; + } + + public static CoreProcessIdentity[] CaptureStartedProcesses( + IReadOnlySet existingProcessIds, + IEnumerable expectedExecutablePaths, + IEnumerable? expectedConfigPaths = null) + { + var expected = expectedExecutablePaths + .Select(NormalizeExecutablePath) + .Where(path => path is not null) + .Select(path => path!) + .ToHashSet(PathComparer); + var expectedConfigs = expectedConfigPaths? + .Select(NormalizeAbsolutePath) + .Where(path => path is not null) + .Select(path => path!) + .ToHashSet(PathComparer); + if (expected.Count == 0) return []; + + var processes = new List(); + foreach (var process in Process.GetProcesses()) + { + using (process) + { + try + { + if (existingProcessIds.Contains(process.Id) || process.HasExited) continue; + var startTimeTicks = GetStartTimeUtcTicks(process); + var executablePath = GetProcessExecutablePath(process); + if (startTimeTicks is null || executablePath is null + || !IsExpectedCoreProcess(process.Id, executablePath, expected, expectedConfigs)) + { + continue; + } + + processes.Add(new CoreProcessIdentity( + process.Id, + NormalizeProcessName(process.ProcessName), + startTimeTicks.Value, + executablePath)); + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception or NotSupportedException) + { + // A process may exit or become inaccessible while the process table is sampled. + } + } + } + + return processes + .DistinctBy(process => process.ProcessId) + .OrderBy(process => process.ProcessId) + .ToArray(); + } + + public static CoreProcessIdentity[] GetActiveProcesses(IEnumerable identities) + { + var active = new List(); + foreach (var identity in identities.DistinctBy(process => process.ProcessId)) + { + try + { + using var process = Process.GetProcessById(identity.ProcessId); + if (process.HasExited + || !string.Equals(NormalizeProcessName(process.ProcessName), identity.ProcessName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var startTimeTicks = GetStartTimeUtcTicks(process); + if (startTimeTicks is long observedStartTime && observedStartTime != identity.StartTimeUtcTicks) continue; + var executablePath = GetProcessExecutablePath(process); + if (executablePath is not null && !PathComparer.Equals(executablePath, identity.ExecutablePath)) continue; + + // If an otherwise matching live PID becomes inaccessible, keep it as an + // uncertain active identity. This may report a conservative stop failure, + // but it cannot mistake the process for stopped or authorize killing a PID. + active.Add(identity); + } + catch (ArgumentException) + { + // The PID no longer exists. + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception or NotSupportedException or UnauthorizedAccessException) + { + // An inaccessible PID is uncertain, so report it as active rather than + // claiming a clean stop. No process is ever terminated from this tracker. + active.Add(identity); + } + } + return active.OrderBy(process => process.ProcessId).ToArray(); + } + + private static string NormalizeProcessName(string name) => + Path.GetFileNameWithoutExtension(name.Trim()); + + private static bool IsExpectedCoreProcess( + int processId, + string executablePath, + HashSet expectedPaths, + HashSet? expectedConfigPaths) + { + if (!OperatingSystem.IsLinux()) return expectedPaths.Contains(executablePath); + + try + { + var arguments = File.ReadAllText($"/proc/{processId}/cmdline") + .Split('\0', StringSplitOptions.RemoveEmptyEntries); + var executableMatches = expectedPaths.Contains(executablePath); + foreach (var expectedPath in expectedPaths) + { + if (arguments.Any(argument => PathComparer.Equals(NormalizeExecutablePath(argument), expectedPath)) + && GetShebangInterpreters(expectedPath).Contains(executablePath, PathComparer)) + { + executableMatches = true; + break; + } + } + + return executableMatches && (expectedConfigPaths is null + || expectedConfigPaths.Count == 0 + || CommandLineReferencesConfig(processId, arguments, expectedConfigPaths)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) + { + // Process metadata can disappear between enumeration and inspection. + } + + return false; + } + + private static bool CommandLineReferencesConfig(int processId, IEnumerable arguments, HashSet expectedConfigPaths) + { + var workingDirectory = GetProcessWorkingDirectory(processId); + if (workingDirectory is null) return false; + + foreach (var argument in arguments) + { + var configArgument = argument.StartsWith("--config=", StringComparison.Ordinal) + ? argument["--config=".Length..] + : argument.StartsWith("--config-file=", StringComparison.Ordinal) + ? argument["--config-file=".Length..] + : argument; + if (configArgument.Length == 0 || configArgument.StartsWith("-", StringComparison.Ordinal)) continue; + var fullPath = NormalizeAbsolutePath(Path.IsPathRooted(configArgument) + ? configArgument + : Path.Combine(workingDirectory, configArgument)); + if (fullPath is not null && expectedConfigPaths.Contains(fullPath)) return true; + } + + return false; + } + + private static string? GetProcessWorkingDirectory(int processId) + { + try + { + return new DirectoryInfo($"/proc/{processId}/cwd").ResolveLinkTarget(returnFinalTarget: false)?.FullName; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return null; + } + } + + private static long? GetStartTimeUtcTicks(Process process) + { + try { return process.StartTime.ToUniversalTime().Ticks; } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception or NotSupportedException or UnauthorizedAccessException) + { + return null; + } + } + + private static string? GetProcessExecutablePath(Process process) + { + try + { + var path = OperatingSystem.IsLinux() + ? new FileInfo($"/proc/{process.Id}/exe").ResolveLinkTarget(returnFinalTarget: false)?.FullName + : process.MainModule?.FileName; + return path is null ? null : NormalizeExecutablePath(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException or Win32Exception or NotSupportedException or ArgumentException) + { + return null; + } + } + + private static string? NormalizeExecutablePath(string? path) + { + var fullPath = NormalizeAbsolutePath(path); + if (fullPath is null) return null; + try + { + var executablePath = fullPath; + if (OperatingSystem.IsLinux() && executablePath.EndsWith(" (deleted)", StringComparison.Ordinal)) + { + executablePath = executablePath[..^" (deleted)".Length]; + } + + if (!OperatingSystem.IsLinux()) + { + var target = new FileInfo(executablePath).ResolveLinkTarget(returnFinalTarget: true); + return Path.GetFullPath(target?.FullName ?? executablePath); + } + + var root = Path.GetPathRoot(executablePath)!; + var current = root; + foreach (var segment in executablePath[root.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); + var linkTarget = info.ResolveLinkTarget(returnFinalTarget: false); + if (linkTarget is not null) current = linkTarget.FullName; + } + return Path.GetFullPath(current); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return null; + } + } + + private static string? NormalizeAbsolutePath(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathRooted(path)) return null; + try { return Path.GetFullPath(path.TrimEnd('\0')); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return null; + } + } + + private static HashSet GetShebangInterpreters(string executablePath) + { + var interpreters = new HashSet(PathComparer); + try + { + using var stream = File.OpenRead(executablePath); + var prefix = new byte[256]; + var length = stream.Read(prefix); + var lineEnd = Array.IndexOf(prefix, (byte)'\n', 0, length); + if (lineEnd < 0) lineEnd = length; + var firstLine = System.Text.Encoding.UTF8.GetString(prefix, 0, lineEnd).TrimEnd('\r'); + if (!firstLine.StartsWith("#!", StringComparison.Ordinal)) return interpreters; + + var tokens = firstLine[2..].Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) return interpreters; + var interpreter = tokens[0]; + if (string.Equals(Path.GetFileName(interpreter), "env", StringComparison.Ordinal)) + { + var index = 1; + while (index < tokens.Length && tokens[index].StartsWith("-", StringComparison.Ordinal)) + { + if (tokens[index] is "-u" or "--unset" or "-C" or "--chdir" or "-a" or "--argv0") index++; + index++; + } + if (index >= tokens.Length) return interpreters; + interpreter = FindOnPath(tokens[index]) ?? string.Empty; + } + + var normalized = NormalizeExecutablePath(interpreter); + if (normalized is not null) interpreters.Add(normalized); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + // A script that cannot be inspected is not claimed based on its process name alone. + } + + return interpreters; + } + + private static string? FindOnPath(string executableName) + { + if (Path.IsPathRooted(executableName)) return executableName; + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(path)) return null; + foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var candidate = Path.Combine(directory, executableName); + if (File.Exists(candidate)) return candidate; + } + return null; + } + + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreRuntimeSnapshot.cs b/v2rayN/v2rayN.WebAPI/Services/CoreRuntimeSnapshot.cs new file mode 100644 index 00000000..259399ee --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreRuntimeSnapshot.cs @@ -0,0 +1,46 @@ +using ServiceLib.Enums; + +namespace v2rayN.WebAPI.Services; + +internal enum CoreRuntimeState +{ + Stopped, + Starting, + Running, + Stopping, + Restarting, + Faulted, +} + +internal sealed record RuntimeListenerSnapshot( + string Name, + string[] Protocols, + string ListenAddress, + int Port); + +/// +/// Immutable description of the Core process that was actually launched. Config changes +/// never mutate this snapshot; only confirmed runtime transitions replace it. +/// +internal sealed record CoreRuntimeSnapshot( + CoreRuntimeState State, + string? ProfileId, + ECoreType? CoreType, + DateTimeOffset? StartedAt, + int? ProxyPort, + int? ApiPort, + RuntimeListenerSnapshot[] Listeners, + int[] ProcessIds, + string? LastFailure) +{ + public static CoreRuntimeSnapshot Stopped { get; } = new( + CoreRuntimeState.Stopped, + null, + null, + null, + null, + null, + [], + [], + null); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreSettingsApplyPolicy.cs b/v2rayN/v2rayN.WebAPI/Services/CoreSettingsApplyPolicy.cs new file mode 100644 index 00000000..8c71ac39 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreSettingsApplyPolicy.cs @@ -0,0 +1,43 @@ +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +internal enum CoreSettingsApplyAction +{ + SaveOnly, + Restart, + Busy, + Inconsistent, +} + +internal static class CoreSettingsApplyPolicy +{ + public static CoreSettingsApplyAction Decide(CoreRuntimeState state, bool hasActiveChild, bool changed) => state switch + { + CoreRuntimeState.Stopped when !hasActiveChild => CoreSettingsApplyAction.SaveOnly, + CoreRuntimeState.Stopped => CoreSettingsApplyAction.Inconsistent, + CoreRuntimeState.Running when hasActiveChild && changed => CoreSettingsApplyAction.Restart, + CoreRuntimeState.Running when hasActiveChild => CoreSettingsApplyAction.SaveOnly, + CoreRuntimeState.Running => CoreSettingsApplyAction.Inconsistent, + CoreRuntimeState.Faulted when hasActiveChild => CoreSettingsApplyAction.Restart, + CoreRuntimeState.Faulted => CoreSettingsApplyAction.SaveOnly, + CoreRuntimeState.Starting or CoreRuntimeState.Stopping or CoreRuntimeState.Restarting => CoreSettingsApplyAction.Busy, + _ => CoreSettingsApplyAction.Inconsistent, + }; +} + +internal sealed record CoreSettingsApplyExecution(CoreSettingsApplyAction Action, OperationView? RestartResult); + +internal static class CoreSettingsApplyExecutor +{ + public static async Task ExecuteAsync( + CoreRuntimeState state, + bool hasActiveChild, + bool changed, + Func> restart) + { + var action = CoreSettingsApplyPolicy.Decide(state, hasActiveChild, changed); + var restartResult = action == CoreSettingsApplyAction.Restart ? await restart() : null; + return new(action, restartResult); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreUpdatePackageStager.cs b/v2rayN/v2rayN.WebAPI/Services/CoreUpdatePackageStager.cs new file mode 100644 index 00000000..eed24cc2 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreUpdatePackageStager.cs @@ -0,0 +1,221 @@ +using System.Formats.Tar; +using System.IO.Compression; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Manager; + +namespace v2rayN.WebAPI.Services; + +internal static class CoreUpdatePackageStager +{ + private const int MaxPackageEntries = 4096; + private const long MaxExpandedBytes = 512L * 1024 * 1024; + private const long MaxArchiveBytes = 512L * 1024 * 1024; + + public static bool SupportsCore(ECoreType coreType) => coreType is ECoreType.Xray or ECoreType.mihomo or ECoreType.sing_box; + + public static bool IsSupported(ECoreType coreType, string archivePath) + { + var name = archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) + ? ".tar.gz" + : Path.GetExtension(archivePath); + return coreType switch + { + ECoreType.Xray => name.Equals(".zip", StringComparison.OrdinalIgnoreCase), + ECoreType.mihomo => name.Equals(".gz", StringComparison.OrdinalIgnoreCase) + || name.Equals(".zip", StringComparison.OrdinalIgnoreCase), + ECoreType.sing_box => name.Equals(".tar.gz", StringComparison.OrdinalIgnoreCase) + || name.Equals(".zip", StringComparison.OrdinalIgnoreCase), + _ => false, + }; + } + + public static async Task ExtractAsync(ECoreType coreType, string archivePath, string destinationDirectory, CancellationToken cancellationToken) + { + var archiveInfo = new FileInfo(archivePath); + if (!archiveInfo.Exists || archiveInfo.Length is <= 0 or > MaxArchiveBytes) + { + throw new InvalidDataException("The core update archive is empty or exceeds the size limit."); + } + if (!IsSupported(coreType, archivePath)) + { + throw new InvalidDataException($"The downloaded archive format is not supported for {coreType}."); + } + + Directory.CreateDirectory(destinationDirectory); + if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + await ExtractTarGzipAsync(archivePath, destinationDirectory, cancellationToken); + } + else if (Path.GetExtension(archivePath).Equals(".gz", StringComparison.OrdinalIgnoreCase)) + { + await ExtractGzipBinaryAsync(coreType, archivePath, destinationDirectory, cancellationToken); + } + else + { + await ExtractZipAsync(coreType, archivePath, destinationDirectory, cancellationToken); + } + } + + private static async Task ExtractZipAsync(ECoreType coreType, string archivePath, string destinationDirectory, CancellationToken cancellationToken) + { + using var archive = ZipFile.OpenRead(archivePath); + var files = archive.Entries.Where(entry => !string.IsNullOrEmpty(entry.Name)).ToArray(); + if (files.Length is 0 or > MaxPackageEntries || files.Sum(entry => entry.Length) > MaxExpandedBytes) + { + throw new InvalidDataException("The core update ZIP has an invalid file count or expanded size."); + } + + var writtenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in files) + { + cancellationToken.ThrowIfCancellationRequested(); + var fileName = GetSafeFlatFileName(entry.FullName, entry.ExternalAttributes); + // The desktop updater deliberately leaves bundled Geo data untouched; GeoFiles + // are updated independently and are not part of a Core binary replacement. + if (coreType == ECoreType.Xray && fileName.StartsWith("geo", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + EnsureUniqueName(writtenNames, fileName); + await using var source = entry.Open(); + await using var target = new FileStream( + Path.Combine(destinationDirectory, fileName), + FileMode.CreateNew, + FileAccess.Write, + FileShare.None); + await CopyWithLimitAsync(source, target, MaxExpandedBytes, cancellationToken); + } + + if (writtenNames.Count == 0) + { + throw new InvalidDataException("The core update ZIP contains no applicable files."); + } + } + + private static async Task ExtractGzipBinaryAsync( + ECoreType coreType, + string archivePath, + string destinationDirectory, + CancellationToken cancellationToken) + { + var executableName = CoreInfoManager.Instance.GetCoreInfo(coreType)?.CoreExes?.FirstOrDefault() + ?? coreType.ToString(); + var destination = Path.Combine(destinationDirectory, Utils.GetExeName(executableName)); + await using var source = File.OpenRead(archivePath); + await using var gzip = new GZipStream(source, CompressionMode.Decompress); + await using var target = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await CopyWithLimitAsync(gzip, target, MaxExpandedBytes, cancellationToken); + } + + private static async Task ExtractTarGzipAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken) + { + using var source = File.OpenRead(archivePath); + using var gzip = new GZipStream(source, CompressionMode.Decompress); + using var reader = new TarReader(gzip); + var writtenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + long expandedBytes = 0; + var entryCount = 0; + + while (reader.GetNextEntry() is { } entry) + { + cancellationToken.ThrowIfCancellationRequested(); + entryCount++; + if (entryCount > MaxPackageEntries) + { + throw new InvalidDataException("The core update tarball has too many entries."); + } + + if (entry.EntryType == TarEntryType.Directory) + { + continue; + } + if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.ContiguousFile)) + { + throw new InvalidDataException("The core update tarball contains a link or unsupported entry type."); + } + if (entry.Length < 0 || entry.Length > MaxExpandedBytes) + { + throw new InvalidDataException("The core update tarball has an invalid file size."); + } + + expandedBytes = checked(expandedBytes + entry.Length); + if (expandedBytes > MaxExpandedBytes) + { + throw new InvalidDataException("The core update tarball exceeds the expanded size limit."); + } + + var fileName = GetSafeFlatFileName(entry.Name, externalAttributes: 0); + EnsureUniqueName(writtenNames, fileName); + if (entry.DataStream is null) + { + throw new InvalidDataException("The core update tarball contains a file without data."); + } + + await using var target = new FileStream( + Path.Combine(destinationDirectory, fileName), + FileMode.CreateNew, + FileAccess.Write, + FileShare.None); + await CopyWithLimitAsync(entry.DataStream, target, MaxExpandedBytes, cancellationToken); + } + + if (writtenNames.Count == 0) + { + throw new InvalidDataException("The core update tarball contains no regular files."); + } + } + + private static string GetSafeFlatFileName(string path, int externalAttributes) + { + var normalized = path.Replace('\\', '/'); + while (normalized.StartsWith("./", StringComparison.Ordinal)) + { + normalized = normalized[2..]; + } + var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (Path.IsPathRooted(normalized) + || segments.Length == 0 + || segments.Any(segment => segment is "." or "..") + || normalized.Contains(':') + || ((externalAttributes >> 16) & 0xF000) == 0xA000) + { + throw new InvalidDataException("The core update archive contains an unsafe path or symbolic link."); + } + + var fileName = Path.GetFileName(normalized); + if (string.IsNullOrWhiteSpace(fileName) || fileName is "." or "..") + { + throw new InvalidDataException("The core update archive contains an invalid file name."); + } + return fileName; + } + + private static void EnsureUniqueName(HashSet writtenNames, string fileName) + { + if (!writtenNames.Add(fileName)) + { + throw new InvalidDataException("The core update archive contains duplicate file names after flattening."); + } + } + + private static async Task CopyWithLimitAsync(Stream source, Stream target, long limit, CancellationToken cancellationToken) + { + var buffer = new byte[81920]; + long total = 0; + while (true) + { + var read = await source.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + return; + } + total += read; + if (total > limit) + { + throw new InvalidDataException("The core update archive expands beyond the size limit."); + } + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/EventHub.cs b/v2rayN/v2rayN.WebAPI/Services/EventHub.cs new file mode 100644 index 00000000..817db241 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/EventHub.cs @@ -0,0 +1,53 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed class EventHub +{ + private const int EventBufferCapacity = 256; + private readonly ConcurrentDictionary _subscribers = new(); + + public void Publish(string type, object? data) + { + var message = new WebEvent(type, data, DateTimeOffset.UtcNow); + foreach (var subscriber in _subscribers.Values) + { + if (message.Type != "log" || subscriber.IncludeLogs) + { + subscriber.Channel.Writer.TryWrite(message); + } + } + } + + public async IAsyncEnumerable Subscribe( + [EnumeratorCancellation] CancellationToken cancellationToken = default, + bool includeLogs = false) + { + var id = Guid.NewGuid(); + var channel = Channel.CreateBounded(new BoundedChannelOptions(EventBufferCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + + _subscribers[id] = new EventSubscriber(channel, includeLogs); + try + { + await foreach (var message in channel.Reader.ReadAllAsync(cancellationToken)) + { + yield return message; + } + } + finally + { + _subscribers.TryRemove(id, out _); + channel.Writer.TryComplete(); + } + } + + private sealed record EventSubscriber(Channel Channel, bool IncludeLogs); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateCompletion.cs b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateCompletion.cs new file mode 100644 index 00000000..6f2baaae --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateCompletion.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; + +namespace v2rayN.WebAPI.Services; + +internal sealed class GeoFilesUpdateCompletion +{ + private readonly ConcurrentQueue _failures = new(); + private int _completionReported; + + public void Report(bool success, string message, bool isProgressMessage) + { + if (success) + { + Interlocked.Exchange(ref _completionReported, 1); + } + else if (!isProgressMessage) + { + _failures.Enqueue(message); + } + } + + public void EnsureSuccessful() + { + if (!_failures.IsEmpty) + { + throw new IOException("GeoFiles update reported an error: " + string.Join("; ", _failures)); + } + if (Volatile.Read(ref _completionReported) == 0) + { + throw new IOException("GeoFiles update did not report successful completion."); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateGate.cs b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateGate.cs new file mode 100644 index 00000000..fef5545c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateGate.cs @@ -0,0 +1,24 @@ +namespace v2rayN.WebAPI.Services; + +/// +/// Serializes every Web-owned GeoFiles transaction, regardless of whether it was started +/// manually, as part of a batch update, or by a regional preset. +/// +internal sealed class GeoFilesUpdateGate +{ + private readonly SemaphoreSlim _gate = new(1, 1); + + public async Task RunAsync(Func update, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(update); + await _gate.WaitAsync(cancellationToken); + try + { + await update(); + } + finally + { + _gate.Release(); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateTransaction.cs b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateTransaction.cs new file mode 100644 index 00000000..c1441ff0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateTransaction.cs @@ -0,0 +1,151 @@ +namespace v2rayN.WebAPI.Services; + +/// +/// Adds Web-owned backup/verification/rollback around ServiceLib's public GeoFiles updater. +/// The updater remains responsible for deciding which files to download and how to download them. +/// +internal static class GeoFilesUpdateTransaction +{ + public static async Task ApplyAsync( + IEnumerable managedFiles, + IEnumerable requiredFiles, + Func update, + CancellationToken cancellationToken, + Func, CancellationToken, Task>? beforeCommit = null) + { + var required = requiredFiles + .Select(Path.GetFullPath) + .Distinct(PathComparer) + .ToArray(); + var originalTargets = managedFiles + .Concat(required) + .Select(Path.GetFullPath) + .Distinct(PathComparer) + .ToArray(); + var backupRoot = Path.Combine(Path.GetTempPath(), $"v2rayn-WebAPI-geofiles-{Guid.NewGuid():N}"); + var backups = new Dictionary(PathComparer); + var transactionCompleted = false; + var rollbackCompleted = false; + var updateStarted = false; + + Task RollBackFilesAsync() + { + if (rollbackCompleted) return Task.CompletedTask; + + var originallyExisting = backups.Keys.ToHashSet(PathComparer); + foreach (var target in originalTargets) + { + if (!originallyExisting.Contains(target) && (File.Exists(target) || IsSymbolicLink(target))) + { + File.Delete(target); + } + } + + foreach (var (target, backup) in backups) + { + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + if (IsSymbolicLink(target)) File.Delete(target); + File.Copy(backup.BackupPath, target, overwrite: true); + File.SetLastWriteTimeUtc(target, backup.LastWriteTimeUtc); + } + rollbackCompleted = true; + return Task.CompletedTask; + } + + try + { + Directory.CreateDirectory(backupRoot); + for (var index = 0; index < originalTargets.Length; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var target = originalTargets[index]; + if (Directory.Exists(target)) + { + throw new IOException($"GeoFiles target is a directory: {target}"); + } + if (IsSymbolicLink(target)) + { + throw new IOException($"GeoFiles target is a symbolic link and cannot be updated safely: {target}"); + } + if (!File.Exists(target)) continue; + var backup = Path.Combine(backupRoot, index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture)); + var lastWriteTimeUtc = File.GetLastWriteTimeUtc(target); + File.Copy(target, backup); + backups.Add(target, new GeoFileBackup(backup, lastWriteTimeUtc)); + } + + try + { + updateStarted = true; + await update(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + foreach (var requiredFile in required) + { + if (!File.Exists(requiredFile) || new FileInfo(requiredFile).Length <= 0) + { + throw new IOException($"GeoFiles update did not produce a non-empty {Path.GetFileName(requiredFile)}."); + } + } + if (beforeCommit is not null) await beforeCommit(RollBackFilesAsync, cancellationToken); + transactionCompleted = true; + } + catch (Exception updateException) + { + if (!rollbackCompleted) + { + try + { + await RollBackFilesAsync(); + } + catch (Exception rollbackException) + { + throw new GeoFilesUpdateRollbackException(backupRoot, updateException, rollbackException); + } + } + + throw; + } + } + finally + { + if ((transactionCompleted || rollbackCompleted || !updateStarted) && Directory.Exists(backupRoot)) + { + try { Directory.Delete(backupRoot, recursive: true); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A cleanup failure does not invalidate a successful update or rollback. + } + } + } + } + + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private static bool IsSymbolicLink(string path) + { + try + { + if (new FileInfo(path).LinkTarget is not null) return true; + return File.Exists(path) && File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint); + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return false; + } + } + + private sealed record GeoFileBackup(string BackupPath, DateTime LastWriteTimeUtc); +} + +internal sealed class GeoFilesUpdateRollbackException( + string backupRoot, + Exception updateException, + Exception rollbackException) + : IOException( + $"GeoFiles update failed and rollback is incomplete; the backup set was retained at {backupRoot}.", + new AggregateException(updateException, rollbackException)) +{ + public string BackupRoot { get; } = backupRoot; +} diff --git a/v2rayN/v2rayN.WebAPI/Services/LogBuffer.cs b/v2rayN/v2rayN.WebAPI/Services/LogBuffer.cs new file mode 100644 index 00000000..f630fe54 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/LogBuffer.cs @@ -0,0 +1,84 @@ +using v2rayN.WebAPI.Contracts; +using ServiceLib.Common; + +namespace v2rayN.WebAPI.Services; + +public sealed class LogBuffer +{ + private const int Capacity = 2000; + private const int MaximumMessageCharacters = 16 * 1024; + private const int MaximumRetainedCharacters = 1_000_000; + private readonly object _gate = new(); + private readonly Queue _items = new(); + private int _retainedCharacters; + private long _generation; + + public LogView Add(string source, string message) + { + var normalized = message.TrimEnd(); + if (normalized.Length > MaximumMessageCharacters) + { + const string marker = "… [log entry truncated]"; + normalized = normalized[..(MaximumMessageCharacters - marker.Length)] + marker; + } + LogView entry; + lock (_gate) + { + entry = new LogView(DateTimeOffset.UtcNow, source, normalized, _generation); + _items.Enqueue(entry); + _retainedCharacters += entry.Source.Length + entry.Message.Length; + while (_items.Count > Capacity || _retainedCharacters > MaximumRetainedCharacters) + { + var removed = _items.Dequeue(); + _retainedCharacters -= removed.Source.Length + removed.Message.Length; + } + } + + return entry; + } + + public IReadOnlyList Recent(int limit, string? filter = null) + { + lock (_gate) + { + var query = _items.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(filter)) + { + query = query.Where(item => Utils.IsRegexMatch($"{item.Source} {item.Message}", filter)); + } + return query.TakeLast(Math.Clamp(limit, 1, Capacity)).ToArray(); + } + } + + public LogPageView RecentPage(int page, int pageSize, string? filter = null) + { + lock (_gate) + { + var filtered = _items.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(filter)) + { + filtered = filtered.Where(item => Utils.IsRegexMatch($"{item.Source} {item.Message}", filter)); + } + + var items = filtered.ToArray(); + var total = items.Length; + var safePageSize = Math.Clamp(pageSize, 1, 200); + var totalPages = Math.Max(1, (total + safePageSize - 1) / safePageSize); + var safePage = Math.Clamp(page, 1, totalPages); + var end = total - ((safePage - 1) * safePageSize); + var start = Math.Max(0, end - safePageSize); + + return new LogPageView(items.Skip(start).Take(end - start).ToArray(), safePage, safePageSize, total, totalPages); + } + } + + public long Clear() + { + lock (_gate) + { + _items.Clear(); + _retainedCharacters = 0; + return ++_generation; + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/RegionalPresetStager.cs b/v2rayN/v2rayN.WebAPI/Services/RegionalPresetStager.cs new file mode 100644 index 00000000..73c152ca --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/RegionalPresetStager.cs @@ -0,0 +1,433 @@ +using System.Net; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Dto; +using ServiceLib.Models.Entities; + +namespace v2rayN.WebAPI.Services; + +internal sealed record RegionalRoutingPlan( + string? TemplateVersion, + IReadOnlyList Items, + string? DefaultRemarks = null, + IReadOnlyList? RemoveItemIds = null, + string? ExistingDefaultItemId = null, + bool ClearLegacyRoutingIndex = false, + bool IsBuiltinFallback = false); + +internal sealed record RegionalPresetStage( + EPresetType Preset, + string GeoSourceUrl, + string SrsSourceUrl, + string RouteRulesTemplateSourceUrl, + SimpleDNSItem SimpleDnsItem, + IReadOnlyList DnsItems, + RegionalRoutingPlan Routing); + +internal sealed class RegionalPresetStager( + Func> download, + TimeSpan? timeout = null) +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + private readonly TimeSpan _timeout = timeout ?? DefaultTimeout; + + public async Task StageAsync( + EPresetType preset, + IReadOnlyList existingDnsItems, + IReadOnlyList existingRoutingItems, + IWebProxy? proxy, + CancellationToken cancellationToken, + string? legacyRoutingIndexId = null) + { + if (preset == EPresetType.Default) + { + return new( + preset, + string.Empty, + string.Empty, + string.Empty, + ConfigHandler.InitBuiltinSimpleDNS(), + [CreateDefaultDns(ECoreType.Xray, "V2ray"), CreateDefaultDns(ECoreType.sing_box, "sing-box")], + new(null, [])); + } + + var sourceIndex = preset switch + { + EPresetType.Russia => 1, + EPresetType.Iran => 2, + _ => throw new ArgumentOutOfRangeException(nameof(preset), preset, "Unsupported regional preset."), + }; + var xrayItem = existingDnsItems.FirstOrDefault(item => item.CoreType == ECoreType.Xray) + ?? throw new InvalidOperationException("The Xray DNS profile is missing."); + var singboxItem = existingDnsItems.FirstOrDefault(item => item.CoreType == ECoreType.sing_box) + ?? throw new InvalidOperationException("The sing-box DNS profile is missing."); + var dnsBaseUrl = Global.DNSTemplateSources[sourceIndex]; + var routeTemplateUrl = Global.RoutingRulesSources[sourceIndex]; + + var xrayTask = StageDnsProfileAsync(ECoreType.Xray, xrayItem, dnsBaseUrl + "v2ray.json", proxy, cancellationToken); + var singboxTask = StageDnsProfileAsync(ECoreType.sing_box, singboxItem, dnsBaseUrl + "sing_box.json", proxy, cancellationToken); + var simpleDnsTask = StageSimpleDnsAsync(dnsBaseUrl + "simple_dns.json", proxy, cancellationToken); + var routingTask = StageRoutingAsync(routeTemplateUrl, existingRoutingItems, legacyRoutingIndexId, proxy, cancellationToken); + await Task.WhenAll(xrayTask, singboxTask, simpleDnsTask, routingTask); + + var xrayDns = await xrayTask; + var singboxDns = await singboxTask; + var simpleDns = await simpleDnsTask; + if (simpleDns.UsedFallback) + { + // Desktop enables both Core-specific DNS profiles when simple_dns.json is unavailable. + xrayDns.Enabled = true; + singboxDns.Enabled = true; + } + + return new( + preset, + Global.GeoFilesSources[sourceIndex], + Global.SingboxRulesetSources[sourceIndex], + routeTemplateUrl, + simpleDns.Item, + [xrayDns, singboxDns], + await routingTask); + } + + private async Task StageDnsProfileAsync( + ECoreType coreType, + DNSItem existing, + string url, + IWebProxy? proxy, + CancellationToken cancellationToken) + { + var content = await TryDownloadAsync(url, proxy, cancellationToken); + var template = content is null ? null : JsonUtils.Deserialize(content); + if (template is null) + { + // ConfigHandler.GetExternalDNSItem returns the current DNS item when the + // template cannot be downloaded or parsed. + return CloneDnsItem(existing); + } + + var normalDnsTask = string.IsNullOrWhiteSpace(template.NormalDNS) + ? Task.FromResult(null) + : TryDownloadAsync(template.NormalDNS, proxy, cancellationToken); + var tunDnsTask = string.IsNullOrWhiteSpace(template.TunDNS) + ? Task.FromResult(null) + : TryDownloadAsync(template.TunDNS, proxy, cancellationToken); + await Task.WhenAll(normalDnsTask, tunDnsTask); + + // Desktop keeps a successfully downloaded template even when its referenced + // NormalDNS/TunDNS resource fails; TryDownloadString then leaves that field empty. + if (!string.IsNullOrWhiteSpace(template.NormalDNS)) + { + template.NormalDNS = await normalDnsTask ?? string.Empty; + } + if (!string.IsNullOrWhiteSpace(template.TunDNS)) + { + template.TunDNS = await tunDnsTask ?? string.Empty; + } + + template.Id = existing.Id; + template.Remarks = existing.Remarks; + template.Enabled = existing.Enabled; + template.CoreType = coreType; + return template; + } + + private async Task<(SimpleDNSItem Item, bool UsedFallback)> StageSimpleDnsAsync( + string url, + IWebProxy? proxy, + CancellationToken cancellationToken) + { + var content = await TryDownloadAsync(url, proxy, cancellationToken); + var template = content is null ? null : JsonUtils.Deserialize(content); + return template is null + ? (ConfigHandler.InitBuiltinSimpleDNS(), true) + : (template, false); + } + + private async Task StageRoutingAsync( + string url, + IReadOnlyList existingItems, + string? legacyRoutingIndexId, + IWebProxy? proxy, + CancellationToken cancellationToken) + { + var content = await TryDownloadAsync(url, proxy, cancellationToken); + var template = content is null ? null : JsonUtils.Deserialize(content); + if (template is null || string.IsNullOrWhiteSpace(template.Version) || template.RoutingItems is null) + { + return StageBuiltinRouting(existingItems, legacyRoutingIndexId); + } + + if (existingItems.Any(item => item.Remarks?.StartsWith(template.Version, StringComparison.Ordinal) == true)) + { + // Importing an already-present template version must not change the active route. + return new(template.Version, []); + } + + var stagedTasks = template.RoutingItems + .Select(sourceItem => StageRoutingItemAsync(sourceItem, template.Version, proxy, cancellationToken)) + .ToArray(); + var stagedResults = await Task.WhenAll(stagedTasks); + var stagedItems = stagedResults.Where(item => item is not null).Cast().ToArray(); + for (var index = 0; index < stagedItems.Length; index++) + { + stagedItems[index].Sort = existingItems.Count + index + 1; + } + + return new(template.Version, stagedItems, stagedItems.FirstOrDefault()?.Remarks); + } + + private async Task StageRoutingItemAsync( + RoutingItem? sourceItem, + string templateVersion, + IWebProxy? proxy, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (sourceItem is null || (string.IsNullOrWhiteSpace(sourceItem.Url) && string.IsNullOrWhiteSpace(sourceItem.RuleSet))) + { + return null; + } + + var rulesContent = !string.IsNullOrWhiteSpace(sourceItem.RuleSet) + ? sourceItem.RuleSet + : await TryDownloadAsync(sourceItem.Url, proxy, cancellationToken); + var rules = rulesContent is null ? null : JsonUtils.Deserialize>(rulesContent); + if (rules is null) + { + // Desktop skips an entry whose URL returns no content. Invalid rule JSON is + // likewise not a usable AddBatchRoutingRules payload, so skip only that entry. + return null; + } + + var item = JsonUtils.DeepCopy(sourceItem); + if (item is null) + { + return null; + } + item.Id = string.Empty; + item.Remarks = $"{templateVersion}-{item.Remarks}"; + item.Enabled = true; + item.Url = string.Empty; + item.IsActive = false; + item.RuleNum = rules.Count; + item.RuleSet = JsonUtils.Serialize(rules, false); + return item; + } + + private static RegionalRoutingPlan StageBuiltinRouting( + IReadOnlyList existingItems, + string? legacyRoutingIndexId) + { + // Match InitBuiltinRouting's one-time locked-profile removal and migration of + // RoutingIndexId, while keeping all database writes in the later apply phase. + var lockedItem = existingItems.FirstOrDefault(item => item.Locked); + var items = lockedItem is null + ? existingItems.ToList() + : existingItems.Where(item => item.Id != lockedItem.Id).ToList(); + var removeIds = lockedItem is null || string.IsNullOrEmpty(lockedItem.Id) + ? Array.Empty() + : [lockedItem.Id]; + var clearLegacyIndex = !string.IsNullOrWhiteSpace(legacyRoutingIndexId); + + if (items.Count > 0) + { + var migrationTarget = clearLegacyIndex + ? items.FirstOrDefault(item => item.Id == legacyRoutingIndexId) + : null; + return new( + null, + [], + RemoveItemIds: removeIds, + ExistingDefaultItemId: migrationTarget is { IsActive: false } ? migrationTarget.Id : null, + ClearLegacyRoutingIndex: clearLegacyIndex, + IsBuiltinFallback: true); + } + + var defaults = new[] + { + (Name: "绕过大陆(Whitelist)", Resource: "white"), + (Name: "黑名单(Blacklist)", Resource: "black"), + (Name: "全局(Global)", Resource: "global"), + }; + var builtins = defaults.Select((entry, index) => + { + var rulesContent = EmbedUtils.GetEmbedText(Global.CustomRoutingFileName + entry.Resource); + var rules = JsonUtils.Deserialize>(rulesContent) + ?? throw new InvalidDataException($"The built-in routing rules '{entry.Resource}' are invalid."); + return new RoutingItem + { + Remarks = $"V4-{entry.Name}", + Url = string.Empty, + RuleNum = rules.Count, + RuleSet = JsonUtils.Serialize(rules, false), + Enabled = true, + Sort = index + 1, + }; + }).ToArray(); + + return new( + null, + builtins, + builtins[0].Remarks, + removeIds, + ClearLegacyRoutingIndex: clearLegacyIndex, + IsBuiltinFallback: true); + } + + private async Task TryDownloadAsync(string url, IWebProxy? proxy, CancellationToken cancellationToken) + { + try + { + return await DownloadRequiredAsync(url, proxy, cancellationToken); + } + catch (Exception exception) when (IsFallbackFailure(exception, cancellationToken)) + { + return null; + } + } + + private async Task DownloadRequiredAsync(string url, IWebProxy? proxy, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_timeout); + try + { + var content = await download(url, proxy, timeoutCts.Token); + if (string.IsNullOrWhiteSpace(content)) + { + throw new InvalidDataException($"The download returned no content: {url}"); + } + return content; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"The download exceeded {_timeout.TotalSeconds:0.###} seconds: {url}"); + } + } + + private static bool IsFallbackFailure(Exception exception, CancellationToken cancellationToken) => + !cancellationToken.IsCancellationRequested + && exception is not OperationCanceledException + && exception is TimeoutException or HttpRequestException or WebException or IOException or InvalidDataException; + + private static DNSItem CloneDnsItem(DNSItem item) => + JsonUtils.DeepCopy(item) ?? throw new InvalidOperationException("A DNS profile could not be copied for regional preset staging."); + + private static DNSItem CreateDefaultDns(ECoreType coreType, string remarks) => new() + { + Remarks = remarks, + CoreType = coreType, + Enabled = false, + }; +} + +internal static class RegionalPresetWorkflow +{ + public static async Task RunAsync( + Func> stage, + Func> apply, + CancellationToken cancellationToken) + { + TStage staged; + try + { + staged = await stage(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + throw new RegionalPresetStagingException(exception); + } + cancellationToken.ThrowIfCancellationRequested(); + return await apply(staged, cancellationToken); + } +} + +internal sealed class RegionalPresetStagingException(Exception innerException) + : Exception("Regional preset staging failed.", innerException) { } + +internal static class RegionalPresetApplyWorkflow +{ + public static async Task RunAsync( + Func applyConfiguration, + Func, CancellationToken, Task>, CancellationToken, Task> updateGeoFilesTransaction, + Func, CancellationToken, Task> completeCoreApply, + Func rollbackConfigurationAndRuntime, + CancellationToken cancellationToken) + { + var configurationApplied = false; + try + { + await applyConfiguration(cancellationToken); + configurationApplied = true; + cancellationToken.ThrowIfCancellationRequested(); + + await updateGeoFilesTransaction(async (rollbackGeoFiles, transactionToken) => + { + transactionToken.ThrowIfCancellationRequested(); + await completeCoreApply(rollbackGeoFiles, transactionToken); + }, cancellationToken); + } + catch (RegionalPresetCoreApplyFailedException) + { + // CoreSettingsApply already restored its snapshot/runtime. The enclosing GeoFiles + // transaction restores files before this exception is observed by its caller. + throw; + } + catch (Exception applyException) + { + if (configurationApplied) + { + try + { + await rollbackConfigurationAndRuntime(); + } + catch (Exception rollbackException) + { + throw new RegionalPresetApplyRollbackException(applyException, rollbackException); + } + } + throw; + } + } +} + +internal sealed class RegionalPresetCoreApplyFailedException : Exception { } + +internal sealed class RegionalPresetApplyRollbackException(Exception applyException, Exception rollbackException) + : Exception("The regional preset failed and its configuration/runtime rollback also failed.", + new AggregateException(applyException, rollbackException)) { } + +internal sealed record RegionalPresetApplyResult(bool Success, bool RollbackSucceeded, Exception? Error); + +internal static class RegionalPresetTransaction +{ + public static async Task RunAsync(Func apply, Func> rollback) + { + try + { + await apply(); + return new(true, true, null); + } + catch (Exception exception) + { + var rollbackSucceeded = false; + try + { + rollbackSucceeded = await rollback(); + } + catch + { + // Rollback failure is represented in the result; preserve the original apply failure. + } + return new(false, rollbackSucceeded, exception); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/RuntimeMutationGate.cs b/v2rayN/v2rayN.WebAPI/Services/RuntimeMutationGate.cs new file mode 100644 index 00000000..58b09a37 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/RuntimeMutationGate.cs @@ -0,0 +1,39 @@ +namespace v2rayN.WebAPI.Services; + +/// +/// Serializes Web-owned mutations of ServiceLib's shared configuration and SQLite state. +/// Subscription updates hold the gate across ServiceLib's download/import operation so their +/// profile, configuration, and timestamp changes remain ordered with other Web mutations. +/// +public sealed class RuntimeMutationGate +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + + public async Task RunAsync(Func mutation, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(mutation); + await _semaphore.WaitAsync(cancellationToken); + try + { + await mutation(); + } + finally + { + _semaphore.Release(); + } + } + + public async Task RunAsync(Func> mutation, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(mutation); + await _semaphore.WaitAsync(cancellationToken); + try + { + return await mutation(); + } + finally + { + _semaphore.Release(); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/RuntimeOperationCoordinator.cs b/v2rayN/v2rayN.WebAPI/Services/RuntimeOperationCoordinator.cs new file mode 100644 index 00000000..0b61a93d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/RuntimeOperationCoordinator.cs @@ -0,0 +1,226 @@ +namespace v2rayN.WebAPI.Services; + +/// +/// Tracks stateful runtime work. Normal operations may overlap; maintenance operations +/// (core updates and restore) take an exclusive lease. Exclusive waiters prevent new +/// normal work from starving them, and shutdown cancels all linked operation tokens. +/// +public sealed class RuntimeOperationCoordinator +{ + private static readonly TimeSpan DefaultDrainTimeout = TimeSpan.FromSeconds(10); + private readonly object _sync = new(); + private readonly CancellationTokenSource _shutdown = new(); + private TaskCompletionSource _changed = NewSignal(); + private int _activeOperations; + private int _waitingExclusive; + private bool _exclusiveActive; + private bool _exclusiveAllowsObservations; + private bool _stopping; + + public CancellationToken ShutdownToken => _shutdown.Token; + public bool IsStopping + { + get + { + lock (_sync) return _stopping; + } + } + + public void RejectNewOperations() + { + lock (_sync) + { + _stopping = true; + SignalChange(); + } + } + + public ValueTask EnterOperationAsync(CancellationToken cancellationToken = default) => + EnterAsync(exclusive: false, allowReadOnlyObservation: false, cancellationToken); + + /// + /// Acquires a shared lease for an observational request such as status, operations, or logs. + /// During selected maintenance windows these requests may also run concurrently with the + /// exclusive operation; they must remain read-only and must not access resources being swapped. + /// + public ValueTask EnterObservationAsync(CancellationToken cancellationToken = default) => + EnterAsync(exclusive: false, allowReadOnlyObservation: true, cancellationToken); + + public ValueTask EnterExclusiveAsync( + CancellationToken cancellationToken = default, + bool allowReadOnlyObservations = false) => + EnterAsync(exclusive: true, allowReadOnlyObservation: false, cancellationToken, allowReadOnlyObservations); + + private async ValueTask EnterAsync( + bool exclusive, + bool allowReadOnlyObservation, + CancellationToken cancellationToken, + bool allowReadOnlyObservations = false) + { + var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdown.Token); + var waiting = false; + try + { + while (true) + { + linked.Token.ThrowIfCancellationRequested(); + Task waitTask; + lock (_sync) + { + if (_stopping) + { + throw new OperationCanceledException(_shutdown.Token); + } + if (linked.IsCancellationRequested) + { + throw new OperationCanceledException(linked.Token); + } + + if (exclusive && !waiting) + { + _waitingExclusive++; + waiting = true; + } + + if (exclusive && !_exclusiveActive && _activeOperations == 0) + { + _waitingExclusive--; + waiting = false; + _exclusiveActive = true; + _exclusiveAllowsObservations = allowReadOnlyObservations; + return new Lease(this, exclusive: true, linked); + } + + if (!exclusive && !_exclusiveActive && _waitingExclusive == 0) + { + _activeOperations++; + return new Lease(this, exclusive: false, linked); + } + + if (allowReadOnlyObservation + && _exclusiveActive + && _exclusiveAllowsObservations + && _waitingExclusive == 0) + { + // Count observations as active so shutdown drains them and a subsequent + // exclusive operation cannot begin while one is still running. + _activeOperations++; + return new Lease(this, exclusive: false, linked); + } + + waitTask = _changed.Task; + } + + await waitTask.WaitAsync(linked.Token); + } + } + catch + { + linked.Dispose(); + if (waiting) + { + lock (_sync) + { + _waitingExclusive--; + SignalChange(); + } + } + throw; + } + } + + public async Task StopAndDrainAsync(CancellationToken cancellationToken, TimeSpan? timeout = null) + { + lock (_sync) + { + _stopping = true; + SignalChange(); + } + + using var drainCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + drainCancellation.CancelAfter(timeout ?? DefaultDrainTimeout); + + try + { + await _shutdown.CancelAsync().WaitAsync(drainCancellation.Token); + while (true) + { + Task waitTask; + lock (_sync) + { + if (_activeOperations == 0 && !_exclusiveActive && _waitingExclusive == 0) + { + return true; + } + waitTask = _changed.Task; + } + + await waitTask.WaitAsync(drainCancellation.Token); + } + } + catch (OperationCanceledException) when (drainCancellation.IsCancellationRequested) + { + return false; + } + } + + private void Release(bool exclusive) + { + lock (_sync) + { + if (exclusive) + { + _exclusiveActive = false; + _exclusiveAllowsObservations = false; + } + else + { + _activeOperations--; + } + SignalChange(); + } + } + + private void SignalChange() + { + var old = _changed; + _changed = NewSignal(); + old.TrySetResult(); + } + + private static TaskCompletionSource NewSignal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public sealed class Lease : IAsyncDisposable, IDisposable + { + private RuntimeOperationCoordinator? _owner; + private readonly bool _exclusive; + private readonly CancellationTokenSource _linked; + + internal Lease(RuntimeOperationCoordinator owner, bool exclusive, CancellationTokenSource linked) + { + _owner = owner; + _exclusive = exclusive; + _linked = linked; + } + + public CancellationToken Token => _linked.Token; + + public void Dispose() + { + var owner = Interlocked.Exchange(ref _owner, null); + if (owner is null) + { + return; + } + _linked.Dispose(); + owner.Release(_exclusive); + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/RuntimeRequestOperationPolicy.cs b/v2rayN/v2rayN.WebAPI/Services/RuntimeRequestOperationPolicy.cs new file mode 100644 index 00000000..31968609 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/RuntimeRequestOperationPolicy.cs @@ -0,0 +1,51 @@ +namespace v2rayN.WebAPI.Services; + +internal enum RuntimeRequestOperationKind +{ + Shared, + Observation, + Exclusive, + ExclusiveReadOnly, + Background, +} + +internal static class RuntimeRequestOperationPolicy +{ + public static RuntimeRequestOperationKind Classify(string method, string path) + { + var normalizedPath = path.TrimEnd('/'); + var isPost = string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase); + var isGet = string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase); + + if (isPost + && (normalizedPath is "/api/core/xray/update" or "/api/core/geo/update" or "/api/web-updates/update" or "/api/speedtests" + or "/api/subscriptions/update" or "/api/backup/restore" or "/api/backup/webdav/restore" + || normalizedPath.StartsWith("/api/settings/regional-presets/", StringComparison.Ordinal) + || normalizedPath.StartsWith("/api/core-updates/", StringComparison.Ordinal) + && normalizedPath.EndsWith("/update", StringComparison.Ordinal) + || (normalizedPath.StartsWith("/api/subscriptions/", StringComparison.Ordinal) + && normalizedPath.EndsWith("/update", StringComparison.Ordinal)))) + { + return RuntimeRequestOperationKind.Background; + } + + if (isGet + && normalizedPath is "/api/status" or "/api/operations" or "/api/logs" or "/api/logs/page") + { + return RuntimeRequestOperationKind.Observation; + } + + if (!isGet && normalizedPath.StartsWith("/api/settings/", StringComparison.Ordinal)) + { + return RuntimeRequestOperationKind.ExclusiveReadOnly; + } + + if ((isGet && normalizedPath == "/api/backup/download") + || (isPost && normalizedPath == "/api/backup/webdav")) + { + return RuntimeRequestOperationKind.Exclusive; + } + + return RuntimeRequestOperationKind.Shared; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/RuntimeRestartRecovery.cs b/v2rayN/v2rayN.WebAPI/Services/RuntimeRestartRecovery.cs new file mode 100644 index 00000000..286e6b6b --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/RuntimeRestartRecovery.cs @@ -0,0 +1,105 @@ +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +internal sealed record RuntimeRestartIntent(bool WasRunning, string? PreferredProfileId) +{ + public string Reason { get; init; } = "Restore"; +} + +internal enum RuntimeRestartRecoverySource +{ + NotRequested, + PreferredProfile, + SelectedProfile, + DefaultProfile, + NoProfile, + StartFailed, +} + +internal sealed record RuntimeRestartRecoveryResult( + RuntimeRestartRecoverySource Source, + string? ProfileId = null, + string? FailureCode = null); + +internal static class RuntimeRestartRecovery +{ + public static bool ShouldAutoStart(RuntimeRestartIntent? intent, bool configured, bool setupRequired = false) => + !setupRequired && intent is null && configured; + + public static bool ShouldRecoverCore(RuntimeRestartIntent? intent, bool setupRequired) => + !setupRequired && intent?.WasRunning == true; + + public static async Task RecoverAsync( + RuntimeRestartIntent intent, + string? selectedProfileId, + string? defaultProfileId, + Func> profileExists, + Func> startCore, + Action log, + CancellationToken cancellationToken) + { + if (!intent.WasRunning) + { + return new(RuntimeRestartRecoverySource.NotRequested); + } + + var reason = string.IsNullOrWhiteSpace(intent.Reason) ? "Runtime restart" : intent.Reason; + log($"{reason} requested Core runtime recovery."); + var preferredId = Normalize(intent.PreferredProfileId); + if (preferredId is not null && await profileExists(preferredId, cancellationToken)) + { + return await StartAsync(preferredId, reason, RuntimeRestartRecoverySource.PreferredProfile, startCore, log, cancellationToken); + } + + if (preferredId is not null) + { + log($"Previous profile {preferredId} no longer exists after {reason.ToLowerInvariant()}."); + } + + var selectedId = Normalize(selectedProfileId); + if (selectedId is not null && selectedId != preferredId) + { + if (await profileExists(selectedId, cancellationToken)) + { + log($"Falling back to selected profile {selectedId}."); + return await StartAsync(selectedId, reason, RuntimeRestartRecoverySource.SelectedProfile, startCore, log, cancellationToken); + } + + log($"Selected profile {selectedId} does not exist after {reason.ToLowerInvariant()}."); + } + + var fallbackId = Normalize(defaultProfileId); + if (fallbackId is not null && fallbackId != preferredId && fallbackId != selectedId + && await profileExists(fallbackId, cancellationToken)) + { + log($"Falling back to the ServiceLib default profile {fallbackId}."); + return await StartAsync(fallbackId, reason, RuntimeRestartRecoverySource.DefaultProfile, startCore, log, cancellationToken); + } + + log($"{reason} requested Core runtime recovery, but no valid profile exists in the current configuration. Core remains stopped."); + return new(RuntimeRestartRecoverySource.NoProfile); + } + + private static async Task StartAsync( + string profileId, + string reason, + RuntimeRestartRecoverySource source, + Func> startCore, + Action log, + CancellationToken cancellationToken) + { + var result = await startCore(profileId, cancellationToken); + if (result.Success) + { + log($"Core runtime restored successfully using {profileId}."); + return new(source, profileId); + } + + log($"{reason} requested Core runtime recovery, but Core failed to start using {profileId} (code: {result.Code}). No further restart will be attempted."); + return new(RuntimeRestartRecoverySource.StartFailed, profileId, result.Code); + } + + private static string? Normalize(string? profileId) => + string.IsNullOrWhiteSpace(profileId) ? null : profileId; +} diff --git a/v2rayN/v2rayN.WebAPI/Services/ShutdownCleanupSequence.cs b/v2rayN/v2rayN.WebAPI/Services/ShutdownCleanupSequence.cs new file mode 100644 index 00000000..03a26a60 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/ShutdownCleanupSequence.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; + +namespace v2rayN.WebAPI.Services; + +internal static class RuntimeShutdownBudgets +{ + // Runtime cleanup owns 20 seconds; the host retains 5 seconds, systemd is + // configured to 30 seconds, and the native --stop launcher waits 5 seconds more. + public static readonly TimeSpan RuntimeCleanup = TimeSpan.FromSeconds(20); + public static readonly TimeSpan HostShutdown = TimeSpan.FromSeconds(25); + public static readonly TimeSpan LauncherWait = TimeSpan.FromSeconds(35); + // Used when the host shutdown budget was already exhausted before cleanup + // started, for example while the web server drained long-lived connections. + public static readonly TimeSpan LateCleanup = TimeSpan.FromSeconds(5); +} + +internal static class ShutdownDiagnostics +{ + private static string? _currentStage; + + public static string? CurrentStage => Volatile.Read(ref _currentStage); + + public static void SetStage(string? stage) => Volatile.Write(ref _currentStage, stage); +} + +internal sealed class ShutdownDeadline : IDisposable +{ + private readonly TimeSpan _budget; + private readonly Stopwatch _elapsed = Stopwatch.StartNew(); + private readonly CancellationTokenSource _deadlineCancellation; + private readonly CancellationTokenSource _linkedCancellation; + + public ShutdownDeadline(TimeSpan budget, CancellationToken cancellationToken) + { + if (budget <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(budget)); + } + + _budget = budget; + _deadlineCancellation = new CancellationTokenSource(budget); + _linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _deadlineCancellation.Token); + } + + public CancellationToken Token => _linkedCancellation.Token; + + public TimeSpan Remaining + { + get + { + var remaining = _budget - _elapsed.Elapsed; + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + } + + public void Dispose() + { + _linkedCancellation.Dispose(); + _deadlineCancellation.Dispose(); + } +} + +internal sealed record ShutdownCleanupStep( + string Name, + Func> ExecuteAsync); + +internal static class ShutdownCleanupSequence +{ + public static async Task RunAsync( + IEnumerable steps, + TimeSpan budget, + CancellationToken cancellationToken, + Action log, + Action? stageChanged = null) + { + if (cancellationToken.IsCancellationRequested) + { + // The host token can already be canceled when cleanup starts, for + // example while the web server drains long-lived stream connections. + // Cleanup is still worth a short, fresh window: it stops the Core + // child and saves shared state instead of skipping both. + log("Shutdown started after the host shutdown budget was exhausted; running a reduced cleanup with a fresh budget."); + budget = RuntimeShutdownBudgets.LateCleanup; + cancellationToken = CancellationToken.None; + } + + using var deadline = new ShutdownDeadline(budget, cancellationToken); + foreach (var step in steps) + { + var remaining = deadline.Remaining; + if (remaining <= TimeSpan.Zero) + { + log($"Shutdown deadline expired before '{step.Name}'; all later cleanup was skipped."); + return false; + } + + stageChanged?.Invoke(step.Name); + try + { + // Run the synchronous prefix on the thread pool too, so a blocking cleanup + // implementation cannot prevent the overall deadline from being enforced. + var execution = Task.Run(async () => await step.ExecuteAsync(deadline)); + var completed = await execution.WaitAsync(remaining, deadline.Token); + if (!completed) + { + log($"Shutdown stopped at '{step.Name}'; all later cleanup was skipped."); + return false; + } + stageChanged?.Invoke(null); + } + catch (TimeoutException) + { + log($"Shutdown deadline expired during '{step.Name}'; all later cleanup was skipped."); + return false; + } + catch (OperationCanceledException) when (deadline.Token.IsCancellationRequested) + { + log($"Shutdown was canceled during '{step.Name}'; all later cleanup was skipped."); + return false; + } + catch (Exception exception) + { + log($"Shutdown cleanup failed at '{step.Name}': {exception.Message}; all later cleanup was skipped."); + return false; + } + } + + stageChanged?.Invoke(null); + return true; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayHostedService.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayHostedService.cs new file mode 100644 index 00000000..c7b40901 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayHostedService.cs @@ -0,0 +1,11 @@ +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Services; + +public sealed class V2rayHostedService(V2rayRuntime runtime, WebAuthService webAuth) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) => + runtime.InitializeAsync(cancellationToken, suppressCoreAutostart: webAuth.SetupRequired); + + public Task StopAsync(CancellationToken cancellationToken) => runtime.ShutdownAsync(cancellationToken); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Backup.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Backup.cs new file mode 100644 index 00000000..e117e82e --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Backup.cs @@ -0,0 +1,848 @@ +using System.IO.Compression; +using System.Text.Json; +using SQLite; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Handler; +using ServiceLib.Helper; +using ServiceLib.Manager; +using ServiceLib.Models.Configs; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + private readonly object _restoreShutdownGate = new(); + private Task? _restoreShutdownTask; + private const long MaxBackupArchiveBytes = 64L * 1024 * 1024; + private const long MaxBackupExpandedBytes = 256L * 1024 * 1024; + private const int MaxBackupEntries = 2048; + internal const string WebAuthFileName = "WebAPI-auth.json"; + private const string RestoreRuntimeStateFileName = "v2rayn-WebAPI-restore-state.json"; + private NativeWebRestoreReplacement? _nativeRestoreReplacement; + + public NativeWebRestoreReplacement? NativeRestoreReplacement => _nativeRestoreReplacement; + + public WebDavSettingsView GetWebDavSettings() => new( + Config.WebDavItem.Url, + Config.WebDavItem.UserName, + Config.WebDavItem.DirName, + !string.IsNullOrEmpty(Config.WebDavItem.Password)); + + public async Task UpdateWebDavSettingsAsync(WebDavSettingsInput input) + { + await _mutations.RunAsync(async () => + { + Config.WebDavItem.Url = input.Url?.Trim(); + Config.WebDavItem.UserName = input.UserName?.Trim(); + if (input.Password is not null) + { + Config.WebDavItem.Password = input.Password; + } + Config.WebDavItem.DirName = input.DirName?.Trim(); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + return OperationView.Ok(ApiMessageKeys.WebDavSettingsSaved); + } + + public async Task CheckWebDavAsync() + { + var success = await WebDavManager.Instance.CheckConnection(); + if (!success) + { + AddLog("backup", WebDavManager.Instance.GetLastError()); + } + return success + ? OperationView.Ok(ApiMessageKeys.WebDavCheckSucceeded) + : OperationView.Fail("webdav_check_failed", ApiMessageKeys.WebDavCheckFailed); + } + + public async Task<(OperationView Result, string? FilePath)> CreateBackupArchiveAsync() + { + await _mutations.RunAsync(async () => + { + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + await ProfileExManager.Instance.SaveTo(); + await StatisticsManager.Instance.SaveTo(); + }); + + var archivePath = Utils.GetBackupPath($"backup_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.zip"); + var tempRoot = Utils.GetTempPath($"backup_{Utils.GetGuid(false)}"); + var tempConfigPath = Path.Combine(tempRoot, "guiConfigs"); + try + { + CopyConfigForBackup(Utils.GetConfigPath(), tempConfigPath); + if (!FileUtils.CreateFromDirectory(tempRoot, archivePath)) + { + return (OperationView.Fail("backup_create_failed", ApiMessageKeys.BackupArchiveInvalid), null); + } + if (new FileInfo(archivePath).Length > MaxBackupArchiveBytes) + { + File.Delete(archivePath); + return (OperationView.Fail("backup_archive_too_large", ApiMessageKeys.BackupArchiveInvalid), null); + } + return (OperationView.Ok(ApiMessageKeys.BackupCreated, new { fileName = Path.GetFileName(archivePath) }), archivePath); + } + catch + { + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + return (OperationView.Fail("backup_create_failed", ApiMessageKeys.BackupArchiveInvalid), null); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + public async Task BackupToWebDavAsync() + { + var (result, archivePath) = await CreateBackupArchiveAsync(); + if (!result.Success || archivePath is null) + { + return result; + } + + try + { + if (!await WebDavManager.Instance.PutFile(archivePath)) + { + AddLog("backup", WebDavManager.Instance.GetLastError()); + return OperationView.Fail("webdav_backup_failed", ApiMessageKeys.WebDavCheckFailed); + } + return OperationView.Ok(ApiMessageKeys.WebDavBackupSucceeded, new { fileName = Path.GetFileName(archivePath) }); + } + finally + { + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + } + } + + public async Task RestoreFromWebDavAsync(CancellationToken cancellationToken) + { + var archivePath = Utils.GetTempPath($"restore_{Utils.GetGuid(false)}.zip"); + if (!await WebDavManager.Instance.GetRawFile(archivePath)) + { + AddLog("backup", WebDavManager.Instance.GetLastError()); + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + return OperationView.Fail("webdav_restore_download_failed", ApiMessageKeys.WebDavRestoreFailed); + } + return await RestoreBackupArchiveAsync(archivePath, cancellationToken); + } + + public async Task RestoreFromUploadAsync(Stream archive, CancellationToken cancellationToken) + { + var archivePath = Utils.GetTempPath($"restore_{Utils.GetGuid(false)}.zip"); + try + { + await using (var output = new FileStream(archivePath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + await CopyWithLimitAsync(archive, output, MaxBackupArchiveBytes, cancellationToken); + } + return await RestoreBackupArchiveAsync(archivePath, cancellationToken); + } + catch + { + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + throw; + } + } + + private async Task RestoreBackupArchiveAsync(string archivePath, CancellationToken cancellationToken) + { + var stagingRoot = Utils.GetTempPath($"restore_stage_{Utils.GetGuid(false)}"); + var databaseClosed = false; + RuntimeOperationCoordinator.Lease? operation = null; + var restoreStateWritten = false; + RuntimeRestartIntent? restoreState = null; + try + { + operation = await _operations.EnterExclusiveAsync(cancellationToken); + cancellationToken = operation.Token; + if (!IsSafeBackupArchive(archivePath)) + { + return OperationView.Fail("backup_archive_invalid", ApiMessageKeys.BackupArchiveInvalid); + } + + Directory.CreateDirectory(stagingRoot); + if (!TryExtractBackupConfig(archivePath, stagingRoot) + || !IsValidBackupConfig(Path.Combine(stagingRoot, Global.ConfigFileName))) + { + return OperationView.Fail("backup_archive_invalid", ApiMessageKeys.BackupArchiveInvalid); + } + var databasePath = Path.Combine(stagingRoot, "guiNDB.db"); + if (File.Exists(databasePath) && !BackupDatabaseCompatibility.IsCompatible(databasePath, out var databaseError)) + { + AddLog("backup", $"Restore preflight rejected guiNDB.db: {databaseError}"); + return OperationView.Fail("backup_database_incompatible", ApiMessageKeys.BackupDatabaseIncompatible); + } + + _nativeRestoreReplacement = null; + var preflight = EvaluateNativeRestorePreflight(); + return await BackupRestorePreflightGate.ExecuteAsync(preflight, async () => + { + _nativeRestoreReplacement = preflight.Replacement; + var (backupResult, safetyBackupPath) = await CreateBackupArchiveAsync(); + if (!backupResult.Success || safetyBackupPath is null) + { + return OperationView.Fail("backup_safety_copy_failed", ApiMessageKeys.BackupRestoreFailed); + } + + var previousRuntime = CurrentCoreRuntime; + restoreState = new RuntimeRestartIntent( + previousRuntime.State == CoreRuntimeState.Running, + previousRuntime.ProfileId); + await WriteRestoreRuntimeStateAsync(restoreState); + restoreStateWritten = true; + SetCoreRuntime(previousRuntime with { State = CoreRuntimeState.Stopping, LastFailure = null }); + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(cancellationToken); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + await ProfileExManager.Instance.SaveTo(); + await StatisticsManager.Instance.SaveTo(); + StatisticsManager.Instance.Close(); + await _mutations.RunAsync(() => EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)), cancellationToken); + await SQLiteHelper.Instance.DisposeDbConnectionAsync(); + databaseClosed = true; + + var configPath = Utils.GetConfigPath(); + var configParent = Path.GetDirectoryName(configPath) + ?? throw new InvalidOperationException("The configuration directory has no parent directory."); + var candidatePath = Path.Combine(configParent, $".guiConfigs-restore-{Guid.NewGuid():N}"); + var displacedPath = Path.Combine(configParent, $".guiConfigs-before-restore-{Guid.NewGuid():N}"); + try + { + PrepareRestoredConfigDirectory(stagingRoot, configPath, candidatePath); + ReplaceConfigDirectory(candidatePath, configPath, displacedPath); + } + finally + { + TryDeleteRestoreDirectory(candidatePath, "restore candidate"); + TryDeleteRestoreDirectory(displacedPath, "displaced configuration"); + } + + _restoring = true; + _operations.RejectNewOperations(); + RequestHostShutdownForRestore(); + return OperationView.Ok(ApiMessageKeys.BackupRestoreStarted, + new { restartRequired = true, safetyBackup = Path.GetFileName(safetyBackupPath) }); + }); + } + catch (Exception exception) + { + AddLog("backup", $"Restore failed: {exception.Message}"); + if (databaseClosed) + { + _restoring = true; + _operations.RejectNewOperations(); + RequestHostShutdownForRestore(); + } + else if (restoreStateWritten) + { + TryDeleteRestoreRuntimeState(); + if (restoreState is not null) + { + await RecoverRuntimeAfterFailedRestoreAsync( + restoreState, + GetActiveCoreProcessIds(), + (processIds, failure) => SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = failure, + }), + StartCoreAsync, + message => AddLog("backup", message), + CancellationToken.None); + } + } + return OperationView.Fail("backup_restore_failed", ApiMessageKeys.BackupRestoreFailed); + } + finally + { + if (operation is not null) + { + await operation.DisposeAsync(); + } + try + { + if (Directory.Exists(stagingRoot)) + { + Directory.Delete(stagingRoot, true); + } + } + catch (Exception exception) + { + AddLog("backup", $"Restore staging cleanup failed: {exception.Message}"); + } + finally + { + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + } + } + } + + private NativeWebRestorePreflightResult EvaluateNativeRestorePreflight() + { + var processPath = Environment.ProcessPath; + var processArguments = Environment.GetCommandLineArgs(); + return NativeWebRestorePreflight.Evaluate( + _configuration, + isLinux: OperatingSystem.IsLinux(), + isWindows: OperatingSystem.IsWindows(), + externalSupervisor: LauncherEnvironment.IsSystemdManagedDeployment(), + containerEnvironment: LauncherEnvironment.IsCurrentContainerEnvironment(), + setsidPath: OperatingSystem.IsLinux() ? LinuxXdgBrowserOpener.FindExecutable("setsid") : null, + processPath: processPath, + managedEntryPoint: WebReplacementCommand.GetManagedEntryPoint(processPath, processArguments), + hostArguments: GetCurrentHostArguments(), + coreWasRunning: CurrentCoreRuntime.State == CoreRuntimeState.Running); + } + + // Restore failed before database replacement: bring the pre-restore Core runtime intent + // back. Only Web-captured Core process identities prove that the previous Core still + // exists; listener or port occupancy is never used as process ownership. When no tracked + // process remains, the normal start path attempts the restart and its launch preflight + // reports a port conflict (proxy_port_in_use) if another program now owns the port. + internal static async Task RecoverRuntimeAfterFailedRestoreAsync( + RuntimeRestartIntent restoreState, + int[] remainingCoreProcessIds, + Action markRuntimeFaulted, + Func> startCore, + Action log, + CancellationToken cancellationToken) + { + if (!restoreState.WasRunning) + { + return null; + } + + if (remainingCoreProcessIds.Length > 0) + { + const string failure = "Restore was canceled before database replacement; the Web runtime could not confirm that the previous Core had stopped."; + markRuntimeFaulted(remainingCoreProcessIds, failure); + log(failure); + return null; + } + + var restart = await startCore(restoreState.PreferredProfileId, cancellationToken); + if (!restart.Success) + { + log($"Restore was canceled before database replacement, and the previous Core runtime intent could not be restored: {restart.Code}"); + } + return restart; + } + + private void TryDeleteRestoreDirectory(string path, string description) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (Exception exception) + { + AddLog("backup", $"Restore {description} cleanup failed: {exception.Message}"); + } + } + + private void RequestHostShutdownForRestore() + { + Interlocked.Exchange(ref _restoreAndRestartRequested, 1); + lock (_restoreShutdownGate) + { + if (_restoreShutdownTask is { IsCompleted: false }) + { + return; + } + var stoppingToken = _lifetime.ApplicationStopping; + _restoreShutdownTask = Task.Run(async () => + { + try + { + // Give the restore API response time to flush; process handoff below + // waits for the actual instance lock, never for this delay. + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + if (!stoppingToken.IsCancellationRequested) + { + _lifetime.StopApplication(); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // The host is already stopping. + } + }); + } + } + + private static string RestoreRuntimeStatePath => Path.Combine(Utils.StartupPath(), RestoreRuntimeStateFileName); + internal static string WebUpdateRuntimeStatePath => Utils.GetTempPath("WebAPI-update-runtime-state.json"); + + private static async Task WriteRestoreRuntimeStateAsync(RuntimeRestartIntent state) + { + await WriteRuntimeIntentAsync(RestoreRuntimeStatePath, state); + } + + internal static async Task WriteRuntimeIntentAsync(string path, RuntimeRestartIntent state) + { + var temporary = path + $".{Guid.NewGuid():N}.tmp"; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(state)); + File.Move(temporary, path, overwrite: true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + private static Task LoadRestoreRuntimeStateAsync(CancellationToken cancellationToken) => + LoadRuntimeRestartIntentAsync(RestoreRuntimeStatePath, cancellationToken); + + internal static async Task LoadRuntimeRestartIntentAsync(string path, CancellationToken cancellationToken) + { + RequeueAbandonedRestoreRuntimeClaim(path); + try + { + if (!File.Exists(path)) return null; + var json = await File.ReadAllTextAsync(path, cancellationToken); + return JsonSerializer.Deserialize(json); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + catch (Exception exception) when (exception is IOException or JsonException) + { + Logging.SaveLog("Restore runtime-state marker could not be read: " + exception.Message); + return null; + } + } + + internal static Task CommitRestoreRuntimeStateAsync() => CommitRestoreRuntimeStateAsync(RestoreRuntimeStatePath); + + internal static Task CommitRestoreRuntimeStateAsync(string path) + { + try + { + // Keep the primary marker until every abandoned claim has been removed. If + // cleanup fails or the process crashes here, startup still has a recoverable + // authoritative marker and can retry cleanup. + DeleteRestoreRuntimeClaims(path, requireComplete: true); + if (File.Exists(path)) File.Delete(path); + if (File.Exists(path)) + { + throw new IOException("The committed restore runtime-state marker still exists."); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Logging.SaveLog("Committed restore runtime-state marker could not be removed: " + exception.Message); + return Task.FromException(exception); + } + return Task.CompletedTask; + } + + private static void RequeueAbandonedRestoreRuntimeClaim(string path) + { + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) return; + try + { + var pattern = Path.GetFileName(path) + ".consuming-*"; + var claims = Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly) + .OrderBy(claim => claim, StringComparer.Ordinal) + .Select(ReadRestoreRuntimeClaim) + .ToArray(); + + // A valid primary marker is authoritative when an interrupted consume left + // duplicate claims behind. An invalid primary must not hide the only valid + // recoverable claim. + if (TryReadRestoreRuntimeIntent(path) is not null) + { + DeleteRestoreRuntimeClaims(path); + return; + } + + // A consuming claim is a renamed copy of the marker. Prefer the most recently + // written valid intent; ties prefer the more informative running/profile state, + // then the ordinal filename. Never rely on directory enumeration order. + var selected = claims + .Where(claim => claim.Intent is not null) + .OrderByDescending(claim => claim.LastWriteTimeUtc) + .ThenByDescending(claim => claim.Intent!.WasRunning) + .ThenByDescending(claim => !string.IsNullOrWhiteSpace(claim.Intent!.PreferredProfileId)) + .ThenBy(claim => claim.Path, StringComparer.Ordinal) + .FirstOrDefault(); + + if (selected is not null) + { + try + { + File.Move(selected.Path, path, overwrite: true); + Logging.SaveLog("Recovered the newest valid interrupted restore runtime-state marker claim."); + DeleteRestoreRuntimeClaims(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (TryReadRestoreRuntimeIntent(path) is not null) + { + // Another writer restored a valid primary marker; it is authoritative. + DeleteRestoreRuntimeClaims(path); + return; + } + + // Keep all claims when replacement failed: one of them may be the only + // valid copy needed by a later startup. + Logging.SaveLog("Abandoned restore runtime-state claim could not be moved to the primary marker: " + exception.Message); + return; + } + + return; + } + + DeleteRestoreRuntimeClaims(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Logging.SaveLog("Abandoned restore runtime-state claim could not be recovered: " + exception.Message); + } + } + + private static RuntimeRestartIntent? TryReadRestoreRuntimeIntent(string path) + { + try + { + if (!File.Exists(path)) return null; + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + return null; + } + } + + private static RestoreRuntimeClaim ReadRestoreRuntimeClaim(string claimPath) + { + RuntimeRestartIntent? intent = null; + try + { + intent = JsonSerializer.Deserialize(File.ReadAllText(claimPath)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + Logging.SaveLog($"Ignoring invalid abandoned restore runtime-state claim {Path.GetFileName(claimPath)}: {exception.Message}"); + } + + DateTime lastWriteTimeUtc; + try { lastWriteTimeUtc = File.GetLastWriteTimeUtc(claimPath); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + lastWriteTimeUtc = DateTime.MinValue; + } + return new RestoreRuntimeClaim(claimPath, intent, lastWriteTimeUtc); + } + + private static void DeleteRestoreRuntimeClaims(string path, bool requireComplete = false) + { + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) return; + try + { + var pattern = Path.GetFileName(path) + ".consuming-*"; + foreach (var claim in Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly)) + { + try + { + File.Delete(claim); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (requireComplete) throw; + Logging.SaveLog("Committed abandoned restore runtime-state claim could not be removed: " + exception.Message); + } + } + if (requireComplete && Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly).Any()) + { + throw new IOException("One or more abandoned restore runtime-state claims still exist."); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (requireComplete) throw; + Logging.SaveLog("Committed abandoned restore runtime-state claims could not be removed: " + exception.Message); + } + } + + private sealed record RestoreRuntimeClaim(string Path, RuntimeRestartIntent? Intent, DateTime LastWriteTimeUtc); + + private static void TryDeleteRestoreRuntimeState() => + _ = CommitRestoreRuntimeStateAsync(RestoreRuntimeStatePath); + + internal static Task DeleteRuntimeIntentAsync(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Logging.SaveLog("Runtime restart intent could not be removed: " + exception.Message); + } + return Task.CompletedTask; + } + + private async Task WaitForRestoreShutdownRequestAsync(CancellationToken cancellationToken) + { + Task? restartTask; + lock (_restoreShutdownGate) + { + restartTask = _restoreShutdownTask; + } + if (restartTask is not null) + { + await restartTask.WaitAsync(cancellationToken); + } + } + + internal static bool IsSafeBackupArchive(string archivePath) + { + try + { + var archiveInfo = new FileInfo(archivePath); + if (!archiveInfo.Exists || archiveInfo.Length is <= 0 or > MaxBackupArchiveBytes) + { + return false; + } + + using var archive = ZipFile.OpenRead(archivePath); + var configFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + if (archive.Entries.Count is 0 or > MaxBackupEntries) + { + return false; + } + + long expandedBytes = 0; + foreach (var entry in archive.Entries) + { + if (entry.Length < 0 || entry.Length > MaxBackupExpandedBytes) + { + return false; + } + expandedBytes = checked(expandedBytes + entry.Length); + if (expandedBytes > MaxBackupExpandedBytes) + { + return false; + } + } + + foreach (var entry in archive.Entries) + { + var relative = entry.FullName.Replace('\\', '/'); + var segments = relative.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (Path.IsPathRooted(relative) + || segments.Any(segment => segment is "." or "..") + || relative.Contains(':') + || ((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000) + { + return false; + } + + if (entry.Length == 0) + { + continue; + } + + var guiConfigIndex = Array.IndexOf(segments, "guiConfigs"); + if (guiConfigIndex < 0 || segments.Length != guiConfigIndex + 2) + { + return false; + } + if (!configFiles.Add(segments[^1])) + { + return false; + } + } + + return configFiles.Contains(Global.ConfigFileName); + } + catch + { + return false; + } + } + + internal static bool TryExtractBackupConfig(string archivePath, string destinationDirectory) + { + try + { + using var archive = ZipFile.OpenRead(archivePath); + Directory.CreateDirectory(destinationDirectory); + foreach (var entry in archive.Entries) + { + if (entry.Length == 0) + { + continue; + } + + var segments = entry.FullName.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + var guiConfigIndex = Array.IndexOf(segments, "guiConfigs"); + if (guiConfigIndex < 0 || segments.Length != guiConfigIndex + 2) + { + return false; + } + + var fileName = segments[^1]; + if (string.Equals(fileName, WebAuthFileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + using var source = entry.Open(); + using var target = new FileStream( + Path.Combine(destinationDirectory, fileName), + FileMode.CreateNew, + FileAccess.Write, + FileShare.None); + source.CopyTo(target); + } + + return File.Exists(Path.Combine(destinationDirectory, Global.ConfigFileName)); + } + catch + { + return false; + } + } + + internal static void CopyConfigForBackup(string sourceDirectory, string destinationDirectory) => + FileUtils.CopyDirectory(sourceDirectory, destinationDirectory, recursive: false, overwrite: true, ignoredName: WebAuthFileName); + + internal static void PrepareRestoredConfigDirectory( + string extractedConfigDirectory, + string currentConfigDirectory, + string candidateDirectory) + { + if (Directory.Exists(currentConfigDirectory)) + { + CopyConfigDirectory(currentConfigDirectory, candidateDirectory); + } + else + { + Directory.CreateDirectory(candidateDirectory); + } + + foreach (var file in Directory.EnumerateFiles(extractedConfigDirectory, "*", SearchOption.TopDirectoryOnly)) + { + if (string.Equals(Path.GetFileName(file), WebAuthFileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + File.Copy(file, Path.Combine(candidateDirectory, Path.GetFileName(file)), overwrite: true); + } + } + + private static void CopyConfigDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + foreach (var sourceFile in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.TopDirectoryOnly)) + { + File.Copy(sourceFile, Path.Combine(destinationDirectory, Path.GetFileName(sourceFile)), overwrite: true); + } + foreach (var sourceSubdirectory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.TopDirectoryOnly)) + { + CopyConfigDirectory(sourceSubdirectory, Path.Combine(destinationDirectory, Path.GetFileName(sourceSubdirectory))); + } + } + + internal static void ReplaceConfigDirectory(string candidateDirectory, string configDirectory, string displacedDirectory) + { + var hadCurrentDirectory = Directory.Exists(configDirectory); + if (hadCurrentDirectory) + { + Directory.Move(configDirectory, displacedDirectory); + } + + try + { + Directory.Move(candidateDirectory, configDirectory); + } + catch + { + if (hadCurrentDirectory && Directory.Exists(displacedDirectory) && !Directory.Exists(configDirectory)) + { + Directory.Move(displacedDirectory, configDirectory); + } + throw; + } + } + + private static bool IsValidBackupConfig(string configPath) + { + try + { + using var document = JsonDocument.Parse( + File.ReadAllText(configPath), + new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip }); + return document.RootElement.ValueKind == JsonValueKind.Object + && JsonSerializer.Deserialize(document.RootElement.GetRawText(), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }) is not null; + } + catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException) + { + return false; + } + } + + private static async Task CopyWithLimitAsync(Stream input, Stream output, long limit, CancellationToken cancellationToken) + { + var buffer = new byte[81920]; + long total = 0; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + return; + } + total += read; + if (total > limit) + { + throw new InvalidDataException("The backup archive exceeds the upload size limit."); + } + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.CoreUpdate.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.CoreUpdate.cs new file mode 100644 index 00000000..57849d93 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.CoreUpdate.cs @@ -0,0 +1,1473 @@ +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Manager; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Dto; +using ServiceLib.Models.Entities; +using ServiceLib.Resx; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + private const string GeoFilesUpdateTarget = "GeoFiles"; + private const string WebUpdateTarget = "v2rayN.WebAPI"; + internal static string NormalizeWebUpdateTarget(string target) => target == "v2rayN.Web" ? WebUpdateTarget : target; + + internal static List? NormalizeWebUpdateTargets(IEnumerable? targets) => + targets?.Select(NormalizeWebUpdateTarget).Distinct(StringComparer.Ordinal).ToList(); + private readonly object _updateTaskGate = new(); + private readonly GeoFilesUpdateGate _geoUpdateGate = new(); + private readonly ConcurrentDictionary _coreUpdateTasks = new(); + private readonly ConcurrentDictionary _updateProgress = new(StringComparer.Ordinal); + private Task? _geoUpdateTask; + private Task? _coreUpdateBatchTask; + + internal bool GetUpdatePreRelease(string target, bool? preRelease = null) => + preRelease ?? (Config.CheckUpdateItem.CheckPreReleaseCoreTypes?.Contains(target, StringComparer.Ordinal) ?? false); + + public CoreUpdateSettingsView GetCoreUpdateSettings() + { + var manager = CoreInfoManager.Instance; + var selectedTypes = Config.CheckUpdateItem.SelectedCoreTypes; + var targets = GetAvailableWebCoreUpdateTypes() + .Select(coreType => + { + var isSupported = manager.IsCheckUpdateSupported(coreType) + && manager.GetCoreInfo(coreType) is { } info + && IsCurrentPlatformDownloadSupported(info); + var canInstall = isSupported + && coreType != ECoreType.v2rayN + && CoreUpdatePackageStager.SupportsCore(coreType); + var unsupportedReason = coreType == ECoreType.v2rayN + ? "maintenance.webUpdateUnsupported" + : canInstall ? null : "maintenance.updateUnsupported"; + var storageName = coreType.ToString(); + return new CoreUpdateTargetView( + storageName, + GetCoreUpdateNameKey(coreType), + isSupported, + canInstall, + manager.GetCheckPreRelease(coreType, preRelease: true), + selectedTypes?.Contains(storageName, StringComparer.Ordinal) ?? true, + unsupportedReason); + }) + .ToArray(); + + var preReleaseNames = GetAvailablePreReleaseUpdateTargets(); + return new CoreUpdateSettingsView( + targets, + selectedTypes?.Contains(GeoFilesUpdateTarget, StringComparer.Ordinal) ?? true, + GetUpdatePreRelease(WebUpdateTarget), + Config.CheckUpdateItem.UpdateViaProxy, + Config.CheckUpdateItem.CheckPreReleaseCoreTypes? + .Where(preReleaseNames.Contains) + .Distinct(StringComparer.Ordinal) + .ToArray() ?? []); + } + + public IReadOnlyList GetCoreUpdateProgress() => + _updateProgress.Values + .Append(GetWebUpdateProgress()) + .Where(item => item is not null) + .Select(item => item!) + .GroupBy(item => item.CoreType, StringComparer.Ordinal) + .Select(group => group.Last()) + .OrderBy(item => item.CoreType, StringComparer.Ordinal) + .ToArray(); + + public async Task SaveCoreUpdateSettingsAsync(CoreUpdateSettingsInput input) + { + var supportedNames = GetAvailableWebCoreUpdateTypes() + .Select(type => type.ToString()) + .Append(WebUpdateTarget) + .ToHashSet(StringComparer.Ordinal); + var requestedNames = (input.SelectedCoreTypes ?? []).Select(NormalizeWebUpdateTarget) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var preReleaseNames = GetAvailablePreReleaseUpdateTargets(); + // Older clients still submit a single switch. An explicit per-target list takes + // precedence, including an empty list, and is independent of update selections. + var requestedPreReleaseNames = NormalizeWebUpdateTargets(input.CheckPreReleaseCoreTypes)?.ToArray() + ?? (input.PreRelease ? preReleaseNames.ToArray() : []); + if (requestedNames.Any(name => name != GeoFilesUpdateTarget && !supportedNames.Contains(name)) + || requestedPreReleaseNames.Any(name => !preReleaseNames.Contains(name))) + { + return OperationView.Fail("core_update_settings_invalid", ApiMessageKeys.CommonInvalidInput); + } + + await _mutations.RunAsync(async () => + { + var previous = NormalizeWebUpdateTargets(Config.CheckUpdateItem.SelectedCoreTypes) ?? []; + // Keep selections from backups that this platform/Web updater cannot display, + // just as the desktop updater keeps hidden Core configuration untouched. + var hiddenSelections = previous.Where(name => name != GeoFilesUpdateTarget && !supportedNames.Contains(name)); + Config.CheckUpdateItem.SelectedCoreTypes = hiddenSelections + .Concat(requestedNames) + .Distinct(StringComparer.Ordinal) + .ToList(); + var hiddenPreReleaseSelections = (NormalizeWebUpdateTargets(Config.CheckUpdateItem.CheckPreReleaseCoreTypes) ?? []) + .Where(name => !preReleaseNames.Contains(name)); + Config.CheckUpdateItem.CheckPreReleaseCoreTypes = hiddenPreReleaseSelections + .Concat(requestedPreReleaseNames) + .Distinct(StringComparer.Ordinal) + .ToList(); + Config.CheckUpdateItem.UpdateViaProxy = input.UseProxy; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + + return OperationView.Ok(ApiMessageKeys.CoreUpdateSettingsSaved); + } + + public async Task> CheckCoreUpdateAsync( + ECoreType coreType, + bool? preRelease, + bool? useProxy, + CancellationToken cancellationToken) + { + if (!CanCheckCoreUpdate(coreType)) + { + return ApiEnvelope.Fail( + "core_update_unsupported", + ApiMessageKeys.CoreUpdateUnsupported); + } + + var checkedUpdate = await CheckCoreUpdateResultAsync(coreType, preRelease, useProxy, cancellationToken); + var result = checkedUpdate.Result; + if (result.Success && result.Version is not null) + { + return ApiEnvelope.Ok( + new CoreUpdateCheckView(true, result.Version.ToString()), + ApiMessageKeys.CoreUpdateAvailable); + } + if (checkedUpdate.IsUpToDate) + { + return ApiEnvelope.Ok( + new CoreUpdateCheckView(false, result.Version?.ToString(), IsUpToDate: true, Detail: result.Msg), + ApiMessageKeys.CoreUpdateCurrent); + } + + AddLog("update", $"{coreType} update check failed: {result.Msg}"); + return ApiEnvelope.Fail( + "core_update_check_failed", + ApiMessageKeys.CoreUpdateCheckFailed, + new CoreUpdateCheckView(false, null, Detail: result.Msg)); + } + + public Task> CheckXrayUpdateAsync( + bool preRelease, + bool useProxy, + CancellationToken cancellationToken) => + CheckCoreUpdateAsync(ECoreType.Xray, preRelease, useProxy, cancellationToken); + + public OperationView StartCoreUpdate(ECoreType coreType, bool? preRelease = null, bool? useProxy = null) + { + if (!CanInstallCoreUpdate(coreType)) + { + return OperationView.Fail("core_update_unsupported", ApiMessageKeys.CoreUpdateUnsupported); + } + + lock (_updateTaskGate) + { + if (IsUpdateRunningLocked()) + { + return OperationView.Fail("core_update_busy", ApiMessageKeys.CoreUpdateBusy); + } + + var selected = Config.CheckUpdateItem.SelectedCoreTypes; + if (selected is not null && !selected.Contains(coreType.ToString(), StringComparer.Ordinal)) + { + return OperationView.Fail("core_update_not_selected", ApiMessageKeys.CoreUpdateUnsupported); + } + + var task = Task.Run(() => RunCoreUpdateAsync( + coreType, + GetUpdatePreRelease(coreType.ToString(), preRelease), + useProxy ?? Config.CheckUpdateItem.UpdateViaProxy)); + _coreUpdateTasks[coreType] = task; + } + + return OperationView.Ok(ApiMessageKeys.CoreUpdateStarted, new { coreType = coreType.ToString() }); + } + + public bool StartXrayUpdate(bool preRelease, bool useProxy) => + StartCoreUpdate(ECoreType.Xray, preRelease, useProxy).Success; + + public OperationView StartSelectedCoreUpdateBatch(bool apply) + { + var selected = Config.CheckUpdateItem.SelectedCoreTypes; + var webSupported = WebUpdatePackageStager.IsSupportedRid(WebBuildIdentity.Current.Rid); + var selectedNames = selected is null + ? GetAvailableWebCoreUpdateTypes().Select(coreType => coreType.ToString()) + .Append(GeoFilesUpdateTarget) + .Concat(webSupported ? [WebUpdateTarget] : []) + .ToArray() + : selected.ToArray(); + var targets = GetAvailableWebCoreUpdateTypes() + .Where(coreType => selectedNames.Contains(coreType.ToString(), StringComparer.Ordinal)) + .ToArray(); + var includeGeoFiles = selectedNames.Contains(GeoFilesUpdateTarget, StringComparer.Ordinal); + var includeWeb = webSupported && selectedNames.Contains(WebUpdateTarget, StringComparer.Ordinal); + if (targets.Length == 0 && !includeGeoFiles && !includeWeb) + { + return OperationView.Fail("core_update_batch_empty", ApiMessageKeys.CommonInvalidInput); + } + + lock (_updateTaskGate) + { + if (IsUpdateRunningLocked()) + { + return OperationView.Fail("core_update_busy", ApiMessageKeys.CoreUpdateBusy); + } + + _coreUpdateBatchTask = Task.Run(() => RunSelectedCoreUpdateBatchAsync( + targets, + includeGeoFiles, + includeWeb, + apply, + Config.CheckUpdateItem.UpdateViaProxy)); + } + + var targetNames = targets.Select(coreType => coreType.ToString()).ToList(); + if (includeGeoFiles) targetNames.Add(GeoFilesUpdateTarget); + if (includeWeb) targetNames.Add(WebUpdateTarget); + return OperationView.Ok(ApiMessageKeys.CoreUpdateBatchStarted, new { apply, targets = targetNames }); + } + + public OperationView StartGeoUpdate(bool? useProxy = null) + { + var selected = Config.CheckUpdateItem.SelectedCoreTypes; + if (selected is not null && !selected.Contains(GeoFilesUpdateTarget, StringComparer.Ordinal)) + { + return OperationView.Fail("geo_update_not_selected", ApiMessageKeys.GeoUpdateNotSelected); + } + + lock (_updateTaskGate) + { + if (IsUpdateRunningLocked()) + { + return OperationView.Fail("geo_update_busy", ApiMessageKeys.GeoUpdateBusy); + } + + var proxy = useProxy ?? Config.CheckUpdateItem.UpdateViaProxy; + _geoUpdateTask = Task.Run(() => RunGeoUpdateAsync(proxy)); + } + return OperationView.Ok(ApiMessageKeys.GeoUpdateStarted); + } + + internal IReadOnlyList GetRunningCoreUpdateOperations() + { + lock (_updateTaskGate) + { + var operations = _coreUpdateTasks + .Where(pair => !pair.Value.IsCompleted) + .Select(pair => GetCoreUpdateOperationName(pair.Key)) + .ToList(); + if (_geoUpdateTask is { IsCompleted: false }) + { + operations.Add("geo-update"); + } + if (_webUpdateTask is { IsCompleted: false }) + { + operations.Add("web-update"); + } + if (_coreUpdateBatchTask is { IsCompleted: false }) + { + operations.Add("core-update-batch"); + } + return operations; + } + } + + private bool IsUpdateRunningLocked() => + _coreUpdateTasks.Values.Any(task => !task.IsCompleted) + || _geoUpdateTask is { IsCompleted: false } + || _webUpdateTask is { IsCompleted: false } + || _coreUpdateBatchTask is { IsCompleted: false }; + + private async Task RunSelectedCoreUpdateBatchAsync( + IReadOnlyList coreTypes, + bool includeGeoFiles, + bool includeWeb, + bool apply, + bool useProxy) + { + var staged = new List(); + var overallSuccess = true; + var currentTarget = string.Empty; + WebUpdateStage? webStage = null; + try + { + // Holding the runtime's exclusive mutation lease prevents a restore or profile + // mutation from invalidating the checked/staged targets. Read-only status and log + // requests remain available. No Core process is stopped during this stage. + await using var maintenance = await _operations.EnterExclusiveAsync( + _operations.ShutdownToken, + allowReadOnlyObservations: true); + var token = maintenance.Token; + var checks = new List(coreTypes.Count); + + foreach (var coreType in coreTypes) + { + currentTarget = coreType.ToString(); + var wasRunning = IsCoreTypeRunning(coreType); + PublishCoreUpdateProgress(coreType, "checking", false, false, wasRunning, null, null, batch: true); + var checkedUpdate = await CheckCoreUpdateResultAsync(coreType, null, useProxy, token); + var check = checkedUpdate.Result; + if (checkedUpdate.IsUpToDate) + { + checks.Add(new CoreUpdateBatchCheck(coreType, check, IsUpToDate: true)); + PublishCoreUpdateProgress(coreType, "completed", true, true, wasRunning, + check.Version?.ToString(), check.Msg ?? "Already up to date.", batch: true); + continue; + } + + if (!check.Success || check.Version is null || string.IsNullOrWhiteSpace(check.Url)) + { + overallSuccess = false; + PublishCoreUpdateProgress(coreType, "failed", true, false, wasRunning, null, check.Msg, batch: true); + AddLog("update", $"Batch update check failed for {coreType}: {check.Msg}"); + checks.Add(new CoreUpdateBatchCheck(coreType, check, IsUpToDate: false, CheckFailed: true)); + continue; + } + + checks.Add(new CoreUpdateBatchCheck(coreType, check, IsUpToDate: false)); + if (!apply) + { + PublishCoreUpdateProgress(coreType, "completed", true, true, wasRunning, + check.Version.ToString(), check.Msg ?? "An update is available.", batch: true); + } + } + + if (includeGeoFiles) + { + PublishGeoUpdateProgress("checking", false, false, + "ServiceLib has no read-only GeoFiles check; the transactional GeoFiles update runs only during batch apply.", batch: true); + if (!apply) + { + PublishGeoUpdateProgress("completed", true, true, + "GeoFiles will be downloaded, validated, and applied only when batch apply is enabled.", batch: true); + } + } + + WebUpdateReleaseCheck? webCheck = null; + if (includeWeb) + { + currentTarget = WebUpdateTarget; + PublishWebUpdateProgress("checking", false, false, null, batch: true); + webCheck = await CheckWebUpdateReleaseAsync(GetUpdatePreRelease(WebUpdateTarget), useProxy, token); + Volatile.Write(ref _latestWebUpdateVersion, webCheck.Version); + if (!string.IsNullOrEmpty(webCheck.Error)) + { + overallSuccess = false; + PublishWebUpdateProgress("failed", true, false, webCheck.Error, webCheck.Version, batch: true); + } + else if (!webCheck.IsUpdateAvailable) + { + PublishWebUpdateProgress("completed", true, true, webCheck.Detail ?? "The Web application is up to date.", + webCheck.Version, batch: true); + } + else if (!apply) + { + var detail = CanInstallWebUpdate() + ? "An update is available." + : $"An update is available; {GetWebUpdateInstallReasonKey()}"; + PublishWebUpdateProgress("completed", true, true, detail, webCheck.Version, batch: true); + } + else if (!CanInstallWebUpdate()) + { + overallSuccess = false; + PublishWebUpdateProgress("failed", true, false, GetWebUpdateInstallReasonKey(), webCheck.Version, batch: true); + } + } + + if (!apply) + { + _events.Publish("core-update-batch-completed", new { success = overallSuccess, apply = false }); + return; + } + + if (checks.Any(item => item.CheckFailed) || webCheck is { Error: { Length: > 0 } }) + { + overallSuccess = false; + foreach (var item in checks.Where(item => !item.CheckFailed && !item.IsUpToDate)) + { + PublishCoreUpdateProgress(item.CoreType, "failed", true, false, IsCoreTypeRunning(item.CoreType), + item.Check.Version?.ToString(), "No targets were installed because at least one selected update check failed.", batch: true); + } + if (includeGeoFiles) + { + PublishGeoUpdateProgress("failed", true, false, "No updates were installed because a selected update check failed.", batch: true); + } + if (webCheck is { Error: { Length: > 0 } }) + { + PublishWebUpdateProgress("failed", true, false, "No updates were installed because a selected update check failed.", + webCheck.Version, batch: true); + } + _events.Publish("core-update-batch-completed", new { success = false, apply = true }); + return; + } + + var targetsToStage = checks.Where(item => !item.IsUpToDate).ToArray(); + var unsupportedTargets = targetsToStage.Where(item => !CanInstallCoreUpdate(item.CoreType)).ToArray(); + if (unsupportedTargets.Length > 0) + { + overallSuccess = false; + foreach (var item in unsupportedTargets) + { + PublishCoreUpdateProgress(item.CoreType, "failed", true, false, IsCoreTypeRunning(item.CoreType), + item.Check.Version?.ToString(), "This Web deployment can check but cannot safely install this target.", batch: true); + } + targetsToStage = targetsToStage.Where(item => CanInstallCoreUpdate(item.CoreType)).ToArray(); + } + var shouldInstallWeb = includeWeb && webCheck is { IsUpdateAvailable: true } && CanInstallWebUpdate(); + if (targetsToStage.Length == 0 && !includeGeoFiles && !shouldInstallWeb) + { + _events.Publish("core-update-batch-completed", new { success = overallSuccess, apply = true }); + return; + } + + // Complete the GeoFiles transaction while the serving Core remains available. + // The Web layer snapshots/validates/rolls back files around the upstream updater. + async Task PrepareAdditionalUpdatesBeforeCoreApplyAsync() + { + if (includeGeoFiles) + { + currentTarget = GeoFilesUpdateTarget; + PublishGeoUpdateProgress("downloading", false, false, null, batch: true); + await ApplyGeoFilesUpdateAsync(useProxy, token); + PublishGeoUpdateProgress("completed", true, true, null, batch: true); + } + if (shouldInstallWeb && webCheck is { } availableWebCheck) + { + currentTarget = WebUpdateTarget; + webStage = await StageWebUpdateAsync(availableWebCheck, useProxy, token, batch: true); + } + } + + await CoreUpdateWorkflow.StageAllThenApplyAsync( + targetsToStage, + async target => + { + currentTarget = target.CoreType.ToString(); + var result = await DownloadAndVerifyCoreUpdateAsync(target.CoreType, target.Check, useProxy, token, batch: true); + staged.Add(result); + return result; + }, + async completeStage => + { + // This callback is reached only after every selected package has been + // downloaded, extracted, and version-verified successfully. + foreach (var stage in completeStage) + { + PublishCoreUpdateProgress(stage.CoreType, "waiting", false, false, + IsCoreTypeRunning(stage.CoreType), stage.VersionOutput, "All selected Core packages are staged and verified.", batch: true); + } + + foreach (var stage in completeStage) + { + currentTarget = stage.CoreType.ToString(); + var result = await ApplyCoreUpdateAsync(stage, token, batch: true); + overallSuccess &= result.Success; + PublishCoreUpdateProgress(stage.CoreType, + result.Success ? "completed" : "failed", + isComplete: true, + success: result.Success, + result.CoreWasRunning, + result.Version ?? stage.VersionOutput, + result.Detail, + batch: true); + } + return true; + }, + includeGeoFiles || shouldInstallWeb ? PrepareAdditionalUpdatesBeforeCoreApplyAsync : null); + + if (webStage is not null) + { + currentTarget = WebUpdateTarget; + await ApplyStagedWebUpdateAsync(webStage, token, batch: true); + // The helper takes over only after every selected Core/Geo package has + // completed its apply phase; no remaining batch work follows the restart. + return; + } + + _events.Publish("core-update-batch-completed", new { success = overallSuccess, apply = true }); + } + catch (OperationCanceledException) when (_operations.IsStopping) + { + overallSuccess = false; + if (currentTarget == GeoFilesUpdateTarget) + { + PublishGeoUpdateProgress("failed", true, false, "Canceled during graceful shutdown.", batch: true); + } + else if (currentTarget == WebUpdateTarget) + { + PublishWebUpdateProgress("failed", true, false, "Canceled during graceful shutdown.", batch: true); + } + else if (Enum.TryParse(currentTarget, out var coreType)) + { + PublishCoreUpdateProgress(coreType, "failed", true, false, IsCoreTypeRunning(coreType), null, + "Canceled during graceful shutdown.", batch: true); + } + _events.Publish("core-update-batch-completed", new { success = false, apply }); + } + catch (Exception exception) + { + overallSuccess = false; + AddLog("update", $"Core update batch failed at {currentTarget}: {exception}"); + if (Enum.TryParse(currentTarget, out var coreType)) + { + PublishCoreUpdateProgress(coreType, "failed", true, false, IsCoreTypeRunning(coreType), null, exception.Message, batch: true); + } + else if (currentTarget == GeoFilesUpdateTarget) + { + PublishGeoUpdateProgress("failed", true, false, exception.Message, batch: true); + } + else if (currentTarget == WebUpdateTarget) + { + PublishWebUpdateProgress("failed", true, false, exception.Message, batch: true); + } + foreach (var notApplied in staged) + { + var progress = _updateProgress.GetValueOrDefault(notApplied.CoreType.ToString()); + if (progress is { IsComplete: false }) + { + PublishCoreUpdateProgress(notApplied.CoreType, "failed", true, false, + IsCoreTypeRunning(notApplied.CoreType), notApplied.VersionOutput, + "The staged package was not applied because another selected target failed.", batch: true); + } + } + _events.Publish("core-update-batch-completed", new { success = false, apply }); + } + finally + { + CleanupWebUpdateStage(webStage); + foreach (var stage in staged) CleanupCoreUpdateStage(stage); + lock (_updateTaskGate) + { + _coreUpdateBatchTask = null; + } + } + } + + private HashSet GetAvailablePreReleaseUpdateTargets() => + GetAvailableWebCoreUpdateTypes() + .Where(coreType => CoreInfoManager.Instance.GetCheckPreRelease(coreType, true)) + .Select(coreType => coreType.ToString()) + .Append(WebUpdateTarget) + .ToHashSet(StringComparer.Ordinal); + + private IReadOnlyList GetAvailableWebCoreUpdateTypes() + { + var manager = CoreInfoManager.Instance; + return manager.GetCheckUpdateCoreTypes() + .Where(coreType => coreType != ECoreType.v2rayN + && manager.IsCheckUpdateSupported(coreType) + && manager.GetCoreInfo(coreType) is { } coreInfo + && IsCurrentPlatformDownloadSupported(coreInfo)) + .ToArray(); + } + + private bool CanCheckCoreUpdate(ECoreType coreType) + { + var manager = CoreInfoManager.Instance; + return coreType != ECoreType.v2rayN + && manager.GetCheckUpdateCoreTypes().Contains(coreType) + && manager.IsCheckUpdateSupported(coreType) + && manager.GetCoreInfo(coreType) is { } coreInfo + && IsCurrentPlatformDownloadSupported(coreInfo); + } + + private bool CanInstallCoreUpdate(ECoreType coreType) => + coreType != ECoreType.v2rayN + && CanCheckCoreUpdate(coreType) + && CoreUpdatePackageStager.SupportsCore(coreType); + + private async Task CheckCoreUpdateResultAsync( + ECoreType coreType, + bool? preRelease, + bool? useProxy, + CancellationToken cancellationToken) + { + var updateService = new UpdateService(Config, (_, _) => Task.CompletedTask); + var result = await updateService.CheckHasUpdateOnly( + coreType, + GetUpdatePreRelease(coreType.ToString(), preRelease), + useProxy ?? Config.CheckUpdateItem.UpdateViaProxy, + cancellationToken); + return new CoreUpdateCheckResult(result, IsUpToDateResult(coreType, result.Msg)); + } + + private async Task RunCoreUpdateAsync(ECoreType coreType, bool preRelease, bool useProxy) + { + CoreUpdateStage? stage = null; + var wasRunning = IsCoreTypeRunning(coreType); + try + { + var result = await CoreUpdateWorkflow.StageThenApplyAsync( + async () => + { + await using var operation = await _operations.EnterOperationAsync(_operations.ShutdownToken); + PublishCoreUpdateProgress(coreType, "checking", isComplete: false, success: false, wasRunning, null, null); + var checkedUpdate = await CheckCoreUpdateResultAsync(coreType, preRelease, useProxy, operation.Token); + var check = checkedUpdate.Result; + if (checkedUpdate.IsUpToDate) + { + return new CoreUpdatePrepareResult(null, check); + } + if (!check.Success || check.Version is null || string.IsNullOrWhiteSpace(check.Url)) + { + throw new InvalidOperationException(check.Msg ?? $"Could not check {coreType} updates."); + } + + stage = await DownloadAndVerifyCoreUpdateAsync(coreType, check, useProxy, operation.Token); + return new CoreUpdatePrepareResult(stage, check); + }, + async prepared => + { + if (prepared.Stage is null) + { + return new CoreUpdateApplyResult(true, false, wasRunning, prepared.Check.Msg ?? "Already up to date.", prepared.Check.Version?.ToString()); + } + + await using var maintenance = await _operations.EnterExclusiveAsync( + _operations.ShutdownToken, + allowReadOnlyObservations: true); + return await ApplyCoreUpdateAsync(prepared.Stage, maintenance.Token); + }); + + PublishCoreUpdateProgress( + coreType, + "completed", + isComplete: true, + success: result.Success, + result.CoreWasRunning, + result.Version ?? stage?.VersionOutput, + result.Detail); + if (!result.Success) + { + AddLog("update", $"{coreType} update failed: {result.Detail}"); + } + } + catch (OperationCanceledException) when (_operations.IsStopping) + { + PublishCoreUpdateProgress(coreType, "failed", isComplete: true, success: false, wasRunning, stage?.VersionOutput, "Canceled during graceful shutdown."); + } + catch (Exception exception) + { + AddLog("update", $"{coreType} update failed: {exception.Message}"); + PublishCoreUpdateProgress(coreType, "failed", isComplete: true, success: false, wasRunning, stage?.VersionOutput, exception.Message); + } + finally + { + CleanupCoreUpdateStage(stage); + lock (_updateTaskGate) + { + _coreUpdateTasks.TryRemove(coreType, out _); + } + } + } + + private async Task DownloadAndVerifyCoreUpdateAsync( + ECoreType coreType, + UpdateResult check, + bool useProxy, + CancellationToken cancellationToken, + bool batch = false) + { + var installPath = Path.GetFullPath(Utils.GetBinPath(string.Empty, coreType.ToString())); + var installParent = Path.GetDirectoryName(installPath) + ?? throw new InvalidOperationException($"The {coreType} installation path has no parent directory."); + var archivePath = Utils.GetTempPath($"core-update-{coreType}-{Guid.NewGuid():N}{GetArchiveSuffix(check.Url!)}"); + var packagePath = Path.Combine(installParent, $".{coreType}-package-{Guid.NewGuid():N}"); + Directory.CreateDirectory(packagePath); + + try + { + PublishCoreUpdateProgress(coreType, "downloading", isComplete: false, success: false, IsCoreTypeRunning(coreType), check.Version?.ToString(), check.Url, batch); + var download = new DownloadService(); + var downloadFailure = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + download.Error += (_, args) => downloadFailure.TrySetResult(args.GetException()); + download.UpdateCompleted += (_, progress) => + { + PublishCoreUpdateProgress(coreType, "downloading", isComplete: false, success: false, IsCoreTypeRunning(coreType), check.Version?.ToString(), progress.Msg, batch); + }; + await download.DownloadFileAsync(new FileDownloadRequest + { + FileUrl = check.Url!, + FilePath = archivePath, + DisplayFileName = Path.GetFileName(archivePath), + }, + useProxy, + cancellationToken); + + if (downloadFailure.Task.IsCompletedSuccessfully) + { + throw new IOException("The core package download failed.", await downloadFailure.Task); + } + if (!File.Exists(archivePath)) + { + throw new IOException("The core package download did not produce an archive."); + } + + PublishCoreUpdateProgress(coreType, "verifying", isComplete: false, success: false, IsCoreTypeRunning(coreType), check.Version?.ToString(), null, batch); + await CoreUpdatePackageStager.ExtractAsync(coreType, archivePath, packagePath, cancellationToken); + var coreInfo = CoreInfoManager.Instance.GetCoreInfo(coreType) + ?? throw new InvalidOperationException($"{coreType} version metadata is unavailable."); + var stagedExecutable = FindCoreExecutable(coreInfo, packagePath) + ?? throw new InvalidDataException($"The downloaded {coreType} archive does not contain a usable executable."); + await Utils.SetLinuxChmod(stagedExecutable); + var versionOutput = await VerifyCoreExecutableAsync(coreType, coreInfo, stagedExecutable, cancellationToken); + + return new CoreUpdateStage(coreType, installPath, archivePath, packagePath, versionOutput); + } + catch + { + CleanupCoreUpdateStageFiles(archivePath, packagePath); + throw; + } + } + + private async Task ApplyCoreUpdateAsync(CoreUpdateStage stage, CancellationToken cancellationToken, bool batch = false) + { + await _coreGate.WaitAsync(cancellationToken); + var installPath = stage.InstallPath; + var parentDirectory = Path.GetDirectoryName(installPath) + ?? throw new InvalidOperationException("The Core installation path has no parent directory."); + var candidatePath = Path.Combine(parentDirectory, $".{stage.CoreType}-candidate-{Guid.NewGuid():N}"); + var backupPath = Path.Combine(parentDirectory, $".{stage.CoreType}-backup-{Guid.NewGuid():N}"); + var hadInstalledCore = Directory.Exists(installPath); + var originalCoreWasRunning = IsCoreTypeRunning(stage.CoreType); + ProfileItem? runningProfile = null; + var oldCoreWasStopped = false; + var candidateInstalled = false; + + try + { + if (originalCoreWasRunning) + { + runningProfile = await AppManager.Instance.GetProfileItem(CurrentCoreRuntime.ProfileId ?? string.Empty); + if (runningProfile is null) + { + return new CoreUpdateApplyResult(false, false, true, "The active profile could not be loaded; the running Core was left unchanged."); + } + } + + // Prepare and verify the complete replacement while the serving Core is still + // alive. This phase performs no network access; proxy-backed downloads and all + // archive verification have already completed in DownloadAndVerifyCoreUpdateAsync. + if (hadInstalledCore) + { + CopyCoreDirectory(installPath, candidatePath); + } + else + { + Directory.CreateDirectory(candidatePath); + } + foreach (var packageFile in Directory.EnumerateFiles(stage.PackagePath, "*", SearchOption.TopDirectoryOnly)) + { + cancellationToken.ThrowIfCancellationRequested(); + File.Copy(packageFile, Path.Combine(candidatePath, Path.GetFileName(packageFile)), overwrite: true); + } + if (stage.CoreType == ECoreType.Xray) + { + CopyLatestGeoFilesForApply(installPath, candidatePath); + } + + var coreInfo = CoreInfoManager.Instance.GetCoreInfo(stage.CoreType) + ?? throw new InvalidOperationException($"{stage.CoreType} version metadata is unavailable."); + var candidateExecutable = FindCoreExecutable(coreInfo, candidatePath) + ?? throw new InvalidDataException($"The prepared {stage.CoreType} directory has no executable."); + await Utils.SetLinuxChmod(candidateExecutable); + await VerifyCoreExecutableAsync(stage.CoreType, coreInfo, candidateExecutable, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + PublishCoreUpdateProgress(stage.CoreType, "stopping-core", isComplete: false, success: false, originalCoreWasRunning, stage.VersionOutput, + originalCoreWasRunning ? null : "The updated Core was not running and will remain stopped.", batch); + if (originalCoreWasRunning) + { + SetCoreRuntime(CurrentCoreRuntime with { State = CoreRuntimeState.Stopping, LastFailure = null }); + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(cancellationToken); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + oldCoreWasStopped = true; + } + + PublishCoreUpdateProgress(stage.CoreType, "installing", isComplete: false, success: false, originalCoreWasRunning, stage.VersionOutput, null, batch); + if (hadInstalledCore) + { + Directory.Move(installPath, backupPath); + } + try + { + Directory.Move(candidatePath, installPath); + candidateInstalled = true; + } + catch + { + if (hadInstalledCore && Directory.Exists(backupPath) && !Directory.Exists(installPath)) + { + Directory.Move(backupPath, installPath); + } + throw; + } + + await CoreManager.Instance.Init(Config, OnCoreMessageAsync); + if (stage.CoreType == ECoreType.Xray) + { + _xrayPath = FindXrayExecutable(out var missingXrayMessage); + if (_xrayPath is null) + { + throw new InvalidDataException(missingXrayMessage); + } + } + + if (originalCoreWasRunning && runningProfile is not null) + { + PublishCoreUpdateProgress(stage.CoreType, "restarting-core", isComplete: false, success: false, true, stage.VersionOutput, null, batch); + var restart = await StartCoreLockedAsync(runningProfile, cancellationToken); + if (!restart.Success) + { + throw new InvalidOperationException($"The updated {stage.CoreType} failed to restart ({restart.Code})."); + } + } + + if (Directory.Exists(backupPath)) + { + try + { + Directory.Delete(backupPath, recursive: true); + } + catch (Exception exception) + { + AddLog("update", $"{stage.CoreType} updated; previous Core cleanup deferred: {exception.Message}"); + } + } + + return new CoreUpdateApplyResult(true, false, originalCoreWasRunning, "Update installed and verified.", stage.VersionOutput); + } + catch (OperationCanceledException) + { + if (candidateInstalled || oldCoreWasStopped || Directory.Exists(backupPath)) + { + var rollback = await RollBackCoreUpdateAsync( + stage.CoreType, + installPath, + backupPath, + candidateInstalled, + hadInstalledCore, + oldCoreWasStopped, + originalCoreWasRunning, + runningProfile, + batch); + if (!rollback.Completed) + { + AddLog("update", rollback.FilesRestored + ? $"{stage.CoreType} previous files were restored, but the old Core process could not be relaunched." + : $"{stage.CoreType} rollback did not complete; previous files remain at {backupPath}."); + } + } + throw; + } + catch (Exception exception) + { + AddLog("update", $"{stage.CoreType} apply failed: {exception.Message}"); + if (CurrentCoreRuntime.State is CoreRuntimeState.Stopping or CoreRuntimeState.Restarting or CoreRuntimeState.Starting) + { + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = exception.Message, + }); + } + var rollback = new CoreUpdateRollbackResult(FilesRestored: true, CoreRestarted: true); + if (candidateInstalled || oldCoreWasStopped || Directory.Exists(backupPath)) + { + rollback = await RollBackCoreUpdateAsync( + stage.CoreType, + installPath, + backupPath, + candidateInstalled, + hadInstalledCore, + oldCoreWasStopped, + originalCoreWasRunning, + runningProfile, + batch); + } + var detail = rollback.Completed + ? $"{exception.Message} The previous Core was restored." + : rollback.FilesRestored + ? $"{exception.Message} The previous Core files were restored, but its process could not be restarted." + : $"{exception.Message} Rollback did not complete; see the runtime log for the retained backup path."; + return new CoreUpdateApplyResult(false, rollback.Completed, originalCoreWasRunning, detail, stage.VersionOutput); + } + finally + { + try + { + if (Directory.Exists(candidatePath)) + { + Directory.Delete(candidatePath, recursive: true); + } + } + catch (Exception exception) + { + AddLog("update", $"{stage.CoreType} candidate cleanup failed: {exception.Message}"); + } + _coreGate.Release(); + } + } + + private async Task RollBackCoreUpdateAsync( + ECoreType coreType, + string installPath, + string backupPath, + bool candidateInstalled, + bool hadInstalledCore, + bool oldCoreWasStopped, + bool wasRunning, + ProfileItem? runningProfile, + bool batch = false) + { + var filesRestored = false; + try + { + return await CoreUpdateWorkflow.RollBackAndRestoreAsync( + async () => + { + // Do not stop an unrelated active Core when the target being updated was idle. + if (oldCoreWasStopped && wasRunning) + { + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(CancellationToken.None); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + } + CoreUpdateWorkflow.RestorePreviousDirectory( + installPath, backupPath, candidateInstalled, hadInstalledCore); + filesRestored = true; + }, + async () => + { + await CoreManager.Instance.Init(Config, OnCoreMessageAsync); + if (coreType == ECoreType.Xray) + { + _xrayPath = FindXrayExecutable(out _); + } + }, + wasRunning && runningProfile is not null, + async () => + { + PublishCoreUpdateProgress(coreType, "restarting-core", isComplete: false, success: false, + true, null, "Restarting the restored Core.", batch); + return (await StartCoreLockedAsync(runningProfile!, CancellationToken.None)).Success; + }); + } + catch (Exception exception) + { + AddLog("update", $"{coreType} rollback failed: {exception.Message}"); + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = exception.Message, + }); + return new CoreUpdateRollbackResult(filesRestored, CoreRestarted: false); + } + } + + private async Task VerifyCoreExecutableAsync( + ECoreType coreType, + CoreInfo coreInfo, + string executablePath, + CancellationToken cancellationToken) + { + var versionArgument = coreInfo.VersionArg + ?? throw new InvalidDataException($"{coreType} has no version-check command configured."); + var versionOutput = await Utils.GetCliWrapOutput(executablePath, versionArgument, cancellationToken); + var expectedName = coreInfo.Match ?? coreType.ToString(); + if (!CoreExecutableVersionCheck.IsValid(versionOutput, expectedName)) + { + throw new InvalidDataException($"The staged {coreType} executable did not pass its version check."); + } + return versionOutput!.Trim(); + } + + private static string? FindCoreExecutable(CoreInfo coreInfo, string directory) => + (coreInfo.CoreExes ?? []) + .Select(name => Path.Combine(directory, Utils.GetExeName(name))) + .FirstOrDefault(File.Exists); + + private static bool IsCurrentPlatformDownloadSupported(CoreInfo coreInfo) + { + var downloadUrl = Utils.IsWindows() + ? RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => coreInfo.DownloadUrlWinArm64, + Architecture.X64 => coreInfo.DownloadUrlWin64, + _ => null, + } + : Utils.IsLinux() + ? RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => coreInfo.DownloadUrlLinuxArm64, + Architecture.RiscV64 => coreInfo.DownloadUrlLinuxRiscV64, + Architecture.LoongArch64 => coreInfo.DownloadUrlLinuxLoong64, + Architecture.X64 => coreInfo.DownloadUrlLinux64, + _ => null, + } + : Utils.IsMacOS() + ? RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => coreInfo.DownloadUrlOSXArm64, + Architecture.X64 => coreInfo.DownloadUrlOSX64, + _ => null, + } + : null; + + return !string.IsNullOrWhiteSpace(downloadUrl); + } + + internal static bool IsUpToDateResult(ECoreType coreType, string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return false; + } + + const string typeMarker = "__V2RAYN_CORE_TYPE__"; + const string versionMarker = "__V2RAYN_CORE_VERSION__"; + var template = string.Format(ResUI.IsLatestCore, typeMarker, versionMarker); + var typePosition = template.IndexOf(typeMarker, StringComparison.Ordinal); + var versionPosition = template.IndexOf(versionMarker, StringComparison.Ordinal); + if (typePosition < 0 || versionPosition <= typePosition) + { + return false; + } + + var expectedPrefix = template[..typePosition] + + coreType + + template[(typePosition + typeMarker.Length)..versionPosition]; + var suffix = template[(versionPosition + versionMarker.Length)..]; + var normalizedMessage = message.Trim(); + if (!normalizedMessage.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase) + || !normalizedMessage.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var versionLength = normalizedMessage.Length - expectedPrefix.Length - suffix.Length; + return versionLength > 0 + && normalizedMessage.Substring(expectedPrefix.Length, versionLength).All(character => !char.IsWhiteSpace(character)); + } + + private bool IsCoreTypeRunning(ECoreType coreType) => + CurrentCoreRuntime.State is CoreRuntimeState.Running or CoreRuntimeState.Faulted + && CurrentCoreRuntime.CoreType == coreType + && HasTrackedCoreProcesses; + + private static string GetArchiveSuffix(string downloadUrl) + { + var path = Uri.TryCreate(downloadUrl, UriKind.Absolute, out var uri) ? uri.AbsolutePath : downloadUrl; + return path.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) + ? ".tar.gz" + : Path.GetExtension(path); + } + + private static void CopyCoreDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + foreach (var sourceFile in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.TopDirectoryOnly)) + { + File.Copy(sourceFile, Path.Combine(destinationDirectory, Path.GetFileName(sourceFile)), overwrite: true); + } + foreach (var sourceSubdirectory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.TopDirectoryOnly)) + { + CopyCoreDirectory(sourceSubdirectory, Path.Combine(destinationDirectory, Path.GetFileName(sourceSubdirectory))); + } + } + + private void CleanupCoreUpdateStage(CoreUpdateStage? stage) + { + if (stage is not null) + { + CleanupCoreUpdateStageFiles(stage.ArchivePath, stage.PackagePath); + } + } + + private void CleanupCoreUpdateStageFiles(string archivePath, string packagePath) + { + try + { + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, recursive: true); + } + } + catch (Exception exception) + { + AddLog("update", $"Core package staging cleanup failed: {exception.Message}"); + } + try + { + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + } + catch (Exception exception) + { + AddLog("update", $"Core archive cleanup failed: {exception.Message}"); + } + } + + private async Task RunGeoUpdateAsync(bool useProxy) + { + try + { + PublishGeoUpdateProgress("downloading", isComplete: false, success: false, null); + await using var operation = await _operations.EnterExclusiveAsync( + _operations.ShutdownToken, + allowReadOnlyObservations: true); + await ApplyGeoFilesUpdateAsync(useProxy, operation.Token); + PublishGeoUpdateProgress("completed", isComplete: true, success: true, null); + } + catch (OperationCanceledException) when (_operations.IsStopping) + { + PublishGeoUpdateProgress("failed", isComplete: true, success: false, "Canceled during graceful shutdown."); + } + catch (Exception exception) + { + AddLog("update", $"GeoFiles update failed: {exception.Message}"); + PublishGeoUpdateProgress("failed", isComplete: true, success: false, exception.Message); + } + finally + { + lock (_updateTaskGate) + { + _geoUpdateTask = null; + } + } + } + + private async Task ApplyGeoFilesUpdateAsync( + bool useProxy, + CancellationToken cancellationToken, + bool publishProgress = true, + Func, CancellationToken, Task>? beforeCommit = null) + { + await _geoUpdateGate.RunAsync(async () => + { + var completion = new GeoFilesUpdateCompletion(); + var updater = new UpdateService(Config, (success, message) => + { + AddLog("update", message); + completion.Report(success, message, IsGeoDownloadProgressMessage(message)); + if (publishProgress) + { + _events.Publish("geo-update-progress", new + { + success, + code = success ? "ok" : "geo_update_progress", + messageKey = success ? ApiMessageKeys.CommonCompleted : ApiMessageKeys.GeoUpdateProgress, + rawLog = message, + }); + } + return Task.CompletedTask; + }); + + var requiredFiles = GetRequiredGeoFiles(); + var managedFiles = await GetManagedGeoFilesAsync(); + await GeoFilesUpdateTransaction.ApplyAsync( + managedFiles, + requiredFiles, + async token => + { + // Keep download selection, URLs, and installation behavior in the upstream + // public API. Its legacy callback reports progress/errors separately from its + // final success notification, so reject non-progress failures before commit. + await updater.UpdateGeoFileAll(useProxy, token); + completion.EnsureSuccessful(); + }, + cancellationToken, + beforeCommit); + }, cancellationToken); + } + + private async Task GetManagedGeoFilesAsync() + { + var files = new HashSet(GetRequiredGeoFiles(), StringComparerForPaths); + var geoipRules = new List(); + var geositeRules = new List(); + + var routingItems = await AppManager.Instance.RoutingItems(); + foreach (var routing in routingItems ?? []) + { + var rules = JsonUtils.Deserialize>(routing.RuleSet); + foreach (var rule in rules ?? []) + { + AddPrefixedItems(rule.Ip, Global.GeoIPPrefix, geoipRules); + AddPrefixedItems(rule.Domain, Global.GeoSitePrefix, geositeRules); + } + } + + var dnsItem = await AppManager.Instance.GetDNSItem(ECoreType.sing_box); + if (dnsItem is not null) + { + ExtractDnsRuleSets(dnsItem.NormalDNS, geoipRules, geositeRules); + ExtractDnsRuleSets(dnsItem.TunDNS, geoipRules, geositeRules); + } + + // Match ServiceLib.UpdateService.GetSrsFileAllRequest exactly: configured rule sets, + // plus its default geosite downloads. Do not treat every .srs or geo*-named file in + // the Core directory as owned by this transaction. + geositeRules.AddRange(["google", "cn", "geolocation-cn", "category-ads-all"]); + var srsDirectory = Path.GetFullPath(Utils.GetBinPath("srss")); + foreach (var (type, names) in new[] { ("geoip", geoipRules), ("geosite", geositeRules) }) + { + foreach (var name in names.Distinct(StringComparer.Ordinal)) + { + files.Add(GetManagedSrsPath(srsDirectory, type, name)); + } + } + + return files.OrderBy(path => path, StringComparerForPaths).ToArray(); + } + + private static string GetManagedSrsPath(string srsDirectory, string type, string name) + { + if (string.IsNullOrWhiteSpace(name) + || name is "." or ".." + || name.Contains('/') + || name.Contains('\\') + || name.Contains(':') + || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || !string.Equals(name, name.TrimEnd(' ', '.'), StringComparison.Ordinal) + || Path.GetFileName(name) != name) + { + throw new InvalidDataException("A GeoFiles rule-set name is not a safe file name."); + } + + var directory = Path.GetFullPath(srsDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var path = Path.GetFullPath(Path.Combine(directory, $"{type}-{name}.srs")); + if (!string.Equals(Path.GetDirectoryName(path), directory, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + { + throw new InvalidDataException("A GeoFiles rule-set path escapes the managed rules directory."); + } + return path; + } + + private static void AddPrefixedItems(IEnumerable? items, string prefix, ICollection output) + { + if (items is null) return; + foreach (var item in items) + { + if (item.StartsWith(prefix, StringComparison.Ordinal)) + { + output.Add(item[prefix.Length..]); + } + } + } + + private static void ExtractDnsRuleSets(string? dnsJson, ICollection geoipRules, ICollection geositeRules) + { + if (string.IsNullOrEmpty(dnsJson)) return; + try + { + var dns = JsonUtils.Deserialize(dnsJson); + foreach (var rule in dns?.rules ?? []) + { + ExtractSrsRuleSets(rule, geoipRules, geositeRules); + } + } + catch + { + // Match the updater: malformed optional DNS JSON does not stop GeoFiles updates. + } + } + + private static void ExtractSrsRuleSets(Rule4Sbox? rule, ICollection geoipRules, ICollection geositeRules) + { + if (rule is null) return; + AddPrefixedItems(rule.rule_set, "geosite-", geositeRules); + AddPrefixedItems(rule.rule_set, "geoip-", geoipRules); + foreach (var nestedRule in rule.rules ?? []) + { + ExtractSrsRuleSets(nestedRule, geoipRules, geositeRules); + } + } + + private string[] GetRequiredGeoFiles() + { + var binDirectory = Utils.GetBinPath(string.Empty); + var required = new List + { + Path.Combine(binDirectory, "geosite.dat"), + Path.Combine(binDirectory, "geoip.dat"), + }; + if (string.IsNullOrEmpty(Config.ConstItem.GeoSourceUrl)) + { + required.AddRange(Global.OtherGeoUrls.Select(url => Path.Combine(binDirectory, Path.GetFileName(url)))); + } + return required.Distinct(StringComparerForPaths).ToArray(); + } + + private static bool IsGeoDownloadProgressMessage(string message) + { + var normalized = message.TrimStart(); + return normalized.StartsWith(ResUI.Downloading, StringComparison.OrdinalIgnoreCase) + || Regex.IsMatch(normalized, @"^\d+/\d+\s*\|", RegexOptions.CultureInvariant); + } + + private static StringComparer StringComparerForPaths => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private void PublishCoreUpdateProgress( + ECoreType coreType, + string phase, + bool isComplete, + bool success, + bool coreWasRunning, + string? version, + string? detail, + bool batch = false) + { + var progress = new CoreUpdateProgressView(coreType.ToString(), phase, isComplete, success, coreWasRunning, version, detail, batch); + if (!_updateProgress.TryGetValue(coreType.ToString(), out var previous) || previous.Phase != phase) + { + AddLog("update", $"{coreType} update phase: {phase}"); + } + _updateProgress[coreType.ToString()] = progress; + _events.Publish("core-update-progress", progress); + if (coreType == ECoreType.Xray && isComplete) + { + _events.Publish("xray-update-completed", success + ? OperationView.Ok(ApiMessageKeys.XrayUpdateCompleted, new { version, coreRestarted = coreWasRunning }) + : OperationView.Fail("xray_update_failed", ApiMessageKeys.XrayUpdateFailed, new { coreWasRunning, detail })); + } + } + + private void PublishGeoUpdateProgress(string phase, bool isComplete, bool success, string? detail, bool batch = false) + { + var progress = new CoreUpdateProgressView(GeoFilesUpdateTarget, phase, isComplete, success, false, null, detail, batch); + if (!_updateProgress.TryGetValue(GeoFilesUpdateTarget, out var previous) || previous.Phase != phase) + { + AddLog("update", $"GeoFiles update phase: {phase}"); + } + _updateProgress[GeoFilesUpdateTarget] = progress; + _events.Publish("core-update-progress", progress); + if (isComplete) + { + _events.Publish("geo-update-completed", success + ? OperationView.Ok(ApiMessageKeys.CommonCompleted, new { batch }) + : OperationView.Fail("geo_update_failed", ApiMessageKeys.CoreUpdateFailed, new { batch })); + } + } + + private static string GetCoreUpdateOperationName(ECoreType coreType) => + $"core-update-{coreType.ToString().ToLowerInvariant()}"; + + private static string GetCoreUpdateNameKey(ECoreType coreType) => coreType switch + { + ECoreType.Xray => "maintenance.coreNames.xray", + ECoreType.mihomo => "maintenance.coreNames.mihomo", + ECoreType.sing_box => "maintenance.coreNames.singBox", + ECoreType.v2rayN => "maintenance.coreNames.v2rayN", + _ => "maintenance.coreNames.other", + }; + + internal static void CopyLatestGeoFilesForApply(string installPath, string stagingPath) + { + if (!Directory.Exists(installPath)) + { + return; + } + foreach (var source in Directory.EnumerateFiles(installPath, "geo*", SearchOption.TopDirectoryOnly)) + { + File.Copy(source, Path.Combine(stagingPath, Path.GetFileName(source)), overwrite: true); + } + } + + internal sealed record CoreUpdateStage(ECoreType CoreType, string InstallPath, string ArchivePath, string PackagePath, string VersionOutput); + private sealed record CoreUpdateCheckResult(UpdateResult Result, bool IsUpToDate); + private sealed record CoreUpdateBatchCheck(ECoreType CoreType, UpdateResult Check, bool IsUpToDate, bool CheckFailed = false); + private sealed record CoreUpdatePrepareResult(CoreUpdateStage? Stage, UpdateResult Check); + private sealed record CoreUpdateApplyResult(bool Success, bool RolledBack, bool CoreWasRunning, string Detail, string? Version = null); +} + +internal static class CoreUpdateRuntimePolicy +{ + public static bool IsTargetRunning(ECoreType target, ECoreType runningCore, DateTimeOffset? startedAt) => + startedAt is not null && target == runningCore; +} + +internal static class CoreUpdateWorkflow +{ + public static void RestorePreviousDirectory( + string installPath, + string backupPath, + bool candidateInstalled, + bool hadInstalledCore) + { + if (candidateInstalled && Directory.Exists(installPath)) + { + Directory.Delete(installPath, recursive: true); + } + if (hadInstalledCore && Directory.Exists(backupPath) && !Directory.Exists(installPath)) + { + Directory.Move(backupPath, installPath); + } + } + + public static async Task RollBackAndRestoreAsync( + Func restoreFilesAsync, + Func initializePreviousCoreAsync, + bool wasRunning, + Func> restartPreviousCoreAsync) + { + await restoreFilesAsync(); + await initializePreviousCoreAsync(); + var restarted = !wasRunning || await restartPreviousCoreAsync(); + return new CoreUpdateRollbackResult(FilesRestored: true, CoreRestarted: restarted); + } + + public static async Task StageThenApplyAsync( + Func> stageAsync, + Func> applyAsync) + { + var staged = await stageAsync(); + return await applyAsync(staged); + } + + public static async Task StageAllThenApplyAsync( + IReadOnlyList targets, + Func> stageAsync, + Func, Task> applyAsync, + Func? beforeApplyAsync = null) + { + var staged = new List(targets.Count); + foreach (var target in targets) + { + staged.Add(await stageAsync(target)); + } + if (beforeApplyAsync is not null) + { + await beforeApplyAsync(); + } + return await applyAsync(staged); + } +} + +internal sealed record CoreUpdateRollbackResult(bool FilesRestored, bool CoreRestarted) +{ + public bool Completed => FilesRestored && CoreRestarted; +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Profiles.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Profiles.cs new file mode 100644 index 00000000..8440b1a3 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Profiles.cs @@ -0,0 +1,572 @@ +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Handler.Builder; +using ServiceLib.Handler.Fmt; +using ServiceLib.Manager; +using ServiceLib.Models.Dto; +using ServiceLib.Models.Entities; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + public async Task> GetProfileGroupsAsync() + { + var subscriptions = await AppManager.Instance.SubItems() ?? []; + var profiles = await AppManager.Instance.ProfileItems(string.Empty) ?? []; + var counts = profiles.GroupBy(item => item.Subid).ToDictionary(group => group.Key, group => group.Count()); + var groups = new List + { + new(string.Empty, "all", null, ApiMessageKeys.ProfilesAllGroup, profiles.Count, string.IsNullOrEmpty(Config.SubIndexId)), + }; + + groups.AddRange(subscriptions.Select(item => new ProfileGroupView( + item.Id, + "subscription", + item.Remarks, + null, + counts.GetValueOrDefault(item.Id), + item.Id == Config.SubIndexId))); + + return groups; + } + + public async Task SelectProfileGroupAsync(string? subscriptionId) + { + if (!string.IsNullOrWhiteSpace(subscriptionId) + && await AppManager.Instance.GetSubItem(subscriptionId) is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound); + } + + await _mutations.RunAsync(async () => + { + Config.SubIndexId = subscriptionId ?? string.Empty; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.CommonCompleted, new { subscriptionId = Config.SubIndexId }); + } + + public async Task ImportProfilesAsync(ProfileImportRequest request) + { + if (string.IsNullOrWhiteSpace(request.Content)) + { + return OperationView.Fail("profile_content_required", ApiMessageKeys.CommonInvalidInput, new { imported = 0 }); + } + + var groupId = request.SubscriptionId ?? Config.SubIndexId ?? string.Empty; + if (!string.IsNullOrEmpty(groupId) && await AppManager.Instance.GetSubItem(groupId) is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound, new { imported = 0 }); + } + + var count = await _mutations.RunAsync(async () => + { + var imported = await ConfigHandler.AddBatchServers(Config, request.Content, groupId, request.IsSubscription); + if (imported > 0) + { + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + } + return imported; + }); + if (count <= 0) + { + return OperationView.Fail("profile_import_empty", ApiMessageKeys.ProfileInvalid, new { imported = Math.Max(count, 0) }); + } + + _events.Publish("profiles-changed", new { subscriptionId = groupId }); + AddLog("profile", $"Imported {count} profile(s)."); + return OperationView.Ok(ApiMessageKeys.ProfileImported, new { imported = count }); + } + + public async Task SaveProfileAsync(ProfileItem profile, string? profileId = null) + { + if (profile is null) + { + return OperationView.Fail("profile_data_required", ApiMessageKeys.CommonInvalidInput); + } + + var isNew = string.IsNullOrWhiteSpace(profileId); + if (!isNew) + { + var existing = await AppManager.Instance.GetProfileItem(profileId!); + if (existing is null) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + if (!IsConfigTypeUnchanged(profile, existing)) + { + return OperationView.Fail("profile_config_type_immutable", ApiMessageKeys.ProfileInvalid); + } + profile.IndexId = profileId!; + profile.Subid = existing.Subid; + profile.IsSub = existing.IsSub; + } + else + { + profile.IndexId = string.Empty; + profile.Subid = Config.SubIndexId ?? string.Empty; + profile.IsSub = false; + if (!string.IsNullOrEmpty(profile.Subid) + && await AppManager.Instance.GetSubItem(profile.Subid) is null) + { + AddLog("profile", $"Rejected profile creation for missing subscription group {profile.Subid}."); + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound, + new { subscriptionId = profile.Subid }); + } + } + + if (profile.ConfigType.IsGroupType()) + { + profile.CoreType ??= ECoreType.Xray; + var protocolExtra = profile.GetProtocolExtra(); + var childIds = Utils.String2List(protocolExtra.ChildItems) ?? []; + if (string.IsNullOrWhiteSpace(profile.Remarks) + || (childIds.Count == 0 && string.IsNullOrWhiteSpace(protocolExtra.SubChildItems)) + || profile.CoreType is not (ECoreType.Xray or ECoreType.sing_box)) + { + return OperationView.Fail("profile_group_invalid", ApiMessageKeys.ProfileInvalid); + } + if (!string.IsNullOrWhiteSpace(protocolExtra.SubChildItems) + && await AppManager.Instance.GetSubItem(protocolExtra.SubChildItems) is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound); + } + var children = await AppManager.Instance.GetProfileItemsByIndexIds(childIds); + if (children.Count != childIds.Distinct(StringComparer.Ordinal).Count() + || await GroupProfileManager.HasCycle(profile)) + { + return OperationView.Fail("profile_group_children_invalid", ApiMessageKeys.ProfileInvalid); + } + } + else if (profile.ConfigType is EConfigType.Custom or EConfigType.Outbound) + { + if (string.IsNullOrWhiteSpace(profile.Remarks) || string.IsNullOrWhiteSpace(profile.Address)) + { + return OperationView.Fail("profile_validation_failed", ApiMessageKeys.ProfileInvalid); + } + } + else if (profile.ConfigType.IsComplexType()) + { + return OperationView.Fail("profile_complex_type_requires_import", ApiMessageKeys.CommonInvalidInput); + } + else if (!TryValidateProfile(profile, out var code, out var messageKey)) + { + return OperationView.Fail(code, messageKey); + } + + var wasRunning = !isNew + && profile.IndexId == CurrentCoreRuntime.ProfileId + && CurrentCoreRuntime.State == CoreRuntimeState.Running; + int result; + try + { + result = await _mutations.RunAsync(() => profile.ConfigType.IsGroupType() + ? ConfigHandler.AddServerCommon(Config, profile) + : profile.ConfigType switch + { + EConfigType.Custom when isNew => ConfigHandler.AddCustomServer(Config, profile, blDelete: false), + EConfigType.Outbound when isNew => ConfigHandler.AddCustomOutboundServer(Config, profile, blDelete: false, toFile: true), + EConfigType.Custom or EConfigType.Outbound => ConfigHandler.EditCustomServer(Config, profile), + _ => ConfigHandler.AddServer(Config, profile), + }); + } + catch (Exception exception) + { + AddLog("profile", $"Profile save failed for {profile.ConfigType} ({profile.IndexId}): {exception}"); + return OperationView.Fail("profile_persistence_failed", ApiMessageKeys.ProfileSaveFailed, + new { stage = "servicelib_or_sqlite_write", exceptionType = exception.GetType().Name, detail = exception.Message }); + } + if (result != 0) + { + AddLog("profile", $"ServiceLib rejected profile save for {profile.ConfigType} ({profile.IndexId}); result={result}."); + return OperationView.Fail("profile_save_rejected", ApiMessageKeys.ProfileSaveFailed, + new { stage = "servicelib_validation_or_persistence", result }); + } + + if (wasRunning) + { + var restart = await RestartCoreAsync(CancellationToken.None); + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return restart.Success + ? OperationView.Ok(ApiMessageKeys.ProfileSaved, new { profileId = profile.IndexId, coreRestarted = true }) + : OperationView.Fail("profile_saved_core_restart_failed", ApiMessageKeys.ProfileSaved, + new { profileId = profile.IndexId, coreRestartRequired = true, coreResultCode = restart.Code }); + } + + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.ProfileSaved, new { profileId = profile.IndexId, coreRestarted = false }); + } + + internal static bool TryValidateProfile(ProfileItem profile, out string code, out string messageKey) + { + if (string.IsNullOrEmpty(profile.Remarks) + || string.IsNullOrEmpty(profile.Address) + || profile.Port <= 0 + || profile.Port >= Global.MaxPort + || (profile.ConfigType is not EConfigType.SOCKS and not EConfigType.HTTP + && string.IsNullOrEmpty(profile.Password))) + { + code = "profile_validation_failed"; + messageKey = ApiMessageKeys.ProfileInvalid; + return false; + } + + var protocolExtra = profile.GetProtocolExtra(); + if (profile.ConfigType == EConfigType.Shadowsocks && string.IsNullOrEmpty(protocolExtra.SsMethod)) + { + code = "profile_validation_failed"; + messageKey = ApiMessageKeys.ProfileInvalid; + return false; + } + + if (profile.ConfigType == EConfigType.HTTP + && !string.IsNullOrEmpty(protocolExtra.HttpHeaders) + && JsonUtils.ParseJson(protocolExtra.HttpHeaders) is null) + { + code = "profile_http_headers_invalid"; + messageKey = ApiMessageKeys.ProfileInvalid; + return false; + } + + code = "ok"; + messageKey = string.Empty; + return true; + } + + internal static bool IsConfigTypeUnchanged(ProfileItem input, ProfileItem existing) => + input.ConfigType == existing.ConfigType; + + public async Task DeleteProfilesAsync(IEnumerable profileIds) + { + var ids = profileIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToArray(); + if (ids.Length == 0) + { + return OperationView.Fail("profile_selection_required", ApiMessageKeys.ProfileSelectRequired); + } + + var selected = await AppManager.Instance.GetProfileItemsByIndexIds(ids); + if (selected.Count == 0) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + + var runningProfileId = CurrentCoreRuntime.ProfileId; + var removesCurrent = ids.Contains(runningProfileId ?? Config.IndexId, StringComparer.Ordinal); + var wasRunning = CurrentCoreRuntime.State == CoreRuntimeState.Running; + if (removesCurrent && wasRunning) + { + var stop = await StopCoreAsync(CancellationToken.None); + if (!stop.Success) + { + return stop; + } + } + + await _mutations.RunAsync(async () => + { + if (await ConfigHandler.RemoveServers(Config, selected) != 0) + { + throw new IOException("ServiceLib could not remove the selected profiles."); + } + if (removesCurrent) + { + _ = await ConfigHandler.GetDefaultServer(Config); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + } + }); + + if (removesCurrent && wasRunning && !string.IsNullOrEmpty(Config.IndexId)) + { + var restart = await StartCoreAsync(null, CancellationToken.None); + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return restart.Success + ? OperationView.Ok(ApiMessageKeys.ProfileDeleted, new { deleted = selected.Count, coreRestarted = true }) + : OperationView.Fail("profiles_deleted_core_restart_failed", ApiMessageKeys.ProfileDeleted, + new { deleted = selected.Count, coreRestartRequired = true, coreResultCode = restart.Code }); + } + + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.ProfileDeleted, new { deleted = selected.Count }); + } + + public async Task CopyProfilesAsync(IEnumerable profileIds) + { + var ids = profileIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToArray(); + var items = await AppManager.Instance.GetProfileItemsOrderedByIndexIds(ids); + if (items.Count == 0) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + + if (await _mutations.RunAsync(() => ConfigHandler.CopyServer(Config, items)) != 0) + { + return OperationView.Fail("profile_copy_failed", ApiMessageKeys.CommonInvalidInput); + } + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.ProfileCopied, new { copied = items.Count }); + } + + public async Task MoveProfilesToGroupAsync(MoveProfilesRequest request) + { + if (!string.IsNullOrEmpty(request.SubscriptionId) + && await AppManager.Instance.GetSubItem(request.SubscriptionId) is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound); + } + + var items = await AppManager.Instance.GetProfileItemsByIndexIds(request.ProfileIds); + if (items.Count == 0) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + + if (await _mutations.RunAsync(() => ConfigHandler.MoveToGroup(Config, items, request.SubscriptionId ?? string.Empty)) != 0) + { + return OperationView.Fail("profile_move_failed", ApiMessageKeys.CommonInvalidInput); + } + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.ProfileMoved, new { moved = items.Count, subscriptionId = request.SubscriptionId }); + } + + public async Task MoveProfileAsync(MoveProfileRequest request) + { + var groupId = Config.SubIndexId ?? string.Empty; + var profiles = await GetProfilesAsync(groupId, null); + var orderedIds = profiles.Select(item => item.IndexId).ToList(); + var index = orderedIds.IndexOf(request.ProfileId); + if (index < 0) + { + return OperationView.Fail("profile_not_in_group", ApiMessageKeys.ProfileNotInGroup); + } + + var result = await _mutations.RunAsync(() => ConfigHandler.MoveServer(Config, orderedIds, index, request.Direction, request.Position)); + _events.Publish("profiles-changed", new { subscriptionId = groupId }); + return result == 0 + ? OperationView.Ok(ApiMessageKeys.ProfileOrderSaved) + : OperationView.Fail("profile_order_failed", ApiMessageKeys.CommonInvalidInput); + } + + public async Task SortProfilesAsync(SortProfilesRequest request) + { + var groupId = request.SubscriptionId ?? Config.SubIndexId ?? string.Empty; + if (!Enum.TryParse(request.Column, true, out _)) + { + return OperationView.Fail("profile_sort_column_invalid", ApiMessageKeys.CommonInvalidInput); + } + + var result = await _mutations.RunAsync(() => ConfigHandler.SortServers(Config, groupId, request.Column, request.Ascending)); + _events.Publish("profiles-changed", new { subscriptionId = groupId }); + return result == 0 + ? OperationView.Ok(ApiMessageKeys.ProfileOrderSaved) + : OperationView.Fail("profile_sort_failed", ApiMessageKeys.CommonInvalidInput); + } + + public async Task RemoveDuplicateProfilesAsync(string? subscriptionId) + { + var result = await _mutations.RunAsync(() => ConfigHandler.DedupServerList(Config, subscriptionId ?? Config.SubIndexId ?? string.Empty)); + _events.Publish("profiles-changed", new { subscriptionId = subscriptionId ?? Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.ProfileDeduplicated, new { removed = result.Item1 - result.Item2 }); + } + + public async Task RemoveInvalidProfilesAsync(string? subscriptionId) + { + var count = await _mutations.RunAsync(() => ConfigHandler.RemoveInvalidServerResult(Config, subscriptionId ?? Config.SubIndexId ?? string.Empty)); + _events.Publish("profiles-changed", new { subscriptionId = subscriptionId ?? Config.SubIndexId }); + return count < 0 + ? OperationView.Ok(ApiMessageKeys.CommonCompleted, new { removed = 0 }) + : OperationView.Ok(ApiMessageKeys.ProfileInvalidRemoved, new { removed = count }); + } + + public async Task GenerateProfileGroupsAsync(string? subscriptionId, bool byRegion) + { + SubItem? subscription = null; + if (!string.IsNullOrEmpty(subscriptionId)) + { + subscription = await AppManager.Instance.GetSubItem(subscriptionId); + if (subscription is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound); + } + } + + var result = await _mutations.RunAsync(() => byRegion + ? ConfigHandler.AddGroupRegionServer(Config, subscription) + : ConfigHandler.AddGroupAllServer(Config, subscription)); + if (!result.Success) + { + return OperationView.Fail("profile_group_empty", ApiMessageKeys.ProfileGrouped, result.Data); + } + + _events.Publish("profiles-changed", new { subscriptionId = subscription?.Id ?? string.Empty }); + return OperationView.Ok(ApiMessageKeys.ProfileGrouped, result.Data); + } + + public async Task> ExportProfileDataAsync(ProfileExportRequest request) + { + var items = await AppManager.Instance.GetProfileItemsOrderedByIndexIds(request.ProfileIds); + var output = new List(); + + if (request.IncludeShareUris) + { + var uris = items.Select(FmtHandler.GetShareUri).Where(uri => !string.IsNullOrWhiteSpace(uri)).ToArray(); + if (uris.Length > 0) + { + var content = string.Join(Environment.NewLine, uris) + Environment.NewLine; + output.Add(new(string.Empty, string.Empty, request.Base64ShareUris ? "share-uri-base64" : "share-uri", + request.Base64ShareUris ? Utils.Base64Encode(content) : content)); + } + } + + if (request.IncludeInnerUri && items.Count > 0) + { + var innerUri = InnerFmt.ToUri(items); + if (!string.IsNullOrWhiteSpace(innerUri)) + { + output.Add(new(string.Empty, string.Empty, "inner-uri", innerUri)); + } + } + + if (request.IncludeClientConfig) + { + foreach (var item in items) + { + var (context, validation) = await CoreConfigContextBuilder.Build(Config, item); + if (validation.Success) + { + var generated = await CoreConfigHandler.GenerateClientConfig(context, null); + if (generated.Success && generated.Data is string configText) + { + output.Add(new(item.IndexId, item.Remarks, "client-config", configText)); + } + } + } + } + return output; + } + + public async Task GetSubscriptionShareAsync(string id) + { + return (await AppManager.Instance.GetSubItem(id))?.Url; + } + + public async Task StartSpeedTestAsync(SpeedTestRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var action = request.Action == ESpeedActionType.FastRealping ? ESpeedActionType.Realping : request.Action; + var profiles = request.ProfileIds is { Length: > 0 } + ? await AppManager.Instance.GetProfileItemsOrderedByIndexIds(request.ProfileIds) + : await AppManager.Instance.ProfileItems(Config.SubIndexId ?? string.Empty) ?? []; + if (profiles.Count == 0) + { + return OperationView.Fail("speedtest_profile_selection_required", ApiMessageKeys.SpeedTestSelectRequired); + } + + if (action != ESpeedActionType.Tcping + && profiles.Where(item => !item.ConfigType.IsComplexType()) + .Any(item => AppManager.Instance.GetCoreType(item, item.ConfigType) is not (ECoreType.Xray or ECoreType.sing_box))) + { + return OperationView.Fail("speedtest_core_unsupported", ApiMessageKeys.SpeedTestUnsupportedCore); + } + + lock (_speedtestGate) + { + if (_speedtestTask is { IsCompleted: false }) + { + return OperationView.Fail("speedtest_busy", ApiMessageKeys.SpeedTestBusy); + } + var speedtestService = _speedtestService ??= CreateSpeedtestService(); + var speedtestCancellation = new CancellationTokenSource(); + _speedtestCancellation = speedtestCancellation; + _speedtestTask = Task.Run(async () => + { + try + { + using var requested = CancellationTokenSource.CreateLinkedTokenSource(_operations.ShutdownToken, speedtestCancellation.Token); + await using var operation = await _operations.EnterOperationAsync(requested.Token); + operation.Token.ThrowIfCancellationRequested(); + await speedtestService.RunLoop(action, profiles, operation.Token); + } + catch (OperationCanceledException) when (_operations.IsStopping) + { + // Expected during graceful service shutdown. + } + catch (OperationCanceledException) when (speedtestCancellation.IsCancellationRequested) + { + // Expected when the caller stops the active speed test. + } + catch (Exception ex) + { + AddLog("speedtest", ex.Message); + } + finally + { + lock (_speedtestGate) + { + if (ReferenceEquals(_speedtestCancellation, speedtestCancellation)) + { + _speedtestCancellation = null; + } + } + speedtestCancellation.Dispose(); + } + }); + } + _events.Publish("speedtest-started", new + { + code = "speedtest_started", + messageKey = ApiMessageKeys.SpeedTestStarted, + action = System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(action.ToString()), + profileIds = profiles.Select(item => item.IndexId), + }); + return OperationView.Ok(ApiMessageKeys.SpeedTestStarted, new { action = action.ToString(), profileCount = profiles.Count }); + } + + private SpeedtestService CreateSpeedtestService() => new(Config, result => + { + int? delay = int.TryParse(result.Delay, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsedDelay) + ? parsedDelay + : null; + decimal? speed = decimal.TryParse(result.Speed, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var parsedSpeed) + ? parsedSpeed + : null; + _events.Publish("speedtest-result", new + { + code = ApiMessageKeys.SpeedTestResult, + messageKey = ApiMessageKeys.SpeedTestResult, + indexId = result.IndexId, + delay, + speed, + ipInfo = result.IpInfo, + rawResult = delay is null && speed is null ? (result.Delay ?? result.Speed) : null, + }); + if (!string.IsNullOrEmpty(result.IndexId)) + { + AddLog("speedtest", $"{result.IndexId}: delay={result.Delay}, speed={result.Speed}"); + } + return Task.CompletedTask; + }); + + public OperationView StopSpeedTests() + { + lock (_speedtestGate) + { + _speedtestCancellation?.Cancel(); + _speedtestService?.ExitLoop(); + } + return OperationView.Ok(ApiMessageKeys.CommonCompleted); + } + + public IReadOnlyList GetRunningSubscriptionUpdates() + { + lock (_subscriptionGate) + { + return _subscriptionTasks.Where(pair => !pair.Value.IsCompleted).Select(pair => pair.Key).ToArray(); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Scheduling.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Scheduling.cs new file mode 100644 index 00000000..f76d4bc3 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Scheduling.cs @@ -0,0 +1,174 @@ +using ServiceLib.Common; +using ServiceLib.Handler; +using ServiceLib.Manager; +using ServiceLib.Models.Entities; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + private CancellationTokenSource? _scheduledOperationsCancellation; + private Task? _scheduledOperationsTask; + + private void StartScheduledOperations(CancellationToken hostToken) + { + _scheduledOperationsCancellation = CancellationTokenSource.CreateLinkedTokenSource(hostToken, _operations.ShutdownToken); + var cancellationToken = _scheduledOperationsCancellation.Token; + _scheduledOperationsTask = Task.Run(() => RunScheduledOperationsAsync(cancellationToken)); + } + + private async Task StopScheduledOperationsAsync(CancellationToken shutdownToken) + { + var cancellation = _scheduledOperationsCancellation; + var task = _scheduledOperationsTask; + if (cancellation is null || task is null) + { + return true; + } + + cancellation.Cancel(); + try + { + await task.WaitAsync(shutdownToken); + } + catch (OperationCanceledException) when (task.IsCompleted && cancellation.IsCancellationRequested) + { + // Expected during graceful service shutdown. + } + finally + { + if (task.IsCompleted) + { + cancellation.Dispose(); + _scheduledOperationsCancellation = null; + _scheduledOperationsTask = null; + } + } + + return true; + } + + private async Task RunScheduledOperationsAsync(CancellationToken cancellationToken) + { + var elapsedMinutes = 1; + while (true) + { + await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); + await RunScheduledStepAsync("subscription", RunScheduledSubscriptionUpdatesAsync, cancellationToken); + + if (elapsedMinutes % 20 == 0) + { + await RunScheduledStepAsync("save", async token => + { + await using var operation = await _operations.EnterOperationAsync(token); + await _mutations.RunAsync(async () => + { + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + await ProfileExManager.Instance.SaveTo(); + }, operation.Token); + }, cancellationToken); + } + + if (elapsedMinutes % 60 == 0) + { + await RunScheduledStepAsync("cleanup", async token => + { + await using var operation = await _operations.EnterOperationAsync(token); + FileUtils.DeleteExpiredFiles(Utils.GetBinConfigPath(), DateTime.Now.AddHours(-1), "Test"); + FileUtils.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddDays(-7)); + FileUtils.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddDays(-7)); + }, cancellationToken); + + var elapsedHours = elapsedMinutes / 60; + if (Config.GuiItem.AutoUpdateInterval > 0 && elapsedHours % Config.GuiItem.AutoUpdateInterval == 0) + { + await RunScheduledStepAsync("geo-update", RunScheduledGeoUpdateAsync, cancellationToken); + } + } + + if (elapsedMinutes % 1440 == 1) + { + await RunScheduledStepAsync("core-update-check", RunScheduledCoreUpdateCheckAsync, cancellationToken); + } + elapsedMinutes++; + } + } + + private async Task RunScheduledStepAsync(string name, Func step, CancellationToken cancellationToken) + { + try + { + await step(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + AddLog("task", $"{name}: {exception.Message}"); + } + } + + private async Task RunScheduledSubscriptionUpdatesAsync(CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + IReadOnlyList subscriptions; + await using (var operation = await _operations.EnterOperationAsync(cancellationToken)) + { + subscriptions = (await AppManager.Instance.SubItems() ?? []) + .Where(item => item.AutoUpdateInterval > 0 + && now - item.UpdateTime >= item.AutoUpdateInterval * 60L) + .ToArray(); + } + + foreach (var subscription in subscriptions) + { + cancellationToken.ThrowIfCancellationRequested(); + var update = StartSubscriptionUpdateTask(subscription.Id, useProxy: true, cancellationToken); + if (update is not null) + { + await update.WaitAsync(cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } + } + + private async Task RunScheduledGeoUpdateAsync(CancellationToken cancellationToken) + { + await using var operation = await _operations.EnterExclusiveAsync( + cancellationToken, + allowReadOnlyObservations: true); + await new UpdateService(Config, (success, message) => + { + AddLog("update", message); + _events.Publish("geo-update-progress", new + { + success, + code = success ? "ok" : "geo_update_progress", + messageKey = success ? ApiMessageKeys.CommonCompleted : ApiMessageKeys.GeoUpdateProgress, + rawLog = message, + }); + if (success) + { + _events.Publish("geo-update-completed", OperationView.Ok(ApiMessageKeys.CommonCompleted)); + } + return Task.CompletedTask; + }).UpdateGeoFileAll(blProxy: true, cancellationToken: operation.Token); + } + + private async Task RunScheduledCoreUpdateCheckAsync(CancellationToken cancellationToken) + { + await using var operation = await _operations.EnterOperationAsync(cancellationToken); + var updateService = new UpdateService(Config, (_, _) => Task.CompletedTask); + var messages = await updateService.CheckHasUpdateOnlyAll( + Config.CheckUpdateItem.UpdateViaProxy, + operation.Token); + foreach (var message in messages) + { + AddLog("update", message); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Settings.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Settings.cs new file mode 100644 index 00000000..b0f35980 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Settings.cs @@ -0,0 +1,1784 @@ +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Helper; +using ServiceLib.Manager; +using ServiceLib.Models.Configs; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Entities; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + private const int DefaultWireGuardMtu = 1280; + private static readonly Lazy ProfileEditorOptionsValue = new(CreateProfileEditorOptions); + + private static readonly EConfigType[] WebEditableCoreTypes = + [ + EConfigType.VMess, + EConfigType.Custom, + EConfigType.Shadowsocks, + EConfigType.SOCKS, + EConfigType.VLESS, + EConfigType.Trojan, + EConfigType.Hysteria2, + EConfigType.WireGuard, + ]; + + public Task GetSettingsAsync() + { + var inbound = Config.Inbound[0]; + return Task.FromResult(new WebSettingsView( + new InboundSettingsView( + inbound.LocalPort, + inbound.SecondLocalPortEnabled, + inbound.UdpEnabled, + inbound.SniffingEnabled, + inbound.DestOverride?.ToArray() ?? [], + inbound.RouteOnly, + inbound.AllowLANConn, + inbound.NewPort4LAN, + inbound.User, + inbound.Pass), + new CoreSettingsView( + Config.CoreBasicItem.LogEnabled, + Config.CoreBasicItem.Loglevel, + Config.CoreBasicItem.DefFingerprint, + Config.CoreBasicItem.DefUserAgent, + Config.CoreBasicItem.SendThrough, + Config.CoreBasicItem.BindInterface, + Config.Mux4RayItem.Concurrency, + Config.Mux4RayItem.XudpConcurrency, + Config.Mux4RayItem.XudpProxyUDP443, + Config.Mux4SboxItem.Protocol, + Config.Mux4SboxItem.Padding, + Config.CoreBasicItem.EnableCacheFile4Sbox, + Config.HysteriaItem.UpMbps, + Config.HysteriaItem.DownMbps, + Config.CoreBasicItem.EnableFragment, + Config.CoreBasicItem.EnableFinalFragment, + Config.Fragment4RayItem?.Packets, + Config.Fragment4RayItem?.Lengths?.ToArray() ?? [], + Config.Fragment4RayItem?.Delays?.ToArray() ?? [], + Config.Fragment4RayItem?.MaxSplit), + new AppSettingsView( + Config.GuiItem.EnableStatistics, + Config.GuiItem.DisplayRealTimeSpeed, + Config.GuiItem.KeepOlderDedupl, + Config.GuiItem.AutoUpdateInterval, + Config.GuiItem.RootCertProvider, + Config.ConstItem.GeoSourceUrl, + Config.ConstItem.SrsSourceUrl, + Config.ConstItem.RouteRulesTemplateSourceUrl, + Config.ConstItem.SubConvertUrl), + new SpeedTestSettingsView( + Config.SpeedTestItem.SpeedTestTimeout, + Config.SpeedTestItem.SpeedTestUrl, + Config.SpeedTestItem.SpeedPingTestUrl, + Config.SpeedTestItem.MixedConcurrencyCount, + Config.SpeedTestItem.IPAPIUrl, + Config.SpeedTestItem.UdpTestTarget), + Config.RoutingBasicItem.DomainStrategy, + Config.RoutingBasicItem.DomainStrategy4Singbox, + Config.CoreTypeItem + .Where(item => WebEditableCoreTypes.Contains(item.ConfigType)) + .Select(item => new CoreTypeMapping(item.ConfigType, item.CoreType)) + .ToArray(), + GetWebSettingsOptions(), + ShouldShowIpInfoColumn(Config.SpeedTestItem.IPAPIUrl, Config.UiItem.HideColumnIpInfo))); + } + + public static WebSettingsOptionsView GetWebSettingsOptions() => new( + Global.LogLevels.ToArray(), + Global.RootCertProviders.ToArray(), + Global.Fingerprints.ToArray(), + Global.UserAgent.ToArray(), + Global.SingboxMuxs.ToArray(), + ["reject", "skip"], + Global.FragmentPacketsOptions.ToArray(), + Global.destOverrideProtocols.ToArray(), + Global.DomainStrategies.ToArray(), + Global.DomainStrategies4Sbox.ToArray(), + Global.DomainStrategies.AppendEmpty().ToArray(), + Global.DomainStrategies4Sbox.ToArray(), + Enumerable.Range(Global.SpeedTestConcurrencyCountMin, 20).ToArray(), + Enumerable.Range(2, 5).Select(value => value * 5).ToArray(), + Global.SpeedTestUrls.ToArray(), + Global.SpeedPingTestUrls.ToArray(), + Global.UdpTestTargets.ToArray(), + Global.IPAPIUrls.ToArray(), + Global.SubConvertUrls.ToArray(), + Global.GeoFilesSources.ToArray(), + Global.SingboxRulesetSources.ToArray(), + Global.RoutingRulesSources.ToArray()); + + public static WebEditorOptionsView GetEditorOptions() => new( + new SubscriptionEditorOptionsView( + Global.SubConvertTargets.ToArray(), + Enum.GetValues().Where(coreType => coreType != ECoreType.v2rayN).Select(coreType => coreType.ToString()).ToArray()), + ProfileEditorOptionsValue.Value, + GetDnsEditorOptions()); + + private static ProfileEditorOptionsView CreateProfileEditorOptions() + { + var shadowsocksMethods = Enum.GetValues() + .Where(coreType => coreType != ECoreType.v2rayN) + .ToDictionary( + coreType => coreType.ToString(), + coreType => coreType switch + { + ECoreType.v2fly => Global.SsSecurities.ToArray(), + ECoreType.Xray => Global.SsSecuritiesInXray.ToArray(), + _ => Global.SsSecuritiesInSingbox.ToArray(), + }, + StringComparer.OrdinalIgnoreCase); + + return new ProfileEditorOptionsView( + Global.CoreTypes.ToArray(), + Enum.GetValues().Select(configType => configType.ToString()).ToArray(), + Global.SingboxOnlyConfigType.Select(configType => configType.ToString()).ToArray(), + Global.Networks.ToArray(), + Global.DefaultNetwork, + new ProfileItem().StreamSecurity, + Global.DefaultSecurity, + Global.None, + Global.DefaultXhttpMode, + Global.GrpcGunMode, + DefaultWireGuardMtu, + [Global.None, Global.RawHeaderHttp], + Global.XhttpMode.ToArray(), + [Global.None, .. Global.KcpHeaderTypes], + [Global.GrpcGunMode, Global.GrpcMultiMode], + Global.VmessSecurities.ToArray(), + Global.Flows.ToArray(), + Global.TuicCongestionControls.ToArray(), + Global.NaiveCongestionControls.ToArray(), + Global.Fingerprints.ToArray(), + Global.Alpns.ToArray(), + shadowsocksMethods, + [string.Empty, Global.StreamSecurity, Global.StreamSecurityReality], + Enum.GetNames()); + } + + public static DnsEditorOptionsView GetDnsEditorOptions() => new( + Global.CoreTypes.ToArray(), + Global.DomainStrategy.ToArray(), + Global.DomainStrategies4Sbox.ToArray(), + Global.DomainPureIPDNSAddress.ToArray(), + Global.DomainDirectDNSAddress.ToArray(), + Global.DomainRemoteDNSAddress.ToArray(), + Global.DomainPureIPDNSAddress.ToArray(), + Global.FakeIPRanges.ToArray(), + Global.ExpectedIPs.ToArray()); + + public IReadOnlyList GetDefaultDnsProfiles() => + [ + new(ECoreType.Xray, + EmbedUtils.GetEmbedText(Global.DNSV2rayNormalFileName), + EmbedUtils.GetEmbedText(Global.DNSV2rayNormalFileName)), + new(ECoreType.sing_box, + EmbedUtils.GetEmbedText(Global.DNSSingboxNormalFileName), + EmbedUtils.GetEmbedText(Global.TunSingboxDNSFileName)), + ]; + + public async Task ApplySettingsAsync(SettingsApplyInput input) + { + var inbound = input.Inbound; + var core = input.Core; + var application = input.Application; + var speed = input.SpeedTest; + var highestPortOffset = inbound.SecondLocalPortEnabled || (inbound.AllowLANConn && inbound.NewPort4LAN) ? 2 : 0; + if (inbound.LocalPort <= 0 || inbound.LocalPort > 65535 - highestPortOffset + || !AreDestOverrideProtocolsValid(inbound.DestOverride)) + { + return OperationView.Fail("inbound_port_invalid", ApiMessageKeys.SettingsInvalidPort); + } + if (!ValidateCoreSettingsInput(core)) + { + return OperationView.Fail("core_setting_invalid", ApiMessageKeys.SettingsInvalidCoreValue); + } + if (application.GeoAutoUpdateInterval < 0 + || !IsRootCertProviderValid(application.RootCertProvider) + || speed.SpeedTestTimeout <= 0 || speed.MixedConcurrencyCount <= 0 + || !IsHttpUrl(speed.SpeedTestUrl) || !IsHttpUrl(speed.SpeedPingTestUrl)) + { + return OperationView.Fail("settings_apply_invalid", ApiMessageKeys.SettingsInvalidSpeedTest); + } + if (!AreWebCoreTypeMappingsValid(input.CoreTypes) + || !Global.DomainStrategies.Contains(input.DomainStrategy ?? string.Empty) + || !Global.DomainStrategies4Sbox.Contains(input.DomainStrategy4Singbox ?? string.Empty)) + { + return OperationView.Fail("settings_apply_invalid", ApiMessageKeys.CommonInvalidInput); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var coreFingerprintBefore = GetCoreConfigurationFingerprint(); + var oldConfig = applyContext.OldConfig; + var statisticsChanged = Config.GuiItem.EnableStatistics != application.EnableStatistics + || Config.GuiItem.DisplayRealTimeSpeed != application.DisplayRealTimeSpeed; + try + { + await _mutations.RunAsync(async () => + { + ApplyInboundSettings(Config.Inbound[0], inbound); + + ApplyCoreSettingsToConfig(core); + Config.GuiItem.EnableStatistics = application.EnableStatistics; + Config.GuiItem.DisplayRealTimeSpeed = application.DisplayRealTimeSpeed; + Config.GuiItem.KeepOlderDedupl = application.KeepOlderDedupl; + Config.GuiItem.AutoUpdateInterval = Math.Max(0, application.GeoAutoUpdateInterval); + Config.GuiItem.RootCertProvider = application.RootCertProvider; + Config.ConstItem.GeoSourceUrl = application.GeoSourceUrl; + Config.ConstItem.SrsSourceUrl = application.SrsSourceUrl; + Config.ConstItem.RouteRulesTemplateSourceUrl = application.RouteRulesTemplateSourceUrl; + Config.ConstItem.SubConvertUrl = application.SubConvertUrl; + + Config.SpeedTestItem.SpeedTestTimeout = speed.SpeedTestTimeout; + Config.SpeedTestItem.SpeedTestUrl = speed.SpeedTestUrl; + Config.SpeedTestItem.SpeedPingTestUrl = speed.SpeedPingTestUrl; + Config.SpeedTestItem.MixedConcurrencyCount = Math.Max(speed.MixedConcurrencyCount, Global.SpeedTestConcurrencyCountMin); + Config.SpeedTestItem.IPAPIUrl = speed.IPAPIUrl; + Config.SpeedTestItem.UdpTestTarget = speed.UdpTestTarget; + + EnsureCoreTypeMappings(); + foreach (var mapping in input.CoreTypes) + { + var existing = Config.CoreTypeItem.FirstOrDefault(item => item.ConfigType == mapping.ConfigType); + if (existing is not null) + { + existing.CoreType = mapping.CoreType; + } + } + Config.RoutingBasicItem.DomainStrategy = input.DomainStrategy!; + Config.RoutingBasicItem.DomainStrategy4Singbox = input.DomainStrategy4Singbox!; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + } + catch (Exception exception) + { + RestoreConfigValues(Config, oldConfig); + try + { + await _mutations.RunAsync(() => EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config))); + } + catch (Exception rollbackException) + { + AddLog("settings", $"Settings rollback persistence failed: {rollbackException}"); + } + AddLog("settings", $"Settings transaction failed before Core apply: {exception}"); + return OperationView.Fail("settings_transaction_failed", ApiMessageKeys.CommonInternal, + new { stage = "config_save", exceptionType = exception.GetType().Name, detail = exception.Message }); + } + + if (statisticsChanged) + { + StatisticsManager.Instance.Close(); + _latestTraffic = null; + if (Config.GuiItem.EnableStatistics || Config.GuiItem.DisplayRealTimeSpeed) + { + try + { + await StatisticsManager.Instance.Init(Config, OnStatisticsUpdateAsync); + } + catch (Exception exception) + { + AddLog("settings", $"Statistics reinitialization failed after settings were saved: {exception}"); + } + } + } + + var coreChanged = coreFingerprintBefore != GetCoreConfigurationFingerprint(); + return await CompleteCoreAffectingChangeAsync("all-settings", coreChanged, applyContext); + } + + public async Task UpdateInboundSettingsAsync(InboundSettingsInput input) + { + var highestPortOffset = input.SecondLocalPortEnabled || (input.AllowLANConn && input.NewPort4LAN) ? 2 : 0; + if (input.LocalPort <= 0 || input.LocalPort > 65535 - highestPortOffset) + { + return OperationView.Fail("inbound_port_invalid", ApiMessageKeys.SettingsInvalidPort); + } + if (!AreDestOverrideProtocolsValid(input.DestOverride)) + { + return OperationView.Fail("inbound_dest_override_invalid", ApiMessageKeys.CommonInvalidInput); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var fingerprint = GetCoreConfigurationFingerprint(); + await _mutations.RunAsync(async () => + { + ApplyInboundSettings(Config.Inbound[0], input); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + + return await CompleteCoreAffectingChangeAsync("inbound", fingerprint != GetCoreConfigurationFingerprint(), applyContext); + } + + public async Task UpdateCoreSettingsAsync(CoreSettingsInput input) + { + if (!AreCoreSettingsOptionsValid(input)) + { + return OperationView.Fail("core_setting_option_invalid", ApiMessageKeys.SettingsInvalidCoreValue); + } + if (!Global.LogLevels.Contains(input.Loglevel ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + { + return OperationView.Fail("core_log_level_invalid", ApiMessageKeys.SettingsInvalidCoreLogLevel); + } + if (input.Mux4RayConcurrency is < 0 or > 1024 || input.Mux4RayXudpConcurrency is < 0 or > 1024 + || input.Hy2UpMbps < 0 || input.Hy2DownMbps < 0) + { + return OperationView.Fail("core_setting_out_of_range", ApiMessageKeys.SettingsInvalidCoreValue); + } + var fragmentLengths = input.FragmentLengths ?? []; + var fragmentDelays = input.FragmentDelays ?? []; + if (fragmentLengths.Any(value => !Utils.TryParseRange(value, 0, int.MaxValue, out _, out _)) + || fragmentDelays.Any(value => !Utils.TryParseRange(value, 0, int.MaxValue, out _, out _)) + || (!string.IsNullOrWhiteSpace(input.FragmentMaxSplit) + && !Utils.TryParseMaxSplit(input.FragmentMaxSplit, 0, 10000, out _, out _))) + { + return OperationView.Fail("fragment_setting_invalid", ApiMessageKeys.SettingsInvalidFragment); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var fingerprint = GetCoreConfigurationFingerprint(); + await _mutations.RunAsync(async () => + { + Config.CoreBasicItem.LogEnabled = input.LogEnabled; + Config.CoreBasicItem.Loglevel = input.Loglevel ?? string.Empty; + Config.CoreBasicItem.DefFingerprint = input.DefFingerprint?.Trim() ?? string.Empty; + Config.CoreBasicItem.DefUserAgent = input.DefUserAgent?.Trim() ?? string.Empty; + Config.CoreBasicItem.SendThrough = input.SendThrough?.Trim(); + Config.CoreBasicItem.BindInterface = input.BindInterface?.Trim(); + Config.Mux4RayItem.Concurrency = input.Mux4RayConcurrency is > 0 ? input.Mux4RayConcurrency : null; + Config.Mux4RayItem.XudpConcurrency = input.Mux4RayXudpConcurrency is > 0 ? input.Mux4RayXudpConcurrency : null; + Config.Mux4RayItem.XudpProxyUDP443 = input.Mux4RayXudpProxyUDP443; + Config.Mux4SboxItem.Protocol = input.Mux4SboxProtocol ?? string.Empty; + Config.Mux4SboxItem.Padding = input.Mux4SboxPadding; + Config.CoreBasicItem.EnableCacheFile4Sbox = input.EnableCacheFile4Sbox; + Config.HysteriaItem.UpMbps = input.Hy2UpMbps; + Config.HysteriaItem.DownMbps = input.Hy2DownMbps; + Config.CoreBasicItem.EnableFragment = input.EnableFragment; + Config.CoreBasicItem.EnableFinalFragment = input.EnableFinalFragment; + Config.Fragment4RayItem ??= new(); + Config.Fragment4RayItem.Packets = input.FragmentPackets; + Config.Fragment4RayItem.Lengths = fragmentLengths.ToList(); + Config.Fragment4RayItem.Delays = fragmentDelays.ToList(); + Config.Fragment4RayItem.MaxSplit = input.FragmentMaxSplit; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + + return await CompleteCoreAffectingChangeAsync("core", fingerprint != GetCoreConfigurationFingerprint(), applyContext); + } + + public async Task UpdateAppSettingsAsync(AppSettingsInput input) + { + if (input.GeoAutoUpdateInterval < 0 || !IsRootCertProviderValid(input.RootCertProvider)) + { + return OperationView.Fail("app_setting_invalid", ApiMessageKeys.CommonInvalidInput); + } + + var statisticsChanged = Config.GuiItem.EnableStatistics != input.EnableStatistics + || Config.GuiItem.DisplayRealTimeSpeed != input.DisplayRealTimeSpeed; + + await _mutations.RunAsync(async () => + { + Config.GuiItem.EnableStatistics = input.EnableStatistics; + Config.GuiItem.DisplayRealTimeSpeed = input.DisplayRealTimeSpeed; + Config.GuiItem.KeepOlderDedupl = input.KeepOlderDedupl; + Config.GuiItem.AutoUpdateInterval = Math.Max(0, input.GeoAutoUpdateInterval); + Config.GuiItem.RootCertProvider = input.RootCertProvider; + Config.ConstItem.GeoSourceUrl = input.GeoSourceUrl; + Config.ConstItem.SrsSourceUrl = input.SrsSourceUrl; + Config.ConstItem.RouteRulesTemplateSourceUrl = input.RouteRulesTemplateSourceUrl; + Config.ConstItem.SubConvertUrl = input.SubConvertUrl; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + + if (statisticsChanged) + { + StatisticsManager.Instance.Close(); + _latestTraffic = null; + if (Config.GuiItem.EnableStatistics || Config.GuiItem.DisplayRealTimeSpeed) + { + await StatisticsManager.Instance.Init(Config, OnStatisticsUpdateAsync); + } + } + + _events.Publish("settings-changed", new { section = "application", restartRequired = false }); + return OperationView.Ok(ApiMessageKeys.CommonSaved); + } + + public async Task UpdateSpeedTestSettingsAsync(SpeedTestSettingsInput input) + { + if (input.SpeedTestTimeout <= 0 || input.MixedConcurrencyCount <= 0 + || !IsHttpUrl(input.SpeedTestUrl) || !IsHttpUrl(input.SpeedPingTestUrl)) + { + return OperationView.Fail("speedtest_settings_invalid", ApiMessageKeys.SettingsInvalidSpeedTest); + } + + await _mutations.RunAsync(async () => + { + Config.SpeedTestItem.SpeedTestTimeout = input.SpeedTestTimeout; + Config.SpeedTestItem.SpeedTestUrl = input.SpeedTestUrl; + Config.SpeedTestItem.SpeedPingTestUrl = input.SpeedPingTestUrl; + Config.SpeedTestItem.MixedConcurrencyCount = Math.Max(input.MixedConcurrencyCount, Global.SpeedTestConcurrencyCountMin); + Config.SpeedTestItem.IPAPIUrl = input.IPAPIUrl; + Config.SpeedTestItem.UdpTestTarget = input.UdpTestTarget; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + _events.Publish("settings-changed", new { section = "speedtest", restartRequired = false }); + return OperationView.Ok(ApiMessageKeys.SpeedTestSettingsSaved); + } + + public async Task UpdateCoreTypeMappingsAsync(IEnumerable mappings) + { + var requestedMappings = mappings.ToArray(); + if (!AreWebCoreTypeMappingsValid(requestedMappings)) + { + return OperationView.Fail("core_type_mapping_invalid", ApiMessageKeys.CommonInvalidInput); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var fingerprint = GetCoreConfigurationFingerprint(); + await _mutations.RunAsync(async () => + { + EnsureCoreTypeMappings(); + foreach (var mapping in requestedMappings) + { + var item = Config.CoreTypeItem.FirstOrDefault(entry => entry.ConfigType == mapping.ConfigType); + if (item is not null) + { + item.CoreType = mapping.CoreType; + } + } + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + return await CompleteCoreAffectingChangeAsync("core-types", fingerprint != GetCoreConfigurationFingerprint(), applyContext); + } + + private void EnsureCoreTypeMappings() + { + Config.CoreTypeItem ??= []; + foreach (var configType in Enum.GetValues()) + { + if (Config.CoreTypeItem.All(item => item.ConfigType != configType)) + { + Config.CoreTypeItem.Add(new CoreTypeItem { ConfigType = configType, CoreType = ECoreType.Xray }); + } + } + } + + public async Task UpdateRoutingStrategiesAsync(RoutingSettingsInput input) + { + if (!Global.DomainStrategies.Contains(input.DomainStrategy ?? string.Empty) + || !Global.DomainStrategies4Sbox.Contains(input.DomainStrategy4Singbox ?? string.Empty)) + { + return OperationView.Fail("routing_strategy_invalid", ApiMessageKeys.SettingsInvalidRoutingStrategy); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var fingerprint = GetCoreConfigurationFingerprint(); + await _mutations.RunAsync(async () => + { + Config.RoutingBasicItem.DomainStrategy = input.DomainStrategy!; + Config.RoutingBasicItem.DomainStrategy4Singbox = input.DomainStrategy4Singbox!; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + return await CompleteCoreAffectingChangeAsync("routing", fingerprint != GetCoreConfigurationFingerprint(), applyContext); + } + + public Task GetSimpleDNSAsync() => Task.FromResult(Config.SimpleDNSItem); + + public async Task UpdateSimpleDNSAsync(SimpleDNSItem input) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var fingerprint = GetCoreConfigurationFingerprint(); + await _mutations.RunAsync(async () => + { + Config.SimpleDNSItem = input; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + return await CompleteCoreAffectingChangeAsync("dns", fingerprint != GetCoreConfigurationFingerprint(), applyContext); + } + + public async Task> GetDnsProfilesAsync() => + (await AppManager.Instance.DNSItems() ?? []) + .Select(ToDnsProfileView) + .ToArray(); + + public async Task UpdateDnsProfileAsync(ECoreType coreType, DnsProfileInput input) + { + if (!IsValidCustomDns(coreType, input.NormalDNS) + || !IsValidCustomDns(coreType, input.TunDNS) + || (coreType == ECoreType.Xray && !Global.DomainStrategy.Contains(input.DomainStrategy4Freedom ?? string.Empty)) + || (coreType == ECoreType.sing_box && !Global.DomainStrategies4Sbox.Contains(input.DomainStrategy4Freedom ?? string.Empty))) + { + return OperationView.Fail("dns_profile_invalid", ApiMessageKeys.CommonInvalidInput); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var result = await _mutations.RunAsync(async () => + { + var current = await AppManager.Instance.GetDNSItem(coreType); + if (current is null) + { + return (Result: -2, Changed: false); + } + var changed = current.Enabled != input.Enabled + || current.UseSystemHosts != input.UseSystemHosts + || current.NormalDNS != input.NormalDNS + || current.TunDNS != input.TunDNS + || current.DomainStrategy4Freedom != input.DomainStrategy4Freedom + || current.DomainDNSAddress != input.DomainDNSAddress + || current.Remarks != (input.Remarks ?? current.Remarks); + var item = ApplyDnsProfileInput(current, coreType, input); + return (Result: await ConfigHandler.SaveDNSItems(Config, item), Changed: changed); + }); + if (result.Result == -2) + { + return OperationView.Fail("dns_profile_not_found", ApiMessageKeys.DnsProfileNotFound, new { coreType = coreType.ToString() }); + } + if (result.Result != 0) + { + return OperationView.Fail("dns_profile_save_failed", ApiMessageKeys.DnsSaveFailed); + } + return await CompleteCoreAffectingChangeAsync("dns", result.Changed, applyContext, data: new { coreType = coreType.ToString() }); + } + + internal static DnsProfileView ToDnsProfileView(DNSItem item) => new( + item.Id, + item.Remarks, + item.Enabled, + item.CoreType, + item.UseSystemHosts, + item.NormalDNS, + item.TunDNS, + item.DomainStrategy4Freedom, + item.DomainDNSAddress); + + internal static DNSItem ApplyDnsProfileInput(DNSItem current, ECoreType coreType, DnsProfileInput input) => new() + { + Id = current.Id, + Remarks = input.Remarks ?? current.Remarks, + Enabled = input.Enabled, + CoreType = coreType, + UseSystemHosts = input.UseSystemHosts, + NormalDNS = input.NormalDNS, + TunDNS = input.TunDNS, + DomainStrategy4Freedom = input.DomainStrategy4Freedom, + DomainDNSAddress = input.DomainDNSAddress, + }; + + internal static bool IsValidCustomDns(ECoreType coreType, string? content) + { + if (string.IsNullOrEmpty(content)) return true; + if (coreType == ECoreType.sing_box) + { + var parsed = JsonUtils.Deserialize(content); + return parsed?.servers is { Count: > 0 } + && parsed.servers.All(server => !string.IsNullOrEmpty(server.type)); + } + + if (!content.TrimStart().StartsWith('{')) + { + return !content.Contains('{') && !content.Contains('}'); + } + var json = JsonUtils.ParseJson(content); + return json is not null && json["servers"] is not null; + } + + public async Task> GetRoutingProfilesAsync() => await AppManager.Instance.RoutingItems() ?? []; + + public async Task SaveRoutingProfileAsync(RoutingItem input, string? routingId = null) + { + if (string.IsNullOrWhiteSpace(input.Remarks)) + { + return OperationView.Fail("routing_name_required", ApiMessageKeys.RoutingNameRequired); + } + if (!AreRoutingProfileStrategiesValid(input.DomainStrategy, input.DomainStrategy4Singbox)) + { + return OperationView.Fail("routing_strategy_invalid", ApiMessageKeys.SettingsInvalidRoutingStrategy); + } + + var ruleJson = string.IsNullOrWhiteSpace(input.RuleSet) ? "[]" : input.RuleSet; + var parsedRules = JsonUtils.Deserialize>(ruleJson); + if (parsedRules is null) + { + return OperationView.Fail("routing_rules_json_invalid", ApiMessageKeys.RoutingRulesInvalid); + } + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var save = await _mutations.RunAsync(async () => + { + RoutingItem item; + var changed = string.IsNullOrEmpty(routingId); + if (string.IsNullOrEmpty(routingId)) + { + item = input; + item.Id = string.Empty; + item.IsActive = false; + var existing = await AppManager.Instance.RoutingItems() ?? []; + item.Sort = item.Sort > 0 ? item.Sort : (existing.MaxBy(route => route.Sort)?.Sort ?? 0) + 1; + } + else + { + var existingItem = await AppManager.Instance.GetRoutingItem(routingId); + if (existingItem is null) + { + return (Result: -2, Item: (RoutingItem?)null, Changed: false); + } + item = existingItem; + changed = item.Remarks != input.Remarks + || item.Url != input.Url + || item.Enabled != input.Enabled + || item.Locked != input.Locked + || item.CustomIcon != input.CustomIcon + || item.CustomRulesetPath4Singbox != input.CustomRulesetPath4Singbox + || item.DomainStrategy != input.DomainStrategy + || item.DomainStrategy4Singbox != input.DomainStrategy4Singbox + || item.RuleSet != ruleJson; + item.Remarks = input.Remarks; + item.Url = input.Url; + item.Enabled = input.Enabled; + item.Locked = input.Locked; + item.CustomIcon = input.CustomIcon; + item.CustomRulesetPath4Singbox = input.CustomRulesetPath4Singbox; + item.DomainStrategy = input.DomainStrategy; + item.DomainStrategy4Singbox = input.DomainStrategy4Singbox; + } + + if (parsedRules.Count == 0) + { + item.RuleSet = "[]"; + item.RuleNum = 0; + } + else + { + item.RuleNum = parsedRules.Count; + } + var result = parsedRules.Count == 0 + ? await ConfigHandler.SaveRoutingItem(Config, item) + : await ConfigHandler.AddBatchRoutingRules(item, JsonUtils.Serialize(parsedRules, false)); + return (Result: result, Item: (RoutingItem?)item, Changed: changed); + }); + if (save.Result == -2) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + if (save.Result != 0 || save.Item is null) + { + return OperationView.Fail("routing_profile_save_failed", ApiMessageKeys.RoutingSaveFailed); + } + + return await CompleteCoreAffectingChangeAsync("routing-profiles", save.Changed && save.Item.IsActive, applyContext, + data: new { routingId = save.Item.Id }); + } + + public async Task DeleteRoutingProfileAsync(string id) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var removed = await _mutations.RunAsync(async () => + { + var item = await AppManager.Instance.GetRoutingItem(id); + if (item is null) + { + return (Removed: false, AffectsCore: false); + } + await ConfigHandler.RemoveRoutingItem(item); + if (item.IsActive) + { + var remaining = await AppManager.Instance.RoutingItems() ?? []; + var fallback = remaining.FirstOrDefault(); + if (fallback is not null) + { + if (await ConfigHandler.SetDefaultRouting(Config, fallback) != 0) + { + throw new IOException("ServiceLib could not select the fallback routing profile."); + } + } + else + { + Config.RoutingBasicItem.RoutingIndexId = string.Empty; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + } + } + return (Removed: true, AffectsCore: item.IsActive); + }); + if (!removed.Removed) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + return await CompleteCoreAffectingChangeAsync("routing-profiles", removed.AffectsCore, applyContext, + ApiMessageKeys.CommonDeleted, new { routingId = id }); + } + + public async Task ActivateRoutingProfileAsync(string id) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var result = await _mutations.RunAsync(async () => + { + var item = await AppManager.Instance.GetRoutingItem(id); + if (item is null) + { + return (Result: -2, Changed: false); + } + var changed = !item.IsActive; + return (Result: changed ? await ConfigHandler.SetDefaultRouting(Config, item) : 0, Changed: changed); + }); + if (result.Result == -2) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + if (result.Result != 0) + { + return OperationView.Fail("routing_profile_activate_failed", ApiMessageKeys.RoutingSaveFailed); + } + return await CompleteCoreAffectingChangeAsync("routing-profiles", result.Changed, applyContext, + ApiMessageKeys.CommonCompleted, new { routingId = id }); + } + + public async Task> GetRoutingRulesAsync(string routingId) + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + return JsonUtils.Deserialize>(item?.RuleSet ?? "[]") ?? []; + } + + public async Task SaveRoutingRulesAsync(string routingId, IEnumerable rules) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var items = rules.ToArray(); + var before = await AppManager.Instance.GetRoutingItem(routingId); + var changed = before is not null && before.IsActive + && before.RuleSet != JsonUtils.Serialize(items, false); + var saved = await _mutations.RunAsync(() => SaveRoutingRulesLockedAsync(routingId, items)); + if (saved == -2) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + if (saved != 0) + { + return OperationView.Fail("routing_rules_save_failed", ApiMessageKeys.RoutingSaveFailed); + } + return await CompleteCoreAffectingChangeAsync("routing-rules", changed, applyContext, data: new { routingId }); + } + + private static async Task SaveRoutingRulesLockedAsync(string routingId, IReadOnlyList rules) + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + if (item is null) + { + return -2; + } + return await ConfigHandler.AddBatchRoutingRules(item, JsonUtils.Serialize(rules, false)); + } + + private async Task GetActiveRoutingSignatureAsync() + { + var active = (await AppManager.Instance.RoutingItems() ?? []).FirstOrDefault(item => item.IsActive); + return active is null + ? string.Empty + : System.Text.Json.JsonSerializer.Serialize(new + { + active.Id, + active.RuleSet, + active.Enabled, + active.DomainStrategy, + active.DomainStrategy4Singbox, + active.CustomRulesetPath4Singbox, + }); + } + + public async Task ImportRoutingRulesAsync(string routingId, RouteRulesImportInput request) + { + var incoming = JsonUtils.Deserialize>(request.Content); + if (incoming is null) + { + return OperationView.Fail("routing_rules_json_invalid", ApiMessageKeys.RoutingRulesInvalid); + } + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var activeBefore = await AppManager.Instance.GetRoutingItem(routingId); + var beforeRuleSet = activeBefore?.RuleSet; + var saved = await _mutations.RunAsync(async () => + { + if (request.Append) + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + if (item is null) + { + return -2; + } + var existing = JsonUtils.Deserialize>(item.RuleSet ?? "[]") ?? []; + existing.AddRange(incoming); + incoming = existing; + } + return await SaveRoutingRulesLockedAsync(routingId, incoming); + }); + if (saved == -2) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + if (saved != 0) + { + return OperationView.Fail("routing_rules_save_failed", ApiMessageKeys.RoutingSaveFailed); + } + var activeAfter = await AppManager.Instance.GetRoutingItem(routingId); + var changed = activeBefore?.IsActive == true && activeAfter?.RuleSet != beforeRuleSet; + return await CompleteCoreAffectingChangeAsync("routing-rules", changed, applyContext, data: new { routingId }); + } + + public async Task ImportRoutingRulesFromUrlAsync(string routingId, bool append, CancellationToken cancellationToken) + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + if (item is null) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + if (!Uri.TryCreate(item.Url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return OperationView.Fail("routing_url_invalid", "routing.urlInvalid"); + } + + var download = new DownloadService(); + var content = await download.TryDownloadString(uri.AbsoluteUri, true, string.Empty, cancellationToken); + if (string.IsNullOrWhiteSpace(content)) + { + return OperationView.Fail("routing_url_download_failed", "routing.urlImportFailedGeneric"); + } + + return await ImportRoutingRulesAsync(routingId, new RouteRulesImportInput(content, append)); + } + + public async Task MoveRoutingRuleAsync(string routingId, string ruleId, EMove direction, int position) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var routeWasActive = (await AppManager.Instance.GetRoutingItem(routingId))?.IsActive == true; + var result = await _mutations.RunAsync(async () => + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + if (item is null) + { + return -2; + } + var rules = JsonUtils.Deserialize>(item.RuleSet ?? "[]") ?? []; + var index = rules.FindIndex(rule => rule.Id == ruleId); + if (index < 0) + { + return -3; + } + if (await ConfigHandler.MoveRoutingRule(rules, index, direction, position) != 0) + { + return -4; + } + return await SaveRoutingRulesLockedAsync(routingId, rules); + }); + if (result != 0) + { + return result switch + { + -2 => OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound), + -3 => OperationView.Fail("routing_rule_not_found", ApiMessageKeys.RoutingRuleNotFound), + -4 => OperationView.Fail("routing_rule_move_failed", ApiMessageKeys.CommonInvalidInput), + _ => OperationView.Fail("routing_rules_save_failed", ApiMessageKeys.RoutingSaveFailed), + }; + } + return await CompleteCoreAffectingChangeAsync("routing-rules", routeWasActive, applyContext, data: new { routingId }); + } + + public async Task DeleteRoutingRuleAsync(string routingId, string ruleId) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var routeWasActive = (await AppManager.Instance.GetRoutingItem(routingId))?.IsActive == true; + var result = await _mutations.RunAsync(async () => + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + if (item is null) + { + return -2; + } + var rules = JsonUtils.Deserialize>(item.RuleSet ?? "[]") ?? []; + if (rules.RemoveAll(rule => rule.Id == ruleId) == 0) + { + return -3; + } + return await SaveRoutingRulesLockedAsync(routingId, rules); + }); + if (result != 0) + { + return result switch + { + -2 => OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound), + -3 => OperationView.Fail("routing_rule_not_found", ApiMessageKeys.RoutingRuleNotFound), + _ => OperationView.Fail("routing_rules_save_failed", ApiMessageKeys.RoutingSaveFailed), + }; + } + return await CompleteCoreAffectingChangeAsync("routing-rules", routeWasActive, applyContext, data: new { routingId }); + } + + public async Task ImportRoutingProfilesAsync() + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var before = await GetActiveRoutingSignatureAsync(); + var result = await _mutations.RunAsync(() => ConfigHandler.InitRouting(Config, true)); + if (result != 0) + { + return OperationView.Fail("routing_import_failed", ApiMessageKeys.RoutingSaveFailed); + } + return await CompleteCoreAffectingChangeAsync("routing-profiles", before != await GetActiveRoutingSignatureAsync(), applyContext, + ApiMessageKeys.CommonCompleted); + } + + public async Task> GetFullConfigTemplatesAsync() => + (await AppManager.Instance.FullConfigTemplateItem() ?? []) + .Select(ToCoreConfigTemplateView) + .ToArray(); + + internal static CoreConfigTemplateView ToCoreConfigTemplateView(FullConfigTemplateItem item) => new( + item.Id, item.Remarks, item.Enabled, item.CoreType, item.Config, item.TunConfig, item.AddProxyOnly, item.ProxyDetour); + + internal static FullConfigTemplateItem ApplyCoreConfigTemplateInput( + FullConfigTemplateItem current, + ECoreType coreType, + CoreConfigTemplateInput input) => new() + { + Id = current.Id, + Remarks = input.Remarks ?? current.Remarks, + Enabled = input.Enabled, + CoreType = coreType, + Config = input.Config, + TunConfig = input.TunConfig, + AddProxyOnly = input.AddProxyOnly, + ProxyDetour = input.ProxyDetour, + }; + + public async Task SaveFullConfigTemplateAsync(ECoreType coreType, CoreConfigTemplateInput input) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var existingBefore = await AppManager.Instance.GetFullConfigTemplateItem(coreType); + var changed = existingBefore is not null + && (existingBefore.Remarks != (input.Remarks ?? existingBefore.Remarks) + || existingBefore.Enabled != input.Enabled + || existingBefore.Config != input.Config + || existingBefore.AddProxyOnly != input.AddProxyOnly + || existingBefore.ProxyDetour != input.ProxyDetour); + var result = await _mutations.RunAsync(async () => + { + var current = await AppManager.Instance.GetFullConfigTemplateItem(coreType); + if (current is null) + { + return -2; + } + var item = ApplyCoreConfigTemplateInput(current, coreType, input); + return await ConfigHandler.SaveFullConfigTemplate(Config, item); + }); + if (result == -2) + { + return OperationView.Fail("core_template_not_found", ApiMessageKeys.CommonNotFound, new { coreType = coreType.ToString() }); + } + if (result != 0) + { + return OperationView.Fail("core_template_save_failed", ApiMessageKeys.CommonInvalidInput); + } + return await CompleteCoreAffectingChangeAsync("core-template", + changed && CurrentCoreRuntime.CoreType == coreType, + applyContext, + data: new { coreType = coreType.ToString() }); + } + + public async Task ApplyRegionalPresetAsync(EPresetType preset, CancellationToken cancellationToken = default) + { + if (preset is not (EPresetType.Default or EPresetType.Russia or EPresetType.Iran)) + { + return OperationView.Fail("regional_preset_invalid", ApiMessageKeys.RegionalPresetInvalid); + } + + await _regionalPresetGate.WaitAsync(cancellationToken); + try + { + var existingDnsItems = await AppManager.Instance.DNSItems() ?? []; + var existingRoutingItems = await AppManager.Instance.RoutingItems() ?? []; + var proxy = preset == EPresetType.Default + ? null + : await GetRegionalPresetProxyAsync(cancellationToken); + var stager = new RegionalPresetStager(async (url, webProxy, token) => + { + var download = new DownloadService(); + return await download.TryDownloadString(url, webProxy, string.Empty, token); + }); + + return await RegionalPresetWorkflow.RunAsync( + token => stager.StageAsync( + preset, + existingDnsItems, + existingRoutingItems, + proxy, + token, + Config.RoutingBasicItem.RoutingIndexId), + ApplyStagedRegionalPresetAsync, + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (RegionalPresetStagingException exception) + { + AddLog("settings", $"Regional preset staging failed ({preset}); no settings were changed: {exception.InnerException}"); + return OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed); + } + catch (Exception exception) + { + AddLog("settings", $"Regional preset workflow failed ({preset}): {exception}"); + return OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed); + } + finally + { + _regionalPresetGate.Release(); + } + } + + private async Task ApplyStagedRegionalPresetAsync( + RegionalPresetStage stage, + CancellationToken cancellationToken) + { + await using var maintenance = await _operations.EnterExclusiveAsync( + cancellationToken, + allowReadOnlyObservations: true); + var operationToken = maintenance.Token; + await using var applyContext = await BeginCoreSettingsApplyAsync(operationToken); + if (applyContext.Failure is { } applyFailure) return applyFailure; + + RegionalPresetApplyResult? applyResult = null; + OperationView? coreApplyResult = null; + CoreSettingsRollbackResult? presetRollback = null; + try + { + await RegionalPresetApplyWorkflow.RunAsync( + applyConfiguration: async token => + { + applyResult = await RegionalPresetTransaction.RunAsync( + () => _mutations.RunAsync(async () => + { + token.ThrowIfCancellationRequested(); + Config.ConstItem.GeoSourceUrl = stage.GeoSourceUrl; + Config.ConstItem.SrsSourceUrl = stage.SrsSourceUrl; + Config.ConstItem.RouteRulesTemplateSourceUrl = stage.RouteRulesTemplateSourceUrl; + Config.SimpleDNSItem = stage.SimpleDnsItem; + + if (stage.Preset == EPresetType.Default) + { + await SQLiteHelper.Instance.DeleteAllAsync(); + foreach (var item in stage.DnsItems) + { + if (await ConfigHandler.SaveDNSItems(Config, item) != 0) + { + throw new IOException($"The {item.CoreType} DNS profile could not be saved."); + } + } + + // Default routing uses embedded data only; this call performs no network I/O + // because the route template source was cleared in the staged plan. + if (await ConfigHandler.InitRouting(Config, false) != 0) + { + throw new IOException("The built-in routing profiles could not be initialized."); + } + } + else + { + foreach (var item in stage.DnsItems) + { + if (await ConfigHandler.SaveDNSItems(Config, item) != 0) + { + throw new IOException($"The {item.CoreType} DNS profile could not be saved."); + } + } + + foreach (var itemId in stage.Routing.RemoveItemIds ?? []) + { + var item = await AppManager.Instance.GetRoutingItem(itemId); + if (item is not null) + { + await ConfigHandler.RemoveRoutingItem(item); + } + } + + if (stage.Routing.ClearLegacyRoutingIndex) + { + Config.RoutingBasicItem.RoutingIndexId = string.Empty; + } + + var currentRoutes = await AppManager.Instance.RoutingItems() ?? []; + var hasVersion = stage.Routing.TemplateVersion is { Length: > 0 } version + && currentRoutes.Any(item => item.Remarks?.StartsWith(version, StringComparison.Ordinal) == true); + var builtinFallbackAlreadyInitialized = stage.Routing.IsBuiltinFallback && currentRoutes.Count > 0; + if (!hasVersion && !builtinFallbackAlreadyInitialized) + { + var nextSort = currentRoutes.Count; + foreach (var item in stage.Routing.Items) + { + item.Sort = ++nextSort; + if (await ConfigHandler.AddBatchRoutingRules(item, item.RuleSet ?? "[]") != 0) + { + throw new IOException($"The routing profile '{item.Remarks}' could not be saved."); + } + } + + var defaultItem = stage.Routing.DefaultRemarks is { } remarks + ? stage.Routing.Items.FirstOrDefault(item => item.Remarks == remarks) + : null; + if (defaultItem is not null && await ConfigHandler.SetDefaultRouting(Config, defaultItem) != 0) + { + throw new IOException("The first staged routing profile could not be activated."); + } + } + + if (stage.Routing.ExistingDefaultItemId is { } existingDefaultId) + { + var existingDefault = currentRoutes.FirstOrDefault(item => item.Id == existingDefaultId); + if (existingDefault is not null && !existingDefault.IsActive + && await ConfigHandler.SetDefaultRouting(Config, existingDefault) != 0) + { + throw new IOException("The migrated default routing profile could not be activated."); + } + } + } + + token.ThrowIfCancellationRequested(); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + token.ThrowIfCancellationRequested(); + }, token), + () => RestoreRegionalPresetSnapshotAsync(applyContext)); + + if (!applyResult.Success) + { + AddLog("settings", $"Regional preset config apply failed ({stage.Preset}); rollback={(applyResult.RollbackSucceeded ? "succeeded" : "failed")}: {applyResult.Error}"); + if (token.IsCancellationRequested) + { + if (!applyResult.RollbackSucceeded) + { + _ = await RollBackCoreSettingsAsync(applyContext, "regional_preset_config_cancelled"); + } + throw new OperationCanceledException(token); + } + if (!applyResult.RollbackSucceeded) + { + var fallbackRollback = await RollBackCoreSettingsAsync(applyContext, "regional_preset_config_apply_failed"); + AddLog("settings", $"Regional preset config rollback fallback ({stage.Preset}) completed={fallbackRollback.RolledBack}; runtimeRestored={fallbackRollback.OldRuntimeRestored}."); + } + throw new RegionalPresetConfigApplyException(); + } + + }, + updateGeoFilesTransaction: (beforeCommit, token) => ApplyGeoFilesUpdateAsync( + Config.CheckUpdateItem.UpdateViaProxy, + token, + publishProgress: false, + beforeCommit: beforeCommit), + completeCoreApply: async (rollbackGeoFiles, token) => + { + // From this point, finish Core apply/recovery even if the HTTP client disconnects. + // A failed Core apply first restores GeoFiles through this transaction scope, + // then the existing settings rollback can relaunch the previous runtime safely. + token.ThrowIfCancellationRequested(); + coreApplyResult = await CompleteCoreAffectingChangeAsync("regional-preset", true, + applyContext, + ApiMessageKeys.CommonCompleted, + new { preset = stage.Preset.ToString() }, + beforeRollback: rollbackGeoFiles); + if (!coreApplyResult.Success) + { + AddLog("settings", $"Regional preset Core restart failed ({stage.Preset}); Core settings rollback was requested."); + throw new RegionalPresetCoreApplyFailedException(); + } + }, + rollbackConfigurationAndRuntime: async () => + { + if (coreApplyResult is { Success: false }) + { + return; + } + + presetRollback = await RollBackCoreSettingsAsync(applyContext, "regional_preset_geofiles_failed"); + if (!presetRollback.RolledBack) + { + AddLog("settings", $"Regional preset rollback failed ({stage.Preset}): {presetRollback.Detail}"); + } + }, + cancellationToken: operationToken); + + return coreApplyResult ?? OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed); + } + catch (RegionalPresetConfigApplyException) + { + return OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed); + } + catch (RegionalPresetCoreApplyFailedException) + { + // The enclosing GeoFiles transaction restored its snapshot before this result is returned. + return coreApplyResult ?? OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed); + } + catch (OperationCanceledException) when (operationToken.IsCancellationRequested) + { + if (presetRollback is not null) + { + AddLog("settings", $"Regional preset GeoFiles update canceled ({stage.Preset}); config/runtime rollback={presetRollback.RolledBack}."); + } + throw; + } + catch (Exception exception) + { + if (coreApplyResult is { Success: false }) + { + // CompleteCoreAffectingChangeAsync already restored config/runtime. This means + // the GeoFiles transaction itself also reported an incomplete file rollback. + AddLog("settings", $"Regional preset GeoFiles rollback failed after Core restart failure ({stage.Preset}): {exception}"); + return coreApplyResult; + } + + AddLog("settings", $"Regional preset GeoFiles update failed ({stage.Preset}); restoring config, DNS, routing, and runtime: {exception}"); + if (presetRollback is null) + { + presetRollback = await RollBackCoreSettingsAsync(applyContext, "regional_preset_geofiles_failed"); + } + var geoFilesRolledBack = exception is not GeoFilesUpdateRollbackException; + return OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed, + new + { + preset = stage.Preset.ToString(), + rolledBack = presetRollback.RolledBack && geoFilesRolledBack, + configAndRuntimeRolledBack = presetRollback.RolledBack, + geoFilesRolledBack, + oldRuntimeRestored = presetRollback.OldRuntimeRestored, + }); + } + } + + private async Task RestoreRegionalPresetSnapshotAsync(CoreSettingsApplyContext context) + { + try + { + await _mutations.RunAsync(async () => + { + RestoreConfigValues(Config, context.OldConfig); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + + await SQLiteHelper.Instance.DeleteAllAsync(); + if (context.OldRoutingItems.Length > 0 + && await SQLiteHelper.Instance.InsertAllAsync(context.OldRoutingItems) != context.OldRoutingItems.Length) + { + throw new IOException("Routing profiles could not be restored from the regional preset snapshot."); + } + await SQLiteHelper.Instance.DeleteAllAsync(); + if (context.OldDnsItems.Length > 0 + && await SQLiteHelper.Instance.InsertAllAsync(context.OldDnsItems) != context.OldDnsItems.Length) + { + throw new IOException("DNS profiles could not be restored from the regional preset snapshot."); + } + AppManager.Instance.Reset(); + }); + return true; + } + catch (Exception exception) + { + AddLog("settings", $"Regional preset rollback failed: {exception}"); + return false; + } + } + + internal static bool ShouldShowIpInfoColumn(string? ipApiUrl, bool hideColumnIpInfo) => + !string.IsNullOrEmpty(ipApiUrl) && !hideColumnIpInfo; + + internal static void ApplyInboundSettings(InItem target, InboundSettingsInput input) + { + var destOverride = NormalizeDestOverride(input.SniffingEnabled, input.DestOverride); + target.LocalPort = input.LocalPort; + target.SecondLocalPortEnabled = input.SecondLocalPortEnabled; + target.UdpEnabled = input.UdpEnabled; + target.SniffingEnabled = input.SniffingEnabled; + target.DestOverride = destOverride.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + target.RouteOnly = input.RouteOnly; + target.AllowLANConn = input.AllowLANConn; + target.NewPort4LAN = input.AllowLANConn && input.NewPort4LAN; + target.User = input.User?.Trim() ?? string.Empty; + target.Pass = input.Pass?.Trim() ?? string.Empty; + } + + internal static string[] NormalizeDestOverride(bool willEnableSniffing, IEnumerable? destOverride) + { + var protocols = destOverride?.ToArray() ?? []; + if (willEnableSniffing && protocols.Length == 0) + { + // Use ServiceLib's defaults so Web stays aligned when upstream changes them. + return new InItem().DestOverride?.ToArray() ?? []; + } + + return protocols; + } + + public async Task ClearStatisticsAsync() + { + await _mutations.RunAsync(() => StatisticsManager.Instance.ClearAllServerStatistics()); + _latestTraffic = null; + _events.Publish("traffic", null!); + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.CommonDeleted); + } + + public Task> GetRunningOperationsAsync() + { + var operations = GetRunningSubscriptionUpdates().ToList(); + if (_speedtestTask is { IsCompleted: false }) + { + operations.Add("speedtest"); + } + operations.AddRange(GetRunningCoreUpdateOperations()); + if (CurrentCoreRuntime.State != CoreRuntimeState.Stopped) + { + operations.Add("core"); + } + return Task.FromResult>(operations); + } + + private static bool IsHttpUrl(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"; + + internal static bool IsWebEditableCoreType(EConfigType configType) => WebEditableCoreTypes.Contains(configType); + + internal static bool AreWebCoreTypeMappingsValid(IReadOnlyCollection mappings) => + mappings.All(mapping => IsWebEditableCoreType(mapping.ConfigType) && Enum.IsDefined(mapping.CoreType)) + && mappings.Select(mapping => mapping.ConfigType).Distinct().Count() == mappings.Count; + + internal static bool AreDestOverrideProtocolsValid(IEnumerable? protocols) => + protocols is null || protocols.All(Global.destOverrideProtocols.Contains); + + internal static bool IsRootCertProviderValid(string? provider) => + string.IsNullOrEmpty(provider) || Global.RootCertProviders.Contains(provider); + + internal static bool AreCoreSettingsOptionsValid(CoreSettingsInput input) => + Global.Fingerprints.Contains(input.DefFingerprint ?? string.Empty) + && (string.IsNullOrEmpty(input.DefUserAgent) || Global.UserAgent.Contains(input.DefUserAgent)) + && Global.SingboxMuxs.Contains(input.Mux4SboxProtocol ?? string.Empty) + && (string.IsNullOrEmpty(input.Mux4RayXudpProxyUDP443) + || input.Mux4RayXudpProxyUDP443 is "reject" or "skip") + && (string.IsNullOrEmpty(input.FragmentPackets) + || Global.FragmentPacketsOptions.Contains(input.FragmentPackets)); + + internal static bool AreRoutingProfileStrategiesValid(string? domainStrategy, string? domainStrategy4Singbox) => + Global.DomainStrategies.AppendEmpty().Contains(domainStrategy ?? string.Empty) + && Global.DomainStrategies4Sbox.Contains(domainStrategy4Singbox ?? string.Empty); + + private string GetCoreConfigurationFingerprint() => System.Text.Json.JsonSerializer.Serialize(new + { + Inbound = Config.Inbound.Select(item => new + { + item.LocalPort, + Protocol = item.Protocol ?? string.Empty, + item.UdpEnabled, + item.SniffingEnabled, + DestOverride = item.DestOverride ?? [], + item.RouteOnly, + item.AllowLANConn, + item.NewPort4LAN, + User = item.User ?? string.Empty, + Pass = item.Pass ?? string.Empty, + item.SecondLocalPortEnabled, + }), + CoreBasic = new + { + Config.CoreBasicItem.LogEnabled, + Loglevel = Config.CoreBasicItem.Loglevel ?? string.Empty, + DefFingerprint = Config.CoreBasicItem.DefFingerprint ?? string.Empty, + DefUserAgent = Config.CoreBasicItem.DefUserAgent ?? string.Empty, + SendThrough = Config.CoreBasicItem.SendThrough ?? string.Empty, + BindInterface = Config.CoreBasicItem.BindInterface ?? string.Empty, + Config.CoreBasicItem.EnableFragment, + Config.CoreBasicItem.EnableFinalFragment, + Config.CoreBasicItem.EnableCacheFile4Sbox, + }, + Mux4Ray = new + { + Config.Mux4RayItem.Concurrency, + Config.Mux4RayItem.XudpConcurrency, + XudpProxyUDP443 = Config.Mux4RayItem.XudpProxyUDP443 ?? string.Empty, + }, + Mux4Sbox = new + { + Protocol = Config.Mux4SboxItem.Protocol ?? string.Empty, + Config.Mux4SboxItem.MaxConnections, + Config.Mux4SboxItem.Padding, + }, + Config.HysteriaItem.UpMbps, + Config.HysteriaItem.DownMbps, + Fragment = new + { + Packets = Config.Fragment4RayItem?.Packets ?? string.Empty, + Lengths = Config.Fragment4RayItem?.Lengths ?? [], + Delays = Config.Fragment4RayItem?.Delays ?? [], + MaxSplit = Config.Fragment4RayItem?.MaxSplit ?? string.Empty, + }, + Config.SimpleDNSItem, + Routing = new + { + DomainStrategy = Config.RoutingBasicItem.DomainStrategy ?? string.Empty, + DomainStrategy4Singbox = Config.RoutingBasicItem.DomainStrategy4Singbox ?? string.Empty, + }, + CoreTypeMapping = Config.CoreTypeItem ?? [], + }); + + private async Task CompleteCoreAffectingChangeAsync( + string section, + bool changed, + CoreSettingsApplyContext context, + string successMessageKey = ApiMessageKeys.CommonSaved, + object? data = null, + Func? beforeRollback = null) + { + var runtime = CurrentCoreRuntime; + var hasActiveChild = HasTrackedCoreProcesses; + var execution = await CoreSettingsApplyExecutor.ExecuteAsync( + runtime.State, + hasActiveChild, + changed, + () => RestartCoreLockedAsync(CancellationToken.None)); + var action = execution.Action; + if (action is CoreSettingsApplyAction.Busy or CoreSettingsApplyAction.Inconsistent) + { + var failure = action == CoreSettingsApplyAction.Busy + ? OperationView.Fail("core_runtime_busy", ApiMessageKeys.CoreRuntimeBusy, + new { runtimeState = runtime.State.ToString().ToLowerInvariant() }) + : OperationView.Fail("core_runtime_faulted", ApiMessageKeys.SettingsCoreApplyFailed, + new { runtimeState = runtime.State.ToString().ToLowerInvariant(), processIds = GetActiveCoreProcessIds() }); + var rollback = await RollBackCoreSettingsAsync(context, failure.Code, beforeRollback); + return CreateSettingsApplyFailure(section, data, failure.Code, ApiMessageKeys.SettingsCoreApplyFailed, + rollback.ConfigRestored, rollback.RolledBack, rollback.OldRuntimeRestored, rollback.Detail); + } + + if (action == CoreSettingsApplyAction.SaveOnly && runtime.State == CoreRuntimeState.Faulted) + { + // A faulted runtime with no child is not serving stale configuration. Normalize + // the empty runtime to Stopped so the UI does not imply an active Core. + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + } + + var restarted = false; + if (action == CoreSettingsApplyAction.Restart) + { + var restart = execution.RestartResult!; + if (!restart.Success) + { + AddLog("settings", $"Settings for {section} failed to apply: {restart.Code}; {CurrentCoreRuntime.LastFailure}"); + var rollback = await RollBackCoreSettingsAsync(context, restart.Code, beforeRollback); + return CreateSettingsApplyFailure(section, data, restart.Code, ApiMessageKeys.SettingsCoreApplyFailed, + rollback.ConfigRestored, rollback.RolledBack, rollback.OldRuntimeRestored, rollback.Detail); + } + restarted = true; + } + + _events.Publish("settings-changed", new { section, restartRequired = false, coreRestarted = restarted }); + var resultData = ToResultDictionary(data); + resultData["changed"] = changed; + resultData["coreRestarted"] = restarted; + resultData["restartRequired"] = false; + return OperationView.Ok(restarted ? ApiMessageKeys.CoreRestarted : successMessageKey, resultData); + } + + private async Task BeginCoreSettingsApplyAsync(CancellationToken cancellationToken = default) + { + await _coreGate.WaitAsync(cancellationToken); + try + { + var oldConfig = JsonUtils.DeepCopy(Config) + ?? throw new InvalidOperationException("The current configuration could not be copied for a Core settings apply."); + var oldRuntime = CurrentCoreRuntime; + var hasActiveChild = HasTrackedCoreProcesses; + var action = CoreSettingsApplyPolicy.Decide(oldRuntime.State, hasActiveChild, changed: false); + var failure = action switch + { + CoreSettingsApplyAction.Busy => OperationView.Fail("core_runtime_busy", ApiMessageKeys.CoreRuntimeBusy, + new { runtimeState = oldRuntime.State.ToString().ToLowerInvariant() }), + CoreSettingsApplyAction.Inconsistent => OperationView.Fail("core_runtime_faulted", ApiMessageKeys.SettingsCoreApplyFailed, + new { runtimeState = oldRuntime.State.ToString().ToLowerInvariant(), processIds = GetActiveCoreProcessIds() }), + _ => null, + }; + var oldRoutingItems = failure is null ? await AppManager.Instance.RoutingItems() ?? [] : []; + var oldDnsItems = failure is null ? await AppManager.Instance.DNSItems() ?? [] : []; + var oldFullConfigTemplates = failure is null ? await AppManager.Instance.FullConfigTemplateItem() ?? [] : []; + return new CoreSettingsApplyContext(_coreGate, oldConfig, oldRuntime, hasActiveChild, + oldRoutingItems.ToArray(), oldDnsItems.ToArray(), oldFullConfigTemplates.ToArray(), failure); + } + catch + { + _coreGate.Release(); + throw; + } + } + + private async Task RollBackCoreSettingsAsync( + CoreSettingsApplyContext context, + string failureCode, + Func? beforeRuntimeRestore = null) + { + var configRestored = false; + Exception? additionalRollbackFailure = null; + if (beforeRuntimeRestore is not null) + { + try + { + await beforeRuntimeRestore(); + } + catch (Exception exception) + { + additionalRollbackFailure = exception; + AddLog("settings", $"Additional state rollback failed before Core recovery ({failureCode}): {exception}"); + } + } + + try + { + RestoreConfigValues(Config, context.OldConfig); + await _mutations.RunAsync(() => EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config))); + configRestored = true; + await _mutations.RunAsync(async () => + { + await SQLiteHelper.Instance.DeleteAllAsync(); + if (await SQLiteHelper.Instance.InsertAllAsync(context.OldRoutingItems) != context.OldRoutingItems.Length) + throw new IOException("Routing profiles could not be restored from the settings snapshot."); + await SQLiteHelper.Instance.DeleteAllAsync(); + if (await SQLiteHelper.Instance.InsertAllAsync(context.OldDnsItems) != context.OldDnsItems.Length) + throw new IOException("DNS profiles could not be restored from the settings snapshot."); + await SQLiteHelper.Instance.DeleteAllAsync(); + if (await SQLiteHelper.Instance.InsertAllAsync(context.OldFullConfigTemplates) != context.OldFullConfigTemplates.Length) + throw new IOException("Core templates could not be restored from the settings snapshot."); + }); + AppManager.Instance.Reset(); + + var current = CurrentCoreRuntime; + if (context.HadActiveChild + && current.State == CoreRuntimeState.Running + && HasTrackedCoreProcesses) + { + if (additionalRollbackFailure is not null) + { + AddLog("settings", $"Core settings were restored and the original Core remained healthy, but additional state rollback failed ({failureCode})."); + return new(false, true, true, additionalRollbackFailure.Message); + } + AddLog("settings", $"Core settings apply failed ({failureCode}); the original Core remained healthy and its previous configuration was restored."); + return new(true, true, true, null); + } + + if (HasTrackedCoreProcesses || current.State != CoreRuntimeState.Stopped) + { + var stop = await StopCoreLockedAsync(CancellationToken.None); + if (!stop.Success) + { + throw new InvalidOperationException($"Could not stop the failed Core before rollback: {stop.Code}."); + } + } + + if (!context.HadActiveChild) + { + if (additionalRollbackFailure is not null) + { + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = $"Settings rollback failed ({failureCode}); additional state rollback failed: {additionalRollbackFailure.Message}", + }); + return new(false, false, configRestored, additionalRollbackFailure.Message); + } + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + AddLog("settings", $"Core settings apply failed ({failureCode}); the original stopped runtime intent was restored."); + return new(true, true, true, null); + } + + if (additionalRollbackFailure is not null) + { + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = $"Settings rollback failed ({failureCode}); the previous Core was not relaunched because additional state rollback failed: {additionalRollbackFailure.Message}", + }); + return new(false, false, configRestored, additionalRollbackFailure.Message); + } + + await CoreManager.Instance.Init(Config, OnCoreMessageAsync); + var oldProfileId = context.OldRuntime.ProfileId ?? Config.IndexId; + var oldProfile = await AppManager.Instance.GetProfileItem(oldProfileId) + ?? throw new InvalidOperationException($"The previous Core profile {oldProfileId} no longer exists."); + var restored = await StartCoreLockedAsync(oldProfile, CancellationToken.None); + if (!restored.Success) + { + throw new InvalidOperationException($"The previous Core runtime could not be restored ({restored.Code})."); + } + + AddLog("settings", $"Core settings apply failed ({failureCode}); the previous configuration and Core runtime were restored."); + return new(true, true, true, null); + } + catch (Exception exception) + { + var processIds = GetActiveCoreProcessIds(); + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = $"Settings apply failed ({failureCode}); rollback failed: {exception.Message}", + }); + AddLog("settings", $"Settings rollback failed after {failureCode}: {exception}"); + return new(false, false, configRestored, exception.Message); + } + } + + private OperationView CreateSettingsApplyFailure( + string section, + object? data, + string? failureCode, + string messageKey, + bool configRestored, + bool rolledBack, + bool oldRuntimeRestored, + string? rollbackDetail) + { + var runtime = CurrentCoreRuntime; + var failureData = ToResultDictionary(data); + failureData["section"] = section; + failureData["configSaved"] = !configRestored; + failureData["configRestored"] = configRestored; + failureData["restartRequired"] = true; + failureData["rolledBack"] = rolledBack; + failureData["oldRuntimeRestored"] = oldRuntimeRestored; + failureData["runtimeState"] = runtime.State.ToString().ToLowerInvariant(); + failureData["configuredProxyPort"] = Config.Inbound.FirstOrDefault()?.LocalPort; + failureData["runningProxyPort"] = runtime.ProxyPort; + failureData["coreResultCode"] = failureCode; + failureData["detail"] = runtime.LastFailure; + failureData["rollbackDetail"] = rollbackDetail; + _events.Publish("settings-changed", new { section, restartRequired = true, restartFailed = true, rolledBack, oldRuntimeRestored }); + return OperationView.Fail("settings_core_apply_failed", messageKey, failureData); + } + + private static Dictionary ToResultDictionary(object? data) + { + if (data is IReadOnlyDictionary readOnly) + { + return new Dictionary(readOnly, StringComparer.Ordinal); + } + if (data is IDictionary mutable) + { + return new Dictionary(mutable, StringComparer.Ordinal); + } + + var result = new Dictionary(StringComparer.Ordinal); + if (data is not null) + { + foreach (var property in data.GetType().GetProperties()) + { + result[property.Name] = property.GetValue(data); + } + } + return result; + } + + private static bool ValidateCoreSettingsInput(CoreSettingsInput input) + { + var fragmentLengths = input.FragmentLengths ?? []; + var fragmentDelays = input.FragmentDelays ?? []; + return AreCoreSettingsOptionsValid(input) + && Global.LogLevels.Contains(input.Loglevel ?? string.Empty, StringComparer.OrdinalIgnoreCase) + && input.Mux4RayConcurrency is >= 0 and <= 1024 + && input.Mux4RayXudpConcurrency is >= 0 and <= 1024 + && input.Hy2UpMbps >= 0 + && input.Hy2DownMbps >= 0 + && fragmentLengths.All(value => Utils.TryParseRange(value, 0, int.MaxValue, out _, out _)) + && fragmentDelays.All(value => Utils.TryParseRange(value, 0, int.MaxValue, out _, out _)) + && (string.IsNullOrWhiteSpace(input.FragmentMaxSplit) + || Utils.TryParseMaxSplit(input.FragmentMaxSplit, 0, 10000, out _, out _)); + } + + private void ApplyCoreSettingsToConfig(CoreSettingsInput input) + { + Config.CoreBasicItem.LogEnabled = input.LogEnabled; + Config.CoreBasicItem.Loglevel = input.Loglevel ?? string.Empty; + Config.CoreBasicItem.DefFingerprint = input.DefFingerprint?.Trim() ?? string.Empty; + Config.CoreBasicItem.DefUserAgent = input.DefUserAgent?.Trim() ?? string.Empty; + Config.CoreBasicItem.SendThrough = input.SendThrough?.Trim(); + Config.CoreBasicItem.BindInterface = input.BindInterface?.Trim(); + Config.Mux4RayItem.Concurrency = input.Mux4RayConcurrency is > 0 ? input.Mux4RayConcurrency : null; + Config.Mux4RayItem.XudpConcurrency = input.Mux4RayXudpConcurrency is > 0 ? input.Mux4RayXudpConcurrency : null; + Config.Mux4RayItem.XudpProxyUDP443 = input.Mux4RayXudpProxyUDP443; + Config.Mux4SboxItem.Protocol = input.Mux4SboxProtocol ?? string.Empty; + Config.Mux4SboxItem.Padding = input.Mux4SboxPadding; + Config.CoreBasicItem.EnableCacheFile4Sbox = input.EnableCacheFile4Sbox; + Config.HysteriaItem.UpMbps = input.Hy2UpMbps; + Config.HysteriaItem.DownMbps = input.Hy2DownMbps; + Config.CoreBasicItem.EnableFragment = input.EnableFragment; + Config.CoreBasicItem.EnableFinalFragment = input.EnableFinalFragment; + Config.Fragment4RayItem ??= new(); + Config.Fragment4RayItem.Packets = input.FragmentPackets; + Config.Fragment4RayItem.Lengths = input.FragmentLengths?.ToList() ?? []; + Config.Fragment4RayItem.Delays = input.FragmentDelays?.ToList() ?? []; + Config.Fragment4RayItem.MaxSplit = input.FragmentMaxSplit; + } + + private static void RestoreConfigValues(Config target, Config snapshot) + { + foreach (var property in typeof(Config).GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)) + { + if (property.CanRead && property.CanWrite) + { + property.SetValue(target, property.GetValue(snapshot)); + } + } + } + + private sealed class CoreSettingsApplyContext( + SemaphoreSlim gate, + Config oldConfig, + CoreRuntimeSnapshot oldRuntime, + bool hadActiveChild, + RoutingItem[] oldRoutingItems, + DNSItem[] oldDnsItems, + FullConfigTemplateItem[] oldFullConfigTemplates, + OperationView? failure) : IAsyncDisposable + { + private int _disposed; + + public Config OldConfig { get; } = oldConfig; + public CoreRuntimeSnapshot OldRuntime { get; } = oldRuntime; + public bool HadActiveChild { get; } = hadActiveChild; + public RoutingItem[] OldRoutingItems { get; } = oldRoutingItems; + public DNSItem[] OldDnsItems { get; } = oldDnsItems; + public FullConfigTemplateItem[] OldFullConfigTemplates { get; } = oldFullConfigTemplates; + public OperationView? Failure { get; } = failure; + + public ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) gate.Release(); + return ValueTask.CompletedTask; + } + } + + private sealed record CoreSettingsRollbackResult(bool RolledBack, bool OldRuntimeRestored, bool ConfigRestored, string? Detail); + + private sealed class RegionalPresetConfigApplyException : Exception { } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.WebUpdate.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.WebUpdate.cs new file mode 100644 index 00000000..d28fb99c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.WebUpdate.cs @@ -0,0 +1,656 @@ +using System.Diagnostics; +using System.Net; +using System.Runtime.InteropServices; +using System.Text.Json; +using ServiceLib.Common; +using ServiceLib.Manager; +using ServiceLib.Models.Dto; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + private const string WebUpdateProgressFile = "WebAPI-update-progress.json"; + private Task? _webUpdateTask; + private string? _latestWebUpdateVersion; + + public WebUpdateTargetView GetWebUpdateTarget() + { + var deployment = GetWebUpdateDeployment(); + var build = WebBuildIdentity.Current; + var selected = Config.CheckUpdateItem.SelectedCoreTypes; + var supportedRid = WebUpdatePackageStager.IsSupportedRid(build.Rid); + var runtimeInstallReason = GetWebUpdateRuntimeInstallReason(); + var canInstall = deployment.CanInstall && runtimeInstallReason is null; + return new WebUpdateTargetView( + WebUpdateTarget, + build.Version, + build.Commit, + build.BuildDate, + build.Rid, + ToDeploymentName(deployment.Kind), + supportedRid, + deployment.CanCheck, + canInstall, + supportedRid && (selected?.Contains(WebUpdateTarget, StringComparer.Ordinal) ?? true), + !supportedRid ? "maintenance.webUpdateUnsupported" : deployment.InstallReasonKey ?? runtimeInstallReason, + Volatile.Read(ref _latestWebUpdateVersion) ?? _updateProgress.GetValueOrDefault(WebUpdateTarget)?.Version); + } + + public async Task> CheckWebUpdateAsync( + bool? preRelease, + bool? useProxy, + CancellationToken cancellationToken) + { + var check = await CheckWebUpdateReleaseAsync( + GetUpdatePreRelease(WebUpdateTarget, preRelease), + useProxy ?? Config.CheckUpdateItem.UpdateViaProxy, + cancellationToken); + Volatile.Write(ref _latestWebUpdateVersion, check.Version); + var deployment = GetWebUpdateDeployment(); + var view = new WebUpdateCheckView( + check.IsUpdateAvailable, + WebBuildIdentity.Current.Version, + WebBuildIdentity.Current.Commit, + WebBuildIdentity.Current.Rid, + check.Version, + check.Commit, + deployment.CanInstall && GetWebUpdateRuntimeInstallReason() is null, + deployment.InstallReasonKey ?? GetWebUpdateRuntimeInstallReason(), + check.Error ?? check.Detail); + if (!string.IsNullOrWhiteSpace(check.Error)) + { + AddLog("update", $"v2rayN.WebAPI update check failed: {check.Error}"); + return ApiEnvelope.Fail("web_update_check_failed", ApiMessageKeys.WebUpdateCheckFailed, view); + } + return ApiEnvelope.Ok(view, + check.IsUpdateAvailable ? ApiMessageKeys.CoreUpdateAvailable : ApiMessageKeys.CoreUpdateCurrent); + } + + public OperationView StartWebUpdate(bool? preRelease = null, bool? useProxy = null) + { + if (!GetWebUpdateDeployment().CanInstall) + { + return OperationView.Fail("web_update_install_unsupported", GetWebUpdateDeployment().InstallReasonKey + ?? ApiMessageKeys.CoreUpdateUnsupported); + } + if (GetWebUpdateRuntimeInstallReason() is { } runtimeReason) + return OperationView.Fail("web_update_runtime_unavailable", runtimeReason); + if (Config.CheckUpdateItem.SelectedCoreTypes is { } selected + && !selected.Contains(WebUpdateTarget, StringComparer.Ordinal)) + { + return OperationView.Fail("web_update_not_selected", ApiMessageKeys.CoreUpdateUnsupported); + } + + lock (_updateTaskGate) + { + if (IsUpdateRunningLocked()) + { + return OperationView.Fail("core_update_busy", ApiMessageKeys.CoreUpdateBusy); + } + _webUpdateTask = Task.Run(() => RunWebUpdateAsync( + GetUpdatePreRelease(WebUpdateTarget, preRelease), + useProxy ?? Config.CheckUpdateItem.UpdateViaProxy)); + } + return OperationView.Ok(ApiMessageKeys.CoreUpdateStarted, new { target = WebUpdateTarget }); + } + + internal CoreUpdateProgressView? GetWebUpdateProgress() + { + var inMemory = _updateProgress.GetValueOrDefault(WebUpdateTarget); + if (inMemory is { IsComplete: false } || inMemory?.Phase == "completed") return inMemory; + try + { + var path = Utils.GetTempPath(WebUpdateProgressFile); + if (!File.Exists(path)) return inMemory; + var persisted = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions); + if (persisted is null) return inMemory; + var progress = new CoreUpdateProgressView( + WebUpdateTarget, + persisted.Phase, + persisted.IsComplete, + persisted.Success, + persisted.CoreWasRunning, + persisted.Version, + persisted.Detail, + false, + persisted.RollbackSucceeded); + // In-flight persisted progress belongs to the detached helper. Caching + // it as local progress would prevent subsequent reads of its result. + if (progress.IsComplete) _updateProgress[WebUpdateTarget] = progress; + return progress; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + AddLog("update", $"Could not read the native Web update progress: {exception.Message}"); + return inMemory; + } + } + + private async Task RunWebUpdateAsync(bool preRelease, bool useProxy) + { + WebUpdateStage? stage = null; + try + { + await using var maintenance = await _operations.EnterExclusiveAsync( + _operations.ShutdownToken, + allowReadOnlyObservations: true); + PublishWebUpdateProgress("checking", false, false, null); + var check = await CheckWebUpdateReleaseAsync(preRelease, useProxy, maintenance.Token); + Volatile.Write(ref _latestWebUpdateVersion, check.Version); + if (!string.IsNullOrWhiteSpace(check.Error)) + { + PublishWebUpdateProgress("failed", true, false, check.Error, check.Version); + return; + } + if (!check.IsUpdateAvailable) + { + PublishWebUpdateProgress("completed", true, true, check.Detail ?? "The Web application is up to date.", check.Version); + return; + } + + stage = await StageWebUpdateAsync(check, useProxy, maintenance.Token, batch: false); + await ApplyStagedWebUpdateAsync(stage, maintenance.Token, batch: false); + } + catch (OperationCanceledException) when (_operations.IsStopping) + { + PublishWebUpdateProgress("failed", true, false, "Canceled during graceful shutdown.", stage?.Manifest.Version); + } + catch (Exception exception) + { + AddLog("update", $"v2rayN.WebAPI update failed: {exception}"); + PublishWebUpdateProgress("failed", true, false, exception.Message, stage?.Manifest.Version); + } + finally + { + CleanupWebUpdateStage(stage); + lock (_updateTaskGate) _webUpdateTask = null; + } + } + + private async Task CheckWebUpdateReleaseAsync( + bool allowPrerelease, + bool useProxy, + CancellationToken cancellationToken) + { + try + { + var repository = WebBuildIdentity.Current.Repository; + var downloader = new DownloadService(); + var releaseJson = await downloader.TryDownloadString( + WebReleaseChannel.BuildReleaseIndexUrl(repository), + useProxy, + $"v2rayN.WebAPI/{WebBuildIdentity.Current.Version}", + cancellationToken); + if (string.IsNullOrWhiteSpace(releaseJson) || releaseJson.Length > 8 * 1024 * 1024) + return WebUpdateReleaseCheck.Failed("The GitHub Web release index could not be downloaded or exceeded its size limit."); + + var candidates = ParseWebReleaseCandidates(releaseJson, allowPrerelease); + if (candidates.Count == 0) + return WebUpdateReleaseCheck.Failed("No v2rayN releases with Web update packages were found for this channel."); + candidates.Sort((left, right) => new ServiceLib.Models.Dto.SemanticVersion(right.Version) + .CompareTo(new ServiceLib.Models.Dto.SemanticVersion(left.Version))); + var selected = candidates[0]; + if (!WebReleaseChannel.IsTrustedAssetUrl(selected.ManifestUrl, repository, selected.Tag, WebReleaseChannel.ManifestAssetName)) + return WebUpdateReleaseCheck.Failed("The Web release manifest URL did not match the trusted release channel."); + + var manifestJson = await downloader.TryDownloadString( + selected.ManifestUrl, + useProxy, + $"v2rayN.WebAPI/{WebBuildIdentity.Current.Version}", + cancellationToken); + if (string.IsNullOrWhiteSpace(manifestJson) || manifestJson.Length > 1024 * 1024) + return WebUpdateReleaseCheck.Failed("The Web release manifest could not be downloaded or exceeded its size limit."); + var manifest = WebUpdatePackageStager.ParseManifest(manifestJson); + if (manifest.Version != selected.Version) + return WebUpdateReleaseCheck.Failed("The Web release manifest version does not match its release tag."); + + var currentRid = WebBuildIdentity.Current.Rid; + var package = manifest.Packages.FirstOrDefault(item => item.Rid == currentRid); + if (package is null) + { + return new WebUpdateReleaseCheck(false, manifest.Version, manifest.Commit, manifest, null, + "This v2rayN.WebAPI release does not include a package for the current runtime identifier.", + "No package matches runtime " + currentRid + "."); + } + var expectedAsset = WebUpdatePackageStager.AppOnlyAssetName(package.Rid); + if (string.IsNullOrEmpty(expectedAsset) + || package.Asset != expectedAsset + || !selected.Assets.TryGetValue(package.Asset, out var releaseAsset) + || releaseAsset.Url != package.Url + || releaseAsset.Size != package.Size + || !WebReleaseChannel.IsTrustedAssetUrl(package.Url, repository, selected.Tag, package.Asset)) + { + return WebUpdateReleaseCheck.Failed("The Web package entry does not match a trusted asset attached to this release."); + } + + var currentVersion = WebBuildIdentity.Current.Version; + var updateAvailable = WebUpdatePackageStager.IsUpdateAvailable( + currentVersion, manifest.Version, allowPrerelease, WebBuildIdentity.Current.Commit, manifest.Commit); + var detail = updateAvailable + ? "A newer v2rayN.WebAPI release is available." + : new ServiceLib.Models.Dto.SemanticVersion(currentVersion) + .CompareTo(new ServiceLib.Models.Dto.SemanticVersion(manifest.Version)) > 0 + ? "This Web build is newer than the latest release in the selected channel." + : "The v2rayN.WebAPI build is up to date."; + return new WebUpdateReleaseCheck(updateAvailable, manifest.Version, manifest.Commit, manifest, package, null, detail); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (exception is HttpRequestException or IOException or JsonException or InvalidDataException or ArgumentException) + { + return WebUpdateReleaseCheck.Failed(exception.Message); + } + } + + /// + /// Extracts official release candidates that carry a Web update manifest from a GitHub + /// "list releases" response. Legacy web-v* tags are intentionally ignored. + /// + internal static List ParseWebReleaseCandidates(string releaseJson, bool allowPrerelease) + { + using var document = JsonDocument.Parse(releaseJson); + if (document.RootElement.ValueKind != JsonValueKind.Array) + throw new InvalidDataException("The GitHub Web release index has an invalid format."); + + var candidates = new List(); + foreach (var release in document.RootElement.EnumerateArray()) + { + if (release.TryGetProperty("draft", out var draft) && draft.GetBoolean()) continue; + var isPrerelease = release.TryGetProperty("prerelease", out var prerelease) && prerelease.GetBoolean(); + if (!WebUpdatePackageStager.ShouldConsiderRelease(isPrerelease, allowPrerelease)) continue; + var tag = release.TryGetProperty("tag_name", out var tagElement) ? tagElement.GetString() : null; + if (!WebReleaseChannel.TryParseReleaseTag(tag, out var version)) continue; + var assets = new Dictionary(StringComparer.Ordinal); + if (release.TryGetProperty("assets", out var assetsElement) && assetsElement.ValueKind == JsonValueKind.Array) + { + foreach (var asset in assetsElement.EnumerateArray()) + { + var name = asset.TryGetProperty("name", out var nameElement) ? nameElement.GetString() : null; + var url = asset.TryGetProperty("browser_download_url", out var urlElement) ? urlElement.GetString() : null; + var size = asset.TryGetProperty("size", out var sizeElement) && sizeElement.TryGetInt64(out var parsedSize) + ? parsedSize : 0; + if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(url)) assets[name] = (url, size); + } + } + if (assets.TryGetValue(WebReleaseChannel.ManifestAssetName, out var manifestAsset)) + candidates.Add(new WebReleaseCandidate(tag!, version, isPrerelease, manifestAsset.Url, assets)); + } + return candidates; + } + + private async Task StageWebUpdateAsync( + WebUpdateReleaseCheck check, + bool useProxy, + CancellationToken cancellationToken, + bool batch) + { + if (check.Manifest is null || check.Package is null) + throw new InvalidDataException("The v2rayN.WebAPI update manifest or runtime package was not checked."); + var deployment = GetWebUpdateDeployment(); + if (!deployment.CanInstall) throw new InvalidOperationException(deployment.InstallReasonKey ?? "Web update installation is unavailable."); + + var id = Guid.NewGuid().ToString("N"); + var archivePath = Utils.GetTempPath($"web-update-{id}.zip"); + var packageDirectory = Utils.GetTempPath($"web-update-stage-{id}"); + var executablePath = Environment.ProcessPath + ?? throw new InvalidOperationException("The current Web executable path is unavailable."); + var installDirectory = Path.GetDirectoryName(Path.GetFullPath(executablePath)) + ?? throw new InvalidOperationException("The current Web executable has no install directory."); + var installParent = Path.GetDirectoryName(installDirectory) + ?? throw new InvalidOperationException("The current Web install directory has no parent."); + var candidateDirectory = Path.Combine(installParent, $".v2rayn-WebAPI-candidate-{id}"); + var backupDirectory = Path.Combine(installParent, $".v2rayn-WebAPI-backup-{id}"); + + try + { + PublishWebUpdateProgress("downloading", false, false, null, check.Manifest.Version, batch); + var downloader = new DownloadService(); + var downloadFailure = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + downloader.Error += (_, args) => downloadFailure.TrySetResult(args.GetException()); + downloader.UpdateCompleted += (_, progress) => + PublishWebUpdateProgress("downloading", false, false, progress.Msg, check.Manifest.Version, batch); + await downloader.DownloadFileAsync(new FileDownloadRequest + { + FileUrl = check.Package.Url, + FilePath = archivePath, + DisplayFileName = Path.GetFileName(archivePath), + }, useProxy, cancellationToken); + if (downloadFailure.Task.IsCompletedSuccessfully) + throw new IOException("The v2rayN.WebAPI package download failed.", await downloadFailure.Task); + if (!File.Exists(archivePath)) throw new IOException("The v2rayN.WebAPI package download did not produce an archive."); + + PublishWebUpdateProgress("verifying", false, false, null, check.Manifest.Version, batch); + var identity = await WebUpdatePackageStager.VerifyAndExtractAsync( + archivePath, packageDirectory, check.Manifest, check.Package, cancellationToken); + if (identity.Rid != WebBuildIdentity.Current.Rid) + throw new InvalidDataException("The staged v2rayN.WebAPI package has the wrong runtime identifier."); + + PublishWebUpdateProgress("staged", false, false, "The app-only package passed SHA-256, RID, identity, and archive checks.", + check.Manifest.Version, batch); + CopyVerifiedWebApp(packageDirectory, candidateDirectory, check.Package.Rid); + return new WebUpdateStage(archivePath, packageDirectory, candidateDirectory, backupDirectory, + installDirectory, check.Manifest, check.Package); + } + catch + { + CleanupWebUpdateStageFiles(archivePath, packageDirectory, candidateDirectory); + throw; + } + } + + private async Task ApplyStagedWebUpdateAsync(WebUpdateStage stage, CancellationToken cancellationToken, bool batch) + { + cancellationToken.ThrowIfCancellationRequested(); + var current = CurrentCoreRuntime; + var wasRunning = HasTrackedCoreProcesses + && current.State is CoreRuntimeState.Running or CoreRuntimeState.Faulted; + var runtimeIntent = new RuntimeRestartIntent(wasRunning, wasRunning ? current.ProfileId : null) + { + Reason = "Web application update", + }; + var runtimeIntentPath = WebUpdateRuntimeStatePath; + var progressPath = Utils.GetTempPath(WebUpdateProgressFile); + var planPath = Utils.GetTempPath($"web-update-plan-{Guid.NewGuid():N}.json"); + var hostArguments = GetCurrentHostArguments(); + // Never stop the current instance before its listener configuration has a + // loopback HTTP probe: the detached helper verifies the replacement (and its + // rollback) through that exact endpoint. + if (!WebProbeUriResolver.TryResolve(_configuration, out var probeEndpoints)) + { + throw new InvalidOperationException( + "The current listener configuration has no loopback HTTP health endpoint, so the update cannot be verified safely."); + } + var plan = new NativeWebUpdatePlan( + stage.InstallDirectory, + stage.CandidateDirectory, + stage.BackupDirectory, + Path.Combine(Utils.StartupPath(), "v2rayN.WebAPI.instance.lock"), + probeEndpoints.HealthUri.ToString(), + hostArguments, + stage.Manifest.Version, + stage.Manifest.Commit, + stage.Package.Rid, + WebBuildIdentity.Current.Version, + runtimeIntentPath, + progressPath, + wasRunning); + + await WriteRuntimeIntentAsync(runtimeIntentPath, runtimeIntent); + try + { + await WriteNativeWebUpdatePlanAsync(planPath, plan); + var executablePath = Environment.ProcessPath + ?? throw new InvalidOperationException("The native Web executable path is unavailable."); + ProcessStartInfo startInfo; + string? isolatedWindowsHelper = null; + if (OperatingSystem.IsLinux()) + { + var setsid = LinuxXdgBrowserOpener.FindExecutable("setsid") + ?? throw new InvalidOperationException("The setsid executable is required for a native Web update."); + startInfo = new ProcessStartInfo + { + FileName = setsid, + WorkingDirectory = stage.InstallDirectory, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(executablePath); + } + else if (OperatingSystem.IsWindows()) + { + // The Windows process image is locked while running. Use a sibling copy as the + // detached update worker so it can replace the installed executable after the + // Web host releases its instance lock. + isolatedWindowsHelper = NativeWebUpdateHelper.CreateIsolatedHelperExecutable(executablePath); + startInfo = new ProcessStartInfo + { + FileName = isolatedWindowsHelper, + WorkingDirectory = stage.InstallDirectory, + UseShellExecute = false, + CreateNoWindow = true, + }; + } + else + { + throw new PlatformNotSupportedException("Native Web self-update is supported on Linux and Windows x64."); + } + startInfo.ArgumentList.Add("--apply-WebAPI-update"); + startInfo.ArgumentList.Add(planPath); + Process helper; + try + { + helper = Process.Start(startInfo) + ?? throw new InvalidOperationException("The native Web update helper could not be started."); + } + catch + { + if (isolatedWindowsHelper is not null) TryDeleteFile(isolatedWindowsHelper); + throw; + } + using (helper) + { + stage.TransferredToHelper = true; + PublishWebUpdateProgress("stopping", false, false, + "All network operations and package verification completed before Web/Core shutdown.", + stage.Manifest.Version, batch); + AddLog("update", $"Handing off the v2rayN.WebAPI update to helper process {helper.Id}."); + _lifetime.StopApplication(); + } + } + catch + { + await DeleteRuntimeIntentAsync(runtimeIntentPath); + TryDeleteFile(planPath); + throw; + } + } + + private WebUpdateDeployment GetWebUpdateDeployment() + { + var isContainer = File.Exists("/.dockerenv") || File.Exists("/run/.containerenv") + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("container")) + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")); + var isSystemd = LauncherEnvironment.IsSystemdManagedDeployment(); + var executable = Environment.ProcessPath; + var expectedExecutableName = WebUpdatePackageStager.IsSupportedRid(WebBuildIdentity.Current.Rid) + ? WebUpdatePackageStager.ExecutableNameForRid(WebBuildIdentity.Current.Rid) + : null; + var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var nativeSingleFile = expectedExecutableName is not null && !string.IsNullOrWhiteSpace(executable) + && Path.GetFileName(executable).Equals(expectedExecutableName, pathComparison) + && File.Exists(executable) + && string.Equals(AppContext.BaseDirectory, + Path.GetDirectoryName(Path.GetFullPath(executable)) + Path.DirectorySeparatorChar, + pathComparison); + var installDirectory = nativeSingleFile && executable is not null ? Path.GetDirectoryName(executable)! : string.Empty; + var writable = nativeSingleFile && !isSystemd && IsWritableInstallDirectory(installDirectory, executable!); + var supportedNativePlatform = OperatingSystem.IsLinux() || OperatingSystem.IsWindows(); + return WebUpdateDeploymentPolicy.Evaluate(supportedNativePlatform, isContainer, isSystemd, nativeSingleFile, writable); + } + + private bool CanInstallWebUpdate() => + WebUpdatePackageStager.IsSupportedRid(WebBuildIdentity.Current.Rid) + && GetWebUpdateDeployment().CanInstall + && GetWebUpdateRuntimeInstallReason() is null; + + internal string? GetWebUpdateRuntimeInstallReason() + { + if (!WebProbeUriResolver.TryResolve(_configuration, out _)) + { + // The update helper verifies both the replacement and its rollback through + // the loopback health endpoint; an unprobeable listener would strand the + // service, so installing is refused before anything is stopped. + return "maintenance.webUpdateProbeUnavailable"; + } + + var snapshot = CurrentCoreRuntime; + var activeChild = HasTrackedCoreProcesses; + if (snapshot.State is CoreRuntimeState.Starting or CoreRuntimeState.Stopping or CoreRuntimeState.Restarting) + return ApiMessageKeys.CoreRuntimeBusy; + if (snapshot.State == CoreRuntimeState.Faulted && activeChild) + return "maintenance.webUpdateRuntimeFaulted"; + if (snapshot.State == CoreRuntimeState.Running && !activeChild + || snapshot.State == CoreRuntimeState.Stopped && activeChild) + return "maintenance.webUpdateRuntimeFaulted"; + return null; + } + + private static bool IsWritableInstallDirectory(string installDirectory, string executablePath) + { + try + { + if (new DirectoryInfo(installDirectory).LinkTarget is not null + || new FileInfo(executablePath).LinkTarget is not null) + return false; + var probe = Path.Combine(installDirectory, $".v2rayn-WebAPI-write-probe-{Guid.NewGuid():N}"); + using (new FileStream(probe, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { } + File.Delete(probe); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) + { + return false; + } + } + + private string GetWebUpdateInstallReasonKey() => GetWebUpdateDeployment().InstallReasonKey + ?? GetWebUpdateRuntimeInstallReason() + ?? "maintenance.webUpdateUnsupported"; + + private static string ToDeploymentName(WebDeploymentKind kind) => kind switch + { + WebDeploymentKind.NativeWritable => "native-writable", + WebDeploymentKind.SystemdManaged => "systemd-managed", + WebDeploymentKind.Container => "container", + WebDeploymentKind.ReadOnly => "native-read-only", + _ => "unsupported", + }; + + private static async Task WriteNativeWebUpdatePlanAsync(string planPath, NativeWebUpdatePlan plan) + { + var temporary = planPath + $".{Guid.NewGuid():N}.tmp"; + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(plan, JsonOptions)); + File.Move(temporary, planPath); + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(planPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + private static string[] GetCurrentHostArguments() + { + var args = Environment.GetCommandLineArgs().Skip(1).ToArray(); + return WebLaunchOptions.Parse( + args, + OperatingSystem.IsLinux(), + daemonEnvironment: false, + containerEnvironment: false, + interactiveLaunch: false).HostArguments; + } + + private static void CopyVerifiedWebApp(string source, string destination, string rid) + { + Directory.CreateDirectory(destination); + var executableName = WebUpdatePackageStager.ExecutableNameForRid(rid); + File.Copy(Path.Combine(source, executableName), Path.Combine(destination, executableName)); + File.Copy(Path.Combine(source, "v2rayN.WebAPI.build.json"), Path.Combine(destination, "v2rayN.WebAPI.build.json")); + if (OperatingSystem.IsLinux() && rid.StartsWith("linux-", StringComparison.Ordinal)) + { + File.SetUnixFileMode(Path.Combine(destination, executableName), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + } + + private static void CleanupWebUpdateStage(WebUpdateStage? stage) + { + if (stage is null) return; + CleanupWebUpdateStageFiles(stage.ArchivePath, stage.PackageDirectory, + stage.TransferredToHelper ? null : stage.CandidateDirectory); + } + + private static void CleanupWebUpdateStageFiles(string archive, string packageDirectory, string? candidateDirectory) + { + TryDeleteFile(archive); + TryDeleteDirectory(packageDirectory); + if (candidateDirectory is not null) TryDeleteDirectory(candidateDirectory); + } + + private void PublishWebUpdateProgress( + string phase, + bool complete, + bool success, + string? detail, + string? version = null, + bool batch = false) + { + var progress = new CoreUpdateProgressView(WebUpdateTarget, phase, complete, success, + HasTrackedCoreProcesses, version, detail, batch); + _updateProgress[WebUpdateTarget] = progress; + _events.Publish("core-update-progress", progress); + AddLog("update", $"v2rayN.WebAPI update phase: {phase}"); + try + { + var path = Utils.GetTempPath(WebUpdateProgressFile); + var temporary = path + $".{Guid.NewGuid():N}.tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(progress, JsonOptions)); + File.Move(temporary, path, overwrite: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + AddLog("update", $"Could not persist Web update progress: {exception.Message}"); + } + } + + private static void TryDeleteFile(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { } + } + + private static void TryDeleteDirectory(string path) + { + try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { } + } + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + internal sealed record WebUpdateReleaseCheck( + bool IsUpdateAvailable, + string? Version, + string? Commit, + WebUpdateManifest? Manifest, + WebUpdatePackage? Package, + string? Error, + string? Detail) + { + public static WebUpdateReleaseCheck Failed(string detail) => new(false, null, null, null, null, detail, detail); + } + + internal sealed record WebUpdateStage( + string ArchivePath, + string PackageDirectory, + string CandidateDirectory, + string BackupDirectory, + string InstallDirectory, + WebUpdateManifest Manifest, + WebUpdatePackage Package) + { + public bool TransferredToHelper { get; set; } + } + + internal sealed record WebReleaseCandidate( + string Tag, + string Version, + bool IsPrerelease, + string ManifestUrl, + IReadOnlyDictionary Assets); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.cs new file mode 100644 index 00000000..9dbb9031 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.cs @@ -0,0 +1,1535 @@ +using System.Net.Sockets; +using System.Net; +using System.Net.NetworkInformation; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Handler.Builder; +using ServiceLib.Helper; +using ServiceLib.Manager; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Dto; +using ServiceLib.Models.Entities; +using ServiceLib.Services; +using NLog; +using NLog.Config; +using NLog.Targets; +using v2rayN.WebAPI.Adapters; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime( + EventHub events, + LogBuffer logs, + IConfiguration configuration, + IHostApplicationLifetime lifetime, + RuntimeOperationCoordinator operations) +{ + private readonly EventHub _events = events; + private readonly LogBuffer _logs = logs; + private readonly IConfiguration _configuration = configuration; + private readonly IHostApplicationLifetime _lifetime = lifetime; + private readonly RuntimeOperationCoordinator _operations = operations; + private readonly RuntimeMutationGate _mutations = new(); + private readonly SemaphoreSlim _coreGate = new(1, 1); + private readonly SemaphoreSlim _regionalPresetGate = new(1, 1); + private readonly SemaphoreSlim _coreMonitorGate = new(1, 1); + private readonly object _subscriptionGate = new(); + private readonly object _speedtestGate = new(); + private readonly Dictionary _subscriptionTasks = new(StringComparer.Ordinal); + private SpeedtestService? _speedtestService; + private Task? _speedtestTask; + private CancellationTokenSource? _speedtestCancellation; + private CoreRuntimeSnapshot _coreRuntime = CoreRuntimeSnapshot.Stopped; + private CancellationTokenSource? _coreMonitorCancellation; + private Task? _coreMonitorTask; + private CoreProcessIdentity[] _trackedCoreProcesses = []; + private string? _xrayPath; + private ServerSpeedItem? _latestTraffic; + private bool _initialized; + private bool _restoring; + private int _restoreAndRestartRequested; + + private Config Config => AppManager.Instance.Config; + private CoreRuntimeSnapshot CurrentCoreRuntime => Volatile.Read(ref _coreRuntime); + public bool RestoreAndRestartRequested => Volatile.Read(ref _restoreAndRestartRequested) != 0; + + public int[] GetCoreProcessIds() => GetActiveCoreProcessIds(); + + public string? GetCoreRuntimeProfileId() => CurrentCoreRuntime.ProfileId; + + public string GetCoreRuntimeState() => CurrentCoreRuntime.State.ToString().ToLowerInvariant(); + + private void SetCoreRuntime(CoreRuntimeSnapshot snapshot) + { + snapshot = snapshot with { ProcessIds = GetActiveCoreProcessIds() }; + Volatile.Write(ref _coreRuntime, snapshot); + if (snapshot.State != CoreRuntimeState.Running) + { + _coreMonitorCancellation?.Cancel(); + } + _events.Publish("core-state", new + { + state = snapshot.State.ToString().ToLowerInvariant(), + profileId = snapshot.ProfileId, + coreType = snapshot.CoreType is { } coreType + ? System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(coreType.ToString()) + : null, + startedAt = snapshot.StartedAt, + proxyPort = snapshot.ProxyPort, + apiPort = snapshot.ApiPort, + processIds = snapshot.ProcessIds, + lastFailure = snapshot.LastFailure, + }); + } + + private async Task StartCoreMonitorAsync() + { + await StopCoreMonitorAsync(); + var cancellation = new CancellationTokenSource(); + _coreMonitorCancellation = cancellation; + _coreMonitorTask = Task.Run(() => MonitorCoreRuntimeAsync(cancellation.Token)); + } + + private async Task StopCoreMonitorAsync() + { + await _coreMonitorGate.WaitAsync(); + try + { + var cancellation = _coreMonitorCancellation; + var task = _coreMonitorTask; + _coreMonitorCancellation = null; + _coreMonitorTask = null; + if (cancellation is null) return; + cancellation.Cancel(); + if (task is not null) + { + try { await task.WaitAsync(TimeSpan.FromSeconds(2)); } + catch (OperationCanceledException) { } + catch (TimeoutException) { AddLog("core", "Core runtime monitor did not stop within its cleanup budget."); } + } + cancellation.Dispose(); + } + finally + { + _coreMonitorGate.Release(); + } + } + + private CoreProcessIdentity[] GetActiveCoreProcesses() => + CoreProcessTracker.GetActiveProcesses(Volatile.Read(ref _trackedCoreProcesses)); + + private int[] GetActiveCoreProcessIds() => + GetActiveCoreProcesses().Select(process => process.ProcessId).ToArray(); + + private bool HasTrackedCoreProcesses => GetActiveCoreProcesses().Length > 0; + + private void SetTrackedCoreProcesses(IEnumerable processes) => + Volatile.Write(ref _trackedCoreProcesses, processes.DistinctBy(process => process.ProcessId).ToArray()); + + private async Task StopCoreAndConfirmAsync(CancellationToken cancellationToken) + { + await CoreManager.Instance.CoreStop(); + + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var activeProcesses = GetActiveCoreProcesses(); + // Port occupancy is not process ownership: another program may bind a just-released + // TCP or UDP port before this check. Only Web's captured process identities can + // keep stop pending or cause a stop failure. + if (activeProcesses.Length == 0) + { + SetTrackedCoreProcesses([]); + return; + } + + if (DateTimeOffset.UtcNow >= deadline) + { + var details = new List(); + if (activeProcesses.Length > 0) + { + details.Add($"Core processes remain: {string.Join(",", activeProcesses.Select(process => process.ProcessId))}."); + } + throw new TimeoutException(string.Join(" ", details)); + } + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + } + } + + private async Task MonitorCoreRuntimeAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMilliseconds(750), cancellationToken); + var snapshot = CurrentCoreRuntime; + if (snapshot.State != CoreRuntimeState.Running || snapshot.ProxyPort is not int port) + { + continue; + } + + var processIds = GetActiveCoreProcessIds(); + var listening = await IsListeningAsync(port, cancellationToken); + if (!ReferenceEquals(snapshot, CurrentCoreRuntime) || CurrentCoreRuntime.State != CoreRuntimeState.Running) + { + continue; + } + if (processIds.Length > 0 && listening) + { + continue; + } + + var failure = processIds.Length == 0 + ? "The tracked Core child process exited unexpectedly." + : "The running Core no longer owns its expected proxy listener."; + SetCoreRuntime(snapshot with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = failure, + }); + AddLog("core", failure); + return; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Expected when the Core is stopped, restarted, or the Web host shuts down. + } + catch (Exception exception) + { + AddLog("core", $"Core runtime monitor failed: {exception}"); + } + } + + public async Task InitializeAsync(CancellationToken cancellationToken, bool suppressCoreAutostart = false) + { + var restoreState = await LoadRestoreRuntimeStateAsync(cancellationToken); + var updateStatePath = WebUpdateRuntimeStatePath; + var updateRuntimeState = restoreState is null + ? await LoadRuntimeRestartIntentAsync(updateStatePath, cancellationToken) + : null; + var startupRuntimeIntent = restoreState ?? updateRuntimeState; + if (!AppManager.Instance.InitApp()) + { + throw new InvalidOperationException("ServiceLib could not load its configuration."); + } + ConfigureServiceLibConsoleLogging(); + + AppManager.Instance.WindowDialog = new HeadlessWindowDialog(); + if (!AppManager.Instance.InitComponents()) + { + throw new InvalidOperationException("ServiceLib component initialization failed."); + } + + await _mutations.RunAsync(async () => + { + EnsureInboundDefaults(Config); + EnsureCoreTypeMappings(); + // Migrate only API-owned preference identifiers; no ServiceLib code changes. + Config.CheckUpdateItem.SelectedCoreTypes = NormalizeWebUpdateTargets(Config.CheckUpdateItem.SelectedCoreTypes); + Config.CheckUpdateItem.CheckPreReleaseCoreTypes = NormalizeWebUpdateTargets(Config.CheckUpdateItem.CheckPreReleaseCoreTypes); + var proxyPortOverride = _configuration.GetValue("V2RAYN_WEB_PROXY_PORT"); + if (proxyPortOverride is <= 0 or > 65535) + { + throw new InvalidOperationException("V2RAYN_WEB_PROXY_PORT must be a valid TCP/UDP port (1-65535)."); + } + if (proxyPortOverride is int configuredProxyPort) + { + Config.Inbound[0].LocalPort = configuredProxyPort; + } + if (_configuration.GetValue("V2RAYN_WEB_PROXY_LISTEN_ALL") is bool allowProxyFromLan) + { + Config.Inbound[0].AllowLANConn = allowProxyFromLan; + } + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + await ConfigHandler.InitBuiltinDNS(Config); + await BuiltinDnsProfileBootstrap.EnsureAsync( + Config, + async () => await AppManager.Instance.DNSItems() ?? [], + ConfigHandler.SaveDNSItems); + await ConfigHandler.InitBuiltinFullConfigTemplate(Config); + // Keep startup aligned with Desktop's StatusBarViewModel. In particular, + // InitBuiltinRouting owns the RoutingIndexId -> IsActive migration and must + // run even when a restored Desktop database already has routing rows. + await ConfigHandler.InitBuiltinRouting(Config); + _ = await ConfigHandler.GetDefaultRouting(Config); + var restoredSubscriptions = await AppManager.Instance.SubItems() ?? []; + var normalizedSubIndexId = NormalizeSelectedSubscriptionId( + Config.SubIndexId, + restoredSubscriptions.Select(item => item.Id).ToArray()); + if (Config.SubIndexId != normalizedSubIndexId) + { + AddLog("restore", "The selected subscription group no longer exists; normalized the selection to all profiles."); + Config.SubIndexId = normalizedSubIndexId; + } + var selectedProfileId = Config.IndexId; + var defaultProfile = await ConfigHandler.GetDefaultServer(Config); + await _mutations.RunAsync(() => EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config))); + await ProfileExManager.Instance.Init(); + await CertPemManager.Instance.Init(Config); + // The Web frontend owns its runtime and rejects generated launch contexts that enable TUN. + await CoreManager.Instance.Init(Config, OnCoreMessageAsync); + if (Config.GuiItem.EnableStatistics || Config.GuiItem.DisplayRealTimeSpeed) + { + await StatisticsManager.Instance.Init(Config, OnStatisticsUpdateAsync); + } + _xrayPath = FindXrayExecutable(out var missingXrayMessage); + if (_xrayPath is null) + { + AddLog("core", missingXrayMessage); + } + + _initialized = true; + AddLog("web", "serviceLib.initialized"); + StartScheduledOperations(cancellationToken); + + if (startupRuntimeIntent is not null) + { + if (!RuntimeRestartRecovery.ShouldRecoverCore(startupRuntimeIntent, suppressCoreAutostart)) + { + if (suppressCoreAutostart && startupRuntimeIntent.WasRunning) + { + AddLog("core", "Core startup was suppressed until first-run Management Key setup is complete."); + } + } + else + { + await RuntimeRestartRecovery.RecoverAsync( + startupRuntimeIntent, + selectedProfileId, + defaultProfile?.IndexId, + async (profileId, _) => await AppManager.Instance.GetProfileItem(profileId) is not null, + StartCoreAsync, + message => AddLog(restoreState is not null ? "restore" : "update", message), + cancellationToken); + } + if (restoreState is not null) + { + await CommitRestoreRuntimeStateAsync(); + } + } + else if (RuntimeRestartRecovery.ShouldAutoStart( + startupRuntimeIntent, + _configuration.GetValue("V2RAYN_WEB_AUTOSTART", false), + suppressCoreAutostart)) + { + var result = await StartCoreAsync(null, cancellationToken); + if (!result.Success) + { + AddLog("core", result.MessageKey); + } + } + else if (suppressCoreAutostart) + { + AddLog("core", "Core autostart was suppressed until first-run Management Key setup is complete."); + } + } + + public async Task ShutdownAsync(CancellationToken cancellationToken) + { + var steps = new List + { + new("restore shutdown request", async deadline => + { + await WaitForRestoreShutdownRequestAsync(deadline.Token); + return true; + }), + new("scheduled operations stop", deadline => StopScheduledOperationsAsync(deadline.Token)), + new("operation drain", async deadline => + { + var drained = await _operations.StopAndDrainAsync(deadline.Token, deadline.Remaining); + if (!drained) + { + AddLog("web", "Shutdown operation drain timed out; skipped all Core, state-save, and SQLite cleanup."); + } + return drained; + }), + }; + + var saveServiceLibState = _initialized && !_restoring; + if (saveServiceLibState) + { + steps.Add(new("Core stop", async deadline => + { + try + { + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(deadline.Token); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + return true; + } + catch (Exception exception) + { + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = exception.Message, + }); + throw; + } + })); + steps.Add(new("profile save", async _ => + { + await ProfileExManager.Instance.SaveTo(); + return true; + })); + steps.Add(new("statistics save", async _ => + { + await StatisticsManager.Instance.SaveTo(); + return true; + })); + steps.Add(new("statistics close", _ => + { + StatisticsManager.Instance.Close(); + return Task.FromResult(true); + })); + steps.Add(new("configuration save", async deadline => + { + await _mutations.RunAsync( + () => EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)), + deadline.Token); + return true; + })); + steps.Add(new("database close", async _ => + { + await SQLiteHelper.Instance.DisposeDbConnectionAsync(); + return true; + })); + } + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + RuntimeShutdownBudgets.RuntimeCleanup, + cancellationToken, + message => AddLog("web", message), + ShutdownDiagnostics.SetStage); + if (completed && saveServiceLibState) + { + AddLog("web", "serviceLib.stopped"); + } + } + + internal static async Task EnsureConfigSaveSucceededAsync(Func> save) + { + if (await save() != 0) + { + throw new IOException("ServiceLib could not persist the configuration."); + } + } + + public async Task> GetSubscriptionsAsync() + { + var items = await AppManager.Instance.SubItems() ?? []; + return items.Select(ToSubscriptionView).ToArray(); + } + + public async Task AddSubscriptionAsync(SubscriptionInput input) + { + if (!TryValidateSubscription(input, out var code, out var messageKey)) + { + return new(false, code, messageKey, null); + } + + var item = ToSubItem(input, null); + var result = await _mutations.RunAsync(() => ConfigHandler.AddSubItem(Config, item)); + if (result != 0) + { + return new(false, "subscription_save_failed", ApiMessageKeys.SubscriptionSaveFailed, null); + } + + var saved = await AppManager.Instance.GetSubItem(item.Id); + return saved is null + ? new(false, "subscription_save_failed", ApiMessageKeys.SubscriptionSaveFailed, null) + : new(true, "ok", ApiMessageKeys.SubscriptionAdded, ToSubscriptionView(saved)); + } + + public async Task UpdateSubscriptionAsync(string id, SubscriptionInput input) + { + if (!TryValidateSubscription(input, out var code, out var messageKey)) + { + return new(false, code, messageKey, null); + } + + return await _mutations.RunAsync(async () => + { + var existing = await AppManager.Instance.GetSubItem(id); + if (existing is null) + { + return new SubscriptionMutationResult(false, "subscription_not_found", ApiMessageKeys.SubscriptionNotFound, null); + } + + var item = ToSubItem(input, existing); + item.Id = id; + var result = await ConfigHandler.AddSubItem(Config, item); + if (result != 0) + { + return new SubscriptionMutationResult(false, "subscription_save_failed", ApiMessageKeys.SubscriptionSaveFailed, null); + } + + var saved = await AppManager.Instance.GetSubItem(id); + return saved is not null + ? new SubscriptionMutationResult(true, "ok", ApiMessageKeys.SubscriptionSaved, ToSubscriptionView(saved)) + : new SubscriptionMutationResult(false, "subscription_save_failed", ApiMessageKeys.SubscriptionSaveFailed, null); + }); + } + + public async Task DeleteSubscriptionAsync(string id) + { + var subscription = await AppManager.Instance.GetSubItem(id); + if (subscription is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound); + } + + var selectedProfile = await AppManager.Instance.GetProfileItem(Config.IndexId); + var removesCurrentProfile = selectedProfile?.Subid == id; + if (removesCurrentProfile) + { + await StopCoreAsync(CancellationToken.None); + } + + await _mutations.RunAsync(async () => + { + if (await ConfigHandler.DeleteSubItem(Config, id) != 0) + { + throw new IOException("ServiceLib could not delete the subscription."); + } + if (removesCurrentProfile) + { + _ = await ConfigHandler.GetDefaultServer(Config); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + } + }); + + AddLog("subscription", ApiMessageKeys.SubscriptionDeleted); + return OperationView.Ok(ApiMessageKeys.SubscriptionDeleted); + } + + public bool StartSubscriptionUpdate(string id, bool useProxy) + { + return StartSubscriptionUpdateTask(id, useProxy, _operations.ShutdownToken) is not null; + } + + private Task? StartSubscriptionUpdateTask(string id, bool useProxy, CancellationToken operationToken) + { + lock (_subscriptionGate) + { + var hasConflictingUpdate = _subscriptionTasks.Any(pair => + !pair.Value.IsCompleted + && (pair.Key.Length == 0 || id.Length == 0 || pair.Key == id)); + if (hasConflictingUpdate) + { + return null; + } + + var task = Task.Run(async () => + { + try + { + await using var operation = await _operations.EnterOperationAsync(operationToken); + await _mutations.RunAsync(async () => + { + await SubscriptionHandler.UpdateProcess(Config, id, useProxy, (success, message) => + { + var payload = new + { + subscriptionId = id, + success, + code = success ? "ok" : "subscription_update_progress", + messageKey = success ? ApiMessageKeys.SubscriptionSaved : ApiMessageKeys.SubscriptionUpdateProgress, + rawLog = message, + }; + _events.Publish("subscription-progress", payload); + AddLog("subscription", message); + return Task.CompletedTask; + }); + + operation.Token.ThrowIfCancellationRequested(); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + await UpdateSubscriptionTimestampLockedAsync(id); + }, operation.Token); + _events.Publish("profiles-changed", new { subscriptionId = id }); + } + catch (OperationCanceledException) when (_operations.IsStopping || operationToken.IsCancellationRequested) + { + // Expected during graceful service shutdown. + } + catch (Exception ex) + { + AddLog("subscription", ex.Message); + } + finally + { + lock (_subscriptionGate) + { + _subscriptionTasks.Remove(id); + } + } + }); + + _subscriptionTasks[id] = task; + return task; + } + } + + public async Task> GetProfilesAsync(string? subscriptionId, string? filter) + { + var groupId = subscriptionId ?? Config.SubIndexId ?? string.Empty; + var profiles = await AppManager.Instance.ProfileItems(groupId) ?? []; + var profileModels = await AppManager.Instance.ProfileModels(groupId, string.Empty) ?? []; + var modelMap = profileModels.ToDictionary(item => item.IndexId); + var subscriptions = (await AppManager.Instance.SubItems() ?? []).ToDictionary(item => item.Id, item => item.Remarks); + var extensions = await ProfileExManager.Instance.GetProfileExs(); + var extensionMap = extensions.ToDictionary(item => item.IndexId); + var statistics = (Config.GuiItem.EnableStatistics ? StatisticsManager.Instance.ServerStat : null) ?? []; + var statisticsMap = statistics.ToDictionary(item => item.IndexId); + var query = filter?.Trim(); + + return profiles + .Where(item => string.IsNullOrEmpty(query) + || Utils.IsRegexMatch(item.Remarks, query) + || Utils.IsRegexMatch(item.Address, query)) + .Select(item => + { + extensionMap.TryGetValue(item.IndexId, out var extension); + modelMap.TryGetValue(item.IndexId, out var model); + statisticsMap.TryGetValue(item.IndexId, out var statistic); + var delay = extension?.Delay ?? 0; + var speed = extension?.Speed ?? 0; + return new ProfileView( + item.IndexId, + item.Remarks, + System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(item.ConfigType.ToString()), + item.Address, + item.Port, + item.GetNetwork(), + item.StreamSecurity, + item.Subid, + model?.SubRemarks ?? subscriptions.GetValueOrDefault(item.Subid), + System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(AppManager.Instance.GetCoreType(item, item.ConfigType).ToString()), + extension?.Sort ?? 0, + delay, + speed, + extension?.IpInfo, + statistic?.TodayUp ?? 0, + statistic?.TodayDown ?? 0, + statistic?.TotalUp ?? 0, + statistic?.TotalDown ?? 0, + item.IndexId == Config.IndexId, + !item.IsComplex() && item.Port > 0); + }) + .OrderBy(item => item.Sort) + .ToArray(); + } + + public async Task GetProfileDetailsAsync(string profileId) => + await AppManager.Instance.GetProfileItem(profileId); + + public async Task GetStatusAsync() + { + var selectedProfile = await AppManager.Instance.GetProfileItem(Config.IndexId); + var profileItems = await AppManager.Instance.ProfileItems(string.Empty) ?? []; + var subscriptions = await AppManager.Instance.SubItems() ?? []; + var snapshot = CurrentCoreRuntime; + var processIds = GetActiveCoreProcessIds(); + var listenerDefinitions = snapshot.ProxyPort.HasValue && snapshot.State != CoreRuntimeState.Stopped + ? snapshot.Listeners + : GetConfiguredListenerSnapshots(); + var listeners = new List(listenerDefinitions.Length); + foreach (var listener in listenerDefinitions) + { + listeners.Add(new ListenerView( + listener.Name, + listener.Protocols, + listener.ListenAddress, + listener.Port, + await IsListeningAsync(listener.Port, CancellationToken.None))); + } + + var mainListener = listeners.FirstOrDefault(listener => listener.Name == "local"); + var listening = mainListener?.Listening == true; + var inbound = Config.Inbound.FirstOrDefault(); + var sniffingEnabled = inbound?.SniffingEnabled ?? false; + var sniffingTypes = NormalizeDestOverride(sniffingEnabled, inbound?.DestOverride); + var coreRunning = snapshot.State == CoreRuntimeState.Running && processIds.Length > 0 && listening; + if (snapshot.State == CoreRuntimeState.Running && !coreRunning) + { + snapshot = snapshot with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = processIds.Length == 0 + ? "The tracked Core child process exited unexpectedly." + : "The running Core no longer owns its expected proxy listener.", + }; + SetCoreRuntime(snapshot); + } + + var runningProfile = string.IsNullOrEmpty(snapshot.ProfileId) + ? null + : await AppManager.Instance.GetProfileItem(snapshot.ProfileId); + var configuredProxyPort = Config.Inbound.FirstOrDefault()?.LocalPort; + return new StatusView( + coreRunning, + snapshot.CoreType is { } coreType + ? System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(coreType.ToString()) + : null, + selectedProfile?.IndexId, + selectedProfile?.Remarks, + snapshot.StartedAt, + listeners.ToArray(), + !string.IsNullOrEmpty(_xrayPath), + Utils.GetRuntimeInfo(), + profileItems.Count, + subscriptions.Count, + DateTimeOffset.UtcNow, + Config.GuiItem.EnableStatistics, + _latestTraffic is null ? null : new TrafficView( + _latestTraffic.ProxyUp, + _latestTraffic.ProxyDown, + _latestTraffic.DirectUp, + _latestTraffic.DirectDown), + snapshot.State.ToString().ToLowerInvariant(), + configuredProxyPort, + snapshot.State == CoreRuntimeState.Stopped ? null : snapshot.ProxyPort, + snapshot.ProfileId, + runningProfile?.Remarks, + snapshot.ApiPort, + processIds, + snapshot.LastFailure, + WebBuildIdentity.Current.Version, + WebBuildIdentity.Current.Commit, + WebBuildIdentity.Current.Rid, + WebBuildIdentity.Current.BuildDate, + sniffingEnabled, + sniffingTypes, + WebApiCapabilities.Current); + + } + + public async Task SelectProfileAsync(string profileId, CancellationToken cancellationToken) + { + var profile = await AppManager.Instance.GetProfileItem(profileId); + if (profile is null) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + + return await StartCoreAsync(profile, cancellationToken, selectProfile: true); + } + + public async Task StartCoreAsync(string? profileId, CancellationToken cancellationToken) + { + ProfileItem? profile = null; + if (!string.IsNullOrWhiteSpace(profileId)) + { + profile = await AppManager.Instance.GetProfileItem(profileId); + if (profile is null) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + } + + return await StartCoreAsync(profile, cancellationToken); + } + + private async Task StartCoreAsync(ProfileItem? requestedProfile, CancellationToken cancellationToken, bool selectProfile = false) + { + await _coreGate.WaitAsync(cancellationToken); + try + { + return await StartCoreLockedAsync(requestedProfile, cancellationToken, selectProfile); + } + finally + { + _coreGate.Release(); + } + } + + private async Task StartCoreLockedAsync(ProfileItem? requestedProfile, CancellationToken cancellationToken, bool selectProfile = false) + { + var profile = requestedProfile ?? await GetDefaultProfileAsync(); + if (profile is null) + { + return OperationView.Fail("profile_not_selected", ApiMessageKeys.ProfileNoneSelected); + } + + var current = CurrentCoreRuntime; + if (!selectProfile + && current.State == CoreRuntimeState.Running + && current.ProfileId == profile.IndexId + && HasTrackedCoreProcesses) + { + return OperationView.Ok(ApiMessageKeys.CoreStarted, new { profileId = profile.IndexId, alreadyRunning = true }); + } + + var preflight = await BuildAndValidateCoreLaunchAsync(profile); + if (preflight.Failure is { } preflightFailure) + { + return preflightFailure; + } + + if (selectProfile) + { + await _mutations.RunAsync(async () => + { + Config.IndexId = profile.IndexId; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + } + + return await LaunchPreflightedCoreLockedAsync(preflight, cancellationToken); + } + + private async Task GetDefaultProfileAsync() => await _mutations.RunAsync(async () => + { + var selected = await ConfigHandler.GetDefaultServer(Config); + if (selected is not null) + { + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + } + return selected; + }); + + private async Task BuildAndValidateCoreLaunchAsync(ProfileItem profile) + { + var preflight = await CoreLaunchPreflight.BuildAndValidateAsync(Config, profile, coreType => + { + var coreInfo = CoreInfoManager.Instance.GetCoreInfo(coreType); + var executable = CoreInfoManager.Instance.GetCoreExecFile(coreInfo, out var missingCoreMessage); + if (!string.IsNullOrEmpty(executable)) + { + return null; + } + + AddLog("core", missingCoreMessage); + return OperationView.Fail("core_binary_missing", ApiMessageKeys.CoreBinaryMissing, + new { coreType = coreType.ToString() }); + }); + + if (preflight.Failure?.Code == "profile_validation_failed") + { + foreach (var message in preflight.BuiltContext.CombinedValidatorResult.Errors + .Concat(preflight.BuiltContext.CombinedValidatorResult.Warnings)) + { + AddLog("core-validation", message); + } + } + + if (preflight.Success) + { + RuntimeListenerSnapshot[] currentlyOwnedListeners = HasTrackedCoreProcesses + ? CurrentCoreRuntime.Listeners + : []; + foreach (var listener in GetConfiguredListenerSnapshots()) + { + if (await IsProjectedListenerUnavailableAsync(listener, currentlyOwnedListeners, CancellationToken.None)) + { + return new CoreLaunchPreflightResult( + preflight.Profile, + preflight.BuiltContext, + OperationView.Fail("proxy_port_in_use", ApiMessageKeys.CorePortInUse, + new { port = listener.Port, listener = listener.Name })); + } + } + } + + return preflight; + } + + private async Task LaunchPreflightedCoreLockedAsync( + CoreLaunchPreflightResult preflight, + CancellationToken cancellationToken) + { + var profile = preflight.Profile; + var built = preflight.BuiltContext; + var port = Config.Inbound.FirstOrDefault()?.LocalPort ?? 0; + if (!HasTrackedCoreProcesses && CurrentCoreRuntime.State == CoreRuntimeState.Stopped + && await IsListeningAsync(port, cancellationToken)) + { + return OperationView.Fail("proxy_port_in_use", ApiMessageKeys.CorePortInUse, new { port }); + } + + if (CurrentCoreRuntime.State != CoreRuntimeState.Stopped || HasTrackedCoreProcesses) + { + SetCoreRuntime(CurrentCoreRuntime with { State = CoreRuntimeState.Stopping, LastFailure = null }); + try + { + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(cancellationToken); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + } + catch (Exception exception) + { + var failedStop = CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = exception.Message, + }; + SetCoreRuntime(failedStop); + AddLog("core", $"Core stop before launch failed: {exception}"); + return OperationView.Fail("core_stop_failed", ApiMessageKeys.CoreStopFailed, + new { detail = exception.Message, processIds = failedStop.ProcessIds }); + } + } + + var expectedCoreType = built.PreSocksResult?.Context.RunCoreType ?? built.MainResult.Context.RunCoreType; + var listeners = GetConfiguredListenerSnapshots(); + SetCoreRuntime(new CoreRuntimeSnapshot( + CoreRuntimeState.Starting, + profile.IndexId, + expectedCoreType, + null, + listeners.FirstOrDefault(listener => listener.Name == "local")?.Port, + AppManager.Instance.StatePort, + listeners, + [], + null)); + + try + { + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + var expectedExecutablePaths = GetExpectedCoreExecutablePaths(built); + var expectedConfigPaths = GetExpectedCoreConfigPaths(built); + try + { + await CoreManager.Instance.LoadCore(built.MainResult.Context, built.PreSocksResult?.Context); + } + finally + { + SetTrackedCoreProcesses(CoreProcessTracker.CaptureStartedProcesses( + existingProcessIds, + expectedExecutablePaths, + expectedConfigPaths)); + } + var started = await WaitForListenerAsync(port, cancellationToken); + var processIds = GetActiveCoreProcessIds(); + if (!started || processIds.Length == 0) + { + var failure = !started + ? "The Core process did not open its configured proxy listener." + : "The Web runtime could not identify a live Core child process."; + try + { + await StopCoreAndConfirmAsync(CancellationToken.None); + processIds = GetActiveCoreProcessIds(); + } + catch (Exception stopException) + { + failure += $" Cleanup also failed: {stopException.Message}"; + } + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = failure, + }); + AddLog("core", failure); + _events.Publish("status", await GetStatusAsync()); + return OperationView.Fail("core_start_failed", ApiMessageKeys.CoreStartFailed, + new { profileId = profile.IndexId, detail = failure, processIds }); + } + + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Running, + CoreType = AppManager.Instance.RunningCoreType, + StartedAt = DateTimeOffset.UtcNow, + ProcessIds = processIds, + LastFailure = null, + }); + await StartCoreMonitorAsync(); + AddLog("core", ApiMessageKeys.CoreStarted); + _events.Publish("status", await GetStatusAsync()); + return OperationView.Ok(ApiMessageKeys.CoreStarted, new { profileId = profile.IndexId, processIds }); + } + catch (Exception exception) + { + AddLog("core", $"Core start failed: {exception}"); + var processIds = GetActiveCoreProcessIds(); + if (CurrentCoreRuntime.State == CoreRuntimeState.Starting || processIds.Length > 0) + { + try + { + await StopCoreAndConfirmAsync(CancellationToken.None); + processIds = GetActiveCoreProcessIds(); + } + catch (Exception stopException) + { + AddLog("core", $"Core start cleanup failed: {stopException}"); + } + } + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = exception.Message, + }); + _events.Publish("status", await GetStatusAsync()); + return OperationView.Fail("core_start_failed", ApiMessageKeys.CoreStartFailed, + new { profileId = profile.IndexId, detail = exception.Message, processIds }); + } + } + + internal static OperationView? GetTunLaunchRejection(CoreConfigContextBuilderAllResult built) => + CoreLaunchPreflight.GetTunLaunchRejection(built); + + internal static string NormalizeSelectedSubscriptionId(string? selectedId, IReadOnlyCollection availableIds) => + string.IsNullOrEmpty(selectedId) || availableIds.Contains(selectedId, StringComparer.Ordinal) + ? selectedId ?? string.Empty + : string.Empty; + + public async Task StopCoreAsync(CancellationToken cancellationToken) + { + await _coreGate.WaitAsync(cancellationToken); + try + { + return await StopCoreLockedAsync(cancellationToken); + } + finally + { + _coreGate.Release(); + } + } + + private async Task StopCoreLockedAsync(CancellationToken cancellationToken) + { + if (CurrentCoreRuntime.State == CoreRuntimeState.Stopped && !HasTrackedCoreProcesses) + { + return OperationView.Ok(ApiMessageKeys.CoreStopped); + } + + SetCoreRuntime(CurrentCoreRuntime with { State = CoreRuntimeState.Stopping, LastFailure = null }); + try + { + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(cancellationToken); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + AddLog("core", ApiMessageKeys.CoreStopped); + _events.Publish("status", await GetStatusAsync()); + return OperationView.Ok(ApiMessageKeys.CoreStopped); + } + catch (Exception exception) + { + var processIds = GetActiveCoreProcessIds(); + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = exception.Message, + }); + AddLog("core", $"Core stop failed: {exception}"); + _events.Publish("status", await GetStatusAsync()); + return OperationView.Fail("core_stop_failed", ApiMessageKeys.CoreStopFailed, + new { detail = exception.Message, processIds }); + } + } + + public async Task RestartCoreAsync(CancellationToken cancellationToken) + { + await _coreGate.WaitAsync(cancellationToken); + try + { + return await RestartCoreLockedAsync(cancellationToken); + } + finally + { + _coreGate.Release(); + } + } + + private async Task RestartCoreLockedAsync(CancellationToken cancellationToken) + { + var previous = CurrentCoreRuntime; + if (previous.State is not (CoreRuntimeState.Running or CoreRuntimeState.Faulted) + || string.IsNullOrEmpty(previous.ProfileId)) + { + return OperationView.Fail("core_not_running", ApiMessageKeys.CoreNotRunning, + new { runtimeState = previous.State.ToString(), processIds = GetActiveCoreProcessIds() }); + } + + var profile = await AppManager.Instance.GetProfileItem(previous.ProfileId); + if (profile is null) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound, + new { profileId = previous.ProfileId }); + } + + AppManager.Instance.Reset(); + var restartStage = "preflight"; + try + { + return await CoreRestartFlow.ExecuteAsync( + () => BuildAndValidateCoreLaunchAsync(profile), + async () => + { + restartStage = "stop"; + SetCoreRuntime(previous with { State = CoreRuntimeState.Restarting, LastFailure = null }); + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(cancellationToken); + }, + () => + { + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + restartStage = "start"; + }, + preflight => LaunchPreflightedCoreLockedAsync(preflight, cancellationToken)); + } + catch (Exception exception) + { + var processIds = GetActiveCoreProcessIds(); + if (restartStage != "preflight") + { + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = exception.Message, + }); + } + AddLog("core", $"Core restart failed during {restartStage}: {exception}"); + return restartStage == "stop" + ? OperationView.Fail("core_stop_failed", ApiMessageKeys.CoreStopFailed, + new { detail = exception.Message, processIds }) + : OperationView.Fail("core_restart_failed", ApiMessageKeys.CoreStartFailed, + new { stage = restartStage, detail = exception.Message, processIds }); + } + } + + public async Task StartLatencyTestAsync(string profileId) + { + var profile = await AppManager.Instance.GetProfileItem(profileId); + if (profile is null || profile.IsComplex() || profile.Port <= 0) + { + return OperationView.Fail("speedtest_invalid_profile", ApiMessageKeys.SpeedTestInvalidProfile); + } + + return await StartSpeedTestAsync(new SpeedTestRequest(ESpeedActionType.Tcping, [profileId]), CancellationToken.None); + } + + public OperationView StopLatencyTests() + { + _speedtestService?.ExitLoop(); + return OperationView.Ok(ApiMessageKeys.CommonCompleted); + } + + public IReadOnlyList GetRecentLogs(int limit, string? filter = null) => _logs.Recent(limit, filter); + + public LogPageView GetRecentLogsPage(int page, int pageSize, string? filter = null) => _logs.RecentPage(page, pageSize, filter); + + public long ClearLogs() + { + var generation = _logs.Clear(); + _events.Publish("logs-cleared", new { generation }); + return generation; + } + + private async Task OnCoreMessageAsync(bool notify, string message) + { + AddLog("core", message); + await Task.CompletedTask; + } + + private void AddLog(string source, string message) + { + var entry = _logs.Add(source, message); + _events.Publish("log", entry); + Console.Out.WriteLine($"[{source}] {message.TrimEnd()}"); + } + + private static void ConfigureServiceLibConsoleLogging() + { + var logging = LogManager.Configuration; + if (logging is null) + { + return; + } + + var target = new ConsoleTarget("web-console") + { + Layout = "${longdate}|${level:uppercase=true}|${logger}|${message}", + }; + logging.AddTarget(target); + logging.LoggingRules.Add(new LoggingRule("*", NLog.LogLevel.Debug, target)); + LogManager.Configuration = logging; + } + + private Task OnStatisticsUpdateAsync(ServerSpeedItem update) + { + _latestTraffic = update; + _events.Publish("traffic", update); + return Task.CompletedTask; + } + + // The subscription update owns the mutation gate while calling this helper. + private async Task UpdateSubscriptionTimestampLockedAsync(string subscriptionId) + { + var updateTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (subscriptionId.Length > 0) + { + var item = await AppManager.Instance.GetSubItem(subscriptionId); + if (item is not null) + { + item.UpdateTime = updateTime; + if (await ConfigHandler.AddSubItem(Config, item) != 0) + { + throw new IOException("ServiceLib could not save the subscription timestamp."); + } + } + return; + } + + foreach (var item in await AppManager.Instance.SubItems() ?? []) + { + if (item.Enabled) + { + item.UpdateTime = updateTime; + if (await ConfigHandler.AddSubItem(Config, item) != 0) + { + throw new IOException("ServiceLib could not save the subscription timestamp."); + } + } + } + } + + internal static bool TryValidateSubscription(SubscriptionInput input, out string code, out string messageKey) + { + if (string.IsNullOrWhiteSpace(input.Remarks)) + { + code = "subscription_name_required"; + messageKey = ApiMessageKeys.SubscriptionNameRequired; + return false; + } + if (!string.IsNullOrWhiteSpace(input.Url) + && (!Uri.TryCreate(input.Url, UriKind.Absolute, out var uri) + || uri.Scheme is not ("http" or "https"))) + { + code = "subscription_url_invalid"; + messageKey = ApiMessageKeys.SubscriptionInvalidUrl; + return false; + } + if (!HttpRequestHeadersHelper.TryParse(input.RequestHeaders, out _)) + { + code = "subscription_headers_invalid"; + messageKey = ApiMessageKeys.CommonInvalidInput; + return false; + } + + code = "ok"; + messageKey = string.Empty; + return true; + } + + internal static SubItem ToSubItem(SubscriptionInput input, SubItem? existing) => new() + { + Id = existing?.Id ?? string.Empty, + Remarks = input.Remarks.Trim(), + Url = input.Url?.Trim() ?? string.Empty, + MoreUrl = input.MoreUrl?.Trim() ?? existing?.MoreUrl ?? string.Empty, + Enabled = input.Enabled ?? existing?.Enabled ?? true, + UserAgent = input.UserAgent?.Trim() ?? existing?.UserAgent ?? string.Empty, + RequestHeaders = input.RequestHeaders ?? existing?.RequestHeaders, + Filter = input.Filter ?? existing?.Filter, + AutoUpdateInterval = Math.Max(input.AutoUpdateInterval ?? existing?.AutoUpdateInterval ?? 0, 0), + ConvertTarget = input.ConvertTarget ?? existing?.ConvertTarget, + Memo = input.Memo ?? existing?.Memo, + Sort = input.Sort ?? existing?.Sort ?? 0, + UpdateTime = existing?.UpdateTime ?? 0, + PrevProfile = input.PrevProfile ?? existing?.PrevProfile, + NextProfile = input.NextProfile ?? existing?.NextProfile, + PreSocksPort = input.PreSocksPort, + CustomCoreType = input.CustomCoreType, + }; + + internal static SubscriptionView ToSubscriptionView(SubItem item) => new( + item.Id, + item.Remarks, + item.Url, + item.Enabled, + item.MoreUrl, + item.Filter, + item.AutoUpdateInterval, + item.UpdateTime, + item.UserAgent, + item.RequestHeaders, + item.ConvertTarget, + item.Memo, + item.Sort, + item.PrevProfile, + item.NextProfile, + item.PreSocksPort, + item.CustomCoreType is null ? null : System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(item.CustomCoreType.Value.ToString())); + + private static void EnsureInboundDefaults(Config config) + { + if (config.Inbound.Count == 0) + { + config.Inbound.Add(new InItem + { + Protocol = nameof(EInboundProtocol.socks), + LocalPort = 10808, + UdpEnabled = true, + SniffingEnabled = true, + }); + } + + if (config.Inbound[0].LocalPort is <= 0 or > 65535) + { + config.Inbound[0].LocalPort = 10808; + } + } + + private RuntimeListenerSnapshot[] GetConfiguredListenerSnapshots() + { + var inbound = Config.Inbound.FirstOrDefault(); + if (inbound is null || inbound.LocalPort is <= 0 or > 65535) + { + return []; + } + + var listeners = new List(); + string[] localProtocols = inbound.UdpEnabled ? ["http", "socks", "udp"] : ["http", "socks"]; + var localAddress = inbound.AllowLANConn && !inbound.NewPort4LAN ? "0.0.0.0" : "127.0.0.1"; + listeners.Add(new RuntimeListenerSnapshot("local", localProtocols, localAddress, inbound.LocalPort)); + if (inbound.SecondLocalPortEnabled) + { + var secondaryPort = AppManager.Instance.GetLocalPort(EInboundProtocol.socks2); + if (secondaryPort is > 0 and <= 65535) + { + listeners.Add(new RuntimeListenerSnapshot("local-secondary", localProtocols, "127.0.0.1", secondaryPort)); + } + } + if (inbound.AllowLANConn && inbound.NewPort4LAN) + { + var lanPort = AppManager.Instance.GetLocalPort(EInboundProtocol.socks3); + if (lanPort is > 0 and <= 65535) + { + listeners.Add(new RuntimeListenerSnapshot("lan", localProtocols, "0.0.0.0", lanPort)); + } + } + return listeners.ToArray(); + } + + private static string[] GetExpectedCoreExecutablePaths(CoreConfigContextBuilderAllResult built) + { + var coreTypes = new ECoreType?[] + { + built.MainResult.Context.RunCoreType, + built.PreSocksResult?.Context.RunCoreType, + } + .Where(coreType => coreType.HasValue) + .Select(coreType => coreType!.Value) + .Distinct(); + + return coreTypes + .Select(coreType => CoreInfoManager.Instance.GetCoreInfo(coreType)) + .Select(info => info is null ? string.Empty : CoreInfoManager.Instance.GetCoreExecFile(info, out _)) + .Where(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)) + .Select(Path.GetFullPath) + .Distinct(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal) + .ToArray(); + } + + private static string[] GetExpectedCoreConfigPaths(CoreConfigContextBuilderAllResult built) + { + var fileNames = built.PreSocksResult is null + ? new[] { Global.CoreConfigFileName } + : new[] { Global.CoreConfigFileName, Global.CorePreConfigFileName }; + return fileNames + .Select(Utils.GetBinConfigPath) + .Select(Path.GetFullPath) + .Distinct(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal) + .ToArray(); + } + + private static string? FindXrayExecutable(out string message) + { + var info = CoreInfoManager.Instance.GetCoreInfo(ECoreType.Xray); + foreach (var name in info?.CoreExes ?? []) + { + var path = Utils.GetBinPath(Utils.GetExeName(name), ECoreType.Xray.ToString()); + if (File.Exists(path)) + { + message = string.Empty; + return path; + } + } + + message = $"Xray-core executable was not found under {Utils.GetBinPath(string.Empty, ECoreType.Xray.ToString())}."; + return null; + } + + private static async Task WaitForListenerAsync(int port, CancellationToken cancellationToken) + { + if (port is <= 0 or > 65535) + { + return false; + } + + var deadline = DateTimeOffset.UtcNow.AddSeconds(3); + while (DateTimeOffset.UtcNow < deadline) + { + if (await IsListeningAsync(port, cancellationToken)) + { + return true; + } + await Task.Delay(100, cancellationToken); + } + + return false; + } + + private static async Task IsListeningAsync(int port, CancellationToken cancellationToken) + { + try + { + using var client = new TcpClient(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMilliseconds(250)); + await client.ConnectAsync("127.0.0.1", port, timeout.Token); + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return false; + } + } + + private async Task GetRegionalPresetProxyAsync(CancellationToken cancellationToken) + { + var runtime = CurrentCoreRuntime; + var inbound = Config.Inbound.FirstOrDefault(); + if (runtime.State != CoreRuntimeState.Running + || !HasTrackedCoreProcesses + || runtime.ProxyPort is not int port + || inbound is null + || port != inbound.LocalPort + || !runtime.Listeners.Any(listener => listener.Name == "local" + && listener.Port == port + && listener.Protocols.Contains("socks", StringComparer.OrdinalIgnoreCase))) + { + return null; + } + + // IsListeningAsync has a 250 ms connect budget. If the live mixed listener is not + // already present, do not probe a configured-but-stopped SOCKS port via DownloadService. + if (!await IsListeningAsync(port, cancellationToken) + || !ReferenceEquals(runtime, CurrentCoreRuntime)) + { + return null; + } + + return new WebProxy($"socks5://{Global.Loopback}:{port}"); + } + + internal static async Task IsProjectedListenerUnavailableAsync( + RuntimeListenerSnapshot target, + IReadOnlyCollection runtimeOwned, + CancellationToken cancellationToken) + { + foreach (var address in GetProbeAddresses(target.ListenAddress)) + { + var ownsTcp = runtimeOwned.Any(listener => listener.Port == target.Port + && ListenerOwnsAddress(listener.ListenAddress, address)); + if (!ownsTcp + && (await IsListeningOnAddressAsync(address, target.Port, cancellationToken) + || !CanBind(address, target.Port, SocketType.Stream, ProtocolType.Tcp))) + { + return true; + } + var targetUsesUdp = target.Protocols.Contains("udp", StringComparer.OrdinalIgnoreCase); + var ownsUdp = runtimeOwned.Any(listener => listener.Port == target.Port + && listener.Protocols.Contains("udp", StringComparer.OrdinalIgnoreCase) + && ListenerOwnsAddress(listener.ListenAddress, address)); + if (targetUsesUdp && !ownsUdp + && !CanBind(address, target.Port, SocketType.Dgram, ProtocolType.Udp)) + { + return true; + } + } + return false; + } + + private static IPAddress[] GetProbeAddresses(string listenAddress) + { + if (IPAddress.TryParse(listenAddress, out var explicitAddress) + && !explicitAddress.Equals(IPAddress.Any)) + { + return [explicitAddress]; + } + + var addresses = new HashSet { IPAddress.Loopback }; + foreach (var network in NetworkInterface.GetAllNetworkInterfaces()) + { + if (network.OperationalStatus != OperationalStatus.Up) continue; + foreach (var address in network.GetIPProperties().UnicastAddresses + .Select(item => item.Address) + .Where(address => address.AddressFamily == AddressFamily.InterNetwork)) + { + addresses.Add(address); + } + } + return addresses.ToArray(); + } + + private static bool ListenerOwnsAddress(string listenAddress, IPAddress address) => + IPAddress.TryParse(listenAddress, out var parsed) + && (parsed.Equals(IPAddress.Any) || parsed.Equals(address)); + + private static async Task IsListeningOnAddressAsync(IPAddress address, int port, CancellationToken cancellationToken) + { + try + { + using var client = new TcpClient(address.AddressFamily); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMilliseconds(250)); + await client.ConnectAsync(address, port, timeout.Token); + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return false; + } + } + + private static bool CanBind(IPAddress address, int port, SocketType socketType, ProtocolType protocolType) + { + try + { + using var socket = new Socket(address.AddressFamily, socketType, protocolType) + { + ExclusiveAddressUse = true, + }; + socket.Bind(new IPEndPoint(address, port)); + if (socketType == SocketType.Stream) socket.Listen(1); + return true; + } + catch (SocketException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + +} diff --git a/v2rayN/v2rayN.WebAPI/Services/WebBuildIdentity.cs b/v2rayN/v2rayN.WebAPI/Services/WebBuildIdentity.cs new file mode 100644 index 00000000..21f828b2 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/WebBuildIdentity.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +namespace v2rayN.WebAPI.Services; + +public sealed record WebBuildIdentity(string Version, string Commit, string BuildDate, string Rid, string Repository) +{ + public static WebBuildIdentity Current { get; } = Load(); + + private static WebBuildIdentity Load() + { + var metadata = Assembly.GetEntryAssembly()?.GetCustomAttributes() + .Where(item => item.Key is "WebVersion" or "WebCommit" or "WebBuildDate" or "WebRepository") + .ToDictionary(item => item.Key, item => item.Value, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + var repository = metadata.GetValueOrDefault("WebRepository"); + if (!WebReleaseChannel.IsValidRepository(repository)) + repository = WebReleaseChannel.DefaultRepository; + return new WebBuildIdentity( + metadata.GetValueOrDefault("WebVersion") ?? "0.0.0-dev", + metadata.GetValueOrDefault("WebCommit") ?? "unknown", + metadata.GetValueOrDefault("WebBuildDate") ?? "unknown", + RuntimeInformation.RuntimeIdentifier, + repository!); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/WebReleaseAssets.cs b/v2rayN/v2rayN.WebAPI/Services/WebReleaseAssets.cs new file mode 100644 index 00000000..a5903fcd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/WebReleaseAssets.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace v2rayN.WebAPI.Services; + +/// +/// The single source of truth for v2rayN Web release asset names. +/// Assets/web-assets.json is embedded here and read by the packaging and manifest +/// scripts, so the updater, the manifest, and the release ZIPs never disagree about names. +/// +internal static class WebReleaseAssets +{ + private const string ResourceName = "v2rayN.WebAPI.Assets.web-assets.json"; + private static readonly IReadOnlyDictionary Map = Load(); + + public static string? ArtifactArch(string? rid) => Find(rid)?.Arch; + + public static string? FullAssetName(string? rid) => Find(rid)?.Full; + + public static string? UpdateAssetName(string? rid) => Find(rid)?.Update; + + private static Entry? Find(string? rid) => + rid is not null && Map.TryGetValue(rid, out var entry) ? entry : null; + + private sealed record Entry(string Arch, string Full, string Update); + + private static IReadOnlyDictionary Load() + { + using var stream = typeof(WebReleaseAssets).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidDataException($"The embedded Web release asset map is missing: {ResourceName}"); + using var document = JsonDocument.Parse(stream); + var map = new Dictionary(StringComparer.Ordinal); + foreach (var element in document.RootElement.GetProperty("assets").EnumerateArray()) + { + var rid = element.GetProperty("rid").GetString(); + var arch = element.GetProperty("arch").GetString(); + var full = element.GetProperty("full").GetString(); + var update = element.GetProperty("update").GetString(); + if (string.IsNullOrWhiteSpace(rid) + || string.IsNullOrWhiteSpace(arch) + || string.IsNullOrWhiteSpace(full) + || string.IsNullOrWhiteSpace(update) + || !map.TryAdd(rid, new Entry(arch, full, update))) + { + throw new InvalidDataException("The embedded Web release asset map is invalid."); + } + } + return map; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/WebReleaseChannel.cs b/v2rayN/v2rayN.WebAPI/Services/WebReleaseChannel.cs new file mode 100644 index 00000000..9f4f1d14 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/WebReleaseChannel.cs @@ -0,0 +1,54 @@ +using System.Text.RegularExpressions; + +namespace v2rayN.WebAPI.Services; + +/// +/// Describes the official v2rayN release channel used by the Web self-update. +/// Official releases use the upstream release tag (for example 7.25.3) and carry +/// WebAPI-update.json plus the app-only ZIP archives as additional release assets. +/// +internal static partial class WebReleaseChannel +{ + public const string DefaultRepository = "2dust/v2rayN"; + public const string ManifestAssetName = "WebAPI-update.json"; + private const string ApiBase = "https://api.github.com/repos"; + + public static bool IsValidRepository(string? value) => + !string.IsNullOrWhiteSpace(value) && RepositoryPattern().IsMatch(value); + + public static string BuildReleaseIndexUrl(string repository) + { + if (!IsValidRepository(repository)) + throw new ArgumentException("The release repository identifier is invalid.", nameof(repository)); + return $"{ApiBase}/{repository}/releases?per_page=100"; + } + + public static bool TryParseReleaseTag(string? tag, out string version) + { + version = string.Empty; + if (string.IsNullOrWhiteSpace(tag) || !ReleaseTagPattern().IsMatch(tag)) return false; + version = tag; + return true; + } + + public static bool IsTrustedAssetUrl(string? value, string repository, string tag, string assetName) + { + if (!IsValidRepository(repository) + || string.IsNullOrWhiteSpace(tag) + || string.IsNullOrWhiteSpace(assetName) + || !Uri.TryCreate(value, UriKind.Absolute, out var uri) + || uri.Scheme != Uri.UriSchemeHttps + || uri.Host != "github.com") + { + return false; + } + var expectedPath = $"/{repository}/releases/download/{Uri.EscapeDataString(tag)}/{Uri.EscapeDataString(assetName)}"; + return uri.AbsolutePath.Equals(expectedPath, StringComparison.Ordinal); + } + + [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+$", RegexOptions.CultureInvariant)] + private static partial Regex RepositoryPattern(); + + [GeneratedRegex("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", RegexOptions.CultureInvariant)] + private static partial Regex ReleaseTagPattern(); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/WebUpdateDeploymentPolicy.cs b/v2rayN/v2rayN.WebAPI/Services/WebUpdateDeploymentPolicy.cs new file mode 100644 index 00000000..fc20d34a --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/WebUpdateDeploymentPolicy.cs @@ -0,0 +1,33 @@ +namespace v2rayN.WebAPI.Services; + +internal enum WebDeploymentKind +{ + NativeWritable, + SystemdManaged, + Container, + ReadOnly, + Unsupported, +} + +internal sealed record WebUpdateDeployment(WebDeploymentKind Kind, bool CanCheck, bool CanInstall, string? InstallReasonKey); + +internal static class WebUpdateDeploymentPolicy +{ + public static WebUpdateDeployment Evaluate( + bool isSupportedNativePlatform, + bool isContainer, + bool isSystemdManaged, + bool isNativeSingleFile, + bool installDirectoryWritable) + { + if (isContainer) + return new(WebDeploymentKind.Container, true, false, "maintenance.webUpdateContainerImage"); + if (isSystemdManaged) + return new(WebDeploymentKind.SystemdManaged, true, false, "maintenance.webUpdateSystemdAdmin"); + if (!isSupportedNativePlatform || !isNativeSingleFile) + return new(WebDeploymentKind.Unsupported, true, false, "maintenance.webUpdateUnsupported"); + if (!installDirectoryWritable) + return new(WebDeploymentKind.ReadOnly, true, false, "maintenance.webUpdateReadOnly"); + return new(WebDeploymentKind.NativeWritable, true, true, null); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/WebUpdatePackageStager.cs b/v2rayN/v2rayN.WebAPI/Services/WebUpdatePackageStager.cs new file mode 100644 index 00000000..81a56198 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/WebUpdatePackageStager.cs @@ -0,0 +1,329 @@ +using System.IO.Compression; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using ServiceLib.Models.Dto; + +namespace v2rayN.WebAPI.Services; + +public sealed record WebUpdatePackage(string Rid, string Asset, string Url, string Sha256, long Size); + +public sealed record WebUpdateManifest( + string Product, + string Version, + string Commit, + string BuildDate, + IReadOnlyList Packages); + +public sealed record WebUpdatePackageIdentity(string Product, string Version, string Commit, string BuildDate, string Rid); + +internal static partial class WebUpdatePackageStager +{ + internal const long MaximumArchiveBytes = 256L * 1024 * 1024; + internal const long MaximumExpandedBytes = 1024L * 1024 * 1024; + private const int MaximumEntries = 10000; + private const string LinuxExecutableName = "v2rayN.WebAPI"; + private const string WindowsExecutableName = "v2rayN.WebAPI.exe"; + + public static bool IsValidVersion(string? value) => + !string.IsNullOrWhiteSpace(value) && VersionPattern().IsMatch(value); + + public static bool IsUpdateAvailable( + string currentVersion, + string candidateVersion, + bool allowPrerelease, + string? currentCommit = null, + string? candidateCommit = null) + { + if (!IsValidVersion(currentVersion) || !IsValidVersion(candidateVersion)) return false; + var current = new SemanticVersion(currentVersion); + var candidate = new SemanticVersion(candidateVersion); + var comparison = candidate.CompareTo(current); + if (comparison > 0) return true; + return comparison == 0 + && !string.IsNullOrWhiteSpace(candidateCommit) + && !string.IsNullOrWhiteSpace(currentCommit) + && !string.Equals(candidateCommit, currentCommit, StringComparison.OrdinalIgnoreCase); + } + + public static bool ShouldConsiderRelease(bool isPrerelease, bool allowPrerelease) => !isPrerelease || allowPrerelease; + + public static WebUpdateManifest ParseManifest(string json) + { + var manifest = JsonSerializer.Deserialize(json, + new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidDataException("The Web release manifest is empty."); + if (manifest.Product == "v2rayN.Web") + throw new InvalidDataException("Legacy v2rayN.Web packages require a one-time manual installation of v2rayN.WebAPI."); + if (manifest.Product != "v2rayN.WebAPI" || !IsValidVersion(manifest.Version) + || string.IsNullOrWhiteSpace(manifest.Commit) || manifest.Packages is null || manifest.Packages.Count is 0 or > 8) + { + throw new InvalidDataException("The Web release manifest has an invalid product identity."); + } + if (manifest.Packages.Select(package => package.Rid).Distinct(StringComparer.Ordinal).Count() != manifest.Packages.Count) + { + throw new InvalidDataException("The Web release manifest contains duplicate runtime identifiers."); + } + foreach (var package in manifest.Packages) + { + if (!IsSupportedRid(package.Rid)) + { + // Forward compatibility: a newer release can carry packages for runtime + // identifiers this build does not support. They are ignored here and are + // never downloaded or installed by this instance. + continue; + } + if (string.IsNullOrWhiteSpace(package.Asset) + || !Uri.TryCreate(package.Url, UriKind.Absolute, out var assetUri) + || assetUri.Scheme != Uri.UriSchemeHttps + || string.IsNullOrWhiteSpace(package.Sha256) + || !Regex.IsMatch(package.Sha256, "^[a-fA-F0-9]{64}$", RegexOptions.CultureInvariant) + || package.Size is <= 0 or > MaximumArchiveBytes) + { + throw new InvalidDataException("The Web release manifest contains an unsafe or unsupported package entry."); + } + } + return manifest; + } + + public static WebUpdatePackage RequirePackage(WebUpdateManifest manifest, string rid) + { + if (!IsSupportedRid(rid)) + throw new InvalidDataException($"The Web release does not support runtime identifier {rid}."); + return manifest.Packages.FirstOrDefault(package => package.Rid == rid) + ?? throw new InvalidDataException($"The Web release has no package for runtime identifier {rid}."); + } + + /// + /// Maps a supported .NET runtime identifier to the release archive architecture suffix + /// (linux-x64/win-x64 become 64; linux-arm64 stays arm64). + /// + public static string? ArtifactArch(string? rid) => WebReleaseAssets.ArtifactArch(rid); + + public static string? FullInstallAssetName(string? rid) => WebReleaseAssets.FullAssetName(rid); + + public static string? AppOnlyAssetName(string? rid) => WebReleaseAssets.UpdateAssetName(rid); + + public static async Task VerifyAndExtractAsync( + string archivePath, + string destinationPath, + WebUpdateManifest manifest, + WebUpdatePackage package, + CancellationToken cancellationToken) + { + var archive = new FileInfo(archivePath); + if (!archive.Exists || archive.Length != package.Size || archive.Length is <= 0 or > MaximumArchiveBytes) + throw new InvalidDataException("The downloaded Web package size does not match its manifest."); + + await using (var stream = File.OpenRead(archivePath)) + { + var hash = await SHA256.HashDataAsync(stream, cancellationToken); + var expectedHash = Convert.FromHexString(package.Sha256); + if (!CryptographicOperations.FixedTimeEquals(hash, expectedHash)) + throw new InvalidDataException("The downloaded Web package SHA-256 does not match its manifest."); + } + + if (Directory.Exists(destinationPath) || File.Exists(destinationPath)) + throw new InvalidDataException("The Web package staging directory already exists."); + Directory.CreateDirectory(destinationPath); + var root = Path.GetFullPath(destinationPath); + if (new DirectoryInfo(root).LinkTarget is not null) + throw new InvalidDataException("The Web package staging root must not be a symbolic link."); + var rootPrefix = root.EndsWith(Path.DirectorySeparatorChar) ? root : root + Path.DirectorySeparatorChar; + long expandedBytes = 0; + var entryCount = 0; + var extractedFiles = new HashSet(StringComparer.Ordinal); + using (var zip = ZipFile.OpenRead(archivePath)) + { + foreach (var entry in zip.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + if (++entryCount > MaximumEntries) + throw new InvalidDataException("The Web package has too many archive entries."); + var relative = NormalizeEntryName(entry.FullName); + var target = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); + if (!target.StartsWith(rootPrefix, StringComparison.Ordinal)) + throw new InvalidDataException("The Web package contains a path outside its staging directory."); + if (IsEnvironmentConfigurationPath(relative)) + throw new InvalidDataException("The Web package must not contain .env or .env.example files."); + if (!IsAllowedPath(relative, package.Rid)) + throw new InvalidDataException($"The Web package contains an unexpected file: {relative}."); + + var unixType = (entry.ExternalAttributes >> 16) & 0xF000; + if (unixType is not (0 or 0x4000 or 0x8000)) + throw new InvalidDataException("The Web package contains a link or special archive entry."); + if (unixType == 0x4000 || entry.FullName.EndsWith('/')) + { + Directory.CreateDirectory(target); + continue; + } + if (!extractedFiles.Add(relative)) + throw new InvalidDataException("The Web package contains duplicate file paths."); + if (entry.Length < 0 || entry.Length > MaximumExpandedBytes - expandedBytes) + throw new InvalidDataException("The expanded Web package exceeds the configured size limit."); + expandedBytes += entry.Length; + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + await using var input = entry.Open(); + await using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, useAsync: true); + await CopyWithLimitAsync(input, output, entry.Length, cancellationToken); + } + } + + var executableName = ExecutableNameForRid(package.Rid); + var executable = Path.Combine(root, executableName); + var identityPath = Path.Combine(root, "v2rayN.WebAPI.build.json"); + if (!File.Exists(executable) || new FileInfo(executable).Length == 0 + || !File.Exists(identityPath)) + { + throw new InvalidDataException("The Web package is missing its executable or build identity."); + } + RequireNativeExecutable(executable, package.Rid); + + var identity = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(identityPath, cancellationToken), + new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidDataException("The Web package build identity is invalid."); + if (identity.Product != "v2rayN.WebAPI" || identity.Version != manifest.Version + || identity.Commit != manifest.Commit || identity.BuildDate != manifest.BuildDate || identity.Rid != package.Rid) + { + throw new InvalidDataException("The Web package identity does not match the verified release manifest."); + } + + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(executable, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + return identity; + } + + private static string NormalizeEntryName(string name) + { + if (string.IsNullOrWhiteSpace(name) || name.Contains('\\') || name.Contains('\0') || name.StartsWith('/')) + throw new InvalidDataException("The Web package contains an invalid archive path."); + var segments = name.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Any(segment => segment is "." or ".." || segment.Contains(':'))) + throw new InvalidDataException("The Web package contains a path traversal entry."); + var normalized = string.Join('/', segments); + if (normalized.Length == 0) throw new InvalidDataException("The Web package contains an empty archive path."); + return normalized; + } + + private static bool IsAllowedPath(string path, string rid) => + path == ExecutableNameForRid(rid) || path == "v2rayN.WebAPI.build.json"; + + // JSON RID alone cannot prove executable identity. Reject wrong-architecture, + // truncated and script payloads before shutdown or process replacement. + internal static bool IsSupportedRid(string? rid) => + rid is "linux-x64" or "linux-arm64" or "linux-riscv64" or "linux-loongarch64" or "win-x64" or "win-arm64"; + + internal static string ExecutableNameForRid(string rid) => rid switch + { + "linux-x64" or "linux-arm64" or "linux-riscv64" or "linux-loongarch64" => LinuxExecutableName, + "win-x64" or "win-arm64" => WindowsExecutableName, + _ => throw new InvalidDataException($"Unsupported WebAPI runtime identifier: {rid}."), + }; + + internal static void RequireNativeExecutable(string path, string rid) + { + using var stream = File.OpenRead(path); + if (rid is "win-x64" or "win-arm64") + { + RequireWindowsExecutable(stream, rid); + return; + } + if (rid is not ("linux-x64" or "linux-arm64" or "linux-riscv64" or "linux-loongarch64")) + throw new InvalidDataException($"Unsupported WebAPI runtime identifier: {rid}."); + + var expectedMachine = rid switch + { + "linux-x64" => 62, + "linux-arm64" => 183, + "linux-riscv64" => 243, + "linux-loongarch64" => 258, + _ => throw new InvalidDataException($"Unsupported WebAPI runtime identifier: {rid}."), + }; + Span header = stackalloc byte[64]; + if (stream.Read(header) != header.Length + || !header[..7].SequenceEqual(new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F', 2, 1, 1 }) + || BinaryPrimitives.ReadUInt16LittleEndian(header[16..]) is not (2 or 3) + || BinaryPrimitives.ReadUInt16LittleEndian(header[18..]) != expectedMachine + || BinaryPrimitives.ReadUInt32LittleEndian(header[20..]) != 1 + || BinaryPrimitives.ReadUInt16LittleEndian(header[52..]) != 64) + throw new InvalidDataException("The WebAPI package executable must be a Linux ELF64 image for its declared runtime identifier."); + var offset = BinaryPrimitives.ReadUInt64LittleEndian(header[32..]); + var entrySize = BinaryPrimitives.ReadUInt16LittleEndian(header[54..]); + var count = BinaryPrimitives.ReadUInt16LittleEndian(header[56..]); + if (offset < 64 || offset > (ulong)stream.Length || entrySize != 56 || count == 0 + || (ulong)count * entrySize > (ulong)stream.Length - offset) + throw new InvalidDataException("The WebAPI package executable has a truncated or invalid ELF program table."); + } + + private static void RequireWindowsExecutable(Stream stream, string rid) + { + var expectedMachine = rid switch + { + "win-x64" => 0x8664, + "win-arm64" => 0xAA64, + _ => throw new InvalidDataException($"Unsupported WebAPI runtime identifier: {rid}."), + }; + Span dosHeader = stackalloc byte[64]; + if (stream.Length < dosHeader.Length || stream.Read(dosHeader) != dosHeader.Length + || BinaryPrimitives.ReadUInt16LittleEndian(dosHeader) != 0x5A4D) + { + throw new InvalidDataException("The WebAPI package executable must be a Windows PE32+ image for its declared runtime identifier."); + } + + var peOffset = BinaryPrimitives.ReadUInt32LittleEndian(dosHeader[0x3C..]); + if (peOffset < dosHeader.Length || peOffset > stream.Length - 26) + throw new InvalidDataException("The WebAPI package executable has a truncated Windows PE header."); + + stream.Position = peOffset; + Span peHeader = stackalloc byte[26]; + if (stream.Read(peHeader) != peHeader.Length + || !peHeader[..4].SequenceEqual(new byte[] { (byte)'P', (byte)'E', 0, 0 }) + || BinaryPrimitives.ReadUInt16LittleEndian(peHeader[4..]) != expectedMachine + || BinaryPrimitives.ReadUInt16LittleEndian(peHeader[6..]) == 0 + || BinaryPrimitives.ReadUInt16LittleEndian(peHeader[20..]) < 2 + || (BinaryPrimitives.ReadUInt16LittleEndian(peHeader[22..]) & 0x0002) == 0) + { + throw new InvalidDataException("The WebAPI package executable must be a Windows PE32+ image for its declared runtime identifier."); + } + + var optionalHeaderSize = BinaryPrimitives.ReadUInt16LittleEndian(peHeader[20..]); + if ((long)peOffset + 24 + optionalHeaderSize > stream.Length) + throw new InvalidDataException("The WebAPI package executable has a truncated Windows optional header."); + Span optionalHeaderMagic = stackalloc byte[2]; + stream.Position = (long)peOffset + 24; + if (stream.Read(optionalHeaderMagic) != optionalHeaderMagic.Length + || BinaryPrimitives.ReadUInt16LittleEndian(optionalHeaderMagic) != 0x20B) + { + throw new InvalidDataException("The WebAPI package executable must be a Windows PE32+ image for its declared runtime identifier."); + } + } + + private static bool IsEnvironmentConfigurationPath(string path) => + path.Split('/').Any(segment => segment is ".env" or ".env.example"); + + private static async Task CopyWithLimitAsync(Stream input, Stream output, long expectedLength, CancellationToken cancellationToken) + { + var buffer = new byte[65536]; + long copied = 0; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + copied += read; + if (copied > expectedLength || copied > MaximumExpandedBytes) + throw new InvalidDataException("A Web package archive entry exceeded its declared size."); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + if (copied != expectedLength) throw new InvalidDataException("A Web package archive entry was truncated."); + } + + [GeneratedRegex("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$", RegexOptions.CultureInvariant)] + private static partial Regex VersionPattern(); +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/.gitignore b/v2rayN/v2rayN.WebAPI/Tests/.gitignore new file mode 100644 index 00000000..18c6a9e8 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/.gitignore @@ -0,0 +1,3 @@ +/bin/ +/obj/ +/TestResults/ diff --git a/v2rayN/v2rayN.WebAPI/Tests/BackupArchiveValidationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/BackupArchiveValidationTests.cs new file mode 100644 index 00000000..030ed3f2 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/BackupArchiveValidationTests.cs @@ -0,0 +1,209 @@ +using System.IO.Compression; +using SQLite; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class BackupArchiveValidationTests +{ + [Test] + public async Task SafeServiceLibBackupIsAccepted() + { + var archivePath = CreateArchive(("guiConfigs/guiNConfig.json", "{}")); + try + { + await V2rayRuntime.IsSafeBackupArchive(archivePath).Should().BeTrue(); + } + finally + { + File.Delete(archivePath); + } + } + + [Test] + public async Task ArchiveTraversalPathIsRejected() + { + var archivePath = CreateArchive( + ("guiConfigs/guiNConfig.json", "{}"), + ("guiConfigs/../../outside.txt", "not safe")); + try + { + await V2rayRuntime.IsSafeBackupArchive(archivePath).Should().BeFalse(); + } + finally + { + File.Delete(archivePath); + } + } + + [Test] + public async Task DesktopBackupSchemaWithOlderNullableColumnsIsAccepted() + { + using var directory = new TemporaryDirectory(); + var database = Path.Combine(directory.Path, "desktop.db"); + using (var connection = new SQLiteConnection(database)) + { + connection.Execute("CREATE TABLE SubItem (Id varchar primary key not null, Remarks varchar, Url varchar);"); + connection.Execute("INSERT INTO SubItem (Id, Remarks, Url) VALUES (?, ?, ?);", "legacy-sub", "Legacy", "https://example.test"); + } + + var archive = CreateArchive( + ("v2rayN_20260925/guiConfigs/guiNConfig.json", "{}"), + ("v2rayN_20260925/guiConfigs/guiNDB.db", await File.ReadAllBytesAsync(database))); + var extracted = Path.Combine(directory.Path, "desktop-extracted"); + try + { + await V2rayRuntime.IsSafeBackupArchive(archive).Should().BeTrue(); + await V2rayRuntime.TryExtractBackupConfig(archive, extracted).Should().BeTrue(); + await BackupDatabaseCompatibility.IsCompatible(Path.Combine(extracted, "guiNDB.db"), out _).Should().BeTrue(); + } + finally + { + File.Delete(archive); + } + } + + [Test] + public async Task ServiceLibAddsMissingColumnsWithoutDroppingDesktopRowsOrUnknownColumns() + { + using var directory = new TemporaryDirectory(); + var database = Path.Combine(directory.Path, "upgrade.db"); + using var connection = new SQLiteConnection(database); + connection.Execute("CREATE TABLE SubItem (Id varchar primary key not null, Remarks varchar, Url varchar, DesktopOnly varchar);"); + connection.Execute("INSERT INTO SubItem (Id, Remarks, Url, DesktopOnly) VALUES (?, ?, ?, ?);", + "legacy-sub", "Desktop subscription", "https://example.test", "preserve-me"); + + connection.CreateTable(); + + var restored = connection.Table().First(); + var columns = connection.Query("PRAGMA table_info(\"SubItem\");"); + await (restored.Id == "legacy-sub" && restored.Remarks == "Desktop subscription").Should().BeTrue(); + await columns.Any(column => column.Name == "RequestHeaders").Should().BeTrue(); + await columns.Any(column => column.Name == "DesktopOnly").Should().BeTrue(); + } + + [Test] + public async Task WebBackupSchemaAndEmbeddedAuthAreCompatibleButCannotReplaceLocalAuth() + { + using var directory = new TemporaryDirectory(); + var database = Path.Combine(directory.Path, "web.db"); + using (var connection = new SQLiteConnection(database)) + { + connection.Execute("CREATE TABLE SubItem (Id varchar primary key not null, Remarks varchar, Url varchar, RequestHeaders varchar);"); + connection.Execute("CREATE TABLE ProfileExItem (IndexId varchar primary key not null, Delay integer, IpInfo varchar);"); + connection.Execute("INSERT INTO SubItem (Id, Remarks, Url, RequestHeaders) VALUES (?, ?, ?, ?);", "web-sub", "Web", "https://example.test", "{}"); + } + + var archive = CreateArchive( + ("backup_WebAPI/guiConfigs/guiNConfig.json", "{}"), + ("backup_WebAPI/guiConfigs/guiNDB.db", await File.ReadAllBytesAsync(database)), + ("backup_WebAPI/guiConfigs/WebAPI-auth.json", "attacker-provided-auth")); + var extracted = Path.Combine(directory.Path, "WebAPI-extracted"); + var current = Path.Combine(directory.Path, "current-config"); + var candidate = Path.Combine(directory.Path, "candidate-config"); + var backupCopy = Path.Combine(directory.Path, "backup-config"); + Directory.CreateDirectory(current); + await File.WriteAllTextAsync(Path.Combine(current, "WebAPI-auth.json"), "local-auth-verifier"); + await File.WriteAllTextAsync(Path.Combine(current, "desktop-note"), "retain-local-config"); + V2rayRuntime.CopyConfigForBackup(current, backupCopy); + + try + { + await V2rayRuntime.IsSafeBackupArchive(archive).Should().BeTrue(); + await File.Exists(Path.Combine(backupCopy, "WebAPI-auth.json")).Should().BeFalse(); + await V2rayRuntime.TryExtractBackupConfig(archive, extracted).Should().BeTrue(); + await BackupDatabaseCompatibility.IsCompatible(Path.Combine(extracted, "guiNDB.db"), out _).Should().BeTrue(); + await File.Exists(Path.Combine(extracted, "WebAPI-auth.json")).Should().BeFalse(); + + V2rayRuntime.PrepareRestoredConfigDirectory(extracted, current, candidate); + + await (await File.ReadAllTextAsync(Path.Combine(candidate, "WebAPI-auth.json"))) + .Should().BeEqualTo("local-auth-verifier"); + await (await File.ReadAllTextAsync(Path.Combine(candidate, "desktop-note"))) + .Should().BeEqualTo("retain-local-config"); + await File.Exists(Path.Combine(candidate, "guiNDB.db")).Should().BeTrue(); + } + finally + { + File.Delete(archive); + } + } + + [Test] + public async Task DatabaseWithMissingPrimaryKeyIsRejectedBeforeRestore() + { + using var directory = new TemporaryDirectory(); + var database = Path.Combine(directory.Path, "invalid.db"); + using (var connection = new SQLiteConnection(database)) + { + connection.Execute("CREATE TABLE SubItem (Remarks varchar, Url varchar);"); + } + + await BackupDatabaseCompatibility.IsCompatible(database, out _).Should().BeFalse(); + } + + [Test] + public async Task FailedConfigDirectorySwapRestoresThePreviouslyActiveConfig() + { + using var directory = new TemporaryDirectory(); + var current = Path.Combine(directory.Path, "guiConfigs"); + var missingCandidate = Path.Combine(directory.Path, "missing-candidate"); + var displaced = Path.Combine(directory.Path, "guiConfigs-before-restore"); + Directory.CreateDirectory(current); + await File.WriteAllTextAsync(Path.Combine(current, "guiNConfig.json"), "old-config"); + + var failed = false; + try + { + V2rayRuntime.ReplaceConfigDirectory(missingCandidate, current, displaced); + } + catch (DirectoryNotFoundException) + { + failed = true; + } + + await failed.Should().BeTrue(); + await Directory.Exists(current).Should().BeTrue(); + await Directory.Exists(displaced).Should().BeFalse(); + await (await File.ReadAllTextAsync(Path.Combine(current, "guiNConfig.json"))) + .Should().BeEqualTo("old-config"); + } + + private static string CreateArchive(params (string Name, object Content)[] files) + { + var path = Path.Combine(Path.GetTempPath(), $"v2rayn-WebAPI-test-{Guid.NewGuid():N}.zip"); + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + foreach (var file in files) + { + using var entry = archive.CreateEntry(file.Name).Open(); + if (file.Content is byte[] bytes) + { + entry.Write(bytes); + } + else + { + using var writer = new StreamWriter(entry); + writer.Write((string)file.Content); + } + } + return path; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-backup-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } + + private sealed class TableColumnRow + { + public string Name { get; set; } = string.Empty; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/BackupRestorePreflightTests.cs b/v2rayN/v2rayN.WebAPI/Tests/BackupRestorePreflightTests.cs new file mode 100644 index 00000000..586382b1 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/BackupRestorePreflightTests.cs @@ -0,0 +1,148 @@ +using Microsoft.Extensions.Configuration; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Launcher; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class BackupRestorePreflightTests +{ + [Test] + [Arguments("https://127.0.0.1:5443", "backup.restoreProbeUnavailable")] + [Arguments("http://192.168.1.20:5080", "backup.restoreProbeUnavailable")] + public async Task UnprobeableListenersRefuseBeforeAnyRestoreMutation(string listener, string messageKey) + { + foreach (var wasRunning in new[] { false, true }) + { + using var directory = new TemporaryDirectory(); + var configPath = Path.Combine(directory.Path, "guiNConfig.json"); + await File.WriteAllTextAsync(configPath, "original-config"); + var coreRunning = wasRunning; + var mutationsStarted = false; + var preflight = CreatePreflight(listener, setsidPath: "/usr/bin/setsid"); + + var result = await BackupRestorePreflightGate.ExecuteAsync(preflight, async () => + { + mutationsStarted = true; + coreRunning = false; + await File.WriteAllTextAsync(configPath, "restored-config"); + return OperationView.Ok(ApiMessageKeys.BackupRestoreStarted); + }); + + await result.Success.Should().BeFalse(); + await result.Code.Should().BeEqualTo("backup_restore_health_probe_unavailable"); + await result.MessageKey.Should().BeEqualTo(messageKey); + await mutationsStarted.Should().BeFalse(); + await coreRunning.Should().BeEqualTo(wasRunning); + await (await File.ReadAllTextAsync(configPath)).Should().BeEqualTo("original-config"); + } + } + + [Test] + public async Task MissingExecutableSetsidRefusesBeforeStoppingCoreOrReplacingConfig() + { + foreach (var wasRunning in new[] { false, true }) + { + using var directory = new TemporaryDirectory(); + var configPath = Path.Combine(directory.Path, "guiNConfig.json"); + await File.WriteAllTextAsync(configPath, "original-config"); + var coreRunning = wasRunning; + var mutationsStarted = false; + var preflight = CreatePreflight("http://127.0.0.1:5080", setsidPath: null); + + var result = await BackupRestorePreflightGate.ExecuteAsync(preflight, async () => + { + mutationsStarted = true; + coreRunning = false; + await File.WriteAllTextAsync(configPath, "restored-config"); + return OperationView.Ok(ApiMessageKeys.BackupRestoreStarted); + }); + + await result.Success.Should().BeFalse(); + await result.Code.Should().BeEqualTo("backup_restore_setsid_unavailable"); + await result.MessageKey.Should().BeEqualTo(ApiMessageKeys.BackupRestoreSetSidUnavailable); + await mutationsStarted.Should().BeFalse(); + await coreRunning.Should().BeEqualTo(wasRunning); + await (await File.ReadAllTextAsync(configPath)).Should().BeEqualTo("original-config"); + } + } + + [Test] + public async Task NativeWindowsRestoreBuildsVerifiedHiddenReplacementWithoutSetsid() + { + var preflight = NativeWebRestorePreflight.Evaluate( + Config(("urls", "http://localhost:5080")), + isLinux: false, + isWindows: true, + externalSupervisor: false, + containerEnvironment: false, + setsidPath: null, + processPath: @"C:\WebAPI\v2rayN.WebAPI.exe", + managedEntryPoint: null, + hostArguments: ["--urls", "http://localhost:5080"], + fileExists: _ => true); + + await preflight.Allowed.Should().BeTrue(); + await preflight.Owner.Should().BeEqualTo(WebLifecycleOwner.NativeProcess); + await preflight.Replacement!.HealthUri.ToString().Should().BeEqualTo("http://localhost:5080/api/health"); + await preflight.Replacement.Command.CreateNoWindow.Should().BeTrue(); + await preflight.Replacement.Command.LauncherPath.Should().BeEqualTo(Path.GetFullPath(@"C:\WebAPI\v2rayN.WebAPI.exe")); + await preflight.Replacement.Command.Arguments.SequenceEqual([ + WebLaunchOptions.BackgroundChildFlag, + "--urls", + "http://localhost:5080", + ]).Should().BeTrue(); + await preflight.Replacement.Command.ToProcessStartInfo().UseShellExecute.Should().BeFalse(); + } + + [Test] + public async Task ExternalSupervisorOwnsRestartAndDoesNotRequireANativeHealthProbeOrLauncher() + { + var preflight = NativeWebRestorePreflight.Evaluate( + Config(("urls", "https://127.0.0.1:5443")), + isLinux: true, + isWindows: false, + externalSupervisor: true, + containerEnvironment: false, + setsidPath: null, + processPath: null, + managedEntryPoint: null, + hostArguments: []); + + await preflight.Allowed.Should().BeTrue(); + await preflight.Owner.Should().BeEqualTo(WebLifecycleOwner.ExternalSupervisor); + await (preflight.Replacement is null).Should().BeTrue(); + } + + private static NativeWebRestorePreflightResult CreatePreflight(string listener, string? setsidPath) => + NativeWebRestorePreflight.Evaluate( + Config(("urls", listener)), + isLinux: true, + isWindows: false, + externalSupervisor: false, + containerEnvironment: false, + setsidPath, + processPath: "/opt/v2rayN.WebAPI", + managedEntryPoint: null, + hostArguments: [], + fileExists: _ => true, + isUnixExecutable: _ => true); + + private static IConfigurationRoot Config(params (string Key, string Value)[] values) => + new ConfigurationBuilder() + .AddInMemoryCollection(values.ToDictionary(item => item.Key, item => (string?)item.Value)) + .Build(); + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-restore-preflight-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/BuiltinDnsProfileBootstrapTests.cs b/v2rayN/v2rayN.WebAPI/Tests/BuiltinDnsProfileBootstrapTests.cs new file mode 100644 index 00000000..f088b681 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/BuiltinDnsProfileBootstrapTests.cs @@ -0,0 +1,89 @@ +using ServiceLib.Enums; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class BuiltinDnsProfileBootstrapTests +{ + [Test] + public async Task EmptyDnsTableGetsExactlyOneDisabledXrayAndSingboxProfile() + { + var result = await Ensure([]); + await AssertBuiltinPair(result.Rows); + await result.SavedTypes.Count.Should().BeEqualTo(2); + await result.Rows.All(item => !item.Enabled).Should().BeTrue(); + await result.Rows.Single(item => item.CoreType == ECoreType.Xray).Remarks.Should().BeEqualTo("V2ray"); + await result.Rows.Single(item => item.CoreType == ECoreType.sing_box).Remarks.Should().BeEqualTo("sing-box"); + } + + [Test] + public async Task XrayOnlyStateAddsSingboxWithoutReplacingXray() + { + var xray = Existing(ECoreType.Xray, "xray-id", "preserved xray", enabled: true); + var result = await Ensure([xray]); + await AssertBuiltinPair(result.Rows); + await (result.Rows.Single(item => item.CoreType == ECoreType.Xray) == xray).Should().BeTrue(); + await result.SavedTypes.SequenceEqual(new[] { ECoreType.sing_box }).Should().BeTrue(); + await (xray.Remarks == "preserved xray" && xray.Enabled).Should().BeTrue(); + var singbox = result.Rows.Single(item => item.CoreType == ECoreType.sing_box); + await (!singbox.Enabled && singbox.Remarks == "sing-box").Should().BeTrue(); + } + + [Test] + public async Task SingboxOnlyStateAddsXrayWithoutReplacingSingbox() + { + var singbox = Existing(ECoreType.sing_box, "singbox-id", "preserved sing-box", enabled: true); + var result = await Ensure([singbox]); + await AssertBuiltinPair(result.Rows); + await (result.Rows.Single(item => item.CoreType == ECoreType.sing_box) == singbox).Should().BeTrue(); + await result.SavedTypes.SequenceEqual(new[] { ECoreType.Xray }).Should().BeTrue(); + await (singbox.Remarks == "preserved sing-box" && singbox.Enabled).Should().BeTrue(); + var xray = result.Rows.Single(item => item.CoreType == ECoreType.Xray); + await (!xray.Enabled && xray.Remarks == "V2ray").Should().BeTrue(); + } + + [Test] + public async Task CompleteDnsStateIsLeftUntouched() + { + var xray = Existing(ECoreType.Xray, "xray-id", "custom xray", enabled: true); + var singbox = Existing(ECoreType.sing_box, "singbox-id", "custom sing-box", enabled: true); + var result = await Ensure([xray, singbox]); + await AssertBuiltinPair(result.Rows); + await result.SavedTypes.Count.Should().BeEqualTo(0); + await (result.Rows[0] == xray && result.Rows[1] == singbox).Should().BeTrue(); + } + + private static async Task<(List Rows, List SavedTypes)> Ensure(List initial) + { + var rows = new List(initial); + var savedTypes = new List(); + await BuiltinDnsProfileBootstrap.EnsureAsync( + new Config(), + () => Task.FromResult>(rows.ToArray()), + (_, item) => + { + rows.Add(item); + savedTypes.Add(item.CoreType); + return Task.FromResult(0); + }); + return (rows, savedTypes); + } + + private static async Task AssertBuiltinPair(IReadOnlyList rows) + { + await rows.Count.Should().BeEqualTo(2); + await rows.Count(item => item.CoreType == ECoreType.Xray).Should().BeEqualTo(1); + await rows.Count(item => item.CoreType == ECoreType.sing_box).Should().BeEqualTo(1); + } + + private static DNSItem Existing(ECoreType coreType, string id, string remarks, bool enabled) => new() + { + Id = id, + CoreType = coreType, + Remarks = remarks, + Enabled = enabled, + NormalDNS = "user DNS data", + }; +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs b/v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs new file mode 100644 index 00000000..202319d1 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs @@ -0,0 +1,198 @@ +using System.Diagnostics; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class CoreProcessTrackerTests +{ + [Test] + public async Task WebTrackerCapturesNewCoreProcessesAndPrunesExitedIdentities() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var executable = CopySleepExecutable(directory.Path); + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + using var process = StartSleepProcess(executable); + try + { + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, [executable]); + await tracked.Any(identity => identity.ProcessId == process.Id).Should().BeTrue(); + await CoreProcessTracker.GetActiveProcesses(tracked) + .Any(identity => identity.ProcessId == process.Id) + .Should().BeTrue(); + + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + await CoreProcessTracker.GetActiveProcesses(tracked).Length.Should().BeEqualTo(0); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + [Test] + public async Task WebTrackerDoesNotClaimProcessesThatPredateItsLaunchSnapshot() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var executable = CopySleepExecutable(directory.Path); + using var process = StartSleepProcess(executable); + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, [executable]); + + try + { + await tracked.Any(identity => identity.ProcessId == process.Id).Should().BeFalse(); + } + finally + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + + [Test] + public async Task WebTrackerRecognizesAnExecutableRunningThroughItsInterpreter() + { + if (!OperatingSystem.IsLinux()) return; + using var directory = new TemporaryDirectory(); + var executable = Path.Combine(directory.Path, "test-core-shim"); + await File.WriteAllTextAsync(executable, "#!/bin/sh\nsleep 60\n"); + File.SetUnixFileMode(executable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + using var process = Process.Start(new ProcessStartInfo(executable) { UseShellExecute = false }) + ?? throw new InvalidOperationException("Could not start the Core shim test process."); + try + { + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, [executable]); + await tracked.Any(identity => identity.ProcessId == process.Id).Should().BeTrue(); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + [Test] + public async Task WebTrackerDoesNotClaimSameNamedExecutableFromAnotherPath() + { + if (!OperatingSystem.IsLinux()) return; + using var directory = new TemporaryDirectory(); + var unrelatedExecutable = Path.Combine(directory.Path, "sleep"); + File.Copy("/bin/sleep", unrelatedExecutable); + + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + using var process = StartSleepProcess(unrelatedExecutable); + try + { + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, ["/bin/sleep"]); + await tracked.Any(identity => identity.ProcessId == process.Id).Should().BeFalse(); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + [Test] + public async Task LinuxTrackerRequiresTheWebCoreConfigPathWhenProvided() + { + if (!OperatingSystem.IsLinux()) return; + using var directory = new TemporaryDirectory(); + var executable = Path.Combine(directory.Path, "test-core-shim"); + var configPath = Path.Combine(directory.Path, "config.json"); + await File.WriteAllTextAsync(executable, "#!/bin/sh\nsleep 60\n"); + await File.WriteAllTextAsync(configPath, "{}"); + File.SetUnixFileMode(executable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false }; + startInfo.ArgumentList.Add(configPath); + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start the Core shim test process."); + try + { + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, [executable], [configPath]); + await tracked.Any(identity => identity.ProcessId == process.Id).Should().BeTrue(); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + [Test] + public async Task WebTrackerRejectsReusedPidWithDifferentStartTimeOrExecutable() + { + if (!OperatingSystem.IsLinux()) return; + using var directory = new TemporaryDirectory(); + var executable = CopySleepExecutable(directory.Path); + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + using var process = StartSleepProcess(executable); + try + { + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, [executable]); + var identity = tracked.Single(item => item.ProcessId == process.Id); + + await CoreProcessTracker.GetActiveProcesses([identity with { StartTimeUtcTicks = identity.StartTimeUtcTicks + 1 }]) + .Length.Should().BeEqualTo(0); + await CoreProcessTracker.GetActiveProcesses([identity with { ExecutablePath = "/tmp/unrelated-core" }]) + .Length.Should().BeEqualTo(0); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + private static Process StartSleepProcess(string executable = "/bin/sleep") + { + var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false }; + startInfo.ArgumentList.Add("60"); + return Process.Start(startInfo) ?? throw new InvalidOperationException("Could not start the test process."); + } + + private static string CopySleepExecutable(string directory) + { + var executable = Path.Combine(directory, "sleep-core-test"); + File.Copy("/bin/sleep", executable); + return executable; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-core-tracker-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/CoreRestartPreflightTests.cs b/v2rayN/v2rayN.WebAPI/Tests/CoreRestartPreflightTests.cs new file mode 100644 index 00000000..8f2c60cd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/CoreRestartPreflightTests.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Net.Sockets; +using ServiceLib.Enums; +using ServiceLib.Handler.Builder; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class CoreRestartPreflightTests +{ + [Test] + public async Task RestartWithTunContextKeepsRunningCore() + { + var preflight = CoreLaunchPreflight.Validate( + new ProfileItem(), + CreateBuiltContext(isTunEnabled: true), + _ => null); + + await AssertRestartRejectedWithoutStoppingCore(preflight, "tun_not_supported"); + } + + [Test] + public async Task RestartWithInvalidContextKeepsRunningCore() + { + var binaryCheckCalled = false; + var preflight = CoreLaunchPreflight.Validate( + new ProfileItem(), + CreateBuiltContext(valid: false), + _ => + { + binaryCheckCalled = true; + return null; + }); + + await AssertRestartRejectedWithoutStoppingCore(preflight, "profile_validation_failed"); + await binaryCheckCalled.Should().BeFalse(); + } + + [Test] + public async Task RestartWithMissingCoreBinaryKeepsRunningCore() + { + var preflight = CoreLaunchPreflight.Validate( + new ProfileItem(), + CreateBuiltContext(), + coreType => OperationView.Fail("core_binary_missing", ApiMessageKeys.CoreBinaryMissing, + new { coreType = coreType.ToString() })); + + await AssertRestartRejectedWithoutStoppingCore(preflight, "core_binary_missing"); + } + + [Test] + public async Task ValidRestartStopsThenLaunchesThePreflightedContext() + { + var profile = new ProfileItem { IndexId = "profile-1" }; + var builtContext = CreateBuiltContext(); + var preflight = CoreLaunchPreflight.Validate(profile, builtContext, _ => null); + var events = new List(); + var coreRunning = true; + CoreConfigContextBuilderAllResult? launchedContext = null; + + var result = await CoreRestartFlow.ExecuteAsync( + () => Task.FromResult(preflight), + () => + { + events.Add("stop"); + coreRunning = false; + return Task.CompletedTask; + }, + () => { }, + plan => + { + events.Add("launch"); + launchedContext = plan.BuiltContext; + coreRunning = true; + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CoreStarted, + new { profileId = plan.Profile.IndexId })); + }); + + await events.Count.Should().BeEqualTo(2); + await events[0].Should().BeEqualTo("stop"); + await events[1].Should().BeEqualTo("launch"); + await ReferenceEquals(launchedContext, builtContext).Should().BeTrue(); + await coreRunning.Should().BeTrue(); + await result.Success.Should().BeTrue(); + await result.MessageKey.Should().BeEqualTo(ApiMessageKeys.CoreRestarted); + } + + [Test] + public async Task UnrelatedListenerIsReportedUnavailableByTheNormalStartPreflightProbe() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + try + { + var target = new RuntimeListenerSnapshot("local", ["http", "socks"], "127.0.0.1", port); + + // With no Core owned by Web, the probe must reject the port before launch. + var unavailableForUnrelatedOwner = await V2rayRuntime.IsProjectedListenerUnavailableAsync( + target, + runtimeOwned: [], + CancellationToken.None); + await unavailableForUnrelatedOwner.Should().BeTrue(); + + // The same listener is fine while Web still owns the tracked Core process. + var unavailableForOwnedListener = await V2rayRuntime.IsProjectedListenerUnavailableAsync( + target, + runtimeOwned: [target], + CancellationToken.None); + await unavailableForOwnedListener.Should().BeFalse(); + } + finally + { + listener.Stop(); + } + } + + private static async Task AssertRestartRejectedWithoutStoppingCore( + CoreLaunchPreflightResult preflight, + string expectedCode) + { + var coreRunning = true; + var stopCalled = false; + var launchCalled = false; + + var result = await CoreRestartFlow.ExecuteAsync( + () => Task.FromResult(preflight), + () => + { + stopCalled = true; + coreRunning = false; + return Task.CompletedTask; + }, + () => { }, + _ => + { + launchCalled = true; + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CoreStarted)); + }); + + await result.Code.Should().BeEqualTo(expectedCode); + await stopCalled.Should().BeFalse(); + await launchCalled.Should().BeFalse(); + await coreRunning.Should().BeTrue(); + } + + private static CoreConfigContextBuilderAllResult CreateBuiltContext( + bool isTunEnabled = false, + bool valid = true) + { + var validator = valid + ? NodeValidatorResult.Empty() + : new NodeValidatorResult(["invalid"], []); + return new CoreConfigContextBuilderAllResult( + new CoreConfigContextBuilderResult( + new CoreConfigContext + { + Node = new ProfileItem(), + RunCoreType = ECoreType.Xray, + IsTunEnabled = isTunEnabled, + }, + validator), + null); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/CoreSettingsApplyPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/CoreSettingsApplyPolicyTests.cs new file mode 100644 index 00000000..afbc5339 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/CoreSettingsApplyPolicyTests.cs @@ -0,0 +1,42 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class CoreSettingsApplyPolicyTests +{ + [Test] + public async Task ApplyPolicyDistinguishesStoppedRunningFaultedAndTransitionStates() + { + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Stopped, hasActiveChild: false, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.SaveOnly); + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Running, hasActiveChild: true, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.Restart); + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Faulted, hasActiveChild: true, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.Restart); + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Faulted, hasActiveChild: false, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.SaveOnly); + + foreach (var transition in new[] + { + CoreRuntimeState.Starting, + CoreRuntimeState.Stopping, + CoreRuntimeState.Restarting, + }) + { + await CoreSettingsApplyPolicy.Decide(transition, hasActiveChild: true, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.Busy); + } + + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Running, hasActiveChild: true, changed: false) + .Should().BeEqualTo(CoreSettingsApplyAction.SaveOnly); + } + + [Test] + public async Task AReportedRunningRuntimeWithoutAChildIsNotTreatedAsStopped() + { + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Running, hasActiveChild: false, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.Inconsistent); + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Stopped, hasActiveChild: true, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.Inconsistent); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/CoreUpdateWorkflowTests.cs b/v2rayN/v2rayN.WebAPI/Tests/CoreUpdateWorkflowTests.cs new file mode 100644 index 00000000..da3a0eda --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/CoreUpdateWorkflowTests.cs @@ -0,0 +1,350 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Text; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Manager; +using ServiceLib.Resx; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class CoreUpdateWorkflowTests +{ + [Test] + public async Task CoreVersionCheckAcceptsVPrefixedMihomoVersion() + { + var output = "Mihomo Meta v1.19.31 linux amd64 with go1.26.8"; + + await CoreExecutableVersionCheck.IsValid(output, "Mihomo").Should().BeTrue(); + } + + [Test] + public async Task CoreVersionCheckStillRequiresExpectedNameAndSemanticVersion() + { + await CoreExecutableVersionCheck.IsValid("Xray 25.9.11", "Mihomo").Should().BeFalse(); + await CoreExecutableVersionCheck.IsValid("Mihomo Meta development build", "Mihomo").Should().BeFalse(); + } + + [Test] + public async Task XraySingBoxAndMihomoPackagesAreStagedUsingTheirNativeArchiveFormats() + { + using var directory = new TemporaryDirectory(); + + var xrayArchive = Path.Combine(directory.Path, "xray.zip"); + using (var zip = ZipFile.Open(xrayArchive, ZipArchiveMode.Create)) + { + await WriteZipFileAsync(zip, "Xray/xray", "xray binary"); + await WriteZipFileAsync(zip, "Xray/geoip.dat", "bundled geo data"); + } + var xrayStage = Path.Combine(directory.Path, "xray-stage"); + await CoreUpdatePackageStager.ExtractAsync(ECoreType.Xray, xrayArchive, xrayStage, CancellationToken.None); + await File.Exists(Path.Combine(xrayStage, "xray")).Should().BeTrue(); + await File.Exists(Path.Combine(xrayStage, "geoip.dat")).Should().BeFalse(); + + var mihomoArchive = Path.Combine(directory.Path, "mihomo.gz"); + await WriteGzipAsync(mihomoArchive, "mihomo binary"); + var mihomoStage = Path.Combine(directory.Path, "mihomo-stage"); + await CoreUpdatePackageStager.ExtractAsync(ECoreType.mihomo, mihomoArchive, mihomoStage, CancellationToken.None); + var mihomoExecutable = Utils.GetExeName(CoreInfoManager.Instance.GetCoreInfo(ECoreType.mihomo)!.CoreExes!.First()); + await File.Exists(Path.Combine(mihomoStage, mihomoExecutable)).Should().BeTrue(); + + var singBoxArchive = Path.Combine(directory.Path, "sing-box.tar.gz"); + await WriteTarGzipAsync(singBoxArchive, "sing-box-release/sing-box", "sing-box binary"); + var singBoxStage = Path.Combine(directory.Path, "sing-box-stage"); + await CoreUpdatePackageStager.ExtractAsync(ECoreType.sing_box, singBoxArchive, singBoxStage, CancellationToken.None); + await File.Exists(Path.Combine(singBoxStage, "sing-box")).Should().BeTrue(); + } + + [Test] + public async Task StagingAndVerificationAlwaysFinishBeforeApplyBegins() + { + var sequence = new List(); + var result = await CoreUpdateWorkflow.StageThenApplyAsync( + () => + { + sequence.Add("checking"); + sequence.Add("downloading"); + sequence.Add("verifying"); + return Task.FromResult("verified-package"); + }, + staged => + { + sequence.Add("stopping-core"); + sequence.Add("installing"); + return Task.FromResult(staged); + }); + + await (result == "verified-package").Should().BeTrue(); + await sequence.SequenceEqual(["checking", "downloading", "verifying", "stopping-core", "installing"]).Should().BeTrue(); + } + + [Test] + public async Task FailedStagingNeverInvokesApply() + { + var applied = false; + + var failed = false; + try + { + await CoreUpdateWorkflow.StageThenApplyAsync( + () => throw new InvalidDataException("package verification failed"), + _ => + { + applied = true; + return Task.FromResult(true); + }); + } + catch (InvalidDataException) + { + failed = true; + } + + await failed.Should().BeTrue(); + await applied.Should().BeFalse(); + } + + [Test] + public async Task SelectedBatchStagesEveryNetworkPackageBeforeAnyApply() + { + var sequence = new List(); + var results = await CoreUpdateWorkflow.StageAllThenApplyAsync( + new[] { "Xray", "sing-box", "Mihomo" }, + target => + { + sequence.Add($"stage:{target}"); + return Task.FromResult($"verified:{target}"); + }, + stages => + { + sequence.Add("CoreStop"); + sequence.AddRange(stages.Select(stage => $"apply:{stage}")); + return Task.FromResult(stages.Count); + }, + () => + { + sequence.Add("prepare:GeoFiles"); + return Task.CompletedTask; + }); + + await results.Should().BeEqualTo(3); + await sequence.SequenceEqual(new[] + { + "stage:Xray", "stage:sing-box", "stage:Mihomo", "prepare:GeoFiles", "CoreStop", + "apply:verified:Xray", "apply:verified:sing-box", "apply:verified:Mihomo", + }).Should().BeTrue(); + } + + [Test] + public async Task FailedAdditionalNetworkPreparationPreventsEveryCoreApply() + { + var applied = false; + var failed = false; + try + { + await CoreUpdateWorkflow.StageAllThenApplyAsync( + new[] { "Xray" }, + target => Task.FromResult($"verified:{target}"), + _ => + { + applied = true; + return Task.FromResult(true); + }, + () => throw new InvalidDataException("GeoFiles preparation failed.")); + } + catch (InvalidDataException) + { + failed = true; + } + + await failed.Should().BeTrue(); + await applied.Should().BeFalse(); + } + + [Test] + public async Task FailedBatchStagingPreventsEveryCoreStopAndInstall() + { + var applied = false; + var failed = false; + try + { + await CoreUpdateWorkflow.StageAllThenApplyAsync( + new[] { "Xray", "sing-box" }, + target => target == "sing-box" + ? throw new InvalidDataException("package verification failed") + : Task.FromResult($"verified:{target}"), + stages => + { + applied = true; + return Task.FromResult(stages.Count); + }); + } + catch (InvalidDataException) + { + failed = true; + } + + await failed.Should().BeTrue(); + await applied.Should().BeFalse(); + } + + [Test] + public async Task SelectedBatchStagesWebWithOtherTargetsAndAppliesItOnlyAfterCoreAndGeo() + { + var sequence = new List(); + await CoreUpdateWorkflow.StageAllThenApplyAsync( + new[] { "Xray", "sing-box" }, + target => + { + sequence.Add($"stage:{target}"); + return Task.FromResult(target); + }, + stages => + { + sequence.AddRange(stages.Select(stage => $"apply:{stage}")); + sequence.Add("apply:GeoFiles"); + return Task.FromResult(true); + }, + () => + { + sequence.Add("prepare:GeoFiles"); + sequence.Add("stage:v2rayN.WebAPI"); + return Task.CompletedTask; + }); + sequence.Add("apply:v2rayN.WebAPI"); + + await sequence.SequenceEqual(new[] + { + "stage:Xray", "stage:sing-box", "prepare:GeoFiles", "stage:v2rayN.WebAPI", + "apply:Xray", "apply:sing-box", "apply:GeoFiles", "apply:v2rayN.WebAPI", + }).Should().BeTrue(); + } + + [Test] + public async Task ApplyFailureRestoresThePreviousBinaryBeforeRestartingTheOldCore() + { + using var directory = new TemporaryDirectory(); + var install = Path.Combine(directory.Path, "xray"); + var backup = Path.Combine(directory.Path, ".xray-backup"); + Directory.CreateDirectory(install); + Directory.CreateDirectory(backup); + await File.WriteAllTextAsync(Path.Combine(install, "xray"), "new-binary"); + await File.WriteAllTextAsync(Path.Combine(backup, "xray"), "old-binary"); + var order = new List(); + + var rollback = await CoreUpdateWorkflow.RollBackAndRestoreAsync( + () => + { + CoreUpdateWorkflow.RestorePreviousDirectory(install, backup, candidateInstalled: true, hadInstalledCore: true); + order.Add("restore-files"); + return Task.CompletedTask; + }, + () => { order.Add("initialize-old"); return Task.CompletedTask; }, + wasRunning: true, + restartPreviousCoreAsync: () => { order.Add("restart-old"); return Task.FromResult(true); }); + + await rollback.Completed.Should().BeTrue(); + await (await File.ReadAllTextAsync(Path.Combine(install, "xray"))).Should().BeEqualTo("old-binary"); + await order.SequenceEqual(["restore-files", "initialize-old", "restart-old"]).Should().BeTrue(); + } + + [Test] + public async Task FailedOldCoreRestartIsReportedAfterItsFilesHaveBeenRestored() + { + using var directory = new TemporaryDirectory(); + var install = Path.Combine(directory.Path, "xray"); + var backup = Path.Combine(directory.Path, ".xray-backup"); + Directory.CreateDirectory(install); + Directory.CreateDirectory(backup); + await File.WriteAllTextAsync(Path.Combine(install, "xray"), "new-binary"); + await File.WriteAllTextAsync(Path.Combine(backup, "xray"), "old-binary"); + + var rollback = await CoreUpdateWorkflow.RollBackAndRestoreAsync( + () => + { + CoreUpdateWorkflow.RestorePreviousDirectory(install, backup, candidateInstalled: true, hadInstalledCore: true); + return Task.CompletedTask; + }, + () => Task.CompletedTask, + wasRunning: true, + restartPreviousCoreAsync: () => Task.FromResult(false)); + + await rollback.FilesRestored.Should().BeTrue(); + await rollback.Completed.Should().BeFalse(); + await (await File.ReadAllTextAsync(Path.Combine(install, "xray"))).Should().BeEqualTo("old-binary"); + } + + [Test] + public async Task OnlyTheExactUpdatedCoreIsStoppedAndRestarted() + { + var now = DateTimeOffset.UtcNow; + foreach (var core in new[] { ECoreType.Xray, ECoreType.sing_box, ECoreType.mihomo }) + { + await CoreUpdateRuntimePolicy.IsTargetRunning(core, core, now).Should().BeTrue(); + await CoreUpdateRuntimePolicy.IsTargetRunning(core, ECoreType.v2fly, now).Should().BeFalse(); + await CoreUpdateRuntimePolicy.IsTargetRunning(core, core, null).Should().BeFalse(); + } + await CoreUpdatePackageStager.SupportsCore(ECoreType.Xray).Should().BeTrue(); + await CoreUpdatePackageStager.SupportsCore(ECoreType.sing_box).Should().BeTrue(); + await CoreUpdatePackageStager.SupportsCore(ECoreType.mihomo).Should().BeTrue(); + await CoreUpdatePackageStager.SupportsCore(ECoreType.v2rayN).Should().BeFalse(); + } + + [Test] + public async Task CoreUpdatePreReleaseChoicesMatchDesktopCoreInfoSemantics() + { + var manager = CoreInfoManager.Instance; + await manager.GetCheckPreRelease(ECoreType.Xray, true).Should().BeTrue(); + await manager.GetCheckPreRelease(ECoreType.v2rayN, true).Should().BeTrue(); + await manager.GetCheckPreRelease(ECoreType.mihomo, true).Should().BeFalse(); + await manager.GetCheckPreRelease(ECoreType.sing_box, true).Should().BeFalse(); + } + + [Test] + public async Task CoreUpdateCheckPreservesServiceLibCurrentVersionSemantics() + { + var latest = string.Format(ResUI.IsLatestCore, ECoreType.Xray, "v1.2.3"); + await V2rayRuntime.IsUpToDateResult(ECoreType.Xray, latest).Should().BeTrue(); + await V2rayRuntime.IsUpToDateResult(ECoreType.Xray, "Could not reach release API").Should().BeFalse(); + await V2rayRuntime.IsUpToDateResult(ECoreType.Xray, null).Should().BeFalse(); + } + + private static async Task WriteZipFileAsync(ZipArchive zip, string name, string content) + { + await using var stream = zip.CreateEntry(name).Open(); + await stream.WriteAsync(Encoding.UTF8.GetBytes(content)); + } + + private static async Task WriteGzipAsync(string path, string content) + { + await using var file = File.Create(path); + await using var gzip = new GZipStream(file, CompressionLevel.SmallestSize); + await gzip.WriteAsync(Encoding.UTF8.GetBytes(content)); + } + + private static async Task WriteTarGzipAsync(string path, string entryName, string content) + { + await using var file = File.Create(path); + await using var gzip = new GZipStream(file, CompressionLevel.SmallestSize, leaveOpen: true); + using var writer = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true); + var entry = new PaxTarEntry(TarEntryType.RegularFile, entryName) + { + DataStream = new MemoryStream(Encoding.UTF8.GetBytes(content)), + }; + writer.WriteEntry(entry); + await Task.CompletedTask; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-core-update-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/DnsEditorParityTests.cs b/v2rayN/v2rayN.WebAPI/Tests/DnsEditorParityTests.cs new file mode 100644 index 00000000..44b3b996 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/DnsEditorParityTests.cs @@ -0,0 +1,252 @@ +using System.Reflection; +using System.Text.Json.Nodes; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models.Configs; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Entities; +using ServiceLib.Services.CoreConfig; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class DnsEditorParityTests +{ + [Test] + public async Task DnsEditorOptionsAreTheCurrentServiceLibOptions() + { + var options = V2rayRuntime.GetEditorOptions(); + var settings = V2rayRuntime.GetWebSettingsOptions(); + + await options.Subscriptions.ConvertTargets.SequenceEqual(Global.SubConvertTargets).Should().BeTrue(); + await options.Subscriptions.CustomCoreTypes.SequenceEqual( + Enum.GetValues().Where(value => value != ECoreType.v2rayN).Select(value => value.ToString())).Should().BeTrue(); + await options.Profiles.CoreTypes.SequenceEqual(Global.CoreTypes).Should().BeTrue(); + await options.Profiles.ConfigTypes.SequenceEqual(Enum.GetValues().Select(value => value.ToString())).Should().BeTrue(); + await options.Profiles.SingboxOnlyConfigTypes.ToHashSet().SetEquals(Global.SingboxOnlyConfigType.Select(value => value.ToString())).Should().BeTrue(); + await options.Profiles.Networks.SequenceEqual(Global.Networks).Should().BeTrue(); + await options.Profiles.DefaultNetwork.Should().BeEqualTo(Global.DefaultNetwork); + await options.Profiles.DefaultStreamSecurity.Should().BeEqualTo(new ProfileItem().StreamSecurity); + await options.Profiles.DefaultSecurity.Should().BeEqualTo(Global.DefaultSecurity); + await options.Profiles.DefaultVlessEncryption.Should().BeEqualTo(Global.None); + await options.Profiles.DefaultXhttpMode.Should().BeEqualTo(Global.DefaultXhttpMode); + await options.Profiles.DefaultGrpcMode.Should().BeEqualTo(Global.GrpcGunMode); + await options.Profiles.DefaultWireGuardMtu.Should().BeEqualTo(1280); + await options.Profiles.VmessSecurities.SequenceEqual(Global.VmessSecurities).Should().BeTrue(); + await options.Profiles.Flows.SequenceEqual(Global.Flows).Should().BeTrue(); + await options.Profiles.XhttpModes.SequenceEqual(Global.XhttpMode).Should().BeTrue(); + await options.Profiles.KcpHeaderTypes.SequenceEqual(new[] { Global.None }.Concat(Global.KcpHeaderTypes)).Should().BeTrue(); + await options.Profiles.TuicCongestionControls.SequenceEqual(Global.TuicCongestionControls).Should().BeTrue(); + await options.Profiles.NaiveCongestionControls.SequenceEqual(Global.NaiveCongestionControls).Should().BeTrue(); + await options.Profiles.Fingerprints.SequenceEqual(Global.Fingerprints).Should().BeTrue(); + await options.Profiles.Alpns.SequenceEqual(Global.Alpns).Should().BeTrue(); + foreach (var coreType in Enum.GetValues().Where(value => value != ECoreType.v2rayN)) + { + var expected = coreType switch + { + ECoreType.v2fly => Global.SsSecurities, + ECoreType.Xray => Global.SsSecuritiesInXray, + _ => Global.SsSecuritiesInSingbox, + }; + await options.Profiles.ShadowsocksSecurities[coreType.ToString()].SequenceEqual(expected).Should().BeTrue(); + } + await options.Profiles.MultipleLoadStrategies.ToHashSet().SetEquals(Enum.GetNames()).Should().BeTrue(); + await settings.LogLevels.SequenceEqual(Global.LogLevels).Should().BeTrue(); + await settings.RootCertProviders.SequenceEqual(Global.RootCertProviders).Should().BeTrue(); + await settings.Fingerprints.SequenceEqual(Global.Fingerprints).Should().BeTrue(); + await settings.UserAgents.SequenceEqual(Global.UserAgent).Should().BeTrue(); + await settings.Mux4SboxProtocols.SequenceEqual(Global.SingboxMuxs).Should().BeTrue(); + await settings.FragmentPacketsOptions.SequenceEqual(Global.FragmentPacketsOptions).Should().BeTrue(); + await settings.DestOverrideProtocols.SequenceEqual(Global.destOverrideProtocols).Should().BeTrue(); + await settings.MixedConcurrencyCounts.SequenceEqual(Enumerable.Range(Global.SpeedTestConcurrencyCountMin, 20)).Should().BeTrue(); + await settings.SpeedTestTimeouts.SequenceEqual(Enumerable.Range(2, 5).Select(value => value * 5)).Should().BeTrue(); + await settings.SpeedTestUrls.SequenceEqual(Global.SpeedTestUrls).Should().BeTrue(); + await settings.SpeedPingTestUrls.SequenceEqual(Global.SpeedPingTestUrls).Should().BeTrue(); + await settings.UdpTestTargets.SequenceEqual(Global.UdpTestTargets).Should().BeTrue(); + await settings.IPAPIUrls.SequenceEqual(Global.IPAPIUrls).Should().BeTrue(); + await settings.SubConvertUrls.SequenceEqual(Global.SubConvertUrls).Should().BeTrue(); + await settings.GeoFilesSources.SequenceEqual(Global.GeoFilesSources).Should().BeTrue(); + await settings.SingboxRulesetSources.SequenceEqual(Global.SingboxRulesetSources).Should().BeTrue(); + await settings.RoutingRulesSources.SequenceEqual(Global.RoutingRulesSources).Should().BeTrue(); + await options.Dns.CoreTypes.SequenceEqual(Global.CoreTypes).Should().BeTrue(); + await options.Dns.DomainStrategies4Freedom.SequenceEqual(Global.DomainStrategy).Should().BeTrue(); + await options.Dns.DomainStrategies4Singbox.SequenceEqual(Global.DomainStrategies4Sbox).Should().BeTrue(); + await options.Dns.DomainDnsAddresses.SequenceEqual(Global.DomainPureIPDNSAddress).Should().BeTrue(); + } + + [Test] + public async Task DnsDtoAndWebMappingRoundTripAllFieldsIncludingTunDns() + { + await (typeof(DnsProfileView).GetProperty(nameof(DnsProfileView.TunDNS)) is not null).Should().BeTrue(); + await (typeof(DnsProfileInput).GetProperty(nameof(DnsProfileInput.TunDNS)) is not null).Should().BeTrue(); + + var source = new DNSItem + { + Id = "xray-dns", + Remarks = "original", + CoreType = ECoreType.Xray, + Enabled = true, + UseSystemHosts = true, + NormalDNS = "original-normal", + TunDNS = "original-tun", + DomainStrategy4Freedom = Global.DomainStrategy.First(), + DomainDNSAddress = Global.DomainPureIPDNSAddress.First(), + }; + var loaded = V2rayRuntime.ToDnsProfileView(source); + var saved = V2rayRuntime.ApplyDnsProfileInput(source, ECoreType.Xray, new DnsProfileInput( + loaded.Remarks, + loaded.Enabled, + loaded.UseSystemHosts, + "edited-normal", + "edited-tun", + Global.DomainStrategy[1], + Global.DomainPureIPDNSAddress[1])); + var reloaded = V2rayRuntime.ToDnsProfileView(saved); + + await reloaded.Id.Should().BeEqualTo("xray-dns"); + await reloaded.CoreType.Should().BeEqualTo(ECoreType.Xray); + await reloaded.Remarks.Should().BeEqualTo("original"); + await reloaded.Enabled.Should().BeTrue(); + await reloaded.UseSystemHosts.Should().BeTrue(); + await reloaded.NormalDNS.Should().BeEqualTo("edited-normal"); + await reloaded.TunDNS.Should().BeEqualTo("edited-tun"); + await reloaded.DomainStrategy4Freedom.Should().BeEqualTo(Global.DomainStrategy[1]); + await reloaded.DomainDNSAddress.Should().BeEqualTo(Global.DomainPureIPDNSAddress[1]); + } + + [Test] + public async Task DesktopDefaultDnsImportReadsEmbeddedXrayAndSingboxProfiles() + { + var defaults = new V2rayRuntime(null!, null!, null!, null!, null!).GetDefaultDnsProfiles(); + var xray = defaults.Single(item => item.CoreType == ECoreType.Xray); + var singbox = defaults.Single(item => item.CoreType == ECoreType.sing_box); + + await xray.NormalDNS.Should().BeEqualTo(EmbedUtils.GetEmbedText(Global.DNSV2rayNormalFileName)); + await xray.TunDNS.Should().BeEqualTo(EmbedUtils.GetEmbedText(Global.DNSV2rayNormalFileName)); + await singbox.NormalDNS.Should().BeEqualTo(EmbedUtils.GetEmbedText(Global.DNSSingboxNormalFileName)); + await singbox.TunDNS.Should().BeEqualTo(EmbedUtils.GetEmbedText(Global.TunSingboxDNSFileName)); + await string.IsNullOrWhiteSpace(xray.NormalDNS).Should().BeFalse(); + await string.IsNullOrWhiteSpace(singbox.TunDNS).Should().BeFalse(); + } + + [Test] + public async Task XrayNormalAndTunDnsAreUsedByGeneratedCoreConfigAfterWebMappingRoundTrip() + { + var config = SniffingSettingsIntegrationTests.CreateConfig(); + SniffingSettingsIntegrationTests.BindAppManagerConfig(config); + var initial = Existing(ECoreType.Xray); + var mapped = V2rayRuntime.ToDnsProfileView(initial); + var saved = V2rayRuntime.ApplyDnsProfileInput(initial, ECoreType.Xray, new DnsProfileInput( + mapped.Remarks, true, false, + "{\"servers\":[\"https://9.9.9.9/dns-query\"]}", + "{\"servers\":[\"tls://1.0.0.1\"]}", + Global.AsIs, + Global.DomainPureIPDNSAddress.First())); + var reloaded = V2rayRuntime.ToDnsProfileView(saved); + + var normal = Generate(ECoreType.Xray, config, saved, tun: false); + var tun = Generate(ECoreType.Xray, config, saved, tun: true); + await reloaded.NormalDNS.Should().BeEqualTo("{\"servers\":[\"https://9.9.9.9/dns-query\"]}"); + await reloaded.TunDNS.Should().BeEqualTo("{\"servers\":[\"tls://1.0.0.1\"]}"); + await normal["dns"]!["servers"]!.ToJsonString().Contains("9.9.9.9").Should().BeTrue(); + await tun["dns"]!["servers"]!.ToJsonString().Contains("1.0.0.1").Should().BeTrue(); + } + + [Test] + public async Task SingboxNormalAndTunDnsAreUsedByGeneratedCoreConfigAfterWebMappingRoundTrip() + { + var config = SniffingSettingsIntegrationTests.CreateConfig(); + SniffingSettingsIntegrationTests.BindAppManagerConfig(config); + var initial = Existing(ECoreType.sing_box); + var mapped = V2rayRuntime.ToDnsProfileView(initial); + var saved = V2rayRuntime.ApplyDnsProfileInput(initial, ECoreType.sing_box, new DnsProfileInput( + mapped.Remarks, true, false, + "{\"servers\":[{\"tag\":\"web-normal\",\"type\":\"udp\",\"server\":\"9.9.9.9\"}],\"final\":\"web-normal\"}", + "{\"servers\":[{\"tag\":\"web-tun\",\"type\":\"udp\",\"server\":\"1.0.0.1\"}],\"final\":\"web-tun\"}", + Global.DomainStrategies4Sbox.First(), + Global.DomainPureIPDNSAddress.First())); + var reloaded = V2rayRuntime.ToDnsProfileView(saved); + + var normal = Generate(ECoreType.sing_box, config, saved, tun: false); + var tun = Generate(ECoreType.sing_box, config, saved, tun: true); + await reloaded.NormalDNS.Should().BeEqualTo(saved.NormalDNS); + await reloaded.TunDNS.Should().BeEqualTo(saved.TunDNS); + await normal["dns"]!["servers"]!.ToJsonString().Contains("9.9.9.9").Should().BeTrue(); + await tun["dns"]!["servers"]!.ToJsonString().Contains("1.0.0.1").Should().BeTrue(); + } + + [Test] + public async Task DnsValidationMatchesTheDesktopCoreEditorRules() + { + await V2rayRuntime.IsValidCustomDns(ECoreType.Xray, "8.8.8.8,1.1.1.1").Should().BeTrue(); + await V2rayRuntime.IsValidCustomDns(ECoreType.Xray, "{\"servers\":[\"1.1.1.1\"]}").Should().BeTrue(); + await V2rayRuntime.IsValidCustomDns(ECoreType.Xray, "{broken").Should().BeFalse(); + await V2rayRuntime.IsValidCustomDns(ECoreType.sing_box, + "{\"servers\":[{\"type\":\"udp\",\"server\":\"1.1.1.1\"}]}").Should().BeTrue(); + await V2rayRuntime.IsValidCustomDns(ECoreType.sing_box, + "{\"servers\":[{\"server\":\"1.1.1.1\"}]}").Should().BeFalse(); + } + + private static DNSItem Existing(ECoreType coreType) => new() + { + Id = $"{coreType}-dns", + Remarks = "custom DNS", + CoreType = coreType, + Enabled = true, + UseSystemHosts = false, + NormalDNS = "old-normal", + TunDNS = "old-tun", + DomainStrategy4Freedom = string.Empty, + DomainDNSAddress = Global.DomainPureIPDNSAddress.First(), + }; + + private static JsonNode Generate(ECoreType coreType, Config config, DNSItem dns, bool tun) + { + var node = new ProfileItem + { + IndexId = "dns-parity-node", + ConfigType = EConfigType.VMess, + CoreType = coreType, + Remarks = "DNS parity node", + Address = "example.test", + Port = 443, + Password = Guid.NewGuid().ToString(), + Network = Global.DefaultNetwork, + StreamSecurity = string.Empty, + Subid = string.Empty, + }; + node.SetProtocolExtra(new ProtocolExtraItem { AlterId = "0", VmessSecurity = Global.DefaultSecurity }); + var context = new CoreConfigContext + { + Node = node, + RunCoreType = coreType, + RoutingItem = new RoutingItem + { + Id = "dns-route", + Remarks = "DNS parity route", + RuleSet = "[]", + DomainStrategy = Global.AsIs, + DomainStrategy4Singbox = string.Empty, + }, + RawDnsItem = dns, + SimpleDnsItem = config.SimpleDNSItem, + AllProxiesMap = new Dictionary { [node.IndexId] = node }, + AppConfig = config, + IsTunEnabled = tun, + ProtectDomainList = [], + HasGlobalIPv6Address = true, + }; + var result = coreType == ECoreType.sing_box + ? new CoreConfigSingboxService(context).GenerateClientConfigContent() + : new CoreConfigV2rayService(context).GenerateClientConfigContent(); + if (!result.Success || result.Data is null) + { + throw new InvalidOperationException($"Core configuration generation failed: {result.Msg}"); + } + return JsonNode.Parse(result.Data.ToString()!)!; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/EventHubTests.cs b/v2rayN/v2rayN.WebAPI/Tests/EventHubTests.cs new file mode 100644 index 00000000..09bd1fe5 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/EventHubTests.cs @@ -0,0 +1,52 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class EventHubTests +{ + [Test] + public async Task LogEventsAreNotQueuedForSubscribersWhenTheLogsPageIsInactive() + { + var hub = new EventHub(); + using var cancellation = new CancellationTokenSource(); + await using var subscription = hub.Subscribe(cancellation.Token, includeLogs: false).GetAsyncEnumerator(); + var next = subscription.MoveNextAsync().AsTask(); + + hub.Publish("log", new { message = "high-frequency core output" }); + hub.Publish("core-state", new { state = "running" }); + + await next.WaitAsync(TimeSpan.FromSeconds(1)); + await subscription.Current.Type.Should().BeEqualTo("core-state"); + cancellation.Cancel(); + } + + [Test] + public async Task LogEventsAreDeliveredWhenTheLogsPageSubscribesForThem() + { + var hub = new EventHub(); + using var cancellation = new CancellationTokenSource(); + await using var subscription = hub.Subscribe(cancellation.Token, includeLogs: true).GetAsyncEnumerator(); + var next = subscription.MoveNextAsync().AsTask(); + + hub.Publish("log", new { message = "visible output" }); + + await next.WaitAsync(TimeSpan.FromSeconds(1)); + await subscription.Current.Type.Should().BeEqualTo("log"); + cancellation.Cancel(); + } + + [Test] + public async Task LogsClearedControlEventsReachSubscribersWithoutTheLogsPayloadStream() + { + var hub = new EventHub(); + using var cancellation = new CancellationTokenSource(); + await using var subscription = hub.Subscribe(cancellation.Token, includeLogs: false).GetAsyncEnumerator(); + var next = subscription.MoveNextAsync().AsTask(); + + hub.Publish("logs-cleared", new { generation = 1 }); + + await next.WaitAsync(TimeSpan.FromSeconds(1)); + await subscription.Current.Type.Should().BeEqualTo("logs-cleared"); + cancellation.Cancel(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/GeoFilesUpdateTransactionTests.cs b/v2rayN/v2rayN.WebAPI/Tests/GeoFilesUpdateTransactionTests.cs new file mode 100644 index 00000000..a660697c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/GeoFilesUpdateTransactionTests.cs @@ -0,0 +1,322 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class GeoFilesUpdateTransactionTests +{ + [Test] + public async Task CallbackFailureIsRejectedEvenWhenUpdaterTaskCompletesNormally() + { + var completion = new GeoFilesUpdateCompletion(); + completion.Report(success: false, "download failed", isProgressMessage: false); + await Task.CompletedTask; + + var failed = false; + try + { + completion.EnsureSuccessful(); + } + catch (IOException exception) when (exception.Message.Contains("download failed", StringComparison.Ordinal)) + { + failed = true; + } + + await failed.Should().BeTrue(); + } + + [Test] + public async Task GeoFilesProgressDoesNotReplaceTheFinalSuccessNotification() + { + var completion = new GeoFilesUpdateCompletion(); + completion.Report(success: false, "Downloading geoip.dat", isProgressMessage: true); + + var missingCompletionRejected = false; + try + { + completion.EnsureSuccessful(); + } + catch (IOException exception) when (exception.Message.Contains("did not report successful completion", StringComparison.Ordinal)) + { + missingCompletionRejected = true; + } + + completion.Report(success: true, "GeoFiles updated", isProgressMessage: false); + completion.EnsureSuccessful(); + await missingCompletionRejected.Should().BeTrue(); + } + + [Test] + public async Task FailedGeoFilesUpdateRestoresReplacedFilesAndRemovesNewFiles() + { + using var directory = new TemporaryDirectory(); + var existing = Path.Combine(directory.Path, "geoip.dat"); + var created = Path.Combine(directory.Path, "geosite-custom.srs"); + await File.WriteAllTextAsync(existing, "old-geoip"); + + var failed = false; + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [existing, created], + [existing, created], + async _ => + { + await File.WriteAllTextAsync(existing, "partially-updated-geoip"); + await File.WriteAllTextAsync(created, "new-srs"); + throw new IOException("simulated later download/apply failure"); + }, + CancellationToken.None); + } + catch (IOException exception) when (exception.Message.Contains("simulated later", StringComparison.Ordinal)) + { + failed = true; + } + + await failed.Should().BeTrue(); + await (await File.ReadAllTextAsync(existing)).Should().BeEqualTo("old-geoip"); + await File.Exists(created).Should().BeFalse(); + } + + [Test] + public async Task MissingRequiredGeoFileFailsAndRestoresTheOriginalSet() + { + using var directory = new TemporaryDirectory(); + var existing = Path.Combine(directory.Path, "geosite.dat"); + var required = Path.Combine(directory.Path, "geoip.dat"); + await File.WriteAllTextAsync(existing, "old-geosite"); + await File.WriteAllTextAsync(required, "old-geoip"); + + var failed = false; + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [existing, required], + [existing, required], + async _ => + { + await File.WriteAllTextAsync(existing, "new-geosite"); + File.Delete(required); + }, + CancellationToken.None); + } + catch (IOException exception) when (exception.Message.Contains("did not produce", StringComparison.Ordinal)) + { + failed = true; + } + + await failed.Should().BeTrue(); + await (await File.ReadAllTextAsync(existing)).Should().BeEqualTo("old-geosite"); + await (await File.ReadAllTextAsync(required)).Should().BeEqualTo("old-geoip"); + } + + [Test] + public async Task VerifiedGeoFilesUpdateKeepsNewFiles() + { + using var directory = new TemporaryDirectory(); + var target = Path.Combine(directory.Path, "geosite.dat"); + + await GeoFilesUpdateTransaction.ApplyAsync( + [target], + [target], + token => File.WriteAllTextAsync(target, "verified-update", token), + CancellationToken.None); + + await (await File.ReadAllTextAsync(target)).Should().BeEqualTo("verified-update"); + } + + [Test] + public async Task BeforeCommitFailureRestoresGeoFilesAfterCoreRestartFailure() + { + using var directory = new TemporaryDirectory(); + var target = Path.Combine(directory.Path, "geoip.dat"); + await File.WriteAllTextAsync(target, "old-geoip"); + var coreCompletionCalled = false; + var coreSawRestoredGeo = false; + var failed = false; + + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [target], + [target], + token => File.WriteAllTextAsync(target, "new-geoip", token), + CancellationToken.None, + async (rollbackGeoFiles, _) => + { + await rollbackGeoFiles(); + coreCompletionCalled = true; + coreSawRestoredGeo = await File.ReadAllTextAsync(target) == "old-geoip"; + throw new IOException("simulated Core restart failure"); + }); + } + catch (IOException exception) when (exception.Message.Contains("simulated Core restart failure", StringComparison.Ordinal)) + { + failed = true; + } + + await failed.Should().BeTrue(); + await coreCompletionCalled.Should().BeTrue(); + await coreSawRestoredGeo.Should().BeTrue(); + await (await File.ReadAllTextAsync(target)).Should().BeEqualTo("old-geoip"); + } + + [Test] + public async Task ManualBatchAndRegionalPresetGeoFilesWorkShareOneTransactionGate() + { + var gate = new GeoFilesUpdateGate(); + var active = 0; + var maximumActive = 0; + var entrants = new System.Collections.Concurrent.ConcurrentBag(); + + await Task.WhenAll(new[] { "manual", "batch", "regional-preset" }.Select(name => gate.RunAsync(async () => + { + var nowActive = Interlocked.Increment(ref active); + UpdateMaximum(ref maximumActive, nowActive); + entrants.Add(name); + await Task.Delay(20); + Interlocked.Decrement(ref active); + }, CancellationToken.None))); + + await maximumActive.Should().BeEqualTo(1); + await entrants.OrderBy(item => item, StringComparer.Ordinal) + .SequenceEqual(new[] { "batch", "manual", "regional-preset" }) + .Should().BeTrue(); + } + + private static void UpdateMaximum(ref int maximum, int value) + { + while (true) + { + var current = Volatile.Read(ref maximum); + if (current >= value || Interlocked.CompareExchange(ref maximum, value, current) == current) + { + return; + } + } + } + + [Test] + public async Task FailedUpdateRemovesNewMmdbAndSrsTargetsButLeavesUnmanagedFilesAlone() + { + using var directory = new TemporaryDirectory(); + var required = Path.Combine(directory.Path, "geosite.dat"); + var geoip = Path.Combine(directory.Path, "geoip.dat"); + var newMmdb = Path.Combine(directory.Path, "Country.mmdb"); + var newSrs = Path.Combine(directory.Path, "geosite-custom.srs"); + var unrelatedSrs = Path.Combine(directory.Path, "locally-managed.srs"); + var unrelatedGeo = Path.Combine(directory.Path, "geography-not-managed.db"); + + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [required, geoip, newMmdb, newSrs], + [required, geoip], + async _ => + { + await File.WriteAllTextAsync(required, "new-geosite"); + await File.WriteAllTextAsync(geoip, "new-geoip"); + await File.WriteAllTextAsync(newMmdb, "new-mmdb"); + await File.WriteAllTextAsync(newSrs, "new-srs"); + await File.WriteAllTextAsync(unrelatedSrs, "keep-srs"); + await File.WriteAllTextAsync(unrelatedGeo, "keep-geo"); + throw new IOException("simulated callback-reported download failure"); + }, + CancellationToken.None); + } + catch (IOException exception) when (exception.Message.Contains("simulated callback-reported", StringComparison.Ordinal)) + { + // The ServiceLib callback can report failure while its Task completes normally; + // the Web callback adapter converts that report to this transaction failure. + } + + await File.Exists(required).Should().BeFalse(); + await File.Exists(geoip).Should().BeFalse(); + await File.Exists(newMmdb).Should().BeFalse(); + await File.Exists(newSrs).Should().BeFalse(); + await (await File.ReadAllTextAsync(unrelatedSrs)).Should().BeEqualTo("keep-srs"); + await (await File.ReadAllTextAsync(unrelatedGeo)).Should().BeEqualTo("keep-geo"); + } + + [Test] + public async Task CancellationRollsBackChangesToTheManagedSet() + { + using var directory = new TemporaryDirectory(); + using var cancellation = new CancellationTokenSource(); + var existing = Path.Combine(directory.Path, "geoip.dat"); + var newFile = Path.Combine(directory.Path, "geosite-new.srs"); + await File.WriteAllTextAsync(existing, "before-cancel"); + + var canceled = false; + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [existing, newFile], + [existing], + async token => + { + await File.WriteAllTextAsync(existing, "partial-update", token); + await File.WriteAllTextAsync(newFile, "partial-srs", token); + cancellation.Cancel(); + token.ThrowIfCancellationRequested(); + }, + cancellation.Token); + } + catch (OperationCanceledException) + { + canceled = true; + } + + await canceled.Should().BeTrue(); + await (await File.ReadAllTextAsync(existing)).Should().BeEqualTo("before-cancel"); + await File.Exists(newFile).Should().BeFalse(); + } + + [Test] + public async Task RollbackFailureRetainsItsBackupSetAndReportsBothErrors() + { + using var directory = new TemporaryDirectory(); + var target = Path.Combine(directory.Path, "geoip.dat"); + await File.WriteAllTextAsync(target, "before-update"); + + IOException? failure = null; + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [target], + [target], + _ => + { + File.Delete(target); + Directory.CreateDirectory(target); + throw new IOException("simulated update failure"); + }, + CancellationToken.None); + } + catch (IOException exception) when (exception.Message.Contains("rollback is incomplete", StringComparison.Ordinal)) + { + failure = exception; + } + + await (failure is not null).Should().BeTrue(); + await (failure!.InnerException is AggregateException aggregate + && aggregate.InnerExceptions.Count == 2).Should().BeTrue(); + const string retainedPrefix = "backup set was retained at "; + var retainedStart = failure!.Message.IndexOf(retainedPrefix, StringComparison.Ordinal) + retainedPrefix.Length; + var retainedPath = failure.Message[retainedStart..].TrimEnd('.'); + await Directory.Exists(retainedPath).Should().BeTrue(); + Directory.Delete(retainedPath, recursive: true); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-geofiles-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 b/v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 new file mode 100644 index 00000000..6fbc4283 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 @@ -0,0 +1,731 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('startup', 'update', 'restore')] + [string]$Mode, + [ValidateNotNullOrEmpty()] + [string]$PublishDirectory, + [ValidateNotNullOrEmpty()] + [string]$PreviousPublishDirectory, + [ValidateNotNullOrEmpty()] + [string]$CandidatePublishDirectory +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Net.Http +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +if ($Mode -in @('startup', 'restore')) { + if (-not $PublishDirectory) { throw "$Mode mode requires -PublishDirectory." } + if ($Mode -eq 'restore') { + $xray = Join-Path $PublishDirectory 'bin\xray\xray.exe' + if (-not (Test-Path -LiteralPath $xray -PathType Leaf)) { + throw "restore mode requires the bundled Xray executable: $xray" + } + } +} elseif ($Mode -eq 'update') { + if (-not $PreviousPublishDirectory -or -not $CandidatePublishDirectory) { + throw 'update mode requires -PreviousPublishDirectory and -CandidatePublishDirectory.' + } +} + +$executableName = 'v2rayN.WebAPI.exe' +if (-not [Environment]::Is64BitOperatingSystem) { throw 'v2rayN.WebAPI Windows smoke requires 64-bit Windows.' } + +# Shared native process control: the app must run in its own process group so the smoke can +# send a graceful CTRL_BREAK_EVENT instead of killing it. +if (-not ('WebApiWindowsSmokeNative' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; +public static class WebApiWindowsSmokeNative +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct STARTUPINFO + { + public int cb; + public IntPtr lpReserved; + public IntPtr lpDesktop; + public IntPtr lpTitle; + public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags; + public short wShowWindow, cbReserved2; + public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError; + } + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public IntPtr hProcess, hThread; + public uint dwProcessId, dwThreadId; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CreateProcess(string applicationName, StringBuilder commandLine, + IntPtr processAttributes, IntPtr threadAttributes, bool inheritHandles, uint creationFlags, + IntPtr environment, string currentDirectory, ref STARTUPINFO startupInfo, + out PROCESS_INFORMATION processInformation); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GenerateConsoleCtrlEvent(uint controlEvent, uint processGroupId); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr handle); +} +'@ +} + +$testRoot = Join-Path $env:TEMP ('v2rayN.WebAPI-windows-' + $Mode + '-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $testRoot -Force | Out-Null + +# The union covers both modes so the finally block restores exactly the original state. +$environmentNames = @( + 'V2RAYN_DATA_HOME', 'V2RAYN_LOCAL_APPLICATION_DATA_V2', 'V2RAYN_WEB_API_KEY', + 'V2RAYN_WEB_AUTOSTART', 'ASPNETCORE_URLS', 'XDG_DATA_HOME', 'DOTNET_BUNDLE_EXTRACT_BASE_DIR', + 'INVOCATION_ID', 'JOURNAL_STREAM', 'NOTIFY_SOCKET', 'DOTNET_RUNNING_IN_CONTAINER', 'container' +) +$oldEnvironment = @{} +foreach ($name in $environmentNames) { + $oldEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') +} +$success = $false + +function Clear-TestEnvironment { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $null, 'Process') + } +} + +function Set-UpdateEnvironment([string]$Url, [string]$Key, [string]$BundleDirectory) { + Clear-TestEnvironment + [Environment]::SetEnvironmentVariable('V2RAYN_LOCAL_APPLICATION_DATA_V2', '0', 'Process') + [Environment]::SetEnvironmentVariable('V2RAYN_WEB_API_KEY', $Key, 'Process') + [Environment]::SetEnvironmentVariable('V2RAYN_WEB_AUTOSTART', 'false', 'Process') + [Environment]::SetEnvironmentVariable('ASPNETCORE_URLS', $Url, 'Process') + [Environment]::SetEnvironmentVariable('DOTNET_BUNDLE_EXTRACT_BASE_DIR', $BundleDirectory, 'Process') +} + +function Restore-TestEnvironment { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $oldEnvironment[$name], 'Process') + } +} + +function New-NoProxyHttpClient { + $handler = [System.Net.Http.HttpClientHandler]::new() + $handler.UseProxy = $false + return [System.Net.Http.HttpClient]::new($handler) +} + +function Get-FreeLoopbackPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + $port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + $listener.Stop() + return $port +} + +function Start-NativeTestProcess([string]$Application, [string]$Arguments, [string]$WorkingDirectory) { + $commandLine = '"{0}" {1}' -f $Application, $Arguments + $startupInfo = [WebApiWindowsSmokeNative+STARTUPINFO]::new() + $startupInfo.cb = [Runtime.InteropServices.Marshal]::SizeOf([type][WebApiWindowsSmokeNative+STARTUPINFO]) + $processInfo = [WebApiWindowsSmokeNative+PROCESS_INFORMATION]::new() + $created = [WebApiWindowsSmokeNative]::CreateProcess( + $Application, [System.Text.StringBuilder]::new($commandLine), [IntPtr]::Zero, [IntPtr]::Zero, + $true, [uint32]0x00000200, [IntPtr]::Zero, $WorkingDirectory, + [ref]$startupInfo, [ref]$processInfo) + if (-not $created) { + $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw "Could not start $Application (Win32 error $code)." + } + try { + return [Diagnostics.Process]::GetProcessById([int]$processInfo.dwProcessId) + } finally { + if ($processInfo.hThread -ne [IntPtr]::Zero) { [void][WebApiWindowsSmokeNative]::CloseHandle($processInfo.hThread) } + if ($processInfo.hProcess -ne [IntPtr]::Zero) { [void][WebApiWindowsSmokeNative]::CloseHandle($processInfo.hProcess) } + } +} + +function Stop-NativeTestProcess([Diagnostics.Process]$Process, [int]$TimeoutMilliseconds = 15000) { + if ($null -eq $Process -or $Process.HasExited) { return } + if (-not [WebApiWindowsSmokeNative]::GenerateConsoleCtrlEvent(1, [uint32]$Process.Id)) { + $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw "Could not send graceful CTRL_BREAK_EVENT (Win32 error $code)." + } + if (-not $Process.WaitForExit($TimeoutMilliseconds)) { + throw "Windows WebAPI process $($Process.Id) did not exit gracefully." + } +} + +function Stop-TestProcessAndWait([int]$ProcessId, [int]$TimeoutMilliseconds = 15000) { + if (-not $ProcessId -or -not (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)) { return } + Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + while ([DateTime]::UtcNow -lt $deadline) { + if (-not (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)) { return } + Start-Sleep -Milliseconds 100 + } + if (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) { + throw "Windows process $ProcessId did not exit during smoke cleanup." + } +} + +function Wait-TestHealth { + param( + [string]$Url, + [string]$ExpectedVersion = '', + [Diagnostics.Process]$Process, + [int]$TimeoutSeconds = 35, + [int]$PreviousProcessId = 0 + ) + $client = New-NoProxyHttpClient + try { + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + $lastError = $null + while ([DateTime]::UtcNow -lt $deadline) { + if ($null -ne $Process -and $Process.HasExited) { + throw "WebAPI exited during startup (code $($Process.ExitCode))." + } + try { + $response = $client.GetAsync("$Url/api/health").GetAwaiter().GetResult() + $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + if ([int]$response.StatusCode -eq 200) { + # The original startup smoke parsed the body and required status "ok"; + # keep that assertion for both modes instead of trusting the 200 alone. + $healthBody = $body | ConvertFrom-Json + if ($healthBody.status -ne 'ok') { throw "Unexpected health response: $body" } + if (-not $ExpectedVersion) { + return [pscustomobject]@{ Version = $null; ProcessId = $null; Body = $body } + } + $version = $response.Headers.GetValues('X-v2rayn-WebAPI-version') | Select-Object -First 1 + $ownerId = $response.Headers.GetValues('X-v2rayn-WebAPI-instance-pid') | Select-Object -First 1 + $isPreviousProcess = $PreviousProcessId -gt 0 -and $ownerId -and [int]$ownerId -eq $PreviousProcessId + if ($version -eq $ExpectedVersion -and $ownerId -and -not $isPreviousProcess) { + return [pscustomobject]@{ Version = $version; ProcessId = [int]$ownerId; Body = $body } + } + if ($isPreviousProcess) { + $lastError = "the previous process $PreviousProcessId is still serving health" + } else { + $lastError = "unexpected version ${version}: $body" + } + } else { + $lastError = "HTTP $([int]$response.StatusCode): $body" + } + $response.Dispose() + } catch { + $lastError = $_.Exception.Message + } + Start-Sleep -Milliseconds 250 + } + if ($ExpectedVersion) { + throw "Windows WebAPI health did not reach version $ExpectedVersion. Last error: $lastError" + } + throw "Health check failed at $Url/api/health. Last error: $lastError" + } finally { + $client.Dispose() + } +} + +function Write-TestJson([string]$Path, $Value) { + $json = $Value | ConvertTo-Json -Depth 8 -Compress + [IO.File]::WriteAllText($Path, $json, [Text.UTF8Encoding]::new($false)) +} + +function Invoke-WebApiJson { + param( + [System.Net.Http.HttpClient]$Client, + [string]$Url, + [string]$Path, + [string]$Method = 'GET', + [string]$Token, + [object]$Body, + [string]$UploadPath + ) + + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method), "$Url$Path") + if ($Token) { + $request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token) + } + if ($UploadPath) { + $multipart = [System.Net.Http.MultipartFormDataContent]::new() + $fileContent = [System.Net.Http.ByteArrayContent]::new([IO.File]::ReadAllBytes($UploadPath)) + $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new('application/zip') + $multipart.Add($fileContent, 'file', [IO.Path]::GetFileName($UploadPath)) + $request.Content = $multipart + } elseif ($null -ne $Body) { + $json = $Body | ConvertTo-Json -Depth 16 -Compress + $request.Content = [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, 'application/json') + } + + try { + $response = $Client.SendAsync($request).GetAwaiter().GetResult() + try { + $text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + $parsed = if ($text) { $text | ConvertFrom-Json } else { $null } + return [pscustomobject]@{ StatusCode = [int]$response.StatusCode; Json = $parsed; Raw = $text } + } finally { + $response.Dispose() + } + } finally { + $request.Dispose() + } +} + +function Get-WebApiSessionToken { + param([System.Net.Http.HttpClient]$Client, [string]$Url, [string]$Key) + $login = Invoke-WebApiJson -Client $Client -Url $Url -Path '/api/auth/login' -Method 'POST' -Body @{ key = $Key } + if (-not $login.Json.success -or -not $login.Json.data.token) { + throw "Windows restore smoke login failed: $($login.Raw)" + } + return [string]$login.Json.data.token +} + +function Download-TestBackup { + param([System.Net.Http.HttpClient]$Client, [string]$Url, [string]$Token, [string]$Path) + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, "$Url/api/backup/download") + $request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token) + try { + $response = $Client.SendAsync($request).GetAwaiter().GetResult() + try { + if (-not $response.IsSuccessStatusCode) { + throw "Backup download failed with HTTP $([int]$response.StatusCode)." + } + [IO.File]::WriteAllBytes($Path, $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()) + } finally { + $response.Dispose() + } + } finally { + $request.Dispose() + } +} + +function Add-RestoreMarkerToBackup([string]$ArchivePath) { + $archive = [System.IO.Compression.ZipFile]::Open($ArchivePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + if ($null -ne $archive.GetEntry('guiConfigs/restore-smoke-marker.txt')) { + throw 'The generated backup unexpectedly contains the restore smoke marker already.' + } + $entry = $archive.CreateEntry('guiConfigs/restore-smoke-marker.txt') + $stream = $entry.Open() + try { + $writer = [IO.StreamWriter]::new($stream, [Text.UTF8Encoding]::new($false)) + try { $writer.Write('RESTORE_CONFIG_APPLIED') } finally { $writer.Dispose() } + } finally { + $stream.Dispose() + } + } finally { + $archive.Dispose() + } +} + +function Invoke-RestoreScenario([bool]$CoreWasRunning) { + $packageRoot = (Resolve-Path -LiteralPath $PublishDirectory).Path + $scenario = if ($CoreWasRunning) { 'running-core' } else { 'stopped-core' } + $caseRoot = Join-Path $testRoot $scenario + $install = Join-Path $caseRoot 'install' + New-Item -ItemType Directory -Path $install -Force | Out-Null + Get-ChildItem -LiteralPath $packageRoot -Force | Where-Object { $_.Name -ne '.env' } | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $install -Recurse -Force + } + + $identity = Get-Content -LiteralPath (Join-Path $install 'v2rayN.WebAPI.build.json') -Raw | ConvertFrom-Json + if ($identity.rid -ne 'win-x64') { throw 'Windows restore smoke must run against the win-x64 package.' } + $guiDirectory = Join-Path $install 'guiConfigs' + $markerPath = Join-Path $guiDirectory 'restore-smoke-marker.txt' + $key = 'Windows-restore-' + [Guid]::NewGuid().ToString('N') + $url = "http://127.0.0.1:$(Get-FreeLoopbackPort)" + $bundleRoot = Join-Path $caseRoot 'bundle' + New-Item -ItemType Directory -Path $bundleRoot -Force | Out-Null + $archivePath = Join-Path $caseRoot 'restore.zip' + $owner = $null + $replacementProcessId = $null + $client = New-NoProxyHttpClient + $token = $null + $success = $false + try { + Set-UpdateEnvironment $url $key $bundleRoot + try { + $executable = Join-Path $install $executableName + $owner = Start-NativeTestProcess $executable ('--foreground --no-open --urls "{0}"' -f $url) $install + } finally { + Restore-TestEnvironment + } + + $started = Wait-TestHealth -Url $url -ExpectedVersion $identity.version -Process $owner + if ($started.ProcessId -ne $owner.Id) { throw 'Initial Windows restore smoke lock/health PID mismatch.' } + $token = Get-WebApiSessionToken -Client $client -Url $url -Key $key + + if ($CoreWasRunning) { + $profile = @{ + configType = 'vless' + remarks = 'Windows backup restore smoke' + address = '127.0.0.1' + port = 65535 + password = [Guid]::NewGuid().ToString() + network = 'tcp' + streamSecurity = '' + } + $saved = Invoke-WebApiJson -Client $client -Url $url -Path '/api/profiles' -Method 'POST' -Token $token -Body $profile + if (-not $saved.Json.success -or -not $saved.Json.data.profileId) { + throw "Could not create the Windows Core restore smoke profile: $($saved.Raw)" + } + $profileId = [string]$saved.Json.data.profileId + $selected = Invoke-WebApiJson -Client $client -Url $url -Path "/api/profiles/$profileId/select" -Method 'POST' -Token $token + if (-not $selected.Json.success) { throw "Could not start the Windows restore smoke Core: $($selected.Raw)" } + } + + $beforeStatus = Invoke-WebApiJson -Client $client -Url $url -Path '/api/status' -Token $token + if ($CoreWasRunning) { + if (-not $beforeStatus.Json.data.coreRunning -or $beforeStatus.Json.data.runtimeState -ne 'running') { + throw "Windows restore smoke did not start Core before backup: $($beforeStatus.Raw)" + } + } elseif ($beforeStatus.Json.data.coreRunning -or $beforeStatus.Json.data.runtimeState -ne 'stopped') { + throw "Windows restore smoke expected Core to remain stopped before backup: $($beforeStatus.Raw)" + } + + Download-TestBackup -Client $client -Url $url -Token $token -Path $archivePath + Add-RestoreMarkerToBackup $archivePath + if (Test-Path -LiteralPath $markerPath) { throw 'Restore marker exists before the Windows backup restore.' } + + $restore = Invoke-WebApiJson -Client $client -Url $url -Path '/api/backup/restore' -Method 'POST' -Token $token -UploadPath $archivePath + if ($restore.StatusCode -ne 202 -or -not $restore.Json.success) { + throw "Windows backup restore was not accepted (HTTP $($restore.StatusCode)): $($restore.Raw)" + } + + $health = Wait-TestHealth -Url $url -ExpectedVersion $identity.version -TimeoutSeconds 90 -PreviousProcessId $owner.Id + $replacementProcessId = $health.ProcessId + if ($replacementProcessId -eq $owner.Id) { throw 'Windows backup restore reconnected to the old WebAPI process.' } + if (-not (Get-Process -Id $replacementProcessId -ErrorAction SilentlyContinue)) { + throw "Windows restored WebAPI process $replacementProcessId is not running." + } + if (-not $owner.WaitForExit(20000)) { throw 'The old Windows WebAPI process did not exit after verified restore handoff.' } + if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { throw 'The restored Windows config marker was not installed.' } + if ((Get-Content -LiteralPath $markerPath -Raw) -ne 'RESTORE_CONFIG_APPLIED') { throw 'The restored Windows config marker content is incorrect.' } + + # Backend restart invalidates in-memory sessions; reconnect with the retained local key. + $token = Get-WebApiSessionToken -Client $client -Url $url -Key $key + $afterStatus = Invoke-WebApiJson -Client $client -Url $url -Path '/api/status' -Token $token + $coreIds = @($afterStatus.Json.data.coreProcessIds | Where-Object { $_ }) + if ($CoreWasRunning) { + if (-not $afterStatus.Json.data.coreRunning -or $afterStatus.Json.data.runtimeState -ne 'running' -or $coreIds.Count -eq 0) { + throw "Windows restore did not recover the previously running Core: $($afterStatus.Raw)" + } + Write-Output 'Windows backup restore passed (new healthy Web PID, restored config, previously running Core restarted).' + } else { + if ($afterStatus.Json.data.coreRunning -or $afterStatus.Json.data.runtimeState -ne 'stopped' -or $coreIds.Count -ne 0) { + throw "Windows restore changed the previously stopped Core intent: $($afterStatus.Raw)" + } + Write-Output 'Windows backup restore passed (new healthy Web PID, restored config, previously stopped Core remains stopped).' + } + $success = $true + } finally { + $cleanupError = $null + if ($null -ne $client -and $replacementProcessId -and (Get-Process -Id $replacementProcessId -ErrorAction SilentlyContinue)) { + try { + if ($token) { [void](Invoke-WebApiJson -Client $client -Url $url -Path '/api/core/stop' -Method 'POST' -Token $token) } + } catch { } + try { Stop-Process -Id $replacementProcessId -Force -ErrorAction SilentlyContinue } catch { } + for ($attempt = 0; $attempt -lt 100; $attempt++) { + if (-not (Get-Process -Id $replacementProcessId -ErrorAction SilentlyContinue)) { break } + Start-Sleep -Milliseconds 100 + } + if (Get-Process -Id $replacementProcessId -ErrorAction SilentlyContinue) { + $cleanupError = "Restored Windows WebAPI process $replacementProcessId did not exit during cleanup." + } + } + if ($null -ne $owner) { + try { + if (-not $owner.HasExited) { + try { [void][WebApiWindowsSmokeNative]::GenerateConsoleCtrlEvent(1, [uint32]$owner.Id) } catch { } + if (-not $owner.WaitForExit(5000)) { Stop-Process -Id $owner.Id -Force -ErrorAction SilentlyContinue } + if (-not $owner.WaitForExit(10000)) { + if ($null -eq $cleanupError) { $cleanupError = "Old Windows WebAPI process $($owner.Id) did not exit during cleanup." } + } + } + } catch { try { Stop-Process -Id $owner.Id -Force -ErrorAction SilentlyContinue } catch { } } + $owner.Dispose() + } + if ($null -ne $client) { $client.Dispose() } + if ($cleanupError) { throw $cleanupError } + } +} + +function Invoke-RestoreSmoke { + Invoke-RestoreScenario $false + Invoke-RestoreScenario $true + Write-Output 'Windows native backup restore replacement and Core-intent smoke passed.' +} + +# First startup, health endpoint, first-run data paths and graceful shutdown. +function Invoke-StartupSmoke { + $packageRoot = (Resolve-Path -LiteralPath $PublishDirectory).Path + $sourceExe = Join-Path $packageRoot $executableName + if (-not (Test-Path -LiteralPath $sourceExe -PathType Leaf)) { throw "Missing executable: $sourceExe" } + + $runtimeDir = Join-Path $testRoot 'runtime' + New-Item -ItemType Directory -Path $runtimeDir -Force | Out-Null + Get-ChildItem -LiteralPath $packageRoot -Force | Where-Object { + $_.Name -notin @('README.txt', 'Test-Windows.ps1', '.env') + } | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $runtimeDir -Recurse -Force + } + + Clear-TestEnvironment + # Hosted Windows runners may export the variable with an empty value; the + # backend binds it as a Boolean, so make the first-startup smoke deterministic. + [Environment]::SetEnvironmentVariable('V2RAYN_WEB_AUTOSTART', 'false', 'Process') + $url = "http://127.0.0.1:$(Get-FreeLoopbackPort)" + $webProcess = $null + $client = $null + try { + $exe = Join-Path $runtimeDir $executableName + $webProcess = Start-NativeTestProcess $exe ('--foreground --no-open --urls "{0}"' -f $url) $runtimeDir + $health = Wait-TestHealth -Url $url -Process $webProcess + + $lockPath = Join-Path $runtimeDir 'v2rayN.WebAPI.instance.lock' + $webApiDataDir = Join-Path $runtimeDir 'WebAPIData' + $tempDataDir = Join-Path $runtimeDir 'guiTemps' + foreach ($path in @($lockPath, $webApiDataDir, $tempDataDir)) { + if (-not (Test-Path -LiteralPath $path)) { throw "First-startup data path was not created: $path" } + } + + Stop-NativeTestProcess $webProcess + $webProcess.Refresh() + if (-not $webProcess.HasExited) { throw 'WebAPI process is still running after CTRL_BREAK_EVENT.' } + if (Get-Process -Id $webProcess.Id -ErrorAction SilentlyContinue) { throw 'WebAPI process remains listed after graceful shutdown.' } + + $lockOwner = (Get-Content -LiteralPath $lockPath -Raw).Trim() + if ($lockOwner -ne [string]$webProcess.Id) { throw "Instance lock owner mismatch: $lockOwner" } + + $client = New-NoProxyHttpClient + try { + $response = $client.GetAsync("$url/api/health").GetAwaiter().GetResult() + throw "Health endpoint remained reachable after shutdown (HTTP $([int]$response.StatusCode))." + } catch [System.Net.Http.HttpRequestException] { + # Expected: Kestrel has closed the listener after graceful shutdown. + } + + Write-Output "Windows smoke passed: win self-contained EXE, first-run data paths, $url, CTRL+BREAK graceful shutdown." + Write-Output "Windows: $([Environment]::OSVersion.VersionString); process exited cleanly after CTRL+BREAK." + } finally { + if ($null -ne $webProcess) { + try { + if (-not $webProcess.HasExited) { + try { [void][WebApiWindowsSmokeNative]::GenerateConsoleCtrlEvent(1, [uint32]$webProcess.Id) } catch { } + if (-not $webProcess.WaitForExit(5000)) { Stop-Process -Id $webProcess.Id -Force } + } + } catch { + try { Stop-Process -Id $webProcess.Id -Force -ErrorAction SilentlyContinue } catch { } + } + $webProcess.Dispose() + } + if ($null -ne $client) { $client.Dispose() } + } +} + +# Real helper replacement and health-mismatch rollback with two distinct win-x64 fixtures. +function Invoke-UpdateScenario([bool]$ExpectRollback) { + $previousRoot = (Resolve-Path -LiteralPath $PreviousPublishDirectory).Path + $candidateRoot = (Resolve-Path -LiteralPath $CandidatePublishDirectory).Path + $previousExe = Join-Path $previousRoot $executableName + $candidateExe = Join-Path $candidateRoot $executableName + $previousIdentityPath = Join-Path $previousRoot 'v2rayN.WebAPI.build.json' + $candidateIdentityPath = Join-Path $candidateRoot 'v2rayN.WebAPI.build.json' + foreach ($path in @($previousExe, $candidateExe, $previousIdentityPath, $candidateIdentityPath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing Windows update fixture file: $path" } + } + $previousIdentity = Get-Content -LiteralPath $previousIdentityPath -Raw | ConvertFrom-Json + $candidateIdentity = Get-Content -LiteralPath $candidateIdentityPath -Raw | ConvertFrom-Json + if ($previousIdentity.rid -ne 'win-x64' -or $candidateIdentity.rid -ne 'win-x64') { throw 'Update fixtures must target win-x64.' } + if ($previousIdentity.version -eq $candidateIdentity.version) { throw 'Update fixture versions must differ.' } + + $caseRoot = Join-Path $testRoot ([Guid]::NewGuid().ToString('N')) + $install = Join-Path $caseRoot 'install' + $candidate = Join-Path $caseRoot ('.v2rayn-WebAPI-candidate-' + [Guid]::NewGuid().ToString('N')) + $backup = Join-Path $caseRoot ('.v2rayn-WebAPI-backup-' + [Guid]::NewGuid().ToString('N')) + $core = Join-Path $install 'bin' + $gui = Join-Path $install 'guiConfigs' + $temps = Join-Path $install 'guiTemps' + New-Item -ItemType Directory -Path $install, $candidate, $gui, $temps -Force | Out-Null + Copy-Item -LiteralPath $previousExe -Destination (Join-Path $install $executableName) + Copy-Item -LiteralPath $previousIdentityPath -Destination (Join-Path $install 'v2rayN.WebAPI.build.json') + $previousBin = Join-Path $previousRoot 'bin' + if (-not (Test-Path -LiteralPath $previousBin -PathType Container)) { throw "Previous update fixture is missing Core data: $previousBin" } + Copy-Item -LiteralPath $previousBin -Destination $install -Recurse -Force + Copy-Item -LiteralPath $candidateExe -Destination (Join-Path $candidate $executableName) + Copy-Item -LiteralPath $candidateIdentityPath -Destination (Join-Path $candidate 'v2rayN.WebAPI.build.json') + $previousAppIdentity = Get-Content -LiteralPath (Join-Path $install 'v2rayN.WebAPI.build.json') -Raw | ConvertFrom-Json + $candidateAppIdentity = Get-Content -LiteralPath (Join-Path $candidate 'v2rayN.WebAPI.build.json') -Raw | ConvertFrom-Json + $expectedVersion = if ($ExpectRollback) { '0.0.999' } else { $candidateAppIdentity.version } + if ($ExpectRollback) { + $candidateAppIdentity.version = $expectedVersion + Write-TestJson (Join-Path $candidate 'v2rayN.WebAPI.build.json') $candidateAppIdentity + } + + $key = 'Windows-update-' + [Guid]::NewGuid().ToString('N') + $envPath = Join-Path $install '.env' + [IO.File]::WriteAllText($envPath, "V2RAYN_WEB_API_KEY=$key`n", [Text.UTF8Encoding]::new($false)) + $userConfigMarker = Join-Path $gui 'web-update-test-marker.dat' + [IO.File]::WriteAllText($userConfigMarker, 'USER_CONFIG_KEEP') + [IO.File]::WriteAllText((Join-Path $core 'web-update-test-marker.dat'), 'CORE_FILES_KEEP') + $envHash = (Get-FileHash -LiteralPath $envPath -Algorithm SHA256).Hash + $configHash = (Get-FileHash -LiteralPath $userConfigMarker -Algorithm SHA256).Hash + $coreHash = (Get-FileHash -LiteralPath (Join-Path $core 'web-update-test-marker.dat') -Algorithm SHA256).Hash + + $url = "http://127.0.0.1:$(Get-FreeLoopbackPort)" + $bundleRoot = Join-Path $caseRoot 'bundle' + New-Item -ItemType Directory -Path $bundleRoot -Force | Out-Null + $owner = $null + $helper = $null + $updatedProcessId = $null + try { + Set-UpdateEnvironment $url $key $bundleRoot + try { + $exe = Join-Path $install $executableName + $owner = Start-NativeTestProcess $exe ('--foreground --no-open --urls "{0}"' -f $url) $install + } finally { + Restore-TestEnvironment + } + $previousHealth = Wait-TestHealth -Url $url -ExpectedVersion $previousAppIdentity.version + if ($previousHealth.ProcessId -ne $owner.Id) { throw 'Previous Windows WebAPI lock/health PID mismatch.' } + + $runtimeIntentPath = Join-Path $temps 'WebAPI-update-runtime-state.json' + $progressPath = Join-Path $temps 'WebAPI-update-progress.json' + $planPath = Join-Path $temps 'windows-update-plan.json' + Write-TestJson $runtimeIntentPath @{ wasRunning = $false; preferredProfileId = $null; reason = 'Windows update smoke' } + Write-TestJson $planPath @{ + installDirectory = $install + candidateDirectory = $candidate + backupDirectory = $backup + instanceLockPath = (Join-Path $install 'v2rayN.WebAPI.instance.lock') + healthUri = "$url/api/health" + hostArguments = @('--urls', $url) + expectedVersion = $expectedVersion + expectedCommit = $candidateAppIdentity.commit + rid = 'win-x64' + previousVersion = $previousAppIdentity.version + runtimeIntentPath = $runtimeIntentPath + progressPath = $progressPath + coreWasRunning = $false + } + + $helperPath = Join-Path $install ('.v2rayn-WebAPI-update-helper-' + [Guid]::NewGuid().ToString('N') + '.exe') + Copy-Item -LiteralPath (Join-Path $install $executableName) -Destination $helperPath + Set-UpdateEnvironment $url $key $bundleRoot + try { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $helperPath + $startInfo.WorkingDirectory = $install + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.Arguments = '"--apply-WebAPI-update" "{0}"' -f $planPath.Replace('"', '\"') + $helperProcess = [Diagnostics.Process]::Start($startInfo) + $helperOutput = $helperProcess.StandardOutput.ReadToEndAsync() + $helperError = $helperProcess.StandardError.ReadToEndAsync() + } finally { + Restore-TestEnvironment + } + $helper = $helperProcess + + Stop-NativeTestProcess $owner + $observedHealth = [System.Collections.Generic.List[string]]::new() + $healthClient = New-NoProxyHttpClient + try { + $helperDeadline = [DateTime]::UtcNow.AddSeconds(180) + while (-not $helper.HasExited -and [DateTime]::UtcNow -lt $helperDeadline) { + try { + $response = $healthClient.GetAsync("$url/api/health").GetAwaiter().GetResult() + $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + if ([int]$response.StatusCode -eq 200) { + $version = $response.Headers.GetValues('X-v2rayn-WebAPI-version') | Select-Object -First 1 + $instanceOwner = $response.Headers.GetValues('X-v2rayn-WebAPI-instance-pid') | Select-Object -First 1 + $coreState = $response.Headers.GetValues('X-v2rayn-WebAPI-core-state') | Select-Object -First 1 + $corePids = $response.Headers.GetValues('X-v2rayn-WebAPI-core-process-ids') | Select-Object -First 1 + $snapshot = "version=$version; pid=$instanceOwner; coreState=$coreState; corePids=$corePids; runtimeIntent=$(Test-Path -LiteralPath $runtimeIntentPath)" + if ($observedHealth.Count -eq 0 -or $observedHealth[$observedHealth.Count - 1] -ne $snapshot) { + if ($observedHealth.Count -lt 20) { $observedHealth.Add($snapshot) } + } + } + $response.Dispose() + } catch { } + Start-Sleep -Milliseconds 200 + } + if (-not $helper.HasExited) { throw 'Windows native update helper timed out.' } + } finally { + $healthClient.Dispose() + } + $expectedExit = if ($ExpectRollback) { 1 } else { 0 } + $helperOutputText = $helperOutput.GetAwaiter().GetResult() + $helperErrorText = $helperError.GetAwaiter().GetResult() + if ($helper.ExitCode -ne $expectedExit) { + $progressDump = if (Test-Path -LiteralPath $progressPath) { Get-Content -LiteralPath $progressPath -Raw } else { '' } + throw "Windows update helper exit $($helper.ExitCode), expected $expectedExit.`nhealth observations:`n$($observedHealth -join "`n")`nprogress:`n$progressDump`n$helperOutputText`n$helperErrorText" + } + + $expectedHealthVersion = if ($ExpectRollback) { $previousAppIdentity.version } else { $candidateAppIdentity.version } + $health = Wait-TestHealth -Url $url -ExpectedVersion $expectedHealthVersion + $updatedProcessId = $health.ProcessId + $progress = Get-Content -LiteralPath $progressPath -Raw | ConvertFrom-Json + if (-not $progress.isComplete -or $progress.success -eq $ExpectRollback) { throw "Unexpected Windows update progress: $($progress | ConvertTo-Json -Compress)" } + if ($ExpectRollback -and -not $progress.rollbackSucceeded) { throw 'Windows update rollback was not confirmed.' } + + $expectedExe = if ($ExpectRollback) { $previousExe } else { $candidateExe } + if ((Get-FileHash -LiteralPath (Join-Path $install $executableName) -Algorithm SHA256).Hash -ne + (Get-FileHash -LiteralPath $expectedExe -Algorithm SHA256).Hash) { throw 'Windows update executable content is incorrect.' } + $installedIdentity = Get-Content -LiteralPath (Join-Path $install 'v2rayN.WebAPI.build.json') -Raw | ConvertFrom-Json + if ($installedIdentity.version -ne $expectedHealthVersion) { throw 'Windows update build identity is incorrect.' } + if ((Get-FileHash -LiteralPath $envPath -Algorithm SHA256).Hash -ne $envHash) { throw 'Windows update changed .env.' } + if ((Get-FileHash -LiteralPath $userConfigMarker -Algorithm SHA256).Hash -ne $configHash) { throw 'Windows update changed user config.' } + if ((Get-FileHash -LiteralPath (Join-Path $core 'web-update-test-marker.dat') -Algorithm SHA256).Hash -ne $coreHash) { throw 'Windows update changed Core files.' } + if (Test-Path -LiteralPath $backup) { throw 'Windows update did not clean its backup directory.' } + if (Test-Path -LiteralPath $candidate) { throw 'Windows update did not clean its candidate directory.' } + $helperDeadline = [DateTime]::UtcNow.AddSeconds(10) + while ((Test-Path -LiteralPath $helperPath) -and [DateTime]::UtcNow -lt $helperDeadline) { Start-Sleep -Milliseconds 200 } + if (Test-Path -LiteralPath $helperPath) { throw 'Windows update helper copy was not cleaned.' } + if ($ExpectRollback) { Write-Output 'Windows native update rollback passed.' } + else { Write-Output 'Windows native update replacement passed.' } + } finally { + foreach ($processId in @($updatedProcessId, $(if ($owner) { $owner.Id } else { $null }))) { + if ($processId) { Stop-TestProcessAndWait -ProcessId $processId } + } + if ($helper) { $helper.Dispose() } + if ($owner) { $owner.Dispose() } + } +} + +try { + if ($Mode -eq 'startup') { + Invoke-StartupSmoke + } elseif ($Mode -eq 'restore') { + Invoke-RestoreSmoke + } else { + Invoke-UpdateScenario $false | Out-Null + Invoke-UpdateScenario $true | Out-Null + Write-Output 'Windows native self-update replacement and rollback smoke passed.' + } + $success = $true +} finally { + Restore-TestEnvironment + if ($success) { + $removed = $false + $cleanupError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction Stop + } + if (-not (Test-Path -LiteralPath $testRoot)) { + $removed = $true + break + } + throw 'The isolated Windows smoke directory still exists.' + } catch { + $cleanupError = $_.Exception.Message + if ($attempt -lt 19) { Start-Sleep -Milliseconds 500 } + } + } + if (-not $removed) { + Write-Warning "Windows $Mode smoke passed, but isolated temp cleanup was deferred at $testRoot : $cleanupError" + } + } else { + Write-Warning "Windows $Mode test failed; isolated files were retained at $testRoot" + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-backup-restore-preflight.sh b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-backup-restore-preflight.sh new file mode 100644 index 00000000..3dd13eac --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-backup-restore-preflight.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +set -euo pipefail + +executable="$(realpath "${1:?Usage: test-backup-restore-preflight.sh }")" +script_path="$(realpath "${BASH_SOURCE[0]}")" +test -x "$executable" +command -v curl >/dev/null +command -v python3 >/dev/null +command -v openssl >/dev/null + +# A service-based Linux runner may keep this test in system.slice even after its +# environment markers are cleared. Move the entire test (and the WebAPI children +# it starts) into a transient scope so production supervisor detection stays intact. +if [[ "${V2RAYN_RESTORE_PREFLIGHT_TEST_SCOPE:-}" != "1" ]]; then + scope_environment=(V2RAYN_RESTORE_PREFLIGHT_TEST_SCOPE=1) + if [[ -n "${TMPDIR:-}" ]]; then scope_environment+=("TMPDIR=$TMPDIR"); fi + if command -v systemd-run >/dev/null && systemd-run --user --scope --quiet /usr/bin/true >/dev/null 2>&1; then + exec systemd-run --user --scope --quiet /usr/bin/env \ + "${scope_environment[@]}" /usr/bin/bash "$script_path" "$executable" + elif command -v sudo >/dev/null && sudo -n systemd-run --scope --quiet /usr/bin/true >/dev/null 2>&1; then + runuser_bin="$(command -v runuser)" + exec sudo -n systemd-run --scope --quiet "$runuser_bin" --preserve-environment -u "$(id -un)" -- \ + /usr/bin/env "${scope_environment[@]}" /usr/bin/bash "$script_path" "$executable" + elif grep -Eq 'system\.slice/.*\.service' /proc/self/cgroup; then + echo 'Backup restore preflight test is in a systemd service cgroup, but no transient scope is available.' >&2 + exit 1 + fi +fi + +temporary="$(mktemp -d "${TMPDIR:-/tmp}/v2rayn-backup-restore-preflight.XXXXXX")" +app_pid="" +cleanup() { + if [[ -n "$app_pid" ]] && kill -0 "$app_pid" 2>/dev/null; then + kill -TERM "$app_pid" 2>/dev/null || true + for _ in {1..40}; do + kill -0 "$app_pid" 2>/dev/null || break + sleep 0.1 + done + kill -KILL "$app_pid" 2>/dev/null || true + wait "$app_pid" 2>/dev/null || true + fi + rm -rf "$temporary" +} +trap cleanup EXIT HUP INT TERM + +port() { + python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +lan_address() { + python3 - <<'PY' +import socket +with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("192.0.2.1", 9)) + print(sock.getsockname()[0]) +PY +} + +config_fingerprint() { + python3 - "$1" <<'PY' +import hashlib +import sys +from pathlib import Path +root = Path(sys.argv[1]) +for path in sorted(p for p in root.rglob("*") if p.is_file()): + print(hashlib.sha256(path.read_bytes()).hexdigest(), path.relative_to(root).as_posix()) +PY +} + +run_refusal_case() { + local name="$1" listener="$2" scheme="$3" expected_key="$4" missing_setsid="$5" + local case_root="$temporary/$name" data_home="$temporary/$name-data" key url pid lock_path token + local curl_tls=() process_env=() + mkdir -p "$case_root" "$data_home" + key="restore-preflight-$name-$(python3 -c 'import secrets; print(secrets.token_hex(12))')" + local listen_port + listen_port="$(port)" + if [[ "$scheme" == https ]]; then + url="https://127.0.0.1:$listen_port" + curl_tls=(--insecure) + openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -subj /CN=localhost \ + -keyout "$case_root/key.pem" -out "$case_root/cert.pem" >/dev/null 2>&1 + openssl pkcs12 -export -out "$case_root/cert.pfx" \ + -inkey "$case_root/key.pem" -in "$case_root/cert.pem" \ + -passout pass:restore-smoke >/dev/null 2>&1 + process_env+=( + "Kestrel__Endpoints__Https__Url=https://127.0.0.1:$listen_port" + "Kestrel__Endpoints__Https__Certificate__Path=$case_root/cert.pfx" + 'Kestrel__Endpoints__Https__Certificate__Password=restore-smoke' + ) + else + url="http://$listener:$listen_port" + process_env+=("ASPNETCORE_URLS=$url") + fi + if [[ "$missing_setsid" == true ]]; then + mkdir -p "$case_root/empty-path" + process_env+=("PATH=$case_root/empty-path") + fi + + env \ + -u ASPNETCORE_URLS -u ASPNETCORE_HTTP_PORTS -u ASPNETCORE_HTTPS_PORTS \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "V2RAYN_WEB_API_KEY=$key" \ + V2RAYN_WEB_AUTOSTART=false \ + V2RAYN_WEB_INTERACTIVE_LAUNCH=1 \ + "V2RAYN_DATA_HOME=$data_home" \ + "DOTNET_BUNDLE_EXTRACT_BASE_DIR=$case_root/dotnet-bundle" \ + "${process_env[@]}" \ + "$executable" --foreground --no-open >"$case_root/runtime.log" 2>&1 & + app_pid=$! + + local ready=false + for _ in {1..100}; do + if ! kill -0 "$app_pid" 2>/dev/null; then + cat "$case_root/runtime.log" >&2 + echo "$name WebAPI exited before health became available." >&2 + return 1 + fi + if curl --noproxy '*' "${curl_tls[@]}" --silent --show-error --fail \ + -D "$case_root/health.headers" \ + "$url/api/health" -o "$case_root/health.json" 2>/dev/null; then + ready=true + break + fi + sleep 0.2 + done + [[ "$ready" == true ]] || { cat "$case_root/runtime.log" >&2; echo "$name WebAPI failed to become healthy." >&2; return 1; } + + curl --noproxy '*' "${curl_tls[@]}" --silent --show-error --fail \ + -H 'Content-Type: application/json' \ + --data "{\"key\":\"$key\"}" \ + "$url/api/auth/login" >"$case_root/login.json" + token="$(python3 - "$case_root/login.json" <<'PY' +import json, sys +response = json.load(open(sys.argv[1], encoding="utf-8")) +assert response["success"] is True, response +print(response["data"]["token"]) +PY +)" + + curl --noproxy '*' "${curl_tls[@]}" --silent --show-error --fail \ + -H "Authorization: Bearer $token" \ + "$url/api/backup/download" -o "$case_root/backup.zip" + python3 - "$case_root/backup.zip" <<'PY' +from pathlib import Path +import sys +import zipfile +path = Path(sys.argv[1]) +with zipfile.ZipFile(path, "a", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("guiConfigs/restore-preflight-marker.txt", "must-not-be-installed") +PY + + local config_directory="$data_home/v2rayN/guiConfigs" + test -s "$config_directory/guiNConfig.json" + local before_config="$case_root/config.before" after_config="$case_root/config.after" + local lock_path="$data_home/v2rayN/v2rayN.WebAPI.instance.lock" + local owner_pid + owner_pid="$(tr -d '\r\n' <"$lock_path")" + [[ "$owner_pid" == "$app_pid" ]] || { + echo "$name instance lock PID does not match the launched process." >&2 + return 1 + } + curl --noproxy '*' "${curl_tls[@]}" --silent --show-error \ + -H "Authorization: Bearer $token" \ + "$url/api/status" >"$case_root/status-before.json" + python3 - "$case_root/status-before.json" <<'PY' +import json, sys +status = json.load(open(sys.argv[1], encoding="utf-8"))["data"] +assert status["runtimeState"] == "stopped", status +assert status["coreRunning"] is False, status +assert not status["coreProcessIds"], status +PY + config_fingerprint "$config_directory" >"$before_config" + python3 - "$case_root/health.json" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +assert data == {"status": "ok"}, data +PY + test ! -e "$config_directory/restore-preflight-marker.txt" + + local status response_key + status="$(curl --noproxy '*' "${curl_tls[@]}" --silent --show-error \ + -o "$case_root/restore.json" -w '%{http_code}' \ + -H "Authorization: Bearer $token" \ + -F "file=@$case_root/backup.zip;type=application/zip" \ + "$url/api/backup/restore")" + response_key="$(python3 - "$case_root/restore.json" <<'PY' +import json, sys +response = json.load(open(sys.argv[1], encoding="utf-8")) +assert response["success"] is False, response +print(response["messageKey"]) +PY +)" + [[ "$response_key" == "$expected_key" ]] || { + cat "$case_root/restore.json" >&2 + echo "$name restore refusal returned $response_key; expected $expected_key." >&2 + return 1 + } + + kill -0 "$app_pid" + config_fingerprint "$config_directory" >"$after_config" + cmp "$before_config" "$after_config" + test ! -e "$config_directory/restore-preflight-marker.txt" + test ! -e "$data_home/v2rayN/v2rayn-WebAPI-restore-state.json" + test "$(tr -d '\r\n' <"$lock_path")" = "$app_pid" + curl --noproxy '*' "${curl_tls[@]}" --silent --show-error --fail \ + -D "$case_root/health-after.headers" -o "$case_root/health-after.json" \ + "$url/api/health" + python3 - "$case_root/health-after.json" "$case_root/status-before.json" <<'PY' +import json, sys +assert json.load(open(sys.argv[1], encoding="utf-8")) == {"status": "ok"} +status = json.load(open(sys.argv[2], encoding="utf-8"))["data"] +assert status["runtimeState"] == "stopped" and not status["coreProcessIds"], status +PY + [[ "$(tr -d '\r\n' <"$lock_path")" == "$app_pid" ]] || { echo "$name refusal changed the healthy instance PID." >&2; return 1; } + printf '%s listener refusal passed (messageKey=%s; config, Core state, owner PID and health unchanged).\n' "$name" "$response_key" + + kill -TERM "$app_pid" + for _ in {1..100}; do + kill -0 "$app_pid" 2>/dev/null || break + sleep 0.1 + done + if kill -0 "$app_pid" 2>/dev/null; then + cat "$case_root/runtime.log" >&2 + echo "$name WebAPI did not stop during cleanup." >&2 + return 1 + fi + wait "$app_pid" || true + app_pid="" +} + +https_port="$(port)" +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -subj /CN=localhost \ + -keyout "$temporary/key.pem" -out "$temporary/cert.pem" >/dev/null 2>&1 +openssl pkcs12 -export -out "$temporary/cert.pfx" \ + -inkey "$temporary/key.pem" -in "$temporary/cert.pem" \ + -passout pass:restore-smoke >/dev/null 2>&1 + +run_refusal_case \ + https-only "https://127.0.0.1:$https_port" https \ + backup.restoreProbeUnavailable false +run_refusal_case \ + explicit-lan "$(lan_address)" http \ + backup.restoreProbeUnavailable false +run_refusal_case \ + missing-setsid 127.0.0.1 http \ + backup.restoreSetSidUnavailable true + +printf 'Linux backup-restore preflight refusal tests passed.\n' diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh new file mode 100644 index 00000000..bb7592d0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +set -euo pipefail + +archive="${1:?Usage: test-full-zip-runtime.sh }" +archive="$(realpath "$archive")" +test -s "$archive" +command -v curl >/dev/null +command -v python3 >/dev/null +command -v unzip >/dev/null + +temporary="$(mktemp -d)" +web_pid="" +cleanup() { + if [[ -n "$web_pid" ]] && kill -0 "$web_pid" 2>/dev/null; then + kill -TERM "$web_pid" 2>/dev/null || true + for _ in {1..20}; do + state="$(ps -o stat= -p "$web_pid" 2>/dev/null | tr -d ' ' || true)" + [[ -z "$state" || "$state" == *Z* ]] && break + sleep 0.1 + done + kill -KILL "$web_pid" 2>/dev/null || true + wait "$web_pid" 2>/dev/null || true + fi + rm -rf "$temporary" +} +trap cleanup EXIT HUP INT TERM + +install_dir="$temporary/install" +data_home="$temporary/data" +mkdir -p "$install_dir" "$data_home" "$temporary/dotnet-bundle" "$temporary/other-working-directory" +unzip -q "$archive" -d "$install_dir" +executable="$install_dir/v2rayN.WebAPI" +test -x "$executable" +test -s "$install_dir/.env.example" +expected_version="$(python3 - "$archive" <<'PY' +import json +import sys +import zipfile + +with zipfile.ZipFile(sys.argv[1]) as package: + identity = json.loads(package.read("v2rayN.WebAPI.build.json")) +assert identity["product"] == "v2rayN.WebAPI", identity +assert identity["rid"] == "linux-x64", identity +print(identity["version"]) +PY +)" + +port="$(python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +management_key="web-zip-smoke-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" +cp "$install_dir/.env.example" "$install_dir/.env" +python3 - "$install_dir/.env" "$management_key" "$port" <<'PY' +import sys +from pathlib import Path + +path, key, port = sys.argv[1:] +content = Path(path).read_text(encoding="utf-8") +replacements = { + "V2RAYN_WEB_API_KEY=": f"V2RAYN_WEB_API_KEY={key}", + "ASPNETCORE_URLS=http://127.0.0.1:5080": f"ASPNETCORE_URLS=http://127.0.0.1:{port}", + "V2RAYN_WEB_AUTOSTART=true": "V2RAYN_WEB_AUTOSTART=false", +} +for original, replacement in replacements.items(): + assert content.count(original) == 1, original + content = content.replace(original, replacement, 1) +Path(path).write_text(content, encoding="utf-8") +PY +chmod 600 "$install_dir/.env" + +( + cd "$temporary/other-working-directory" + exec env \ + -u V2RAYN_WEB_API_KEY \ + -u V2RAYN_WEB_AUTOSTART \ + -u ASPNETCORE_URLS \ + -u ASPNETCORE_HTTP_PORTS \ + V2RAYN_DATA_HOME="$data_home" \ + DOTNET_BUNDLE_EXTRACT_BASE_DIR="$temporary/dotnet-bundle" \ + "$executable" --foreground --no-open +) >"$temporary/web.log" 2>&1 & +web_pid=$! +health_url="http://127.0.0.1:$port/api/health" +healthy=false +for _ in {1..90}; do + if ! kill -0 "$web_pid" 2>/dev/null; then + cat "$temporary/web.log" >&2 + echo "Web process exited before /api/health became ready." >&2 + exit 1 + fi + if curl --noproxy '*' --silent --show-error --fail "$health_url" \ + -D "$temporary/health.headers" -o "$temporary/health.json" 2>/dev/null \ + && python3 - "$temporary/health.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +assert response == {"status": "ok"}, response +PY + then + healthy=true + break + fi + sleep 1 +done +if [[ "$healthy" != true ]]; then + cat "$temporary/web.log" >&2 + echo "Full ZIP did not become healthy within 90 seconds." >&2 + exit 1 +fi +test "$(curl --noproxy '*' --silent --show-error -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/")" = 404 +grep -Fiq 'X-v2rayn-WebAPI-instance-pid:' "$temporary/health.headers" +grep -Fiq "X-v2rayn-WebAPI-version: $expected_version" "$temporary/health.headers" +grep -Fiq 'X-v2rayn-WebAPI-core-state: stopped' "$temporary/health.headers" +grep -Fiq 'X-v2rayn-WebAPI-core-process-ids:' "$temporary/health.headers" + +login_body="$(python3 -c 'import json,sys; print(json.dumps({"key": sys.argv[1]}))' "$management_key")" +curl --noproxy '*' --silent --show-error --fail \ + -H 'Content-Type: application/json' \ + --data "$login_body" \ + "http://127.0.0.1:$port/api/auth/login" >"$temporary/login.json" +session_token="$(python3 - "$temporary/login.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +assert response["success"] is True, response +print(response["data"]["token"]) +PY +)" +curl --noproxy '*' --silent --show-error --fail \ + -H "Authorization: Bearer $session_token" \ + "http://127.0.0.1:$port/api/status" >"$temporary/status.json" +python3 - "$temporary/status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +status = response["data"] +assert response["success"] is True, response +assert status["coreRunning"] is False, status +assert status["runtimeState"] == "stopped", status +assert not status["coreProcessIds"], status +PY + +kill -TERM "$web_pid" +stopped=false +for _ in {1..120}; do + state="$(ps -o stat= -p "$web_pid" 2>/dev/null | tr -d ' ' || true)" + if [[ -z "$state" || "$state" == *Z* ]]; then + stopped=true + break + fi + sleep 0.25 +done +if [[ "$stopped" != true ]]; then + echo "Web process did not shut down after SIGTERM." >&2 + cat "$temporary/web.log" >&2 + exit 1 +fi +set +e +wait "$web_pid" +exit_code=$? +set -e +web_pid="" +if [[ "$exit_code" -ne 0 ]]; then + echo "Web process exited with status $exit_code during graceful shutdown." >&2 + cat "$temporary/web.log" >&2 + exit 1 +fi + +python3 - "$install_dir" "$data_home" <<'PY' +import os +import sys + +install, data_home = map(os.path.realpath, sys.argv[1:]) +expected = { + os.path.join(install, "v2rayN.WebAPI"), + os.path.join(install, "bin", "xray", "xray"), + os.path.join(install, "bin", "sing_box", "sing-box"), + os.path.join(install, "bin", "mihomo", "mihomo"), + os.path.join(data_home, "v2rayN", "bin", "xray", "xray"), + os.path.join(data_home, "v2rayN", "bin", "sing_box", "sing-box"), + os.path.join(data_home, "v2rayN", "bin", "mihomo", "mihomo"), +} +expected = {os.path.realpath(path) for path in expected if os.path.exists(path)} +residual = [] +for entry in os.scandir("/proc"): + if not entry.name.isdigit(): + continue + try: + executable = os.path.realpath(f"/proc/{entry.name}/exe") + except (OSError, PermissionError): + continue + if executable in expected: + residual.append((entry.name, executable)) +assert not residual, f"Residual Web/Core processes remain: {residual}" +PY + +echo "Full ZIP runtime smoke passed (healthy API, authenticated stopped Core, graceful shutdown, no residual Web/Core processes)." diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-identity.py b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-identity.py new file mode 100755 index 00000000..8ec6fd05 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-identity.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Real native WebAPI helper replacement/rollback smoke; uses isolated data only. + +Usage: test-native-update-identity.py +The two artifacts must carry distinct versions in v2rayN.WebAPI.build.json. +""" +import hashlib +import json +import os +from pathlib import Path +import secrets +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request + +NAME = "v2rayN.WebAPI" +opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def fingerprint(directory): + return {str(p.relative_to(directory)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in directory.rglob("*") if p.is_file()} + + +def health(url): + try: + with opener.open(url + "/api/health", timeout=1) as response: + return dict(response.headers) + except (OSError, urllib.error.URLError): + return {} + + +def wait_health(url, version): + for _ in range(300): + headers = health(url) + if headers.get("X-v2rayn-WebAPI-version") == version: + return headers + time.sleep(0.1) + raise AssertionError("native instance failed its version health check") + + +def run_scenario(root, previous, candidate, rollback, reject_invalid=False, listen_host="127.0.0.1"): + root.mkdir() + install = root / "install" + install.mkdir() + shutil.copy2(previous / NAME, install / NAME) + shutil.copy2(previous / (NAME + ".build.json"), install / (NAME + ".build.json")) + before = json.loads((previous / (NAME + ".build.json")).read_text()) + after = json.loads((candidate / (NAME + ".build.json")).read_text()) + assert before["product"] == after["product"] == NAME + assert before["version"] != after["version"] + if rollback: + # Keep a real native executable. Force a version-health mismatch rather + # than bypassing package validation with a synthetic shell executable. + after["version"] = "0.0.999-review" + data = root / "data" + scope = data / "v2rayN" + temps = scope / "guiTemps" + temps.mkdir(parents=True) + (install / "extra").mkdir() + (install / "extra" / "marker.dat").write_text("unrelated files must survive") + (scope / "bin").mkdir() + (scope / "bin" / "marker").write_text("Core files must survive") + (install / "bin").mkdir() + (install / "bin" / ".keep").touch() # Full-install package requires a bin/ root. + family = socket.AF_INET6 if ":" in listen_host else socket.AF_INET + with socket.socket(family) as sock: + sock.bind((listen_host, 0)) + port = sock.getsockname()[1] + url = f"http://[{listen_host}]:{port}" if ":" in listen_host else f"http://{listen_host}:{port}" + env = {**os.environ, "V2RAYN_WEB_API_KEY": secrets.token_urlsafe(32), + "V2RAYN_DATA_HOME": str(data), "XDG_DATA_HOME": str(data), + "V2RAYN_LOCAL_APPLICATION_DATA_V2": "1", "ASPNETCORE_URLS": url, + "V2RAYN_WEB_AUTOSTART": "false", + "DOTNET_BUNDLE_EXTRACT_BASE_DIR": str(root / ".net-bundle")} + for key in ("INVOCATION_ID", "JOURNAL_STREAM", "container", "DOTNET_RUNNING_IN_CONTAINER"): + env.pop(key, None) + log = (root / "runtime.log").open("wb") + owner = subprocess.Popen([str(install / NAME), "--foreground", "--no-open"], env=env, stdout=log, stderr=log) + try: + wait_health(url, before["version"]) + preserved = {str(p): fingerprint(p) for p in (install / "extra", install / "bin", scope / "bin")} + staged = root / (".v2rayn-WebAPI-candidate-" + secrets.token_hex(8)) + backup = root / (".v2rayn-WebAPI-backup-" + secrets.token_hex(8)) + staged.mkdir() + (staged / (NAME + ".build.json")).write_text(json.dumps(after)) + shutil.copy2(candidate / NAME, staged / NAME) + if reject_invalid: + (staged / NAME).write_text("#!/bin/sh\nexit 1\n") + intent = temps / "WebAPI-update-runtime-state.json" + progress = temps / "WebAPI-update-progress.json" + intent.write_text(json.dumps({"wasRunning": False, "profileId": None})) + plan_path = temps / "identity-update-plan.json" + plan_path.write_text(json.dumps({ + "installDirectory": str(install), "candidateDirectory": str(staged), "backupDirectory": str(backup), + "instanceLockPath": str(scope / (NAME + ".instance.lock")), "healthUri": url + "/api/health", + "hostArguments": ["--urls", url], "expectedVersion": after["version"], "expectedCommit": after["commit"], + "rid": after["rid"], "previousVersion": before["version"], + "runtimeIntentPath": str(intent), "progressPath": str(progress), "coreWasRunning": False, + })) + plan_path.chmod(0o600) + if reject_invalid: + owner.send_signal(signal.SIGTERM) + owner.wait(timeout=40) + helper = subprocess.Popen([str(install / NAME), "--apply-WebAPI-update", str(plan_path)], env=env, stdout=log, stderr=log) + if not reject_invalid: + owner.send_signal(signal.SIGTERM) + owner.wait(timeout=40) + helper_code = helper.wait(timeout=100) + assert not list(install.glob(".v2rayn-WebAPI-update-helper-*")), "Isolated helper bundle was not cleaned after worker exit" + failed = rollback or reject_invalid + expected_helper_code = 1 if failed else 0 + if helper_code != expected_helper_code: + log.flush() + runtime_log = (root / "runtime.log").read_text(errors="replace") + progress_state = progress.read_text(errors="replace") if progress.exists() else "" + raise AssertionError( + f"helper returned {helper_code}, expected {expected_helper_code};\n" + f"runtime.log:\n{runtime_log}\nupdate progress:\n{progress_state}" + ) + wait_health(url, before["version"] if failed else after["version"]) + if failed: + # The old build must be restored and running, not merely copied back: + # health was already polled above and must come from a replacement process. + running = health(url) + assert int(running["X-v2rayn-WebAPI-instance-pid"]) != owner.pid, \ + "the restored build must be a running replacement process" + result = json.loads(progress.read_text()) + assert result["isComplete"] and result["success"] == (not failed) + if failed: + assert result["rollbackSucceeded"] + expected = previous if failed else candidate + assert (install / NAME).read_bytes() == (expected / NAME).read_bytes() + assert not (install / "v2rayN.Web").exists() + for directory, snapshot in preserved.items(): + assert fingerprint(Path(directory)) == snapshot + assert not backup.exists() and not intent.exists() + label = "invalid executable rejection" if reject_invalid else "rollback" if rollback else "replacement" + suffix = "" if listen_host == "127.0.0.1" else f" ({listen_host})" + print("Native WebAPI " + label + suffix + " passed (version/PID/lock health, atomic executable swap, unrelated file/Core preservation).") + finally: + if owner.poll() is None: + owner.send_signal(signal.SIGTERM) + owner.wait(timeout=40) + if health(url): + subprocess.run([str(install / NAME), "--stop", "--urls", url], env=env, stdout=log, stderr=log, check=True, timeout=40) + log.close() + + +def primary_ipv4(): + # Documentation-range UDP connect selects the primary interface without + # sending any packets; UDP "connect" only resolves the local address. + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("192.0.2.1", 9)) + address = sock.getsockname()[0] + return None if address.startswith("127.") else address + except OSError: + return None + + +def ipv6_loopback_available(): + if not socket.has_ipv6: + return False + try: + with socket.socket(socket.AF_INET6) as sock: + sock.bind(("::1", 0)) + return True + except OSError: + return False + + +def run_refusal_scenario(root, previous, host, label): + """An instance whose listener cannot be verified through the shared loopback probe + rule must refuse self-updates and keep serving instead of stopping itself for an + unverifiable replacement.""" + root.mkdir() + install = root / "install" + install.mkdir() + shutil.copy2(previous / NAME, install / NAME) + shutil.copy2(previous / (NAME + ".build.json"), install / (NAME + ".build.json")) + data = root / "data" + scope = data / "v2rayN" + temps = scope / "guiTemps" + temps.mkdir(parents=True) + (install / "bin").mkdir() + (install / "bin" / ".keep").touch() + with socket.socket() as sock: + sock.bind((host, 0)) + port = sock.getsockname()[1] + url = f"http://{host}:{port}" + key = secrets.token_urlsafe(32) + env = {**os.environ, "V2RAYN_WEB_API_KEY": key, "V2RAYN_DATA_HOME": str(data), "XDG_DATA_HOME": str(data), + "V2RAYN_LOCAL_APPLICATION_DATA_V2": "1", "ASPNETCORE_URLS": url, + "V2RAYN_WEB_AUTOSTART": "false", + "DOTNET_BUNDLE_EXTRACT_BASE_DIR": str(root / ".net-bundle")} + for entry in ("INVOCATION_ID", "JOURNAL_STREAM", "container", "DOTNET_RUNNING_IN_CONTAINER"): + env.pop(entry, None) + log = (root / "runtime.log").open("wb") + owner = subprocess.Popen([str(install / NAME), "--foreground", "--no-open"], env=env, stdout=log, stderr=log) + try: + # The health diagnostics are loopback-only, so a non-loopback listener only + # guarantees a successful response body here. + for _ in range(300): + if health(url): + break + time.sleep(0.1) + else: + raise AssertionError("the non-loopback instance did not become ready") + + def login(): + request = urllib.request.Request( + url + "/api/auth/login", method="POST", + data=json.dumps({"key": key}).encode(), + headers={"Content-Type": "application/json"}) + with opener.open(request, timeout=5) as response: + body = json.load(response) + assert body["success"] is True, body + return body["data"]["token"] + + token = login() + + def api(path, method="GET", expect=200): + request = urllib.request.Request( + url + path, method=method, + headers={"Authorization": f"Bearer {token}"}) + try: + with opener.open(request, timeout=10) as response: + status = response.status + body = json.load(response) + except urllib.error.HTTPError as error: + status = error.code + with error: + body = json.load(error) + assert status == expect, (status, body) + return body + + target = api("/api/web-updates") + assert target["success"] is True, target + view = target["data"] + assert view["canInstall"] is False, view + assert view["installReasonKey"] == "maintenance.webUpdateProbeUnavailable", view + + # A refused install answers 409 Conflict with the reason view. + refusal = api("/api/web-updates/update", method="POST", expect=409) + assert refusal["success"] is False, refusal + assert refusal["code"] == "web_update_runtime_unavailable", refusal + assert refusal["messageKey"] == "maintenance.webUpdateProbeUnavailable", refusal + + # The same unprobeable configuration must also make --stop refuse to signal + # instead of guessing a loopback address for owner verification. + stop = subprocess.run( + [str(install / NAME), "--stop", "--urls", url], + env={**env, "LC_ALL": "C", "LANG": "C"}, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=40) + assert stop.returncode != 0, stop.stdout + assert b"loopback HTTP health endpoint" in stop.stdout, stop.stdout + assert owner.poll() is None, "an unprobeable --stop must not stop the current instance" + + assert owner.poll() is None, "the refused update must not stop the current instance" + assert health(url), "the current instance must keep serving health after the refusal" + lock_path = scope / (NAME + ".instance.lock") + assert lock_path.exists() and int(lock_path.read_text().strip()) == owner.pid, \ + "the refused update must keep the same lock owner" + assert not (temps / "WebAPI-update-progress.json").exists(), \ + "a refused update must not start publishing update progress" + assert not list(root.glob(".v2rayn-WebAPI-candidate-*")), "no update candidate may be staged" + assert not list(install.parent.glob(".v2rayn-WebAPI-update-helper-*")), "no update helper may be started" + print(f"Native WebAPI unprobeable-listener refusal passed ({label}; canInstall=false with a clear reason, current instance keeps running).") + finally: + if owner.poll() is None: + owner.send_signal(signal.SIGTERM) + owner.wait(timeout=40) + log.close() + + +if __name__ == "__main__": + previous, candidate = [Path(value).resolve() for value in sys.argv[1:]] + root = Path(tempfile.mkdtemp(prefix="webapi-native-identity-", dir=os.environ.get("TMPDIR", "/tmp/opencode"))) + try: + run_scenario(root / "replacement", previous, candidate, False) + run_scenario(root / "rollback", previous, candidate, True) + run_scenario(root / "invalid-executable", previous, candidate, False, True) + run_scenario(root / "localhost-replacement", previous, candidate, False, listen_host="localhost") + if ipv6_loopback_available(): + run_scenario(root / "ipv6-only-replacement", previous, candidate, False, listen_host="::1") + else: + print("Skipped the IPv6-only update scenario: IPv6 loopback is not available.") + primary = primary_ipv4() + if primary is not None: + run_refusal_scenario(root / "probe-refused", previous, primary, "explicit non-loopback address") + else: + print("Skipped the non-loopback refusal scenario: no non-loopback IPv4 address is available.") + run_refusal_scenario( + root / "loopback-alias-refused", previous, "127.0.0.2", + "unverifiable loopback alias 127.0.0.2") + except Exception: + print(f"Failed fixture retained for diagnosis: {root}", file=sys.stderr) + raise + else: + shutil.rmtree(root) diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-regression.sh b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-regression.sh new file mode 100755 index 00000000..8cce2278 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-regression.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Exercise real single-file updates with deliberately different bundle layouts. +set -euo pipefail + +web_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +previous="$(realpath "${1:?Usage: test-native-update-regression.sh }")" +test -x "$previous/v2rayN.WebAPI" +test -s "$previous/v2rayN.WebAPI.build.json" + +dotnet="${DOTNET:-$web_root/.NET/dotnet}" +if [[ "$dotnet" != */* ]]; then + dotnet="$(command -v "$dotnet")" +elif [[ ! -x "$dotnet" ]]; then + dotnet="$(command -v dotnet)" +fi +export NUGET_PACKAGES="${NUGET_PACKAGES:-$web_root/.packages/nuget}" + +# Keep native bundles and test installs beside the publish directory, not on /tmp tmpfs. +scratch="$(mktemp -d "$(dirname -- "$previous")/.webapi-update-regression.XXXXXX")" +cleanup() { + local status=$? + if [[ "$status" -eq 0 ]]; then + rm -rf -- "$scratch" + else + printf 'Native update regression fixture retained: %s\n' "$scratch" >&2 + fi +} +trap cleanup EXIT + +mapfile -t identity < <(python3 - "$previous/v2rayN.WebAPI.build.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + identity = json.load(source) +assert identity["product"] == "v2rayN.WebAPI" +print("0.0.1-regression" if identity["version"] != "0.0.1-regression" else "0.0.2-regression") +print(identity["commit"]) +print(identity["buildDate"]) +print(identity["rid"]) +PY +) +[[ "${#identity[@]}" -eq 4 ]] + +# A test-only assembly attribute grows the managed payload and shifts bundle offsets. +# Version-only fixtures can retain identical offsets and hide lazy-loading failures. +python3 - "$scratch/layout.targets" <<'PY' +from pathlib import Path +import sys + +Path(sys.argv[1]).write_text( + '' + '', encoding="utf-8") +PY + +"$dotnet" publish "$web_root/v2rayN.WebAPI.csproj" \ + --configuration Release --runtime "${identity[3]}" --self-contained true \ + -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:WebVersion="${identity[0]}" -p:WebCommit="${identity[1]}" -p:WebBuildDate="${identity[2]}" \ + -p:WebRepository="${V2RAYN_WEB_REPOSITORY:-${GITHUB_REPOSITORY:-2dust/v2rayN}}" \ + -p:CustomBeforeMicrosoftCommonTargets="$scratch/layout.targets" \ + --output "$scratch/candidate" + +python3 - "$previous" "$scratch/candidate" "${identity[0]}" <<'PY' +from pathlib import Path +import json +import sys + +previous, candidate = map(Path, sys.argv[1:3]) +identity = json.loads((previous / "v2rayN.WebAPI.build.json").read_text()) +identity["version"] = sys.argv[3] +(candidate / "v2rayN.WebAPI.build.json").write_text(json.dumps(identity), encoding="utf-8") +assert (previous / "v2rayN.WebAPI").stat().st_size != (candidate / "v2rayN.WebAPI").stat().st_size, \ + "Regression fixtures must have different bundle layouts, not just different versions" +PY + +identity_test=(python3 "$web_root/Tests/Integration/test-native-update-identity.py" "$previous" "$scratch/candidate") +# Run in a transient scope so a hosted runner's enclosing systemd service is not mistaken for +# the WebAPI service under test. Production still rejects actual systemd-managed WebAPI updates. +if command -v systemd-run >/dev/null && systemd-run --user --scope --quiet /usr/bin/true >/dev/null 2>&1; then + systemd-run --user --scope --quiet /usr/bin/env TMPDIR="$scratch" "${identity_test[@]}" +elif command -v sudo >/dev/null && sudo -n systemd-run --scope --quiet /usr/bin/true >/dev/null 2>&1; then + runuser_bin="$(command -v runuser)" + sudo -n systemd-run --scope --quiet "$runuser_bin" --preserve-environment -u "$(id -un)" -- \ + /usr/bin/env TMPDIR="$scratch" "${identity_test[@]}" +elif grep -Eq 'system\.slice/.*\.service' /proc/self/cgroup; then + echo 'Native update regression is in a systemd service cgroup, but no transient scope is available.' >&2 + exit 1 +else + TMPDIR="$scratch" "${identity_test[@]}" +fi +printf 'Native single-file different-layout update regression passed.\n' diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-release-packaging.sh b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-release-packaging.sh new file mode 100755 index 00000000..46c47af2 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-release-packaging.sh @@ -0,0 +1,346 @@ +#!/usr/bin/env bash +# Release packaging dry-run: validates Linux/Windows full-install and app-only ZIP boundaries +# plus the WebAPI-update.json manifest using small fixtures, without publishing anything. +set -euo pipefail + +web_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +asset_tool="$web_root/Scripts/web-release-assets.py" +temporary="$(mktemp -d)" +trap 'rm -rf "$temporary"' EXIT HUP INT TERM + +make_publish_fixture() { + local rid="$1" + local publish="$temporary/publish-$rid" + local app_executable="v2rayN.WebAPI" xray="bin/xray/xray" sing_box="bin/sing_box/sing-box" mihomo="bin/mihomo/mihomo" + local pe_machine="" + case "$rid" in + win-x64) pe_machine="0x8664" ;; + win-arm64) pe_machine="0xAA64" ;; + esac + if [[ -n "$pe_machine" ]]; then + app_executable="v2rayN.WebAPI.exe" + xray="bin/xray/xray.exe" + sing_box="bin/sing_box/sing-box.exe" + mihomo="bin/mihomo/mihomo.exe" + fi + mkdir -p "$publish/bin/xray" "$publish/bin/sing_box" "$publish/bin/mihomo" "$publish/bin/srss" + if [[ -n "$pe_machine" ]]; then + python3 - "$publish/$app_executable" "$publish/$xray" "$publish/$sing_box" "$publish/$mihomo" "$pe_machine" <<'PY' +from pathlib import Path +import struct +import sys + +names = sys.argv[1:-1] +machine = int(sys.argv[-1], 16) +image = bytearray(512) +image[:2] = b"MZ" +struct.pack_into(" "$publish/$app_executable" + chmod 755 "$publish/$app_executable" + for executable in "$xray" "$sing_box" "$mihomo"; do + printf '#!/bin/sh\nexit 0\n' > "$publish/$executable" + chmod 755 "$publish/$executable" + done + fi + # Simulate accidental local environment files in the publish tree. Only the reviewed + # repository template may be copied into a full install ZIP. + printf 'V2RAYN_WEB_API_KEY=fixture-secret\n' > "$publish/.env" + printf 'unreviewed template\n' > "$publish/.env.example" + local assets=(bin/geosite.dat bin/geoip.dat bin/geoip.metadb bin/Country.mmdb bin/srss/geosite-category-ads-all.srs) + if [[ "$rid" == linux-x64 || "$rid" == linux-arm64 ]]; then assets+=(bin/sing_box/libcronet.so); fi + for asset in "${assets[@]}"; do + printf 'asset\n' > "$publish/$asset" + done + # User data that must never leak into the app-only self-update package. + mkdir -p "$publish/guiConfigs" "$publish/guiLogs" "$publish/WebAPIData" + printf 'config\n' > "$publish/guiConfigs/guiNConfig.json" + printf 'log\n' > "$publish/guiLogs/app.log" + printf 'auth\n' > "$publish/WebAPIData/WebAPI-auth.json" + python3 - "$publish/v2rayN.WebAPI.build.json" "$rid" <<'PY' +import json +import sys + +path, rid = sys.argv[1:] +with open(path, "w", encoding="utf-8") as handle: + json.dump({ + "product": "v2rayN.WebAPI", + "version": "7.25.3", + "commit": "0123456789abcdef", + "buildDate": "2026-09-28T00:00:00Z", + "rid": rid, + }, handle, separators=(",", ":")) + handle.write("\n") +PY +} + +for rid in linux-x64 linux-arm64 linux-riscv64 linux-loongarch64 win-x64 win-arm64; do + make_publish_fixture "$rid" + python3 "$asset_tool" package --rid "$rid" --publish "$temporary/publish-$rid" --dist "$temporary/dist" +done + +full_x64="$(python3 "$asset_tool" get --rid linux-x64 --field full)" +full_arm64="$(python3 "$asset_tool" get --rid linux-arm64 --field full)" +full_riscv64="$(python3 "$asset_tool" get --rid linux-riscv64 --field full)" +full_loong64="$(python3 "$asset_tool" get --rid linux-loongarch64 --field full)" +update_x64="$(python3 "$asset_tool" get --rid linux-x64 --field update)" +update_arm64="$(python3 "$asset_tool" get --rid linux-arm64 --field update)" +update_riscv64="$(python3 "$asset_tool" get --rid linux-riscv64 --field update)" +update_loong64="$(python3 "$asset_tool" get --rid linux-loongarch64 --field update)" +full_windows="$(python3 "$asset_tool" get --rid win-x64 --field full)" +update_windows="$(python3 "$asset_tool" get --rid win-x64 --field update)" +full_windows_arm64="$(python3 "$asset_tool" get --rid win-arm64 --field full)" +update_windows_arm64="$(python3 "$asset_tool" get --rid win-arm64 --field update)" + +for asset in "$full_x64" "$full_arm64" "$full_riscv64" "$full_loong64" \ + "$update_x64" "$update_arm64" "$update_riscv64" "$update_loong64" \ + "$full_windows" "$update_windows" "$full_windows_arm64" "$update_windows_arm64"; do + test -s "$temporary/dist/$asset" +done +test "$full_x64" = "v2rayN-linux-64-WebAPI.zip" +test "$full_arm64" = "v2rayN-linux-arm64-WebAPI.zip" +test "$full_riscv64" = "v2rayN-linux-riscv64-WebAPI.zip" +test "$full_loong64" = "v2rayN-linux-loong64-WebAPI.zip" +test "$update_x64" = "v2rayN-linux-64-WebAPI-update.zip" +test "$update_arm64" = "v2rayN-linux-arm64-WebAPI-update.zip" +test "$update_riscv64" = "v2rayN-linux-riscv64-WebAPI-update.zip" +test "$update_loong64" = "v2rayN-linux-loong64-WebAPI-update.zip" +test "$full_windows" = "v2rayN-windows-64-WebAPI.zip" +test "$update_windows" = "v2rayN-windows-64-WebAPI-update.zip" +test "$full_windows_arm64" = "v2rayN-windows-arm64-WebAPI.zip" +test "$update_windows_arm64" = "v2rayN-windows-arm64-WebAPI-update.zip" + +python3 - "$temporary/dist/$full_x64" "$temporary/dist/$update_x64" "$temporary/dist/$full_windows" "$temporary/dist/$update_windows" "$web_root/.env.example" <<'PY' +from pathlib import Path +import sys +import zipfile + +full, update, full_windows, update_windows, env_example_source = sys.argv[1:] +with zipfile.ZipFile(full) as archive: + names = archive.namelist() + env_example_data = archive.read(".env.example") +for required in ("v2rayN.WebAPI", "v2rayN.WebAPI.build.json", ".env.example", "bin/xray/xray"): + assert required in names, required +assert names.count(".env.example") == 1, names +assert env_example_data == Path(env_example_source).read_bytes() +assert not any(".env" in name.split("/") for name in names), names +assert not any(".env.example" in name.split("/") and name != ".env.example" for name in names), names +with zipfile.ZipFile(update) as archive: + update_names = set(archive.namelist()) +assert update_names == {"v2rayN.WebAPI", "v2rayN.WebAPI.build.json"}, update_names +assert not any(segment in (".env", ".env.example") for name in update_names for segment in name.split("/")), update_names +assert not any(name.startswith(("bin/", "guiConfigs/", "guiLogs/", "WebAPIData/")) for name in update_names), update_names +with zipfile.ZipFile(full_windows) as archive: + windows_names = archive.namelist() + assert "v2rayN.WebAPI.exe" in windows_names, windows_names + assert "v2rayN.WebAPI.build.json" in windows_names, windows_names + assert "bin/xray/xray.exe" in windows_names, windows_names + assert archive.read(".env.example") == Path(env_example_source).read_bytes() + assert not any(name.split("/")[-1] == ".env" for name in windows_names), windows_names +with zipfile.ZipFile(update_windows) as archive: + windows_update_names = set(archive.namelist()) +assert windows_update_names == {"v2rayN.WebAPI.exe", "v2rayN.WebAPI.build.json"}, windows_update_names +process = __import__("subprocess").run(["unzip", "-l", update], capture_output=True, text=True) +assert process.returncode == 0, process.stderr + +web_root = Path(env_example_source).parent +web_ignore = (web_root / ".gitignore").read_text(encoding="utf-8").splitlines() +container_ignore = (web_root / "Containerfile.dockerignore").read_text(encoding="utf-8").splitlines() +assert ".env" in web_ignore, web_ignore +assert ".env" in container_ignore and "**/.env" in container_ignore, container_ignore +print("ZIP boundary assertions passed") +PY + +python3 "$web_root/Scripts/web-update-manifest.py" write \ + --version 7.25.3 \ + --repository 2dust/v2rayN \ + --commit 0123456789abcdef \ + --build-date 2026-09-28T00:00:00Z \ + --dist "$temporary/dist" \ + --output "$temporary/dist/WebAPI-update.json" +python3 "$web_root/Scripts/web-update-manifest.py" verify \ + --manifest "$temporary/dist/WebAPI-update.json" \ + --dist "$temporary/dist" \ + --repository 2dust/v2rayN \ + --version 7.25.3 + +if python3 "$web_root/Scripts/web-update-manifest.py" verify \ + --manifest "$temporary/dist/WebAPI-update.json" \ + --dist "$temporary/dist" \ + --repository 2dust/v2rayN \ + --version 7.25.4 2>/dev/null; then + echo "Expected the wrong requested release version to be rejected" >&2 + exit 1 +fi + +python3 - "$temporary/dist/WebAPI-update.json" "$update_x64" "$update_arm64" "$update_riscv64" "$update_loong64" "$update_windows" "$update_windows_arm64" <<'PY' +import hashlib +import json +import sys +from pathlib import Path + +manifest_path = Path(sys.argv[1]) +asset_x64, asset_arm64, asset_riscv64, asset_loong64, asset_windows, asset_windows_arm64 = sys.argv[2:] +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) +assert manifest["product"] == "v2rayN.WebAPI", manifest +assert manifest["version"] == "7.25.3", manifest +assert manifest["commit"] == "0123456789abcdef", manifest +assert manifest["buildDate"] == "2026-09-28T00:00:00Z", manifest +expected = { + "linux-x64": asset_x64, + "linux-arm64": asset_arm64, + "linux-riscv64": asset_riscv64, + "linux-loongarch64": asset_loong64, + "win-x64": asset_windows, + "win-arm64": asset_windows_arm64, +} +packages = {package["rid"]: package for package in manifest["packages"]} +assert set(packages) == set(expected), packages +for rid, asset in expected.items(): + package = packages[rid] + url = f"https://github.com/2dust/v2rayN/releases/download/7.25.3/{asset}" + assert asset.endswith("-WebAPI-update.zip"), asset + assert package["asset"] == asset, package + assert package["url"] == url, package + data = (manifest_path.parent / asset).read_bytes() + assert package["sha256"] == hashlib.sha256(data).hexdigest(), package + assert package["size"] == len(data), package +print("WebAPI-update.json assertions passed") +PY + +# A dev identity must never become a release manifest. +if python3 "$web_root/Scripts/web-update-manifest.py" write \ + --version 7.25.3-dev \ + --repository 2dust/v2rayN \ + --commit 0123456789abcdef \ + --build-date 2026-09-28T00:00:00Z \ + --dist "$temporary/dist" \ + --output "$temporary/dev-update.json" 2>/dev/null; then + echo "Expected the dev identity manifest to be rejected" >&2 + exit 1 +fi +test ! -e "$temporary/dev-update.json" + +# A tampered asset must fail SHA-256 verification. +cp -a "$temporary/dist" "$temporary/tampered" +printf 'tampered' >> "$temporary/tampered/$update_x64" +cp -a "$temporary/dist/WebAPI-update.json" "$temporary/tampered/WebAPI-update.json" +if python3 "$web_root/Scripts/web-update-manifest.py" verify \ + --manifest "$temporary/tampered/WebAPI-update.json" \ + --dist "$temporary/tampered" \ + --repository 2dust/v2rayN 2>/dev/null; then + echo "Expected the tampered manifest to be rejected" >&2 + exit 1 +fi + +# The embedded v2rayN.WebAPI.build.json must match the manifest, and the app-only ZIP boundary +# must reject forbidden files, links, duplicates, and path traversal. Each variant regenerates +# the manifest from the tampered archive so SHA-256 and size stay self-consistent and only the +# identity/layout cross-check can fail. +python3 - "$temporary/dist" "$temporary/identity-tamper" "$update_x64" <<'PY' +import json +import shutil +import sys +import warnings +import zipfile +from pathlib import Path + +warnings.filterwarnings("ignore", message="Duplicate name:.*") + +dist = Path(sys.argv[1]) +out_root = Path(sys.argv[2]) +source = dist / sys.argv[3] +identity_name = "v2rayN.WebAPI.build.json" +fixed_time = (1980, 1, 1, 0, 0, 0) + +with zipfile.ZipFile(source) as archive: + base = [(info, archive.read(info)) for info in archive.infolist()] + +identity = json.loads(next(data for info, data in base if info.filename == identity_name)) +identity_data = next(data for info, data in base if info.filename == identity_name) +untouched = [path for path in dist.iterdir() if path.is_file() and path.name.endswith(".zip") and path != source] + + +def write_variant(name, transform): + target = out_root / name + target.mkdir(parents=True, exist_ok=True) + for path in untouched: + shutil.copy2(path, target / path.name) + with zipfile.ZipFile(target / source.name, "w", zipfile.ZIP_DEFLATED) as archive: + for info, data in transform(list(base)): + archive.writestr(info, data) + + +def identity_transform(field=None, value=None, drop=False, invalid=False): + def transform(entries): + result = [] + for info, data in entries: + if info.filename != identity_name: + result.append((info, data)) + continue + if drop: + continue + if invalid: + result.append((info, b"{ not valid json")) + continue + changed = dict(identity) + changed[field] = value + result.append((info, json.dumps(changed, separators=(",", ":")).encode("utf-8"))) + return result + return transform + + +def appended(name, data, symlink=False): + def transform(entries): + info = zipfile.ZipInfo(name, date_time=fixed_time) + info.create_system = 3 + file_type = 0o120000 if symlink else 0o100000 + info.external_attr = (file_type | 0o755) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + return entries + [(info, data)] + return transform + + +write_variant("version", identity_transform(field="version", value="7.25.4")) +write_variant("commit", identity_transform(field="commit", value="ffffffffffffffff")) +write_variant("build-date", identity_transform(field="buildDate", value="2026-09-29T00:00:00Z")) +write_variant("rid", identity_transform(field="rid", value="linux-arm64")) +write_variant("missing-identity", identity_transform(drop=True)) +write_variant("invalid-json", identity_transform(invalid=True)) +write_variant("embedded-bin", appended("bin/xray/xray", b"#!/bin/sh\nexit 0\n")) +write_variant("symlink", appended("extra/vendor.dat", b"/etc/passwd", symlink=True)) +write_variant("duplicate", appended(identity_name, identity_data)) +write_variant("traversal", appended("../outside.txt", b"nope")) +print(f"prepared identity tamper variants in {out_root}") +PY + +for variant in \ + version commit build-date rid missing-identity invalid-json \ + embedded-bin symlink duplicate traversal; do + if python3 "$web_root/Scripts/web-update-manifest.py" write \ + --version 7.25.3 \ + --repository 2dust/v2rayN \ + --commit 0123456789abcdef \ + --build-date 2026-09-28T00:00:00Z \ + --dist "$temporary/identity-tamper/$variant" \ + --output "$temporary/identity-tamper/$variant/WebAPI-update.json" \ + 2>"$temporary/identity-tamper/$variant.log"; then + echo "Expected the $variant identity tamper to be rejected" >&2 + exit 1 + fi + if ! grep -q "app-only archive $update_x64" "$temporary/identity-tamper/$variant.log"; then + echo "The $variant identity tamper was rejected without naming the app-only archive" >&2 + cat "$temporary/identity-tamper/$variant.log" >&2 + exit 1 + fi +done + +printf 'Release packaging dry-run passed\n' diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-startup-security.sh b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-startup-security.sh new file mode 100644 index 00000000..231c4b99 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-startup-security.sh @@ -0,0 +1,472 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_executable="${1:?Usage: test-startup-security.sh }" +source_executable="$(realpath "$source_executable")" +test -x "$source_executable" +command -v curl >/dev/null +command -v python3 >/dev/null + +temporary="$(mktemp -d)" +web_pid="" +cleanup() { + if [[ -n "$web_pid" ]] && kill -0 "$web_pid" 2>/dev/null; then + kill -TERM "$web_pid" 2>/dev/null || true + for _ in {1..20}; do + state="$(ps -o stat= -p "$web_pid" 2>/dev/null | tr -d ' ' || true)" + [[ -z "$state" || "$state" == *Z* ]] && break + sleep 0.1 + done + kill -KILL "$web_pid" 2>/dev/null || true + wait "$web_pid" 2>/dev/null || true + fi + rm -rf "$temporary" +} +trap cleanup EXIT HUP INT TERM + +# Run from an isolated copy so a developer's executable-directory .env is not read or copied +# into the test; individual cases create controlled configuration files beside this apphost. +runtime_directory="$temporary/runtime" +source_directory="$(dirname "$source_executable")" +mkdir -p "$runtime_directory" "$temporary/other-working-directory" +for item in "$source_directory"/* "$source_directory"/.[!.]* "$source_directory"/..?*; do + [[ -e "$item" || -L "$item" ]] || continue + [[ "${item##*/}" == ".env" ]] && continue + cp -a -- "$item" "$runtime_directory/" +done +executable="$runtime_directory/v2rayN.WebAPI" +test -x "$executable" + +new_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +assert_rejected_without_key() { + local deployment="$1" data_home="$temporary/$1-data" output="$temporary/$1-rejected.log" status + local markers=() + case "$deployment" in + systemd) markers=(INVOCATION_ID=startup-security-test) ;; + container) markers=(DOTNET_RUNNING_IN_CONTAINER=true) ;; + *) echo "Unknown deployment mode: $deployment" >&2; exit 2 ;; + esac + + set +e + if ( + cd "$temporary/other-working-directory" + exec env -u V2RAYN_WEB_API_KEY \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "${markers[@]}" V2RAYN_DATA_HOME="$data_home" \ + "$executable" --foreground --no-open + ) >"$output" 2>&1; then + status=0 + else + status=$? + fi + set -e + + [[ "$status" -eq 1 ]] || { cat "$output" >&2; echo "$deployment did not fail closed (exit $status)." >&2; exit 1; } + grep -Fqx 'Management Key is required in supervised/container deployments. Set V2RAYN_WEB_API_KEY before starting v2rayN.WebAPI.' "$output" + test ! -e "$data_home" +} + +wait_for_health() { + local base_url="$1" output="$2" healthy=false + for _ in {1..60}; do + if ! kill -0 "$web_pid" 2>/dev/null; then + cat "$output" >&2 + echo "Web process exited before becoming healthy." >&2 + exit 1 + fi + if curl --silent --show-error --fail "$base_url/api/health" -o "$temporary/health.json" 2>/dev/null \ + && python3 - "$temporary/health.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + assert json.load(handle) == {"status": "ok"} +PY + then + healthy=true + break + fi + sleep 0.5 + done + if [[ "$healthy" != true ]]; then + cat "$output" >&2 + echo "Web process did not become healthy." >&2 + exit 1 + fi +} + +stop_instance() { + local output="$1" stopped=false state exit_code + kill -TERM "$web_pid" + for _ in {1..80}; do + state="$(ps -o stat= -p "$web_pid" 2>/dev/null | tr -d ' ' || true)" + if [[ -z "$state" || "$state" == *Z* ]]; then + stopped=true + break + fi + sleep 0.25 + done + if [[ "$stopped" != true ]]; then + cat "$output" >&2 + echo "Web process did not stop gracefully." >&2 + exit 1 + fi + set +e + wait "$web_pid" + exit_code=$? + set -e + web_pid="" + if [[ "$exit_code" -ne 0 ]]; then + cat "$output" >&2 + echo "Web process exited with status $exit_code." >&2 + exit 1 + fi +} + +assert_rejected_without_key systemd +assert_rejected_without_key container + +assert_remote_listener_rejected() { + local source="$1" output="$temporary/listener-$1.log" status + local settings=() arguments=() + case "$source" in + urls) settings=(ASPNETCORE_URLS=http://0.0.0.0:5080) ;; + ipv6) settings=('ASPNETCORE_URLS=http://[::]:5080') ;; + lan) settings=(ASPNETCORE_URLS=http://192.168.1.20:5080) ;; + multiple) settings=('ASPNETCORE_URLS=http://localhost:5080;http://0.0.0.0:5081') ;; + cli) settings=(ASPNETCORE_URLS=http://localhost:5080); arguments=(--urls http://0.0.0.0:5081) ;; + cli-equals) arguments=(--urls=http://0.0.0.0:5081) ;; + http-ports) settings=(ASPNETCORE_HTTP_PORTS=5080) ;; + https-ports) settings=(ASPNETCORE_HTTPS_PORTS=5443) ;; + kestrel) settings=(ASPNETCORE_URLS=http://localhost:5080 Kestrel__Endpoints__Http__Url=http://0.0.0.0:5081) ;; + esac + if timeout 15 env -u V2RAYN_WEB_API_KEY \ + -u ASPNETCORE_URLS -u ASPNETCORE_HTTP_PORTS -u ASPNETCORE_HTTPS_PORTS \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "${settings[@]}" V2RAYN_DATA_HOME="$temporary/listener-$source-data" \ + "$executable" --foreground --no-open "${arguments[@]}" >"$output" 2>&1; then + status=0 + else + status=$? + fi + [[ "$status" -eq 1 ]] || { cat "$output" >&2; echo "$source listener did not fail closed (exit $status)." >&2; exit 1; } + grep -Fq 'Management Key is required for non-loopback Web listeners.' "$output" +} + +for source in urls ipv6 lan multiple cli cli-equals http-ports https-ports kestrel; do + assert_remote_listener_rejected "$source" +done + +assert_malformed_configuration_fails() { + local output="$temporary/malformed-env.log" status + printf 'V2RAYN_WEB_API_KEY=must-not-be-echoed"\n' > "$runtime_directory/.env" + if ( + cd "$temporary/other-working-directory" + exec env -u V2RAYN_WEB_API_KEY -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container "$executable" --foreground --no-open + ) >"$output" 2>&1; then + status=0 + else + status=$? + fi + rm -f "$runtime_directory/.env" + [[ "$status" -eq 1 ]] || { cat "$output" >&2; echo "Malformed .env did not fail startup (exit $status)." >&2; exit 1; } + grep -Fq 'Invalid v2rayN.WebAPI environment configuration:' "$output" + ! grep -Fq 'must-not-be-echoed' "$output" +} + +assert_malformed_configuration_fails + +assert_invalid_management_key_fails() { + local source="$1" length="$2" key output data_home status + output="$temporary/invalid-key-$source-$length.log" + data_home="$temporary/invalid-key-$source-$length-data" + key="$(python3 -c 'import sys; print("x" * int(sys.argv[1]))' "$length")" + rm -f "$runtime_directory/.env" + if [[ "$source" == dotenv ]]; then + printf 'V2RAYN_WEB_API_KEY=%s\n' "$key" > "$runtime_directory/.env" + chmod 600 "$runtime_directory/.env" + fi + + set +e + if ( + cd "$temporary/other-working-directory" + if [[ "$source" == environment ]]; then + exec env -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "V2RAYN_WEB_API_KEY=$key" V2RAYN_DATA_HOME="$data_home" \ + "$executable" --foreground --no-open + else + exec env -u V2RAYN_WEB_API_KEY \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + V2RAYN_DATA_HOME="$data_home" "$executable" --foreground --no-open + fi + ) >"$output" 2>&1; then + status=0 + else + status=$? + fi + set -e + rm -f "$runtime_directory/.env" + + [[ "$status" -eq 1 ]] || { cat "$output" >&2; echo "$source key with length $length did not fail startup (exit $status)." >&2; exit 1; } + grep -Fqx 'V2RAYN_WEB_API_KEY length must be between 12 and 4096 characters.' "$output" + ! grep -Fq "$key" "$output" + test ! -e "$data_home" +} + +start_with_valid_management_key_boundary() { + local source="$1" length="$2" key port data_home output url + local key_settings=() + key="$(python3 -c 'import sys; print("b" * int(sys.argv[1]))' "$length")" + port="$(new_port)" + data_home="$temporary/valid-key-$source-$length-data" + output="$temporary/valid-key-$source-$length.log" + url="http://127.0.0.1:$port" + rm -f "$runtime_directory/.env" + + if [[ "$source" == dotenv ]]; then + printf 'V2RAYN_WEB_API_KEY=%s\n' "$key" > "$runtime_directory/.env" + chmod 600 "$runtime_directory/.env" + else + key_settings=("V2RAYN_WEB_API_KEY=$key") + fi + + ( + cd "$temporary/other-working-directory" + exec env -u V2RAYN_WEB_API_KEY \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + -u ASPNETCORE_URLS -u ASPNETCORE_HTTP_PORTS -u ASPNETCORE_HTTPS_PORTS \ + "${key_settings[@]}" \ + V2RAYN_DATA_HOME="$data_home" V2RAYN_WEB_AUTOSTART=false ASPNETCORE_URLS="$url" \ + "$executable" --foreground --no-open + ) >"$output" 2>&1 & + web_pid=$! + wait_for_health "$url" "$output" + stop_instance "$output" + rm -f "$runtime_directory/.env" +} + +assert_invalid_management_key_fails environment 11 +assert_invalid_management_key_fails environment 4097 +assert_invalid_management_key_fails dotenv 11 +assert_invalid_management_key_fails dotenv 4097 +start_with_valid_management_key_boundary environment 12 +start_with_valid_management_key_boundary dotenv 4096 + +# Native interactive first run remains available without SSH or a preconfigured key. +native_port="$(new_port)" +native_data="$temporary/native-data" +native_log="$temporary/native-first-run.log" +native_url="http://127.0.0.1:$native_port" +native_key="first-run-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" +( + cd "$temporary/other-working-directory" + exec env -u V2RAYN_WEB_API_KEY \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + V2RAYN_DATA_HOME="$native_data" \ + V2RAYN_WEB_AUTOSTART=true \ + ASPNETCORE_URLS="$native_url" \ + DOTNET_BUNDLE_EXTRACT_BASE_DIR="$temporary/native-bundle" \ + "$executable" --foreground --no-open +) >"$native_log" 2>&1 & +web_pid=$! +wait_for_health "$native_url" "$native_log" + +curl --silent --show-error --fail "$native_url/api/setup/status" >"$temporary/setup-status.json" +python3 - "$temporary/setup-status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + status = json.load(handle) +assert status["setupRequired"] is True, status +assert status["setupAllowedFromThisRequest"] is True, status +PY + +setup_body="$(python3 -c 'import json,sys; print(json.dumps({"key":sys.argv[1],"confirmKey":sys.argv[1]}))' "$native_key")" +curl --silent --show-error --fail \ + -H 'Content-Type: application/json' \ + --data "$setup_body" \ + "$native_url/api/setup" >"$temporary/setup.json" +session_token="$(python3 - "$temporary/setup.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +assert response["setupRequired"] is False, response +print(response["token"]) +PY +)" +curl --silent --show-error --fail \ + -H "Authorization: Bearer $session_token" \ + "$native_url/api/status" >"$temporary/native-status.json" +python3 - "$temporary/native-status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +status = response["data"] +assert response["success"] is True, response +assert status["coreRunning"] is False, status +assert status["runtimeState"] == "stopped", status +assert not status["coreProcessIds"], status +PY +grep -Fq 'Core autostart was suppressed until first-run Management Key setup is complete.' "$native_log" +stop_instance "$native_log" + +start_managed_with_key() { + local deployment="$1" port="$2" data_home="$temporary/$1-data" output="$temporary/$1-key.log" url="http://127.0.0.1:$2" + local key="managed-key-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" + local markers=() + case "$deployment" in + systemd) markers=(INVOCATION_ID=startup-security-test) ;; + container) markers=(DOTNET_RUNNING_IN_CONTAINER=true) ;; + esac + + env -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "${markers[@]}" V2RAYN_WEB_API_KEY="$key" \ + V2RAYN_DATA_HOME="$data_home" V2RAYN_WEB_AUTOSTART=false \ + ASPNETCORE_URLS="$url" DOTNET_BUNDLE_EXTRACT_BASE_DIR="$temporary/$deployment-bundle" \ + "$executable" --foreground --no-open >"$output" 2>&1 & + web_pid=$! + wait_for_health "$url" "$output" + stop_instance "$output" +} + +start_managed_with_dotenv_key() { + local deployment="$1" port="$2" data_home="$temporary/$1-dotenv-data" output="$temporary/$1-dotenv-key.log" url="http://127.0.0.1:$2" + local key="dotenv-managed-key-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" + local markers=() + case "$deployment" in + systemd) markers=(INVOCATION_ID=startup-security-dotenv-test) ;; + container) markers=(DOTNET_RUNNING_IN_CONTAINER=true) ;; + esac + + printf 'V2RAYN_WEB_API_KEY=%s\nASPNETCORE_URLS=%s\nV2RAYN_WEB_AUTOSTART=false\n' \ + "$key" "$url" > "$runtime_directory/.env" + chmod 600 "$runtime_directory/.env" + ( + cd "$temporary/other-working-directory" + exec env -u V2RAYN_WEB_API_KEY \ + -u V2RAYN_WEB_AUTOSTART -u ASPNETCORE_URLS -u ASPNETCORE_HTTP_PORTS \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "${markers[@]}" V2RAYN_DATA_HOME="$data_home" \ + DOTNET_BUNDLE_EXTRACT_BASE_DIR="$temporary/$deployment-dotenv-bundle" \ + "$executable" --foreground --no-open + ) >"$output" 2>&1 & + web_pid=$! + wait_for_health "$url" "$output" + + python3 -c 'import json,sys; print(json.dumps({"key":sys.argv[1]}))' "$key" >"$temporary/$deployment-dotenv-login-request.json" + curl --silent --show-error --fail -H 'Content-Type: application/json' \ + --data-binary "@$temporary/$deployment-dotenv-login-request.json" \ + "$url/api/auth/login" >"$temporary/$deployment-dotenv-login.json" + local session_token + session_token="$(python3 - "$temporary/$deployment-dotenv-login.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +assert response["success"] is True, response +print(response["data"]["token"]) +PY +)" + curl --silent --show-error --fail -H "Authorization: Bearer $session_token" \ + "$url/api/status" >"$temporary/$deployment-dotenv-status.json" + python3 - "$temporary/$deployment-dotenv-status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +status = response["data"] +assert response["success"] is True, response +assert status["coreRunning"] is False, status +assert status["runtimeState"] == "stopped", status +assert not status["coreProcessIds"], status +PY + stop_instance "$output" + rm -f "$runtime_directory/.env" +} + +start_managed_with_process_environment_precedence() { + local port="$1" dotenv_port="$(new_port)" data_home="$temporary/systemd-precedence-data" + local output="$temporary/systemd-precedence.log" url="http://127.0.0.1:$1" + local dotenv_key="dotenv-shadowed-key-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" + local process_key="process-priority-key-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" + + printf 'V2RAYN_WEB_API_KEY=%s\nASPNETCORE_URLS=http://127.0.0.1:%s\nV2RAYN_WEB_AUTOSTART=true\n' \ + "$dotenv_key" "$dotenv_port" > "$runtime_directory/.env" + ( + cd "$temporary/other-working-directory" + exec env -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + INVOCATION_ID=startup-security-priority-test \ + V2RAYN_WEB_API_KEY="$process_key" \ + V2RAYN_WEB_AUTOSTART=false ASPNETCORE_URLS="$url" \ + V2RAYN_DATA_HOME="$data_home" \ + DOTNET_BUNDLE_EXTRACT_BASE_DIR="$temporary/systemd-priority-bundle" \ + "$executable" --foreground --no-open + ) >"$output" 2>&1 & + web_pid=$! + wait_for_health "$url" "$output" + + python3 -c 'import json,sys; print(json.dumps({"key":sys.argv[1]}))' "$process_key" >"$temporary/systemd-priority-login-request.json" + curl --silent --show-error --fail -H 'Content-Type: application/json' \ + --data-binary "@$temporary/systemd-priority-login-request.json" \ + "$url/api/auth/login" >"$temporary/systemd-priority-login.json" + local session_token + session_token="$(python3 - "$temporary/systemd-priority-login.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +assert response["success"] is True, response +print(response["data"]["token"]) +PY +)" + curl --silent --show-error --fail -H "Authorization: Bearer $session_token" \ + "$url/api/status" >"$temporary/systemd-priority-status.json" + python3 - "$temporary/systemd-priority-status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +status = response["data"] +assert response["success"] is True, response +assert status["coreRunning"] is False, status +assert status["runtimeState"] == "stopped", status +PY + stop_instance "$output" + rm -f "$runtime_directory/.env" +} + +start_managed_with_dotenv_key systemd "$(new_port)" +start_managed_with_dotenv_key container "$(new_port)" +start_managed_with_process_environment_precedence "$(new_port)" +start_managed_with_key systemd "$(new_port)" +start_managed_with_key container "$(new_port)" + +echo "Startup security tests passed (loopback first-run, remote listeners fail-closed across configuration sources, malformed .env rejection, systemd/container keys from .env or process environment)." diff --git a/v2rayN/v2rayN.WebAPI/Tests/IpInfoColumnParityTests.cs b/v2rayN/v2rayN.WebAPI/Tests/IpInfoColumnParityTests.cs new file mode 100644 index 00000000..7627fd3f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/IpInfoColumnParityTests.cs @@ -0,0 +1,36 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class IpInfoColumnParityTests +{ + [Test] + public async Task IpInfoColumnRequiresConfiguredApiAndDesktopColumnVisibility() + { + await V2rayRuntime.ShouldShowIpInfoColumn("https://ip.example.test", hideColumnIpInfo: false).Should().BeTrue(); + await V2rayRuntime.ShouldShowIpInfoColumn(" ", hideColumnIpInfo: false).Should().BeTrue(); + await V2rayRuntime.ShouldShowIpInfoColumn(string.Empty, hideColumnIpInfo: false).Should().BeFalse(); + await V2rayRuntime.ShouldShowIpInfoColumn("https://ip.example.test", hideColumnIpInfo: true).Should().BeFalse(); + await V2rayRuntime.ShouldShowIpInfoColumn(null, hideColumnIpInfo: true).Should().BeFalse(); + } + + [Test] + public async Task CanonicalSettingsExposeIpColumnVisibilityFromApiUrlAndDesktopHideFlag() + { + var config = SniffingSettingsIntegrationTests.CreateConfig(); + SniffingSettingsIntegrationTests.BindAppManagerConfig(config); + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + + var initiallyEmpty = await runtime.GetSettingsAsync(); + await initiallyEmpty.ShowIpInfoColumn.Should().BeFalse(); + + config.SpeedTestItem.IPAPIUrl = "https://ip.example.test"; + var configured = await runtime.GetSettingsAsync(); + await configured.ShowIpInfoColumn.Should().BeTrue(); + + config.UiItem.HideColumnIpInfo = true; + var hiddenByDesktopPreference = await runtime.GetSettingsAsync(); + await hiddenByDesktopPreference.ShowIpInfoColumn.Should().BeFalse(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/LogBufferTests.cs b/v2rayN/v2rayN.WebAPI/Tests/LogBufferTests.cs new file mode 100644 index 00000000..ee81f1db --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/LogBufferTests.cs @@ -0,0 +1,59 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class LogBufferTests +{ + [Test] + public async Task HighVolumeRetentionRemainsBoundedAndClearDropsAllEntries() + { + var logs = new LogBuffer(); + for (var index = 0; index < 50_000; index++) + { + logs.Add("core", $"line {index}"); + } + + var retained = logs.Recent(2000); + await retained.Count.Should().BeEqualTo(2000); + await retained[^1].Message.Should().BeEqualTo("line 49999"); + + logs.Clear(); + await logs.Recent(2000).Count.Should().BeEqualTo(0); + } + + [Test] + public async Task OversizedLogMessagesAreTruncatedAtTheBufferBoundary() + { + var logs = new LogBuffer(); + var entry = logs.Add("core", new string('x', 100_000)); + + await entry.Message.Length.Should().BeLessThanOrEqualTo(16 * 1024); + await entry.Message.Should().Contain("log entry truncated"); + } + + [Test] + public async Task CharacterRetentionIsBoundedIndependentlyOfEntryCount() + { + var logs = new LogBuffer(); + var message = new string('x', 10_000); + for (var index = 0; index < 300; index++) logs.Add("core", message); + + var retained = logs.Recent(2000); + await retained.Count.Should().BeLessThan(2000); + await retained.Sum(item => item.Message.Length + item.Source.Length) + .Should().BeLessThanOrEqualTo(1_000_000); + } + + [Test] + public async Task ClearingLogsAdvancesGenerationForRejectingStaleRealtimeEntries() + { + var logs = new LogBuffer(); + var previous = logs.Add("core", "old entry"); + var generation = logs.Clear(); + var current = logs.Add("core", "new entry"); + + await previous.Generation.Should().BeEqualTo(0); + await generation.Should().BeEqualTo(1); + await current.Generation.Should().BeEqualTo(1); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/NativeEnvironmentFileTests.cs b/v2rayN/v2rayN.WebAPI/Tests/NativeEnvironmentFileTests.cs new file mode 100644 index 00000000..dd0c1fec --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/NativeEnvironmentFileTests.cs @@ -0,0 +1,159 @@ +using v2rayN.WebAPI.Configuration; + +namespace v2rayN.WebAPI.Tests; + +public class NativeEnvironmentFileTests +{ + [Test] + public async Task MissingFileIsOptionalAndBlankOrCommentOnlyFilesHaveNoEntries() + { + using var directory = new TemporaryDirectory(); + NativeEnvironmentFile.LoadFromExecutableDirectory(directory.Path); + + await NativeEnvironmentFile.Parse(string.Empty).Count.Should().BeEqualTo(0); + await NativeEnvironmentFile.Parse("\n # comment\n\t\n# another comment\n").Count.Should().BeEqualTo(0); + } + + [Test] + public async Task ParserAcceptsPlainAndQuotedValuesWithSpacesEqualsAndHashes() + { + var parsed = NativeEnvironmentFile.Parse(""" + PLAIN=value with spaces = signs # kept as data + DOUBLE=" value with spaces = signs # kept " + SINGLE='single quoted value = # data' + EMPTY= + INLINE_HASH=value#not-a-comment + """); + + await parsed["PLAIN"].Should().BeEqualTo("value with spaces = signs # kept as data"); + await parsed["DOUBLE"].Should().BeEqualTo(" value with spaces = signs # kept "); + await parsed["SINGLE"].Should().BeEqualTo("single quoted value = # data"); + await parsed["EMPTY"].Should().BeEqualTo(string.Empty); + await parsed["INLINE_HASH"].Should().BeEqualTo("value#not-a-comment"); + } + + [Test] + public async Task DuplicateKeysUseFirstOccurrence() + { + var parsed = NativeEnvironmentFile.Parse("VALUE=first\nVALUE=second\n"); + + await parsed["VALUE"].Should().BeEqualTo("first"); + } + + [Test] + public async Task ProcessEnvironmentOverridesDotEnvIncludingAnEmptyValue() + { + var dotenv = new Dictionary(StringComparer.Ordinal) + { + ["TEST_VALUE"] = "from-dotenv", + ["EMPTY_VALUE"] = "from-dotenv", + ["DOTENV_ONLY"] = "from-dotenv", + }; + var process = new Dictionary(StringComparer.Ordinal) + { + ["TEST_VALUE"] = "from-process", + ["EMPTY_VALUE"] = string.Empty, + }; + + var merged = NativeEnvironmentFile.Merge(dotenv, process); + + await merged["TEST_VALUE"].Should().BeEqualTo("from-process"); + await merged["EMPTY_VALUE"].Should().BeEqualTo(string.Empty); + await merged["DOTENV_ONLY"].Should().BeEqualTo("from-dotenv"); + } + + [Test] + public async Task LoaderUsesTheProvidedExecutableDirectoryRatherThanAnotherDirectory() + { + using var directory = new TemporaryDirectory(); + var executableDirectory = Path.Combine(directory.Path, "install"); + var workingDirectory = Path.Combine(directory.Path, "other-working-directory"); + Directory.CreateDirectory(executableDirectory); + Directory.CreateDirectory(workingDirectory); + var variable = $"V2RAYN_DOTENV_PATH_TEST_{Guid.NewGuid():N}"; + Environment.SetEnvironmentVariable(variable, null); + await File.WriteAllTextAsync(Path.Combine(executableDirectory, ".env"), $"{variable}=from-executable-directory\n"); + await File.WriteAllTextAsync(Path.Combine(workingDirectory, ".env"), $"{variable}=from-working-directory\n"); + + try + { + NativeEnvironmentFile.LoadFromExecutableDirectory(executableDirectory); + await Environment.GetEnvironmentVariable(variable).Should().BeEqualTo("from-executable-directory"); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task InvalidKeysQuotesNulShellSyntaxAndOversizedInputsAreRejected() + { + foreach (var invalid in new[] + { + "1BAD=value", + "export KEY=value", + "KEY=\"unterminated", + "KEY='mismatched\"", + "KEY=bare\"quote", + "KEY=first line\nsecond line", + "KEY=\"first line\nsecond line\"", + "KEY=value\0hidden", + "KEY=$(command)", + "KEY=${OTHER_VAR}", + "KEY=$OTHER_VAR", + "KEY=`command`", + "KEY=back\\slash", + }) + { + await IsRejected(invalid).Should().BeTrue(); + } + + await IsRejected(new string('x', NativeEnvironmentFile.MaximumFileBytes + 1)).Should().BeTrue(); + await IsRejected($"VALUE={new string('x', NativeEnvironmentFile.MaximumValueBytes + 1)}").Should().BeTrue(); + } + + [Test] + public async Task MalformedFileErrorIdentifiesLineWithoutEchoingItsContents() + { + var secretLikeValue = "KEY=private-malformed-value\""; + var message = string.Empty; + try + { + _ = NativeEnvironmentFile.Parse($"# safe comment\n{secretLikeValue}\n"); + } + catch (NativeEnvironmentConfigurationException exception) + { + message = exception.Message; + } + + await message.Should().Contain("Line 2"); + await message.Should().NotContain("private-malformed-value"); + } + + private static bool IsRejected(string value) + { + try + { + _ = NativeEnvironmentFile.Parse(value); + return false; + } + catch (NativeEnvironmentConfigurationException) + { + return true; + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-dotenv-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateFileSwapTests.cs b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateFileSwapTests.cs new file mode 100644 index 00000000..bbad34fd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateFileSwapTests.cs @@ -0,0 +1,140 @@ +using v2rayN.WebAPI.Launcher; +using System.Security.Cryptography; +using System.Text; +using System.IO.MemoryMappedFiles; + +namespace v2rayN.WebAPI.Tests; + +public class NativeWebUpdateFileSwapTests +{ + private static string ExecutableName => OperatingSystem.IsWindows() ? "v2rayN.WebAPI.exe" : "v2rayN.WebAPI"; + private static string Rid => OperatingSystem.IsWindows() ? "win-x64" : "linux-x64"; + + [Test] + public async Task RollbackDoesNotModifyAnExecutableThatIsStillMemoryMapped() + { + if (!OperatingSystem.IsLinux()) return; + using var directory = new TemporaryDirectory(); + var plan = await CreatePlanAsync(directory.Path, includeNewIdentity: true); + NativeWebUpdateHelper.CopyCurrentAppToBackup(plan); + NativeWebUpdateHelper.SwapCandidateAppIntoPlace(plan); + using var executable = new FileStream(Path.Combine(plan.InstallDirectory, ExecutableName), + FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var mapping = MemoryMappedFile.CreateFromFile(executable, null, 0, + MemoryMappedFileAccess.Read, HandleInheritability.None, leaveOpen: true); + using var view = mapping.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); + await view.ReadByte(0).Should().BeEqualTo((byte)'n'); + + NativeWebUpdateHelper.RestorePreviousApp(plan); + + await view.ReadByte(0).Should().BeEqualTo((byte)'n'); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, ExecutableName))).Should().BeEqualTo("old-web"); + await Directory.GetFiles(plan.InstallDirectory, ".v2rayn-WebAPI-restore-*").Length.Should().BeEqualTo(0); + } + + [Test] + public async Task ApiSelfUpdatePreservesUnrelatedFilesByteForByte() + { + using var directory = new TemporaryDirectory(); + var plan = await CreatePlanAsync(directory.Path, includeNewIdentity: true); + var extraHash = await HashDirectoryAsync(Path.Combine(plan.InstallDirectory, "extra")); + NativeWebUpdateHelper.CopyCurrentAppToBackup(plan); + NativeWebUpdateHelper.SwapCandidateAppIntoPlace(plan); + + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, ExecutableName))).Should().BeEqualTo("new-web"); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, "extra", "marker.dat"))).Should().BeEqualTo("UNRELATED_FILE_TEST"); + await (await HashDirectoryAsync(Path.Combine(plan.InstallDirectory, "extra"))).Should().BeEqualTo(extraHash); + await Directory.Exists(Path.Combine(plan.BackupDirectory, "extra")).Should().BeFalse(); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, "bin", "xray", "xray"))).Should().BeEqualTo("updated-core"); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, "guiConfigs", "guiNConfig.json"))).Should().BeEqualTo("user-config"); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, ".env"))).Should().BeEqualTo("V2RAYN_WEB_API_KEY=private-test-key\n"); + await File.Exists(Path.Combine(plan.BackupDirectory, ".env")).Should().BeFalse(); + } + + [Test] + public async Task PartialFileReplacementFailureRestoresOldAppWithoutTouchingCoreFiles() + { + using var directory = new TemporaryDirectory(); + var plan = await CreatePlanAsync(directory.Path, includeNewIdentity: false); + var extraHash = await HashDirectoryAsync(Path.Combine(plan.InstallDirectory, "extra")); + NativeWebUpdateHelper.CopyCurrentAppToBackup(plan); + + var rollback = await NativeWebUpdateWorkflow.ApplyAsync( + () => { NativeWebUpdateHelper.SwapCandidateAppIntoPlace(plan); return Task.CompletedTask; }, + () => Task.FromResult(false), + () => { NativeWebUpdateHelper.RestorePreviousApp(plan); return Task.CompletedTask; }, + () => Task.FromResult(true), + _ => { }, + () => Task.CompletedTask); + + await rollback.Success.Should().BeFalse(); + await rollback.RollbackSucceeded.Should().BeTrue(); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, ExecutableName))).Should().BeEqualTo("old-web"); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, "extra", "marker.dat"))).Should().BeEqualTo("UNRELATED_FILE_TEST"); + await (await HashDirectoryAsync(Path.Combine(plan.InstallDirectory, "extra"))).Should().BeEqualTo(extraHash); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, "bin", "xray", "xray"))).Should().BeEqualTo("updated-core"); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, ".env"))).Should().BeEqualTo("V2RAYN_WEB_API_KEY=private-test-key\n"); + await File.Exists(Path.Combine(plan.BackupDirectory, ".env")).Should().BeFalse(); + } + + private static async Task CreatePlanAsync(string root, bool includeNewIdentity) + { + var install = Path.Combine(root, "native"); + var parent = Path.GetDirectoryName(install)!; + var candidate = Path.Combine(parent, ".v2rayn-WebAPI-candidate-test"); + var backup = Path.Combine(parent, ".v2rayn-WebAPI-backup-test"); + Directory.CreateDirectory(Path.Combine(install, "extra", "nested")); + Directory.CreateDirectory(Path.Combine(install, "bin", "xray")); + Directory.CreateDirectory(Path.Combine(install, "guiConfigs")); + Directory.CreateDirectory(candidate); + await File.WriteAllTextAsync(Path.Combine(install, ExecutableName), "old-web"); + await File.WriteAllTextAsync(Path.Combine(install, "v2rayN.WebAPI.build.json"), "old-identity"); + await File.WriteAllTextAsync(Path.Combine(install, "extra", "marker.dat"), "UNRELATED_FILE_TEST"); + await File.WriteAllTextAsync(Path.Combine(install, "extra", "nested", "test.dat"), "unrelated nested data"); + await File.WriteAllTextAsync(Path.Combine(install, "bin", "xray", "xray"), "updated-core"); + await File.WriteAllTextAsync(Path.Combine(install, "guiConfigs", "guiNConfig.json"), "user-config"); + await File.WriteAllTextAsync(Path.Combine(install, ".env"), "V2RAYN_WEB_API_KEY=private-test-key\n"); + await File.WriteAllTextAsync(Path.Combine(candidate, ExecutableName), "new-web"); + if (includeNewIdentity) + await File.WriteAllTextAsync(Path.Combine(candidate, "v2rayN.WebAPI.build.json"), "new-identity"); + return new NativeWebUpdatePlan( + install, + candidate, + backup, + Path.Combine(root, "instance.lock"), + "http://127.0.0.1:5080/api/health", + [], + "7.25.2-web.2", + "new-commit", + Rid, + "7.25.2-web.1", + Path.Combine(root, "runtime-intent.json"), + Path.Combine(root, "update-progress.json")); + } + + private static async Task HashDirectoryAsync(string path) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Order(StringComparer.Ordinal)) + { + hash.AppendData(Encoding.UTF8.GetBytes(Path.GetRelativePath(path, file).Replace(Path.DirectorySeparatorChar, '/'))); + hash.AppendData([0]); + hash.AppendData(await File.ReadAllBytesAsync(file)); + } + return Convert.ToHexString(hash.GetHashAndReset()); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-swap-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateHelperTests.cs b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateHelperTests.cs new file mode 100644 index 00000000..907de51d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateHelperTests.cs @@ -0,0 +1,124 @@ +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Tests; + +public class NativeWebUpdateHelperTests +{ + [Test] + public async Task TransientFileOperationFailuresAreRetriedBeforeTheUpdateGivesUp() + { + var attempts = 0; + NativeWebUpdateHelper.WithTransientFileRetry( + () => + { + attempts++; + if (attempts < 3) + { + throw new IOException("The process cannot access the file because it is being used by another process."); + } + }, + "test file replacement", + retryWindow: TimeSpan.FromSeconds(2), + retryDelay: TimeSpan.FromMilliseconds(10)); + + await (attempts == 3).Should().BeTrue(); + } + + [Test] + public async Task PermanentFileOperationFailuresSurfaceTheOperationAndCauseAfterRetries() + { + var attempts = 0; + IOException? failure = null; + try + { + NativeWebUpdateHelper.WithTransientFileRetry( + () => + { + attempts++; + throw new UnauthorizedAccessException("Access is denied."); + }, + "replacing the installed Web executable 'C:/install/v2rayN.WebAPI.exe'", + retryWindow: TimeSpan.FromMilliseconds(120), + retryDelay: TimeSpan.FromMilliseconds(10)); + } + catch (IOException exception) + { + failure = exception; + } + + await (failure is not null).Should().BeTrue(); + await failure!.Message.Contains("replacing the installed Web executable").Should().BeTrue(); + await failure.Message.Contains("Access is denied.").Should().BeTrue(); + await (attempts > 1).Should().BeTrue(); + } + + [Test] + public async Task OnlyTheInstalledExecutableAndGuidNamedWorkersAreAccepted() + { + await NativeWebUpdateHelper.IsUpdateHelperExecutableName("v2rayN.WebAPI").Should().BeTrue(); + await NativeWebUpdateHelper.IsUpdateHelperExecutableName("v2rayN.WebAPI.exe").Should().BeTrue(); + await NativeWebUpdateHelper.IsUpdateHelperExecutableName( + ".v2rayn-WebAPI-update-helper-" + Guid.NewGuid().ToString("N")).Should().BeTrue(); + await NativeWebUpdateHelper.IsUpdateHelperExecutableName( + ".v2rayn-WebAPI-update-helper-" + Guid.NewGuid().ToString("N") + ".exe").Should().BeTrue(); + foreach (var name in new[] { "", "v2rayN.Web", "other", ".v2rayn-WebAPI-update-helper-", + ".v2rayn-WebAPI-update-helper-not-a-guid", ".v2rayn-WebAPI-update-helper-" + Guid.NewGuid().ToString("D"), + "../v2rayN.WebAPI", ".v2rayn-WebAPI-update-helper-" + Guid.NewGuid().ToString("N") + ".bak" }) + { + await NativeWebUpdateHelper.IsUpdateHelperExecutableName(name).Should().BeFalse(); + } + } + + [Test] + public async Task WorkerBundleStaysBesideTheInstallAndSurvivesExecutableReplacement() + { + var directory = Path.Combine(Path.GetTempPath(), $"webapi-helper-copy-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var executable = Path.Combine(directory, OperatingSystem.IsWindows() ? "v2rayN.WebAPI.exe" : "v2rayN.WebAPI"); + await File.WriteAllTextAsync(executable, "original-bundle"); + var helper = NativeWebUpdateHelper.CreateIsolatedHelperExecutable(executable); + await Path.GetDirectoryName(helper).Should().BeEqualTo(directory); + await NativeWebUpdateHelper.IsUpdateHelperExecutableName(Path.GetFileName(helper)).Should().BeTrue(); + if (OperatingSystem.IsLinux()) + await File.GetUnixFileMode(helper).Should().BeEqualTo( + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var candidate = Path.Combine(directory, "candidate"); + await File.WriteAllTextAsync(candidate, "different-bundle-layout"); + File.Move(candidate, executable, overwrite: true); + await (await File.ReadAllTextAsync(helper)).Should().BeEqualTo("original-bundle"); + await (await File.ReadAllTextAsync(executable)).Should().BeEqualTo("different-bundle-layout"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task SymlinkManagedExecutablesCannotCreateWorkers() + { + if (!OperatingSystem.IsLinux()) return; + var directory = Path.Combine(Path.GetTempPath(), $"webapi-helper-link-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var target = Path.Combine(directory, "target"); + await File.WriteAllTextAsync(target, "unchanged"); + var link = Path.Combine(directory, "v2rayN.WebAPI"); + File.CreateSymbolicLink(link, target); + var rejected = false; + try { NativeWebUpdateHelper.CreateIsolatedHelperExecutable(link); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + await Directory.GetFiles(directory, ".v2rayn-WebAPI-update-helper-*").Length.Should().BeEqualTo(0); + await (await File.ReadAllTextAsync(target)).Should().BeEqualTo("unchanged"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateWorkflowTests.cs b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateWorkflowTests.cs new file mode 100644 index 00000000..21c5dc76 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateWorkflowTests.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Tests; + +public class NativeWebUpdateWorkflowTests +{ + [Test, NotInParallel] + public async Task InFlightHelperProgressIsReReadUntilTheTerminalResult() + { + var previousLocalData = Environment.GetEnvironmentVariable(ServiceLib.Global.LocalAppData); + Environment.SetEnvironmentVariable(ServiceLib.Global.LocalAppData, "0"); + var path = ServiceLib.Common.Utils.GetTempPath("WebAPI-update-progress.json"); + var previous = File.Exists(path) ? await File.ReadAllBytesAsync(path) : null; + try + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + var runtime = new v2rayN.WebAPI.Services.V2rayRuntime(null!, null!, null!, null!, null!); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize( + new WebUpdateProgressState("verifying-health", false, false, "7.25.91", null, CoreWasRunning: true), options)); + await runtime.GetWebUpdateProgress()!.IsComplete.Should().BeFalse(); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize( + new WebUpdateProgressState("failed", true, false, "7.25.91", null, true, true), options)); + var terminal = runtime.GetWebUpdateProgress()!; + await terminal.Phase.Should().BeEqualTo("failed"); + await terminal.IsComplete.Should().BeTrue(); + await terminal.RollbackSucceeded.Should().BeEqualTo(true); + await terminal.CoreWasRunning.Should().BeTrue(); + } + finally + { + if (previous is null) File.Delete(path); + else await File.WriteAllBytesAsync(path, previous); + Environment.SetEnvironmentVariable(ServiceLib.Global.LocalAppData, previousLocalData); + } + } + + [Test] + public async Task PersistedProgressKeepsThePreUpdateCoreStateAndAcceptsOlderProgress() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + var state = new WebUpdateProgressState("completed", true, true, "7.25.91", null, + CoreWasRunning: true); + var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(state, options), options); + await restored!.CoreWasRunning.Should().BeTrue(); + + var legacy = JsonSerializer.Deserialize( + """{"phase":"completed","isComplete":true,"success":true,"version":"7.25.90"}""", options); + await legacy!.CoreWasRunning.Should().BeFalse(); + } + + [Test] + public async Task UpdateHealthRequiresTheOriginalRunningOrStoppedRuntimeIntent() + { + var runningIntent = new v2rayN.WebAPI.Services.RuntimeRestartIntent(true, "removed-profile"); + var recoveredWithFallback = new WebHealthProbeResult( + true, 42, CoreProcessIds: [99], CoreState: "running", WebVersion: "7.25.2-web.9", CoreProfileId: "fallback-profile"); + await NativeWebUpdateWorkflow.RuntimeIntentIsSatisfied(runningIntent, recoveredWithFallback).Should().BeTrue(); + + var stoppedIntent = new v2rayN.WebAPI.Services.RuntimeRestartIntent(false, "old-profile"); + var remainsStopped = new WebHealthProbeResult(true, 43, CoreProcessIds: [], CoreState: "stopped"); + await NativeWebUpdateWorkflow.RuntimeIntentIsSatisfied(stoppedIntent, remainsStopped).Should().BeTrue(); + await NativeWebUpdateWorkflow.RuntimeIntentIsSatisfied(stoppedIntent, + recoveredWithFallback with { CoreState = "running" }).Should().BeFalse(); + await NativeWebUpdateWorkflow.RuntimeIntentIsSatisfied(runningIntent, + remainsStopped with { CoreState = "stopped" }).Should().BeFalse(); + } + + [Test] + public async Task HealthyNewBuildCommitsAndCleansThePreviousFiles() + { + var sequence = new List(); + var result = await NativeWebUpdateWorkflow.ApplyAsync( + () => { sequence.Add("install-new"); return Task.CompletedTask; }, + () => { sequence.Add("start-new-health-ok"); return Task.FromResult(true); }, + () => { sequence.Add("restore-old"); return Task.CompletedTask; }, + () => { sequence.Add("start-old"); return Task.FromResult(true); }, + sequence.Add, + () => { sequence.Add("cleanup-backup"); return Task.CompletedTask; }); + + await result.Success.Should().BeTrue(); + await result.RollbackSucceeded.Should().BeFalse(); + await sequence.Contains("restore-old").Should().BeFalse(); + await sequence.Last().Should().BeEqualTo("cleanup-backup"); + } + + [Test] + public async Task NewProcessExitOrHealthTimeoutRestoresTheOldHealthyBuild() + { + var sequence = new List(); + var result = await NativeWebUpdateWorkflow.ApplyAsync( + () => { sequence.Add("install-new"); return Task.CompletedTask; }, + () => { sequence.Add("new-health-timeout"); return Task.FromResult(false); }, + () => { sequence.Add("restore-old-files"); return Task.CompletedTask; }, + () => { sequence.Add("old-health-ok"); return Task.FromResult(true); }, + sequence.Add, + () => { sequence.Add("cleanup-backup"); return Task.CompletedTask; }); + + await result.Success.Should().BeFalse(); + await result.RollbackSucceeded.Should().BeTrue(); + await sequence.IndexOf("restore-old-files").Should().BeGreaterThan(sequence.IndexOf("new-health-timeout")); + await sequence.IndexOf("old-health-ok").Should().BeGreaterThan(sequence.IndexOf("restore-old-files")); + } + + [Test] + public async Task FileReplacementFailureStillAttemptsToRestartTheOldBuild() + { + var sequence = new List(); + var result = await NativeWebUpdateWorkflow.ApplyAsync( + () => throw new IOException("simulated replace failure"), + () => { sequence.Add("new-health"); return Task.FromResult(false); }, + () => { sequence.Add("restore-old-files"); return Task.CompletedTask; }, + () => { sequence.Add("old-health-ok"); return Task.FromResult(true); }, + sequence.Add, + () => { sequence.Add("cleanup-backup"); return Task.CompletedTask; }); + + await result.RollbackSucceeded.Should().BeTrue(); + await result.Detail.Should().Contain("simulated replace failure"); + await sequence.Contains("new-health").Should().BeFalse(); + await sequence.Contains("old-health-ok").Should().BeTrue(); + } + + [Test] + public async Task FailedOldBuildHealthIsReportedWithoutDeletingTheBackup() + { + var backupCleaned = false; + var result = await NativeWebUpdateWorkflow.ApplyAsync( + () => Task.CompletedTask, + () => Task.FromResult(false), + () => Task.CompletedTask, + () => Task.FromResult(false), + _ => { }, + () => { backupCleaned = true; return Task.CompletedTask; }); + + await result.Success.Should().BeFalse(); + await result.RollbackSucceeded.Should().BeFalse(); + await backupCleaned.Should().BeFalse(); + await result.Detail.Should().Contain("did not become healthy"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/ParityValidationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/ParityValidationTests.cs new file mode 100644 index 00000000..63e5e701 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/ParityValidationTests.cs @@ -0,0 +1,239 @@ +using ServiceLib; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class ParityValidationTests +{ + [Test] + public async Task RestoredSubscriptionSelectionIsPreservedOnlyWhenItsSubItemExists() + { + var available = new[] { "subscription-a", "subscription-b" }; + await V2rayRuntime.NormalizeSelectedSubscriptionId("subscription-a", available).Should().BeEqualTo("subscription-a"); + await V2rayRuntime.NormalizeSelectedSubscriptionId("deleted-subscription", available).Should().BeEqualTo(string.Empty); + await V2rayRuntime.NormalizeSelectedSubscriptionId(string.Empty, available).Should().BeEqualTo(string.Empty); + } + + [Test] + public async Task XrayShadowsocksPlainIsAcceptedAndSavedByServiceLib() + { + var profile = CreateShadowsocks(ECoreType.Xray, "plain"); + + var valid = V2rayRuntime.TryValidateProfile(profile, out var code, out _); + await valid.Should().BeTrue(); + await code.Should().BeEqualTo("ok"); + await profile.IsValid().Should().BeFalse(); + + var result = await ConfigHandler.AddShadowsocksServer(new Config(), profile, toFile: false); + + await result.Should().BeEqualTo(0); + await profile.ConfigVersion.Should().BeEqualTo(4); + } + + [Test] + public async Task SingboxShadowsocksMethodIsAcceptedAndSavedByServiceLib() + { + var method = "aes-128-gcm"; + var profile = CreateShadowsocks(ECoreType.sing_box, method); + + var valid = V2rayRuntime.TryValidateProfile(profile, out var code, out _); + await valid.Should().BeTrue(); + await code.Should().BeEqualTo("ok"); + await profile.IsValid().Should().BeTrue(); + + var result = await ConfigHandler.AddShadowsocksServer(new Config(), profile, toFile: false); + + await result.Should().BeEqualTo(0); + } + + [Test] + public async Task InvalidShadowsocksMethodIsLeftForServiceLibToReject() + { + var profile = CreateShadowsocks(ECoreType.Xray, "not-a-method"); + + var valid = V2rayRuntime.TryValidateProfile(profile, out _, out _); + await valid.Should().BeTrue(); + + var result = await ConfigHandler.AddShadowsocksServer(new Config(), profile, toFile: false); + + await result.Should().BeEqualTo(-1); + } + + [Test] + public async Task ExistingProfileConfigTypeCannotChange() + { + var existing = CreateShadowsocks(ECoreType.Xray, "plain"); + var unchanged = CreateShadowsocks(ECoreType.Xray, "plain"); + var converted = new ProfileItem { ConfigType = EConfigType.VLESS }; + + await V2rayRuntime.IsConfigTypeUnchanged(unchanged, existing).Should().BeTrue(); + await V2rayRuntime.IsConfigTypeUnchanged(converted, existing).Should().BeFalse(); + } + + [Test] + public async Task HttpHeadersJsonUsesServiceLibParserOnlyForHttpProfiles() + { + var validHttp = CreateProfile(EConfigType.HTTP); + validHttp.SetProtocolExtra(new ProtocolExtraItem { HttpHeaders = "{\"X-Test\":\"value\"}" }); + var emptyHttp = CreateProfile(EConfigType.HTTP); + emptyHttp.SetProtocolExtra(new ProtocolExtraItem { HttpHeaders = string.Empty }); + var invalidHttp = CreateProfile(EConfigType.HTTP); + invalidHttp.SetProtocolExtra(new ProtocolExtraItem { HttpHeaders = "{invalid" }); + var invalidNonHttp = CreateProfile(EConfigType.SOCKS); + invalidNonHttp.SetProtocolExtra(new ProtocolExtraItem { HttpHeaders = "{invalid" }); + var originalInvalidHeaders = invalidHttp.ProtoExtra; + + await V2rayRuntime.TryValidateProfile(validHttp, out _, out _).Should().BeTrue(); + await V2rayRuntime.TryValidateProfile(emptyHttp, out _, out _).Should().BeTrue(); + await V2rayRuntime.TryValidateProfile(invalidHttp, out var code, out _).Should().BeFalse(); + await code.Should().BeEqualTo("profile_http_headers_invalid"); + await (invalidHttp.ProtoExtra == originalInvalidHeaders).Should().BeTrue(); + await V2rayRuntime.TryValidateProfile(invalidNonHttp, out _, out _).Should().BeTrue(); + } + + [Test] + public async Task WebCoreMappingsAreLimitedToUpstreamEditableTypes() + { + var editableTypes = Enum.GetValues() + .Where(V2rayRuntime.IsWebEditableCoreType) + .ToArray(); + var expected = new[] + { + EConfigType.VMess, + EConfigType.Custom, + EConfigType.Shadowsocks, + EConfigType.SOCKS, + EConfigType.VLESS, + EConfigType.Trojan, + EConfigType.Hysteria2, + EConfigType.WireGuard, + }; + await (editableTypes.SequenceEqual(expected)).Should().BeTrue(); + + var validMappings = expected.Select(configType => new CoreTypeMapping(configType, ECoreType.Xray)).ToArray(); + await V2rayRuntime.AreWebCoreTypeMappingsValid(validMappings).Should().BeTrue(); + foreach (var configType in new[] + { + EConfigType.TUIC, EConfigType.HTTP, EConfigType.Anytls, EConfigType.Naive, + EConfigType.Outbound, EConfigType.PolicyGroup, EConfigType.ProxyChain, + }) + { + await V2rayRuntime.AreWebCoreTypeMappingsValid([new(configType, ECoreType.Xray)]).Should().BeFalse(); + } + } + + [Test] + public async Task FixedCoreSettingsOptionsRejectUnknownValuesAndAllowEmptyValues() + { + await V2rayRuntime.AreCoreSettingsOptionsValid(CreateCoreSettings( + fingerprint: "chrome", userAgent: "chrome", mux4SboxProtocol: "h2mux", + xudpProxyUDP443: "reject", fragmentPackets: "tlshello")).Should().BeTrue(); + await V2rayRuntime.AreCoreSettingsOptionsValid(CreateCoreSettings( + fingerprint: string.Empty, userAgent: string.Empty, mux4SboxProtocol: string.Empty, + xudpProxyUDP443: null, fragmentPackets: null)).Should().BeTrue(); + await V2rayRuntime.AreCoreSettingsOptionsValid(CreateCoreSettings( + fingerprint: "random-user-value", userAgent: "random-user-value", mux4SboxProtocol: "random-user-value", + xudpProxyUDP443: "random-user-value", fragmentPackets: "random-user-value")).Should().BeFalse(); + } + + [Test] + public async Task DestOverrideOnlyAcceptsUpstreamProtocols() + { + await V2rayRuntime.AreDestOverrideProtocolsValid(["http", "tls"]).Should().BeTrue(); + await V2rayRuntime.AreDestOverrideProtocolsValid([]).Should().BeTrue(); + await V2rayRuntime.AreDestOverrideProtocolsValid(null).Should().BeTrue(); + await V2rayRuntime.AreDestOverrideProtocolsValid(["http", "arbitrary"]).Should().BeFalse(); + } + + [Test] + public async Task RootCertificateProviderAcceptsOnlyDesktopOptionsOrAnEmptyValue() + { + await V2rayRuntime.IsRootCertProviderValid(null).Should().BeTrue(); + await V2rayRuntime.IsRootCertProviderValid(string.Empty).Should().BeTrue(); + foreach (var provider in Global.RootCertProviders) + { + await V2rayRuntime.IsRootCertProviderValid(provider).Should().BeTrue(); + } + await V2rayRuntime.IsRootCertProviderValid("not-a-desktop-provider").Should().BeFalse(); + } + + [Test] + public async Task SettingsApiDoesNotInventDesktopEditorFieldsForHiddenServiceLibValues() + { + await (typeof(CoreSettingsView).GetProperty("Mux4SboxMaxConnections") is null).Should().BeTrue(); + await (typeof(CoreSettingsInput).GetProperty("Mux4SboxMaxConnections") is null).Should().BeTrue(); + await (typeof(SpeedTestSettingsView).GetProperty("SpeedTestPageSize") is null).Should().BeTrue(); + await (typeof(SpeedTestSettingsInput).GetProperty("SpeedTestDelayInterval") is null).Should().BeTrue(); + } + + [Test] + public async Task RoutingProfileStrategiesUseTheTwoUpstreamOptionSets() + { + foreach (var strategy in new[] { string.Empty, "AsIs", "IPIfNonMatch", "IPOnDemand" }) + { + await V2rayRuntime.AreRoutingProfileStrategiesValid(strategy, string.Empty).Should().BeTrue(); + } + foreach (var strategy in new[] { "", "prefer_ipv4", "prefer_ipv6", "ipv4_only", "ipv6_only" }) + { + await V2rayRuntime.AreRoutingProfileStrategiesValid("AsIs", strategy).Should().BeTrue(); + } + await V2rayRuntime.AreRoutingProfileStrategiesValid("UseIP", "random-value").Should().BeFalse(); + } + + [Test] + public async Task FullConfigTemplateTunConfigCanBeEditedSavedAndReloadedWithoutLoss() + { + var current = new FullConfigTemplateItem + { + Id = "template-id", + Remarks = "custom template", + Enabled = true, + CoreType = ECoreType.Xray, + Config = "main-template", + TunConfig = "old-tun-template", + AddProxyOnly = true, + ProxyDetour = "upstream", + }; + var saved = V2rayRuntime.ApplyCoreConfigTemplateInput(current, ECoreType.Xray, + new CoreConfigTemplateInput(current.Remarks, current.Enabled, current.Config, "new-tun-template", current.AddProxyOnly, current.ProxyDetour)); + var reloaded = V2rayRuntime.ToCoreConfigTemplateView(saved); + + await reloaded.Config.Should().BeEqualTo("main-template"); + await reloaded.TunConfig.Should().BeEqualTo("new-tun-template"); + await reloaded.AddProxyOnly.Should().BeEqualTo(true); + await reloaded.ProxyDetour.Should().BeEqualTo("upstream"); + } + + private static ProfileItem CreateShadowsocks(ECoreType coreType, string method) + { + var profile = CreateProfile(EConfigType.Shadowsocks); + profile.CoreType = coreType; + profile.Password = "password"; + profile.ConfigVersion = 3; + profile.SetProtocolExtra(new ProtocolExtraItem { SsMethod = method }); + return profile; + } + + private static ProfileItem CreateProfile(EConfigType configType) => new() + { + ConfigType = configType, + Remarks = "test profile", + Address = "example.test", + Port = 443, + }; + + private static CoreSettingsInput CreateCoreSettings( + string? fingerprint, + string? userAgent, + string? mux4SboxProtocol, + string? xudpProxyUDP443, + string? fragmentPackets) => new( + false, "warning", fingerprint, userAgent, null, null, + null, null, xudpProxyUDP443, mux4SboxProtocol, null, false, + 0, 0, false, false, fragmentPackets, [], [], null); +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RegionalPresetStagingTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RegionalPresetStagingTests.cs new file mode 100644 index 00000000..e7c76f2d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RegionalPresetStagingTests.cs @@ -0,0 +1,678 @@ +using System.Collections.Concurrent; +using System.Net; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Dto; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class RegionalPresetStagingTests +{ + [Test] + public async Task RegionalPresetStagesAllRemoteDnsAndRoutingContentWithoutChangingUserProfileState() + { + var existingDns = CreateDnsRows(); + var responses = CreateRegionalResponses(); + var requestedUrls = new ConcurrentBag(); + var stager = new RegionalPresetStager((url, _, token) => + { + token.ThrowIfCancellationRequested(); + requestedUrls.Add(url); + return Task.FromResult(responses.GetValueOrDefault(url)); + }); + + var staged = await stager.StageAsync( + EPresetType.Russia, + existingDns, + [], + proxy: null, + CancellationToken.None); + + await staged.GeoSourceUrl.Should().BeEqualTo(Global.GeoFilesSources[1]); + await staged.SrsSourceUrl.Should().BeEqualTo(Global.SingboxRulesetSources[1]); + await staged.RouteRulesTemplateSourceUrl.Should().BeEqualTo(Global.RoutingRulesSources[1]); + await staged.DnsItems.Count.Should().BeEqualTo(2); + await (staged.DnsItems[0].Id == "xray-id" && staged.DnsItems[0].Enabled && staged.DnsItems[0].Remarks == "my Xray DNS").Should().BeTrue(); + await (staged.DnsItems[0].NormalDNS == "xray-normal-content" && staged.DnsItems[0].TunDNS == "xray-tun-content").Should().BeTrue(); + await (staged.DnsItems[1].Id == "singbox-id" && !staged.DnsItems[1].Enabled && staged.DnsItems[1].Remarks == "my sing-box DNS").Should().BeTrue(); + await staged.SimpleDnsItem.RemoteDNS.Should().BeEqualTo("https://dns.example.test/remote"); + await staged.Routing.TemplateVersion.Should().BeEqualTo("R1"); + await staged.Routing.Items.Count.Should().BeEqualTo(1); + await staged.Routing.Items[0].Remarks.Should().BeEqualTo("R1-Region rules"); + await existingDns[0].NormalDNS.Should().BeEqualTo("existing-xray-data"); + await requestedUrls.Count.Should().BeEqualTo(7); + } + + [Test] + public async Task DefaultPresetStagesOnlyLocalDefaultsAndDoesNotInvokeDownloader() + { + var downloaded = false; + var stager = new RegionalPresetStager((_, _, _) => + { + downloaded = true; + return Task.FromResult(null); + }); + + var staged = await stager.StageAsync(EPresetType.Default, [], [], null, CancellationToken.None); + + await downloaded.Should().BeFalse(); + await staged.DnsItems.Count.Should().BeEqualTo(2); + await staged.DnsItems.All(item => !item.Enabled).Should().BeTrue(); + await staged.GeoSourceUrl.Should().BeEqualTo(string.Empty); + await staged.Routing.Items.Count.Should().BeEqualTo(0); + } + + [Test] + public async Task SlowFailingPresetStagesDesktopFallbacksOutsideCoreAndMutationGates() + { + var downloadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var failDownload = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var coreGate = new SemaphoreSlim(1, 1); + var mutationGate = new SemaphoreSlim(1, 1); + var config = SniffingSettingsIntegrationTests.CreateConfig(); + config.ConstItem.GeoSourceUrl = "original-geo-source"; + SniffingSettingsIntegrationTests.BindAppManagerConfig(config); + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + var dnsRows = new List { "original dns" }; + var routingRows = new List { "original routing" }; + var applyCalled = false; + RegionalPresetStage? capturedStage = null; + var stager = new RegionalPresetStager(async (_, _, token) => + { + downloadStarted.TrySetResult(); + await failDownload.Task.WaitAsync(token); + throw new IOException("simulated download failure"); + }, TimeSpan.FromSeconds(2)); + var workflow = RegionalPresetWorkflow.RunAsync( + token => stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, token), + (stage, _) => + { + applyCalled = true; + capturedStage = stage; + return Task.FromResult(true); + }, + CancellationToken.None); + + await downloadStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + var settingsRead = await runtime.GetSettingsAsync(); + await settingsRead.App.GeoSourceUrl.Should().BeEqualTo("original-geo-source"); + await coreGate.WaitAsync(TimeSpan.Zero); + coreGate.Release(); + await mutationGate.WaitAsync(TimeSpan.Zero); + mutationGate.Release(); + + failDownload.TrySetResult(); + await workflow; + await applyCalled.Should().BeTrue(); + await capturedStage.Should().NotBeNull(); + await capturedStage!.DnsItems[0].NormalDNS.Should().BeEqualTo("existing-xray-data"); + await capturedStage.SimpleDnsItem.RemoteDNS.Should().BeEqualTo(ConfigHandler.InitBuiltinSimpleDNS().RemoteDNS); + await capturedStage.Routing.IsBuiltinFallback.Should().BeTrue(); + await capturedStage.Routing.Items.Count.Should().BeEqualTo(3); + await config.ConstItem.GeoSourceUrl.Should().BeEqualTo("original-geo-source"); + await dnsRows.SequenceEqual(new[] { "original dns" }).Should().BeTrue(); + await routingRows.SequenceEqual(new[] { "original routing" }).Should().BeTrue(); + } + + [Test] + public async Task RequestCancellationCancelsStagingBeforeApply() + { + using var cancellation = new CancellationTokenSource(); + var downloadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var applyCalled = false; + var stager = new RegionalPresetStager(async (_, _, token) => + { + downloadStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return null; + }); + var workflow = RegionalPresetWorkflow.RunAsync( + token => stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, token), + (_, _) => + { + applyCalled = true; + return Task.FromResult(true); + }, + cancellation.Token); + + await downloadStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + cancellation.Cancel(); + var cancelled = false; + try + { + await workflow; + } + catch (OperationCanceledException) + { + cancelled = true; + } + + await cancelled.Should().BeTrue(); + await applyCalled.Should().BeFalse(); + } + + [Test] + public async Task FailedApplyRunsRollbackAndCannotTriggerCoreRestart() + { + var configValue = "before"; + var dnsRows = new List { "dns-before" }; + var routingRows = new List { "routing-before" }; + var restartCalled = false; + + var result = await RegionalPresetTransaction.RunAsync( + async () => + { + configValue = "after"; + dnsRows[0] = "dns-after"; + routingRows[0] = "routing-after"; + await Task.Yield(); + throw new IOException("simulated persistence failure"); + }, + () => + { + configValue = "before"; + dnsRows[0] = "dns-before"; + routingRows[0] = "routing-before"; + return Task.FromResult(true); + }); + + if (result.Success) restartCalled = true; + await result.Success.Should().BeFalse(); + await result.RollbackSucceeded.Should().BeTrue(); + await restartCalled.Should().BeFalse(); + await configValue.Should().BeEqualTo("before"); + await dnsRows.SequenceEqual(new[] { "dns-before" }).Should().BeTrue(); + await routingRows.SequenceEqual(new[] { "routing-before" }).Should().BeTrue(); + } + + [Test] + public async Task DefaultRussiaAndIranApplySourcesBeforeGeoUpdateAndCoreCompletion() + { + foreach (var (preset, sourceIndex) in new[] + { + (EPresetType.Default, -1), + (EPresetType.Russia, 1), + (EPresetType.Iran, 2), + }) + { + var responses = sourceIndex < 0 ? new Dictionary() : CreateRegionalResponses(sourceIndex); + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + var stage = await stager.StageAsync( + preset, + sourceIndex < 0 ? [] : CreateDnsRows(), + [], + null, + CancellationToken.None); + var currentGeoSource = "old-geo-source"; + var currentSrsSource = "old-srs-source"; + var updaterGeoSource = string.Empty; + var updaterSrsSource = string.Empty; + var order = new List(); + + await RegionalPresetApplyWorkflow.RunAsync( + applyConfiguration: _ => + { + currentGeoSource = stage.GeoSourceUrl; + currentSrsSource = stage.SrsSourceUrl; + order.Add("config/db apply"); + return Task.CompletedTask; + }, + updateGeoFilesTransaction: async (beforeCommit, token) => + { + order.Add("GeoFiles update started"); + updaterGeoSource = currentGeoSource; + updaterSrsSource = currentSrsSource; + await Task.Yield(); + order.Add("GeoFiles update completed"); + await beforeCommit(() => Task.CompletedTask, token); + }, + completeCoreApply: (_, _) => + { + order.Add("Core restart"); + return Task.CompletedTask; + }, + rollbackConfigurationAndRuntime: () => Task.CompletedTask, + CancellationToken.None); + + await updaterGeoSource.Should().BeEqualTo(sourceIndex < 0 ? string.Empty : Global.GeoFilesSources[sourceIndex]); + await updaterSrsSource.Should().BeEqualTo(sourceIndex < 0 ? string.Empty : Global.SingboxRulesetSources[sourceIndex]); + await order.SequenceEqual(new[] + { + "config/db apply", "GeoFiles update started", "GeoFiles update completed", "Core restart", + }).Should().BeTrue(); + } + } + + [Test] + public async Task RunningCoreRestartsOnceAfterGeoUpdateAndStoppedCoreStaysStopped() + { + foreach (var initialState in new[] { CoreRuntimeState.Running, CoreRuntimeState.Stopped }) + { + var geoUpdates = 0; + var restarts = 0; + var action = CoreSettingsApplyAction.SaveOnly; + + await RegionalPresetApplyWorkflow.RunAsync( + _ => Task.CompletedTask, + async (beforeCommit, token) => + { + geoUpdates++; + await beforeCommit(() => Task.CompletedTask, token); + }, + (_, _) => + { + action = CoreSettingsApplyPolicy.Decide( + initialState, + hasActiveChild: initialState == CoreRuntimeState.Running, + changed: true); + if (action == CoreSettingsApplyAction.Restart) + { + restarts++; + } + return Task.CompletedTask; + }, + rollbackConfigurationAndRuntime: () => Task.CompletedTask, + CancellationToken.None); + + await geoUpdates.Should().BeEqualTo(1); + await restarts.Should().BeEqualTo(initialState == CoreRuntimeState.Running ? 1 : 0); + await action.Should().BeEqualTo(initialState == CoreRuntimeState.Running + ? CoreSettingsApplyAction.Restart + : CoreSettingsApplyAction.SaveOnly); + } + } + + [Test] + public async Task GeoFilesFailureRollsBackPresetStateAndSkipsCoreCompletion() + { + using var directory = new TemporaryDirectory(); + var geoip = Path.Combine(directory.Path, "geoip.dat"); + var geosite = Path.Combine(directory.Path, "geosite.dat"); + var newSrs = Path.Combine(directory.Path, "geosite-region.srs"); + await File.WriteAllTextAsync(geoip, "old-geoip"); + await File.WriteAllTextAsync(geosite, "old-geosite"); + var configSource = "old-source"; + var dns = "old-dns"; + var routing = "old-routing"; + var restartCalled = false; + var rollbackCalled = false; + var failed = false; + + try + { + await RegionalPresetApplyWorkflow.RunAsync( + _ => + { + configSource = "new-source"; + dns = "new-dns"; + routing = "new-routing"; + return Task.CompletedTask; + }, + (beforeCommit, token) => GeoFilesUpdateTransaction.ApplyAsync( + [geoip, geosite, newSrs], + [geoip, geosite], + async _ => + { + await File.WriteAllTextAsync(geoip, "partial-geoip"); + await File.WriteAllTextAsync(geosite, "partial-geosite"); + await File.WriteAllTextAsync(newSrs, "partial-srs"); + throw new IOException("simulated GeoFiles failure"); + }, + token, + beforeCommit), + (_, _) => + { + restartCalled = true; + return Task.CompletedTask; + }, + rollbackConfigurationAndRuntime: () => + { + configSource = "old-source"; + dns = "old-dns"; + routing = "old-routing"; + rollbackCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None); + } + catch (IOException exception) when (exception.Message.Contains("simulated GeoFiles failure", StringComparison.Ordinal)) + { + failed = true; + } + + await failed.Should().BeTrue(); + await configSource.Should().BeEqualTo("old-source"); + await dns.Should().BeEqualTo("old-dns"); + await routing.Should().BeEqualTo("old-routing"); + await (await File.ReadAllTextAsync(geoip)).Should().BeEqualTo("old-geoip"); + await (await File.ReadAllTextAsync(geosite)).Should().BeEqualTo("old-geosite"); + await File.Exists(newSrs).Should().BeFalse(); + await rollbackCalled.Should().BeTrue(); + await restartCalled.Should().BeFalse(); + } + + [Test] + public async Task GeoDownloadCancellationRollsBackPresetStateAndFilesWithoutCoreRestart() + { + using var directory = new TemporaryDirectory(); + using var cancellation = new CancellationTokenSource(); + var geoip = Path.Combine(directory.Path, "geoip.dat"); + var geosite = Path.Combine(directory.Path, "geosite.dat"); + await File.WriteAllTextAsync(geoip, "old-geoip"); + await File.WriteAllTextAsync(geosite, "old-geosite"); + var configSource = "old-source"; + var rollbackCalled = false; + var restartCalled = false; + var canceled = false; + + try + { + await RegionalPresetApplyWorkflow.RunAsync( + _ => + { + configSource = "new-source"; + return Task.CompletedTask; + }, + (beforeCommit, token) => GeoFilesUpdateTransaction.ApplyAsync( + [geoip, geosite], + [geoip, geosite], + async updateToken => + { + await File.WriteAllTextAsync(geoip, "partial-geoip", updateToken); + await File.WriteAllTextAsync(geosite, "partial-geosite", updateToken); + cancellation.Cancel(); + updateToken.ThrowIfCancellationRequested(); + }, + token, + beforeCommit), + (_, _) => + { + restartCalled = true; + return Task.CompletedTask; + }, + rollbackConfigurationAndRuntime: () => + { + configSource = "old-source"; + rollbackCalled = true; + return Task.CompletedTask; + }, + cancellation.Token); + } + catch (OperationCanceledException) + { + canceled = true; + } + + await canceled.Should().BeTrue(); + await configSource.Should().BeEqualTo("old-source"); + await (await File.ReadAllTextAsync(geoip)).Should().BeEqualTo("old-geoip"); + await (await File.ReadAllTextAsync(geosite)).Should().BeEqualTo("old-geosite"); + await rollbackCalled.Should().BeTrue(); + await restartCalled.Should().BeFalse(); + } + + [Test] + public async Task CoreRestartFailureRestoresPresetStateAndGeoFilesBeforeReturning() + { + using var directory = new TemporaryDirectory(); + var geoFile = Path.Combine(directory.Path, "geoip.dat"); + await File.WriteAllTextAsync(geoFile, "old-geoip"); + var configSource = "old-source"; + var runtimeState = CoreRuntimeState.Running; + var restartAttempts = 0; + var settingsRollbackCalled = false; + var failed = false; + + try + { + await RegionalPresetApplyWorkflow.RunAsync( + _ => + { + configSource = "new-source"; + return Task.CompletedTask; + }, + (beforeCommit, token) => GeoFilesUpdateTransaction.ApplyAsync( + [geoFile], + [geoFile], + updateToken => File.WriteAllTextAsync(geoFile, "new-geoip", updateToken), + token, + beforeCommit), + async (rollbackGeoFiles, _) => + { + restartAttempts++; + await rollbackGeoFiles(); + configSource = "old-source"; + runtimeState = CoreRuntimeState.Running; + throw new RegionalPresetCoreApplyFailedException(); + }, + rollbackConfigurationAndRuntime: () => + { + settingsRollbackCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None); + } + catch (RegionalPresetCoreApplyFailedException) + { + failed = true; + } + + await failed.Should().BeTrue(); + await configSource.Should().BeEqualTo("old-source"); + await runtimeState.Should().BeEqualTo(CoreRuntimeState.Running); + await restartAttempts.Should().BeEqualTo(1); + await settingsRollbackCalled.Should().BeFalse(); + await (await File.ReadAllTextAsync(geoFile)).Should().BeEqualTo("old-geoip"); + } + + private static List CreateDnsRows() => + [ + new() { Id = "xray-id", CoreType = ECoreType.Xray, Remarks = "my Xray DNS", Enabled = true, NormalDNS = "existing-xray-data" }, + new() { Id = "singbox-id", CoreType = ECoreType.sing_box, Remarks = "my sing-box DNS", Enabled = false, NormalDNS = "existing-singbox-data" }, + ]; + + private static Dictionary CreateRegionalResponses(int sourceIndex = 1) + { + var dnsBase = Global.DNSTemplateSources[sourceIndex]; + var xrayNormalUrl = "https://assets.example.test/xray-normal"; + var xrayTunUrl = "https://assets.example.test/xray-tun"; + var singboxNormalUrl = "https://assets.example.test/singbox-normal"; + var template = new RoutingTemplate + { + Version = "R1", + RoutingItems = + [ + new RoutingItem + { + Remarks = "Region rules", + RuleSet = JsonUtils.Serialize(new List { new() { Remarks = "sample" } }, false), + }, + ], + }; + + return new(StringComparer.Ordinal) + { + [dnsBase + "v2ray.json"] = JsonUtils.Serialize(new DNSItem { NormalDNS = xrayNormalUrl, TunDNS = xrayTunUrl }, false), + [dnsBase + "sing_box.json"] = JsonUtils.Serialize(new DNSItem { NormalDNS = singboxNormalUrl }, false), + [dnsBase + "simple_dns.json"] = JsonUtils.Serialize(new SimpleDNSItem { RemoteDNS = "https://dns.example.test/remote" }, false), + [Global.RoutingRulesSources[sourceIndex]] = JsonUtils.Serialize(template, false), + [xrayNormalUrl] = "xray-normal-content", + [xrayTunUrl] = "xray-tun-content", + [singboxNormalUrl] = "singbox-normal-content", + }; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-regional-preset-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } + + [Test] + public async Task FailedOrInvalidCoreDnsTemplatePreservesExistingDnsProfile() + { + var existingDns = CreateDnsRows(); + var responses = CreateRegionalResponses(); + var dnsBase = Global.DNSTemplateSources[1]; + responses[dnsBase + "v2ray.json"] = null; + responses[dnsBase + "sing_box.json"] = "not-json"; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, existingDns, [], null, CancellationToken.None); + + await (staged.DnsItems[0].Id == existingDns[0].Id + && staged.DnsItems[0].Remarks == existingDns[0].Remarks + && staged.DnsItems[0].Enabled == existingDns[0].Enabled + && staged.DnsItems[0].NormalDNS == existingDns[0].NormalDNS).Should().BeTrue(); + await (staged.DnsItems[1].Id == existingDns[1].Id + && staged.DnsItems[1].Enabled == existingDns[1].Enabled + && staged.DnsItems[1].NormalDNS == existingDns[1].NormalDNS).Should().BeTrue(); + await existingDns[0].NormalDNS.Should().BeEqualTo("existing-xray-data"); + } + + [Test] + public async Task DownloadedDnsTemplateWithFailedExternalResourceKeepsTemplateAndLeavesFailedFieldEmpty() + { + var responses = CreateRegionalResponses(); + responses["https://assets.example.test/xray-normal"] = null; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, CancellationToken.None); + + await staged.DnsItems[0].NormalDNS.Should().BeEqualTo(string.Empty); + await staged.DnsItems[0].TunDNS.Should().BeEqualTo("xray-tun-content"); + await staged.DnsItems[0].Enabled.Should().BeTrue(); + } + + [Test] + public async Task FailedSimpleDnsUsesDesktopBuiltinAndEnablesBothCoreDnsProfiles() + { + var responses = CreateRegionalResponses(); + responses[Global.DNSTemplateSources[1] + "simple_dns.json"] = null; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, CancellationToken.None); + + var builtin = ConfigHandler.InitBuiltinSimpleDNS(); + await staged.SimpleDnsItem.RemoteDNS.Should().BeEqualTo(builtin.RemoteDNS); + await staged.SimpleDnsItem.DirectDNS.Should().BeEqualTo(builtin.DirectDNS); + await staged.DnsItems.All(item => item.Enabled).Should().BeTrue(); + } + + [Test] + public async Task InvalidRoutingTemplateStagesBuiltinFallbackAndMakesFirstBuiltinDefaultWhenNoRoutesExist() + { + var responses = CreateRegionalResponses(); + responses[Global.RoutingRulesSources[1]] = "not-json"; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, CancellationToken.None); + + await staged.Routing.IsBuiltinFallback.Should().BeTrue(); + await staged.Routing.Items.Count.Should().BeEqualTo(3); + await staged.Routing.Items[0].Remarks.Should().BeEqualTo("V4-绕过大陆(Whitelist)"); + await staged.Routing.DefaultRemarks.Should().BeEqualTo(staged.Routing.Items[0].Remarks); + await staged.Routing.Items.All(item => item.RuleNum > 0).Should().BeTrue(); + await staged.Routing.ExistingDefaultItemId.Should().BeNull(); + } + + [Test] + public async Task RoutingTemplateFallbackPreservesExistingActiveRouteLikeDesktopBuiltinInitialization() + { + var responses = CreateRegionalResponses(); + responses[Global.RoutingRulesSources[1]] = null; + var active = new RoutingItem { Id = "active", Remarks = "user route", IsActive = true }; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [active], null, CancellationToken.None); + + await staged.Routing.IsBuiltinFallback.Should().BeTrue(); + await staged.Routing.Items.Count.Should().BeEqualTo(0); + await staged.Routing.DefaultRemarks.Should().BeNull(); + await staged.Routing.ExistingDefaultItemId.Should().BeNull(); + } + + [Test] + public async Task NewRegionalTemplatePlanActivatesFirstNewRouteEvenWhenAnotherRouteIsActive() + { + var responses = CreateRegionalResponses(); + var active = new RoutingItem { Id = "existing", Remarks = "user route", IsActive = true }; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [active], null, CancellationToken.None); + + await staged.Routing.Items.Count.Should().BeEqualTo(1); + await staged.Routing.DefaultRemarks.Should().BeEqualTo("R1-Region rules"); + await active.IsActive.Should().BeTrue(); + } + + [Test] + public async Task ImportedRoutingTemplateVersionStagesNoDuplicateAndNoDefaultSwitch() + { + var responses = CreateRegionalResponses(); + var active = new RoutingItem { Id = "imported", Remarks = "R1-previous route", IsActive = true }; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [active], null, CancellationToken.None); + + await staged.Routing.TemplateVersion.Should().BeEqualTo("R1"); + await staged.Routing.Items.Count.Should().BeEqualTo(0); + await staged.Routing.DefaultRemarks.Should().BeNull(); + await active.IsActive.Should().BeTrue(); + } + + [Test] + public async Task SingleRoutingRuleDownloadFailureSkipsOnlyThatEntryAndActivatesFirstStagedRule() + { + var responses = CreateRegionalResponses(); + var template = new RoutingTemplate + { + Version = "R2", + RoutingItems = + [ + new RoutingItem { Remarks = "unavailable", Url = "https://rules.example.test/missing" }, + new RoutingItem { Remarks = "available", Url = "https://rules.example.test/available" }, + ], + }; + responses[Global.RoutingRulesSources[1]] = JsonUtils.Serialize(template, false); + responses["https://rules.example.test/missing"] = null; + responses["https://rules.example.test/available"] = JsonUtils.Serialize(new List { new() { Remarks = "ok" } }, false); + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, CancellationToken.None); + + await staged.Routing.Items.Count.Should().BeEqualTo(1); + await staged.Routing.Items[0].Remarks.Should().BeEqualTo("R2-available"); + await staged.Routing.DefaultRemarks.Should().BeEqualTo("R2-available"); + } + + [Test] + public async Task PerDownloadTimeoutUsesDesktopFallbackInsteadOfFailingPreset() + { + var stager = new RegionalPresetStager(async (_, _, token) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return null; + }, TimeSpan.FromMilliseconds(20)); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, CancellationToken.None); + + await staged.DnsItems[0].NormalDNS.Should().BeEqualTo("existing-xray-data"); + await staged.SimpleDnsItem.RemoteDNS.Should().BeEqualTo(ConfigHandler.InitBuiltinSimpleDNS().RemoteDNS); + await staged.Routing.IsBuiltinFallback.Should().BeTrue(); + await staged.Routing.Items.Count.Should().BeEqualTo(3); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RestoreRuntimeRecoveryTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RestoreRuntimeRecoveryTests.cs new file mode 100644 index 00000000..2bf665e0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RestoreRuntimeRecoveryTests.cs @@ -0,0 +1,449 @@ +using System.Text.Json; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class RuntimeRestartRecoveryTests +{ + [Test] + public async Task RestoreMarkerWithStoppedCoreSuppressesAutostart() + { + var started = new List(); + var result = await RuntimeRestartRecovery.RecoverAsync( + new RuntimeRestartIntent(false, "old"), + "restored", + "default", + (_, _) => Task.FromResult(true), + (id, _) => + { + started.Add(id); + return Task.FromResult(OperationView.Ok("core.started")); + }, + _ => { }, + CancellationToken.None); + + await (result.Source == RuntimeRestartRecoverySource.NotRequested).Should().BeTrue(); + await (started.Count == 0).Should().BeTrue(); + await RuntimeRestartRecovery.ShouldAutoStart(new RuntimeRestartIntent(false, null), configured: true).Should().BeFalse(); + await RuntimeRestartRecovery.ShouldAutoStart(null, configured: true).Should().BeTrue(); + await RuntimeRestartRecovery.ShouldAutoStart(null, configured: true, setupRequired: true).Should().BeFalse(); + await RuntimeRestartRecovery.ShouldRecoverCore(new RuntimeRestartIntent(true, "selected"), setupRequired: true).Should().BeFalse(); + await RuntimeRestartRecovery.ShouldRecoverCore(new RuntimeRestartIntent(true, "selected"), setupRequired: false).Should().BeTrue(); + await RuntimeRestartRecovery.ShouldAutoStart(null, configured: true, setupRequired: true).Should().BeFalse(); + await RuntimeRestartRecovery.ShouldRecoverCore(new RuntimeRestartIntent(true, "selected"), setupRequired: true).Should().BeFalse(); + await RuntimeRestartRecovery.ShouldRecoverCore(new RuntimeRestartIntent(true, "selected"), setupRequired: false).Should().BeTrue(); + } + + [Test] + public async Task RunningCoreRestoresThePreviousProfileWhenItStillExists() + { + var (result, started, _) = await RecoverAsync( + new RuntimeRestartIntent(true, "old"), + "selected", + "default", + ["old", "selected", "default"]); + + await (result.Source == RuntimeRestartRecoverySource.PreferredProfile).Should().BeTrue(); + await (result.ProfileId == "old").Should().BeTrue(); + await started.Contains("old").Should().BeTrue(); + await started.Count.Should().BeEqualTo(1); + } + + [Test] + public async Task MissingPreviousProfileFallsBackToRestoredSelectedProfile() + { + var (result, started, logs) = await RecoverAsync( + new RuntimeRestartIntent(true, "old"), + "selected", + "default", + ["selected", "default"]); + + await (result.Source == RuntimeRestartRecoverySource.SelectedProfile).Should().BeTrue(); + await (result.ProfileId == "selected").Should().BeTrue(); + await started.Contains("selected").Should().BeTrue(); + await logs.Any(log => log.Contains("Previous profile old no longer exists", StringComparison.Ordinal)).Should().BeTrue(); + await logs.Any(log => log.Contains("Falling back to selected profile selected", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task MissingPreviousAndSelectedProfilesFallBackToServiceLibDefault() + { + var (result, started, logs) = await RecoverAsync( + new RuntimeRestartIntent(true, "old"), + "missing-selected", + "default", + ["default"]); + + await (result.Source == RuntimeRestartRecoverySource.DefaultProfile).Should().BeTrue(); + await (result.ProfileId == "default").Should().BeTrue(); + await started.Contains("default").Should().BeTrue(); + await logs.Any(log => log.Contains("Falling back to the ServiceLib default profile default", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task RestoreWithNoAvailableProfileKeepsCoreStoppedAndLogsContext() + { + var (result, started, logs) = await RecoverAsync( + new RuntimeRestartIntent(true, "old"), + "missing-selected", + null, + []); + + await (result.Source == RuntimeRestartRecoverySource.NoProfile).Should().BeTrue(); + await (started.Count == 0).Should().BeTrue(); + await logs.Any(log => log.Contains("no valid profile exists in the current configuration", StringComparison.Ordinal)).Should().BeTrue(); + await logs.Any(log => log.Contains("Core remains stopped", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task FailedFallbackPreflightIsNotReportedAsSuccessOrRetried() + { + var starts = new List(); + var logs = new List(); + var result = await RuntimeRestartRecovery.RecoverAsync( + new RuntimeRestartIntent(true, "removed"), + "also-removed", + "fallback", + (id, _) => Task.FromResult(id == "fallback"), + (id, _) => + { + starts.Add(id); + return Task.FromResult(OperationView.Fail("profile_validation_failed", "errors.invalidInput")); + }, + logs.Add, + CancellationToken.None); + + await (result.Source == RuntimeRestartRecoverySource.StartFailed).Should().BeTrue(); + await (result.FailureCode == "profile_validation_failed").Should().BeTrue(); + await starts.Contains("fallback").Should().BeTrue(); + await starts.Count.Should().BeEqualTo(1); + await logs.Any(log => log.Contains("No further restart will be attempted", StringComparison.Ordinal)).Should().BeTrue(); + await (!logs.Any(log => log.Contains("Core restored successfully", StringComparison.Ordinal))).Should().BeTrue(); + } + + [Test] + public async Task RestoreRuntimeMarkerSurvivesFailedInitializationAndIsConsumedAfterRecovery() + { + var markerJson = JsonSerializer.Serialize(new RuntimeRestartIntent(true, "old")); + foreach (var reverseCreationOrder in new[] { false, true }) + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "restore-state.json"); + var newest = DateTime.UtcNow.AddMinutes(-1); + var claims = new[] + { + (Name: "consuming-abandoned", Json: JsonSerializer.Serialize(new RuntimeRestartIntent(false, null)), Time: newest.AddMinutes(-1)), + (Name: "consuming-prior", Json: markerJson, Time: newest), + }; + foreach (var claim in reverseCreationOrder ? claims.Reverse() : claims) + { + var claimPath = path + "." + claim.Name; + await File.WriteAllTextAsync(claimPath, claim.Json); + File.SetLastWriteTimeUtc(claimPath, claim.Time); + } + var invalidClaimPath = path + ".consuming-invalid-stale"; + await File.WriteAllTextAsync(invalidClaimPath, "not-json"); + File.SetLastWriteTimeUtc(invalidClaimPath, newest.AddMinutes(1)); + + // Simulate initialization failing after the newest valid claim has been requeued. + var firstStartupFailed = false; + try + { + _ = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + throw new InvalidOperationException("simulated initialization failure"); + } + catch (InvalidOperationException) + { + firstStartupFailed = true; + } + + var secondStartup = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + await firstStartupFailed.Should().BeTrue(); + await (secondStartup is { WasRunning: true, PreferredProfileId: "old" }).Should().BeTrue(); + await File.Exists(path).Should().BeTrue(); + await Directory.GetFiles(directory.Path, "*.consuming-*").Length.Should().BeEqualTo(0); + + await V2rayRuntime.CommitRestoreRuntimeStateAsync(path); + await File.Exists(path).Should().BeFalse(); + await Directory.GetFiles(directory.Path, "*.consuming-*").Length.Should().BeEqualTo(0); + } + + using (var directory = new TemporaryDirectory()) + { + var stoppedIntentPath = Path.Combine(directory.Path, "restore-stopped.json"); + var stoppedIntent = new RuntimeRestartIntent(false, null); + await File.WriteAllTextAsync(stoppedIntentPath, JsonSerializer.Serialize(stoppedIntent)); + try + { + _ = await V2rayRuntime.LoadRuntimeRestartIntentAsync(stoppedIntentPath, CancellationToken.None); + throw new InvalidOperationException("simulated stopped-intent initialization failure"); + } + catch (InvalidOperationException) { } + var stoppedOnSecondStartup = await V2rayRuntime.LoadRuntimeRestartIntentAsync(stoppedIntentPath, CancellationToken.None); + await (stoppedOnSecondStartup is { WasRunning: false }).Should().BeTrue(); + await File.Exists(stoppedIntentPath).Should().BeTrue(); + await V2rayRuntime.CommitRestoreRuntimeStateAsync(stoppedIntentPath); + await File.Exists(stoppedIntentPath).Should().BeFalse(); + } + await markerJson.Should().Contain("PreferredProfileId"); + await (!markerJson.Contains("ProcessIds", StringComparison.Ordinal) + && !markerJson.Contains("ApiPort", StringComparison.Ordinal) + && !markerJson.Contains("Listeners", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task RestoreClaimTieUsesTheMoreInformativeStateRegardlessOfCreationOrder() + { + foreach (var reverseCreationOrder in new[] { false, true }) + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "restore-state.json"); + var sameTimestamp = DateTime.UtcNow.AddMinutes(-1); + var claims = new[] + { + (Name: "consuming-a-stopped", Intent: new RuntimeRestartIntent(false, null)), + (Name: "consuming-z-running", Intent: new RuntimeRestartIntent(true, "old")), + }; + foreach (var claim in reverseCreationOrder ? claims.Reverse() : claims) + { + var claimPath = path + "." + claim.Name; + await File.WriteAllTextAsync(claimPath, JsonSerializer.Serialize(claim.Intent)); + File.SetLastWriteTimeUtc(claimPath, sameTimestamp); + } + + var recovered = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + await (recovered is { WasRunning: true, PreferredProfileId: "old" }).Should().BeTrue(); + await Directory.GetFiles(directory.Path, "*.consuming-*").Length.Should().BeEqualTo(0); + await V2rayRuntime.CommitRestoreRuntimeStateAsync(path); + await File.Exists(path).Should().BeFalse(); + } + } + + [Test] + public async Task ValidPrimaryMarkerWinsOverAbandonedClaimsAndCleansThemUp() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "restore-state.json"); + var primary = new RuntimeRestartIntent(false, "stopped-profile"); + var claim = new RuntimeRestartIntent(true, "newer-claim"); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(primary)); + var claimPath = path + ".consuming-abandoned"; + await File.WriteAllTextAsync(claimPath, JsonSerializer.Serialize(claim)); + File.SetLastWriteTimeUtc(claimPath, DateTime.UtcNow.AddMinutes(1)); + + var recovered = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + + await (recovered is { WasRunning: false, PreferredProfileId: "stopped-profile" }).Should().BeTrue(); + await Directory.GetFiles(directory.Path, "*.consuming-*").Length.Should().BeEqualTo(0); + await V2rayRuntime.CommitRestoreRuntimeStateAsync(path); + await File.Exists(path).Should().BeFalse(); + } + + [Test] + public async Task InvalidPrimaryMarkerIsReplacedByTheNewestValidClaim() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "restore-state.json"); + await File.WriteAllTextAsync(path, "not-json"); + var claimPath = path + ".consuming-recoverable"; + var expected = new RuntimeRestartIntent(true, "recovered-profile"); + await File.WriteAllTextAsync(claimPath, JsonSerializer.Serialize(expected)); + + var recovered = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + + await (recovered is { WasRunning: true, PreferredProfileId: "recovered-profile" }).Should().BeTrue(); + await Directory.GetFiles(directory.Path, "*.consuming-*").Length.Should().BeEqualTo(0); + await V2rayRuntime.CommitRestoreRuntimeStateAsync(path); + await File.Exists(path).Should().BeFalse(); + } + + [Test] + public async Task FailedClaimMoveDoesNotDeleteTheOnlyValidClaim() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "restore-state.json"); + Directory.CreateDirectory(path); + var claimPath = path + ".consuming-recoverable"; + await File.WriteAllTextAsync(claimPath, JsonSerializer.Serialize(new RuntimeRestartIntent(true, "recoverable"))); + + var recovered = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + + await (recovered is null).Should().BeTrue(); + await File.Exists(claimPath).Should().BeTrue(); + } + + [Test] + public async Task WebUpdateRuntimeIntentRemainsUntilTheExternalHealthVerifierCommitsIt() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "WebAPI-update-runtime-state.json"); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(new RuntimeRestartIntent(true, "removed"))); + var intent = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + var started = new List(); + var recovery = await RuntimeRestartRecovery.RecoverAsync( + intent!, + selectedProfileId: "restored", + defaultProfileId: "default", + profileExists: (id, _) => Task.FromResult(id is "restored" or "default"), + startCore: (id, _) => + { + started.Add(id); + return Task.FromResult(OperationView.Ok("core.started")); + }, + _ => { }, + CancellationToken.None); + + await (recovery.Source == RuntimeRestartRecoverySource.SelectedProfile).Should().BeTrue(); + await started.SequenceEqual(["restored"]).Should().BeTrue(); + await File.Exists(path).Should().BeTrue(); + await V2rayRuntime.DeleteRuntimeIntentAsync(path); + await File.Exists(path).Should().BeFalse(); + } + + private static async Task<(RuntimeRestartRecoveryResult Result, List Started, List Logs)> RecoverAsync( + RuntimeRestartIntent intent, + string? selected, + string? defaultProfile, + HashSet existing) + { + var started = new List(); + var logs = new List(); + var result = await RuntimeRestartRecovery.RecoverAsync( + intent, + selected, + defaultProfile, + (id, _) => Task.FromResult(existing.Contains(id)), + (id, _) => + { + started.Add(id); + return Task.FromResult(OperationView.Ok("core.started")); + }, + logs.Add, + CancellationToken.None); + return (result, started, logs); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-restore-intent-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} + +public class FailedRestoreRuntimeRecoveryTests +{ + [Test] + public async Task TrackedCoreProcessKeepsTheRuntimeFaultedWithoutStartingAnotherCore() + { + var started = new List(); + var faulted = new List<(int[] ProcessIds, string Failure)>(); + var logs = new List(); + + var result = await V2rayRuntime.RecoverRuntimeAfterFailedRestoreAsync( + new RuntimeRestartIntent(true, "old"), + [4242], + (processIds, failure) => faulted.Add((processIds, failure)), + (profileId, _) => + { + started.Add(profileId); + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CoreStarted)); + }, + logs.Add, + CancellationToken.None); + + await (result is null).Should().BeTrue(); + await started.Count.Should().BeEqualTo(0); + await faulted.Count.Should().BeEqualTo(1); + await faulted[0].ProcessIds.SequenceEqual([4242]).Should().BeTrue(); + await faulted[0].Failure + .Contains("could not confirm that the previous Core had stopped", StringComparison.Ordinal) + .Should().BeTrue(); + await logs.Any(log => log.Contains("could not confirm", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task StoppedCoreWithAnUnrelatedPortOwnerStillAttemptsTheNormalStart() + { + // Web's tracked Core is gone. An unrelated program has taken over the old proxy + // port; that listener must not be mistaken for the previous Core surviving. + var started = new List(); + var faulted = false; + var logs = new List(); + + var result = await V2rayRuntime.RecoverRuntimeAfterFailedRestoreAsync( + new RuntimeRestartIntent(true, "old"), + [], + (_, _) => faulted = true, + (profileId, _) => + { + started.Add(profileId); + // The normal launch preflight owns the port-conflict decision. + return Task.FromResult(OperationView.Fail( + "proxy_port_in_use", + ApiMessageKeys.CorePortInUse, + new { port = 10808 })); + }, + logs.Add, + CancellationToken.None); + + await faulted.Should().BeFalse(); + await started.SequenceEqual(["old"]).Should().BeTrue(); + await (result?.Code == "proxy_port_in_use").Should().BeTrue(); + await logs.Any(log => log.Contains("proxy_port_in_use", StringComparison.Ordinal)).Should().BeTrue(); + await logs.Any(log => log.Contains("could not confirm", StringComparison.Ordinal)).Should().BeFalse(); + } + + [Test] + public async Task FailedRestoreWithStoppedRuntimeIntentNeitherStartsNorFaultsTheRuntime() + { + var started = new List(); + var faulted = false; + + var result = await V2rayRuntime.RecoverRuntimeAfterFailedRestoreAsync( + new RuntimeRestartIntent(false, "old"), + [], + (_, _) => faulted = true, + (profileId, _) => + { + started.Add(profileId); + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CoreStarted)); + }, + _ => { }, + CancellationToken.None); + + await (result is null).Should().BeTrue(); + await faulted.Should().BeFalse(); + await started.Count.Should().BeEqualTo(0); + } + + [Test] + public async Task SuccessfulTrackedStartIsReportedWithoutAFailureLog() + { + var started = new List(); + var logs = new List(); + + var result = await V2rayRuntime.RecoverRuntimeAfterFailedRestoreAsync( + new RuntimeRestartIntent(true, "old"), + [], + (_, _) => throw new InvalidOperationException("the runtime must not be faulted when no tracked process remains"), + (profileId, _) => + { + started.Add(profileId); + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CoreStarted)); + }, + logs.Add, + CancellationToken.None); + + await (result?.Success).Should().BeTrue(); + await started.SequenceEqual(["old"]).Should().BeTrue(); + await logs.Count.Should().BeEqualTo(0); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RoutingParityTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RoutingParityTests.cs new file mode 100644 index 00000000..c4ca0011 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RoutingParityTests.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using ServiceLib.Enums; +using ServiceLib.Models.Entities; + +namespace v2rayN.WebAPI.Tests; + +public class RoutingParityTests +{ + [Test] + public async Task RoutingProfileApiEntityCarriesAllDesktopFieldsAcrossJsonRoundTrip() + { + var original = new RoutingItem + { + Id = "route-profile", + Remarks = "locked rules", + Url = "https://rules.example/list.json", + RuleSet = "[]", + RuleNum = 0, + Enabled = false, + Locked = true, + CustomIcon = "custom-icon", + CustomRulesetPath4Singbox = "/srv/rules/custom.srs", + DomainStrategy = "UseIP", + DomainStrategy4Singbox = "prefer_ipv4", + Sort = 19, + IsActive = true, + }; + var json = JsonSerializer.Serialize(original, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var reloaded = JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + + await reloaded.Id.Should().BeEqualTo(original.Id); + await reloaded.Remarks.Should().BeEqualTo(original.Remarks); + await reloaded.Url.Should().BeEqualTo(original.Url); + await reloaded.RuleSet.Should().BeEqualTo(original.RuleSet); + await reloaded.RuleNum.Should().BeEqualTo(original.RuleNum); + await reloaded.Enabled.Should().BeEqualTo(original.Enabled); + await reloaded.Locked.Should().BeEqualTo(original.Locked); + await reloaded.CustomIcon.Should().BeEqualTo(original.CustomIcon); + await reloaded.CustomRulesetPath4Singbox.Should().BeEqualTo(original.CustomRulesetPath4Singbox); + await reloaded.DomainStrategy.Should().BeEqualTo(original.DomainStrategy); + await reloaded.DomainStrategy4Singbox.Should().BeEqualTo(original.DomainStrategy4Singbox); + await reloaded.Sort.Should().BeEqualTo(original.Sort); + await reloaded.IsActive.Should().BeEqualTo(original.IsActive); + } + + [Test] + public async Task RoutingRuleEntityCarriesEveryDesktopMatcherAndControlField() + { + var original = new RulesItem + { + Id = "rule-id", + Type = "field", + Port = "80,443", + Network = "tcp,udp", + InboundTag = ["socks", "tun"], + OutboundTag = "proxy", + Ip = ["geoip:cn"], + Domain = ["domain:example.com"], + Protocol = ["http", "tls"], + Process = ["browser"], + Enabled = false, + Remarks = "web rule", + RuleType = ERuleType.Routing, + }; + var json = JsonSerializer.Serialize(original, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var reloaded = JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + + await reloaded.Id.Should().BeEqualTo(original.Id); + await reloaded.Type.Should().BeEqualTo(original.Type); + await reloaded.Port.Should().BeEqualTo(original.Port); + await reloaded.Network.Should().BeEqualTo(original.Network); + await reloaded.InboundTag!.SequenceEqual(original.InboundTag!).Should().BeTrue(); + await reloaded.OutboundTag.Should().BeEqualTo(original.OutboundTag); + await reloaded.Ip!.SequenceEqual(original.Ip!).Should().BeTrue(); + await reloaded.Domain!.SequenceEqual(original.Domain!).Should().BeTrue(); + await reloaded.Protocol!.SequenceEqual(original.Protocol!).Should().BeTrue(); + await reloaded.Process!.SequenceEqual(original.Process!).Should().BeTrue(); + await reloaded.Enabled.Should().BeEqualTo(original.Enabled); + await reloaded.Remarks.Should().BeEqualTo(original.Remarks); + await reloaded.RuleType.Should().BeEqualTo(original.RuleType); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RuntimeMutationGateTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RuntimeMutationGateTests.cs new file mode 100644 index 00000000..dc4df8d0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RuntimeMutationGateTests.cs @@ -0,0 +1,80 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class RuntimeMutationGateTests +{ + [Test] + public async Task ConcurrentConfigWritesSerializeTheFixedTemporaryFile() + { + var gate = new RuntimeMutationGate(); + var configPath = Path.Combine(Path.GetTempPath(), $"v2rayn-WebAPI-config-{Guid.NewGuid():N}.json"); + var tempPath = $"{configPath}_temp"; + var sharedConfig = new List(); + var activeWriters = 0; + var maximumConcurrentWriters = 0; + + try + { + var writers = Enumerable.Range(0, 24).Select(value => gate.RunAsync(async () => + { + var active = Interlocked.Increment(ref activeWriters); + UpdateMaximum(ref maximumConcurrentWriters, active); + try + { + sharedConfig.Add(value); + await File.WriteAllTextAsync(tempPath, System.Text.Json.JsonSerializer.Serialize(sharedConfig)); + await Task.Delay(2); + File.Move(tempPath, configPath, overwrite: true); + } + finally + { + Interlocked.Decrement(ref activeWriters); + } + })); + + await Task.WhenAll(writers); + + var persisted = System.Text.Json.JsonSerializer.Deserialize(await File.ReadAllTextAsync(configPath)); + await maximumConcurrentWriters.Should().BeEqualTo(1); + await (persisted is not null).Should().BeTrue(); + await (persisted?.Order().SequenceEqual(Enumerable.Range(0, 24)) == true).Should().BeTrue(); + await File.Exists(tempPath).Should().BeFalse(); + } + finally + { + File.Delete(configPath); + File.Delete(tempPath); + } + } + + private static void UpdateMaximum(ref int maximum, int value) + { + while (true) + { + var current = Volatile.Read(ref maximum); + if (current >= value || Interlocked.CompareExchange(ref maximum, value, current) == current) + { + return; + } + } + } + + [Test] + [Arguments(-1)] + [Arguments(1)] + public async Task SaveFailureCannotBeTreatedAsSuccess(int saveResult) + { + var failed = false; + try + { + await V2rayRuntime.EnsureConfigSaveSucceededAsync(() => Task.FromResult(saveResult)); + } + catch (IOException) + { + failed = true; + } + + await failed.Should().BeTrue(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RuntimeOperationCoordinatorTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RuntimeOperationCoordinatorTests.cs new file mode 100644 index 00000000..75cbb4c9 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RuntimeOperationCoordinatorTests.cs @@ -0,0 +1,129 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class RuntimeOperationCoordinatorTests +{ + [Test] + public async Task ExclusiveLeaseBlocksNewSharedOperations() + { + var coordinator = new RuntimeOperationCoordinator(); + var exclusive = await coordinator.EnterExclusiveAsync(); + var pending = coordinator.EnterOperationAsync().AsTask(); + + await Task.Delay(25); + await pending.IsCompleted.Should().BeFalse(); + + await exclusive.DisposeAsync(); + await using var shared = await pending; + await shared.Token.IsCancellationRequested.Should().BeFalse(); + } + + [Test] + public async Task RequestUsesItsExistingSharedLeaseWhenAnExclusiveWaiterArrives() + { + var coordinator = new RuntimeOperationCoordinator(); + var requestLease = await coordinator.EnterOperationAsync(); + var exclusiveTask = coordinator.EnterExclusiveAsync().AsTask(); + await Task.Delay(25); + await exclusiveTask.IsCompleted.Should().BeFalse(); + + // Runtime work executes under the middleware-owned lease. It must not acquire another + // shared lease here: an exclusive waiter would otherwise block this work from finishing + // and the outer request could never release its lease. + var requestWork = Task.Run(async () => + { + requestLease.Token.ThrowIfCancellationRequested(); + await Task.Yield(); + return true; + }); + await (await requestWork.WaitAsync(TimeSpan.FromSeconds(2))).Should().BeTrue(); + + // A later HTTP request queues behind the exclusive waiter. + var nextRequestTask = coordinator.EnterOperationAsync().AsTask(); + await Task.Delay(25); + await nextRequestTask.IsCompleted.Should().BeFalse(); + + await requestLease.DisposeAsync(); + await using (var maintenance = await exclusiveTask.WaitAsync(TimeSpan.FromSeconds(2))) + { + await maintenance.Token.IsCancellationRequested.Should().BeFalse(); + } + await using var nextRequest = await nextRequestTask.WaitAsync(TimeSpan.FromSeconds(2)); + await nextRequest.Token.IsCancellationRequested.Should().BeFalse(); + } + + [Test] + public async Task ReadOnlyObserversRemainAvailableDuringUpdateMaintenance() + { + var coordinator = new RuntimeOperationCoordinator(); + await using var maintenance = await coordinator.EnterExclusiveAsync(allowReadOnlyObservations: true); + + await using var status = await coordinator.EnterObservationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + await using var operations = await coordinator.EnterObservationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + await using var logs = await coordinator.EnterObservationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + var mutation = coordinator.EnterOperationAsync().AsTask(); + + await (status.Token.CanBeCanceled && operations.Token.CanBeCanceled && logs.Token.CanBeCanceled).Should().BeTrue(); + await mutation.IsCompleted.Should().BeFalse(); + await maintenance.DisposeAsync(); + await using var mutationLease = await mutation.WaitAsync(TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task ReadOnlyObserversStillWaitDuringMaintenanceThatDoesNotAllowThem() + { + var coordinator = new RuntimeOperationCoordinator(); + await using var maintenance = await coordinator.EnterExclusiveAsync(); + var observer = coordinator.EnterObservationAsync().AsTask(); + + await observer.IsCompleted.Should().BeFalse(); + await maintenance.DisposeAsync(); + await using var lease = await observer.WaitAsync(TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task SharedUpdateStagingDoesNotBlockObservationRequests() + { + var coordinator = new RuntimeOperationCoordinator(); + await using var staging = await coordinator.EnterOperationAsync(); + await using var status = await coordinator.EnterOperationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + await using var operations = await coordinator.EnterOperationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + await using var logs = await coordinator.EnterOperationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + await (!status.Token.IsCancellationRequested + && !operations.Token.IsCancellationRequested + && !logs.Token.IsCancellationRequested).Should().BeTrue(); + } + + [Test] + public async Task ShutdownCancelsAndDrainsActiveLeases() + { + var coordinator = new RuntimeOperationCoordinator(); + var operation = await coordinator.EnterOperationAsync(); + var shutdown = coordinator.StopAndDrainAsync(CancellationToken.None); + + for (var attempt = 0; attempt < 100 && !operation.Token.IsCancellationRequested; attempt++) + { + await Task.Delay(10); + } + await operation.Token.IsCancellationRequested.Should().BeTrue(); + await operation.DisposeAsync(); + await shutdown; + } + + [Test] + public async Task ShutdownDrainHasAFiniteBestEffortDeadline() + { + var coordinator = new RuntimeOperationCoordinator(); + var operation = await coordinator.EnterOperationAsync(); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var drained = await coordinator.StopAndDrainAsync(CancellationToken.None, TimeSpan.FromMilliseconds(40)); + + await drained.Should().BeFalse(); + await operation.Token.IsCancellationRequested.Should().BeTrue(); + await stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1)); + await operation.DisposeAsync(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RuntimeRequestOperationPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RuntimeRequestOperationPolicyTests.cs new file mode 100644 index 00000000..98d8071f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RuntimeRequestOperationPolicyTests.cs @@ -0,0 +1,62 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class RuntimeRequestOperationPolicyTests +{ + [Test] + [Arguments("GET", "/api/status")] + [Arguments("GET", "/api/operations")] + [Arguments("GET", "/api/logs")] + [Arguments("GET", "/api/logs/page")] + public async Task ReadOnlyRuntimeEndpointsUseObservationLeases(string method, string path) + { + await RuntimeRequestOperationPolicy.Classify(method, path) + .Should().BeEqualTo(RuntimeRequestOperationKind.Observation); + } + + [Test] + [Arguments("POST", "/api/core/xray/update")] + [Arguments("POST", "/api/core-updates/Xray/update")] + [Arguments("POST", "/api/core-updates/sing_box/update")] + [Arguments("POST", "/api/core/geo/update")] + [Arguments("POST", "/api/speedtests")] + [Arguments("POST", "/api/subscriptions/example/update")] + [Arguments("POST", "/api/backup/restore")] + [Arguments("POST", "/api/backup/webdav/restore")] + [Arguments("POST", "/api/settings/regional-presets/Default")] + [Arguments("POST", "/api/settings/regional-presets/Russia")] + [Arguments("POST", "/api/settings/regional-presets/Iran")] + public async Task LongRunningRequestStartsItsOwnBackgroundLease(string method, string path) + { + await RuntimeRequestOperationPolicy.Classify(method, path) + .Should().BeEqualTo(RuntimeRequestOperationKind.Background); + } + + [Test] + [Arguments("GET", "/api/backup/download")] + [Arguments("POST", "/api/backup/webdav")] + public async Task BackupSnapshotRequestsUseExclusiveLeases(string method, string path) + { + await RuntimeRequestOperationPolicy.Classify(method, path) + .Should().BeEqualTo(RuntimeRequestOperationKind.Exclusive); + } + + [Test] + [Arguments("PUT", "/api/settings/apply")] + [Arguments("PUT", "/api/settings/inbound")] + [Arguments("PUT", "/api/settings/dns/profiles/xray")] + [Arguments("POST", "/api/settings/routing-profiles/import")] + public async Task CoreAffectingSettingsUseExclusiveMutationLeasesWithReadOnlyStatusAccess(string method, string path) + { + await RuntimeRequestOperationPolicy.Classify(method, path) + .Should().BeEqualTo(RuntimeRequestOperationKind.ExclusiveReadOnly); + } + + [Test] + public async Task OtherApiRequestsUseSharedLeases() + { + await RuntimeRequestOperationPolicy.Classify("POST", "/api/core/start") + .Should().BeEqualTo(RuntimeRequestOperationKind.Shared); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/ShutdownCleanupSequenceTests.cs b/v2rayN/v2rayN.WebAPI/Tests/ShutdownCleanupSequenceTests.cs new file mode 100644 index 00000000..3b145564 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/ShutdownCleanupSequenceTests.cs @@ -0,0 +1,219 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class ShutdownCleanupSequenceTests +{ + [Test] + public async Task CleanupStopsAnActiveCoreBeforeSavingAndClosingSharedState() + { + var coreRunning = true; + var databaseClosed = false; + var visited = new List(); + var steps = new ShutdownCleanupStep[] + { + new("scheduled operations", _ => Task.FromResult(true)), + new("operation drain", _ => Task.FromResult(true)), + new("Core stop", _ => + { + visited.Add("Core stop"); + coreRunning = false; + return Task.FromResult(true); + }), + new("profile save", _ => { visited.Add("profile save"); return Task.FromResult(true); }), + new("statistics save/close", _ => { visited.Add("statistics save/close"); return Task.FromResult(true); }), + new("configuration save", _ => { visited.Add("configuration save"); return Task.FromResult(true); }), + new("database close", _ => { visited.Add("database close"); databaseClosed = true; return Task.FromResult(true); }), + }; + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + TimeSpan.FromSeconds(1), + CancellationToken.None, + _ => { }); + + await completed.Should().BeTrue(); + await coreRunning.Should().BeFalse(); + await databaseClosed.Should().BeTrue(); + await visited.SequenceEqual(["Core stop", "profile save", "statistics save/close", "configuration save", "database close"]).Should().BeTrue(); + } + + [Test] + public async Task CoreStopTimeoutIsReportedByNameAndPreventsDatabaseClose() + { + var neverCompletingCoreStop = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var databaseClosed = false; + var messages = new List(); + var stages = new List(); + var steps = new ShutdownCleanupStep[] + { + new("Core stop", _ => neverCompletingCoreStop.Task), + new("database close", _ => { databaseClosed = true; return Task.FromResult(true); }), + }; + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + TimeSpan.FromMilliseconds(70), + CancellationToken.None, + messages.Add, + stage => + { + stages.Add(stage); + ShutdownDiagnostics.SetStage(stage); + }); + + await completed.Should().BeFalse(); + await databaseClosed.Should().BeFalse(); + await messages.Any(message => message.Contains("Core stop", StringComparison.Ordinal)).Should().BeTrue(); + await (stages.FirstOrDefault() == "Core stop").Should().BeTrue(); + await ShutdownDiagnostics.CurrentStage.Should().BeEqualTo("Core stop"); + neverCompletingCoreStop.TrySetResult(true); + ShutdownDiagnostics.SetStage(null); + } + + [Test] + public async Task RuntimeHostAndLauncherShutdownBudgetsLeaveOrderedMargins() + { + await (RuntimeShutdownBudgets.RuntimeCleanup < RuntimeShutdownBudgets.HostShutdown).Should().BeTrue(); + await (RuntimeShutdownBudgets.HostShutdown < RuntimeShutdownBudgets.LauncherWait).Should().BeTrue(); + await (RuntimeShutdownBudgets.LateCleanup < RuntimeShutdownBudgets.RuntimeCleanup).Should().BeTrue(); + await (RuntimeShutdownBudgets.HostShutdown - RuntimeShutdownBudgets.RuntimeCleanup) + .Should().BeEqualTo(TimeSpan.FromSeconds(5)); + await (RuntimeShutdownBudgets.LauncherWait - RuntimeShutdownBudgets.HostShutdown) + .Should().BeEqualTo(TimeSpan.FromSeconds(10)); + } + + [Test] + public async Task LateShutdownRunsAReducedFreshCleanupAfterTheHostTokenWasCanceled() + { + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + var visited = new List(); + var messages = new List(); + var steps = new ShutdownCleanupStep[] + { + new("Core stop", _ => { visited.Add("Core stop"); return Task.FromResult(true); }), + new("database close", _ => { visited.Add("database close"); return Task.FromResult(true); }), + }; + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + RuntimeShutdownBudgets.RuntimeCleanup, + canceled.Token, + messages.Add); + + await completed.Should().BeTrue(); + await visited.SequenceEqual(["Core stop", "database close"]).Should().BeTrue(); + await messages.Any(message => message.Contains("reduced cleanup", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task LateShutdownStillHonorsTheReducedBudgetInsteadOfTheFullOne() + { + var neverCompletingStep = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var completed = await ShutdownCleanupSequence.RunAsync( + new ShutdownCleanupStep[] { new("Core stop", _ => neverCompletingStep.Task) }, + RuntimeShutdownBudgets.RuntimeCleanup, + canceled.Token, + _ => { }); + + await completed.Should().BeFalse(); + await stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(RuntimeShutdownBudgets.LateCleanup - TimeSpan.FromMilliseconds(100)); + await stopwatch.Elapsed.Should().BeLessThan(RuntimeShutdownBudgets.LateCleanup + TimeSpan.FromSeconds(2)); + neverCompletingStep.TrySetResult(true); + } + + [Test] + public async Task ActiveOperationDrainTimeoutSkipsDatabaseClose() + { + var coordinator = new RuntimeOperationCoordinator(); + await using var activeOperation = await coordinator.EnterOperationAsync(); + var databaseCloseStarted = false; + var messages = new List(); + var steps = new ShutdownCleanupStep[] + { + new("operation drain", async deadline => + await coordinator.StopAndDrainAsync(deadline.Token, deadline.Remaining)), + new("database close", _ => + { + databaseCloseStarted = true; + return Task.FromResult(true); + }), + }; + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + TimeSpan.FromMilliseconds(80), + CancellationToken.None, + messages.Add); + + await completed.Should().BeFalse(); + await databaseCloseStarted.Should().BeFalse(); + await messages.Any(message => message.Contains("operation drain", StringComparison.OrdinalIgnoreCase)) + .Should().BeTrue(); + await activeOperation.DisposeAsync(); + } + + [Test] + public async Task TimedOutCleanupStepPreventsLaterSharedResourceCleanup() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var neverCompletingStep = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var databaseCloseStarted = false; + var steps = new ShutdownCleanupStep[] + { + new("profile save", _ => + { + started.TrySetResult(true); + return neverCompletingStep.Task; + }), + new("database close", _ => + { + databaseCloseStarted = true; + return Task.FromResult(true); + }), + }; + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + TimeSpan.FromMilliseconds(80), + CancellationToken.None, + _ => { }); + + await started.Task.WaitAsync(TimeSpan.FromSeconds(1)); + await completed.Should().BeFalse(); + await databaseCloseStarted.Should().BeFalse(); + neverCompletingStep.TrySetResult(true); + } + + [Test] + public async Task ShutdownUsesOneFiniteOverallBudget() + { + var neverCompletingStep = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var steps = new ShutdownCleanupStep[] + { + new("first cleanup", async _ => + { + await Task.Delay(50); + return true; + }), + new("second cleanup", _ => neverCompletingStep.Task), + new("third cleanup", _ => Task.FromResult(true)), + }; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + TimeSpan.FromMilliseconds(100), + CancellationToken.None, + _ => { }); + + await completed.Should().BeFalse(); + await stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1)); + neverCompletingStep.TrySetResult(true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SniffingSettingsIntegrationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SniffingSettingsIntegrationTests.cs new file mode 100644 index 00000000..83005c05 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SniffingSettingsIntegrationTests.cs @@ -0,0 +1,237 @@ +using System.Reflection; +using System.Text.Json.Nodes; +using ServiceLib; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models; +using ServiceLib.Models.Configs; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Entities; +using ServiceLib.Manager; +using ServiceLib.Services.CoreConfig; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class SniffingSettingsIntegrationTests +{ + [Test] + public async Task WebInboundInputEnablesXraySniffingAndUsesServiceLibDefaultsWhenOverrideIsEmpty() + { + var config = CreateConfig(); + BindAppManagerConfig(config); + var input = CreateInboundInput(sniffingEnabled: true, destOverride: []); + V2rayRuntime.ApplyInboundSettings(config.Inbound[0], input); + + var generated = Generate(ECoreType.Xray, config); + var sniffing = generated["inbounds"]![0]!["sniffing"]!; + + await sniffing["enabled"]!.GetValue().Should().BeTrue(); + await sniffing["destOverride"]![0]!.GetValue().Should().BeEqualTo("http"); + await sniffing["destOverride"]![1]!.GetValue().Should().BeEqualTo("tls"); + await config.Inbound[0].SniffingEnabled.Should().BeTrue(); + + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + var reloadedSettings = await runtime.GetSettingsAsync(); + await reloadedSettings.Inbound.SniffingEnabled.Should().BeTrue(); + await reloadedSettings.Inbound.DestOverride.SequenceEqual(new[] { "http", "tls" }).Should().BeTrue(); + } + + [Test] + public async Task WebInboundInputEnablesSingboxSniffAction() + { + var config = CreateConfig(); + BindAppManagerConfig(config); + var input = CreateInboundInput(sniffingEnabled: true, destOverride: []); + V2rayRuntime.ApplyInboundSettings(config.Inbound[0], input); + + var generated = Generate(ECoreType.sing_box, config); + var routeRules = generated["route"]!["rules"]!.AsArray(); + + await routeRules.Any(rule => rule?["action"]?.GetValue() == "sniff").Should().BeTrue(); + await config.Inbound[0].SniffingEnabled.Should().BeTrue(); + } + + [Test] + public async Task EnablingSniffingPreservesExplicitProtocolsAndRunningCoreChangeRequestsOneRestart() + { + var config = CreateConfig(); + var input = CreateInboundInput(sniffingEnabled: true, destOverride: ["tls"]); + var normalized = V2rayRuntime.NormalizeDestOverride(input.SniffingEnabled, input.DestOverride); + await normalized.SequenceEqual(new[] { "tls" }).Should().BeTrue(); + + var coreChanged = config.Inbound[0].SniffingEnabled != input.SniffingEnabled + || !config.Inbound[0].DestOverride.SequenceEqual(normalized, StringComparer.OrdinalIgnoreCase); + var restartCount = 0; + var execution = await CoreSettingsApplyExecutor.ExecuteAsync( + CoreRuntimeState.Running, + hasActiveChild: true, + changed: coreChanged, + () => + { + restartCount++; + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CommonCompleted)); + }); + + await coreChanged.Should().BeTrue(); + await execution.Action.Should().BeEqualTo(CoreSettingsApplyAction.Restart); + await restartCount.Should().BeEqualTo(1); + } + + [Test] + public async Task EmptyDestOverrideNormalizationIsIdempotentAcrossRepeatedStaleSettingsSaves() + { + var config = CreateConfig(); + BindAppManagerConfig(config); + var stalePayload = CreateInboundInput(sniffingEnabled: true, destOverride: []); + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + + // First save flips sniffing on, and the Web-owned inbound apply path supplies + // ServiceLib's defaults even though the submitted protocols remain empty. + V2rayRuntime.ApplyInboundSettings(config.Inbound[0], stalePayload); + var afterFirstSave = await runtime.GetSettingsAsync(); + await afterFirstSave.Inbound.SniffingEnabled.Should().BeTrue(); + await afterFirstSave.Inbound.DestOverride.SequenceEqual(new[] { "http", "tls" }).Should().BeTrue(); + + // The browser still submits its stale empty array. A subsequent save must not + // erase the canonical backend defaults just because sniffing was already enabled. + V2rayRuntime.ApplyInboundSettings(config.Inbound[0], stalePayload); + var afterSecondSave = await runtime.GetSettingsAsync(); + await afterSecondSave.Inbound.SniffingEnabled.Should().BeTrue(); + await afterSecondSave.Inbound.DestOverride.SequenceEqual(new[] { "http", "tls" }).Should().BeTrue(); + + var generated = Generate(ECoreType.Xray, config); + var sniffing = generated["inbounds"]![0]!["sniffing"]!; + await sniffing["enabled"]!.GetValue().Should().BeTrue(); + await sniffing["destOverride"]![0]!.GetValue().Should().BeEqualTo("http"); + await sniffing["destOverride"]![1]!.GetValue().Should().BeEqualTo("tls"); + } + + [Test] + public async Task DestOverrideNormalizationPreservesExplicitAndDisabledValues() + { + var defaults = new InItem().DestOverride?.ToArray() ?? []; + var falseToTrue = V2rayRuntime.NormalizeDestOverride(true, []); + var trueToTrue = V2rayRuntime.NormalizeDestOverride(true, []); + var explicitProtocols = V2rayRuntime.NormalizeDestOverride(true, ["tls"]); + var disabledEmpty = V2rayRuntime.NormalizeDestOverride(false, []); + var disabledExisting = V2rayRuntime.NormalizeDestOverride(false, ["http", "tls"]); + + await falseToTrue.SequenceEqual(defaults).Should().BeTrue(); + await trueToTrue.SequenceEqual(defaults).Should().BeTrue(); + await explicitProtocols.SequenceEqual(new[] { "tls" }).Should().BeTrue(); + await disabledEmpty.Length.Should().BeEqualTo(0); + await disabledExisting.SequenceEqual(new[] { "http", "tls" }).Should().BeTrue(); + } + + private static InboundSettingsInput CreateInboundInput(bool sniffingEnabled, string[] destOverride) => new( + 10808, false, true, sniffingEnabled, destOverride, false, false, false, string.Empty, string.Empty); + + private static JsonNode Generate(ECoreType coreType, Config config) + { + var node = CreateVmessNode(coreType); + var context = new CoreConfigContext + { + Node = node, + RunCoreType = coreType, + RoutingItem = new RoutingItem + { + Id = "route-id", + Remarks = "default", + RuleSet = "[]", + DomainStrategy = Global.AsIs, + DomainStrategy4Singbox = string.Empty, + }, + RawDnsItem = null, + SimpleDnsItem = config.SimpleDNSItem, + AllProxiesMap = new Dictionary { [node.IndexId] = node }, + AppConfig = config, + FullConfigTemplate = null, + IsTunEnabled = false, + ProtectDomainList = [], + HasGlobalIPv6Address = true, + }; + var result = coreType == ECoreType.sing_box + ? new CoreConfigSingboxService(context).GenerateClientConfigContent() + : new CoreConfigV2rayService(context).GenerateClientConfigContent(); + if (result.Success != true || result.Data is null) + { + throw new InvalidOperationException($"Core configuration generation failed: {result.Msg}"); + } + return JsonNode.Parse(result.Data.ToString()!)!; + } + + internal static void BindAppManagerConfig(Config config) + { + var field = typeof(AppManager).GetField("_config", BindingFlags.Instance | BindingFlags.NonPublic); + field?.SetValue(AppManager.Instance, config); + } + + private static ProfileItem CreateVmessNode(ECoreType coreType) + { + var node = new ProfileItem + { + IndexId = "sniffing-test-node", + ConfigType = EConfigType.VMess, + CoreType = coreType, + Remarks = "sniffing test", + Address = "example.com", + Port = 443, + Password = Guid.NewGuid().ToString(), + Network = nameof(ETransport.raw), + StreamSecurity = string.Empty, + Subid = string.Empty, + }; + node.SetProtocolExtra(node.GetProtocolExtra() with { AlterId = "0", VmessSecurity = Global.DefaultSecurity }); + return node; + } + + internal static Config CreateConfig() => new() + { + CoreBasicItem = new CoreBasicItem { Loglevel = "warning" }, + TunModeItem = new TunModeItem { EnableTun = false, IcmpRouting = "default" }, + KcpItem = new KcpItem(), + GrpcItem = new GrpcItem(), + RoutingBasicItem = new RoutingBasicItem { DomainStrategy = Global.AsIs, DomainStrategy4Singbox = string.Empty, RoutingIndexId = string.Empty }, + GuiItem = new GUIItem { EnableStatistics = false, DisplayRealTimeSpeed = false, EnableLog = false }, + MsgUIItem = new MsgUIItem(), + UiItem = new UIItem { CurrentLanguage = "en", CurrentFontFamily = "sans", MainColumnItem = [], WindowSizeItem = [] }, + ConstItem = new ConstItem(), + SpeedTestItem = new SpeedTestItem + { + SpeedPingTestUrl = Global.SpeedPingTestUrls.First(), + SpeedTestUrl = Global.SpeedTestUrls.First(), + SpeedTestTimeout = 10, + MixedConcurrencyCount = 1, + IPAPIUrl = string.Empty, + }, + Mux4RayItem = new Mux4RayItem { Concurrency = 8, XudpConcurrency = 16, XudpProxyUDP443 = "reject" }, + Mux4SboxItem = new Mux4SboxItem { Protocol = Global.SingboxMuxs.First(), MaxConnections = 8 }, + HysteriaItem = new HysteriaItem { UpMbps = 100, DownMbps = 100 }, + ClashUIItem = new ClashUIItem { ConnectionsColumnItem = [] }, + SystemProxyItem = new SystemProxyItem { SystemProxyExceptions = string.Empty, SystemProxyAdvancedProtocol = string.Empty }, + WebDavItem = new WebDavItem(), + CheckUpdateItem = new CheckUpdateItem(), + Fragment4RayItem = new Fragment4RayItem { Packets = "tlshello", Lengths = ["100-200"], Delays = ["10-20"] }, + Inbound = + [ + new InItem + { + Protocol = nameof(EInboundProtocol.socks), + LocalPort = 10808, + UdpEnabled = true, + SniffingEnabled = false, + RouteOnly = false, + DestOverride = [], + }, + ], + GlobalHotkeys = [], + CoreTypeItem = [new CoreTypeItem { ConfigType = EConfigType.VMess, CoreType = ECoreType.Xray }], + SimpleDNSItem = ConfigHandler.InitBuiltinSimpleDNS(), + HappyEyeballs4RayItem = new HappyEyeballs4RayItem(), + IndexId = string.Empty, + SubIndexId = string.Empty, + }; +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SseSerializationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SseSerializationTests.cs new file mode 100644 index 00000000..6b8bead8 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SseSerializationTests.cs @@ -0,0 +1,12 @@ +using v2rayN.WebAPI.Api; + +namespace v2rayN.WebAPI.Tests; + +public class SseSerializationTests +{ + [Test] + public async Task NullEventDataSerializesAsJsonNull() + { + await WebApiEndpoints.SerializeEventData(null).Should().BeEqualTo("null"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SseShutdownTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SseShutdownTests.cs new file mode 100644 index 00000000..e4dd93f3 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SseShutdownTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class SseShutdownTests +{ + private const string ManagementKey = "test-management-key-2026"; + + [Test] + public async Task HostShutdownClosesActiveSseStreamsInsteadOfWaitingForTheShutdownTimeout() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await Harness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions, + shutdownTimeout: TimeSpan.FromSeconds(4)); + + var session = sessions.CreateSession(); + var ticket = await GetSseTicketAsync(api, session.Token); + var responseTask = api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(ticket)}", + HttpCompletionOption.ResponseHeadersRead); + for (var attempt = 0; attempt < 100 && !responseTask.IsCompleted; attempt++) + { + api.Events.Publish("status", new { ready = true }); + await Task.Delay(10); + } + + using var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(5)); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await using var stream = await response.Content.ReadAsStreamAsync(); + var buffer = new byte[512]; + var firstRead = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(3)); + await (firstRead > 0).Should().BeTrue(); + + // Kestrel drains open requests during a graceful stop. Without the stream + // reacting to ApplicationStopping the host would wait for the whole + // ShutdownTimeout (4s here) and the runtime cleanup would start too late. + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + api.App.Lifetime.StopApplication(); + await api.App.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(20)); + stopwatch.Stop(); + + await stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(2.5)); + var closed = await ReadSseStreamUntilClosedAsync(stream); + await closed.Should().BeTrue(); + } + + private static async Task GetSseTicketAsync(Harness api, string sessionToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/sse-ticket"); + request.Headers.Authorization = new("Bearer", sessionToken); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("data").GetProperty("ticket").GetString()!; + } + + private static async Task ReadSseStreamUntilClosedAsync(Stream stream) + { + try + { + await stream.CopyToAsync(Stream.Null).WaitAsync(TimeSpan.FromSeconds(3)); + return true; + } + catch (IOException) + { + return true; + } + catch (TimeoutException) + { + return false; + } + } + + private sealed class Harness(WebApplication app, HttpClient client, EventHub events) : IAsyncDisposable + { + public WebApplication App => app; + public HttpClient Client => client; + public EventHub Events => events; + + public static async Task StartAsync( + WebAuthService auth, + WebSessionService sessions, + TimeSpan shutdownTimeout) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); + builder.Services.Configure(options => options.ShutdownTimeout = shutdownTimeout); + builder.Services.AddSingleton(auth); + builder.Services.AddSingleton(sessions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + + var application = builder.Build(); + application.UseRouting(); + application.UseRateLimiter(); + application.UseMiddleware(); + application.UseMiddleware(); + application.MapWebApi(); + application.MapWebAuthEndpoints(); + application.MapWebSetupEndpoints(); + await application.StartAsync(); + + var addresses = application.Services.GetRequiredService() + .Features.Get()!.Addresses; + var httpClient = new HttpClient(new SocketsHttpHandler { UseProxy = false }) + { + BaseAddress = new Uri(addresses.Single()), + Timeout = TimeSpan.FromSeconds(5), + }; + return new Harness( + application, + httpClient, + application.Services.GetRequiredService()); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + if (!App.Lifetime.ApplicationStopping.IsCancellationRequested) + { + await App.StopAsync(); + } + await App.DisposeAsync(); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-sse-shutdown-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SubscriptionCreationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionCreationTests.cs new file mode 100644 index 00000000..c0e59b64 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionCreationTests.cs @@ -0,0 +1,83 @@ +using System.Reflection; +using ServiceLib; +using ServiceLib.Helper; +using ServiceLib.Manager; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class SubscriptionCreationTests +{ + [Test] + public async Task EmptyUrlSubscriptionIsCreatedAndReturnedWithAnEmptyUrl() + { + var previousLocalData = Environment.GetEnvironmentVariable(Global.LocalAppData); + var manager = AppManager.Instance; + var configField = typeof(AppManager).GetField("_config", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingFieldException(typeof(AppManager).FullName, "_config"); + var previousConfig = configField.GetValue(manager); + var config = new Config(); + SubItem? saved = null; + try + { + Environment.SetEnvironmentVariable(Global.LocalAppData, "0"); + configField.SetValue(manager, config); + SQLiteHelper.Instance.CreateTable(); + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + var result = await runtime.AddSubscriptionAsync(new SubscriptionInput( + "自建", string.Empty, MoreUrl: string.Empty, Enabled: false, AutoUpdateInterval: 0)); + + if (result.Data is not null) + { + saved = await manager.GetSubItem(result.Data.Id); + } + + await result.Success.Should().BeTrue(); + await (result.Data is not null).Should().BeTrue(); + await result.Data!.Url.Should().BeEqualTo(string.Empty); + + await (saved is not null).Should().BeTrue(); + await saved!.Remarks.Should().BeEqualTo("自建"); + await saved.Url.Should().BeEqualTo(string.Empty); + await saved.MoreUrl.Should().BeEqualTo(string.Empty); + await saved.Enabled.Should().BeFalse(); + await saved.AutoUpdateInterval.Should().BeEqualTo(0); + + var updateResult = await runtime.UpdateSubscriptionAsync(result.Data!.Id, new SubscriptionInput( + "自建(已编辑)", string.Empty, MoreUrl: string.Empty, Enabled: true, AutoUpdateInterval: 0)); + await updateResult.Success.Should().BeTrue(); + await updateResult.Data!.Url.Should().BeEqualTo(string.Empty); + + saved = await manager.GetSubItem(result.Data.Id); + await (saved is not null).Should().BeTrue(); + await saved!.Remarks.Should().BeEqualTo("自建(已编辑)"); + await saved.Url.Should().BeEqualTo(string.Empty); + await saved.Enabled.Should().BeTrue(); + await saved.AutoUpdateInterval.Should().BeEqualTo(0); + + var reloadedView = V2rayRuntime.ToSubscriptionView(saved); + await reloadedView.Url.Should().BeEqualTo(string.Empty); + await reloadedView.Enabled.Should().BeTrue(); + await reloadedView.AutoUpdateInterval.Should().BeEqualTo(0); + } + finally + { + try + { + if (saved is not null) + { + await SQLiteHelper.Instance.DeleteAsync(saved); + } + } + finally + { + configField.SetValue(manager, previousConfig); + Environment.SetEnvironmentVariable(Global.LocalAppData, previousLocalData); + } + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SubscriptionParityTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionParityTests.cs new file mode 100644 index 00000000..b7f5a6d0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionParityTests.cs @@ -0,0 +1,218 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ServiceLib; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models.Entities; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class SubscriptionParityTests +{ + [Test] + public async Task EveryEditableSubItemFieldRoundTripsAndIntervalZeroStaysZero() + { + var original = new SubItem + { + Id = "subscription-parity", + Remarks = "Manual group", + Url = "https://main.example/sub", + MoreUrl = "https://a.example/sub,https://b.example/sub", + Enabled = false, + UserAgent = "parity-agent", + RequestHeaders = "{\"X-Test\":\"value\"}", + Sort = 17, + Filter = "^edge", + AutoUpdateInterval = 0, + ConvertTarget = "mixed", + PrevProfile = "typed previous alias", + NextProfile = "typed landing alias", + PreSocksPort = 10809, + Memo = "memo", + CustomCoreType = ECoreType.sing_box, + }; + + var loaded = V2rayRuntime.ToSubscriptionView(original); + var input = new SubscriptionInput( + loaded.Remarks, + loaded.Url, + loaded.MoreUrl, + loaded.Enabled, + loaded.UserAgent, + loaded.RequestHeaders, + loaded.Filter, + loaded.AutoUpdateInterval, + loaded.ConvertTarget, + loaded.Memo, + loaded.Sort, + loaded.PrevProfile, + loaded.NextProfile, + loaded.PreSocksPort, + ECoreType.sing_box); + var saved = V2rayRuntime.ToSubItem(input, original); + var reloaded = V2rayRuntime.ToSubscriptionView(saved); + + await reloaded.Remarks.Should().BeEqualTo(original.Remarks); + await reloaded.Url.Should().BeEqualTo(original.Url); + await reloaded.MoreUrl.Should().BeEqualTo(original.MoreUrl); + await reloaded.Enabled.Should().BeEqualTo(original.Enabled); + await reloaded.UserAgent.Should().BeEqualTo(original.UserAgent); + await reloaded.RequestHeaders.Should().BeEqualTo(original.RequestHeaders); + await reloaded.Filter.Should().BeEqualTo(original.Filter); + await reloaded.AutoUpdateInterval.Should().BeEqualTo(0); + await reloaded.ConvertTarget.Should().BeEqualTo(original.ConvertTarget); + await reloaded.Memo.Should().BeEqualTo(original.Memo); + await reloaded.Sort.Should().BeEqualTo(original.Sort); + await reloaded.PrevProfile.Should().BeEqualTo("typed previous alias"); + await reloaded.NextProfile.Should().BeEqualTo("typed landing alias"); + await reloaded.PreSocksPort.Should().BeEqualTo(original.PreSocksPort); + await reloaded.CustomCoreType.Should().BeEqualTo("sing_box"); + } + + [Test] + public async Task NullAndEmptySubscriptionFieldsAreNotCollapsedDuringMapping() + { + var original = new SubItem + { + Id = "nullable-subscription", + Remarks = "Nullable values", + Url = "", + MoreUrl = "", + RequestHeaders = null, + Filter = null, + ConvertTarget = null, + Memo = null, + AutoUpdateInterval = 0, + }; + var loaded = V2rayRuntime.ToSubscriptionView(original); + var untouched = V2rayRuntime.ToSubItem(new SubscriptionInput(loaded.Remarks, loaded.Url, + MoreUrl: loaded.MoreUrl, RequestHeaders: loaded.RequestHeaders, Filter: loaded.Filter, + ConvertTarget: loaded.ConvertTarget, Memo: loaded.Memo, AutoUpdateInterval: loaded.AutoUpdateInterval), original); + var cleared = V2rayRuntime.ToSubItem(new SubscriptionInput(loaded.Remarks, loaded.Url, + MoreUrl: string.Empty, RequestHeaders: string.Empty, Filter: string.Empty, + ConvertTarget: string.Empty, Memo: string.Empty, AutoUpdateInterval: 0), original); + + await untouched.RequestHeaders.Should().BeNull(); + await untouched.Filter.Should().BeNull(); + await untouched.ConvertTarget.Should().BeNull(); + await untouched.Memo.Should().BeNull(); + await cleared.RequestHeaders.Should().BeEqualTo(string.Empty); + await cleared.Filter.Should().BeEqualTo(string.Empty); + await cleared.ConvertTarget.Should().BeEqualTo(string.Empty); + await cleared.Memo.Should().BeEqualTo(string.Empty); + await cleared.AutoUpdateInterval.Should().BeEqualTo(0); + } + + [Test] + public async Task MoreUrlDownloadsMainAndEveryAdditionalUrlAndMergesTheirContent() + { + await ServiceLib.Manager.CertPemManager.Instance.Init( + new ServiceLib.Models.Configs.Config { GuiItem = new ServiceLib.Models.Configs.GUIItem() }); + var visited = new ConcurrentQueue(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + await using var app = builder.Build(); + app.MapGet("/main", () => Respond("/main", "main-node.example")); + app.MapGet("/a", () => Respond("/a", "additional-a.example")); + app.MapGet("/b", () => Respond("/b", "additional-b.example")); + await app.StartAsync(); + try + { + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.Single(); + var subscription = new SubItem + { + Id = "multiple-urls", + Remarks = "Three URLs", + Url = $"{address.TrimEnd('/')}/main", + MoreUrl = $"{address.TrimEnd('/')}/a,{address.TrimEnd('/')}/b", + UserAgent = string.Empty, + ConvertTarget = string.Empty, + }; + + var download = typeof(SubscriptionHandler).GetMethod("DownloadAllSubscriptions", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException(typeof(SubscriptionHandler).FullName, "DownloadAllSubscriptions"); + var task = (Task?)download.Invoke(null, [new ServiceLib.Models.Configs.Config(), subscription, false, new DownloadService()]) + ?? throw new InvalidOperationException("The ServiceLib subscription download task was not created."); + var merged = await task; + + await visited.ToArray().SequenceEqual(new[] { "/main", "/a", "/b" }).Should().BeTrue(); + await merged.Contains("main-node.example").Should().BeTrue(); + await merged.Contains("additional-a.example").Should().BeTrue(); + await merged.Contains("additional-b.example").Should().BeTrue(); + } + finally + { + await app.StopAsync(); + } + + IResult Respond(string path, string body) + { + visited.Enqueue(path); + return Results.Text(body); + } + } + + [Test] + public async Task ServiceLibKeepsConversionAndMoreUrlMutuallyExclusive() + { + await ServiceLib.Manager.CertPemManager.Instance.Init( + new ServiceLib.Models.Configs.Config { GuiItem = new ServiceLib.Models.Configs.GUIItem() }); + var visited = new ConcurrentQueue(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + await using var app = builder.Build(); + app.MapGet("/convert", () => + { + visited.Enqueue("/convert"); + return Results.Text("converted-node"); + }); + app.MapGet("/a", () => + { + visited.Enqueue("/a"); + return Results.Text("must-not-be-downloaded"); + }); + await app.StartAsync(); + try + { + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.Single(); + var subscription = new SubItem + { + Id = "converted-subscription", + Remarks = "Converted subscription", + Url = "https://main.example/sub", + MoreUrl = $"{address.TrimEnd('/')}/a", + ConvertTarget = "mixed", + }; + var config = new ServiceLib.Models.Configs.Config + { + ConstItem = new ServiceLib.Models.Configs.ConstItem(), + }; + config.ConstItem.SubConvertUrl = $"{address.TrimEnd('/')}/convert?url={{0}}"; + var download = typeof(SubscriptionHandler).GetMethod("DownloadAllSubscriptions", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException(typeof(SubscriptionHandler).FullName, "DownloadAllSubscriptions"); + var task = (Task?)download.Invoke(null, [config, subscription, false, new DownloadService()]) + ?? throw new InvalidOperationException("The ServiceLib subscription download task was not created."); + + var merged = await task; + await visited.ToArray().SequenceEqual(new[] { "/convert" }).Should().BeTrue(); + await merged.Should().BeEqualTo("converted-node"); + } + finally + { + await app.StopAsync(); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SubscriptionValidationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionValidationTests.cs new file mode 100644 index 00000000..282ee368 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionValidationTests.cs @@ -0,0 +1,85 @@ +using ServiceLib.Enums; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class SubscriptionValidationTests +{ + [Test] + public async Task AliasOnlySubscriptionIsValid() + { + var valid = V2rayRuntime.TryValidateSubscription(new SubscriptionInput("Manual group", string.Empty), out var code, out _); + + await valid.Should().BeTrue(); + await (code == "ok").Should().BeTrue(); + } + + [Test] + public async Task NonEmptySubscriptionUrlStillRequiresAbsoluteHttpOrHttps() + { + var valid = V2rayRuntime.TryValidateSubscription(new SubscriptionInput("Remote group", "not-a-url"), out var code, out _); + + await valid.Should().BeFalse(); + await (code == "subscription_url_invalid").Should().BeTrue(); + } + + [Test] + public async Task ValidAndEmptySubscriptionRequestHeadersAreAccepted() + { + foreach (var requestHeaders in new string?[] { "{\"X-Test\":\"value\"}", null, string.Empty, " " }) + { + var valid = V2rayRuntime.TryValidateSubscription( + new SubscriptionInput("Remote group", "", RequestHeaders: requestHeaders), out var code, out _); + + await valid.Should().BeTrue(); + await code.Should().BeEqualTo("ok"); + } + } + + [Test] + public async Task InvalidSubscriptionRequestHeadersAreRejectedAsInvalidInput() + { + var valid = V2rayRuntime.TryValidateSubscription( + new SubscriptionInput("Remote group", "", RequestHeaders: "[\"not-an-object\"]"), out var code, out var messageKey); + + await valid.Should().BeFalse(); + await code.Should().BeEqualTo("subscription_headers_invalid"); + await messageKey.Should().BeEqualTo(ApiMessageKeys.CommonInvalidInput); + } + + [Test] + public async Task InvalidRequestHeadersAreRejectedBeforeDatabaseAccess() + { + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + + var result = await runtime.UpdateSubscriptionAsync("existing-subscription", + new SubscriptionInput("Changed subscription", "", RequestHeaders: "[]")); + + await result.Success.Should().BeFalse(); + await result.Code.Should().BeEqualTo("subscription_headers_invalid"); + } + + [Test] + public async Task SubscriptionUpdateCanClearCustomCoreTypeAndReloadAsNone() + { + var existing = new SubItem + { + Id = "existing-subscription", + Remarks = "Existing subscription", + Url = "https://example.test/subscription", + CustomCoreType = ECoreType.mihomo, + }; + var input = new SubscriptionInput("Existing subscription", "https://example.test/subscription", + PreSocksPort: 10809, CustomCoreType: null); + + var updated = V2rayRuntime.ToSubItem(input, existing); + var reloaded = V2rayRuntime.ToSubscriptionView(updated); + + await (existing.CustomCoreType == ECoreType.mihomo).Should().BeTrue(); + await (updated.CustomCoreType is null).Should().BeTrue(); + await (reloaded.CustomCoreType is null).Should().BeTrue(); + await updated.PreSocksPort.Should().BeEqualTo(10809); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/UpdatePreReleaseSettingsTests.cs b/v2rayN/v2rayN.WebAPI/Tests/UpdatePreReleaseSettingsTests.cs new file mode 100644 index 00000000..0144dc86 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/UpdatePreReleaseSettingsTests.cs @@ -0,0 +1,212 @@ +using System.Reflection; +using System.Text.Json; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Manager; +using ServiceLib.Models.Configs; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class UpdatePreReleaseSettingsTests +{ + private const string WebTarget = "v2rayN.WebAPI"; + + [Test] + public async Task LegacyApiPreferencesMigrateWithoutChangingDesktopOrCorePreferences() + { + await (V2rayRuntime.NormalizeWebUpdateTargets(null) is null).Should().BeTrue(); + var migrated = V2rayRuntime.NormalizeWebUpdateTargets(["v2rayN", "Xray", "v2rayN.Web", WebTarget, "OtherPlatform"]); + await migrated!.SequenceEqual(["v2rayN", "Xray", WebTarget, "OtherPlatform"]).Should().BeTrue(); + using var fixture = new RuntimeFixture(); + var result = await fixture.Runtime.SaveCoreUpdateSettingsAsync(new CoreUpdateSettingsInput( + ["v2rayN.Web"], false, false, ["v2rayN.Web"])); + await result.Success.Should().BeTrue(); + await fixture.Config.CheckUpdateItem.SelectedCoreTypes.SequenceEqual([WebTarget]).Should().BeTrue(); + await fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes.SequenceEqual([WebTarget]).Should().BeTrue(); + } + + [Test] + [Arguments(true, null, true)] + [Arguments(false, null, false)] + [Arguments(true, true, true)] + [Arguments(true, false, false)] + [Arguments(false, true, true)] + [Arguments(false, false, false)] + public async Task WebPreReleaseUsesItsOwnSettingUnlessExplicitlyOverridden( + bool configured, bool? preRelease, bool expected) + { + using var fixture = new RuntimeFixture(); + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = configured ? [WebTarget] : []; + + await fixture.Runtime.GetUpdatePreRelease(WebTarget, preRelease).Should().BeEqualTo(expected); + // An override is request-scoped, not a change to persisted preferences. + var settings = fixture.Runtime.GetCoreUpdateSettings(); + await settings.PreRelease.Should().BeEqualTo(configured); + await settings.CheckPreReleaseCoreTypes.Contains(WebTarget).Should().BeEqualTo(configured); + } + + [Test] + public async Task MissingPreReleaseListDefaultsToStable() + { + using var fixture = new RuntimeFixture(); + + await fixture.Runtime.GetUpdatePreRelease(WebTarget).Should().BeFalse(); + await fixture.Runtime.GetUpdatePreRelease("Xray").Should().BeFalse(); + await fixture.Runtime.GetCoreUpdateSettings().CheckPreReleaseCoreTypes.Count.Should().BeEqualTo(0); + } + + [Test] + public async Task WebPreReleaseIsIndependentOfDesktopAndCoreTargets() + { + using var fixture = new RuntimeFixture(); + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = ["v2rayN", "Xray", "v2rayn.web"]; + + await fixture.Runtime.GetUpdatePreRelease(WebTarget).Should().BeFalse(); + await fixture.Runtime.GetUpdatePreRelease("Xray").Should().BeTrue(); + var settings = fixture.Runtime.GetCoreUpdateSettings(); + await settings.PreRelease.Should().BeFalse(); + await settings.CheckPreReleaseCoreTypes.SequenceEqual(["Xray"]).Should().BeTrue(); + + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = [WebTarget]; + await fixture.Runtime.GetUpdatePreRelease(WebTarget).Should().BeTrue(); + await fixture.Runtime.GetUpdatePreRelease("Xray").Should().BeFalse(); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task PerTargetSettingsSaveAddsOrRemovesWebAndPreservesHiddenPreferences(bool enableWeb) + { + using var fixture = new RuntimeFixture(); + fixture.Config.CheckUpdateItem.SelectedCoreTypes = ["v2rayN", "Xray", WebTarget]; + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = ["v2rayN", "OtherPlatform", "Xray", WebTarget]; + var input = new CoreUpdateSettingsInput( + ["Xray"], PreRelease: !enableWeb, UseProxy: false, + CheckPreReleaseCoreTypes: enableWeb ? [WebTarget, WebTarget] : []); + + var result = await fixture.Runtime.SaveCoreUpdateSettingsAsync(input); + + await result.Success.Should().BeTrue(); + var saved = fixture.Config.CheckUpdateItem; + await saved.CheckPreReleaseCoreTypes.Contains(WebTarget).Should().BeEqualTo(enableWeb); + await saved.CheckPreReleaseCoreTypes.Contains("Xray").Should().BeFalse(); + await saved.CheckPreReleaseCoreTypes.Contains("v2rayN").Should().BeTrue(); + await saved.CheckPreReleaseCoreTypes.Contains("OtherPlatform").Should().BeTrue(); + await saved.CheckPreReleaseCoreTypes.Count(name => name == WebTarget).Should().BeEqualTo(enableWeb ? 1 : 0); + // Prerelease preferences are saved even for targets not selected for a batch. + await saved.SelectedCoreTypes.Contains(WebTarget).Should().BeFalse(); + await saved.SelectedCoreTypes.Contains("v2rayN").Should().BeTrue(); + await saved.UpdateViaProxy.Should().BeFalse(); + + var view = fixture.Runtime.GetCoreUpdateSettings(); + await view.PreRelease.Should().BeEqualTo(enableWeb); + await view.CheckPreReleaseCoreTypes.SequenceEqual(enableWeb ? [WebTarget] : []).Should().BeTrue(); + var reloaded = await fixture.ReadSavedConfigAsync(); + await reloaded.CheckUpdateItem.CheckPreReleaseCoreTypes.SequenceEqual(saved.CheckPreReleaseCoreTypes).Should().BeTrue(); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task LegacySettingsPayloadStillSavesSupportedTargetsIncludingWeb(bool preRelease) + { + using var fixture = new RuntimeFixture(); + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = ["v2rayN", "Xray", WebTarget]; + var json = JsonSerializer.Serialize(new + { + selectedCoreTypes = Array.Empty(), + preRelease, + useProxy = true, + }); + var input = JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + + await (input.CheckPreReleaseCoreTypes is null).Should().BeTrue(); + var result = await fixture.Runtime.SaveCoreUpdateSettingsAsync(input); + + await result.Success.Should().BeTrue(); + await fixture.Runtime.GetUpdatePreRelease(WebTarget).Should().BeEqualTo(preRelease); + await fixture.Runtime.GetUpdatePreRelease("Xray").Should().BeEqualTo(preRelease); + await fixture.Runtime.GetUpdatePreRelease("mihomo").Should().BeFalse(); + await fixture.Runtime.GetUpdatePreRelease("sing_box").Should().BeFalse(); + await fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes.Contains("v2rayN").Should().BeTrue(); + var reloaded = await fixture.ReadSavedConfigAsync(); + await reloaded.CheckUpdateItem.CheckPreReleaseCoreTypes.Contains(WebTarget).Should().BeEqualTo(preRelease); + } + + [Test] + [Arguments("GeoFiles")] + [Arguments("v2rayN")] + [Arguments("mihomo")] + public async Task InvalidPerTargetPreReleaseSettingsDoNotMutateConfig(string target) + { + using var fixture = new RuntimeFixture(); + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = [WebTarget]; + var previous = JsonUtils.Serialize(fixture.Config); + + var result = await fixture.Runtime.SaveCoreUpdateSettingsAsync( + new CoreUpdateSettingsInput([], false, false, [target])); + + await result.Success.Should().BeFalse(); + await result.Code.Should().BeEqualTo("core_update_settings_invalid"); + await JsonUtils.Serialize(fixture.Config).Should().BeEqualTo(previous); + } + + private sealed class RuntimeFixture : IDisposable + { + private readonly FieldInfo _configField = typeof(AppManager) + .GetField("_config", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingFieldException(typeof(AppManager).FullName, "_config"); + private readonly object? _previousConfig; + private readonly string? _previousLocalData; + private readonly string _configPath; + private readonly byte[]? _previousConfigFile; + private readonly UnixFileMode? _previousConfigMode; + + public Config Config { get; } = new() { CheckUpdateItem = new CheckUpdateItem() }; + public V2rayRuntime Runtime { get; } = new(null!, null!, null!, null!, null!); + + public RuntimeFixture() + { + _previousConfig = _configField.GetValue(AppManager.Instance); + _previousLocalData = Environment.GetEnvironmentVariable(Global.LocalAppData); + // ConfigHandler writes only into the test build directory, never user data. + _configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "guiConfigs", Global.ConfigFileName); + if (File.Exists(_configPath)) + { + _previousConfigFile = File.ReadAllBytes(_configPath); + if (!OperatingSystem.IsWindows()) _previousConfigMode = File.GetUnixFileMode(_configPath); + } + Environment.SetEnvironmentVariable(Global.LocalAppData, "0"); + _configField.SetValue(AppManager.Instance, Config); + } + + public async Task ReadSavedConfigAsync() => + JsonUtils.Deserialize(await File.ReadAllTextAsync(_configPath)) + ?? throw new InvalidDataException("The saved update settings could not be reloaded."); + + public void Dispose() + { + try + { + if (_previousConfigFile is null) + { + if (File.Exists(_configPath)) File.Delete(_configPath); + } + else + { + File.WriteAllBytes(_configPath, _previousConfigFile); + if (!OperatingSystem.IsWindows() && _previousConfigMode is { } mode) + File.SetUnixFileMode(_configPath, mode); + } + } + finally + { + _configField.SetValue(AppManager.Instance, _previousConfig); + Environment.SetEnvironmentVariable(Global.LocalAppData, _previousLocalData); + } + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebApiCapabilitiesTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebApiCapabilitiesTests.cs new file mode 100644 index 00000000..e373d001 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebApiCapabilitiesTests.cs @@ -0,0 +1,16 @@ +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Tests; + +public class WebApiCapabilitiesTests +{ + [Test] + public async Task StatusCapabilitiesAreStableUniqueApiIdentifiers() + { + var capabilities = WebApiCapabilities.Current; + + await capabilities.Distinct(StringComparer.Ordinal).Count().Should().BeEqualTo(capabilities.Length); + await capabilities.Should().Contain("events.sse"); + await capabilities.Should().Contain("editor.options"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs new file mode 100644 index 00000000..dfc22c2f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs @@ -0,0 +1,532 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebAuthEndpointTests +{ + private const string ManagementKey = "test-management-key-2026"; + + [Test] + public async Task EnvironmentManagementKeyLogsInAndOnlyTheReturnedSessionAuthenticates() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + + using var invalidLogin = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = "wrong-management-key" }); + await (invalidLogin.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + using var invalidLoginDocument = JsonDocument.Parse(await invalidLogin.Content.ReadAsStringAsync()); + await invalidLoginDocument.RootElement.GetProperty("code").GetString() + .Should().BeEqualTo("management_key_invalid"); + using var validLogin = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = ManagementKey }); + await (validLogin.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var loginDocument = JsonDocument.Parse(await validLogin.Content.ReadAsStringAsync()); + var sessionToken = loginDocument.RootElement.GetProperty("data").GetProperty("token").GetString()!; + await (sessionToken.Length == 43).Should().BeTrue(); + await (loginDocument.RootElement.GetProperty("data").GetProperty("expiresAt").GetDateTimeOffset() > DateTimeOffset.UtcNow).Should().BeTrue(); + + using var sessionRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + sessionRequest.Headers.Authorization = new("Bearer", sessionToken); + using var sessionResponse = await api.Client.SendAsync(sessionRequest); + await (sessionResponse.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + + using var managementKeyRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + managementKeyRequest.Headers.Authorization = new("Bearer", ManagementKey); + using var managementKeyResponse = await api.Client.SendAsync(managementKeyRequest); + await (managementKeyResponse.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + + using var managementTicketRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/sse-ticket"); + managementTicketRequest.Headers.Authorization = new("Bearer", ManagementKey); + using var managementTicketResponse = await api.Client.SendAsync(managementTicketRequest); + await (managementTicketResponse.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + + using var managementKeySse = await api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(ManagementKey)}", + HttpCompletionOption.ResponseHeadersRead); + await (managementKeySse.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + + using var legacySessionUrl = await api.Client.GetAsync( + $"/api/events?access_token={Uri.EscapeDataString(sessionToken)}", + HttpCompletionOption.ResponseHeadersRead); + await (legacySessionUrl.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + } + + [Test] + public async Task PersistedManagementKeyExchangesForSessionAtLogin() + { + using var directory = new TemporaryDirectory(); + var configPath = Path.Combine(directory.Path, "guiConfigs", "web-auth.json"); + var seedAuth = new WebAuthService(configPath, null); + await ((await seedAuth.SetupAsync(ManagementKey, ManagementKey, setupAccessAllowed: true)) == WebSetupResult.Created).Should().BeTrue(); + + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync(new WebAuthService(configPath, null), sessions); + + using var response = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = ManagementKey }); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var token = document.RootElement.GetProperty("data").GetProperty("token").GetString()!; + await sessions.TryValidateWithoutRenewal(token, out _).Should().BeTrue(); + } + + [Test] + public async Task RuntimeDataFailureAfterLoginDoesNotInvalidateTheEstablishedSession() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + + using var login = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = ManagementKey }); + using var loginDocument = JsonDocument.Parse(await login.Content.ReadAsStringAsync()); + var token = loginDocument.RootElement.GetProperty("data").GetProperty("token").GetString()!; + using var failedDataRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/runtime-failure"); + failedDataRequest.Headers.Authorization = new("Bearer", token); + + using var failure = await api.Client.SendAsync(failedDataRequest); + + await (failure.StatusCode == HttpStatusCode.InternalServerError).Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(token, out _).Should().BeTrue(); + } + + [Test] + public async Task CorrectManagementKeyWithSessionCreationFailureIsNotReportedAsWrongPassword() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + sessions.Dispose(); + + using var response = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = ManagementKey }); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + await (response.StatusCode == HttpStatusCode.InternalServerError).Should().BeTrue(); + await document.RootElement.GetProperty("code").GetString().Should().BeEqualTo("internal_error"); + await sessions.TryValidateWithoutRenewal(ManagementKey, out _).Should().BeFalse(); + } + + [Test] + public async Task SetupRequiresLocalAddressAndAllowedHostAndReturnsAUsableSession() + { + using var directory = new TemporaryDirectory(); + var configPath = Path.Combine(directory.Path, "guiConfigs", "web-auth.json"); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync(new WebAuthService(configPath, null), sessions); + + using var status = await api.Client.GetAsync("/api/setup/status"); + using var statusDocument = JsonDocument.Parse(await status.Content.ReadAsStringAsync()); + await statusDocument.RootElement.GetProperty("setupRequired").GetBoolean().Should().BeTrue(); + await statusDocument.RootElement.GetProperty("setupAllowedFromThisRequest").GetBoolean().Should().BeTrue(); + + using var maliciousHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/setup/status"); + maliciousHostRequest.Headers.Host = "attacker.example"; + using var maliciousHostStatus = await api.Client.SendAsync(maliciousHostRequest); + using var maliciousStatusDocument = JsonDocument.Parse(await maliciousHostStatus.Content.ReadAsStringAsync()); + await maliciousStatusDocument.RootElement.GetProperty("setupAllowedFromThisRequest").GetBoolean().Should().BeFalse(); + + using var deniedRequest = new HttpRequestMessage(HttpMethod.Post, "/api/setup") + { + Content = JsonContent.Create(new { key = ManagementKey, confirmKey = ManagementKey }), + }; + deniedRequest.Headers.Host = "attacker.example"; + using var deniedResponse = await api.Client.SendAsync(deniedRequest); + await (deniedResponse.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + + using var setupResponse = await api.Client.PostAsJsonAsync("/api/setup", new { key = ManagementKey, confirmKey = ManagementKey }); + await (setupResponse.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var setupDocument = JsonDocument.Parse(await setupResponse.Content.ReadAsStringAsync()); + await setupDocument.RootElement.GetProperty("setupRequired").GetBoolean().Should().BeFalse(); + var sessionToken = setupDocument.RootElement.GetProperty("token").GetString()!; + await sessions.TryValidateWithoutRenewal(sessionToken, out _).Should().BeTrue(); + + using var protectedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + protectedRequest.Headers.Authorization = new("Bearer", sessionToken); + using var protectedResponse = await api.Client.SendAsync(protectedRequest); + await (protectedResponse.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + var persistedConfig = await File.ReadAllTextAsync(configPath); + await persistedConfig.Contains(ManagementKey, StringComparison.Ordinal).Should().BeFalse(); + } + + [Test] + public async Task LogoutRevokesOnlyTheCurrentSession() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var first = sessions.CreateSession(); + var second = sessions.CreateSession(); + + using var logout = new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout"); + logout.Headers.Authorization = new("Bearer", first.Token); + using var logoutResponse = await api.Client.SendAsync(logout); + await (logoutResponse.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + + using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + firstRequest.Headers.Authorization = new("Bearer", first.Token); + using var firstResponse = await api.Client.SendAsync(firstRequest); + await (firstResponse.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + + using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + secondRequest.Headers.Authorization = new("Bearer", second.Token); + using var secondResponse = await api.Client.SendAsync(secondRequest); + await (secondResponse.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + } + + [Test] + public async Task SseTicketIsOneTimeAndCannotAuthenticateRest() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var session = sessions.CreateSession(); + var ticket = await GetSseTicketAsync(api, session.Token); + + using var restWithTicket = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + restWithTicket.Headers.Authorization = new("Bearer", ticket); + using var restResponse = await api.Client.SendAsync(restWithTicket); + await (restResponse.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + + var responseTask = api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(ticket)}", + HttpCompletionOption.ResponseHeadersRead); + for (var attempt = 0; attempt < 100 && !responseTask.IsCompleted; attempt++) + { + api.Events.Publish("status", new { ready = true }); + await Task.Delay(10); + } + + using var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(3)); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var replay = await api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(ticket)}", + HttpCompletionOption.ResponseHeadersRead); + await (replay.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + sessions.Revoke(session.Token); + } + + [Test] + public async Task IssuingSseTicketDoesNotRenewTheOwningSession() + { + using var directory = new TemporaryDirectory(); + var startedAt = DateTimeOffset.Parse("2026-03-10T00:00:00Z"); + var time = new ManualTimeProvider(startedAt); + using var sessions = new WebSessionService(time); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var session = sessions.CreateSession(); + time.Advance(TimeSpan.FromDays(6)); + + _ = await GetSseTicketAsync(api, session.Token); + + await sessions.TryValidateWithoutRenewal(session.Token, out var snapshot).Should().BeTrue(); + await (snapshot!.LastSeenAt == startedAt).Should().BeTrue(); + await (snapshot.ExpiresAt == session.ExpiresAt).Should().BeTrue(); + } + + [Test] + public async Task RevokingAnActiveSseSessionClosesItsStream() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var session = sessions.CreateSession(); + var sseTicket = await GetSseTicketAsync(api, session.Token); + + var responseTask = api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(sseTicket)}", + HttpCompletionOption.ResponseHeadersRead); + for (var attempt = 0; attempt < 100 && !responseTask.IsCompleted; attempt++) + { + api.Events.Publish("status", new { ready = true }); + await Task.Delay(10); + } + + using var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(3)); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await using var stream = await response.Content.ReadAsStreamAsync(); + var buffer = new byte[512]; + var firstRead = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(3)); + await (firstRead > 0).Should().BeTrue(); + + sessions.Revoke(session.Token); + var closed = await ReadSseStreamUntilClosedAsync(stream); + await closed.Should().BeTrue(); + } + + [Test] + public async Task ExpiringAnActiveSseSessionClosesItsStream() + { + using var directory = new TemporaryDirectory(); + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-04-01T00:00:00Z")); + using var sessions = new WebSessionService(time); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var session = sessions.CreateSession(); + var sseTicket = await GetSseTicketAsync(api, session.Token); + var responseTask = api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(sseTicket)}", + HttpCompletionOption.ResponseHeadersRead); + for (var attempt = 0; attempt < 100 && !responseTask.IsCompleted; attempt++) + { + api.Events.Publish("status", new { ready = true }); + await Task.Delay(10); + } + + using var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(3)); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await using var stream = await response.Content.ReadAsStreamAsync(); + var buffer = new byte[512]; + var firstRead = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(3)); + await (firstRead > 0).Should().BeTrue(); + + time.Advance(WebSessionService.SlidingLifetime + TimeSpan.FromSeconds(1)); + await sessions.TryValidateWithoutRenewal(session.Token, out _).Should().BeFalse(); + var closed = await ReadSseStreamUntilClosedAsync(stream); + await closed.Should().BeTrue(); + } + + [Test] + public async Task AuthenticatedRestRequestRenewsSessionAfterSixDays() + { + using var directory = new TemporaryDirectory(); + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-01T00:00:00Z")); + using var sessions = new WebSessionService(time); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var session = sessions.CreateSession(); + time.Advance(TimeSpan.FromDays(6)); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + request.Headers.Authorization = new("Bearer", session.Token); + using var response = await api.Client.SendAsync(request); + + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(session.Token, out var renewed).Should().BeTrue(); + await (renewed!.LastSeenAt == time.GetUtcNow()).Should().BeTrue(); + await (renewed.ExpiresAt == time.GetUtcNow() + WebSessionService.SlidingLifetime).Should().BeTrue(); + } + + [Test] + public async Task LoginRejectsOverlongKeyUniformlyAndAcceptsMaximumLengthKey() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + var maximumLengthKey = new string('k', WebAuthService.MaximumKeyLength); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), maximumLengthKey), + sessions); + + using var valid = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = maximumLengthKey }); + using var overlong = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = maximumLengthKey + "k" }); + using var empty = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = string.Empty }); + using var missing = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = (string?)null }); + + await (valid.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await (overlong.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + await (empty.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + await (missing.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + } + + [Test] + public async Task OversizedLoginAndSetupBodiesAreRejectedBeforeJsonBinding() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var oversizedJson = new string(' ', checked((int)WebAuthRequestBodyLimitMiddleware.MaximumRequestBodyBytes + 1)); + + using var loginRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + { + Content = new StringContent(oversizedJson, Encoding.UTF8, "application/json"), + }; + loginRequest.Headers.ConnectionClose = true; + using var login = await api.Client.SendAsync(loginRequest); + using var setupRequest = new HttpRequestMessage(HttpMethod.Post, "/api/setup") + { + Content = new StringContent(oversizedJson, Encoding.UTF8, "application/json"), + }; + setupRequest.Headers.ConnectionClose = true; + using var setup = await api.Client.SendAsync(setupRequest); + + await (login.StatusCode == HttpStatusCode.RequestEntityTooLarge).Should().BeTrue(); + await (setup.StatusCode == HttpStatusCode.RequestEntityTooLarge).Should().BeTrue(); + } + + [Test] + public async Task SseHeartbeatDoesNotRenewSessionAndExpiryCancelsItsRevocationToken() + { + var startedAt = DateTimeOffset.Parse("2026-06-01T00:00:00Z"); + var time = new ManualTimeProvider(startedAt); + using var sessions = new WebSessionService(time); + var session = sessions.CreateSession(); + await sessions.TryValidateAndRenew(session.Token, out var snapshot).Should().BeTrue(); + var context = new DefaultHttpContext(); + context.Response.Body = Stream.Null; + + for (var minute = 1; minute < 7 * 24 * 60; minute++) + { + time.Advance(TimeSpan.FromMinutes(1)); + if (!await WebApiEndpoints.TryWriteSseHeartbeatAsync(context, sessions, snapshot!, CancellationToken.None)) + { + throw new InvalidOperationException("SSE heartbeat expired the session before its sliding deadline."); + } + } + + time.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(1)); + var heartbeatSent = await WebApiEndpoints.TryWriteSseHeartbeatAsync( + context, + sessions, + snapshot!, + CancellationToken.None); + + await heartbeatSent.Should().BeFalse(); + await (snapshot!.LastSeenAt == startedAt).Should().BeTrue(); + await snapshot.RevocationToken.IsCancellationRequested.Should().BeTrue(); + } + + private static async Task ReadSseStreamUntilClosedAsync(Stream stream) + { + try + { + // A network read may return only part of already-buffered SSE frames. + await stream.CopyToAsync(Stream.Null).WaitAsync(TimeSpan.FromSeconds(3)); + return true; + } + catch (IOException) + { + return true; + } + } + + private static async Task GetSseTicketAsync(ApiHarness api, string sessionToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/sse-ticket"); + request.Headers.Authorization = new("Bearer", sessionToken); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("data").GetProperty("ticket").GetString()!; + } + + private sealed class ApiHarness : IAsyncDisposable + { + private readonly WebApplication _app; + + private ApiHarness(WebApplication app, HttpClient client, EventHub events) + { + _app = app; + Client = client; + Events = events; + } + + public HttpClient Client { get; } + public EventHub Events { get; } + + public static async Task StartAsync(WebAuthService auth, WebSessionService sessions) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); + builder.Services.AddSingleton(auth); + builder.Services.AddSingleton(sessions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + + var app = builder.Build(); + app.Use(async (context, next) => + { + try + { + await next(); + } + catch (Exception) when (context.Request.Path.StartsWithSegments("/api") && !context.Response.HasStarted) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(ApiEnvelope.Fail("internal_error", ApiMessageKeys.CommonInternal)); + } + }); + app.UseRouting(); + app.UseRateLimiter(); + app.UseMiddleware(); + app.UseMiddleware(); + app.MapWebApi(); + app.MapWebAuthEndpoints(); + app.MapWebSetupEndpoints(); + app.MapGet("/api/test/session", () => Results.Ok(new { authorized = true })); + app.MapGet("/api/test/runtime-failure", () => Results.StatusCode(StatusCodes.Status500InternalServerError)); + await app.StartAsync(); + + var addresses = app.Services.GetRequiredService() + .Features.Get()!.Addresses; + var client = new HttpClient(new SocketsHttpHandler { UseProxy = false }) + { + BaseAddress = new Uri(addresses.Single()), + Timeout = TimeSpan.FromSeconds(5), + }; + return new ApiHarness(app, client, app.Services.GetRequiredService()); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-auth-api-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } + + private sealed class ManualTimeProvider(DateTimeOffset initialTime) : TimeProvider + { + private DateTimeOffset _now = initialTime; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan amount) => _now += amount; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs new file mode 100644 index 00000000..754a6e32 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs @@ -0,0 +1,178 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebAuthRateLimitTests +{ + [Test] + public async Task LoginRateLimiterRejectsExcessAttemptsWithTooManyRequests() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + builder.Services.AddSingleton(new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), null)); + builder.Services.AddSingleton(sessions); + + await using var app = builder.Build(); + app.UseRouting(); + app.UseRateLimiter(); + app.UseMiddleware(); + app.MapWebAuthEndpoints(); + await app.StartAsync(); + try + { + var server = app.Services.GetRequiredService(); + var address = server.Features.Get()!.Addresses.Single(); + using var client = new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(20) }; + var statuses = new List(); + int? retryAfterSeconds = null; + + for (var attempt = 0; attempt < 6; attempt++) + { + using var response = await client.PostAsJsonAsync("/api/auth/login", new { key = "incorrect-management-key" }); + statuses.Add(response.StatusCode); + if (attempt == 5 && response.Headers.TryGetValues("Retry-After", out var values) + && int.TryParse(values.SingleOrDefault(), out var seconds)) + { + retryAfterSeconds = seconds; + } + } + + await statuses.Take(5).All(status => status == HttpStatusCode.Unauthorized).Should().BeTrue(); + await (statuses[5] == HttpStatusCode.TooManyRequests).Should().BeTrue(); + await (retryAfterSeconds > 0).Should().BeTrue(); + } + finally + { + await app.StopAsync(); + } + } + + [Test] + [Arguments("/login-limited")] + [Arguments("/setup-limited")] + public async Task AllLoopbackAddressesShareOneRateLimitPartition(string endpoint) + { + await using var app = CreatePartitionTestApp(); + await app.StartAsync(); + try + { + using var client = CreateClient(app); + var loopbackAddresses = new[] { "127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1" }; + var permitLimit = endpoint == "/login-limited" ? 5 : 10; + + for (var index = 0; index < permitLimit; index++) + { + await (await GetLimitedStatusAsync(client, endpoint, loopbackAddresses[index % loopbackAddresses.Length], index)) + .Should().BeEqualTo(HttpStatusCode.OK); + } + + await (await GetLimitedStatusAsync(client, endpoint, loopbackAddresses[permitLimit % loopbackAddresses.Length], permitLimit)) + .Should().BeEqualTo(HttpStatusCode.TooManyRequests); + } + finally + { + await app.StopAsync(); + } + } + + [Test] + [Arguments("/login-limited")] + [Arguments("/setup-limited")] + public async Task DifferentNonLoopbackAddressesKeepIndependentRateLimitPartitions(string endpoint) + { + await using var app = CreatePartitionTestApp(); + await app.StartAsync(); + try + { + using var client = CreateClient(app); + var permitLimit = endpoint == "/login-limited" ? 5 : 10; + + for (var index = 0; index < permitLimit; index++) + { + await (await GetLimitedStatusAsync(client, endpoint, "192.0.2.1", index)) + .Should().BeEqualTo(HttpStatusCode.OK); + } + + await (await GetLimitedStatusAsync(client, endpoint, "192.0.2.2", permitLimit)) + .Should().BeEqualTo(HttpStatusCode.OK); + await (await GetLimitedStatusAsync(client, endpoint, "192.0.2.1", permitLimit + 1)) + .Should().BeEqualTo(HttpStatusCode.TooManyRequests); + } + finally + { + await app.StopAsync(); + } + } + + private static WebApplication CreatePartitionTestApp() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + + var app = builder.Build(); + app.UseRouting(); + // Test-only injection lets the integration tests exercise multiple peer addresses + // without trusting forwarded headers in the WebAPI pipeline. + app.Use(async (context, next) => + { + if (IPAddress.TryParse(context.Request.Headers["X-Test-Remote-Ip"].FirstOrDefault(), out var address)) + { + context.Connection.RemoteIpAddress = address; + } + + await next(); + }); + app.UseRateLimiter(); + app.MapGet("/login-limited", () => Results.Ok()) + .RequireRateLimiting(WebAuthRateLimiting.LoginPolicy); + app.MapGet("/setup-limited", () => Results.Ok()) + .RequireRateLimiting(WebAuthRateLimiting.SetupPolicy); + return app; + } + + private static HttpClient CreateClient(WebApplication app) + { + var server = app.Services.GetRequiredService(); + var address = server.Features.Get()!.Addresses.Single(); + return new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(20) }; + } + + private static async Task GetLimitedStatusAsync(HttpClient client, string endpoint, string remoteIp, int requestIndex) + { + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + request.Headers.Add("X-Test-Remote-Ip", remoteIp); + request.Headers.Add("X-Forwarded-For", $"198.51.100.{requestIndex + 1}"); + request.Headers.Add("Forwarded", $"for=198.51.100.{requestIndex + 1}"); + request.Headers.Add("X-Real-IP", $"198.51.100.{requestIndex + 1}"); + using var response = await client.SendAsync(request); + return response.StatusCode; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-rate-limit-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebAuthServiceTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebAuthServiceTests.cs new file mode 100644 index 00000000..f6f20ea6 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebAuthServiceTests.cs @@ -0,0 +1,175 @@ +using System.Text.Json; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebAuthServiceTests +{ + private const string ManagementKey = "test-management-key-2026"; + + [Test] + public async Task MissingEnvironmentAndPersistedKeyRequiresSetup() + { + using var directory = new TemporaryDirectory(); + var auth = new WebAuthService(Path.Combine(directory.Path, "guiConfigs", "WebAPI-auth.json"), null); + + await auth.SetupRequired.Should().BeTrue(); + await auth.EnvironmentKeyConfigured.Should().BeFalse(); + await auth.ValidateManagementKey(ManagementKey).Should().BeFalse(); + } + + [Test] + public async Task EnvironmentKeyTakesPrecedenceWithoutPersistingIt() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "guiConfigs", "WebAPI-auth.json"); + var auth = new WebAuthService(path, ManagementKey); + + await auth.SetupRequired.Should().BeFalse(); + await auth.EnvironmentKeyConfigured.Should().BeTrue(); + await auth.ValidateManagementKey(ManagementKey).Should().BeTrue(); + await auth.ValidateManagementKey("a-different-management-key").Should().BeFalse(); + await File.Exists(path).Should().BeFalse(); + } + + [Test] + public async Task SetupPersistsOnlySaltAndVerifierAndVerifiesTheKeyImmediately() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "guiConfigs", "WebAPI-auth.json"); + var auth = new WebAuthService(path, null); + + var result = await auth.SetupAsync(ManagementKey, ManagementKey, setupAccessAllowed: true); + + await (result == WebSetupResult.Created).Should().BeTrue(); + await auth.SetupRequired.Should().BeFalse(); + await auth.ValidateManagementKey(ManagementKey).Should().BeTrue(); + await auth.ValidateManagementKey("not-the-management-key").Should().BeFalse(); + + var persisted = await File.ReadAllTextAsync(path); + await persisted.Contains(ManagementKey, StringComparison.Ordinal).Should().BeFalse(); + using var document = JsonDocument.Parse(persisted); + await (document.RootElement.GetProperty("version").GetInt32() == 1).Should().BeTrue(); + await (!string.IsNullOrWhiteSpace(document.RootElement.GetProperty("salt").GetString())).Should().BeTrue(); + await (!string.IsNullOrWhiteSpace(document.RootElement.GetProperty("keyVerifier").GetString())).Should().BeTrue(); + if (OperatingSystem.IsLinux()) + { + await (File.GetUnixFileMode(path) == (UnixFileMode.UserRead | UnixFileMode.UserWrite)).Should().BeTrue(); + } + } + + [Test] + public async Task SetupRejectsShortOrMismatchedKeys() + { + using var directory = new TemporaryDirectory(); + var auth = new WebAuthService(Path.Combine(directory.Path, "WebAPI-auth.json"), null); + + await ((await auth.SetupAsync("short", "short", setupAccessAllowed: true)) == WebSetupResult.KeyTooShort).Should().BeTrue(); + await ((await auth.SetupAsync(new string('x', WebAuthService.MaximumKeyLength + 1), new string('x', WebAuthService.MaximumKeyLength + 1), setupAccessAllowed: true)) == WebSetupResult.KeyTooLong).Should().BeTrue(); + await ((await auth.SetupAsync(ManagementKey, "a-different-key", setupAccessAllowed: true)) == WebSetupResult.KeysDoNotMatch).Should().BeTrue(); + await auth.SetupRequired.Should().BeTrue(); + } + + [Test] + public async Task SetupCanOnlyBeCompletedOnce() + { + using var directory = new TemporaryDirectory(); + var auth = new WebAuthService(Path.Combine(directory.Path, "WebAPI-auth.json"), null); + + await ((await auth.SetupAsync(ManagementKey, ManagementKey, setupAccessAllowed: true)) == WebSetupResult.Created).Should().BeTrue(); + await ((await auth.SetupAsync("a-second-management-key", "a-second-management-key", setupAccessAllowed: true)) == WebSetupResult.AlreadyConfigured).Should().BeTrue(); + await auth.ValidateManagementKey(ManagementKey).Should().BeTrue(); + await auth.ValidateManagementKey("a-second-management-key").Should().BeFalse(); + } + + [Test] + public async Task SetupRejectsWhenRequestAccessPolicyDeniesIt() + { + using var directory = new TemporaryDirectory(); + var auth = new WebAuthService(Path.Combine(directory.Path, "WebAPI-auth.json"), null); + + await ((await auth.SetupAsync(ManagementKey, ManagementKey, setupAccessAllowed: false)) == WebSetupResult.Forbidden).Should().BeTrue(); + await auth.SetupRequired.Should().BeTrue(); + } + + [Test] + public async Task PersistedVerifierSurvivesServiceRecreation() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "guiConfigs", "WebAPI-auth.json"); + var firstInstance = new WebAuthService(path, null); + await ((await firstInstance.SetupAsync(ManagementKey, ManagementKey, setupAccessAllowed: true)) == WebSetupResult.Created).Should().BeTrue(); + + var restartedInstance = new WebAuthService(path, null); + await restartedInstance.SetupRequired.Should().BeFalse(); + await restartedInstance.ValidateManagementKey(ManagementKey).Should().BeTrue(); + } + + [Test] + public async Task WebAuthStorageMovesLegacyConfigCredentialOutsideTheBackupDirectory() + { + using var directory = new TemporaryDirectory(); + var legacyPath = Path.Combine(directory.Path, "guiConfigs", "web-auth.json"); + Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!); + await File.WriteAllTextAsync(legacyPath, "local-verifier"); + + var authPath = WebAuthStorage.MigrateAndGetPath(directory.Path, legacyPath); + + await authPath.Should().BeEqualTo(Path.Combine(directory.Path, "WebAPIData", "WebAPI-auth.json")); + await (await File.ReadAllTextAsync(authPath)).Should().BeEqualTo("local-verifier"); + await File.Exists(legacyPath).Should().BeFalse(); + if (OperatingSystem.IsLinux()) + { + await (File.GetUnixFileMode(Path.GetDirectoryName(authPath)!) + == (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute)).Should().BeTrue(); + } + } + + [Test] + public async Task ExistingPrivateWebAuthCredentialWinsOverAStaleLegacyCopy() + { + using var directory = new TemporaryDirectory(); + var privatePath = Path.Combine(directory.Path, "WebAPIData", "WebAPI-auth.json"); + var legacyPath = Path.Combine(directory.Path, "guiConfigs", "web-auth.json"); + Directory.CreateDirectory(Path.GetDirectoryName(privatePath)!); + Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!); + await File.WriteAllTextAsync(privatePath, "private-verifier"); + await File.WriteAllTextAsync(legacyPath, "stale-verifier"); + + var authPath = WebAuthStorage.MigrateAndGetPath(directory.Path, legacyPath); + + await authPath.Should().BeEqualTo(privatePath); + await (await File.ReadAllTextAsync(authPath)).Should().BeEqualTo("private-verifier"); + await File.Exists(legacyPath).Should().BeFalse(); + } + + [Test] + public async Task PreviousPrivateWebAuthCredentialMigratesToTheRenamedLocation() + { + using var directory = new TemporaryDirectory(); + var previousPrivatePath = Path.Combine(directory.Path, "webData", "web-auth.json"); + Directory.CreateDirectory(Path.GetDirectoryName(previousPrivatePath)!); + await File.WriteAllTextAsync(previousPrivatePath, "old-private-verifier"); + + var authPath = WebAuthStorage.MigrateAndGetPath( + directory.Path, + Path.Combine(directory.Path, "guiConfigs", "web-auth.json")); + + await authPath.Should().BeEqualTo(Path.Combine(directory.Path, "WebAPIData", "WebAPI-auth.json")); + await (await File.ReadAllTextAsync(authPath)).Should().BeEqualTo("old-private-verifier"); + await File.Exists(previousPrivatePath).Should().BeFalse(); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-auth-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebCorsTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebCorsTests.cs new file mode 100644 index 00000000..773fea8b --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebCorsTests.cs @@ -0,0 +1,261 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebCorsTests +{ + private const string Origin = "https://good.example.com"; + private const string Key = "isolated-cors-management-key"; + + [Test] + public async Task ConfigurationRejectsUnsafeOriginsAndMatchesExactSchemeHostPort() + { + foreach (var invalid in new[] { "*", "null", "file://host", "https://*.example.com", "https://good.example.com/path", + "https://good.example.com?", "https://good.example.com#", "https://user:password@good.example.com", "https://good.example.com,", "https://good.example.com\\evil", "https://good.example.com/other/..", "https://good.example.com/%2e" }) + { + var rejected = false; + try { _ = new WebCorsPolicy(invalid); } catch (ArgumentException) { rejected = true; } + await rejected.Should().BeTrue(); + } + var policy = new WebCorsPolicy(" HTTPS://GOOD.EXAMPLE.COM:443, http://localhost:5173 "); + await policy.Allows(Origin).Should().BeTrue(); + await policy.Allows("http://good.example.com").Should().BeFalse(); + await policy.Allows("https://good.example.com:444").Should().BeFalse(); + await policy.Allows("https://good.example.com.evil.com").Should().BeFalse(); + } + + [Test] + public async Task DefaultDeniesCrossOriginIncludingSimpleLoginWithoutExecutingIt() + { + await using var api = await Harness.StartAsync(null); + using var request = Request(HttpMethod.Post, "/api/auth/login", Origin); + request.Content = JsonContent.Create(new { key = Key }); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + await response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + await document.RootElement.GetProperty("code").GetString().Should().BeEqualTo("origin_not_allowed"); + } + + [Test] + public async Task SimilarOriginAndNullOriginAreDeniedEvenWithAValidBearer() + { + await using var api = await Harness.StartAsync(Origin); + foreach (var origin in new[] { Origin + ".evil.com", "http://good.example.com", Origin + ":444", "null" }) + { + using var request = Request(HttpMethod.Get, "/api/test/session", origin, api.Sessions.CreateSession().Token); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + await response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse(); + } + } + + [Test] + public async Task PreflightCompletesWithoutBearerAndDoesNotEnableCookiesOrPrivateNetworkBypass() + { + await using var api = await Harness.StartAsync(Origin); + using var request = Request(HttpMethod.Options, "/api/test/session", Origin); + request.Headers.Add("Access-Control-Request-Method", "PUT"); + request.Headers.Add("Access-Control-Request-Headers", "authorization,content-type"); + request.Headers.Add("Access-Control-Request-Private-Network", "true"); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.NoContent).Should().BeTrue(); + await AssertCorsAsync(response); + await response.Headers.GetValues("Access-Control-Allow-Methods").Single().Contains("PUT").Should().BeTrue(); + await response.Headers.GetValues("Access-Control-Allow-Headers").Single().Contains("authorization", StringComparison.OrdinalIgnoreCase).Should().BeTrue(); + await response.Headers.Contains("Access-Control-Allow-Private-Network").Should().BeFalse(); + } + + [Test] + public async Task LoginAndBearerRestWorkAndManagementKeyIsStillNotAnApiToken() + { + await using var api = await Harness.StartAsync(Origin); + using var login = Request(HttpMethod.Post, "/api/auth/login", Origin); + login.Content = JsonContent.Create(new { key = Key }); + using var loggedIn = await api.Client.SendAsync(login); + await (loggedIn.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await AssertCorsAsync(loggedIn); + using var document = JsonDocument.Parse(await loggedIn.Content.ReadAsStringAsync()); + var token = document.RootElement.GetProperty("data").GetProperty("token").GetString()!; + using var request = Request(HttpMethod.Get, "/api/test/session", Origin, token); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await AssertCorsAsync(response); + using var wrong = Request(HttpMethod.Get, "/api/test/session", Origin, Key); + using var unauthorized = await api.Client.SendAsync(wrong); + await (unauthorized.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + await AssertCorsAsync(unauthorized); + } + + [Test] + public async Task TicketAndRealSseEndpointWorkAcrossOriginAndTicketCannotBeConsumedByDeniedOrigin() + { + await using var api = await Harness.StartAsync(Origin); + var token = api.Sessions.CreateSession().Token; + using var ticketRequest = Request(HttpMethod.Post, "/api/auth/sse-ticket", Origin, token); + using var ticketResponse = await api.Client.SendAsync(ticketRequest); + await AssertCorsAsync(ticketResponse); + using var document = JsonDocument.Parse(await ticketResponse.Content.ReadAsStringAsync()); + var ticket = document.RootElement.GetProperty("data").GetProperty("ticket").GetString()!; + var path = "/api/events?sse_ticket=" + Uri.EscapeDataString(ticket); + using var deniedRequest = Request(HttpMethod.Get, path, Origin + ".evil.com"); + using var denied = await api.Client.SendAsync(deniedRequest); + await (denied.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + using var request = Request(HttpMethod.Get, path, Origin); + var responseTask = api.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + for (var attempt = 0; attempt < 100 && !responseTask.IsCompleted; attempt++) + { + api.Events.Publish("status", new { ready = true }); + await Task.Delay(10); + } + using var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(5)); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await AssertCorsAsync(response); + await (response.Content.Headers.ContentType?.MediaType == "text/event-stream").Should().BeTrue(); + api.Sessions.Revoke(token); + } + + [Test] + public async Task DownloadExposesOnlyRequiredPublicHeaders() + { + await using var api = await Harness.StartAsync(Origin); + using var request = Request(HttpMethod.Get, "/api/test/download", Origin, api.Sessions.CreateSession().Token); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await AssertCorsAsync(response); + await (response.Content.Headers.ContentDisposition?.FileNameStar == "backup.zip").Should().BeTrue(); + await response.Headers.GetValues("Access-Control-Expose-Headers").Single().Should().BeEqualTo("Content-Disposition"); + } + + [Test] + public async Task AllowedOriginCannotSetupAndStatusExplainsWhyWhileSameOriginStillCan() + { + await using var api = await Harness.StartAsync(Origin, configured: false); + using var statusRequest = Request(HttpMethod.Get, "/api/setup/status", Origin); + using var status = await api.Client.SendAsync(statusRequest); + await AssertCorsAsync(status); + using var statusDocument = JsonDocument.Parse(await status.Content.ReadAsStringAsync()); + await statusDocument.RootElement.GetProperty("setupRequired").GetBoolean().Should().BeTrue(); + await statusDocument.RootElement.GetProperty("setupAllowedFromThisRequest").GetBoolean().Should().BeFalse(); + using var setupRequest = Request(HttpMethod.Post, "/api/setup", Origin); + setupRequest.Content = JsonContent.Create(new { key = Key, confirmKey = Key }); + using var denied = await api.Client.SendAsync(setupRequest); + await (denied.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + await api.Auth.SetupRequired.Should().BeTrue(); + using var sameOrigin = Request(HttpMethod.Post, "/api/setup", api.Client.BaseAddress!.GetLeftPart(UriPartial.Authority)); + sameOrigin.Content = JsonContent.Create(new { key = Key, confirmKey = Key }); + using var created = await api.Client.SendAsync(sameOrigin); + await (created.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + } + + [Test] + public async Task NativeAndSameOriginRequestsAreUnchangedWithoutAllowedOrigins() + { + await using var api = await Harness.StartAsync(null); + foreach (var origin in new[] { null, api.Client.BaseAddress!.GetLeftPart(UriPartial.Authority) }) + { + using var request = Request(HttpMethod.Get, "/api/test/session", origin, api.Sessions.CreateSession().Token); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + } + } + + [Test] + public async Task SameOriginBrowserBehindTlsProxyKeepsRestButCannotRelaySetup() + { + await using var api = await Harness.StartAsync(null, configured: false); + var token = api.Sessions.CreateSession().Token; + using var request = Request(HttpMethod.Get, "/api/test/session", "https://proxy.example.com", token); + request.Headers.Add("Sec-Fetch-Site", "same-origin"); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + // Genuine cross-origin browser requests cannot forge same-origin metadata. + using var crossSite = Request(HttpMethod.Get, "/api/test/session", "https://proxy.example.com", token); + crossSite.Headers.Add("Sec-Fetch-Site", "same-site"); + using var denied = await api.Client.SendAsync(crossSite); + await (denied.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + using var statusRequest = Request(HttpMethod.Get, "/api/setup/status", "https://proxy.example.com"); + statusRequest.Headers.Add("Sec-Fetch-Site", "same-origin"); + using var status = await api.Client.SendAsync(statusRequest); + using var document = JsonDocument.Parse(await status.Content.ReadAsStringAsync()); + await document.RootElement.GetProperty("setupAllowedFromThisRequest").GetBoolean().Should().BeFalse(); + } + + private static HttpRequestMessage Request(HttpMethod method, string path, string? origin, string? token = null) + { + var request = new HttpRequestMessage(method, path); + if (origin is not null) request.Headers.Add("Origin", origin); + if (token is not null) request.Headers.Authorization = new("Bearer", token); + return request; + } + + private static async Task AssertCorsAsync(HttpResponseMessage response) + { + await response.Headers.GetValues("Access-Control-Allow-Origin").Single().Should().BeEqualTo(Origin); + await response.Headers.Contains("Access-Control-Allow-Credentials").Should().BeFalse(); + } + + private sealed class Harness(WebApplication app, HttpClient client, WebAuthService auth, string directory) : IAsyncDisposable + { + public HttpClient Client { get; } = client; + public WebAuthService Auth { get; } = auth; + public WebSessionService Sessions => app.Services.GetRequiredService(); + public EventHub Events => app.Services.GetRequiredService(); + + public static async Task StartAsync(string? origins, bool configured = true) + { + var directory = Path.Combine(Path.GetTempPath(), "webapi-cors-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var auth = new WebAuthService(Path.Combine(directory, "web-auth.json"), configured ? Key : null); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Logging.ClearProviders(); + var cors = new WebCorsPolicy(origins); + builder.Services.AddSingleton(cors); + builder.Services.AddCors(options => options.AddPolicy(WebCorsPolicy.PolicyName, cors.Configure)); + builder.Services.AddSingleton(auth); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + var app = builder.Build(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseRouting(); + app.UseMiddleware(); + app.UseCors(WebCorsPolicy.PolicyName); + app.UseRateLimiter(); + app.UseMiddleware(); + app.MapWebApi(); + app.MapWebAuthEndpoints(); + app.MapWebSetupEndpoints(); + app.MapGet("/api/test/session", () => Results.Ok(new { authorized = true })); + app.MapGet("/api/test/download", () => Results.File(new byte[] { 1, 2, 3 }, "application/zip", "backup.zip")); + await app.StartAsync(); + var address = app.Services.GetRequiredService().Features.Get()!.Addresses.Single(); + return new Harness(app, new HttpClient(new SocketsHttpHandler { UseProxy = false }) { BaseAddress = new Uri(address) }, auth, directory); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await app.StopAsync(); + await app.DisposeAsync(); + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebDeploymentSecurityPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebDeploymentSecurityPolicyTests.cs new file mode 100644 index 00000000..6d8a61a6 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebDeploymentSecurityPolicyTests.cs @@ -0,0 +1,33 @@ +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebDeploymentSecurityPolicyTests +{ + [Test] + public async Task NativeInteractiveInstallCanStartWithoutEnvironmentKey() + { + await WebDeploymentSecurityPolicy.GetStartupError(false, false, null).Should().BeNull(); + await WebDeploymentSecurityPolicy.GetStartupError(false, false, " ").Should().BeNull(); + } + + [Test] + public async Task SupervisedDeploymentRequiresAnEnvironmentKey() + { + await WebDeploymentSecurityPolicy.GetStartupError(true, false, null) + .Should().BeEqualTo(WebDeploymentSecurityPolicy.MissingManagementKeyMessage); + await WebDeploymentSecurityPolicy.GetStartupError(true, false, "\t") + .Should().BeEqualTo(WebDeploymentSecurityPolicy.MissingManagementKeyMessage); + await WebDeploymentSecurityPolicy.GetStartupError(true, false, "a-configured-key") + .Should().BeNull(); + } + + [Test] + public async Task ContainerDeploymentRequiresAnEnvironmentKey() + { + await WebDeploymentSecurityPolicy.GetStartupError(false, true, null) + .Should().BeEqualTo(WebDeploymentSecurityPolicy.MissingManagementKeyMessage); + await WebDeploymentSecurityPolicy.GetStartupError(false, true, "a-configured-key") + .Should().BeNull(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebHealthEndpointTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebHealthEndpointTests.cs new file mode 100644 index 00000000..dcd4df3e --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebHealthEndpointTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebHealthEndpointTests +{ + [Test] + public async Task LanHealthResponseContainsOnlyStatusAndDoesNotExposeRuntimeDetails() + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse("192.168.1.20"); + + var response = WebHealthEndpoint.CreateResponse(context, runtime: null!); + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + await json.Should().BeEqualTo("{\"status\":\"ok\"}"); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-instance-pid").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-version").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-state").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-process-ids").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-profile-id").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-shutdown-stage").Should().BeFalse(); + } + + [Test] + public async Task LoopbackHealthProbeRetainsTheFieldsRequiredByLauncherAndUpdater() + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Loopback; + context.Request.Host = new HostString("127.0.0.1", 5080); + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + + _ = WebHealthEndpoint.CreateResponse(context, runtime); + + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-instance-pid").Should().BeTrue(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-version").Should().BeTrue(); + await context.Response.Headers["X-v2rayn-WebAPI-core-state"].ToString().Should().BeEqualTo("stopped"); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-process-ids").Should().BeTrue(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-profile-id").Should().BeTrue(); + } + + [Test] + public async Task LocalReverseProxyDoesNotGetExtendedHealthFields() + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Loopback; + context.Request.Host = new HostString("127.0.0.1", 5080); + context.Request.Headers["X-Forwarded-For"] = "198.51.100.25"; + + var response = WebHealthEndpoint.CreateResponse(context, runtime: null!); + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + await json.Should().BeEqualTo("{\"status\":\"ok\"}"); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-instance-pid").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-process-ids").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-profile-id").Should().BeFalse(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs new file mode 100644 index 00000000..8771e9bf --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs @@ -0,0 +1,545 @@ +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Tests; + +public class WebLauncherTests +{ + [Test] + public async Task InstanceLockOwnerCanBeReadWhileAnotherProcessHoldsTheLock() + { + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: true, out var heldLock).Should().BeTrue(); + using (heldLock) + { + await WebInstanceLock.ReadOwnerProcessId(lockPath).Should().BeEqualTo(Environment.ProcessId); + await WebInstanceLock.IsHeld(lockPath).Should().BeTrue(); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: false, out _).Should().BeFalse(); + } + await WebInstanceLock.IsHeld(lockPath).Should().BeFalse(); + } + + [Test] + public async Task StopFlagSelectsStopModeAndIsNotForwardedToHost() + { + var options = WebLaunchOptions.Parse( + ["--stop", "--foreground", "--background", "--urls", "http://127.0.0.1:5090"], + isLinux: true, + daemonEnvironment: false, + containerEnvironment: false, + interactiveLaunch: false); + + await (options.Mode == WebLaunchMode.Stop).Should().BeTrue(); + await options.HostArguments.SequenceEqual(["--urls", "http://127.0.0.1:5090"]).Should().BeTrue(); + } + + [Test] + public async Task ForegroundFlagAlwaysSelectsForegroundMode() + { + var options = WebLaunchOptions.Parse(["--foreground"], isLinux: true, daemonEnvironment: false, containerEnvironment: false, interactiveLaunch: false); + await (options.Mode == WebLaunchMode.Foreground).Should().BeTrue(); + } + + [Test] + public async Task BackgroundFlagSelectsLauncherMode() + { + var options = WebLaunchOptions.Parse(["--background"], isLinux: true, daemonEnvironment: true, containerEnvironment: true, interactiveLaunch: false); + await (options.Mode == WebLaunchMode.BackgroundLauncher).Should().BeTrue(); + } + + [Test] + public async Task BackgroundChildRunsTheForegroundHostWithoutRecursiveLauncherArguments() + { + var options = WebLaunchOptions.Parse(["--background-child", "--no-open", "--urls", "http://127.0.0.1:5090"], isLinux: true, daemonEnvironment: false, containerEnvironment: false, interactiveLaunch: false); + await (options.Mode == WebLaunchMode.BackgroundChild).Should().BeTrue(); + await options.NoOpen.Should().BeTrue(); + await options.HostArguments.SequenceEqual(["--urls", "http://127.0.0.1:5090"]).Should().BeTrue(); + } + + [Test] + public async Task SystemdAndContainerDefaultsRemainForeground() + { + var systemd = WebLaunchOptions.Parse([], isLinux: true, daemonEnvironment: true, containerEnvironment: false, interactiveLaunch: false); + var container = WebLaunchOptions.Parse([], isLinux: true, daemonEnvironment: false, containerEnvironment: true, interactiveLaunch: false); + + await (systemd.Mode == WebLaunchMode.Foreground).Should().BeTrue(); + await (container.Mode == WebLaunchMode.Foreground).Should().BeTrue(); + await (WebLaunchOptions.Parse(["--foreground"], true, true, true, false).Mode == WebLaunchMode.Foreground).Should().BeTrue(); + } + + [Test] + public async Task NativeLinuxDefaultUsesTheBackgroundLauncher() + { + var desktop = WebLaunchOptions.Parse([], isLinux: true, daemonEnvironment: false, containerEnvironment: false, interactiveLaunch: false); + var otherPlatform = WebLaunchOptions.Parse([], isLinux: false, daemonEnvironment: false, containerEnvironment: false, interactiveLaunch: false); + + await (desktop.Mode == WebLaunchMode.BackgroundLauncher).Should().BeTrue(); + await (otherPlatform.Mode == WebLaunchMode.Foreground).Should().BeTrue(); + } + + [Test] + public async Task InteractiveLaunchOverridesLeakedSystemdMarkersForTheDefaultLauncher() + { + // Terminals started by systemd export INVOCATION_ID/JOURNAL_STREAM to every + // command they run; commands run in such a terminal are still interactive. + var nestedTerminal = WebLaunchOptions.Parse( + [], + isLinux: true, + daemonEnvironment: true, + containerEnvironment: false, + interactiveLaunch: true); + + await (nestedTerminal.Mode == WebLaunchMode.BackgroundLauncher).Should().BeTrue(); + } + + [Test] + public async Task InteractiveLaunchMarkerIsDetectedAndHelpFlagsAreNotForwarded() + { + await LauncherEnvironment.IsInteractiveLaunchMarker( + new Dictionary { [LauncherEnvironment.InteractiveLaunchEnvironmentVariable] = "1" }) + .Should().BeTrue(); + await LauncherEnvironment.IsInteractiveLaunchMarker(new Dictionary()) + .Should().BeFalse(); + + var options = WebLaunchOptions.Parse( + ["--help"], + isLinux: true, + daemonEnvironment: false, + containerEnvironment: false, + interactiveLaunch: false); + await (options.HostArguments.Length == 0).Should().BeTrue(); + } + + [Test] + public async Task HelpTextListsTheDocumentedFlagsInEveryLocale() + { + foreach (var locale in new[] + { + LauncherLocale.SimplifiedChinese, + LauncherLocale.TraditionalChinese, + LauncherLocale.English, + }) + { + var help = LauncherMessages.Help(locale); + await help.Contains("--foreground", StringComparison.Ordinal).Should().BeTrue(); + await help.Contains("--background", StringComparison.Ordinal).Should().BeTrue(); + await help.Contains("--no-open", StringComparison.Ordinal).Should().BeTrue(); + await help.Contains("--stop", StringComparison.Ordinal).Should().BeTrue(); + await help.Contains("--help", StringComparison.Ordinal).Should().BeTrue(); + await help.Contains("V2RAYN_WEB_API_KEY", StringComparison.Ordinal).Should().BeTrue(); + } + } + + [Test] + public async Task SystemdEnvironmentDetectionRecognizesInvocationMarkers() + { + var environment = new Dictionary { ["INVOCATION_ID"] = "unit-run-id" }; + await LauncherEnvironment.IsDaemonEnvironment(environment).Should().BeTrue(); + await LauncherEnvironment.IsContainerEnvironment(new Dictionary { ["container"] = "podman" }).Should().BeTrue(); + } + + [Test] + public async Task ExistingHealthyInstanceIsReusedAndBrowserAdapterIsInvoked() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: true, out var heldLock).Should().BeTrue(); + using (heldLock) + { + var browser = new FakeBrowserOpener(); + var result = await ExistingInstanceHandler.TryReuseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + new Uri("http://127.0.0.1:5080/"), + new FakeHealthProbe(true, WebInstanceLock.ReadOwnerProcessId(lockPath)), + browser, + noOpen: false, + TimeSpan.FromSeconds(1)); + + await result.Existing.Should().BeTrue(); + await result.BrowserOpened.Should().BeTrue(); + await (browser.LastUri == new Uri("http://127.0.0.1:5080/")).Should().BeTrue(); + } + } + + [Test] + public async Task HealthyEndpointFromAnotherInstanceIsNotReusedForThisDataHome() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: true, out var heldLock).Should().BeTrue(); + using (heldLock) + { + var expectedOwner = WebInstanceLock.ReadOwnerProcessId(lockPath); + var browser = new FakeBrowserOpener(); + var timedOut = false; + try + { + await ExistingInstanceHandler.TryReuseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + new Uri("http://127.0.0.1:5080/"), + new FakeHealthProbe(true, expectedOwner + 1), + browser, + noOpen: false, + TimeSpan.FromMilliseconds(50)); + } + catch (TimeoutException) + { + timedOut = true; + } + + await timedOut.Should().BeTrue(); + await (browser.LastUri is null).Should().BeTrue(); + } + } + + [Test] + public async Task UnlockedStaleLockFileCanBeRecovered() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + await File.WriteAllTextAsync(lockPath, "99999999\n"); + + var result = await ExistingInstanceHandler.TryReuseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + new Uri("http://127.0.0.1:5080/"), + new FakeHealthProbe(false), + new FakeBrowserOpener(), + noOpen: true, + TimeSpan.FromMilliseconds(100)); + + await result.Existing.Should().BeFalse(); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: false, out var recovered).Should().BeTrue(); + recovered!.Dispose(); + } + + [Test] + public async Task StopTreatsAnUnlockedStaleLockAsAlreadyStopped() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + await File.WriteAllTextAsync(lockPath, "123\n"); + var signals = new FakeSignalSender(); + var stopper = new WebStopper(new FakeHealthProbe(true, 123), signals); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.NotRunning).Should().BeTrue(); + await (signals.ProcessIds.Count == 0).Should().BeTrue(); + } + + [Test] + public async Task StopSendsOneSigTermToVerifiedOwnerAndWaitsForLockRelease() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + using (heldLock) + { + var signals = new FakeSignalSender(); + var health = new FakeHealthProbe(true, 123); + signals.OnSignal = processId => + { + _ = ReleaseLockAfterDelayAsync(heldLock, health); + }; + var stopper = new WebStopper( + health, + signals, + stopTimeout: TimeSpan.FromMilliseconds(500), + pollInterval: TimeSpan.FromMilliseconds(10)); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.Stopped).Should().BeTrue(); + await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue(); + await (health.ProbeCount >= 2).Should().BeTrue(); + } + } + + [Test] + public async Task StopRefusesToCompeteWithSystemdManagedLifecycle() + { + await WebStopper.IsSystemdServiceCgroup("0::/system.slice/v2rayn-web.service").Should().BeTrue(); + await WebStopper.IsSystemdServiceCgroup("0::/user.slice/user-1000.slice/session-2.scope").Should().BeFalse(); + } + + [Test] + public async Task StopDoesNotReportSuccessWhileAKnownCoreChildRemainsAlive() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + using (heldLock) + { + var health = new FakeHealthProbe(true, 123, [Environment.ProcessId]); + var signals = new FakeSignalSender(); + signals.OnSignal = processId => _ = ReleaseLockAfterDelayAsync(heldLock, health); + var stopper = new WebStopper(health, signals, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(10)); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.CoreProcessStillRunning).Should().BeTrue(); + await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue(); + } + } + + [Test] + public async Task StopNeverSignalsWhenHealthPidDoesNotMatchLockOwner() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var signals = new FakeSignalSender(); + var stopper = new WebStopper(new FakeHealthProbe(true, 456), signals); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.IdentityUnverified).Should().BeTrue(); + await (signals.ProcessIds.Count == 0).Should().BeTrue(); + } + + [Test] + public async Task StopNeverSignalsWhenHealthCannotConfirmTheInstance() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var signals = new FakeSignalSender(); + var stopper = new WebStopper(new FakeHealthProbe(false, 123), signals); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.IdentityUnverified).Should().BeTrue(); + await (signals.ProcessIds.Count == 0).Should().BeTrue(); + } + + [Test] + public async Task StopWithoutAProbeEndpointNeverSignals() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var signals = new FakeSignalSender(); + var stopper = new WebStopper(new FakeHealthProbe(true, 123), signals); + + var result = await stopper.StopAsync(lockPath, healthUri: null); + + await (result == WebStopResult.ProbeUnavailable).Should().BeTrue(); + await (signals.ProcessIds.Count == 0).Should().BeTrue(); + + var notRunning = await stopper.StopAsync( + Path.Combine(directory.Path, "missing-instance.lock"), healthUri: null); + await (notRunning == WebStopResult.NotRunning).Should().BeTrue(); + } + + [Test] + public async Task StopTimesOutWithoutEscalatingBeyondOneSigTerm() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var signals = new FakeSignalSender(); + var stopper = new WebStopper( + new FakeHealthProbe(true, 123), + signals, + stopTimeout: TimeSpan.FromMilliseconds(60), + pollInterval: TimeSpan.FromMilliseconds(5)); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.TimedOut).Should().BeTrue(); + await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue(); + await WebInstanceLock.IsHeld(lockPath).Should().BeTrue(); + } + + [Test] + public async Task StopTimeoutReportsLastObservedCleanupStage() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var health = new FakeHealthProbe(true, 123); + health.SetShutdownStage("Core stop"); + var stopper = new WebStopper( + health, + new FakeSignalSender(), + stopTimeout: TimeSpan.FromMilliseconds(60), + pollInterval: TimeSpan.FromMilliseconds(5)); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.TimedOut).Should().BeTrue(); + await (stopper.LastObservedShutdownStage == "Core stop").Should().BeTrue(); + await LauncherMessages.StopMessage(result, LauncherLocale.English, stopper.LastObservedShutdownStage) + .Contains("Core stop", StringComparison.Ordinal).Should().BeTrue(); + } + + [Test] + public async Task LockIsReleasedWhenOwnerExitsAndDataHomesUseDistinctLocks() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var firstPath = Path.Combine(directory.Path, "one", "instance.lock"); + var secondPath = Path.Combine(directory.Path, "two", "instance.lock"); + await WebInstanceLock.TryAcquire(firstPath, writeOwner: true, out var first).Should().BeTrue(); + await WebInstanceLock.TryAcquire(secondPath, writeOwner: true, out var second).Should().BeTrue(); + using (first) + using (second) + { + await WebInstanceLock.IsHeld(firstPath).Should().BeTrue(); + await WebInstanceLock.IsHeld(secondPath).Should().BeTrue(); + } + await WebInstanceLock.IsHeld(firstPath).Should().BeFalse(); + await WebInstanceLock.TryAcquire(firstPath, writeOwner: false, out var recovered).Should().BeTrue(); + recovered!.Dispose(); + } + + [Test] + public async Task LauncherMessagesUseTheSharedLocaleResources() + { + var command = LauncherMessages.ExecutableCommand("/opt/v2rayn/v2rayN.WebAPI"); + var chinese = LauncherMessages.Started("http://127.0.0.1:5080", command, LauncherLocale.SimplifiedChinese); + var english = LauncherMessages.Started("http://127.0.0.1:5080", command, LauncherLocale.English); + + await chinese.Contains("API 健康端点", StringComparison.Ordinal).Should().BeTrue(); + await chinese.Contains("Ctrl+C 不会停止", StringComparison.Ordinal).Should().BeTrue(); + await chinese.Contains("./v2rayN.WebAPI --stop", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.AlreadyRunning("http://127.0.0.1:5080", command, false, LauncherLocale.TraditionalChinese) + .Contains("--stop", StringComparison.Ordinal).Should().BeTrue(); + await (LauncherMessages.Started("http://127.0.0.1:5080", command, LauncherLocale.TraditionalChinese) + .Contains("--stop", StringComparison.Ordinal)).Should().BeTrue(); + await english.Contains("API health endpoint", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.ForegroundStarted("http://127.0.0.1:5080", LauncherLocale.English) + .Contains("Ctrl+C", StringComparison.Ordinal).Should().BeTrue(); + await (LauncherMessages.StopMessage(WebStopResult.NotRunning, LauncherLocale.English) + == "v2rayN.WebAPI is not running for this data directory.").Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.NotRunning, LauncherLocale.SimplifiedChinese) + .Contains("数据目录", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.NotRunning, LauncherLocale.TraditionalChinese) + .Contains("資料目錄", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.SupervisorManaged, LauncherLocale.English) + .Contains("systemctl stop", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.CoreProcessStillRunning, LauncherLocale.SimplifiedChinese) + .Contains("Core", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.ProbeUnavailable, LauncherLocale.English) + .Contains("loopback HTTP health endpoint", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.ProbeUnavailable, LauncherLocale.SimplifiedChinese) + .Contains("回环 HTTP 健康端点", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.ProbeUnavailable, LauncherLocale.TraditionalChinese) + .Contains("回送 HTTP 健康端點", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StartUnprobeable(LauncherLocale.English) + .Contains("--foreground", StringComparison.Ordinal).Should().BeTrue(); + if (OperatingSystem.IsLinux()) + { + await (LauncherMessages.ExecutableCommand("/usr/share/dotnet/dotnet", "/opt/v2rayn/v2rayN.WebAPI.dll") + == "\"/usr/share/dotnet/dotnet\" \"/opt/v2rayn/v2rayN.WebAPI.dll\"").Should().BeTrue(); + } + await (LauncherMessages.ResolveLocale("zh_Hant_TW") == LauncherLocale.TraditionalChinese).Should().BeTrue(); + await (LauncherMessages.ResolveLocale("zh_CN.UTF-8") == LauncherLocale.SimplifiedChinese).Should().BeTrue(); + await (LauncherMessages.ResolveLocale("fr_FR.UTF-8") == LauncherLocale.English).Should().BeTrue(); + } + + private static async Task AcquireLockWithOwnerAsync(string lockPath, int ownerProcessId) + { + await File.WriteAllTextAsync(lockPath, $"{ownerProcessId}\n"); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: false, out var heldLock).Should().BeTrue(); + return heldLock!; + } + + private static async Task ReleaseLockAfterDelayAsync(WebInstanceLock heldLock, FakeHealthProbe health) + { + await Task.Delay(40); + heldLock.Dispose(); + health.SetResult(false, null); + } + + private sealed class FakeHealthProbe(bool healthy, int? processId = null, int[]? coreProcessIds = null) : IWebHealthProbe + { + private bool _healthy = healthy; + private int? _processId = processId; + private string? _shutdownStage; + private int[] _coreProcessIds = coreProcessIds ?? []; + + public int ProbeCount { get; private set; } + + public Task ProbeAsync(Uri healthUri, CancellationToken cancellationToken) => + RecordProbe(); + + public void SetResult(bool isHealthy, int? instanceProcessId, int[]? coreProcessIds = null) + { + _healthy = isHealthy; + _processId = instanceProcessId; + if (coreProcessIds is not null) _coreProcessIds = coreProcessIds; + } + + public void SetShutdownStage(string? stage) => _shutdownStage = stage; + + private Task RecordProbe() + { + ProbeCount++; + return Task.FromResult(new WebHealthProbeResult(_healthy, _processId, _shutdownStage, _coreProcessIds)); + } + } + + private sealed class FakeSignalSender : IProcessSignalSender + { + public List ProcessIds { get; } = []; + public Action? OnSignal { get; set; } + + public bool SendSigTerm(int processId) + { + ProcessIds.Add(processId); + OnSignal?.Invoke(processId); + return true; + } + } + + private sealed class FakeBrowserOpener : IBrowserOpener + { + public Uri? LastUri { get; private set; } + + public bool TryOpen(Uri apiUri) + { + LastUri = apiUri; + return true; + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-launcher-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebListenerSecurityPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebListenerSecurityPolicyTests.cs new file mode 100644 index 00000000..268dbc22 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebListenerSecurityPolicyTests.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebListenerSecurityPolicyTests +{ + [Test] + [Arguments("http://127.0.0.1:5080", false)] + [Arguments("http://localhost:5080", false)] + [Arguments("http://[::1]:5080", false)] + [Arguments("http://0.0.0.0:5080", true)] + [Arguments("http://[::]:5080", true)] + [Arguments("http://192.168.1.20:5080", true)] + [Arguments("http://*:5080", true)] + [Arguments("http://+:5080", true)] + [Arguments("http://nas:5080", true)] + [Arguments("http://localhost.example:5080", true)] + [Arguments("http://127.0.0.1:5080;https://[::1]:5443", false)] + [Arguments("http://localhost:5080;http://0.0.0.0:5081", true)] + [Arguments("http://192.168.1.20:5080;http://localhost:5081", true)] + [Arguments("not-a-url", true)] + public async Task AllEffectiveListenersMustBeLoopbackWithoutKey(string urls, bool requiresKey) + { + var configuration = Config(("urls", urls)); + await (WebListenerSecurityPolicy.GetStartupError(configuration, null) is not null).Should().BeEqualTo(requiresKey); + await (WebListenerSecurityPolicy.GetStartupError(configuration, " \t") is not null).Should().BeEqualTo(requiresKey); + await WebListenerSecurityPolicy.GetStartupError(configuration, "configured-key").Should().BeNull(); + } + + [Test] + [Arguments("http_ports")] + [Arguments("https_ports")] + public async Task PortOnlyConfigurationIsWildcardButUrlsTakePrecedence(string portsKey) + { + await WebListenerSecurityPolicy.GetStartupError(Config((portsKey, "5080;5081")), null) + .Should().BeEqualTo(WebListenerSecurityPolicy.MissingManagementKeyMessage); + await WebListenerSecurityPolicy.GetStartupError(Config((portsKey, "5080"), ("urls", "http://localhost:5081")), null) + .Should().BeNull(); + } + + [Test] + public async Task KestrelEndpointsAndPreferHostingUrlsRespectBindingPrecedence() + { + var configuration = Config(("urls", "http://localhost:5080"), ("Kestrel:Endpoints:Http:Url", "http://0.0.0.0:5081")); + await WebListenerSecurityPolicy.GetStartupError(configuration, null) + .Should().BeEqualTo(WebListenerSecurityPolicy.MissingManagementKeyMessage); + configuration["preferHostingUrls"] = "true"; + await WebListenerSecurityPolicy.GetStartupError(configuration, null).Should().BeNull(); + configuration["urls"] = "http://0.0.0.0:5080"; + configuration["Kestrel:Endpoints:Http:Url"] = "http://[::1]:5081"; + await WebListenerSecurityPolicy.GetStartupError(configuration, null) + .Should().BeEqualTo(WebListenerSecurityPolicy.MissingManagementKeyMessage); + configuration["preferHostingUrls"] = "false"; + await WebListenerSecurityPolicy.GetStartupError(configuration, null).Should().BeNull(); + configuration["Kestrel:Endpoints:Other:Url"] = "http://192.168.1.20:5082"; + await WebListenerSecurityPolicy.GetStartupError(configuration, null) + .Should().BeEqualTo(WebListenerSecurityPolicy.MissingManagementKeyMessage); + } + + [Test] + public async Task DefaultIsLoopbackAndDoesNotReplaceHttpsPorts() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] }); + builder.Configuration.Sources.Clear(); + builder.Configuration.AddInMemoryCollection(); + WebListenerSecurityPolicy.ApplyDefault(builder); + await builder.Configuration["urls"].Should().BeEqualTo(WebListenerSecurityPolicy.DefaultUrl); + await WebListenerSecurityPolicy.GetStartupError(builder.Configuration, null).Should().BeNull(); + builder.Configuration["urls"] = ""; + builder.Configuration["https_ports"] = "5443"; + WebListenerSecurityPolicy.ApplyDefault(builder); + await WebListenerSecurityPolicy.GetStartupError(builder.Configuration, null) + .Should().BeEqualTo(WebListenerSecurityPolicy.MissingManagementKeyMessage); + } + + [Test] + [Arguments("--urls", "http://localhost:5080", false)] + [Arguments("--urls=http://0.0.0.0:5080", null, true)] + [Arguments("--http_ports", "5080", true)] + public async Task CommandLineOverridesPrefixedEnvironmentConfiguration(string argument, string? value, bool requiresKey) + { + // Same provider order as CreateBuilder: prefixed host configuration, then CLI. + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["urls"] = "http://192.168.1.20:5080" }) + .AddCommandLine(value is null ? [argument] : [argument, value]).Build(); + // URLs win over port-only configuration, so remove the URL for the ports-only case. + if (argument == "--http_ports") configuration["urls"] = ""; + await (WebListenerSecurityPolicy.GetStartupError(configuration, null) is not null).Should().BeEqualTo(requiresKey); + } + + private static IConfigurationRoot Config(params (string Key, string Value)[] values) => + new ConfigurationBuilder().AddInMemoryCollection(values.ToDictionary(item => item.Key, item => (string?)item.Value)).Build(); +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebProbeUriResolverTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebProbeUriResolverTests.cs new file mode 100644 index 00000000..fa5db688 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebProbeUriResolverTests.cs @@ -0,0 +1,198 @@ +using System.Net; +using Microsoft.Extensions.Configuration; +using v2rayN.WebAPI.Launcher; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebProbeUriResolverTests +{ + [Test] + [Arguments("http://127.0.0.1:5080", "http://127.0.0.1:5080/api/health")] + [Arguments("http://localhost:5080", "http://localhost:5080/api/health")] + [Arguments("http://[::1]:5080", "http://[::1]:5080/api/health")] + [Arguments("http://0.0.0.0:5080", "http://127.0.0.1:5080/api/health")] + [Arguments("http://[::]:5080", "http://[::1]:5080/api/health")] + [Arguments("http://127.0.0.2:5080", null)] + [Arguments("http://127.255.255.254:5080", null)] + [Arguments("http://*:5080", "http://localhost:5080/api/health")] + [Arguments("http://+:5080", "http://localhost:5080/api/health")] + [Arguments("http://nas:5080", "http://localhost:5080/api/health")] + [Arguments("https://127.0.0.1:5443", null)] + [Arguments("http://192.168.1.20:5080", null)] + [Arguments("http://[2001:db8::1]:5080", null)] + [Arguments("not-a-url", null)] + [Arguments("", null)] + public async Task EveryListenUrlMapsToItsMatchingLoopbackProbe(string listenUrl, string? expected) + { + var resolved = WebProbeUriResolver.TryCreateHealthUri(listenUrl, out var healthUri); + + if (expected is null) + { + await resolved.Should().BeFalse(); + return; + } + + await resolved.Should().BeTrue(); + await healthUri.ToString().Should().BeEqualTo(expected); + } + + [Test] + public async Task DefaultConfigurationResolvesToTheDefaultLoopbackProbe() + { + await WebProbeUriResolver.TryResolve(Config(), out var endpoints).Should().BeTrue(); + await endpoints.HealthUri.ToString().Should().BeEqualTo("http://127.0.0.1:5080/api/health"); + await endpoints.ApiUri.ToString().Should().BeEqualTo("http://127.0.0.1:5080/"); + } + + [Test] + public async Task CustomPortsAndHttpPortsResolve() + { + await WebProbeUriResolver.TryResolve(Config(("urls", "http://127.0.0.1:5090")), out var custom) + .Should().BeTrue(); + await custom.HealthUri.ToString().Should().BeEqualTo("http://127.0.0.1:5090/api/health"); + + await WebProbeUriResolver.TryResolve(Config(("http_ports", "6001")), out var ports).Should().BeTrue(); + await ports.HealthUri.ToString().Should().BeEqualTo("http://localhost:6001/api/health"); + } + + [Test] + public async Task Ipv6OnlyAndMixedListenersPickAProbeableEndpoint() + { + await WebProbeUriResolver.TryResolve(Config(("urls", "http://[::1]:5090")), out var ipv6).Should().BeTrue(); + await ipv6.HealthUri.ToString().Should().BeEqualTo("http://[::1]:5090/api/health"); + + await WebProbeUriResolver.TryResolve( + Config(("urls", "http://192.168.1.20:5080;http://[::1]:5091")), out var mixed).Should().BeTrue(); + await mixed.HealthUri.ToString().Should().BeEqualTo("http://[::1]:5091/api/health"); + + // A loopback alias cannot be verified, so the resolver falls through to the + // next verifiable listener instead of producing an unverifiable probe. + await WebProbeUriResolver.TryResolve( + Config(("urls", "http://127.0.0.2:5080;http://127.0.0.1:5092")), out var aliasMixed).Should().BeTrue(); + await aliasMixed.HealthUri.ToString().Should().BeEqualTo("http://127.0.0.1:5092/api/health"); + } + + [Test] + public async Task KestrelEndpointsOverrideHostingUrlsUnlessPreferHostingUrlsIsSet() + { + var configuration = Config( + ("urls", "http://127.0.0.1:5080"), + ("Kestrel:Endpoints:Http:Url", "http://[::1]:5081")); + await WebProbeUriResolver.TryResolve(configuration, out var kestrel).Should().BeTrue(); + await kestrel.HealthUri.ToString().Should().BeEqualTo("http://[::1]:5081/api/health"); + + configuration["preferHostingUrls"] = "true"; + await WebProbeUriResolver.TryResolve(configuration, out var hosting).Should().BeTrue(); + await hosting.HealthUri.ToString().Should().BeEqualTo("http://127.0.0.1:5080/api/health"); + } + + [Test] + public async Task HttpsOnlyListenersAreRefusedAndMixedListenersFallBackToHttp() + { + await WebProbeUriResolver.TryResolve(Config(("urls", "https://127.0.0.1:5443")), out _).Should().BeFalse(); + await WebProbeUriResolver.TryResolve(Config(("https_ports", "5443")), out _).Should().BeFalse(); + + await WebProbeUriResolver.TryResolve( + Config(("urls", "https://[::1]:5443;http://0.0.0.0:5080")), out var mixed).Should().BeTrue(); + await mixed.HealthUri.ToString().Should().BeEqualTo("http://127.0.0.1:5080/api/health"); + } + + [Test] + public async Task LauncherUrisFollowTheSameResolutionAndNeverGuess() + { + var (health, api) = Program.GetLauncherUris(Config(("urls", "http://[::1]:5090"))); + await health!.ToString().Should().BeEqualTo("http://[::1]:5090/api/health"); + await api!.ToString().Should().BeEqualTo("http://[::1]:5090/"); + + // A configured localhost stays localhost instead of being rewritten to 127.0.0.1. + var (localhostHealth, localhostApi) = Program.GetLauncherUris(Config(("urls", "http://localhost:5090"))); + await localhostHealth!.ToString().Should().BeEqualTo("http://localhost:5090/api/health"); + await localhostApi!.ToString().Should().BeEqualTo("http://localhost:5090/"); + + var (httpsHealth, httpsApi) = Program.GetLauncherUris(Config(("urls", "https://127.0.0.1:5443"))); + await (httpsHealth is null).Should().BeTrue(); + await (httpsApi is null).Should().BeTrue(); + } + + [Test] + [Arguments("http://127.0.0.1:5080/api/health", true)] + [Arguments("http://localhost:5080/api/health", true)] + [Arguments("http://[::1]:5080/api/health", true)] + [Arguments("https://[::1]:5443/api/health", false)] + [Arguments("http://0.0.0.0:5080/api/health", false)] + [Arguments("http://127.0.0.2:5080/api/health", false)] + [Arguments("http://192.168.1.20:5080/api/health", false)] + [Arguments("not-a-uri", false)] + public async Task UpdatePlansOnlyAcceptLoopbackHttpHealthUris(string healthUri, bool accepted) + { + await WebProbeUriResolver.IsProbeableLoopbackUri(healthUri).Should().BeEqualTo(accepted); + } + + [Test] + public async Task LoopbackAliasListenersCannotProduceAProbe() + { + foreach (var listenUrl in new[] { "http://127.0.0.2:5080", "http://127.1.2.3:5080", "http://127.255.255.254:5080" }) + { + await WebProbeUriResolver.TryCreateHealthUri(listenUrl, out _).Should().BeFalse(); + await WebProbeUriResolver.TryResolve(Config(("urls", listenUrl)), out _).Should().BeFalse(); + } + } + + [Test] + public async Task EveryProducedProbeUriSatisfiesTheSharedLoopbackRule() + { + foreach (var listenUrl in new[] + { + "http://127.0.0.1:5080", "http://localhost:5080", "http://[::1]:5080", + "http://0.0.0.0:5080", "http://[::]:5080", "http://*:5080", "http://+:5080", + "http://nas:5080", + }) + { + await WebProbeUriResolver.TryCreateHealthUri(listenUrl, out var healthUri).Should().BeTrue(); + await WebProbeUriResolver.IsProbeableLoopbackUri(healthUri.ToString()).Should().BeTrue(); + } + } + + [Test] + public async Task HealthDiagnosticsAndProbesShareTheSameAddressRule() + { + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Loopback, "127.0.0.1").Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Loopback, "localhost").Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.IPv6Loopback, "::1").Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.IPv6Loopback, "[::1]").Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Parse("192.168.1.20"), "127.0.0.1").Should().BeFalse(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Loopback, "192.168.1.20").Should().BeFalse(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Any, "localhost").Should().BeFalse(); + + // Every probe host the resolver can produce is accepted by the diagnostics rule. + foreach (var probeHost in new[] { "localhost", "127.0.0.1", "::1" }) + { + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Loopback, probeHost).Should().BeTrue(); + } + } + + [Test] + public async Task UnprobeableListenerConfigurationRefusesInstallWithAClearReason() + { + var unprobeable = new V2rayRuntime(null!, null!, Config(("urls", "https://127.0.0.1:5443")), null!, null!); + await unprobeable.GetWebUpdateRuntimeInstallReason().Should().BeEqualTo("maintenance.webUpdateProbeUnavailable"); + + var nonLoopback = new V2rayRuntime(null!, null!, Config(("urls", "http://192.168.1.20:5080")), null!, null!); + await nonLoopback.GetWebUpdateRuntimeInstallReason().Should().BeEqualTo("maintenance.webUpdateProbeUnavailable"); + + // 127.0.0.2 is loopback for the OS but is not verifiable by the health + // diagnostics or the update helper, so installing must be refused up front. + var loopbackAlias = new V2rayRuntime(null!, null!, Config(("urls", "http://127.0.0.2:5080")), null!, null!); + await loopbackAlias.GetWebUpdateRuntimeInstallReason().Should().BeEqualTo("maintenance.webUpdateProbeUnavailable"); + + var probeable = new V2rayRuntime(null!, null!, Config(("urls", "http://localhost:5080")), null!, null!); + await (probeable.GetWebUpdateRuntimeInstallReason() is null).Should().BeTrue(); + } + + private static IConfigurationRoot Config(params (string Key, string Value)[] values) => + new ConfigurationBuilder() + .AddInMemoryCollection(values.ToDictionary(item => item.Key, item => (string?)item.Value)) + .Build(); +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebReleaseChannelTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebReleaseChannelTests.cs new file mode 100644 index 00000000..51135107 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebReleaseChannelTests.cs @@ -0,0 +1,98 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebReleaseChannelTests +{ + [Test] + public async Task OfficialReleaseTagsAreAcceptedAndLegacyWebTagsAreIgnored() + { + var accepted = WebReleaseChannel.TryParseReleaseTag("7.25.3", out var version); + await accepted.Should().BeTrue(); + await version.Should().BeEqualTo("7.25.3"); + + foreach (var legacy in new[] { "web-v7.25.3-web.1", "7.25.3-web.1", "v7.25.3", "7.25", "7.25.3.1", "", null }) + { + var parsed = WebReleaseChannel.TryParseReleaseTag(legacy, out var parsedVersion); + await parsed.Should().BeFalse(); + await parsedVersion.Should().BeEqualTo(string.Empty); + } + } + + [Test] + public async Task ReleaseIndexUrlIsBuiltFromTheBuildRepository() + { + await WebReleaseChannel.BuildReleaseIndexUrl("2dust/v2rayN") + .Should().BeEqualTo("https://api.github.com/repos/2dust/v2rayN/releases?per_page=100"); + await WebReleaseChannel.DefaultRepository.Should().BeEqualTo("2dust/v2rayN"); + + var rejected = false; + try { _ = WebReleaseChannel.BuildReleaseIndexUrl("2dust/v2rayN/../../evil"); } + catch (ArgumentException) { rejected = true; } + await rejected.Should().BeTrue(); + await WebReleaseChannel.IsValidRepository("community-fork/v2rayN").Should().BeTrue(); + await WebReleaseChannel.IsValidRepository("not-a-repository").Should().BeFalse(); + } + + [Test] + public async Task TrustedAssetUrlsMustUseTheBuildRepositoryTagAndAsset() + { + var repository = "2dust/v2rayN"; + var tag = "7.25.3"; + var asset = "v2rayN-linux-64-WebAPI-update.zip"; + var trusted = $"https://github.com/{repository}/releases/download/{tag}/{asset}"; + + await WebReleaseChannel.IsTrustedAssetUrl(trusted, repository, tag, asset).Should().BeTrue(); + // A build must not accept the same asset from another repository. + await WebReleaseChannel.IsTrustedAssetUrl(trusted.Replace(repository, "another-owner/v2rayN", StringComparison.Ordinal), + repository, tag, asset).Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl("http://github.com/2dust/v2rayN/releases/download/7.25.3/" + asset, + repository, tag, asset).Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl("https://example.com/2dust/v2rayN/releases/download/7.25.3/" + asset, + repository, tag, asset).Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl(trusted, repository, "7.25.4", asset).Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl(trusted, repository, tag, "WebAPI-update.json").Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl("https://github.com/2dust/v2rayN/releases/download/7.25.3/sub/" + asset, + repository, tag, asset).Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl(null, repository, tag, asset).Should().BeFalse(); + } + + [Test] + public async Task RuntimeIdentifiersMapToSupportedArchiveNames() + { + await WebUpdatePackageStager.ArtifactArch("linux-x64").Should().BeEqualTo("64"); + await WebUpdatePackageStager.ArtifactArch("linux-arm64").Should().BeEqualTo("arm64"); + await WebUpdatePackageStager.FullInstallAssetName("linux-x64").Should().BeEqualTo("v2rayN-linux-64-WebAPI.zip"); + await WebUpdatePackageStager.FullInstallAssetName("linux-arm64").Should().BeEqualTo("v2rayN-linux-arm64-WebAPI.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("linux-x64").Should().BeEqualTo("v2rayN-linux-64-WebAPI-update.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("linux-arm64").Should().BeEqualTo("v2rayN-linux-arm64-WebAPI-update.zip"); + await WebUpdatePackageStager.ArtifactArch("win-x64").Should().BeEqualTo("64"); + await WebUpdatePackageStager.FullInstallAssetName("win-x64").Should().BeEqualTo("v2rayN-windows-64-WebAPI.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("win-x64").Should().BeEqualTo("v2rayN-windows-64-WebAPI-update.zip"); + await WebUpdatePackageStager.ArtifactArch("linux-riscv64").Should().BeEqualTo("riscv64"); + await WebUpdatePackageStager.FullInstallAssetName("linux-riscv64").Should().BeEqualTo("v2rayN-linux-riscv64-WebAPI.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("linux-riscv64").Should().BeEqualTo("v2rayN-linux-riscv64-WebAPI-update.zip"); + await WebUpdatePackageStager.ArtifactArch("linux-loongarch64").Should().BeEqualTo("loong64"); + await WebUpdatePackageStager.FullInstallAssetName("linux-loongarch64").Should().BeEqualTo("v2rayN-linux-loong64-WebAPI.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("linux-loongarch64").Should().BeEqualTo("v2rayN-linux-loong64-WebAPI-update.zip"); + await WebUpdatePackageStager.ArtifactArch("win-arm64").Should().BeEqualTo("arm64"); + await WebUpdatePackageStager.FullInstallAssetName("win-arm64").Should().BeEqualTo("v2rayN-windows-arm64-WebAPI.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("win-arm64").Should().BeEqualTo("v2rayN-windows-arm64-WebAPI-update.zip"); + await WebUpdatePackageStager.IsSupportedRid("linux-riscv64").Should().BeTrue(); + await WebUpdatePackageStager.IsSupportedRid("linux-loongarch64").Should().BeTrue(); + await WebUpdatePackageStager.IsSupportedRid("win-arm64").Should().BeTrue(); + await WebUpdatePackageStager.IsSupportedRid("linux-unknown64").Should().BeFalse(); + await WebUpdatePackageStager.FullInstallAssetName("linux-unknown64").Should().BeNull(); + await WebUpdatePackageStager.AppOnlyAssetName(null).Should().BeNull(); + } + + [Test] + public async Task OfficialVersionsCompareGreaterThanDevAndLegacyIdentities() + { + await WebUpdatePackageStager.IsUpdateAvailable("7.25.2", "7.25.3", allowPrerelease: false).Should().BeTrue(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.3", allowPrerelease: false).Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.2", allowPrerelease: false).Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("0.0.0-dev", "7.25.3", allowPrerelease: false).Should().BeTrue(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.2-web.9", "7.25.3", allowPrerelease: false).Should().BeTrue(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebRestartCoordinatorTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebRestartCoordinatorTests.cs new file mode 100644 index 00000000..bb5605e7 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebRestartCoordinatorTests.cs @@ -0,0 +1,286 @@ +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Tests; + +public class WebRestartCoordinatorTests +{ + [Test] + public async Task NativeSelfContainedRestartUsesDetachedBackgroundChildCommand() + { + if (!OperatingSystem.IsLinux()) return; + + var command = WebReplacementCommand.Create( + "/usr/bin/setsid", + "/opt/v2rayN.WebAPI", + null, + ["--urls", "http://127.0.0.1:5090"]); + + await (command is not null).Should().BeTrue(); + await (command!.LauncherPath == "/usr/bin/setsid").Should().BeTrue(); + await command.Arguments.SequenceEqual([ + "/opt/v2rayN.WebAPI", + WebLaunchOptions.BackgroundChildFlag, + "--urls", + "http://127.0.0.1:5090", + ]).Should().BeTrue(); + await command.ToProcessStartInfo().UseShellExecute.Should().BeFalse(); + } + + [Test] + public async Task FrameworkDependentRestartPreservesDotnetDllAndHostArguments() + { + if (!OperatingSystem.IsLinux()) return; + + var processPath = "/usr/bin/dotnet"; + var entryPoint = WebReplacementCommand.GetManagedEntryPoint(processPath, ["/opt/v2rayN/v2rayN.WebAPI.dll", "--urls"]); + var command = WebReplacementCommand.Create( + "/usr/bin/setsid", + processPath, + entryPoint, + ["--urls", "http://127.0.0.1:5090", "--contentRoot", "/opt/v2rayN"]); + + await (entryPoint == "/opt/v2rayN/v2rayN.WebAPI.dll").Should().BeTrue(); + await command!.Arguments.SequenceEqual([ + "/usr/bin/dotnet", + "/opt/v2rayN/v2rayN.WebAPI.dll", + WebLaunchOptions.BackgroundChildFlag, + "--urls", + "http://127.0.0.1:5090", + "--contentRoot", + "/opt/v2rayN", + ]).Should().BeTrue(); + await (WebReplacementCommand.Create("/usr/bin/setsid", processPath, null, []) is null).Should().BeTrue(); + } + + [Test] + public async Task NativeWindowsRestoreStartsTheExecutableWithoutLinuxSetsid() + { + var executable = "/opt/v2rayN.WebAPI.exe"; + var command = WebReplacementCommand.CreateNative( + isLinux: false, + isWindows: true, + setsidPath: null, + processPath: executable, + managedEntryPoint: null, + hostArguments: ["--urls", "http://127.0.0.1:5080"]); + + await (command is not null).Should().BeTrue(); + await command!.LauncherPath.Should().BeEqualTo(Path.GetFullPath(executable)); + await command.CreateNoWindow.Should().BeTrue(); + await command.Arguments.SequenceEqual([ + WebLaunchOptions.BackgroundChildFlag, + "--urls", + "http://127.0.0.1:5080", + ]).Should().BeTrue(); + await command.ToProcessStartInfo().UseShellExecute.Should().BeFalse(); + } + + [Test] + public async Task SystemdAndContainerOwnRestartWhileNativeLinuxOwnsItsReplacement() + { + var systemd = WebLifecyclePlanner.Create(WebLifecycleAction.RestoreAndRestart, true, daemonEnvironment: true, containerEnvironment: false); + var cgroupManaged = WebStopper.IsSystemdServiceCgroup("0::/system.slice/v2rayn-web.service"); + var cgroupSystemd = WebLifecyclePlanner.Create(WebLifecycleAction.RestoreAndRestart, true, daemonEnvironment: cgroupManaged, containerEnvironment: false); + var container = WebLifecyclePlanner.Create(WebLifecycleAction.RestoreAndRestart, true, daemonEnvironment: false, containerEnvironment: true); + var native = WebLifecyclePlanner.Create(WebLifecycleAction.RestoreAndRestart, true, daemonEnvironment: false, containerEnvironment: false); + var nativeRestart = WebLifecyclePlanner.Create(WebLifecycleAction.Restart, true, daemonEnvironment: false, containerEnvironment: false); + var stop = WebLifecyclePlanner.Create(WebLifecycleAction.Stop, true, daemonEnvironment: false, containerEnvironment: false); + var shutdown = WebLifecyclePlanner.Create(WebLifecycleAction.Shutdown, true, daemonEnvironment: false, containerEnvironment: false); + + await systemd.ExitsForSupervisor.Should().BeTrue(); + await cgroupSystemd.ExitsForSupervisor.Should().BeTrue(); + await container.ExitsForSupervisor.Should().BeTrue(); + await systemd.ShouldStartReplacement.Should().BeFalse(); + await cgroupSystemd.ShouldStartReplacement.Should().BeFalse(); + await container.ShouldStartReplacement.Should().BeFalse(); + await native.ShouldStartReplacement.Should().BeTrue(); + await nativeRestart.ShouldStartReplacement.Should().BeTrue(); + await stop.ShouldStartReplacement.Should().BeFalse(); + await shutdown.ShouldStartReplacement.Should().BeFalse(); + } + + [Test] + public async Task HandoffWaitsForCurrentScopeLockAndDoesNotTouchAnotherDataHome() + { + var currentScope = "/data/one/v2rayN.WebAPI.instance.lock"; + var otherScope = "/data/two/v2rayN.WebAPI.instance.lock"; + var ownership = new FakeOwnershipProbe(); + ownership.Set(currentScope, held: true, owner: 111); + ownership.Set(otherScope, held: true, owner: 222); + var starter = new FakeProcessStarter(() => + { + var lockWasReleasedBeforeSpawn = !ownership.IsLockHeld(currentScope); + ownership.Set(currentScope, held: true, owner: 4242); + return (new FakeReplacementProcess(4242), lockWasReleasedBeforeSpawn); + }); + var health = new FakeHealthProbe(4242); + var releasedOldOwner = false; + async Task Delay(TimeSpan delay, CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromMilliseconds(1), cancellationToken); + if (!releasedOldOwner) + { + ownership.Set(currentScope, held: false, owner: null); + releasedOldOwner = true; + } + } + + var coordinator = new NativeWebRestartCoordinator( + health, + starter, + ownership, + timeout: TimeSpan.FromSeconds(1), + pollInterval: TimeSpan.FromMilliseconds(1), + delay: Delay); + var result = await coordinator.StartReplacementAfterOwnerReleaseAsync( + currentScope, + new Uri("http://127.0.0.1:5080/api/health"), + NewCommand()); + + await result.Success.Should().BeTrue(); + await starter.StartCount.Should().BeEqualTo(1); + await starter.StartObservedReleasedLock.Should().BeTrue(); + await ownership.ObservedPaths.All(path => path == currentScope).Should().BeTrue(); + await ownership.IsLockHeld(otherScope).Should().BeTrue(); + await (health.LastUri == new Uri("http://127.0.0.1:5080/api/health")).Should().BeTrue(); + } + + [Test] + public async Task HandoffNeverSpawnsWhileOldOwnerStillHoldsLock() + { + var lockPath = "/data/current/v2rayN.WebAPI.instance.lock"; + var ownership = new FakeOwnershipProbe(); + ownership.Set(lockPath, held: true, owner: 123); + var starter = new FakeProcessStarter(() => (new FakeReplacementProcess(456), false)); + var coordinator = new NativeWebRestartCoordinator( + new FakeHealthProbe(456), + starter, + ownership, + timeout: TimeSpan.FromMilliseconds(30), + pollInterval: TimeSpan.FromMilliseconds(2)); + + var result = await coordinator.StartReplacementAfterOwnerReleaseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + NewCommand()); + + await result.Success.Should().BeFalse(); + await result.Message.Should().Contain("did not release"); + await starter.StartCount.Should().BeEqualTo(0); + } + + [Test] + public async Task FailedReplacementStartupIsReportedWithoutRetryingOrKillingOtherInstances() + { + var lockPath = "/data/current/v2rayN.WebAPI.instance.lock"; + var otherScope = "/data/other/v2rayN.WebAPI.instance.lock"; + var ownership = new FakeOwnershipProbe(); + ownership.Set(otherScope, held: true, owner: 222); + var starter = new FakeProcessStarter(() => (new FakeReplacementProcess(456, hasExited: true), false)); + var coordinator = new NativeWebRestartCoordinator( + new FakeHealthProbe(null), + starter, + ownership, + timeout: TimeSpan.FromSeconds(1), + pollInterval: TimeSpan.FromMilliseconds(1)); + + var result = await coordinator.StartReplacementAfterOwnerReleaseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + NewCommand()); + + await result.Success.Should().BeFalse(); + await result.Message.Should().Contain("exited before its Web health endpoint"); + await starter.StartCount.Should().BeEqualTo(1); + await ownership.IsLockHeld(otherScope).Should().BeTrue(); + } + + [Test] + public async Task HandoffDoesNotAcceptAHealthyDifferentLockOwnerAsTheReplacement() + { + const string lockPath = "/data/current/v2rayN.WebAPI.instance.lock"; + var ownership = new FakeOwnershipProbe(); + ownership.Set(lockPath, held: false, owner: null); + var starter = new FakeProcessStarter(() => + { + ownership.Set(lockPath, held: true, owner: 789); + return (new FakeReplacementProcess(456), true); + }); + var coordinator = new NativeWebRestartCoordinator( + new FakeHealthProbe(789), + starter, + ownership, + timeout: TimeSpan.FromMilliseconds(30), + pollInterval: TimeSpan.FromMilliseconds(2)); + + var result = await coordinator.StartReplacementAfterOwnerReleaseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + NewCommand()); + + await result.Success.Should().BeFalse(); + await result.Message.Should().Contain("did not become healthy"); + await result.ReplacementProcessId.Should().BeEqualTo(456); + await starter.StartCount.Should().BeEqualTo(1); + } + + private static WebReplacementCommand NewCommand() => new( + "/usr/bin/setsid", + ["/opt/v2rayN.WebAPI", WebLaunchOptions.BackgroundChildFlag], + "/opt"); + + private sealed class FakeOwnershipProbe : IWebInstanceOwnershipProbe + { + private readonly Dictionary _scopes = new(StringComparer.Ordinal); + + public List ObservedPaths { get; } = []; + + public void Set(string path, bool held, int? owner) => _scopes[path] = (held, owner); + + public bool IsLockHeld(string lockPath) + { + ObservedPaths.Add(lockPath); + return _scopes.GetValueOrDefault(lockPath).Held; + } + + public int? ReadOwnerProcessId(string lockPath) + { + ObservedPaths.Add(lockPath); + return _scopes.GetValueOrDefault(lockPath).Owner; + } + } + + private sealed class FakeProcessStarter(Func<(FakeReplacementProcess Process, bool LockReleased)> start) : IWebReplacementProcessStarter + { + public int StartCount { get; private set; } + public bool StartObservedReleasedLock { get; private set; } + + public IWebReplacementProcess? Start(WebReplacementCommand command) + { + StartCount++; + var result = start(); + StartObservedReleasedLock = result.LockReleased; + return result.Process; + } + } + + private sealed class FakeReplacementProcess(int id, bool hasExited = false) : IWebReplacementProcess + { + public int Id { get; } = id; + public bool HasExited { get; } = hasExited; + public bool Killed { get; private set; } + public void KillTree() => Killed = true; + public void Dispose() { } + } + + private sealed class FakeHealthProbe(int? processId) : IWebHealthProbe + { + public Uri? LastUri { get; private set; } + + public Task ProbeAsync(Uri healthUri, CancellationToken cancellationToken) + { + LastUri = healthUri; + return Task.FromResult(new WebHealthProbeResult(processId.HasValue, processId)); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebSecurityHeadersTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebSecurityHeadersTests.cs new file mode 100644 index 00000000..eb3ae56d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebSecurityHeadersTests.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Http; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebSecurityHeadersTests +{ + [Test] + public async Task BrowserResponsesReceiveSecurityHeadersAndSelfHostedCsp() + { + var context = new DefaultHttpContext(); + var downstreamCalled = false; + var middleware = new WebSecurityHeadersMiddleware(_ => + { + downstreamCalled = true; + return Task.CompletedTask; + }); + + await middleware.InvokeAsync(context); + + await downstreamCalled.Should().BeTrue(); + await context.Response.Headers["X-Content-Type-Options"].ToString().Should().BeEqualTo("nosniff"); + await context.Response.Headers["Referrer-Policy"].ToString().Should().BeEqualTo("no-referrer"); + await context.Response.Headers["X-Frame-Options"].ToString().Should().BeEqualTo("DENY"); + var csp = context.Response.Headers["Content-Security-Policy"].ToString(); + await csp.Should().Contain("frame-ancestors 'none'"); + await csp.Should().Contain("script-src 'self'"); + await csp.Should().Contain("connect-src 'self'"); + await csp.Should().Contain("style-src 'self' 'unsafe-inline'"); + await csp.Should().NotContain("'unsafe-eval'"); + await csp.Should().NotContain("https:"); + await csp.Should().NotContain("ws:"); + await csp.Should().NotContain("data:"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebSessionServiceTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebSessionServiceTests.cs new file mode 100644 index 00000000..040be4ef --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebSessionServiceTests.cs @@ -0,0 +1,167 @@ +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebSessionServiceTests +{ + [Test] + public async Task SessionTokensAreRandomUrlSafeAndManagementKeysAreNotSessions() + { + using var sessions = new WebSessionService(); + var first = sessions.CreateSession(); + var second = sessions.CreateSession(); + const string managementKey = "this-is-a-management-key"; + + await (first.Token.Length == 43).Should().BeTrue(); + await first.Token.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_').Should().BeTrue(); + await (first.Token != second.Token).Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(first.Token, out _).Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(managementKey, out _).Should().BeFalse(); + } + + [Test] + public async Task SessionsSlideForSevenDaysAndExpireAfterIdleTimeout() + { + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-01-01T00:00:00Z")); + using var sessions = new WebSessionService(time); + var created = sessions.CreateSession(); + + time.Advance(TimeSpan.FromDays(6)); + await sessions.TryValidateAndRenew(created.Token, out var renewed).Should().BeTrue(); + await (renewed!.LastSeenAt == time.GetUtcNow()).Should().BeTrue(); + await (renewed.ExpiresAt == time.GetUtcNow() + WebSessionService.SlidingLifetime).Should().BeTrue(); + + time.Advance(TimeSpan.FromDays(8)); + await sessions.TryValidateWithoutRenewal(created.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task SessionHasThirtyDayAbsoluteLifetimeEvenWhenActive() + { + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-02-01T00:00:00Z")); + using var sessions = new WebSessionService(time); + var startedAt = time.GetUtcNow(); + var created = sessions.CreateSession(); + + for (var day = 0; day < 5; day++) + { + time.Advance(TimeSpan.FromDays(5)); + await sessions.TryValidateAndRenew(created.Token, out _).Should().BeTrue(); + } + + await (time.GetUtcNow() == startedAt + TimeSpan.FromDays(25)).Should().BeTrue(); + await sessions.TryValidateAndRenew(created.Token, out var active).Should().BeTrue(); + await (active!.AbsoluteExpiresAt == startedAt + WebSessionService.AbsoluteLifetime).Should().BeTrue(); + time.Advance(TimeSpan.FromDays(5)); + await sessions.TryValidateWithoutRenewal(created.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task RevocationCancelsActiveConnectionsAndOnlyRevokesThatSession() + { + using var sessions = new WebSessionService(); + var first = sessions.CreateSession(); + var second = sessions.CreateSession(); + await sessions.TryValidateAndRenew(first.Token, out var activeSession).Should().BeTrue(); + + await sessions.Revoke(first.Token).Should().BeTrue(); + await activeSession!.RevocationToken.IsCancellationRequested.Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(first.Token, out _).Should().BeFalse(); + await sessions.TryValidateWithoutRenewal(second.Token, out _).Should().BeTrue(); + } + + [Test] + public async Task SseTicketsAreRandomShortLivedOneTimeAndCannotAuthenticateRest() + { + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-03-20T00:00:00Z")); + using var sessions = new WebSessionService(time); + var session = sessions.CreateSession(); + time.Advance(TimeSpan.FromDays(6)); + + await sessions.TryCreateSseTicket(session.Token, out var ticket).Should().BeTrue(); + await (ticket!.Token.Length == 43).Should().BeTrue(); + await ticket.Token.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_').Should().BeTrue(); + await (ticket.Token != session.Token).Should().BeTrue(); + await (ticket.ExpiresAt == time.GetUtcNow() + WebSessionService.SseTicketLifetime).Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(ticket.Token, out _).Should().BeFalse(); + + await sessions.TryConsumeSseTicket(ticket.Token, out var consumed).Should().BeTrue(); + await (consumed!.LastSeenAt == session.ExpiresAt - WebSessionService.SlidingLifetime).Should().BeTrue(); + await sessions.TryConsumeSseTicket(ticket.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task ExpiredTicketsAndRevokedSessionsCannotOpenOrCreateSseTickets() + { + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-03-25T00:00:00Z")); + using var sessions = new WebSessionService(time); + var session = sessions.CreateSession(); + await sessions.TryCreateSseTicket(session.Token, out var ticket).Should().BeTrue(); + + await sessions.Revoke(session.Token).Should().BeTrue(); + await sessions.TryCreateSseTicket(session.Token, out _).Should().BeFalse(); + await sessions.TryConsumeSseTicket(ticket!.Token, out _).Should().BeFalse(); + + var secondSession = sessions.CreateSession(); + await sessions.TryCreateSseTicket(secondSession.Token, out var expiringTicket).Should().BeTrue(); + time.Advance(WebSessionService.SseTicketLifetime + TimeSpan.FromSeconds(1)); + await sessions.TryConsumeSseTicket(expiringTicket!.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task CleanupRemovesExpiredSessions() + { + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-03-01T00:00:00Z")); + using var sessions = new WebSessionService(time); + var session = sessions.CreateSession(); + await sessions.TryValidateAndRenew(session.Token, out var active).Should().BeTrue(); + + time.Advance(WebSessionService.SlidingLifetime + TimeSpan.FromSeconds(1)); + await (sessions.CleanupExpiredSessions() == 1).Should().BeTrue(); + await active!.RevocationToken.IsCancellationRequested.Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(session.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task SessionsDoNotSurviveServiceRestart() + { + var firstProcess = new WebSessionService(); + var session = firstProcess.CreateSession(); + firstProcess.Dispose(); + + using var restartedProcess = new WebSessionService(); + await restartedProcess.TryValidateWithoutRenewal(session.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task PassiveHeartbeatValidationDoesNotRenewAndExpiresTheSession() + { + var startedAt = DateTimeOffset.Parse("2026-03-15T00:00:00Z"); + var time = new ManualTimeProvider(startedAt); + using var sessions = new WebSessionService(time); + var created = sessions.CreateSession(); + WebSessionSnapshot? lastValidSnapshot = null; + + for (var minute = 1; minute < 7 * 24 * 60; minute++) + { + time.Advance(TimeSpan.FromMinutes(1)); + await sessions.TryValidateWithoutRenewal(created.Token, out lastValidSnapshot).Should().BeTrue(); + } + + time.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(1)); + await sessions.TryValidateWithoutRenewal(created.Token, out _).Should().BeFalse(); + await (sessions.CleanupExpiredSessions() == 0).Should().BeTrue(); + await (lastValidSnapshot!.LastSeenAt == startedAt).Should().BeTrue(); + await (lastValidSnapshot.ExpiresAt == created.ExpiresAt).Should().BeTrue(); + await lastValidSnapshot.RevocationToken.IsCancellationRequested.Should().BeTrue(); + } + + private sealed class ManualTimeProvider(DateTimeOffset initialTime) : TimeProvider + { + private DateTimeOffset _now = initialTime; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan amount) => _now += amount; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebSetupAccessPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebSetupAccessPolicyTests.cs new file mode 100644 index 00000000..a1651c8b --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebSetupAccessPolicyTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebSetupAccessPolicyTests +{ + [Test] + public async Task Ipv4LoopbackAndAllowedHostsCanInitialize() + { + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback, "localhost", false).Should().BeTrue(); + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback, "127.0.0.1", false).Should().BeTrue(); + } + + [Test] + public async Task Ipv6LoopbackAndBracketedHostCanInitialize() + { + await WebSetupAccessPolicy.IsAllowed(IPAddress.IPv6Loopback, "::1", false).Should().BeTrue(); + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback.MapToIPv6(), "localhost", false).Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalHost("[::1]").Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalHost(new HostString("[::1]:5080").Host).Should().BeTrue(); + } + + [Test] + public async Task RemoteAddressAndRebindingHostAreDenied() + { + await WebSetupAccessPolicy.IsAllowed(IPAddress.Parse("192.0.2.25"), "localhost", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed(null, "localhost", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback, "attacker.example", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback, "127.0.0.2", false).Should().BeFalse(); + } + + [Test] + public async Task PrivateNetworkClientCannotPerformFirstRunSetup() + { + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("192.168.1.10"), "192.168.1.20", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("10.0.0.25"), "192.168.1.20", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("192.168.1.10"), "192.168.1.21", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("203.0.113.8"), "192.168.1.20", false).Should().BeFalse(); + } + + [Test] + public async Task ForwardingHeadersNeverGrantSetupAccess() + { + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback, "localhost", true).Should().BeFalse(); + var headers = new HeaderDictionary { ["X-Forwarded-Host"] = "localhost" }; + await WebSetupAccessPolicy.HasForwardedHeaders(headers).Should().BeTrue(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Loopback, + "localhost", + WebSetupAccessPolicy.HasForwardedHeaders(headers)).Should().BeFalse(); + + var privateClient = IPAddress.Parse("192.168.1.10"); + await WebSetupAccessPolicy.IsAllowed(privateClient, "192.168.1.20", true).Should().BeFalse(); + } + + [Test] + public async Task PublicAndProxyAddressSetupIsRejected() + { + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("203.0.113.12"), "203.0.113.10", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("192.168.1.10"), "2001:db8::10", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("192.168.1.10"), "proxy.example", false).Should().BeFalse(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebTunScopeTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebTunScopeTests.cs new file mode 100644 index 00000000..bcd68c0e --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebTunScopeTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ServiceLib.Enums; +using ServiceLib.Handler.Builder; +using ServiceLib.Models.Configs; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebTunScopeTests +{ + [Test] + public async Task WebContractsDoNotExposeTunManagementButCoreDnsIncludesTunDns() + { + await HasProperty(typeof(WebSettingsView), "Tun").Should().BeFalse(); + await HasProperty(typeof(StatusView), "TunEnabled").Should().BeFalse(); + await HasProperty(typeof(StatusView), "TunInterfaceName").Should().BeFalse(); + await HasProperty(typeof(StatusView), "TunInterfaceActive").Should().BeFalse(); + await HasProperty(typeof(DnsProfileView), "TunDNS").Should().BeTrue(); + await HasProperty(typeof(DnsProfileInput), "TunDNS").Should().BeTrue(); + await HasProperty(typeof(CoreConfigTemplateView), "TunConfig").Should().BeTrue(); + await HasProperty(typeof(CoreConfigTemplateInput), "TunConfig").Should().BeTrue(); + } + + [Test] + public async Task TunSettingsRouteIsNotRegistered() + { + using var sessions = new WebSessionService(); + var session = sessions.CreateSession(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Services.AddSingleton(sessions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + await using var app = builder.Build(); + app.UseRouting(); + app.UseMiddleware(); + app.MapWebApi(); + await app.StartAsync(); + try + { + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.Single(); + using var client = new HttpClient { BaseAddress = new Uri(address) }; + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/settings/tun"); + request.Headers.Authorization = new("Bearer", session.Token); + using var response = await client.SendAsync(request); + + await (response.StatusCode == HttpStatusCode.NotFound).Should().BeTrue(); + } + finally + { + await app.StopAsync(); + } + } + + [Test] + public async Task GeneratedMainOrPreContextWithTunIsRejectedWithoutChangingSavedConfig() + { + var config = new Config(); + config.TunModeItem = new ServiceLib.Models.Configs.TunModeItem(); + config.TunModeItem.EnableTun = true; + var main = CreateContextResult(isTunEnabled: false); + var noTun = V2rayRuntime.GetTunLaunchRejection(new CoreConfigContextBuilderAllResult(main, null)); + await (noTun is null).Should().BeTrue(); + + var mainTun = V2rayRuntime.GetTunLaunchRejection( + new CoreConfigContextBuilderAllResult(CreateContextResult(isTunEnabled: true), null)); + await (mainTun?.Code == "tun_not_supported").Should().BeTrue(); + await (mainTun?.MessageKey == ApiMessageKeys.CoreTunNotSupported).Should().BeTrue(); + + var preTun = V2rayRuntime.GetTunLaunchRejection( + new CoreConfigContextBuilderAllResult(main, CreateContextResult(isTunEnabled: true))); + await (preTun?.Code == "tun_not_supported").Should().BeTrue(); + + var invalidTun = V2rayRuntime.GetTunLaunchRejection( + new CoreConfigContextBuilderAllResult(CreateContextResult(isTunEnabled: true, valid: false), null)); + await (invalidTun?.Code == "tun_not_supported").Should().BeTrue(); + await config.TunModeItem.EnableTun.Should().BeTrue(); + } + + private static bool HasProperty(Type type, string name) => + type.GetProperties().Any(property => property.Name == name); + + private static CoreConfigContextBuilderResult CreateContextResult(bool isTunEnabled, bool valid = true) => + new( + new CoreConfigContext + { + Node = new ProfileItem(), + RunCoreType = ECoreType.Xray, + IsTunEnabled = isTunEnabled, + }, + valid ? NodeValidatorResult.Empty() : new NodeValidatorResult(["invalid"], [])); +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebUpdateDeploymentPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebUpdateDeploymentPolicyTests.cs new file mode 100644 index 00000000..8ae321bc --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebUpdateDeploymentPolicyTests.cs @@ -0,0 +1,39 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebUpdateDeploymentPolicyTests +{ + [Test] + public async Task OnlyWritableNativeSingleFileInstallCanApplyUpdates() + { + var native = WebUpdateDeploymentPolicy.Evaluate(true, false, false, true, true); + await native.Kind.Should().BeEqualTo(WebDeploymentKind.NativeWritable); + await native.CanCheck.Should().BeTrue(); + await native.CanInstall.Should().BeTrue(); + + var readOnly = WebUpdateDeploymentPolicy.Evaluate(true, false, false, true, false); + await readOnly.CanCheck.Should().BeTrue(); + await readOnly.CanInstall.Should().BeFalse(); + await readOnly.InstallReasonKey.Should().BeEqualTo("maintenance.webUpdateReadOnly"); + + var systemd = WebUpdateDeploymentPolicy.Evaluate(true, false, true, true, false); + await systemd.Kind.Should().BeEqualTo(WebDeploymentKind.SystemdManaged); + await systemd.CanInstall.Should().BeFalse(); + await systemd.InstallReasonKey.Should().BeEqualTo("maintenance.webUpdateSystemdAdmin"); + + var container = WebUpdateDeploymentPolicy.Evaluate(true, true, false, true, true); + await container.Kind.Should().BeEqualTo(WebDeploymentKind.Container); + await container.CanCheck.Should().BeTrue(); + await container.CanInstall.Should().BeFalse(); + + var unsupported = WebUpdateDeploymentPolicy.Evaluate(false, false, false, false, true); + await unsupported.CanCheck.Should().BeTrue(); + await unsupported.CanInstall.Should().BeFalse(); + + var windowsNative = WebUpdateDeploymentPolicy.Evaluate(true, false, false, true, true); + await windowsNative.Kind.Should().BeEqualTo(WebDeploymentKind.NativeWritable); + await windowsNative.CanCheck.Should().BeTrue(); + await windowsNative.CanInstall.Should().BeTrue(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebUpdatePackageStagerTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebUpdatePackageStagerTests.cs new file mode 100644 index 00000000..e83705e5 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebUpdatePackageStagerTests.cs @@ -0,0 +1,459 @@ +using System.IO.Compression; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebUpdatePackageStagerTests +{ + [Test] + public async Task LegacyProductIdentityIsRejectedWithManualReinstallInstruction() + { + var rejected = false; + try + { + _ = WebUpdatePackageStager.ParseManifest("{\"product\":\"v2rayN.Web\"}"); + } + catch (InvalidDataException exception) + { + rejected = exception.Message.Contains("manual installation of v2rayN.WebAPI", StringComparison.Ordinal); + } + await rejected.Should().BeTrue(); + } + + [Test] + public async Task OfficialVersionChannelHandlesOlderNewerEqualPrereleaseAndLegacyBuilds() + { + await WebUpdatePackageStager.IsUpdateAvailable("7.25.2", "7.25.3", allowPrerelease: false) + .Should().BeTrue(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.3", allowPrerelease: true) + .Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.3", allowPrerelease: true, + currentCommit: "old-build", candidateCommit: "release-build").Should().BeTrue(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.3", allowPrerelease: true, + currentCommit: "same", candidateCommit: "same").Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.2", allowPrerelease: true) + .Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("0.0.0-dev", "7.25.3", allowPrerelease: false) + .Should().BeTrue(); + // Development identities are older than the official releases they track. + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3-dev.2", "7.25.3", allowPrerelease: true) + .Should().BeTrue(); + // Migration from the retired web-v/-web.N channel: a legacy build upgrades to the official tag, + // and an official build is never "updated" back to a legacy prerelease. + await WebUpdatePackageStager.IsUpdateAvailable("7.25.2-web.5", "7.25.3", allowPrerelease: false) + .Should().BeTrue(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.3-web.1", allowPrerelease: true) + .Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("not-a-version", "7.25.3", allowPrerelease: true) + .Should().BeFalse(); + await WebUpdatePackageStager.ShouldConsiderRelease(isPrerelease: false, allowPrerelease: false).Should().BeTrue(); + await WebUpdatePackageStager.ShouldConsiderRelease(isPrerelease: true, allowPrerelease: false).Should().BeFalse(); + await WebUpdatePackageStager.ShouldConsiderRelease(isPrerelease: true, allowPrerelease: true).Should().BeTrue(); + + var build = WebBuildIdentity.Current; + await WebUpdatePackageStager.IsValidVersion(build.Version).Should().BeTrue(); + await WebReleaseChannel.IsValidRepository(build.Repository).Should().BeTrue(); + await string.IsNullOrWhiteSpace(build.Rid).Should().BeFalse(); + } + + [Test] + public async Task ManifestRequiresWebProductHttpsAssetsAndAnExactRid() + { + var manifest = new WebUpdateManifest("v2rayN.WebAPI", "7.25.3", "0123456789abcdef", "2026-09-28T00:00:00Z", + [ + new WebUpdatePackage("linux-x64", "v2rayN-linux-64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-64-WebAPI-update.zip", + new string('a', 64), 1234), + new WebUpdatePackage("linux-arm64", "v2rayN-linux-arm64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-arm64-WebAPI-update.zip", + new string('b', 64), 2345), + new WebUpdatePackage("win-x64", "v2rayN-windows-64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-windows-64-WebAPI-update.zip", + new string('c', 64), 3456), + new WebUpdatePackage("linux-riscv64", "v2rayN-linux-riscv64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-riscv64-WebAPI-update.zip", + new string('d', 64), 4567), + new WebUpdatePackage("linux-loongarch64", "v2rayN-linux-loong64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-loong64-WebAPI-update.zip", + new string('e', 64), 5678), + new WebUpdatePackage("win-arm64", "v2rayN-windows-arm64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-windows-arm64-WebAPI-update.zip", + new string('f', 64), 6789), + ]); + var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var parsed = WebUpdatePackageStager.ParseManifest(json); + await parsed.Version.Should().BeEqualTo("7.25.3"); + await WebUpdatePackageStager.RequirePackage(parsed, "linux-x64").Rid.Should().BeEqualTo("linux-x64"); + await WebUpdatePackageStager.RequirePackage(parsed, "linux-arm64").Rid.Should().BeEqualTo("linux-arm64"); + await WebUpdatePackageStager.RequirePackage(parsed, "win-x64").Rid.Should().BeEqualTo("win-x64"); + await WebUpdatePackageStager.RequirePackage(parsed, "linux-riscv64").Rid.Should().BeEqualTo("linux-riscv64"); + await WebUpdatePackageStager.RequirePackage(parsed, "linux-loongarch64").Rid.Should().BeEqualTo("linux-loongarch64"); + await WebUpdatePackageStager.RequirePackage(parsed, "win-arm64").Rid.Should().BeEqualTo("win-arm64"); + await WebReleaseChannel.IsTrustedAssetUrl(parsed.Packages[0].Url, "2dust/v2rayN", parsed.Version, parsed.Packages[0].Asset) + .Should().BeTrue(); + + var wrongRidRejected = false; + try { _ = WebUpdatePackageStager.RequirePackage(parsed, "linux-unknown64"); } + catch (InvalidDataException) { wrongRidRejected = true; } + await wrongRidRejected.Should().BeTrue(); + + var oversized = manifest with + { + Packages = [manifest.Packages[0] with { Size = WebUpdatePackageStager.MaximumArchiveBytes + 1 }], + }; + var oversizedRejected = false; + try + { + _ = WebUpdatePackageStager.ParseManifest(JsonSerializer.Serialize(oversized, new JsonSerializerOptions(JsonSerializerDefaults.Web))); + } + catch (InvalidDataException) { oversizedRejected = true; } + await oversizedRejected.Should().BeTrue(); + } + + [Test] + public async Task UnknownRidsInANewerManifestAreIgnoredForForwardCompatibility() + { + var manifest = new WebUpdateManifest("v2rayN.WebAPI", "7.25.3", "0123456789abcdef", "2026-09-28T00:00:00Z", + [ + new WebUpdatePackage("linux-x64", "v2rayN-linux-64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-64-WebAPI-update.zip", + new string('a', 64), 1234), + new WebUpdatePackage("linux-future64", "v2rayN-linux-future64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-future64-WebAPI-update.zip", + new string('b', 64), 2345), + ]); + var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var parsed = WebUpdatePackageStager.ParseManifest(json); + + await WebUpdatePackageStager.RequirePackage(parsed, "linux-x64").Rid.Should().BeEqualTo("linux-x64"); + var unknownRejected = false; + try { _ = WebUpdatePackageStager.RequirePackage(parsed, "linux-future64"); } + catch (InvalidDataException) { unknownRejected = true; } + await unknownRejected.Should().BeTrue(); + } + + [Test] + public async Task NativeExecutableChecksEnforceTheDeclaredArchitecture() + { + using var directory = new TemporaryDirectory(); + foreach (var (rid, machine) in new[] + { + ("linux-x64", 62), ("linux-arm64", 183), ("linux-riscv64", 243), ("linux-loongarch64", 258), + }) + { + var matching = Path.Combine(directory.Path, $"elf-{rid}"); + await File.WriteAllBytesAsync(matching, BuildElfImage(machine)); + WebUpdatePackageStager.RequireNativeExecutable(matching, rid); + + var mismatched = Path.Combine(directory.Path, $"elf-wrong-{rid}"); + await File.WriteAllBytesAsync(mismatched, BuildElfImage(machine == 62 ? 183 : 62)); + var rejected = false; + try { WebUpdatePackageStager.RequireNativeExecutable(mismatched, rid); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + } + + foreach (var (rid, machine) in new[] { ("win-x64", 0x8664), ("win-arm64", 0xAA64) }) + { + var matching = Path.Combine(directory.Path, $"pe-{rid}.exe"); + await File.WriteAllBytesAsync(matching, BuildPeImage((ushort)machine)); + WebUpdatePackageStager.RequireNativeExecutable(matching, rid); + + var mismatched = Path.Combine(directory.Path, $"pe-wrong-{rid}.exe"); + await File.WriteAllBytesAsync(mismatched, BuildPeImage((ushort)(machine == 0x8664 ? 0xAA64 : 0x8664))); + var rejected = false; + try { WebUpdatePackageStager.RequireNativeExecutable(mismatched, rid); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + } + } + + private static byte[] BuildElfImage(int machine) + { + var image = new byte[128]; + new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F', 2, 1, 1 }.CopyTo(image, 0); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(16), 2); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(18), (ushort)machine); + BinaryPrimitives.WriteUInt32LittleEndian(image.AsSpan(20), 1); + BinaryPrimitives.WriteUInt64LittleEndian(image.AsSpan(32), 64); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(52), 64); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(54), 56); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(56), 1); + return image; + } + + private static byte[] BuildPeImage(ushort machine) + { + var image = new byte[512]; + image[0] = (byte)'M'; + image[1] = (byte)'Z'; + BinaryPrimitives.WriteUInt32LittleEndian(image.AsSpan(0x3C), 128); + new byte[] { (byte)'P', (byte)'E', 0, 0 }.CopyTo(image, 128); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(132), machine); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(134), 1); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(148), 240); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(150), 0x0002); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(152), 0x020B); + return image; + } + + [Test] + public async Task AppOnlyZipPackageIsVerifiedAndExtractedForItsExactIdentity() + { + using var directory = new TemporaryDirectory(); + var archive = Path.Combine(directory.Path, "web-app.zip"); + var identity = new WebUpdatePackageIdentity("v2rayN.WebAPI", "7.25.3", "0123456789abcdef", "2026-09-28T01:00:00Z", "linux-x64"); + await WriteArchiveAsync(archive, + [ + ("v2rayN.WebAPI", NativeExecutableFixture()), + ("v2rayN.WebAPI.build.json", JsonSerializer.SerializeToUtf8Bytes(identity, new JsonSerializerOptions(JsonSerializerDefaults.Web))), + ]); + var manifest = CreateManifest(archive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + var package = manifest.Packages[0]; + + var extracted = Path.Combine(directory.Path, "stage"); + var actualIdentity = await WebUpdatePackageStager.VerifyAndExtractAsync( + archive, extracted, manifest, package, CancellationToken.None); + await actualIdentity.Should().BeEqualTo(identity); + await File.Exists(Path.Combine(extracted, "bin", "xray", "xray")).Should().BeFalse(); + await File.Exists(Path.Combine(extracted, "guiConfigs", "guiNConfig.json")).Should().BeFalse(); + await File.Exists(Path.Combine(extracted, "WebAPIData", "WebAPI-auth.json")).Should().BeFalse(); + await Directory.GetFiles(extracted, "*", SearchOption.AllDirectories).Length.Should().BeEqualTo(2); + } + + [Test] + public async Task WindowsAppOnlyZipRequiresAndExtractsAnX64PeExecutable() + { + using var directory = new TemporaryDirectory(); + var archive = Path.Combine(directory.Path, "web-app-win.zip"); + var identity = new WebUpdatePackageIdentity("v2rayN.WebAPI", "7.25.4", "windows-commit", + "2026-10-09T00:00:00Z", "win-x64"); + await WriteArchiveAsync(archive, + [ + ("v2rayN.WebAPI.exe", WindowsExecutableFixture()), + ("v2rayN.WebAPI.build.json", JsonSerializer.SerializeToUtf8Bytes(identity, new JsonSerializerOptions(JsonSerializerDefaults.Web))), + ]); + var manifest = CreateManifest(archive, identity.Version, identity.Commit, identity.Rid, identity.BuildDate); + var extracted = Path.Combine(directory.Path, "windows-stage"); + + var actualIdentity = await WebUpdatePackageStager.VerifyAndExtractAsync( + archive, extracted, manifest, manifest.Packages[0], CancellationToken.None); + + await actualIdentity.Should().BeEqualTo(identity); + await File.Exists(Path.Combine(extracted, "v2rayN.WebAPI.exe")).Should().BeTrue(); + await File.Exists(Path.Combine(extracted, "v2rayN.WebAPI")).Should().BeFalse(); + await Directory.GetFiles(extracted, "*", SearchOption.AllDirectories).Length.Should().BeEqualTo(2); + } + + [Test] + public async Task ChecksumMismatchCorruptArchiveWrongRidAndMissingExecutableAreRejected() + { + using var directory = new TemporaryDirectory(); + var identity = new WebUpdatePackageIdentity("v2rayN.WebAPI", "7.25.4", "abcdef0123456789", "2026-09-28T02:00:00Z", "linux-x64"); + var archive = Path.Combine(directory.Path, "good.zip"); + await WriteArchiveAsync(archive, + [ + ("v2rayN.WebAPI", NativeExecutableFixture()), + ("v2rayN.WebAPI.build.json", JsonSerializer.SerializeToUtf8Bytes(identity, new JsonSerializerOptions(JsonSerializerDefaults.Web))), + ]); + var manifest = CreateManifest(archive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + var damaged = Path.Combine(directory.Path, "damaged.zip"); + var bytes = await File.ReadAllBytesAsync(archive); + bytes[^1] ^= 0x01; + await File.WriteAllBytesAsync(damaged, bytes); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + damaged, Path.Combine(directory.Path, "checksum-failure"), manifest, manifest.Packages[0], CancellationToken.None)); + + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + archive, Path.Combine(directory.Path, "wrong-rid"), manifest, + manifest.Packages[0] with { Rid = "linux-arm64" }, CancellationToken.None)); + + var missingExecutableArchive = Path.Combine(directory.Path, "missing.zip"); + await WriteArchiveAsync(missingExecutableArchive, + [ + ("v2rayN.WebAPI.build.json", JsonSerializer.SerializeToUtf8Bytes(identity, new JsonSerializerOptions(JsonSerializerDefaults.Web))), + ]); + var missingManifest = CreateManifest(missingExecutableArchive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + missingExecutableArchive, Path.Combine(directory.Path, "missing-executable"), missingManifest, + missingManifest.Packages[0], CancellationToken.None)); + } + + [Test] + public async Task ZipTraversalLinksDuplicatesAndUnexpectedEntriesAreRejected() + { + using var directory = new TemporaryDirectory(); + var identity = new WebUpdatePackageIdentity("v2rayN.WebAPI", "7.25.5", "0123abcdef", "2026-09-28T03:00:00Z", "linux-x64"); + var identityJson = JsonSerializer.SerializeToUtf8Bytes(identity, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + foreach (var entryName in new[] + { + "../escape", "/absolute", "extra/../escape", + ".env", ".env.example", "extra/.env", "extra/.env.example", + }) + { + var archive = Path.Combine(directory.Path, Guid.NewGuid().ToString("N") + ".zip"); + await WriteArchiveAsync(archive, [(entryName, Encoding.UTF8.GetBytes("nope"))]); + var manifest = CreateManifest(archive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + archive, Path.Combine(directory.Path, Guid.NewGuid().ToString("N")), manifest, + manifest.Packages[0], CancellationToken.None)); + } + + var symlinkArchive = Path.Combine(directory.Path, "symlink.zip"); + await WriteSymlinkAsync(symlinkArchive, "extra/marker.dat", "../../outside"); + var symlinkManifest = CreateManifest(symlinkArchive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + symlinkArchive, Path.Combine(directory.Path, "symlink-stage"), symlinkManifest, + symlinkManifest.Packages[0], CancellationToken.None)); + + var duplicateArchive = Path.Combine(directory.Path, "duplicate.zip"); + await WriteArchiveAsync(duplicateArchive, + [ + ("v2rayN.WebAPI", NativeExecutableFixture()), + ("v2rayN.WebAPI.build.json", identityJson), + ("v2rayN.WebAPI.build.json", identityJson), + ]); + var duplicateManifest = CreateManifest(duplicateArchive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + duplicateArchive, Path.Combine(directory.Path, "duplicate-stage"), duplicateManifest, + duplicateManifest.Packages[0], CancellationToken.None)); + + var unexpectedArchive = Path.Combine(directory.Path, "unexpected.zip"); + await WriteArchiveAsync(unexpectedArchive, + [ + ("v2rayN.WebAPI", NativeExecutableFixture()), + ("v2rayN.WebAPI.build.json", identityJson), + ("extra/marker.dat", Encoding.UTF8.GetBytes("UNEXPECTED_FILE_TEST")), + ]); + var unexpectedManifest = CreateManifest(unexpectedArchive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + unexpectedArchive, Path.Combine(directory.Path, "unexpected-stage"), unexpectedManifest, + unexpectedManifest.Packages[0], CancellationToken.None)); + } + + [Test] + public async Task ScriptsTruncatedImagesAndWrongArchitectureCannotReachProcessReplacement() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "candidate"); + foreach (var image in new[] { Encoding.UTF8.GetBytes("#!/bin/sh\nexit 1\n"), NativeExecutableFixture()[..64], NativeExecutableFixture(183) }) + { + await File.WriteAllBytesAsync(path, image); + var rejected = false; + try { WebUpdatePackageStager.RequireNativeExecutable(path, "linux-x64"); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + } + await File.WriteAllBytesAsync(path, NativeExecutableFixture()); + WebUpdatePackageStager.RequireNativeExecutable(path, "linux-x64"); + } + + [Test] + public async Task WindowsExecutableMustBePe32PlusAmd64() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "candidate.exe"); + foreach (var image in new[] + { + Encoding.UTF8.GetBytes("MZ but not a PE executable"), + WindowsExecutableFixture(machine: 0x014C), + WindowsExecutableFixture(optionalHeaderMagic: 0x010B), + WindowsExecutableFixture()[..80], + }) + { + await File.WriteAllBytesAsync(path, image); + var rejected = false; + try { WebUpdatePackageStager.RequireNativeExecutable(path, "win-x64"); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + } + await File.WriteAllBytesAsync(path, WindowsExecutableFixture()); + WebUpdatePackageStager.RequireNativeExecutable(path, "win-x64"); + } + + private static byte[] NativeExecutableFixture(ushort machine = 62) + { + var image = new byte[120]; // Minimal ELF/program-table fixture, never executed. + new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F', 2, 1, 1 }.CopyTo(image, 0); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(16), 3); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(18), machine); + BinaryPrimitives.WriteUInt32LittleEndian(image.AsSpan(20), 1); + BinaryPrimitives.WriteUInt64LittleEndian(image.AsSpan(32), 64); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(52), 64); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(54), 56); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(56), 1); + return image; + } + + private static byte[] WindowsExecutableFixture(ushort machine = 0x8664, ushort optionalHeaderMagic = 0x020B) + { + var image = new byte[512]; // Minimal PE32+ AMD64 image fixture, never executed. + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(0), 0x5A4D); + BinaryPrimitives.WriteUInt32LittleEndian(image.AsSpan(0x3C), 128); + "PE\0\0"u8.CopyTo(image.AsSpan(128)); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(132), machine); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(134), 1); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(148), 240); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(150), 0x0002); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(152), optionalHeaderMagic); + return image; + } + + private static WebUpdateManifest CreateManifest(string archive, string version, string commit, string rid, string buildDate) + { + using var stream = File.OpenRead(archive); + var hash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + var asset = WebUpdatePackageStager.AppOnlyAssetName(rid) + ?? throw new InvalidOperationException($"No app-only asset for {rid}."); + return new WebUpdateManifest("v2rayN.WebAPI", version, commit, buildDate, + [new WebUpdatePackage(rid, asset, + $"https://github.com/2dust/v2rayN/releases/download/{version}/{asset}", + hash, new FileInfo(archive).Length)]); + } + + private static async Task WriteArchiveAsync(string path, IReadOnlyList<(string Name, byte[] Content)> files) + { + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + foreach (var item in files) + { + var entry = archive.CreateEntry(item.Name, CompressionLevel.SmallestSize); + await using var stream = entry.Open(); + await stream.WriteAsync(item.Content); + } + } + + private static async Task WriteSymlinkAsync(string path, string name, string target) + { + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + var entry = archive.CreateEntry(name); + entry.ExternalAttributes = (0xA000 | 0x1FF) << 16; + await using var stream = entry.Open(); + await stream.WriteAsync(Encoding.UTF8.GetBytes(target)); + } + + private static async Task ExpectInvalidDataAsync(Func> action) + { + var rejected = false; + try { _ = await action(); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-package-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebUpdateReleaseSelectionTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebUpdateReleaseSelectionTests.cs new file mode 100644 index 00000000..80d3657b --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebUpdateReleaseSelectionTests.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebUpdateReleaseSelectionTests +{ + private const string ReleaseIndex = """ + [ + { + "tag_name": "web-v7.25.9-web.3", + "draft": false, + "prerelease": false, + "assets": [ + { "name": "WebAPI-update.json", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/web-v7.25.9-web.3/WebAPI-update.json", "size": 512 } + ] + }, + { + "tag_name": "7.25.6", + "draft": true, + "prerelease": false, + "assets": [ + { "name": "WebAPI-update.json", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/7.25.6/WebAPI-update.json", "size": 512 } + ] + }, + { + "tag_name": "7.25.5", + "draft": false, + "prerelease": true, + "assets": [ + { "name": "WebAPI-update.json", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/7.25.5/WebAPI-update.json", "size": 512 } + ] + }, + { + "tag_name": "7.25.4", + "draft": false, + "prerelease": false, + "assets": [ + { "name": "v2rayN-linux-64.zip", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/7.25.4/v2rayN-linux-64.zip", "size": 1024 } + ] + }, + { + "tag_name": "7.25.3", + "draft": false, + "prerelease": false, + "assets": [ + { "name": "WebAPI-update.json", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/7.25.3/WebAPI-update.json", "size": 512 }, + { "name": "v2rayN-linux-64-WebAPI-update.zip", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-64-WebAPI-update.zip", "size": 4096 } + ] + }, + { + "tag_name": "v7.25.7", + "draft": false, + "prerelease": false, + "assets": [ + { "name": "WebAPI-update.json", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/v7.25.7/WebAPI-update.json", "size": 512 } + ] + } + ] + """; + + [Test] + public async Task OfficialReleaseIndexSelectsOnlyTaggedReleasesWithAWebManifest() + { + var stable = V2rayRuntime.ParseWebReleaseCandidates(ReleaseIndex, allowPrerelease: false); + await stable.Count.Should().BeEqualTo(1); + var selected = stable[0]; + await selected.Tag.Should().BeEqualTo("7.25.3"); + await selected.Version.Should().BeEqualTo("7.25.3"); + await selected.IsPrerelease.Should().BeFalse(); + await selected.ManifestUrl.Should().BeEqualTo("https://github.com/2dust/v2rayN/releases/download/7.25.3/WebAPI-update.json"); + await selected.Assets.ContainsKey("v2rayN-linux-64-WebAPI-update.zip").Should().BeTrue(); + + var withPrerelease = V2rayRuntime.ParseWebReleaseCandidates(ReleaseIndex, allowPrerelease: true); + await withPrerelease.Count.Should().BeEqualTo(2); + await withPrerelease[0].Version.Should().BeEqualTo("7.25.5"); + await withPrerelease[1].Version.Should().BeEqualTo("7.25.3"); + } + + [Test] + public async Task NewestOfficialCandidateWinsAfterVersionOrdering() + { + var candidates = V2rayRuntime.ParseWebReleaseCandidates(ReleaseIndex, allowPrerelease: false); + candidates.Sort((left, right) => new ServiceLib.Models.Dto.SemanticVersion(right.Version) + .CompareTo(new ServiceLib.Models.Dto.SemanticVersion(left.Version))); + await candidates[0].Version.Should().BeEqualTo("7.25.3"); + } + + [Test] + public async Task InvalidReleaseIndexesAreRejected() + { + var malformedRejected = false; + try { _ = V2rayRuntime.ParseWebReleaseCandidates("{ not json", allowPrerelease: false); } + catch (JsonException) { malformedRejected = true; } + await malformedRejected.Should().BeTrue(); + + var nonArrayRejected = false; + try { _ = V2rayRuntime.ParseWebReleaseCandidates("{}", allowPrerelease: false); } + catch (InvalidDataException) { nonArrayRejected = true; } + await nonArrayRejected.Should().BeTrue(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/XrayGeoStagingTests.cs b/v2rayN/v2rayN.WebAPI/Tests/XrayGeoStagingTests.cs new file mode 100644 index 00000000..29a8b63c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/XrayGeoStagingTests.cs @@ -0,0 +1,36 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class XrayGeoStagingTests +{ + [Test] + public async Task ApplyCopiesTheLatestGeoFilesAfterStaging() + { + var root = Path.Combine(Path.GetTempPath(), $"v2rayn-WebAPI-xray-stage-{Guid.NewGuid():N}"); + var installPath = Path.Combine(root, "installed"); + var stagingPath = Path.Combine(root, "staging"); + Directory.CreateDirectory(installPath); + Directory.CreateDirectory(stagingPath); + + try + { + var installedGeo = Path.Combine(installPath, "geoip.dat"); + var stagedGeo = Path.Combine(stagingPath, "geoip.dat"); + await File.WriteAllTextAsync(installedGeo, "old geo data"); + await File.WriteAllTextAsync(Path.Combine(stagingPath, "xray"), "staged executable"); + + await File.Exists(stagedGeo).Should().BeFalse(); + + // A Geo update completes after Xray staging but before exclusive apply. + await File.WriteAllTextAsync(installedGeo, "latest geo data"); + V2rayRuntime.CopyLatestGeoFilesForApply(installPath, stagingPath); + + await (await File.ReadAllTextAsync(stagedGeo)).Should().BeEqualTo("latest geo data"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj b/v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj new file mode 100644 index 00000000..d79249ce --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj @@ -0,0 +1,27 @@ + + + Exe + net10.0 + false + enable + + + + + + + + + + + + + + + + + + + + + diff --git a/v2rayN/v2rayN.WebAPI/compose.yaml b/v2rayN/v2rayN.WebAPI/compose.yaml new file mode 100644 index 00000000..380b72a9 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/compose.yaml @@ -0,0 +1,26 @@ +services: + v2rayn-web: + build: + context: ../.. + dockerfile: v2rayN/v2rayN.WebAPI/Containerfile + args: + RID: ${V2RAYN_BUILD_RID:-linux-x64} + image: localhost/v2rayn-web:latest + restart: unless-stopped + environment: + ASPNETCORE_URLS: http://0.0.0.0:5080 + V2RAYN_WEB_API_KEY: ${V2RAYN_WEB_API_KEY:?Set V2RAYN_WEB_API_KEY in your environment} + V2RAYN_WEB_ALLOWED_ORIGINS: ${V2RAYN_WEB_ALLOWED_ORIGINS:-} + V2RAYN_WEB_AUTOSTART: "true" + V2RAYN_WEB_PROXY_PORT: ${V2RAYN_PROXY_PORT:-10808} + V2RAYN_WEB_PROXY_LISTEN_ALL: "true" + V2RAYN_DATA_HOME: /data + ports: + - "127.0.0.1:${V2RAYN_WEB_PORT:-5080}:5080/tcp" + - "127.0.0.1:${V2RAYN_PROXY_PORT:-10808}:${V2RAYN_PROXY_PORT:-10808}/tcp" + - "127.0.0.1:${V2RAYN_PROXY_PORT:-10808}:${V2RAYN_PROXY_PORT:-10808}/udp" + volumes: + - v2rayn-data:/data + +volumes: + v2rayn-data: diff --git a/v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj b/v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj new file mode 100644 index 00000000..3bb46b05 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj @@ -0,0 +1,46 @@ + + + net10.0 + v2rayN.WebAPI + v2rayN.WebAPI + enable + enable + false + $(DefaultItemExcludes);.NET/**;.packages/**;Tests/**;TestResults/**;publish/** + 0.0.0-dev + unknown + unknown + 2dust/v2rayN + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/v2rayN/v2rayN.slnx b/v2rayN/v2rayN.slnx index c1532839..a01d2390 100644 --- a/v2rayN/v2rayN.slnx +++ b/v2rayN/v2rayN.slnx @@ -2,6 +2,7 @@ + @@ -21,4 +22,6 @@ + +