mirror of
https://github.com/2dust/v2rayN.git
synced 2026-10-21 04:35:00 +03:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94e9a3ca02 | |||
| 57f17d5939 | |||
| 66dd7da6e6 | |||
| 5e0ea3275d | |||
| 673283da4b | |||
| 1f083adbe4 | |||
| 80ab14d057 | |||
| 95f438abc9 | |||
| f1023e08e1 | |||
| 1883178b5f | |||
| 1cde521d75 | |||
| 2407d44f4f | |||
| fbf822d74c | |||
| 4869a6cbba | |||
| b501efbc45 | |||
| 5ea8ae6420 | |||
| fa9d4a4769 | |||
| 771ef3192a |
@@ -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 <<EOF
|
||||
WEB_VERSION=${V2RAYN_WEB_VERSION}
|
||||
WEB_REPOSITORY=${V2RAYN_WEB_REPOSITORY}
|
||||
WEB_COMMIT=${V2RAYN_WEB_COMMIT}
|
||||
WEB_BUILD_DATE=${V2RAYN_WEB_BUILD_DATE}
|
||||
EOF
|
||||
cat > 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@v8
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}" }
|
||||
@@ -19,7 +19,9 @@ jobs:
|
||||
$wingetPackage = "2dust.v2rayN"
|
||||
$gitToken = "${{ secrets.PT_WINGET }}"
|
||||
|
||||
$github = Invoke-RestMethod -uri "https://api.github.com/repos/2dust/v2rayN/releases"
|
||||
$headers = @{ Authorization = "Bearer $gitToken"; Accept = "application/vnd.github+json" }
|
||||
|
||||
$github = Invoke-RestMethod -Uri "https://api.github.com/repos/2dust/v2rayN/releases" -Headers $headers
|
||||
|
||||
$targetRelease = $github | Where-Object -Property prerelease -match 'False' | Select -First 1
|
||||
|
||||
|
||||
@@ -676,6 +676,15 @@ EOF
|
||||
find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} +
|
||||
[[ -f "$stage/opt/v2rayN/v2rayN" ]] && chmod 0755 "$stage/opt/v2rayN/v2rayN" || true
|
||||
|
||||
# Core binaries must be executable before installation: ordinary users cannot
|
||||
# chmod the root-owned files installed by dpkg.
|
||||
local core
|
||||
for core in xray/xray sing_box/sing-box mihomo/mihomo; do
|
||||
if [[ -f "$stage/opt/v2rayN/bin/$core" ]]; then
|
||||
chmod 0755 "$stage/opt/v2rayN/bin/$core"
|
||||
fi
|
||||
done
|
||||
|
||||
deb_out="$OUTPUT_DIR/v2rayn_${VERSION}_${deb_arch}.deb"
|
||||
dpkg-deb --root-owner-group --build "$stage" "$deb_out"
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ internal class UpgradeApp
|
||||
var thisAppOldFile = $"{Utils.GetExePath()}.tmp";
|
||||
File.Delete(thisAppOldFile);
|
||||
var splitKey = "/";
|
||||
var allowedBaseDir = Utils.StartupPath();
|
||||
var pathComparison = Utils.GetPathComparison();
|
||||
|
||||
using var archive = ZipFile.OpenRead(fileName);
|
||||
foreach (var entry in archive.Entries)
|
||||
@@ -65,16 +67,21 @@ internal class UpgradeApp
|
||||
}
|
||||
|
||||
var fullName = string.Join(splitKey, lst[1..lst.Length]);
|
||||
var entryOutputPath = Utils.GetPath(fullName);
|
||||
if (!Utils.IsPathUnderDirectory(allowedBaseDir, entryOutputPath))
|
||||
{
|
||||
Console.WriteLine($"{Resx.Resource.FailedUpgrade} blocked potential path traversal: {entry.FullName}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(Utils.GetExePath(), Utils.GetPath(fullName), StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(Utils.GetExePath(), entryOutputPath, pathComparison))
|
||||
{
|
||||
File.Move(Utils.GetExePath(), thisAppOldFile);
|
||||
}
|
||||
|
||||
var entryOutputPath = Utils.GetPath(fullName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(entryOutputPath)!);
|
||||
//In the bin folder, if the file already exists, it will be skipped
|
||||
if (fullName.StartsWith("bin") && File.Exists(entryOutputPath))
|
||||
if (fullName.StartsWith("bin", pathComparison) && File.Exists(entryOutputPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,28 @@ internal class Utils
|
||||
return Path.Combine(startupPath, fileName);
|
||||
}
|
||||
|
||||
public static bool IsPathUnderDirectory(string baseDir, string targetPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseDir) || string.IsNullOrWhiteSpace(targetPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var baseFull = Path.GetFullPath(baseDir);
|
||||
var targetFull = Path.GetFullPath(targetPath);
|
||||
|
||||
baseFull = baseFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
|
||||
return targetFull.StartsWith(baseFull, GetPathComparison());
|
||||
}
|
||||
|
||||
public static StringComparison GetPathComparison()
|
||||
{
|
||||
return OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
}
|
||||
|
||||
public static string V2rayN => "v2rayN";
|
||||
|
||||
public static void StartV2RayN()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>7.25.5</Version>
|
||||
<Version>7.25.6</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
|
||||
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.5" />
|
||||
<PackageVersion Include="Downloader" Version="5.9.8" />
|
||||
<PackageVersion Include="Downloader" Version="5.10.1" />
|
||||
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
|
||||
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
|
||||
<PackageVersion Include="QRCoder" Version="1.8.0" />
|
||||
@@ -27,7 +27,7 @@
|
||||
<PackageVersion Include="sqlite-net-e" Version="1.11.285" />
|
||||
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
|
||||
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
|
||||
<PackageVersion Include="TUnit" Version="1.72.16" />
|
||||
<PackageVersion Include="TUnit" Version="1.75.0" />
|
||||
<PackageVersion Include="TUnit.Assertions.Should" Version="1.72.16-beta" />
|
||||
<PackageVersion Include="WebDav.Client" Version="2.9.0" />
|
||||
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace ServiceLib.Tests.Common;
|
||||
|
||||
public class UnixFileModeTests
|
||||
{
|
||||
[Test]
|
||||
public async Task AlreadyExecutableSystemBinary_ShouldNotRequireOwnership()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// /bin/sh is executable but owned by root on the Unix CI runners.
|
||||
// A regular user must not need chmod permission just to launch it.
|
||||
var mode = File.GetUnixFileMode("/bin/sh");
|
||||
await Utils.SetUnixFileMode("/bin/sh").Should().BeTrue();
|
||||
await File.GetUnixFileMode("/bin/sh").Should().BeEqualTo(mode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NonExecutableOwnedFile_ShouldGainExecuteBitsWithoutLosingOtherPermissions()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead;
|
||||
File.SetUnixFileMode(path, mode);
|
||||
|
||||
await Utils.SetUnixFileMode(path).Should().BeTrue();
|
||||
await File.GetUnixFileMode(path).Should().BeEqualTo(mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,13 +587,11 @@ public class CoreConfigV2rayServiceTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Arguments(true)]
|
||||
[Arguments(false)]
|
||||
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6(bool enableIPv6Address)
|
||||
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6()
|
||||
{
|
||||
// A host without a global IPv6 address has no IPv6 traffic that could bypass the tunnel,
|
||||
// while ::/0 would pull IPv6 attempts into a tunnel they cannot leave.
|
||||
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address);
|
||||
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address: false);
|
||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||
|
||||
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||
@@ -611,6 +609,26 @@ public class CoreConfigV2rayServiceTests
|
||||
await ipv6Routes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GenerateClientConfigContent_Tun_ShouldRouteIPv6WhenEnabledWithoutGlobalIPv6()
|
||||
{
|
||||
// Hosts behind NAT66 hold only ULA addresses, so EnableIPv6Address must still route ::/0.
|
||||
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address: true);
|
||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||
|
||||
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray, hasGlobalIPv6Address: false);
|
||||
|
||||
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||
|
||||
await result.Success.Should().BeTrue();
|
||||
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
|
||||
|
||||
await tunInbound.Should().NotBeNull();
|
||||
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("::/0");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldSkipIPv6RangesWithoutGlobalIPv6()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace ServiceLib.Tests.Manager;
|
||||
|
||||
public class ProfileExManagerConcurrencyTests
|
||||
{
|
||||
[Test]
|
||||
public async Task SetTestDelay_FromManyThreads_ShouldNotThrow()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var exceptions = new List<Exception>();
|
||||
var threads = Environment.ProcessorCount * 2;
|
||||
|
||||
//Hammer the same manager from several streams at once: the queue is mutated while
|
||||
//other threads keep pushing into it
|
||||
var barrier = new Barrier(threads);
|
||||
Parallel.For(0, threads, t =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
for (var i = 0; i < 5000; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
manager.SetTestDelay($"index{i % 500}", i);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (exceptions)
|
||||
{
|
||||
exceptions.Add(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await exceptions.Count.Should().BeEqualTo(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SetTestDelay_FromManyThreads_ShouldNotLoseQueuedIds()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var ids = 500;
|
||||
|
||||
Parallel.For(0, Environment.ProcessorCount * 2, t =>
|
||||
{
|
||||
for (var i = 0; i < 5000; i++)
|
||||
{
|
||||
manager.SetTestDelay($"id{i % ids}", i);
|
||||
}
|
||||
});
|
||||
|
||||
var drained = Drain(manager);
|
||||
await drained.Count.Should().BeGreaterThanOrEqualTo(ids);
|
||||
await drained.Distinct().Count().Should().BeEqualTo(ids);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SetTestDelay_ShouldCreateOneEntryPerIndex()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
|
||||
await Parallel.ForEachAsync(Enumerable.Range(0, 500), new ParallelOptions { MaxDegreeOfParallelism = 32 },
|
||||
async (i, ct) =>
|
||||
{
|
||||
manager.SetTestDelay($"same{i % 10}", i);
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
var items = await manager.GetProfileExs();
|
||||
await items.Count.Should().BeEqualTo(10);
|
||||
await items.Select(t => t.IndexId).Distinct().Count().Should().BeEqualTo(10);
|
||||
}
|
||||
|
||||
private static ProfileExManager CreateManager()
|
||||
{
|
||||
return (ProfileExManager)Activator.CreateInstance(typeof(ProfileExManager), nonPublic: true)!;
|
||||
}
|
||||
|
||||
private static List<string> Drain(ProfileExManager manager)
|
||||
{
|
||||
var queue = (System.Collections.Concurrent.ConcurrentQueue<string>)typeof(ProfileExManager)
|
||||
.GetField("_queIndexIds", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(manager)!;
|
||||
|
||||
var result = new List<string>();
|
||||
while (queue.TryDequeue(out var id))
|
||||
{
|
||||
result.Add(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace ServiceLib.Tests.Services;
|
||||
|
||||
public class SpeedtestServiceBatchTests
|
||||
{
|
||||
[Test]
|
||||
[Arguments(0)]
|
||||
[Arguments(1000)]
|
||||
public async Task EmptySelection_ShouldReturnNoBatches(int pageSize)
|
||||
{
|
||||
await GetBatches([], pageSize).Count.Should().BeEqualTo(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Arguments(0)]
|
||||
[Arguments(-1)]
|
||||
public async Task NonPositivePageSize_ShouldKeepEveryEligibleProfile(int pageSize)
|
||||
{
|
||||
var profiles = CreateProfiles();
|
||||
var batches = GetBatches(profiles, pageSize);
|
||||
|
||||
await batches.Count.Should().BeEqualTo(profiles.Count);
|
||||
await batches.SelectMany(batch => batch).Count().Should().BeEqualTo(profiles.Count);
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
await batches.SelectMany(batch => batch).Count(item => ReferenceEquals(item, profile)).Should().BeEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NormalBatches_ShouldRespectPageSizeAndKeepCoreTypesSeparate()
|
||||
{
|
||||
var profiles = CreateProfiles();
|
||||
var batches = GetBatches(profiles, 2);
|
||||
|
||||
await batches.Count.Should().BeEqualTo(3);
|
||||
await batches.SelectMany(batch => batch).Count().Should().BeEqualTo(profiles.Count);
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
await (batch.Count > 0 && batch.Count <= 2).Should().BeTrue();
|
||||
await batch.Select(item => item.CoreType).Distinct().Count().Should().BeEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ServerTestItem> CreateProfiles() =>
|
||||
[
|
||||
new() { CoreType = ECoreType.Xray },
|
||||
new() { CoreType = ECoreType.Xray },
|
||||
new() { CoreType = ECoreType.Xray },
|
||||
new() { CoreType = ECoreType.sing_box },
|
||||
new() { CoreType = ECoreType.sing_box }
|
||||
];
|
||||
|
||||
private static List<List<ServerTestItem>> GetBatches(List<ServerTestItem> profiles, int pageSize)
|
||||
{
|
||||
var service = new SpeedtestService(new Config { SpeedTestItem = new() }, _ => Task.CompletedTask);
|
||||
var method = typeof(SpeedtestService).GetMethod("GetTestBatchItem", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
return (List<List<ServerTestItem>>)method.Invoke(service, [profiles, pageSize])!;
|
||||
}
|
||||
}
|
||||
@@ -1441,7 +1441,11 @@ public class Utils
|
||||
if (File.Exists(fileName))
|
||||
{
|
||||
var currentMode = File.GetUnixFileMode(fileName);
|
||||
File.SetUnixFileMode(fileName, currentMode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
|
||||
var executableMode = currentMode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
|
||||
if (currentMode != executableMode)
|
||||
{
|
||||
File.SetUnixFileMode(fileName, executableMode);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,10 +656,10 @@ public class Global
|
||||
{ ECoreType.Xray, "XTLS/Xray-core" },
|
||||
{ ECoreType.sing_box, "SagerNet/sing-box" },
|
||||
{ ECoreType.mihomo, "MetaCubeX/mihomo" },
|
||||
{ ECoreType.hysteria, "apernet/hysteria" },
|
||||
{ ECoreType.hysteria2, "apernet/hysteria" },
|
||||
{ ECoreType.hysteria, "HyNetworks/hysteria" },
|
||||
{ ECoreType.hysteria2, "HyNetworks/hysteria" },
|
||||
{ ECoreType.naiveproxy, "klzgrad/naiveproxy" },
|
||||
{ ECoreType.tuic, "EAimTY/tuic" },
|
||||
{ ECoreType.tuic, "tuic-protocol/tuic" },
|
||||
{ ECoreType.juicity, "juicity/juicity" },
|
||||
{ ECoreType.brook, "txthinking/brook" },
|
||||
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
|
||||
|
||||
@@ -4,7 +4,8 @@ public class ProfileExManager
|
||||
{
|
||||
private static readonly Lazy<ProfileExManager> _instance = new(() => new());
|
||||
private ConcurrentBag<ProfileExItem> _lstProfileEx = [];
|
||||
private readonly Queue<string> _queIndexIds = new();
|
||||
private readonly ConcurrentQueue<string> _queIndexIds = new();
|
||||
private readonly Lock _profileExLock = new();
|
||||
public static ProfileExManager Instance => _instance.Value;
|
||||
private static readonly string _tag = "ProfileExHandler";
|
||||
|
||||
@@ -27,29 +28,39 @@ public class ProfileExManager
|
||||
{
|
||||
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem where indexId not in ( select indexId from ProfileItem )");
|
||||
|
||||
_lstProfileEx = new(await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync());
|
||||
var lstProfileEx = new ConcurrentBag<ProfileExItem>(await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync());
|
||||
lock (_profileExLock)
|
||||
{
|
||||
_lstProfileEx = lstProfileEx;
|
||||
}
|
||||
}
|
||||
|
||||
private void IndexIdEnqueue(string indexId)
|
||||
{
|
||||
if (indexId.IsNotEmpty() && !_queIndexIds.Contains(indexId))
|
||||
if (indexId.IsNotEmpty())
|
||||
{
|
||||
//Speed tests touch this from many threads; duplicates are harmless here since
|
||||
//the save loop just writes the same row more than once
|
||||
_queIndexIds.Enqueue(indexId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveQueueIndexIds()
|
||||
{
|
||||
var cnt = _queIndexIds.Count;
|
||||
if (cnt > 0)
|
||||
var lstIds = new List<string>();
|
||||
while (_queIndexIds.TryDequeue(out var id))
|
||||
{
|
||||
lstIds.Add(id);
|
||||
}
|
||||
|
||||
if (lstIds.Count > 0)
|
||||
{
|
||||
var lstExists = await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync();
|
||||
List<ProfileExItem> lstInserts = [];
|
||||
List<ProfileExItem> lstUpdates = [];
|
||||
|
||||
for (var i = 0; i < cnt; i++)
|
||||
foreach (var id in lstIds)
|
||||
{
|
||||
var id = _queIndexIds.Dequeue();
|
||||
var item = lstExists.FirstOrDefault(t => t.IndexId == id);
|
||||
var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id);
|
||||
if (itemNew is null)
|
||||
@@ -103,13 +114,26 @@ public class ProfileExManager
|
||||
|
||||
private ProfileExItem GetProfileExItem(string? indexId)
|
||||
{
|
||||
return _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId) ?? AddProfileEx(indexId);
|
||||
var profileEx = _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId);
|
||||
if (profileEx is not null)
|
||||
{
|
||||
return profileEx;
|
||||
}
|
||||
|
||||
lock (_profileExLock)
|
||||
{
|
||||
profileEx = _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId);
|
||||
return profileEx ?? AddProfileEx(indexId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClearAll()
|
||||
{
|
||||
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem ");
|
||||
_lstProfileEx = [];
|
||||
lock (_profileExLock)
|
||||
{
|
||||
_lstProfileEx = [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveTo()
|
||||
|
||||
@@ -300,40 +300,6 @@ AAoACxGV2lZFA4gKn2fQ1XmxqI1AbQ3CekD6819kR5LLU7m7Wc5P/dAVUwHY3+vZ
|
||||
5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFsDCCA5igAwIBAgIQFci9ZUdcr7iXAF7kBtK8nTANBgkqhkiG9w0BAQUFADBe
|
||||
MQswCQYDVQQGEwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0
|
||||
ZC4xKjAoBgNVBAsMIWVQS0kgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAe
|
||||
Fw0wNDEyMjAwMjMxMjdaFw0zNDEyMjAwMjMxMjdaMF4xCzAJBgNVBAYTAlRXMSMw
|
||||
IQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEqMCgGA1UECwwhZVBL
|
||||
SSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAg8AMIICCgKCAgEA4SUP7o3biDN1Z82tH306Tm2d0y8U82N0ywEhajfqhFAH
|
||||
SyZbCUNsIZ5qyNUD9WBpj8zwIuQf5/dqIjG3LBXy4P4AakP/h2XGtRrBp0xtInAh
|
||||
ijHyl3SJCRImHJ7K2RKilTza6We/CKBk49ZCt0Xvl/T29de1ShUCWH2YWEtgvM3X
|
||||
DZoTM1PRYfl61dd4s5oz9wCGzh1NlDivqOx4UXCKXBCDUSH3ET00hl7lSM2XgYI1
|
||||
TBnsZfZrxQWh7kcT1rMhJ5QQCtkkO7q+RBNGMD+XPNjX12ruOzjjK9SXDrkb5wdJ
|
||||
fzcq+Xd4z1TtW0ado4AOkUPB1ltfFLqfpo0kR0BZv3I4sjZsN/+Z0V0OWQqraffA
|
||||
sgRFelQArr5T9rXn4fg8ozHSqf4hUmTFpmfwdQcGlBSBVcYn5AGPF8Fqcde+S/uU
|
||||
WH1+ETOxQvdibBjWzwloPn9s9h6PYq2lY9sJpx8iQkEeb5mKPtf5P0B6ebClAZLS
|
||||
nT0IFaUQAS2zMnaolQ2zepr7BxB4EW/hj8e6DyUadCrlHJhBmd8hh+iVBmoKs2pH
|
||||
dmX2Os+PYhcZewoozRrSgx4hxyy/vv9haLdnG7t4TY3OZ+XkwY63I2binZB1NJip
|
||||
NiuKmpS5nezMirH4JYlcWrYvjB9teSSnUmjDhDXiZo1jDiVN1Rmy5nk3pyKdVDEC
|
||||
AwEAAaNqMGgwHQYDVR0OBBYEFB4M97Zn8uGSJglFwFU5Lnc/QkqiMAwGA1UdEwQF
|
||||
MAMBAf8wOQYEZyoHAAQxMC8wLQIBADAJBgUrDgMCGgUAMAcGBWcqAwAABBRFsMLH
|
||||
ClZ87lt4DJX5GFPBphzYEDANBgkqhkiG9w0BAQUFAAOCAgEACbODU1kBPpVJufGB
|
||||
uvl2ICO1J2B01GqZNF5sAFPZn/KmsSQHRGoqxqWOeBLoR9lYGxMqXnmbnwoqZ6Yl
|
||||
PwZpVnPDimZI+ymBV3QGypzqKOg4ZyYr8dW1P2WT+DZdjo2NQCCHGervJ8A9tDkP
|
||||
JXtoUHRVnAxZfVo9QZQlUgjgRywVMRnVvwdVxrsStZf0X4OFunHB2WyBEXYKCrC/
|
||||
gpf36j36+uwtqSiUO1bd0lEursC9CBWMd1I0ltabrNMdjmEPNXubrjlpC2JgQCA2
|
||||
j6/7Nu4tCEoduL+bXPjqpRugc6bY+G7gMwRfaKonh+3ZwZCc7b3jajWvY9+rGNm6
|
||||
5ulK6lCKD2GTHuItGeIwlDWSXQ62B68ZgI9HkFFLLk3dheLSClIKF5r8GrBQAuUB
|
||||
o2M3IUxExJtRmREOc5wGj1QupyheRDmHVi03vYVElOEMSyycw5KFNGHLD7ibSkNS
|
||||
/jQ6fbjpKdx2qcgw+BRxgMYeNkh0IkFch4LoGHGLQYlE535YW6i4jRPpp2zDR+2z
|
||||
Gp1iro2C6pSe3VkQw63d4k3jMdXH7OjysP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTE
|
||||
W9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmODBCEIZ43ygknQW/2xzQ+D
|
||||
hNQ+IIX3Sj0rnP0qCglN6oH4EZw=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRF
|
||||
MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBD
|
||||
@@ -732,20 +698,6 @@ LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI
|
||||
4uJEvlz36hz1
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYD
|
||||
VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG
|
||||
A1UEAxMLR1RTIFJvb3QgUjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw
|
||||
WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz
|
||||
IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjOPQIBBgUrgQQAIgNi
|
||||
AAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout736G
|
||||
jOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL2
|
||||
4CejQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
|
||||
BBTB8Sa6oC2uhYHP0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7
|
||||
VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azTL818+FsuVbu/3ZL3pAzcMeGiAjEA/Jdm
|
||||
ZuVDFhOD3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV11RZt+cRLInUue4X
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
|
||||
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
|
||||
@@ -805,38 +757,6 @@ HQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVLB7rUW44kB/
|
||||
bmF0774BxL4YSFlhgjICICadVGNA3jdgUM/I2O2dgq43mLyjj0xMqTQrbO/7lZsm
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQsw
|
||||
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
|
||||
MBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
|
||||
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
|
||||
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUA
|
||||
A4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3LvCvpt
|
||||
nfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY
|
||||
6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAu
|
||||
MC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7k
|
||||
RXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWg
|
||||
f9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1mKPV
|
||||
+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K8Yzo
|
||||
dDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW
|
||||
Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKa
|
||||
G73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCq
|
||||
gc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwID
|
||||
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
|
||||
FgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBAB/Kzt3H
|
||||
vqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8
|
||||
0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyC
|
||||
B19m3H0Q/gxhswWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2u
|
||||
NmSRXbBoGOqKYcl3qJfEycel/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMg
|
||||
yALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVnjWQye+mew4K6Ki3pHrTgSAai/Gev
|
||||
HyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y59PYjJbigapordwj6
|
||||
xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M7YNR
|
||||
TOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924Sg
|
||||
JPFI/2R80L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV
|
||||
7LXTWtiBmelDGDfrs7vRWGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl
|
||||
6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjWHYbL
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQEL
|
||||
BQAwbzELMAkGA1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJ
|
||||
@@ -1189,31 +1109,6 @@ AHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D5KbvtwEwXlGjefVwaaZB
|
||||
RA+GsCyRxj3qrg+E
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEFTCCAv2gAwIBAgIGSUEs5AAQMA0GCSqGSIb3DQEBCwUAMIGnMQswCQYDVQQG
|
||||
EwJIVTERMA8GA1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3
|
||||
MDUGA1UECwwuVGFuw7pzw610dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNl
|
||||
cnZpY2VzKTE1MDMGA1UEAwwsTmV0TG9jayBBcmFueSAoQ2xhc3MgR29sZCkgRsWR
|
||||
dGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUwODIxWhcNMjgxMjA2MTUwODIxWjCB
|
||||
pzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQKDAxOZXRM
|
||||
b2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAoQ2VydGlm
|
||||
aWNhdGlvbiBTZXJ2aWNlcykxNTAzBgNVBAMMLE5ldExvY2sgQXJhbnkgKENsYXNz
|
||||
IEdvbGQpIEbFkXRhbsO6c8OtdHbDoW55MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
|
||||
MIIBCgKCAQEAxCRec75LbRTDofTjl5Bu0jBFHjzuZ9lk4BqKf8owyoPjIMHj9DrT
|
||||
lF8afFttvzBPhCf2nx9JvMaZCpDyD/V/Q4Q3Y1GLeqVw/HpYzY6b7cNGbIRwXdrz
|
||||
AZAj/E4wqX7hJ2Pn7WQ8oLjJM2P+FpD/sLj916jAwJRDC7bVWaaeVtAkH3B5r9s5
|
||||
VA1lddkVQZQBr17s9o3x/61k/iCa11zr/qYfCGSji3ZVrR47KGAuhyXoqq8fxmRG
|
||||
ILdwfzzeSNuWU7c5d+Qa4scWhHaXWy+7GRWF+GmF9ZmnqfI0p6m2pgP8b4Y9VHx2
|
||||
BJtr+UBdADTHLpl1neWIA6pN+APSQnbAGwIDAKiLo0UwQzASBgNVHRMBAf8ECDAG
|
||||
AQH/AgEEMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUzPpnk/C2uNClwB7zU/2M
|
||||
U9+D15YwDQYJKoZIhvcNAQELBQADggEBAKt/7hwWqZw8UQCgwBEIBaeZ5m8BiFRh
|
||||
bvG5GK1Krf6BQCOUL/t1fC8oS2IkgYIL9WHxHG64YTjrgfpioTtaYtOUZcTh5m2C
|
||||
+C8lcLIhJsFyUR+MLMOEkMNaj7rP9KdlpeuY0fsFskZ1FSNqb4VjMIDw1Z4fKRzC
|
||||
bLBQWV2QWzuoDTDPv31/zvGdg73JRm4gpvlhUbohL3u+pRVjodSVh/GeufOJ8z2F
|
||||
uLjbvrW5KfnaNwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2
|
||||
XjG4Kvte9nHfRCaexOYNkbQudZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBt
|
||||
MQswCQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUg
|
||||
|
||||
@@ -356,6 +356,8 @@ public partial class CoreConfigSingboxService
|
||||
{
|
||||
case EConfigType.WireGuard:
|
||||
{
|
||||
endpoint.server = null;
|
||||
endpoint.server_port = null;
|
||||
endpoint.address = Utils.String2List(protocolExtra.WgInterfaceAddress)?.Select(s => s.Trim()).ToList() ?? ["172.16.0.2/32"];
|
||||
var peer = new Peer4Sbox
|
||||
{
|
||||
|
||||
@@ -67,12 +67,10 @@ public partial class CoreConfigV2rayService
|
||||
|
||||
var address = _config.TunModeItem.IPv4Address.NullIfEmpty() ?? Global.TunIPv4Address.First();
|
||||
tunInbound.settings.gateway = [address];
|
||||
// Route both families into the tunnel regardless of EnableIPv6Address. That option only
|
||||
// controls whether the interface gets an IPv6 address; leaving ::/0 out of the routing
|
||||
// table makes IPv6 follow the system default route and bypass the tunnel entirely.
|
||||
// A host without a global IPv6 address is the exception: it has nothing to leak,
|
||||
// and IPv6 sent into the tunnel would have no way back out.
|
||||
tunInbound.settings.autoSystemRoutingTable = context.HasGlobalIPv6Address
|
||||
// Without ::/0, IPv6 bypasses the tunnel. Skip it only when the host has no global IPv6
|
||||
// address and EnableIPv6Address is off; NAT66 hosts have only ULA addresses.
|
||||
var routeIPv6 = _config.TunModeItem.EnableIPv6Address || context.HasGlobalIPv6Address;
|
||||
tunInbound.settings.autoSystemRoutingTable = routeIPv6
|
||||
? ["0.0.0.0/0", "::/0"]
|
||||
: ["0.0.0.0/0"];
|
||||
if (_config.TunModeItem.EnableIPv6Address == true)
|
||||
@@ -108,7 +106,7 @@ public partial class CoreConfigV2rayService
|
||||
.Where(x => x != null).ToList();
|
||||
|
||||
var includeList = new List<IPNetwork2> { wholeInternet };
|
||||
var includeListV6 = context.HasGlobalIPv6Address
|
||||
var includeListV6 = routeIPv6
|
||||
? new List<IPNetwork2> { wholeInternetV6 }
|
||||
: new List<IPNetwork2>();
|
||||
|
||||
|
||||
@@ -587,6 +587,12 @@ public class SpeedtestService(Config config, Func<SpeedTestResult, Task> updateF
|
||||
private List<List<ServerTestItem>> GetTestBatchItem(List<ServerTestItem> lstSelected, int pageSize)
|
||||
{
|
||||
List<List<ServerTestItem>> lstTest = [];
|
||||
if (lstSelected.Count == 0)
|
||||
{
|
||||
return lstTest;
|
||||
}
|
||||
|
||||
pageSize = Math.Max(pageSize, 1);
|
||||
var lst1 = lstSelected.Where(t => t.CoreType == ECoreType.Xray).ToList();
|
||||
var lst2 = lstSelected.Where(t => t.CoreType == ECoreType.sing_box).ToList();
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ServiceLib.ViewModels;
|
||||
|
||||
public partial class ClashProxiesViewModel : MyReactiveObject
|
||||
{
|
||||
private readonly int _delayTimeout = 99999999;
|
||||
private const int _delayTimeout = 99999999;
|
||||
private ClashItem _clashItem = new();
|
||||
|
||||
public ClashProxiesViewModel()
|
||||
@@ -235,6 +235,11 @@ public partial class ClashProxiesViewModel : MyReactiveObject
|
||||
{
|
||||
return;
|
||||
}
|
||||
var dicCurrentDetails = new Dictionary<string, ClashProxyModel>();
|
||||
foreach (var item in ProxyDetails)
|
||||
{
|
||||
dicCurrentDetails.TryAdd(item.Name, item);
|
||||
}
|
||||
var lstDetails = new List<ClashProxyModel>();
|
||||
foreach (var item in proxy.all)
|
||||
{
|
||||
@@ -243,15 +248,29 @@ public partial class ClashProxiesViewModel : MyReactiveObject
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var delay = proxy2.history?.Count > 0 ? proxy2.history.Last().delay : -1;
|
||||
var delay = -1;
|
||||
var hasData = false;
|
||||
|
||||
if (proxy2.history is { Count: > 0 })
|
||||
{
|
||||
delay = proxy2.history.Last().delay;
|
||||
hasData = true;
|
||||
}
|
||||
else if (dicCurrentDetails.TryGetValue(item, out var detail))
|
||||
{
|
||||
delay = detail.Delay;
|
||||
hasData = true;
|
||||
}
|
||||
|
||||
var isValidDelay = delay is > 0 and not _delayTimeout;
|
||||
|
||||
lstDetails.Add(new ClashProxyModel
|
||||
{
|
||||
IsActive = item == proxy.now,
|
||||
Name = item,
|
||||
Type = proxy2.type,
|
||||
Delay = delay <= 0 ? _delayTimeout : delay,
|
||||
DelayName = delay <= 0 ? string.Empty : $"{delay}ms",
|
||||
Delay = isValidDelay ? delay : _delayTimeout,
|
||||
DelayName = !hasData ? string.Empty : (isValidDelay ? $"{delay}ms" : "-1"),
|
||||
});
|
||||
}
|
||||
// sort
|
||||
@@ -362,8 +381,9 @@ public partial class ClashProxiesViewModel : MyReactiveObject
|
||||
{
|
||||
return;
|
||||
}
|
||||
detail.Delay = Convert.ToInt32(result.Delay);
|
||||
detail.DelayName = $"{detail.Delay}ms";
|
||||
var isValid = int.TryParse(result.Delay, out var delay) && delay > 0 && delay != _delayTimeout;
|
||||
detail.Delay = isValid ? delay : _delayTimeout;
|
||||
detail.DelayName = isValid ? $"{delay}ms" : "-1";
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -527,7 +527,7 @@ public partial class MainWindowViewModel : MyReactiveObject
|
||||
public async Task AddServerViaImageAsync()
|
||||
{
|
||||
var imageFileName = await BrowseImageFileInteraction.HandleSafe(RxVoid.Default);
|
||||
await AddScanResultAsync(imageFileName);
|
||||
await ScanImageResult(imageFileName);
|
||||
}
|
||||
|
||||
public async Task ScanImageResult(string fileName)
|
||||
|
||||
@@ -219,7 +219,8 @@ public partial class StatusBarViewModel : MyReactiveObject
|
||||
await ConfigHandler.InitBuiltinRouting(_config);
|
||||
await RefreshRoutingsMenu();
|
||||
await InboundDisplayStatus();
|
||||
await ChangeSystemProxyAsync(_config.SystemProxyItem.SysProxyType, true);
|
||||
// The view refreshes its icon on initialization; interaction handlers are not registered yet.
|
||||
await ChangeSystemProxyAsync(_config.SystemProxyItem.SysProxyType, false);
|
||||
|
||||
BlRouting = true;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel>
|
||||
<StackPanel Grid.IsSharedSizeScope="True">
|
||||
<Border
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
@@ -32,8 +32,14 @@
|
||||
|
||||
<Grid Margin="{StaticResource Margin4}" RowDefinitions="Auto,Auto,Auto,Auto">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" MinWidth="300" />
|
||||
<ColumnDefinition Width="Auto" MinWidth="200" />
|
||||
<ColumnDefinition
|
||||
Width="Auto"
|
||||
MinWidth="300"
|
||||
SharedSizeGroup="BackupLabel" />
|
||||
<ColumnDefinition
|
||||
Width="Auto"
|
||||
MinWidth="200"
|
||||
SharedSizeGroup="BackupButton" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
@@ -82,8 +88,14 @@
|
||||
Theme="{DynamicResource CardBorder}">
|
||||
<Grid Margin="{StaticResource Margin4}" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" MinWidth="300" />
|
||||
<ColumnDefinition Width="Auto" MinWidth="200" />
|
||||
<ColumnDefinition
|
||||
Width="Auto"
|
||||
MinWidth="300"
|
||||
SharedSizeGroup="BackupLabel" />
|
||||
<ColumnDefinition
|
||||
Width="Auto"
|
||||
MinWidth="200"
|
||||
SharedSizeGroup="BackupButton" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
|
||||
@@ -75,22 +75,23 @@
|
||||
VerticalAlignment="Center"
|
||||
IsChecked="{Binding IsSelected}" />
|
||||
|
||||
<StackPanel
|
||||
<DockPanel
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource Margin4}"
|
||||
IsVisible="{Binding ShowCheckPreRelease}"
|
||||
Orientation="Horizontal">
|
||||
Margin="{StaticResource MarginLr4}"
|
||||
IsVisible="{Binding ShowCheckPreRelease}">
|
||||
<ToggleSwitch
|
||||
Margin="{StaticResource Margin4}"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
IsChecked="{Binding IsCheckPreRelease}"
|
||||
OffContent=""
|
||||
OnContent="" />
|
||||
<TextBlock
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Static resx:ResUI.TbSettingsEnableCheckPreReleaseUpdate}" />
|
||||
</StackPanel>
|
||||
Text="{x:Static resx:ResUI.TbSettingsEnableCheckPreReleaseUpdate}"
|
||||
TextWrapping="Wrap" />
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="2"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
/.packages/
|
||||
/.NET/*
|
||||
!/.NET/.gitignore
|
||||
/TestResults/
|
||||
.env
|
||||
/bin/
|
||||
/obj/
|
||||
/publish/
|
||||
/core-bin/
|
||||
/Tests/bin/
|
||||
/Tests/obj/
|
||||
/Tests/TestResults/
|
||||
@@ -0,0 +1,9 @@
|
||||
using ServiceLib.Base;
|
||||
|
||||
namespace v2rayN.WebAPI.Adapters;
|
||||
|
||||
public sealed class HeadlessWindowDialog : IWindowDialog
|
||||
{
|
||||
public Task<bool> ShowDialogAsync<TViewModel>(TViewModel vm)
|
||||
where TViewModel : class => Task.FromResult(false);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using v2rayN.WebAPI.Contracts;
|
||||
|
||||
namespace v2rayN.WebAPI.Api;
|
||||
|
||||
public static class ApiReplies
|
||||
{
|
||||
public static IResult Ok<T>(T data, string messageKey = ApiMessageKeys.CommonLoaded) =>
|
||||
Results.Ok(ApiEnvelope<T>.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<object>.Fail(code, messageKey), statusCode: StatusCodes.Status404NotFound);
|
||||
|
||||
public static IResult Conflict(string code, string messageKey, object? data = null) =>
|
||||
Results.Json(ApiEnvelope<object>.Fail(code, messageKey, data), statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RulesItem> 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<bool> 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<object>.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized),
|
||||
context.RequestAborted);
|
||||
}
|
||||
}
|
||||
@@ -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<object>.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<object>.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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, string> 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<string, string> 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<string, string>(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<string, string> Merge(
|
||||
IReadOnlyDictionary<string, string> fileValues,
|
||||
IReadOnlyDictionary<string, string?> processEnvironment)
|
||||
{
|
||||
var merged = new Dictionary<string, string>(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<string, string?> ReadProcessEnvironment()
|
||||
{
|
||||
var values = new Dictionary<string, string?>(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}.");
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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<CoreTypeMapping> 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<string, string[]> 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<CoreUpdateTargetView> Targets,
|
||||
bool GeoFilesSelected,
|
||||
bool PreRelease,
|
||||
bool UseProxy,
|
||||
IReadOnlyList<string>? 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<CoreTypeMapping> Mappings);
|
||||
|
||||
public sealed record SettingsApplyInput(
|
||||
InboundSettingsInput Inbound,
|
||||
CoreSettingsInput Core,
|
||||
AppSettingsInput Application,
|
||||
SpeedTestSettingsInput SpeedTest,
|
||||
IReadOnlyList<CoreTypeMapping> CoreTypes,
|
||||
string? DomainStrategy,
|
||||
string? DomainStrategy4Singbox);
|
||||
|
||||
public sealed record ApiEnvelope<T>(bool Success, string Code, string MessageKey, T? Data)
|
||||
{
|
||||
public static ApiEnvelope<T> Ok(T? data, string messageKey = ApiMessageKeys.CommonLoaded) => new(true, "ok", messageKey, data);
|
||||
|
||||
public static ApiEnvelope<T> 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<LogView> Items, int Page, int PageSize, int Total, int TotalPages);
|
||||
|
||||
public sealed record WebEvent(string Type, object? Data, DateTimeOffset Timestamp);
|
||||
@@ -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",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
V2RAYN_WEB_API_KEY=
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"}}
|
||||
@@ -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"}}
|
||||
@@ -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"}}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> hostArguments,
|
||||
Func<string, bool>? fileExists = null,
|
||||
Func<string, bool>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<int> RunAsync(string planPath)
|
||||
{
|
||||
var executable = Environment.ProcessPath;
|
||||
try
|
||||
{
|
||||
return await RunCoreAsync(planPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ScheduleWindowsHelperCleanup(executable);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> RunCoreAsync(string planPath)
|
||||
{
|
||||
NativeWebUpdatePlan? plan = null;
|
||||
try
|
||||
{
|
||||
plan = JsonSerializer.Deserialize<NativeWebUpdatePlan>(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<WebUpdatePackageIdentity>(
|
||||
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<bool> 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<RuntimeRestartIntent>(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<bool> 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<bool> 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);
|
||||
}
|
||||
@@ -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<NativeWebUpdateTransactionResult> ApplyAsync(
|
||||
Func<Task> installCandidateAsync,
|
||||
Func<Task<bool>> startCandidateAndVerifyHealthAsync,
|
||||
Func<Task> restorePreviousFilesAsync,
|
||||
Func<Task<bool>> startPreviousAndVerifyHealthAsync,
|
||||
Action<string> setPhase,
|
||||
Func<Task> 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<NativeWebUpdateTransactionResult> TryRestoreAndVerifyAsync(
|
||||
Func<Task> restorePreviousFilesAsync,
|
||||
Func<Task<bool>> startPreviousAndVerifyHealthAsync,
|
||||
Func<Task> cleanBackupsAsync,
|
||||
Action<string> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, string?> environment)
|
||||
{
|
||||
return HasValue(environment, "INVOCATION_ID")
|
||||
|| HasValue(environment, "JOURNAL_STREAM")
|
||||
|| HasValue(environment, "NOTIFY_SOCKET");
|
||||
}
|
||||
|
||||
public static bool IsContainerEnvironment(IReadOnlyDictionary<string, string?> 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<string, string?> environment) =>
|
||||
HasValue(environment, InteractiveLaunchEnvironmentVariable);
|
||||
|
||||
/// <summary>
|
||||
/// True when this process runs with an interactive terminal attached or the
|
||||
/// launch chain was marked as interactive by the process that had one.
|
||||
/// </summary>
|
||||
public static bool IsInteractiveLaunch(IReadOnlyDictionary<string, string?> environment) =>
|
||||
!Console.IsInputRedirected || IsInteractiveLaunchMarker(environment);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static bool IsSystemdManagedDeployment()
|
||||
{
|
||||
if (WebStopper.IsManagedBySystemd(Environment.ProcessId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var environment = ReadCurrentEnvironment();
|
||||
return IsDaemonEnvironment(environment) && !IsInteractiveLaunch(environment);
|
||||
}
|
||||
|
||||
public static Dictionary<string, string?> ReadCurrentEnvironment() =>
|
||||
Environment.GetEnvironmentVariables()
|
||||
.Cast<System.Collections.DictionaryEntry>()
|
||||
.ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static bool HasValue(IReadOnlyDictionary<string, string?> 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<string, string> 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}");
|
||||
}
|
||||
}
|
||||
@@ -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<WebHealthProbeResult> ProbeAsync(Uri healthUri, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record WebHealthProbeResult(
|
||||
bool IsHealthy,
|
||||
int? InstanceProcessId,
|
||||
string? ShutdownStage = null,
|
||||
IReadOnlyList<int>? 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<WebHealthProbeResult> 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<ExistingInstanceResult> 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<int> 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();
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Net;
|
||||
using v2rayN.WebAPI.Security;
|
||||
|
||||
namespace v2rayN.WebAPI.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="IsProbeableLoopbackUri"/>.
|
||||
/// </summary>
|
||||
public static class WebProbeUriResolver
|
||||
{
|
||||
public const string HealthPath = "/api/health";
|
||||
|
||||
public sealed record WebProbeEndpoints(Uri HealthUri, Uri ApiUri);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static bool IsProbeableLoopbackUri(string? value) =>
|
||||
Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& uri.Scheme == Uri.UriSchemeHttp
|
||||
&& WebSetupAccessPolicy.IsLocalHost(uri.Host);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<string> 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<string> hostArguments)
|
||||
{
|
||||
if ((!isLinux && !isWindows)
|
||||
|| string.IsNullOrWhiteSpace(processPath)
|
||||
|| (isLinux && string.IsNullOrWhiteSpace(setsidPath)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var arguments = new List<string>();
|
||||
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<TimeSpan, CancellationToken, Task> _delay;
|
||||
|
||||
public NativeWebRestartCoordinator(
|
||||
IWebHealthProbe healthProbe,
|
||||
IWebReplacementProcessStarter processStarter,
|
||||
IWebInstanceOwnershipProbe ownershipProbe,
|
||||
TimeSpan? timeout = null,
|
||||
TimeSpan? pollInterval = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? 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<WebRestartHandoffResult> 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;
|
||||
}
|
||||
@@ -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<WebStopResult> 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<int>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<int> 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<WebHostRunResult> 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<EventHub>();
|
||||
builder.Services.AddSingleton<LogBuffer>();
|
||||
builder.Services.AddSingleton<RuntimeOperationCoordinator>();
|
||||
builder.Services.AddSingleton<V2rayRuntime>();
|
||||
builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = RuntimeShutdownBudgets.HostShutdown);
|
||||
builder.Services.AddHostedService<V2rayHostedService>(services =>
|
||||
new V2rayHostedService(
|
||||
services.GetRequiredService<V2rayRuntime>(),
|
||||
services.GetRequiredService<WebAuthService>()));
|
||||
builder.Services.AddSingleton(webAuth);
|
||||
builder.Services.AddSingleton<WebSessionService>();
|
||||
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<V2rayRuntime>();
|
||||
app.UseMiddleware<WebSecurityHeadersMiddleware>();
|
||||
app.UseMiddleware<WebAuthRequestBodyLimitMiddleware>();
|
||||
app.UseRouting();
|
||||
app.UseMiddleware<WebOriginGuardMiddleware>();
|
||||
app.UseCors(WebCorsPolicy.PolicyName); // OPTIONS must complete before session auth.
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseMiddleware<WebSessionAuthenticationMiddleware>();
|
||||
|
||||
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<object>.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<object>.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<object>.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<RuntimeOperationCoordinator>();
|
||||
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<object>.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<string, string?> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("v2rayN.WebAPI.Tests")]
|
||||
@@ -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 <session-token>` 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=<your-management-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 <previous-publish-dir> <candidate-publish-dir>`
|
||||
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 <native-publish-directory>` 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.
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "Usage: $0 <linux-x64|linux-arm64|linux-riscv64|linux-loongarch64> <destination-bin-directory>" >&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"
|
||||
@@ -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"
|
||||
Executable
+55
@@ -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"
|
||||
+269
@@ -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:])
|
||||
+336
@@ -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 <sha> \
|
||||
--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<tag>[^/]+)/(?P<asset>[^/]+)$"
|
||||
)
|
||||
|
||||
|
||||
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:])
|
||||
@@ -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<string> 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()));
|
||||
}
|
||||
@@ -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<IHttpMaxRequestBodySizeFeature>();
|
||||
if (requestBodySizeFeature is { IsReadOnly: false })
|
||||
{
|
||||
requestBodySizeFeature.MaxRequestBodySize = MaximumRequestBodyBytes;
|
||||
}
|
||||
|
||||
if (context.Request.ContentLength > MaximumRequestBodyBytes)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
|
||||
await context.Response.WriteAsJsonAsync(
|
||||
ApiEnvelope<object>.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");
|
||||
}
|
||||
@@ -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<WebSetupResult> 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<StoredWebAuth>(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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string> _origins;
|
||||
|
||||
public WebCorsPolicy(string? configuration)
|
||||
{
|
||||
_origins = new HashSet<string>(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<object>.Fail("origin_not_allowed", ApiMessageKeys.CommonInvalidInput));
|
||||
return;
|
||||
}
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
|
||||
namespace v2rayN.WebAPI.Security;
|
||||
|
||||
/// <summary>Checks server binding configuration, never request Host/forwarding headers.</summary>
|
||||
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<string> 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<object>.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<object>.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -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<string, SessionEntry> _sessions = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, SseTicketEntry> _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<KeyValuePair<string, SessionEntry>>)_sessions)
|
||||
.Remove(new KeyValuePair<string, SessionEntry>(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);
|
||||
@@ -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));
|
||||
|
||||
/// <summary>
|
||||
/// The loopback peer + local Host pair required for per-instance health diagnostics.
|
||||
/// The update/launcher probe rules (see WebProbeUriResolver) target exactly this pair.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,81 @@
|
||||
using SQLite;
|
||||
|
||||
namespace v2rayN.WebAPI.Services;
|
||||
|
||||
internal static class BackupDatabaseCompatibility
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> RequiredPrimaryKeys = new Dictionary<string, string>(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<string>("PRAGMA quick_check;"), "ok", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = "SQLite quick_check did not return ok.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var tableNames = database.Query<TableName>(
|
||||
"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<TableColumn>($"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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using v2rayN.WebAPI.Contracts;
|
||||
using v2rayN.WebAPI.Launcher;
|
||||
|
||||
namespace v2rayN.WebAPI.Services;
|
||||
|
||||
internal static class BackupRestorePreflightGate
|
||||
{
|
||||
public static Task<OperationView> ExecuteAsync(
|
||||
NativeWebRestorePreflightResult preflight,
|
||||
Func<Task<OperationView>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Task<IReadOnlyList<DNSItem>>> load,
|
||||
Func<Config, DNSItem, Task<int>> 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<CoreLaunchPreflightResult> BuildAndValidateAsync(
|
||||
Config config,
|
||||
ProfileItem profile,
|
||||
Func<ECoreType, OperationView?> validateCoreBinary)
|
||||
{
|
||||
var built = await CoreConfigContextBuilder.BuildAll(config, profile);
|
||||
return Validate(profile, built, validateCoreBinary);
|
||||
}
|
||||
|
||||
internal static CoreLaunchPreflightResult Validate(
|
||||
ProfileItem profile,
|
||||
CoreConfigContextBuilderAllResult built,
|
||||
Func<ECoreType, OperationView?> 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<OperationView> ExecuteAsync(
|
||||
Func<Task<CoreLaunchPreflightResult>> buildAndValidate,
|
||||
Func<Task> stopCore,
|
||||
Action markCoreStopped,
|
||||
Func<CoreLaunchPreflightResult, Task<OperationView>> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class CoreProcessTracker
|
||||
{
|
||||
public static HashSet<int> CaptureExistingProcessIds()
|
||||
{
|
||||
var ids = new HashSet<int>();
|
||||
foreach (var process in Process.GetProcesses())
|
||||
{
|
||||
using (process)
|
||||
{
|
||||
try { ids.Add(process.Id); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
public static CoreProcessIdentity[] CaptureStartedProcesses(
|
||||
IReadOnlySet<int> existingProcessIds,
|
||||
IEnumerable<string> expectedExecutablePaths,
|
||||
IEnumerable<string>? 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<CoreProcessIdentity>();
|
||||
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<CoreProcessIdentity> identities)
|
||||
{
|
||||
var active = new List<CoreProcessIdentity>();
|
||||
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<string> expectedPaths,
|
||||
HashSet<string>? 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<string> arguments, HashSet<string> 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<string> GetShebangInterpreters(string executablePath)
|
||||
{
|
||||
var interpreters = new HashSet<string>(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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable description of the Core process that was actually launched. Config changes
|
||||
/// never mutate this snapshot; only confirmed runtime transitions replace it.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -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<CoreSettingsApplyExecution> ExecuteAsync(
|
||||
CoreRuntimeState state,
|
||||
bool hasActiveChild,
|
||||
bool changed,
|
||||
Func<Task<OperationView>> restart)
|
||||
{
|
||||
var action = CoreSettingsApplyPolicy.Decide(state, hasActiveChild, changed);
|
||||
var restartResult = action == CoreSettingsApplyAction.Restart ? await restart() : null;
|
||||
return new(action, restartResult);
|
||||
}
|
||||
}
|
||||
@@ -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<string>(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<string>(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<string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Guid, EventSubscriber> _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<WebEvent> Subscribe(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default,
|
||||
bool includeLogs = false)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var channel = Channel.CreateBounded<WebEvent>(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<WebEvent> Channel, bool IncludeLogs);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace v2rayN.WebAPI.Services;
|
||||
|
||||
internal sealed class GeoFilesUpdateCompletion
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace v2rayN.WebAPI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes every Web-owned GeoFiles transaction, regardless of whether it was started
|
||||
/// manually, as part of a batch update, or by a regional preset.
|
||||
/// </summary>
|
||||
internal sealed class GeoFilesUpdateGate
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public async Task RunAsync(Func<Task> update, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await update();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
namespace v2rayN.WebAPI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class GeoFilesUpdateTransaction
|
||||
{
|
||||
public static async Task ApplyAsync(
|
||||
IEnumerable<string> managedFiles,
|
||||
IEnumerable<string> requiredFiles,
|
||||
Func<CancellationToken, Task> update,
|
||||
CancellationToken cancellationToken,
|
||||
Func<Func<Task>, 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<string, GeoFileBackup>(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;
|
||||
}
|
||||
@@ -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<LogView> _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<LogView> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RoutingItem> Items,
|
||||
string? DefaultRemarks = null,
|
||||
IReadOnlyList<string>? 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<DNSItem> DnsItems,
|
||||
RegionalRoutingPlan Routing);
|
||||
|
||||
internal sealed class RegionalPresetStager(
|
||||
Func<string, IWebProxy?, CancellationToken, Task<string?>> download,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
|
||||
private readonly TimeSpan _timeout = timeout ?? DefaultTimeout;
|
||||
|
||||
public async Task<RegionalPresetStage> StageAsync(
|
||||
EPresetType preset,
|
||||
IReadOnlyList<DNSItem> existingDnsItems,
|
||||
IReadOnlyList<RoutingItem> 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<DNSItem> 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<DNSItem>(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<string?>(null)
|
||||
: TryDownloadAsync(template.NormalDNS, proxy, cancellationToken);
|
||||
var tunDnsTask = string.IsNullOrWhiteSpace(template.TunDNS)
|
||||
? Task.FromResult<string?>(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<SimpleDNSItem>(content);
|
||||
return template is null
|
||||
? (ConfigHandler.InitBuiltinSimpleDNS(), true)
|
||||
: (template, false);
|
||||
}
|
||||
|
||||
private async Task<RegionalRoutingPlan> StageRoutingAsync(
|
||||
string url,
|
||||
IReadOnlyList<RoutingItem> existingItems,
|
||||
string? legacyRoutingIndexId,
|
||||
IWebProxy? proxy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var content = await TryDownloadAsync(url, proxy, cancellationToken);
|
||||
var template = content is null ? null : JsonUtils.Deserialize<RoutingTemplate>(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<RoutingItem>().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<RoutingItem?> 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<List<RulesItem>>(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<RoutingItem> 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<string>()
|
||||
: [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<List<RulesItem>>(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<string?> 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<string> 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<TResult> RunAsync<TStage, TResult>(
|
||||
Func<CancellationToken, Task<TStage>> stage,
|
||||
Func<TStage, CancellationToken, Task<TResult>> 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<CancellationToken, Task> applyConfiguration,
|
||||
Func<Func<Func<Task>, CancellationToken, Task>, CancellationToken, Task> updateGeoFilesTransaction,
|
||||
Func<Func<Task>, CancellationToken, Task> completeCoreApply,
|
||||
Func<Task> 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<RegionalPresetApplyResult> RunAsync(Func<Task> apply, Func<Task<bool>> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace v2rayN.WebAPI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class RuntimeMutationGate
|
||||
{
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
public async Task RunAsync(Func<Task> mutation, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mutation);
|
||||
await _semaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await mutation();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T> RunAsync<T>(Func<Task<T>> mutation, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mutation);
|
||||
await _semaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
return await mutation();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
namespace v2rayN.WebAPI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Lease> EnterOperationAsync(CancellationToken cancellationToken = default) =>
|
||||
EnterAsync(exclusive: false, allowReadOnlyObservation: false, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public ValueTask<Lease> EnterObservationAsync(CancellationToken cancellationToken = default) =>
|
||||
EnterAsync(exclusive: false, allowReadOnlyObservation: true, cancellationToken);
|
||||
|
||||
public ValueTask<Lease> EnterExclusiveAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
bool allowReadOnlyObservations = false) =>
|
||||
EnterAsync(exclusive: true, allowReadOnlyObservation: false, cancellationToken, allowReadOnlyObservations);
|
||||
|
||||
private async ValueTask<Lease> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<RuntimeRestartRecoveryResult> RecoverAsync(
|
||||
RuntimeRestartIntent intent,
|
||||
string? selectedProfileId,
|
||||
string? defaultProfileId,
|
||||
Func<string, CancellationToken, Task<bool>> profileExists,
|
||||
Func<string, CancellationToken, Task<OperationView>> startCore,
|
||||
Action<string> 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<RuntimeRestartRecoveryResult> StartAsync(
|
||||
string profileId,
|
||||
string reason,
|
||||
RuntimeRestartRecoverySource source,
|
||||
Func<string, CancellationToken, Task<OperationView>> startCore,
|
||||
Action<string> 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;
|
||||
}
|
||||
@@ -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<ShutdownDeadline, Task<bool>> ExecuteAsync);
|
||||
|
||||
internal static class ShutdownCleanupSequence
|
||||
{
|
||||
public static async Task<bool> RunAsync(
|
||||
IEnumerable<ShutdownCleanupStep> steps,
|
||||
TimeSpan budget,
|
||||
CancellationToken cancellationToken,
|
||||
Action<string> log,
|
||||
Action<string?>? 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<OperationView> 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<OperationView> 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<OperationView> 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<OperationView> 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<OperationView> 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<OperationView> 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<OperationView?> RecoverRuntimeAfterFailedRestoreAsync(
|
||||
RuntimeRestartIntent restoreState,
|
||||
int[] remainingCoreProcessIds,
|
||||
Action<int[], string> markRuntimeFaulted,
|
||||
Func<string?, CancellationToken, Task<OperationView>> startCore,
|
||||
Action<string> 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<RuntimeRestartIntent?> LoadRestoreRuntimeStateAsync(CancellationToken cancellationToken) =>
|
||||
LoadRuntimeRestartIntentAsync(RestoreRuntimeStatePath, cancellationToken);
|
||||
|
||||
internal static async Task<RuntimeRestartIntent?> LoadRuntimeRestartIntentAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
RequeueAbandonedRestoreRuntimeClaim(path);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
return JsonSerializer.Deserialize<RuntimeRestartIntent>(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<RuntimeRestartIntent>(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<RuntimeRestartIntent>(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<string>(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<Config>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<IReadOnlyList<ProfileGroupView>> 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<ProfileGroupView>
|
||||
{
|
||||
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<OperationView> 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<OperationView> 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<OperationView> 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<OperationView> DeleteProfilesAsync(IEnumerable<string> 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<OperationView> CopyProfilesAsync(IEnumerable<string> 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<OperationView> 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<OperationView> 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<OperationView> SortProfilesAsync(SortProfilesRequest request)
|
||||
{
|
||||
var groupId = request.SubscriptionId ?? Config.SubIndexId ?? string.Empty;
|
||||
if (!Enum.TryParse<EServerColName>(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<OperationView> 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<OperationView> 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<OperationView> 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<IReadOnlyList<ProfileExportItem>> ExportProfileDataAsync(ProfileExportRequest request)
|
||||
{
|
||||
var items = await AppManager.Instance.GetProfileItemsOrderedByIndexIds(request.ProfileIds);
|
||||
var output = new List<ProfileExportItem>();
|
||||
|
||||
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<string?> GetSubscriptionShareAsync(string id)
|
||||
{
|
||||
return (await AppManager.Instance.GetSubItem(id))?.Url;
|
||||
}
|
||||
|
||||
public async Task<OperationView> 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<string> GetRunningSubscriptionUpdates()
|
||||
{
|
||||
lock (_subscriptionGate)
|
||||
{
|
||||
return _subscriptionTasks.Where(pair => !pair.Value.IsCompleted).Select(pair => pair.Key).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user