mirror of
https://github.com/2dust/v2rayN.git
synced 2026-10-21 12:44:59 +03:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbdd78739e | |||
| 57f17d5939 | |||
| 66dd7da6e6 | |||
| 5e0ea3275d | |||
| 673283da4b | |||
| 1f083adbe4 | |||
| 80ab14d057 | |||
| 95f438abc9 | |||
| f1023e08e1 | |||
| 1883178b5f | |||
| 1cde521d75 | |||
| 2407d44f4f | |||
| fbf822d74c | |||
| 4869a6cbba | |||
| b501efbc45 | |||
| 5ea8ae6420 | |||
| fa9d4a4769 | |||
| 771ef3192a | |||
| f5747bb321 | |||
| d099e44ff6 | |||
| c63d271d16 | |||
| 6152cab494 | |||
| 3187eeef79 | |||
| 3364ec0e57 | |||
| 23033fa002 | |||
| 125cbb18a1 | |||
| f85326cf0e | |||
| 3666fc549d | |||
| a147249f95 | |||
| a28f68c3a7 | |||
| 1a689f869c | |||
| 50023a5976 | |||
| c659ca8af8 |
@@ -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@v7
|
||||||
|
env:
|
||||||
|
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./v2rayN/v2rayN.WebAPI/Containerfile
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
tags: v2rayn-webapi:ci
|
||||||
|
build-args: |
|
||||||
|
RID=${{ matrix.rid }}
|
||||||
|
WEB_VERSION=${{ needs.identity.outputs.version }}
|
||||||
|
WEB_REPOSITORY=${{ github.repository }}
|
||||||
|
WEB_COMMIT=${{ github.sha }}
|
||||||
|
WEB_BUILD_DATE=${{ needs.identity.outputs.build_date }}
|
||||||
|
push: false
|
||||||
|
|
||||||
|
release-assets:
|
||||||
|
name: Assemble API release assets
|
||||||
|
if: needs.identity.outputs.release == 'true'
|
||||||
|
needs: [identity, verify, windows, windows-arm64, native, native-riscv64, native-loong64, container]
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
pattern: v2rayN.WebAPI-*
|
||||||
|
path: release-assets
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Build and verify WebAPI-update.json
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ needs.identity.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py write \
|
||||||
|
--version "$RELEASE_TAG" \
|
||||||
|
--repository "${{ github.repository }}" \
|
||||||
|
--commit "${{ github.sha }}" \
|
||||||
|
--build-date "${{ needs.identity.outputs.build_date }}" \
|
||||||
|
--dist release-assets \
|
||||||
|
--output release-assets/WebAPI-update.json
|
||||||
|
python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py verify \
|
||||||
|
--manifest release-assets/WebAPI-update.json \
|
||||||
|
--dist release-assets \
|
||||||
|
--repository "${{ github.repository }}"
|
||||||
|
- name: Smoke-test full API ZIP
|
||||||
|
run: bash v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh release-assets/v2rayN-linux-64-WebAPI.zip
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: WebAPI-release-packages
|
||||||
|
path: release-assets/*.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: WebAPI-release-manifest
|
||||||
|
path: release-assets/WebAPI-update.json
|
||||||
|
if-no-files-found: error
|
||||||
@@ -20,6 +20,13 @@ jobs:
|
|||||||
secrets:
|
secrets:
|
||||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
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:
|
update:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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"
|
$wingetPackage = "2dust.v2rayN"
|
||||||
$gitToken = "${{ secrets.PT_WINGET }}"
|
$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
|
$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 {} +
|
find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} +
|
||||||
[[ -f "$stage/opt/v2rayN/v2rayN" ]] && chmod 0755 "$stage/opt/v2rayN/v2rayN" || true
|
[[ -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"
|
deb_out="$OUTPUT_DIR/v2rayn_${VERSION}_${deb_arch}.deb"
|
||||||
dpkg-deb --root-owner-group --build "$stage" "$deb_out"
|
dpkg-deb --root-owner-group --build "$stage" "$deb_out"
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ internal class UpgradeApp
|
|||||||
var thisAppOldFile = $"{Utils.GetExePath()}.tmp";
|
var thisAppOldFile = $"{Utils.GetExePath()}.tmp";
|
||||||
File.Delete(thisAppOldFile);
|
File.Delete(thisAppOldFile);
|
||||||
var splitKey = "/";
|
var splitKey = "/";
|
||||||
|
var allowedBaseDir = Utils.StartupPath();
|
||||||
|
var pathComparison = Utils.GetPathComparison();
|
||||||
|
|
||||||
using var archive = ZipFile.OpenRead(fileName);
|
using var archive = ZipFile.OpenRead(fileName);
|
||||||
foreach (var entry in archive.Entries)
|
foreach (var entry in archive.Entries)
|
||||||
@@ -65,16 +67,21 @@ internal class UpgradeApp
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fullName = string.Join(splitKey, lst[1..lst.Length]);
|
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);
|
File.Move(Utils.GetExePath(), thisAppOldFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
var entryOutputPath = Utils.GetPath(fullName);
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(entryOutputPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(entryOutputPath)!);
|
||||||
//In the bin folder, if the file already exists, it will be skipped
|
//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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,28 @@ internal class Utils
|
|||||||
return Path.Combine(startupPath, fileName);
|
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 string V2rayN => "v2rayN";
|
||||||
|
|
||||||
public static void StartV2RayN()
|
public static void StartV2RayN()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>7.25.4</Version>
|
<Version>7.25.6</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
|
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
|
||||||
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" />
|
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" />
|
||||||
<PackageVersion Include="CliWrap" Version="3.10.5" />
|
<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="H.NotifyIcon.Wpf" Version="2.4.1" />
|
||||||
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
|
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
|
||||||
<PackageVersion Include="QRCoder" Version="1.8.0" />
|
<PackageVersion Include="QRCoder" Version="1.8.0" />
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
<PackageVersion Include="sqlite-net-e" Version="1.11.285" />
|
<PackageVersion Include="sqlite-net-e" Version="1.11.285" />
|
||||||
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
|
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
|
||||||
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
|
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
|
||||||
<PackageVersion Include="TUnit" Version="1.68.17" />
|
<PackageVersion Include="TUnit" Version="1.75.0" />
|
||||||
<PackageVersion Include="TUnit.Assertions.Should" Version="1.65.38-beta" />
|
<PackageVersion Include="TUnit.Assertions.Should" Version="1.72.16-beta" />
|
||||||
<PackageVersion Include="WebDav.Client" Version="2.9.0" />
|
<PackageVersion Include="WebDav.Client" Version="2.9.0" />
|
||||||
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
|
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
|
||||||
<PackageVersion Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.22" />
|
<PackageVersion Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.22" />
|
||||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
using System.Reflection;
|
|
||||||
using ServiceLib.Enums;
|
|
||||||
using ServiceLib.Manager;
|
|
||||||
using ServiceLib.Models;
|
|
||||||
|
|
||||||
namespace ServiceLib.Tests.CoreConfig;
|
namespace ServiceLib.Tests.CoreConfig;
|
||||||
|
|
||||||
internal static class CoreConfigTestFactory
|
internal static class CoreConfigTestFactory
|
||||||
|
|||||||
@@ -716,7 +716,6 @@ public class CoreConfigSingboxServiceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
|
public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class CoreConfigV2rayServiceTests
|
|||||||
.Select(item => item!.GetValue<string>())
|
.Select(item => item!.GetValue<string>())
|
||||||
.Should().BeEquivalentTo(["a=1", "b=2"]);
|
.Should().BeEquivalentTo(["a=1", "b=2"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer()
|
public async Task GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer()
|
||||||
{
|
{
|
||||||
@@ -587,13 +587,11 @@ public class CoreConfigV2rayServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
[Arguments(true)]
|
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6()
|
||||||
[Arguments(false)]
|
|
||||||
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6(bool enableIPv6Address)
|
|
||||||
{
|
{
|
||||||
// A host without a global IPv6 address has no IPv6 traffic that could bypass the tunnel,
|
// 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.
|
// 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);
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
@@ -611,6 +609,26 @@ public class CoreConfigV2rayServiceTests
|
|||||||
await ipv6Routes.Should().BeEmpty();
|
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]
|
[Test]
|
||||||
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldSkipIPv6RangesWithoutGlobalIPv6()
|
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldSkipIPv6RangesWithoutGlobalIPv6()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using ServiceLib.Tests.CoreConfig;
|
||||||
|
|
||||||
|
namespace ServiceLib.Tests.Handler;
|
||||||
|
|
||||||
|
public class ConfigHandlerCustomSubscriptionTests
|
||||||
|
{
|
||||||
|
private const string FullXrayConfigs = """
|
||||||
|
[
|
||||||
|
{"remarks":"custom-1","inbounds":[{"tag":"socks","listen":"127.0.0.1","port":10808,"protocol":"socks","settings":{"auth":"noauth","udp":true}}],"outbounds":[{"tag":"proxy","protocol":"vless","settings":{"vnext":[{"address":"127.0.0.1","port":443,"users":[{"id":"00000000-0000-0000-0000-000000000001","encryption":"none"}]}]},"streamSettings":{"network":"tcp"}}],"routing":{"domainStrategy":"AsIs","rules":[]}},
|
||||||
|
{"remarks":"custom-2","inbounds":[{"tag":"socks","listen":"127.0.0.1","port":10808,"protocol":"socks","settings":{"auth":"noauth","udp":true}}],"outbounds":[{"tag":"proxy","protocol":"vless","settings":{"vnext":[{"address":"127.0.0.1","port":443,"users":[{"id":"00000000-0000-0000-0000-000000000001","encryption":"none"}]}]},"streamSettings":{"network":"tcp"}}],"routing":{"domainStrategy":"AsIs","rules":[]}}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task AddBatchServers_FullConfigsFromSubscription_InheritSubscriptionPreSocksPort()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig();
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
SQLiteHelper.Instance.CreateTable<SubItem>();
|
||||||
|
SQLiteHelper.Instance.CreateTable<ProfileItem>();
|
||||||
|
|
||||||
|
var subId = $"sub-{Guid.NewGuid():N}";
|
||||||
|
await SQLiteHelper.Instance.ReplaceAsync(new SubItem
|
||||||
|
{
|
||||||
|
Id = subId,
|
||||||
|
Remarks = "sub",
|
||||||
|
Url = "http://127.0.0.1/sub",
|
||||||
|
PreSocksPort = 10808,
|
||||||
|
});
|
||||||
|
|
||||||
|
var count = await ConfigHandler.AddBatchServers(config, FullXrayConfigs, subId, true);
|
||||||
|
var profiles = await AppManager.Instance.ProfileItems(subId) ?? [];
|
||||||
|
|
||||||
|
await count.Should().BeEqualTo(2);
|
||||||
|
await profiles.Should().HaveCount(2);
|
||||||
|
foreach (var profile in profiles)
|
||||||
|
{
|
||||||
|
await profile.ConfigType.Should().BeEqualTo(EConfigType.Custom);
|
||||||
|
await profile.PreSocksPort.Should().BeEqualTo(10808);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,9 +43,9 @@ public class McBeService : IUdpTest
|
|||||||
{
|
{
|
||||||
// 0x1c | client alive time in ms (recorded from previous ping) |
|
// 0x1c | client alive time in ms (recorded from previous ping) |
|
||||||
// server GUID | Magic | string length | Edition
|
// server GUID | Magic | string length | Edition
|
||||||
//
|
//
|
||||||
// Edition Example:
|
// Edition Example:
|
||||||
//
|
//
|
||||||
// MCPE;Dedicated Server;527;1.19.1;0;10;13253860892328930865;Bedrock level;Survival;1;19132;19133;
|
// MCPE;Dedicated Server;527;1.19.1;0;10;13253860892328930865;Bedrock level;Survival;1;19132;19133;
|
||||||
if (mcbeResponseBytes.Length < 48)
|
if (mcbeResponseBytes.Length < 48)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -501,6 +501,31 @@ public class Utils
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static List<(string, string)> ParseHeaders(string? headers)
|
||||||
|
{
|
||||||
|
var result = new List<(string, string)>();
|
||||||
|
if (headers.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
var lines = headers.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var parts = line.Split(new[] { ':' }, 2);
|
||||||
|
if (parts.Length != 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var key = parts[0].Trim();
|
||||||
|
var value = parts[1].Trim();
|
||||||
|
if (!string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
result.Add((key, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
#endregion Conversion Functions
|
#endregion Conversion Functions
|
||||||
|
|
||||||
#region Data Checks
|
#region Data Checks
|
||||||
@@ -1416,7 +1441,11 @@ public class Utils
|
|||||||
if (File.Exists(fileName))
|
if (File.Exists(fileName))
|
||||||
{
|
{
|
||||||
var currentMode = File.GetUnixFileMode(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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public enum EConfigType
|
|||||||
Anytls = 11,
|
Anytls = 11,
|
||||||
Naive = 12,
|
Naive = 12,
|
||||||
Outbound = 13,
|
Outbound = 13,
|
||||||
|
MASQUE = 14,
|
||||||
PolicyGroup = 101,
|
PolicyGroup = 101,
|
||||||
ProxyChain = 102,
|
ProxyChain = 102,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,7 +261,8 @@ public class Global
|
|||||||
{ EConfigType.TUIC, "tuic" },
|
{ EConfigType.TUIC, "tuic" },
|
||||||
{ EConfigType.WireGuard, "wireguard" },
|
{ EConfigType.WireGuard, "wireguard" },
|
||||||
{ EConfigType.Anytls, "anytls" },
|
{ EConfigType.Anytls, "anytls" },
|
||||||
{ EConfigType.Naive, "naive" }
|
{ EConfigType.Naive, "naive" },
|
||||||
|
{ EConfigType.MASQUE, "masque" },
|
||||||
};
|
};
|
||||||
|
|
||||||
public static readonly List<string> VmessSecurities =
|
public static readonly List<string> VmessSecurities =
|
||||||
@@ -371,6 +372,7 @@ public class Global
|
|||||||
EConfigType.Trojan,
|
EConfigType.Trojan,
|
||||||
EConfigType.Hysteria2,
|
EConfigType.Hysteria2,
|
||||||
EConfigType.WireGuard,
|
EConfigType.WireGuard,
|
||||||
|
EConfigType.MASQUE,
|
||||||
EConfigType.SOCKS,
|
EConfigType.SOCKS,
|
||||||
EConfigType.HTTP,
|
EConfigType.HTTP,
|
||||||
];
|
];
|
||||||
@@ -386,6 +388,7 @@ public class Global
|
|||||||
EConfigType.Anytls,
|
EConfigType.Anytls,
|
||||||
EConfigType.Naive,
|
EConfigType.Naive,
|
||||||
EConfigType.WireGuard,
|
EConfigType.WireGuard,
|
||||||
|
EConfigType.MASQUE,
|
||||||
EConfigType.SOCKS,
|
EConfigType.SOCKS,
|
||||||
EConfigType.HTTP,
|
EConfigType.HTTP,
|
||||||
];
|
];
|
||||||
@@ -653,10 +656,10 @@ public class Global
|
|||||||
{ ECoreType.Xray, "XTLS/Xray-core" },
|
{ ECoreType.Xray, "XTLS/Xray-core" },
|
||||||
{ ECoreType.sing_box, "SagerNet/sing-box" },
|
{ ECoreType.sing_box, "SagerNet/sing-box" },
|
||||||
{ ECoreType.mihomo, "MetaCubeX/mihomo" },
|
{ ECoreType.mihomo, "MetaCubeX/mihomo" },
|
||||||
{ ECoreType.hysteria, "apernet/hysteria" },
|
{ ECoreType.hysteria, "HyNetworks/hysteria" },
|
||||||
{ ECoreType.hysteria2, "apernet/hysteria" },
|
{ ECoreType.hysteria2, "HyNetworks/hysteria" },
|
||||||
{ ECoreType.naiveproxy, "klzgrad/naiveproxy" },
|
{ ECoreType.naiveproxy, "klzgrad/naiveproxy" },
|
||||||
{ ECoreType.tuic, "EAimTY/tuic" },
|
{ ECoreType.tuic, "tuic-protocol/tuic" },
|
||||||
{ ECoreType.juicity, "juicity/juicity" },
|
{ ECoreType.juicity, "juicity/juicity" },
|
||||||
{ ECoreType.brook, "txthinking/brook" },
|
{ ECoreType.brook, "txthinking/brook" },
|
||||||
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
|
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public class CoreConfigContextBuilder
|
|||||||
RoutingItem = await ConfigHandler.GetDefaultRouting(config),
|
RoutingItem = await ConfigHandler.GetDefaultRouting(config),
|
||||||
IsWindows = Utils.IsWindows(),
|
IsWindows = Utils.IsWindows(),
|
||||||
IsMacOS = Utils.IsMacOS(),
|
IsMacOS = Utils.IsMacOS(),
|
||||||
|
IsLinux = Utils.IsLinux(),
|
||||||
HasGlobalIPv6Address = Utils.HasGlobalIPv6Address(),
|
HasGlobalIPv6Address = Utils.HasGlobalIPv6Address(),
|
||||||
ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : []
|
ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ public static class ConfigHandler
|
|||||||
EConfigType.WireGuard => await AddWireguardServer(config, item),
|
EConfigType.WireGuard => await AddWireguardServer(config, item),
|
||||||
EConfigType.Anytls => await AddAnytlsServer(config, item),
|
EConfigType.Anytls => await AddAnytlsServer(config, item),
|
||||||
EConfigType.Naive => await AddNaiveServer(config, item),
|
EConfigType.Naive => await AddNaiveServer(config, item),
|
||||||
|
EConfigType.MASQUE => await AddMasqueServer(config, item),
|
||||||
_ => -1,
|
_ => -1,
|
||||||
};
|
};
|
||||||
return ret;
|
return ret;
|
||||||
@@ -992,6 +993,29 @@ public static class ConfigHandler
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task<int> AddMasqueServer(Config config, ProfileItem profileItem, bool toFile = true)
|
||||||
|
{
|
||||||
|
profileItem.ConfigType = EConfigType.MASQUE;
|
||||||
|
|
||||||
|
profileItem.Address = profileItem.Address.TrimEx();
|
||||||
|
profileItem.Username = profileItem.Username.TrimEx();
|
||||||
|
profileItem.Password = profileItem.Password.TrimEx();
|
||||||
|
profileItem.Fingerprint = string.Empty;
|
||||||
|
profileItem.Alpn = string.Empty;
|
||||||
|
profileItem.Network = string.Empty;
|
||||||
|
profileItem.AllowInsecure = string.Empty;
|
||||||
|
if (profileItem.StreamSecurity.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
profileItem.StreamSecurity = Global.StreamSecurity;
|
||||||
|
}
|
||||||
|
if (profileItem.Password.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
await AddServerCommon(config, profileItem, toFile);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sort the server list by the specified column
|
/// Sort the server list by the specified column
|
||||||
/// Updates the sort order in the profile extension data
|
/// Updates the sort order in the profile extension data
|
||||||
@@ -1684,6 +1708,7 @@ public static class ConfigHandler
|
|||||||
EConfigType.WireGuard => await AddWireguardServer(config, profileItem, false),
|
EConfigType.WireGuard => await AddWireguardServer(config, profileItem, false),
|
||||||
EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false),
|
EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false),
|
||||||
EConfigType.Naive => await AddNaiveServer(config, profileItem, false),
|
EConfigType.Naive => await AddNaiveServer(config, profileItem, false),
|
||||||
|
EConfigType.MASQUE => await AddMasqueServer(config, profileItem, false),
|
||||||
_ => -1,
|
_ => -1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1753,7 +1778,7 @@ public static class ConfigHandler
|
|||||||
}
|
}
|
||||||
if (lstProfiles.Count > 0)
|
if (lstProfiles.Count > 0)
|
||||||
{
|
{
|
||||||
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
|
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub, subItem);
|
||||||
if (count > 0)
|
if (count > 0)
|
||||||
{
|
{
|
||||||
return count;
|
return count;
|
||||||
@@ -1799,7 +1824,7 @@ public static class ConfigHandler
|
|||||||
|
|
||||||
if (lstProfiles?.Count > 0)
|
if (lstProfiles?.Count > 0)
|
||||||
{
|
{
|
||||||
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
|
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub, subItem);
|
||||||
if (count > 0)
|
if (count > 0)
|
||||||
{
|
{
|
||||||
return count;
|
return count;
|
||||||
@@ -1813,7 +1838,8 @@ public static class ConfigHandler
|
|||||||
Config config,
|
Config config,
|
||||||
List<ProfileItem> lstProfiles,
|
List<ProfileItem> lstProfiles,
|
||||||
string subid,
|
string subid,
|
||||||
bool isSub)
|
bool isSub,
|
||||||
|
SubItem? subItem)
|
||||||
{
|
{
|
||||||
var count = 0;
|
var count = 0;
|
||||||
foreach (var it in lstProfiles)
|
foreach (var it in lstProfiles)
|
||||||
@@ -1823,6 +1849,7 @@ public static class ConfigHandler
|
|||||||
|
|
||||||
if (it.ConfigType == EConfigType.Custom)
|
if (it.ConfigType == EConfigType.Custom)
|
||||||
{
|
{
|
||||||
|
it.PreSocksPort = subItem?.PreSocksPort;
|
||||||
if (await AddCustomServer(config, it, true) == 0)
|
if (await AddCustomServer(config, it, true) == 0)
|
||||||
{
|
{
|
||||||
count++;
|
count++;
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ public sealed class CoreInfoManager
|
|||||||
DownloadUrlLinuxLoong64 = urlSingbox + "/download/{0}/sing-box-{1}-linux-loong64.tar.gz",
|
DownloadUrlLinuxLoong64 = urlSingbox + "/download/{0}/sing-box-{1}-linux-loong64.tar.gz",
|
||||||
DownloadUrlOSX64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-amd64.tar.gz",
|
DownloadUrlOSX64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-amd64.tar.gz",
|
||||||
DownloadUrlOSXArm64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-arm64.tar.gz",
|
DownloadUrlOSXArm64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-arm64.tar.gz",
|
||||||
LockedMaxVersion = new SemanticVersion(1, 14, int.MaxValue),
|
//LockedMaxVersion = new SemanticVersion(1, 14, int.MaxValue),
|
||||||
Match = "sing-box",
|
Match = "sing-box",
|
||||||
VersionArg = "version",
|
VersionArg = "version",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ public class ProfileExManager
|
|||||||
{
|
{
|
||||||
private static readonly Lazy<ProfileExManager> _instance = new(() => new());
|
private static readonly Lazy<ProfileExManager> _instance = new(() => new());
|
||||||
private ConcurrentBag<ProfileExItem> _lstProfileEx = [];
|
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;
|
public static ProfileExManager Instance => _instance.Value;
|
||||||
private static readonly string _tag = "ProfileExHandler";
|
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 )");
|
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)
|
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);
|
_queIndexIds.Enqueue(indexId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveQueueIndexIds()
|
private async Task SaveQueueIndexIds()
|
||||||
{
|
{
|
||||||
var cnt = _queIndexIds.Count;
|
var lstIds = new List<string>();
|
||||||
if (cnt > 0)
|
while (_queIndexIds.TryDequeue(out var id))
|
||||||
|
{
|
||||||
|
lstIds.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lstIds.Count > 0)
|
||||||
{
|
{
|
||||||
var lstExists = await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync();
|
var lstExists = await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync();
|
||||||
List<ProfileExItem> lstInserts = [];
|
List<ProfileExItem> lstInserts = [];
|
||||||
List<ProfileExItem> lstUpdates = [];
|
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 item = lstExists.FirstOrDefault(t => t.IndexId == id);
|
||||||
var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id);
|
var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id);
|
||||||
if (itemNew is null)
|
if (itemNew is null)
|
||||||
@@ -103,13 +114,26 @@ public class ProfileExManager
|
|||||||
|
|
||||||
private ProfileExItem GetProfileExItem(string? indexId)
|
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()
|
public async Task ClearAll()
|
||||||
{
|
{
|
||||||
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem ");
|
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem ");
|
||||||
_lstProfileEx = [];
|
lock (_profileExLock)
|
||||||
|
{
|
||||||
|
_lstProfileEx = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SaveTo()
|
public async Task SaveTo()
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ public class TaskManager
|
|||||||
|
|
||||||
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
|
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
|
||||||
|
|
||||||
var msgs = await updateService.CheckHasUpdateOnlyAll(_config.CheckUpdateItem.CheckPreReleaseUpdate, _config.CheckUpdateItem.UpdateViaProxy);
|
var msgs = await updateService.CheckHasUpdateOnlyAll(_config.CheckUpdateItem.UpdateViaProxy);
|
||||||
foreach (var msg in msgs)
|
foreach (var msg in msgs)
|
||||||
{
|
{
|
||||||
await _updateFunc?.Invoke(false, msg);
|
await _updateFunc?.Invoke(false, msg);
|
||||||
|
|||||||
@@ -242,9 +242,9 @@ public class WebDavItem
|
|||||||
[Serializable]
|
[Serializable]
|
||||||
public class CheckUpdateItem
|
public class CheckUpdateItem
|
||||||
{
|
{
|
||||||
public bool CheckPreReleaseUpdate { get; set; }
|
|
||||||
public bool UpdateViaProxy { get; set; } = true;
|
public bool UpdateViaProxy { get; set; } = true;
|
||||||
public List<string>? SelectedCoreTypes { get; set; }
|
public List<string>? SelectedCoreTypes { get; set; }
|
||||||
|
public List<string>? CheckPreReleaseCoreTypes { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public record CoreConfigContext
|
|||||||
|
|
||||||
public bool IsWindows { get; init; }
|
public bool IsWindows { get; init; }
|
||||||
public bool IsMacOS { get; init; }
|
public bool IsMacOS { get; init; }
|
||||||
|
public bool IsLinux { get; init; }
|
||||||
|
|
||||||
// Defaults to true so that a context built without this flag keeps routing IPv6 into the
|
// Defaults to true so that a context built without this flag keeps routing IPv6 into the
|
||||||
// tunnel; only a positive detection of the host having no global IPv6 address turns it off.
|
// tunnel; only a positive detection of the host having no global IPv6 address turns it off.
|
||||||
|
|||||||
@@ -128,8 +128,6 @@ public class User4Sbox
|
|||||||
|
|
||||||
public class Outbound4Sbox : BaseServer4Sbox
|
public class Outbound4Sbox : BaseServer4Sbox
|
||||||
{
|
{
|
||||||
public string? server { get; set; }
|
|
||||||
public int? server_port { get; set; }
|
|
||||||
public List<string>? server_ports { get; set; }
|
public List<string>? server_ports { get; set; }
|
||||||
public string? uuid { get; set; }
|
public string? uuid { get; set; }
|
||||||
public string? security { get; set; }
|
public string? security { get; set; }
|
||||||
@@ -171,6 +169,10 @@ public class Endpoints4Sbox : BaseServer4Sbox
|
|||||||
public string? udp_timeout { get; set; }
|
public string? udp_timeout { get; set; }
|
||||||
public int? workers { get; set; }
|
public int? workers { get; set; }
|
||||||
public List<Peer4Sbox> peers { get; set; }
|
public List<Peer4Sbox> peers { get; set; }
|
||||||
|
public string? username { get; set; }
|
||||||
|
public string? password { get; set; }
|
||||||
|
public string? path { get; set; }
|
||||||
|
public Dictionary<string, List<string>>? headers { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Peer4Sbox
|
public class Peer4Sbox
|
||||||
@@ -271,10 +273,8 @@ public class Server4Sbox : BaseServer4Sbox
|
|||||||
public string? inet4_range { get; set; }
|
public string? inet4_range { get; set; }
|
||||||
public string? inet6_range { get; set; }
|
public string? inet6_range { get; set; }
|
||||||
public string? client_subnet { get; set; }
|
public string? client_subnet { get; set; }
|
||||||
public string? server { get; set; }
|
|
||||||
public new string? domain_resolver { get; set; }
|
public new string? domain_resolver { get; set; }
|
||||||
[JsonPropertyName("interface")] public string? Interface { get; set; }
|
[JsonPropertyName("interface")] public string? Interface { get; set; }
|
||||||
public int? server_port { get; set; }
|
|
||||||
public string? path { get; set; }
|
public string? path { get; set; }
|
||||||
public Headers4Sbox? headers { get; set; }
|
public Headers4Sbox? headers { get; set; }
|
||||||
|
|
||||||
@@ -359,4 +359,6 @@ public abstract class BaseServer4Sbox : DialFields4Sbox
|
|||||||
{
|
{
|
||||||
public string type { get; set; }
|
public string type { get; set; }
|
||||||
public string tag { get; set; }
|
public string tag { get; set; }
|
||||||
|
public string? server { get; set; }
|
||||||
|
public int? server_port { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ public class Inboundsettings4Ray
|
|||||||
public string? autoOutboundsInterface { get; set; }
|
public string? autoOutboundsInterface { get; set; }
|
||||||
|
|
||||||
public List<string>? dns { get; set; }
|
public List<string>? dns { get; set; }
|
||||||
|
|
||||||
|
public bool? autoSystemDnsToGateway { get; set; }
|
||||||
|
|
||||||
|
public List<string>? autoSystemWfpBlockLeak { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Sniffing4Ray
|
public class Sniffing4Ray
|
||||||
@@ -315,6 +319,7 @@ public class StreamSettings4Ray
|
|||||||
public GrpcSettings4Ray? grpcSettings { get; set; }
|
public GrpcSettings4Ray? grpcSettings { get; set; }
|
||||||
|
|
||||||
public HysteriaSettings4Ray? hysteriaSettings { get; set; }
|
public HysteriaSettings4Ray? hysteriaSettings { get; set; }
|
||||||
|
public MasqueSettings4Ray? masqueSettings { get; set; }
|
||||||
|
|
||||||
public object? finalmask { get; set; }
|
public object? finalmask { get; set; }
|
||||||
|
|
||||||
@@ -443,6 +448,12 @@ public class HysteriaSettings4Ray
|
|||||||
public string? auth { get; set; }
|
public string? auth { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class MasqueSettings4Ray
|
||||||
|
{
|
||||||
|
public string? path { get; set; }
|
||||||
|
public Dictionary<string, string>? headers { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class UdpHop4Ray
|
public class UdpHop4Ray
|
||||||
{
|
{
|
||||||
public string? ports { get; set; }
|
public string? ports { get; set; }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ namespace ServiceLib.Models.Dto;
|
|||||||
public partial class CheckUpdateModel : ReactiveObject
|
public partial class CheckUpdateModel : ReactiveObject
|
||||||
{
|
{
|
||||||
public bool? IsSelected { get; set; }
|
public bool? IsSelected { get; set; }
|
||||||
|
public bool IsCheckPreRelease { get; set; }
|
||||||
|
public bool ShowCheckPreRelease { get; set; }
|
||||||
public ECoreType? CoreType { get; set; }
|
public ECoreType? CoreType { get; set; }
|
||||||
[Reactive] public partial string? Remarks { get; set; }
|
[Reactive] public partial string? Remarks { get; set; }
|
||||||
public string? FileName { get; set; }
|
public string? FileName { get; set; }
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ public record ProtocolExtraItem
|
|||||||
public int? InsecureConcurrency { get; init; }
|
public int? InsecureConcurrency { get; init; }
|
||||||
public bool? NaiveQuic { get; init; }
|
public bool? NaiveQuic { get; init; }
|
||||||
|
|
||||||
|
// MASQUE
|
||||||
|
public string? MasquePath { get; init; }
|
||||||
|
public string? MasqueHeaders { get; init; }
|
||||||
|
|
||||||
// group profile
|
// group profile
|
||||||
public string? GroupType { get; init; }
|
public string? GroupType { get; init; }
|
||||||
public string? ChildItems { get; init; }
|
public string? ChildItems { get; init; }
|
||||||
|
|||||||
Generated
+18
@@ -789,6 +789,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Add [MASQUE] 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string menuAddMasqueServer {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("menuAddMasqueServer", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Add [NaïveProxy] 的本地化字符串。
|
/// 查找类似 Add [NaïveProxy] 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -924,6 +933,15 @@ namespace ServiceLib.Resx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找类似 Check and Update 的本地化字符串。
|
||||||
|
/// </summary>
|
||||||
|
public static string menuCheckAndUpdate {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("menuCheckAndUpdate", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查找类似 Only Check 的本地化字符串。
|
/// 查找类似 Only Check 的本地化字符串。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -399,6 +399,9 @@
|
|||||||
<data name="MsgServerTitle" xml:space="preserve">
|
<data name="MsgServerTitle" xml:space="preserve">
|
||||||
<value>Filter, press Enter to execute</value>
|
<value>Filter, press Enter to execute</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="menuCheckAndUpdate" xml:space="preserve">
|
||||||
|
<value>Check and Update</value>
|
||||||
|
</data>
|
||||||
<data name="menuCheckUpdate" xml:space="preserve">
|
<data name="menuCheckUpdate" xml:space="preserve">
|
||||||
<value>Check Update</value>
|
<value>Check Update</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -1899,4 +1902,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
|
|||||||
<data name="TbSortingProcess" xml:space="preserve">
|
<data name="TbSortingProcess" xml:space="preserve">
|
||||||
<value>Process</value>
|
<value>Process</value>
|
||||||
</data>
|
</data>
|
||||||
</root>
|
<data name="menuAddMasqueServer" xml:space="preserve">
|
||||||
|
<value>Add [MASQUE]</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
@@ -399,8 +399,11 @@
|
|||||||
<data name="MsgServerTitle" xml:space="preserve">
|
<data name="MsgServerTitle" xml:space="preserve">
|
||||||
<value>Фильтр, нажмите Enter для выполнения</value>
|
<value>Фильтр, нажмите Enter для выполнения</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="menuCheckAndUpdate" xml:space="preserve">
|
||||||
|
<value>Проверить и обновить</value>
|
||||||
|
</data>
|
||||||
<data name="menuCheckUpdate" xml:space="preserve">
|
<data name="menuCheckUpdate" xml:space="preserve">
|
||||||
<value>Обновить</value>
|
<value>Проверить обновления</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="menuClose" xml:space="preserve">
|
<data name="menuClose" xml:space="preserve">
|
||||||
<value>Закрыть</value>
|
<value>Закрыть</value>
|
||||||
@@ -1780,7 +1783,7 @@
|
|||||||
<value>Доступна новая версия {0}: {1}</value>
|
<value>Доступна новая версия {0}: {1}</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="menuCheckOnly" xml:space="preserve">
|
<data name="menuCheckOnly" xml:space="preserve">
|
||||||
<value>Проверить</value>
|
<value>Только проверить</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="MsgNotSupport" xml:space="preserve">
|
<data name="MsgNotSupport" xml:space="preserve">
|
||||||
<value>Не поддерживается</value>
|
<value>Не поддерживается</value>
|
||||||
@@ -1899,4 +1902,7 @@
|
|||||||
<data name="TbSortingProcess" xml:space="preserve">
|
<data name="TbSortingProcess" xml:space="preserve">
|
||||||
<value>Процесс</value>
|
<value>Процесс</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="menuAddMasqueServer" xml:space="preserve">
|
||||||
|
<value>Добавить сервер [MASQUE]</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -399,6 +399,9 @@
|
|||||||
<data name="MsgServerTitle" xml:space="preserve">
|
<data name="MsgServerTitle" xml:space="preserve">
|
||||||
<value>过滤器,按回车执行</value>
|
<value>过滤器,按回车执行</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="menuCheckAndUpdate" xml:space="preserve">
|
||||||
|
<value>检查并更新</value>
|
||||||
|
</data>
|
||||||
<data name="menuCheckUpdate" xml:space="preserve">
|
<data name="menuCheckUpdate" xml:space="preserve">
|
||||||
<value>检查更新</value>
|
<value>检查更新</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -1891,4 +1894,7 @@
|
|||||||
<data name="TbSortingProcess" xml:space="preserve">
|
<data name="TbSortingProcess" xml:space="preserve">
|
||||||
<value>进程</value>
|
<value>进程</value>
|
||||||
</data>
|
</data>
|
||||||
</root>
|
<data name="menuAddMasqueServer" xml:space="preserve">
|
||||||
|
<value>添加 [MASQUE]</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
@@ -399,6 +399,9 @@
|
|||||||
<data name="MsgServerTitle" xml:space="preserve">
|
<data name="MsgServerTitle" xml:space="preserve">
|
||||||
<value>過濾器,按 Enter 執行</value>
|
<value>過濾器,按 Enter 執行</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="menuCheckAndUpdate" xml:space="preserve">
|
||||||
|
<value>檢查並更新</value>
|
||||||
|
</data>
|
||||||
<data name="menuCheckUpdate" xml:space="preserve">
|
<data name="menuCheckUpdate" xml:space="preserve">
|
||||||
<value>檢查更新</value>
|
<value>檢查更新</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -300,40 +300,6 @@ AAoACxGV2lZFA4gKn2fQ1XmxqI1AbQ3CekD6819kR5LLU7m7Wc5P/dAVUwHY3+vZ
|
|||||||
5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su
|
5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su
|
||||||
-----END CERTIFICATE-----
|
-----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-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRF
|
MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRF
|
||||||
MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBD
|
MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBD
|
||||||
@@ -732,20 +698,6 @@ LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI
|
|||||||
4uJEvlz36hz1
|
4uJEvlz36hz1
|
||||||
-----END CERTIFICATE-----
|
-----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-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
|
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
|
||||||
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
|
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
|
||||||
@@ -805,38 +757,6 @@ HQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVLB7rUW44kB/
|
|||||||
bmF0774BxL4YSFlhgjICICadVGNA3jdgUM/I2O2dgq43mLyjj0xMqTQrbO/7lZsm
|
bmF0774BxL4YSFlhgjICICadVGNA3jdgUM/I2O2dgq43mLyjj0xMqTQrbO/7lZsm
|
||||||
-----END CERTIFICATE-----
|
-----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-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQEL
|
MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQEL
|
||||||
BQAwbzELMAkGA1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJ
|
BQAwbzELMAkGA1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJ
|
||||||
@@ -1189,31 +1109,6 @@ AHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D5KbvtwEwXlGjefVwaaZB
|
|||||||
RA+GsCyRxj3qrg+E
|
RA+GsCyRxj3qrg+E
|
||||||
-----END CERTIFICATE-----
|
-----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-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBt
|
MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBt
|
||||||
MQswCQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUg
|
MQswCQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUg
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ public partial class CoreConfigSingboxService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tunInbound = JsonUtils.Deserialize<Inbound4Sbox>(EmbedUtils.GetEmbedText(Global.TunSingboxInboundFileName)) ?? new Inbound4Sbox { };
|
var tunInbound = JsonUtils.Deserialize<Inbound4Sbox>(EmbedUtils.GetEmbedText(Global.TunSingboxInboundFileName)) ?? new Inbound4Sbox { };
|
||||||
tunInbound.interface_name = context.IsMacOS ? $"utun{new Random().Next(99)}" : "singbox_tun";
|
tunInbound.interface_name = context.IsMacOS ? null : "singbox_tun";
|
||||||
tunInbound.mtu = _config.TunModeItem.Mtu;
|
tunInbound.mtu = _config.TunModeItem.Mtu;
|
||||||
tunInbound.auto_route = _config.TunModeItem.AutoRoute;
|
tunInbound.auto_route = _config.TunModeItem.AutoRoute;
|
||||||
tunInbound.strict_route = _config.TunModeItem.StrictRoute;
|
tunInbound.strict_route = _config.TunModeItem.StrictRoute;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public partial class CoreConfigSingboxService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_node.ConfigType == EConfigType.WireGuard)
|
if (_node.ConfigType is EConfigType.WireGuard or EConfigType.MASQUE)
|
||||||
{
|
{
|
||||||
var endpoint = JsonUtils.Deserialize<Endpoints4Sbox>(txtOutbound);
|
var endpoint = JsonUtils.Deserialize<Endpoints4Sbox>(txtOutbound);
|
||||||
FillEndpoint(endpoint);
|
FillEndpoint(endpoint);
|
||||||
@@ -350,13 +350,15 @@ public partial class CoreConfigSingboxService
|
|||||||
{
|
{
|
||||||
var protocolExtra = _node.GetProtocolExtra();
|
var protocolExtra = _node.GetProtocolExtra();
|
||||||
|
|
||||||
endpoint.address = Utils.String2List(protocolExtra.WgInterfaceAddress)?.Select(s => s.Trim()).ToList() ?? ["172.16.0.2/32"];
|
|
||||||
endpoint.type = Global.ProtocolTypes[_node.ConfigType];
|
endpoint.type = Global.ProtocolTypes[_node.ConfigType];
|
||||||
|
|
||||||
switch (_node.ConfigType)
|
switch (_node.ConfigType)
|
||||||
{
|
{
|
||||||
case EConfigType.WireGuard:
|
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
|
var peer = new Peer4Sbox
|
||||||
{
|
{
|
||||||
public_key = protocolExtra.WgPublicKey ?? string.Empty,
|
public_key = protocolExtra.WgPublicKey ?? string.Empty,
|
||||||
@@ -371,6 +373,28 @@ public partial class CoreConfigSingboxService
|
|||||||
endpoint.peers = [peer];
|
endpoint.peers = [peer];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case EConfigType.MASQUE:
|
||||||
|
{
|
||||||
|
endpoint.type = "masque-client";
|
||||||
|
endpoint.server = _node.Address;
|
||||||
|
endpoint.server_port = _node.Port;
|
||||||
|
endpoint.username = _node.Username;
|
||||||
|
endpoint.password = _node.Password;
|
||||||
|
endpoint.path = protocolExtra?.MasquePath.NullIfEmpty();
|
||||||
|
if (!string.IsNullOrEmpty(protocolExtra?.MasqueHeaders))
|
||||||
|
{
|
||||||
|
var headers = Utils.ParseHeaders(protocolExtra.MasqueHeaders)
|
||||||
|
.GroupBy(x => x.Item1, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.Select(x => x.Item2).ToList(),
|
||||||
|
StringComparer.OrdinalIgnoreCase
|
||||||
|
);
|
||||||
|
endpoint.headers = headers.Count > 0 ? headers : null;
|
||||||
|
}
|
||||||
|
FillOutboundTls(endpoint);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -402,7 +426,7 @@ public partial class CoreConfigSingboxService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FillOutboundTls(Outbound4Sbox outbound)
|
private void FillOutboundTls(BaseServer4Sbox server)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -476,7 +500,7 @@ public partial class CoreConfigSingboxService
|
|||||||
{
|
{
|
||||||
tls.ech = ech;
|
tls.ech = ech;
|
||||||
}
|
}
|
||||||
outbound.tls = tls;
|
server.tls = tls;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -67,12 +67,10 @@ public partial class CoreConfigV2rayService
|
|||||||
|
|
||||||
var address = _config.TunModeItem.IPv4Address.NullIfEmpty() ?? Global.TunIPv4Address.First();
|
var address = _config.TunModeItem.IPv4Address.NullIfEmpty() ?? Global.TunIPv4Address.First();
|
||||||
tunInbound.settings.gateway = [address];
|
tunInbound.settings.gateway = [address];
|
||||||
// Route both families into the tunnel regardless of EnableIPv6Address. That option only
|
// Without ::/0, IPv6 bypasses the tunnel. Skip it only when the host has no global IPv6
|
||||||
// controls whether the interface gets an IPv6 address; leaving ::/0 out of the routing
|
// address and EnableIPv6Address is off; NAT66 hosts have only ULA addresses.
|
||||||
// table makes IPv6 follow the system default route and bypass the tunnel entirely.
|
var routeIPv6 = _config.TunModeItem.EnableIPv6Address || context.HasGlobalIPv6Address;
|
||||||
// A host without a global IPv6 address is the exception: it has nothing to leak,
|
tunInbound.settings.autoSystemRoutingTable = routeIPv6
|
||||||
// and IPv6 sent into the tunnel would have no way back out.
|
|
||||||
tunInbound.settings.autoSystemRoutingTable = context.HasGlobalIPv6Address
|
|
||||||
? ["0.0.0.0/0", "::/0"]
|
? ["0.0.0.0/0", "::/0"]
|
||||||
: ["0.0.0.0/0"];
|
: ["0.0.0.0/0"];
|
||||||
if (_config.TunModeItem.EnableIPv6Address == true)
|
if (_config.TunModeItem.EnableIPv6Address == true)
|
||||||
@@ -90,6 +88,15 @@ public partial class CoreConfigV2rayService
|
|||||||
// tunInbound.sniffing.routeOnly = inbound.sniffing.routeOnly;
|
// tunInbound.sniffing.routeOnly = inbound.sniffing.routeOnly;
|
||||||
tunInbound.sniffing.routeOnly = true;
|
tunInbound.sniffing.routeOnly = true;
|
||||||
|
|
||||||
|
if (context.IsWindows && _config.TunModeItem.StrictRoute == true)
|
||||||
|
{
|
||||||
|
tunInbound.settings.autoSystemWfpBlockLeak = ["dns", "misconfigtun"];
|
||||||
|
}
|
||||||
|
if (context.IsLinux)
|
||||||
|
{
|
||||||
|
tunInbound.settings.autoSystemDnsToGateway = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (_config.TunModeItem.RouteExcludeAddress is { Count: > 0 })
|
if (_config.TunModeItem.RouteExcludeAddress is { Count: > 0 })
|
||||||
{
|
{
|
||||||
var wholeInternet = IPNetwork2.Parse("0.0.0.0/0");
|
var wholeInternet = IPNetwork2.Parse("0.0.0.0/0");
|
||||||
@@ -99,7 +106,7 @@ public partial class CoreConfigV2rayService
|
|||||||
.Where(x => x != null).ToList();
|
.Where(x => x != null).ToList();
|
||||||
|
|
||||||
var includeList = new List<IPNetwork2> { wholeInternet };
|
var includeList = new List<IPNetwork2> { wholeInternet };
|
||||||
var includeListV6 = context.HasGlobalIPv6Address
|
var includeListV6 = routeIPv6
|
||||||
? new List<IPNetwork2> { wholeInternetV6 }
|
? new List<IPNetwork2> { wholeInternetV6 }
|
||||||
: new List<IPNetwork2>();
|
: new List<IPNetwork2>();
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,12 @@ public partial class CoreConfigV2rayService
|
|||||||
outboundSettings.version = 2;
|
outboundSettings.version = 2;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case EConfigType.MASQUE:
|
||||||
|
{
|
||||||
|
outboundSettings.address = _node.Address;
|
||||||
|
outboundSettings.port = _node.Port;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case EConfigType.WireGuard:
|
case EConfigType.WireGuard:
|
||||||
{
|
{
|
||||||
var address = _node.Address;
|
var address = _node.Address;
|
||||||
@@ -257,8 +263,13 @@ public partial class CoreConfigV2rayService
|
|||||||
{
|
{
|
||||||
network = "hysteria";
|
network = "hysteria";
|
||||||
}
|
}
|
||||||
|
else if (_node.ConfigType == EConfigType.MASQUE)
|
||||||
|
{
|
||||||
|
network = "masque";
|
||||||
|
}
|
||||||
streamSettings.network = network;
|
streamSettings.network = network;
|
||||||
var transport = _node.GetTransportExtra();
|
var transport = _node.GetTransportExtra();
|
||||||
|
var protocolExtra = _node.GetProtocolExtra();
|
||||||
var host = string.Empty;
|
var host = string.Empty;
|
||||||
var path = string.Empty;
|
var path = string.Empty;
|
||||||
var kcpSeed = string.Empty;
|
var kcpSeed = string.Empty;
|
||||||
@@ -502,7 +513,6 @@ public partial class CoreConfigV2rayService
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "hysteria":
|
case "hysteria":
|
||||||
var protocolExtra = _node.GetProtocolExtra();
|
|
||||||
var ports = protocolExtra?.Ports;
|
var ports = protocolExtra?.Ports;
|
||||||
int? upMbps = protocolExtra?.UpMbps is { } su and >= 0
|
int? upMbps = protocolExtra?.UpMbps is { } su and >= 0
|
||||||
? su
|
? su
|
||||||
@@ -574,6 +584,32 @@ public partial class CoreConfigV2rayService
|
|||||||
streamSettings.finalmask = hy2Finalmask;
|
streamSettings.finalmask = hy2Finalmask;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "masque":
|
||||||
|
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var authValue = $"Basic {Utils.Base64Encode($"{_node.Username}:{_node.Password}")}";
|
||||||
|
headers.Add("Authorization", authValue);
|
||||||
|
if (!string.IsNullOrEmpty(protocolExtra?.MasqueHeaders))
|
||||||
|
{
|
||||||
|
var userHeaders = Utils.ParseHeaders(protocolExtra.MasqueHeaders)
|
||||||
|
.GroupBy(x => x.Item1, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => string.Join(", ", g.Select(x => x.Item2)),
|
||||||
|
StringComparer.OrdinalIgnoreCase
|
||||||
|
);
|
||||||
|
if (userHeaders.TryGetValue("Authorization", out var userAuthValue))
|
||||||
|
{
|
||||||
|
headers["Authorization"] = userAuthValue;
|
||||||
|
}
|
||||||
|
headers = userHeaders;
|
||||||
|
}
|
||||||
|
streamSettings.masqueSettings = new()
|
||||||
|
{
|
||||||
|
path = protocolExtra?.MasquePath.NullIfEmpty(),
|
||||||
|
headers = headers.Count > 0 ? headers : null,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// raw
|
// raw
|
||||||
if (headerType == Global.RawHeaderHttp)
|
if (headerType == Global.RawHeaderHttp)
|
||||||
|
|||||||
@@ -587,6 +587,12 @@ public class SpeedtestService(Config config, Func<SpeedTestResult, Task> updateF
|
|||||||
private List<List<ServerTestItem>> GetTestBatchItem(List<ServerTestItem> lstSelected, int pageSize)
|
private List<List<ServerTestItem>> GetTestBatchItem(List<ServerTestItem> lstSelected, int pageSize)
|
||||||
{
|
{
|
||||||
List<List<ServerTestItem>> lstTest = [];
|
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 lst1 = lstSelected.Where(t => t.CoreType == ECoreType.Xray).ToList();
|
||||||
var lst2 = lstSelected.Where(t => t.CoreType == ECoreType.sing_box).ToList();
|
var lst2 = lstSelected.Where(t => t.CoreType == ECoreType.sing_box).ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
|
|||||||
return await CheckUpdateAsync(downloadHandle, type, checkPreRelease, blProxy, cancellationToken);
|
return await CheckUpdateAsync(downloadHandle, type, checkPreRelease, blProxy, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<string>> CheckHasUpdateOnlyAll(bool preRelease, bool blProxy = true, CancellationToken cancellationToken = default)
|
public async Task<List<string>> CheckHasUpdateOnlyAll(bool blProxy = true, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var msgs = new List<string>();
|
var msgs = new List<string>();
|
||||||
foreach (var type in CoreInfoManager.Instance.GetCheckUpdateCoreTypes())
|
foreach (var type in CoreInfoManager.Instance.GetCheckUpdateCoreTypes())
|
||||||
@@ -121,6 +121,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var preRelease = _config.CheckUpdateItem.CheckPreReleaseCoreTypes?.Contains(type.ToString()) ?? false;
|
||||||
var result = await CheckHasUpdateOnly(type, preRelease, blProxy, cancellationToken);
|
var result = await CheckHasUpdateOnly(type, preRelease, blProxy, cancellationToken);
|
||||||
if (result.Success && result.Version != null)
|
if (result.Success && result.Version != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
|
|||||||
[Reactive]
|
[Reactive]
|
||||||
public partial int GeckoMaxPacketSize { get; set; }
|
public partial int GeckoMaxPacketSize { get; set; }
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial string MasquePath { get; set; }
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial string MasqueHeaders { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public partial string RawHeaderType { get; set; }
|
public partial string RawHeaderType { get; set; }
|
||||||
|
|
||||||
@@ -322,6 +328,8 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
|
|||||||
Hy2RealmUrl = protocolExtra.Hy2RealmUrl ?? string.Empty;
|
Hy2RealmUrl = protocolExtra.Hy2RealmUrl ?? string.Empty;
|
||||||
GeckoMinPacketSize = protocolExtra.GeckoMinPacketSize.ToInt();
|
GeckoMinPacketSize = protocolExtra.GeckoMinPacketSize.ToInt();
|
||||||
GeckoMaxPacketSize = protocolExtra.GeckoMaxPacketSize.ToInt();
|
GeckoMaxPacketSize = protocolExtra.GeckoMaxPacketSize.ToInt();
|
||||||
|
MasquePath = protocolExtra.MasquePath ?? string.Empty;
|
||||||
|
MasqueHeaders = protocolExtra.MasqueHeaders ?? string.Empty;
|
||||||
|
|
||||||
RawHeaderType = transport.RawHeaderType ?? Global.None;
|
RawHeaderType = transport.RawHeaderType ?? Global.None;
|
||||||
Host = transport.Host ?? string.Empty;
|
Host = transport.Host ?? string.Empty;
|
||||||
@@ -443,6 +451,8 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
|
|||||||
Hy2RealmUrl = realm?.ToUri().NullIfEmpty(),
|
Hy2RealmUrl = realm?.ToUri().NullIfEmpty(),
|
||||||
GeckoMinPacketSize = GeckoMinPacketSize > 0 ? GeckoMinPacketSize.ToString() : null,
|
GeckoMinPacketSize = GeckoMinPacketSize > 0 ? GeckoMinPacketSize.ToString() : null,
|
||||||
GeckoMaxPacketSize = GeckoMaxPacketSize > 0 ? GeckoMaxPacketSize.ToString() : null,
|
GeckoMaxPacketSize = GeckoMaxPacketSize > 0 ? GeckoMaxPacketSize.ToString() : null,
|
||||||
|
MasquePath = MasquePath.NullIfEmpty(),
|
||||||
|
MasqueHeaders = MasqueHeaders.NullIfEmpty(),
|
||||||
});
|
});
|
||||||
SelectedSource.SetTransportExtra(transport);
|
SelectedSource.SetTransportExtra(transport);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
|
|||||||
public BulkObservableCollection<CheckUpdateModel> CheckUpdateModels { get; } = [];
|
public BulkObservableCollection<CheckUpdateModel> CheckUpdateModels { get; } = [];
|
||||||
public ReactiveCommand<RxVoid, RxVoid> CheckUpdateCmd { get; }
|
public ReactiveCommand<RxVoid, RxVoid> CheckUpdateCmd { get; }
|
||||||
public ReactiveCommand<RxVoid, RxVoid> CheckOnlyCmd { get; }
|
public ReactiveCommand<RxVoid, RxVoid> CheckOnlyCmd { get; }
|
||||||
[Reactive] public partial bool EnableCheckPreReleaseUpdate { get; set; }
|
|
||||||
[Reactive] public partial bool EnableUpdateViaProxy { get; set; }
|
[Reactive] public partial bool EnableUpdateViaProxy { get; set; }
|
||||||
|
|
||||||
public CheckUpdateViewModel()
|
public CheckUpdateViewModel()
|
||||||
@@ -33,12 +33,8 @@ public partial class CheckUpdateViewModel : MyReactiveObject
|
|||||||
_ = UpdateView(_v2rayN, ex.Message);
|
_ = UpdateView(_v2rayN, ex.Message);
|
||||||
});
|
});
|
||||||
|
|
||||||
EnableCheckPreReleaseUpdate = _config.CheckUpdateItem.CheckPreReleaseUpdate;
|
|
||||||
EnableUpdateViaProxy = _config.CheckUpdateItem.UpdateViaProxy;
|
EnableUpdateViaProxy = _config.CheckUpdateItem.UpdateViaProxy;
|
||||||
|
|
||||||
this.WhenAnyValue(x => x.EnableCheckPreReleaseUpdate)
|
|
||||||
.SubscribeAsync(async _ => await OnCheckPreReleaseUpdateChanged());
|
|
||||||
|
|
||||||
this.WhenAnyValue(x => x.EnableUpdateViaProxy)
|
this.WhenAnyValue(x => x.EnableUpdateViaProxy)
|
||||||
.Subscribe(c => _ = OnUpdateViaProxyChanged());
|
.Subscribe(c => _ = OnUpdateViaProxyChanged());
|
||||||
|
|
||||||
@@ -73,6 +69,8 @@ public partial class CheckUpdateViewModel : MyReactiveObject
|
|||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
IsSelected = false,
|
IsSelected = false,
|
||||||
|
IsCheckPreRelease = false,
|
||||||
|
ShowCheckPreRelease = false,
|
||||||
CoreType = coreType,
|
CoreType = coreType,
|
||||||
IsGeoFile = false,
|
IsGeoFile = false,
|
||||||
Remarks = ResUI.menuCheckUpdate + $" ({ResUI.MsgNotSupport})",
|
Remarks = ResUI.menuCheckUpdate + $" ({ResUI.MsgNotSupport})",
|
||||||
@@ -83,6 +81,8 @@ public partial class CheckUpdateViewModel : MyReactiveObject
|
|||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
IsSelected = _config.CheckUpdateItem.SelectedCoreTypes?.Contains(coreType.ToString()) ?? true,
|
IsSelected = _config.CheckUpdateItem.SelectedCoreTypes?.Contains(coreType.ToString()) ?? true,
|
||||||
|
IsCheckPreRelease = _config.CheckUpdateItem.CheckPreReleaseCoreTypes?.Contains(coreType.ToString()) ?? false,
|
||||||
|
ShowCheckPreRelease = CoreInfoManager.Instance.GetCheckPreRelease(coreType, true),
|
||||||
CoreType = coreType,
|
CoreType = coreType,
|
||||||
IsGeoFile = false,
|
IsGeoFile = false,
|
||||||
Remarks = lastResult ?? ResUI.menuCheckUpdate,
|
Remarks = lastResult ?? ResUI.menuCheckUpdate,
|
||||||
@@ -94,22 +94,14 @@ public partial class CheckUpdateViewModel : MyReactiveObject
|
|||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
IsSelected = _config.CheckUpdateItem.SelectedCoreTypes?.Contains(_geo) ?? true,
|
IsSelected = _config.CheckUpdateItem.SelectedCoreTypes?.Contains(_geo) ?? true,
|
||||||
|
IsCheckPreRelease = false,
|
||||||
|
ShowCheckPreRelease = false,
|
||||||
CoreType = null,
|
CoreType = null,
|
||||||
IsGeoFile = true,
|
IsGeoFile = true,
|
||||||
Remarks = ResUI.menuCheckUpdate,
|
Remarks = ResUI.menuCheckUpdate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnCheckPreReleaseUpdateChanged()
|
|
||||||
{
|
|
||||||
if (_config.CheckUpdateItem.CheckPreReleaseUpdate == EnableCheckPreReleaseUpdate)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_config.CheckUpdateItem.CheckPreReleaseUpdate = EnableCheckPreReleaseUpdate;
|
|
||||||
await SaveSelectedCoreTypes();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnUpdateViaProxyChanged()
|
private async Task OnUpdateViaProxyChanged()
|
||||||
{
|
{
|
||||||
if (_config.CheckUpdateItem.UpdateViaProxy == EnableUpdateViaProxy)
|
if (_config.CheckUpdateItem.UpdateViaProxy == EnableUpdateViaProxy)
|
||||||
@@ -127,6 +119,11 @@ public partial class CheckUpdateViewModel : MyReactiveObject
|
|||||||
.Select(t => t.CoreTypeForStorage)
|
.Select(t => t.CoreTypeForStorage)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
_config.CheckUpdateItem.CheckPreReleaseCoreTypes =
|
||||||
|
CheckUpdateModels.Where(t => t.IsCheckPreRelease == true)
|
||||||
|
.Select(t => t.CoreTypeForStorage)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
await ConfigHandler.SaveConfig(_config);
|
await ConfigHandler.SaveConfig(_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +164,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
|
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
|
||||||
var result = await updateService.CheckHasUpdateOnly(item.CoreType.Value, EnableCheckPreReleaseUpdate, EnableUpdateViaProxy);
|
var result = await updateService.CheckHasUpdateOnly(item.CoreType.Value, item.IsCheckPreRelease, EnableUpdateViaProxy);
|
||||||
if (result.Success && result.Version != null)
|
if (result.Success && result.Version != null)
|
||||||
{
|
{
|
||||||
await UpdateView(item.CoreType, string.Format(ResUI.MsgCheckUpdateHasNewVersion, item.CoreType, result.Version));
|
await UpdateView(item.CoreType, string.Format(ResUI.MsgCheckUpdateHasNewVersion, item.CoreType, result.Version));
|
||||||
@@ -213,15 +210,11 @@ public partial class CheckUpdateViewModel : MyReactiveObject
|
|||||||
await UpdateView(_v2rayN, ResUI.MsgNotSupport);
|
await UpdateView(_v2rayN, ResUI.MsgNotSupport);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await CheckUpdateN(EnableCheckPreReleaseUpdate);
|
await CheckUpdateN(item.IsCheckPreRelease);
|
||||||
}
|
|
||||||
else if (item.CoreType is ECoreType.Xray or ECoreType.sing_box)
|
|
||||||
{
|
|
||||||
await CheckUpdateCore(item, EnableCheckPreReleaseUpdate);
|
|
||||||
}
|
}
|
||||||
else if (item.CoreType.HasValue)
|
else if (item.CoreType.HasValue)
|
||||||
{
|
{
|
||||||
await CheckUpdateCore(item, false);
|
await CheckUpdateCore(item, item.IsCheckPreRelease);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace ServiceLib.ViewModels;
|
|||||||
|
|
||||||
public partial class ClashProxiesViewModel : MyReactiveObject
|
public partial class ClashProxiesViewModel : MyReactiveObject
|
||||||
{
|
{
|
||||||
private readonly int _delayTimeout = 99999999;
|
private const int _delayTimeout = 99999999;
|
||||||
private ClashItem _clashItem = new();
|
private ClashItem _clashItem = new();
|
||||||
|
|
||||||
public ClashProxiesViewModel()
|
public ClashProxiesViewModel()
|
||||||
@@ -235,6 +235,11 @@ public partial class ClashProxiesViewModel : MyReactiveObject
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var dicCurrentDetails = new Dictionary<string, ClashProxyModel>();
|
||||||
|
foreach (var item in ProxyDetails)
|
||||||
|
{
|
||||||
|
dicCurrentDetails.TryAdd(item.Name, item);
|
||||||
|
}
|
||||||
var lstDetails = new List<ClashProxyModel>();
|
var lstDetails = new List<ClashProxyModel>();
|
||||||
foreach (var item in proxy.all)
|
foreach (var item in proxy.all)
|
||||||
{
|
{
|
||||||
@@ -243,15 +248,29 @@ public partial class ClashProxiesViewModel : MyReactiveObject
|
|||||||
{
|
{
|
||||||
continue;
|
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
|
lstDetails.Add(new ClashProxyModel
|
||||||
{
|
{
|
||||||
IsActive = item == proxy.now,
|
IsActive = item == proxy.now,
|
||||||
Name = item,
|
Name = item,
|
||||||
Type = proxy2.type,
|
Type = proxy2.type,
|
||||||
Delay = delay <= 0 ? _delayTimeout : delay,
|
Delay = isValidDelay ? delay : _delayTimeout,
|
||||||
DelayName = delay <= 0 ? string.Empty : $"{delay}ms",
|
DelayName = !hasData ? string.Empty : (isValidDelay ? $"{delay}ms" : "-1"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// sort
|
// sort
|
||||||
@@ -362,8 +381,9 @@ public partial class ClashProxiesViewModel : MyReactiveObject
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
detail.Delay = Convert.ToInt32(result.Delay);
|
var isValid = int.TryParse(result.Delay, out var delay) && delay > 0 && delay != _delayTimeout;
|
||||||
detail.DelayName = $"{detail.Delay}ms";
|
detail.Delay = isValid ? delay : _delayTimeout;
|
||||||
|
detail.DelayName = isValid ? $"{delay}ms" : "-1";
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public partial class MainWindowViewModel : MyReactiveObject
|
|||||||
public ReactiveCommand<RxVoid, RxVoid> AddWireguardServerCmd { get; }
|
public ReactiveCommand<RxVoid, RxVoid> AddWireguardServerCmd { get; }
|
||||||
public ReactiveCommand<RxVoid, RxVoid> AddAnytlsServerCmd { get; }
|
public ReactiveCommand<RxVoid, RxVoid> AddAnytlsServerCmd { get; }
|
||||||
public ReactiveCommand<RxVoid, RxVoid> AddNaiveServerCmd { get; }
|
public ReactiveCommand<RxVoid, RxVoid> AddNaiveServerCmd { get; }
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> AddMasqueServerCmd { get; }
|
||||||
public ReactiveCommand<RxVoid, RxVoid> AddCustomServerCmd { get; }
|
public ReactiveCommand<RxVoid, RxVoid> AddCustomServerCmd { get; }
|
||||||
public ReactiveCommand<RxVoid, RxVoid> AddCustomOutboundServerCmd { get; }
|
public ReactiveCommand<RxVoid, RxVoid> AddCustomOutboundServerCmd { get; }
|
||||||
public ReactiveCommand<RxVoid, RxVoid> AddPolicyGroupServerCmd { get; }
|
public ReactiveCommand<RxVoid, RxVoid> AddPolicyGroupServerCmd { get; }
|
||||||
@@ -140,6 +141,10 @@ public partial class MainWindowViewModel : MyReactiveObject
|
|||||||
{
|
{
|
||||||
await AddServerAsync(EConfigType.Naive);
|
await AddServerAsync(EConfigType.Naive);
|
||||||
});
|
});
|
||||||
|
AddMasqueServerCmd = ReactiveCommand.CreateFromTask(async () =>
|
||||||
|
{
|
||||||
|
await AddServerAsync(EConfigType.MASQUE);
|
||||||
|
});
|
||||||
AddCustomServerCmd = ReactiveCommand.CreateFromTask(async () =>
|
AddCustomServerCmd = ReactiveCommand.CreateFromTask(async () =>
|
||||||
{
|
{
|
||||||
await AddServerAsync(EConfigType.Custom);
|
await AddServerAsync(EConfigType.Custom);
|
||||||
@@ -522,7 +527,7 @@ public partial class MainWindowViewModel : MyReactiveObject
|
|||||||
public async Task AddServerViaImageAsync()
|
public async Task AddServerViaImageAsync()
|
||||||
{
|
{
|
||||||
var imageFileName = await BrowseImageFileInteraction.HandleSafe(RxVoid.Default);
|
var imageFileName = await BrowseImageFileInteraction.HandleSafe(RxVoid.Default);
|
||||||
await AddScanResultAsync(imageFileName);
|
await ScanImageResult(imageFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ScanImageResult(string fileName)
|
public async Task ScanImageResult(string fileName)
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
|
|||||||
[Reactive] public partial string CoreType6 { get; set; }
|
[Reactive] public partial string CoreType6 { get; set; }
|
||||||
[Reactive] public partial string CoreType7 { get; set; }
|
[Reactive] public partial string CoreType7 { get; set; }
|
||||||
[Reactive] public partial string CoreType9 { get; set; }
|
[Reactive] public partial string CoreType9 { get; set; }
|
||||||
|
[Reactive] public partial string CoreType14 { get; set; }
|
||||||
|
|
||||||
#endregion CoreType
|
#endregion CoreType
|
||||||
|
|
||||||
@@ -283,6 +284,10 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
|
|||||||
case 9:
|
case 9:
|
||||||
CoreType9 = type;
|
CoreType9 = type;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 14:
|
||||||
|
CoreType14 = type;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
@@ -451,6 +456,10 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
|
|||||||
type = CoreType9;
|
type = CoreType9;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 14:
|
||||||
|
type = CoreType14;
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,8 @@ public partial class StatusBarViewModel : MyReactiveObject
|
|||||||
await ConfigHandler.InitBuiltinRouting(_config);
|
await ConfigHandler.InitBuiltinRouting(_config);
|
||||||
await RefreshRoutingsMenu();
|
await RefreshRoutingsMenu();
|
||||||
await InboundDisplayStatus();
|
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;
|
BlRouting = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,12 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
|
|||||||
@@ -23,12 +23,12 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
|
|||||||
@@ -24,12 +24,12 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
@@ -783,6 +783,70 @@
|
|||||||
HorizontalAlignment="Left" />
|
HorizontalAlignment="Left" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
x:Name="gridMasque"
|
||||||
|
Grid.Row="2"
|
||||||
|
ColumnDefinitions="300,Auto"
|
||||||
|
IsVisible="False"
|
||||||
|
RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||||
|
<TextBlock
|
||||||
|
Grid.Row="1"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="{StaticResource Margin4}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="{x:Static resx:ResUI.TbUsername}" />
|
||||||
|
<TextBox
|
||||||
|
x:Name="txtId14"
|
||||||
|
Grid.Row="1"
|
||||||
|
Grid.Column="1"
|
||||||
|
Width="400"
|
||||||
|
Margin="{StaticResource Margin4}" />
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Grid.Row="2"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="{StaticResource Margin4}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="{x:Static resx:ResUI.TbId3}" />
|
||||||
|
<TextBox
|
||||||
|
x:Name="txtSecurity14"
|
||||||
|
Grid.Row="2"
|
||||||
|
Grid.Column="1"
|
||||||
|
Width="400"
|
||||||
|
Margin="{StaticResource Margin4}" />
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Grid.Row="3"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="{StaticResource Margin4}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="{x:Static resx:ResUI.TbPath}" />
|
||||||
|
<TextBox
|
||||||
|
x:Name="txtPath14"
|
||||||
|
Grid.Row="3"
|
||||||
|
Grid.Column="1"
|
||||||
|
Width="400"
|
||||||
|
Margin="{StaticResource Margin4}" />
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Grid.Row="4"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="{StaticResource Margin4}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="{x:Static resx:ResUI.TbHttpOutboundHeaders}" />
|
||||||
|
<TextBox
|
||||||
|
x:Name="txtHttpHeaders14"
|
||||||
|
Grid.Row="4"
|
||||||
|
Grid.Column="1"
|
||||||
|
Width="400"
|
||||||
|
Margin="{StaticResource Margin4}"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
Classes="TextArea"
|
||||||
|
PlaceholderText="Header: Value"
|
||||||
|
TextWrapping="NoWrap" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Separator
|
<Separator
|
||||||
x:Name="sepa2"
|
x:Name="sepa2"
|
||||||
Grid.Row="3"
|
Grid.Row="3"
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
|
|||||||
this.Bind(ViewModel, vm => vm.InsecureConcurrency, v => v.txtInsecureConcurrency12.Text).DisposeWith(currentTypeDisposables);
|
this.Bind(ViewModel, vm => vm.InsecureConcurrency, v => v.txtInsecureConcurrency12.Text).DisposeWith(currentTypeDisposables);
|
||||||
this.Bind(ViewModel, vm => vm.Uot, v => v.togUotEnabled12.IsChecked).DisposeWith(currentTypeDisposables);
|
this.Bind(ViewModel, vm => vm.Uot, v => v.togUotEnabled12.IsChecked).DisposeWith(currentTypeDisposables);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case EConfigType.MASQUE:
|
||||||
|
this.Bind(ViewModel, vm => vm.SelectedSource.Username, v => v.txtId14.Text).DisposeWith(currentTypeDisposables);
|
||||||
|
this.Bind(ViewModel, vm => vm.SelectedSource.Password, v => v.txtSecurity14.Text).DisposeWith(currentTypeDisposables);
|
||||||
|
this.Bind(ViewModel, vm => vm.MasquePath, v => v.txtPath14.Text).DisposeWith(currentTypeDisposables);
|
||||||
|
this.Bind(ViewModel, vm => vm.MasqueHeaders, v => v.txtHttpHeaders14.Text).DisposeWith(currentTypeDisposables);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.DisposeWith(disposables);
|
.DisposeWith(disposables);
|
||||||
@@ -194,7 +201,6 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
|
|||||||
this.BindCommand(ViewModel, vm => vm.FetchCertChainCmd, v => v.btnFetchCertChain).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.FetchCertChainCmd, v => v.btnFetchCertChain).DisposeWith(disposables);
|
||||||
this.BindCommand(ViewModel, vm => vm.SaveCmd, v => v.btnSave).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.SaveCmd, v => v.btnSave).DisposeWith(disposables);
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeData(ProfileItem profileItem)
|
private void InitializeData(ProfileItem profileItem)
|
||||||
@@ -284,6 +290,14 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
|
|||||||
|
|
||||||
cmbCongestionControl12.ItemsSource = Global.NaiveCongestionControls;
|
cmbCongestionControl12.ItemsSource = Global.NaiveCongestionControls;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case EConfigType.MASQUE:
|
||||||
|
gridMasque.IsVisible = true;
|
||||||
|
sepa2.IsVisible = false;
|
||||||
|
gridTransport.IsVisible = false;
|
||||||
|
cmbFingerprint.IsEnabled = false;
|
||||||
|
cmbAlpn.IsEnabled = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
cmbStreamSecurity.ItemsSource = lstStreamSecurity;
|
cmbStreamSecurity.ItemsSource = lstStreamSecurity;
|
||||||
cmbStreamSecurity.SelectedItem = profileItem.StreamSecurity;
|
cmbStreamSecurity.SelectedItem = profileItem.StreamSecurity;
|
||||||
|
|||||||
@@ -24,16 +24,23 @@
|
|||||||
HorizontalAlignment="Left" />
|
HorizontalAlignment="Left" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel>
|
<StackPanel Grid.IsSharedSizeScope="True">
|
||||||
<Border
|
<Border
|
||||||
Margin="{StaticResource Margin4}"
|
Margin="{StaticResource Margin4}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Theme="{DynamicResource CardBorder}">
|
Theme="{DynamicResource CardBorder}">
|
||||||
|
|
||||||
<Grid
|
<Grid Margin="{StaticResource Margin4}" RowDefinitions="Auto,Auto,Auto,Auto">
|
||||||
Margin="{StaticResource Margin4}"
|
<Grid.ColumnDefinitions>
|
||||||
ColumnDefinitions="300,200"
|
<ColumnDefinition
|
||||||
RowDefinitions="Auto,Auto,Auto,Auto">
|
Width="Auto"
|
||||||
|
MinWidth="300"
|
||||||
|
SharedSizeGroup="BackupLabel" />
|
||||||
|
<ColumnDefinition
|
||||||
|
Width="Auto"
|
||||||
|
MinWidth="200"
|
||||||
|
SharedSizeGroup="BackupButton" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
@@ -79,10 +86,17 @@
|
|||||||
Margin="{StaticResource Margin4}"
|
Margin="{StaticResource Margin4}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Theme="{DynamicResource CardBorder}">
|
Theme="{DynamicResource CardBorder}">
|
||||||
<Grid
|
<Grid Margin="{StaticResource Margin4}" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||||
Margin="{StaticResource Margin4}"
|
<Grid.ColumnDefinitions>
|
||||||
ColumnDefinitions="300,200"
|
<ColumnDefinition
|
||||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
Width="Auto"
|
||||||
|
MinWidth="300"
|
||||||
|
SharedSizeGroup="BackupLabel" />
|
||||||
|
<ColumnDefinition
|
||||||
|
Width="Auto"
|
||||||
|
MinWidth="200"
|
||||||
|
SharedSizeGroup="BackupButton" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<StackPanel
|
<StackPanel
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
|
|||||||
@@ -35,27 +35,17 @@
|
|||||||
Margin="{StaticResource Margin4}"
|
Margin="{StaticResource Margin4}"
|
||||||
HorizontalAlignment="Left" />
|
HorizontalAlignment="Left" />
|
||||||
|
|
||||||
<TextBlock
|
|
||||||
Margin="{StaticResource Margin4}"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Text="{x:Static resx:ResUI.TbSettingsEnableCheckPreReleaseUpdate}" />
|
|
||||||
<ToggleSwitch
|
|
||||||
x:Name="togEnableCheckPreReleaseUpdate"
|
|
||||||
Margin="{StaticResource Margin4}"
|
|
||||||
HorizontalAlignment="Left" />
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCheckOnly"
|
x:Name="btnCheckOnly"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource Margin4}"
|
Margin="{StaticResource Margin4}"
|
||||||
Content="{x:Static resx:ResUI.menuCheckOnly}" />
|
Content="{x:Static resx:ResUI.menuCheckOnly}" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCheckUpdate"
|
x:Name="btnCheckUpdate"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource Margin4}"
|
Margin="{StaticResource Margin4}"
|
||||||
Content="{x:Static resx:ResUI.menuCheckUpdate}" />
|
Content="{x:Static resx:ResUI.menuCheckAndUpdate}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -75,8 +65,8 @@
|
|||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Panel Width="500" VerticalAlignment="Center">
|
<Panel Width="680" VerticalAlignment="Center">
|
||||||
<Grid ColumnDefinitions="1*,1*,3*" RowDefinitions="Auto">
|
<Grid ColumnDefinitions="1*,3*,1*,3*" RowDefinitions="Auto">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
x:Name="togAutoRefresh"
|
x:Name="togAutoRefresh"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
@@ -84,13 +74,32 @@
|
|||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
IsChecked="{Binding IsSelected}" />
|
IsChecked="{Binding IsSelected}" />
|
||||||
<TextBlock
|
|
||||||
|
<DockPanel
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
|
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}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="2"
|
||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Text="{Binding CoreTypeForStorage}" />
|
Text="{Binding CoreTypeForStorage}" />
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Column="2"
|
Grid.Column="3"
|
||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Text="{Binding Remarks}"
|
Text="{Binding Remarks}"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ public partial class CheckUpdateView : ReactiveUserControl<CheckUpdateViewModel>
|
|||||||
{
|
{
|
||||||
this.OneWayBind(ViewModel, vm => vm.CheckUpdateModels, v => v.lstCheckUpdates.ItemsSource).DisposeWith(disposables);
|
this.OneWayBind(ViewModel, vm => vm.CheckUpdateModels, v => v.lstCheckUpdates.ItemsSource).DisposeWith(disposables);
|
||||||
|
|
||||||
this.Bind(ViewModel, vm => vm.EnableCheckPreReleaseUpdate, v => v.togEnableCheckPreReleaseUpdate.IsChecked).DisposeWith(disposables);
|
|
||||||
this.Bind(ViewModel, vm => vm.EnableUpdateViaProxy, v => v.togEnableUpdateViaProxy.IsChecked).DisposeWith(disposables);
|
this.Bind(ViewModel, vm => vm.EnableUpdateViaProxy, v => v.togEnableUpdateViaProxy.IsChecked).DisposeWith(disposables);
|
||||||
this.BindCommand(ViewModel, vm => vm.CheckOnlyCmd, v => v.btnCheckOnly).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.CheckOnlyCmd, v => v.btnCheckOnly).DisposeWith(disposables);
|
||||||
this.BindCommand(ViewModel, vm => vm.CheckUpdateCmd, v => v.btnCheckUpdate).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.CheckUpdateCmd, v => v.btnCheckUpdate).DisposeWith(disposables);
|
||||||
|
|||||||
@@ -24,12 +24,12 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
|
|||||||
@@ -24,12 +24,12 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
|
|||||||
@@ -23,17 +23,17 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnReset"
|
x:Name="btnReset"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbReset}" />
|
Content="{x:Static resx:ResUI.TbReset}" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
|
|||||||
@@ -18,6 +18,15 @@
|
|||||||
ShowInTaskbar="True"
|
ShowInTaskbar="True"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
<Window.Styles>
|
||||||
|
<!-- Keep the log selected; hide only the strip when it has no other tabs. -->
|
||||||
|
<Style Selector="TabControl.logPane /template/ ItemsPresenter#PART_ItemsPresenter">
|
||||||
|
<Setter Property="IsVisible" Value="{Binding ShowClashUI}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TabControl.logPane /template/ Border#PART_BorderSeparator">
|
||||||
|
<Setter Property="IsVisible" Value="{Binding ShowClashUI}" />
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
<dialogHost:DialogHost CloseOnClickAway="True" DisableOpeningAnimation="True">
|
<dialogHost:DialogHost CloseOnClickAway="True" DisableOpeningAnimation="True">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<DockPanel Margin="{StaticResource Margin8}" DockPanel.Dock="Top">
|
<DockPanel Margin="{StaticResource Margin8}" DockPanel.Dock="Top">
|
||||||
@@ -45,6 +54,7 @@
|
|||||||
<MenuItem x:Name="menuAddTrojanServer" Header="{x:Static resx:ResUI.menuAddTrojanServer}" />
|
<MenuItem x:Name="menuAddTrojanServer" Header="{x:Static resx:ResUI.menuAddTrojanServer}" />
|
||||||
<MenuItem x:Name="menuAddHysteria2Server" Header="{x:Static resx:ResUI.menuAddHysteria2Server}" />
|
<MenuItem x:Name="menuAddHysteria2Server" Header="{x:Static resx:ResUI.menuAddHysteria2Server}" />
|
||||||
<MenuItem x:Name="menuAddWireguardServer" Header="{x:Static resx:ResUI.menuAddWireguardServer}" />
|
<MenuItem x:Name="menuAddWireguardServer" Header="{x:Static resx:ResUI.menuAddWireguardServer}" />
|
||||||
|
<MenuItem x:Name="menuAddMasqueServer" Header="{x:Static resx:ResUI.menuAddMasqueServer}" />
|
||||||
<MenuItem x:Name="menuAddSocksServer" Header="{x:Static resx:ResUI.menuAddSocksServer}" />
|
<MenuItem x:Name="menuAddSocksServer" Header="{x:Static resx:ResUI.menuAddSocksServer}" />
|
||||||
<MenuItem x:Name="menuAddHttpServer" Header="{x:Static resx:ResUI.menuAddHttpServer}" />
|
<MenuItem x:Name="menuAddHttpServer" Header="{x:Static resx:ResUI.menuAddHttpServer}" />
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -123,6 +133,7 @@
|
|||||||
<TabControl
|
<TabControl
|
||||||
x:Name="tabMain"
|
x:Name="tabMain"
|
||||||
Grid.Column="2"
|
Grid.Column="2"
|
||||||
|
Classes="logPane"
|
||||||
HorizontalContentAlignment="Left">
|
HorizontalContentAlignment="Left">
|
||||||
<TabItem x:Name="tabMsgView" Header="{x:Static resx:ResUI.MsgInformationTitle}" />
|
<TabItem x:Name="tabMsgView" Header="{x:Static resx:ResUI.MsgInformationTitle}" />
|
||||||
<TabItem x:Name="tabClashProxies" Header="{x:Static resx:ResUI.TbProxies}" />
|
<TabItem x:Name="tabClashProxies" Header="{x:Static resx:ResUI.TbProxies}" />
|
||||||
@@ -138,6 +149,7 @@
|
|||||||
<TabControl
|
<TabControl
|
||||||
x:Name="tabMain1"
|
x:Name="tabMain1"
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
|
Classes="logPane"
|
||||||
TabStripPlacement="Left">
|
TabStripPlacement="Left">
|
||||||
<TabItem x:Name="tabMsgView1" Header="{x:Static resx:ResUI.MsgInformationTitle}" />
|
<TabItem x:Name="tabMsgView1" Header="{x:Static resx:ResUI.MsgInformationTitle}" />
|
||||||
<TabItem x:Name="tabClashProxies1" Header="{x:Static resx:ResUI.TbProxies}" />
|
<TabItem x:Name="tabClashProxies1" Header="{x:Static resx:ResUI.TbProxies}" />
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
|
|||||||
this.BindCommand(ViewModel, vm => vm.AddWireguardServerCmd, v => v.menuAddWireguardServer).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.AddWireguardServerCmd, v => v.menuAddWireguardServer).DisposeWith(disposables);
|
||||||
this.BindCommand(ViewModel, vm => vm.AddAnytlsServerCmd, v => v.menuAddAnytlsServer).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.AddAnytlsServerCmd, v => v.menuAddAnytlsServer).DisposeWith(disposables);
|
||||||
this.BindCommand(ViewModel, vm => vm.AddNaiveServerCmd, v => v.menuAddNaiveServer).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.AddNaiveServerCmd, v => v.menuAddNaiveServer).DisposeWith(disposables);
|
||||||
|
this.BindCommand(ViewModel, vm => vm.AddMasqueServerCmd, v => v.menuAddMasqueServer).DisposeWith(disposables);
|
||||||
this.BindCommand(ViewModel, vm => vm.AddCustomServerCmd, v => v.menuAddCustomServer).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.AddCustomServerCmd, v => v.menuAddCustomServer).DisposeWith(disposables);
|
||||||
this.BindCommand(ViewModel, vm => vm.AddCustomOutboundServerCmd, v => v.menuAddCustomOutboundServer).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.AddCustomOutboundServerCmd, v => v.menuAddCustomOutboundServer).DisposeWith(disposables);
|
||||||
this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables);
|
||||||
@@ -418,7 +419,6 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
|
|||||||
this.OneWayBind(ViewModel, vm => vm.MsgViewModel, v => v.tabMsgView.Content).DisposeWith(currentLayoutDisposables);
|
this.OneWayBind(ViewModel, vm => vm.MsgViewModel, v => v.tabMsgView.Content).DisposeWith(currentLayoutDisposables);
|
||||||
this.OneWayBind(ViewModel, vm => vm.ClashProxiesViewModel, v => v.tabClashProxies.Content).DisposeWith(currentLayoutDisposables);
|
this.OneWayBind(ViewModel, vm => vm.ClashProxiesViewModel, v => v.tabClashProxies.Content).DisposeWith(currentLayoutDisposables);
|
||||||
this.OneWayBind(ViewModel, vm => vm.ClashConnectionsViewModel, v => v.tabClashConnections.Content).DisposeWith(currentLayoutDisposables);
|
this.OneWayBind(ViewModel, vm => vm.ClashConnectionsViewModel, v => v.tabClashConnections.Content).DisposeWith(currentLayoutDisposables);
|
||||||
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabMsgView.IsVisible).DisposeWith(currentLayoutDisposables);
|
|
||||||
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashProxies.IsVisible).DisposeWith(currentLayoutDisposables);
|
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashProxies.IsVisible).DisposeWith(currentLayoutDisposables);
|
||||||
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashConnections.IsVisible).DisposeWith(currentLayoutDisposables);
|
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashConnections.IsVisible).DisposeWith(currentLayoutDisposables);
|
||||||
this.Bind(ViewModel, vm => vm.TabMainSelectedIndex, v => v.tabMain.SelectedIndex).DisposeWith(currentLayoutDisposables);
|
this.Bind(ViewModel, vm => vm.TabMainSelectedIndex, v => v.tabMain.SelectedIndex).DisposeWith(currentLayoutDisposables);
|
||||||
@@ -429,7 +429,6 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
|
|||||||
this.OneWayBind(ViewModel, vm => vm.MsgViewModel, v => v.tabMsgView1.Content).DisposeWith(currentLayoutDisposables);
|
this.OneWayBind(ViewModel, vm => vm.MsgViewModel, v => v.tabMsgView1.Content).DisposeWith(currentLayoutDisposables);
|
||||||
this.OneWayBind(ViewModel, vm => vm.ClashProxiesViewModel, v => v.tabClashProxies1.Content).DisposeWith(currentLayoutDisposables);
|
this.OneWayBind(ViewModel, vm => vm.ClashProxiesViewModel, v => v.tabClashProxies1.Content).DisposeWith(currentLayoutDisposables);
|
||||||
this.OneWayBind(ViewModel, vm => vm.ClashConnectionsViewModel, v => v.tabClashConnections1.Content).DisposeWith(currentLayoutDisposables);
|
this.OneWayBind(ViewModel, vm => vm.ClashConnectionsViewModel, v => v.tabClashConnections1.Content).DisposeWith(currentLayoutDisposables);
|
||||||
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabMsgView1.IsVisible).DisposeWith(currentLayoutDisposables);
|
|
||||||
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashProxies1.IsVisible).DisposeWith(currentLayoutDisposables);
|
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashProxies1.IsVisible).DisposeWith(currentLayoutDisposables);
|
||||||
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashConnections1.IsVisible).DisposeWith(currentLayoutDisposables);
|
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashConnections1.IsVisible).DisposeWith(currentLayoutDisposables);
|
||||||
this.Bind(ViewModel, vm => vm.TabMainSelectedIndex, v => v.tabMain1.SelectedIndex).DisposeWith(currentLayoutDisposables);
|
this.Bind(ViewModel, vm => vm.TabMainSelectedIndex, v => v.tabMain1.SelectedIndex).DisposeWith(currentLayoutDisposables);
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
Width="200"
|
Width="200"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
VerticalContentAlignment="Center"
|
VerticalContentAlignment="Center"
|
||||||
PlaceholderText="{x:Static resx:ResUI.MsgFilterTitle}" />
|
PlaceholderText="{x:Static resx:ResUI.MsgFilterTitle}"
|
||||||
|
ToolTip.Tip="{x:Static resx:ResUI.MsgFilterTitle}" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCopy"
|
x:Name="btnCopy"
|
||||||
|
|||||||
@@ -47,9 +47,14 @@ public partial class MsgView : ReactiveUserControl<MsgViewModel>
|
|||||||
if (togScrollToEnd.IsChecked ?? true)
|
if (togScrollToEnd.IsChecked ?? true)
|
||||||
{
|
{
|
||||||
txtMsg.ScrollToEnd();
|
txtMsg.ScrollToEnd();
|
||||||
Dispatcher.UIThread.Invoke(() =>
|
// Let layout finish without a nested dispatcher loop during a native window drag.
|
||||||
txtMsg.ScrollTo(txtMsg.LineCount, 0, AvaloniaEdit.Rendering.VisualYPosition.TextBottom, txtMsg.Bounds.Height, 0),
|
Dispatcher.UIThread.Post(() =>
|
||||||
DispatcherPriority.Background);
|
{
|
||||||
|
if (togScrollToEnd.IsChecked ?? true)
|
||||||
|
{
|
||||||
|
txtMsg.ScrollTo(txtMsg.LineCount, 0, AvaloniaEdit.Rendering.VisualYPosition.TextBottom, txtMsg.Bounds.Height, 0);
|
||||||
|
}
|
||||||
|
}, DispatcherPriority.Background);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
@@ -1192,7 +1192,7 @@
|
|||||||
<Grid
|
<Grid
|
||||||
Margin="{StaticResource Margin4}"
|
Margin="{StaticResource Margin4}"
|
||||||
ColumnDefinitions="Auto,Auto"
|
ColumnDefinitions="Auto,Auto"
|
||||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
|
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
@@ -1296,6 +1296,19 @@
|
|||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Width="200"
|
Width="200"
|
||||||
Margin="{StaticResource Margin4}" />
|
Margin="{StaticResource Margin4}" />
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Grid.Row="9"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="{StaticResource Margin4}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="MASQUE" />
|
||||||
|
<ComboBox
|
||||||
|
x:Name="cmbCoreType14"
|
||||||
|
Grid.Row="9"
|
||||||
|
Grid.Column="1"
|
||||||
|
Width="200"
|
||||||
|
Margin="{StaticResource Margin4}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
</TabControl>
|
</TabControl>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public partial class OptionSettingWindow : WindowBase<OptionSettingViewModel>
|
|||||||
cmbCoreType6.ItemsSource = Global.CoreTypes;
|
cmbCoreType6.ItemsSource = Global.CoreTypes;
|
||||||
cmbCoreType7.ItemsSource = Global.CoreTypes;
|
cmbCoreType7.ItemsSource = Global.CoreTypes;
|
||||||
cmbCoreType9.ItemsSource = Global.CoreTypes;
|
cmbCoreType9.ItemsSource = Global.CoreTypes;
|
||||||
|
cmbCoreType14.ItemsSource = Global.CoreTypes;
|
||||||
|
|
||||||
cmbMixedConcurrencyCount.ItemsSource = Enumerable.Range(Global.SpeedTestConcurrencyCountMin, 20).ToList();
|
cmbMixedConcurrencyCount.ItemsSource = Enumerable.Range(Global.SpeedTestConcurrencyCountMin, 20).ToList();
|
||||||
cmbSpeedTestTimeout.ItemsSource = Enumerable.Range(2, 5).Select(i => i * 5).ToList();
|
cmbSpeedTestTimeout.ItemsSource = Enumerable.Range(2, 5).Select(i => i * 5).ToList();
|
||||||
@@ -145,6 +146,7 @@ public partial class OptionSettingWindow : WindowBase<OptionSettingViewModel>
|
|||||||
this.Bind(ViewModel, vm => vm.CoreType6, v => v.cmbCoreType6.SelectedValue).DisposeWith(disposables);
|
this.Bind(ViewModel, vm => vm.CoreType6, v => v.cmbCoreType6.SelectedValue).DisposeWith(disposables);
|
||||||
this.Bind(ViewModel, vm => vm.CoreType7, v => v.cmbCoreType7.SelectedValue).DisposeWith(disposables);
|
this.Bind(ViewModel, vm => vm.CoreType7, v => v.cmbCoreType7.SelectedValue).DisposeWith(disposables);
|
||||||
this.Bind(ViewModel, vm => vm.CoreType9, v => v.cmbCoreType9.SelectedValue).DisposeWith(disposables);
|
this.Bind(ViewModel, vm => vm.CoreType9, v => v.cmbCoreType9.SelectedValue).DisposeWith(disposables);
|
||||||
|
this.Bind(ViewModel, vm => vm.CoreType14, v => v.cmbCoreType14.SelectedValue).DisposeWith(disposables);
|
||||||
|
|
||||||
this.BindCommand(ViewModel, vm => vm.SaveCmd, v => v.btnSave).DisposeWith(disposables);
|
this.BindCommand(ViewModel, vm => vm.SaveCmd, v => v.btnSave).DisposeWith(disposables);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,12 +28,12 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
@@ -72,7 +72,8 @@
|
|||||||
Margin="{StaticResource MarginLr4}"
|
Margin="{StaticResource MarginLr4}"
|
||||||
VerticalContentAlignment="Center"
|
VerticalContentAlignment="Center"
|
||||||
Text="{Binding ServerFilter, Mode=TwoWay}"
|
Text="{Binding ServerFilter, Mode=TwoWay}"
|
||||||
PlaceholderText="{x:Static resx:ResUI.MsgServerTitle}" />
|
PlaceholderText="{x:Static resx:ResUI.MsgServerTitle}"
|
||||||
|
ToolTip.Tip="{x:Static resx:ResUI.MsgServerTitle}" />
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
|
|
||||||
<!-- Profiles grid -->
|
<!-- Profiles grid -->
|
||||||
|
|||||||
@@ -66,7 +66,8 @@
|
|||||||
Width="200"
|
Width="200"
|
||||||
Margin="{StaticResource MarginLr4}"
|
Margin="{StaticResource MarginLr4}"
|
||||||
VerticalContentAlignment="Center"
|
VerticalContentAlignment="Center"
|
||||||
PlaceholderText="{x:Static resx:ResUI.MsgServerTitle}" />
|
PlaceholderText="{x:Static resx:ResUI.MsgServerTitle}"
|
||||||
|
ToolTip.Tip="{x:Static resx:ResUI.MsgServerTitle}" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnAutofitColumnWidth"
|
x:Name="btnAutofitColumnWidth"
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ public partial class ProfilesView : ReactiveUserControl<ProfilesViewModel>
|
|||||||
|
|
||||||
private static readonly DataFormat<ProfileItemModel> LstProfilesRowFormat =
|
private static readonly DataFormat<ProfileItemModel> LstProfilesRowFormat =
|
||||||
DataFormat.CreateInProcessFormat<ProfileItemModel>("LstProfilesRow");
|
DataFormat.CreateInProcessFormat<ProfileItemModel>("LstProfilesRow");
|
||||||
|
|
||||||
private (Point, PointerPressedEventArgs)? _dragStartPoint;
|
private (Point, PointerPressedEventArgs)? _dragStartPoint;
|
||||||
|
|
||||||
private void LstProfiles_PointerPressed(object? sender, PointerPressedEventArgs e)
|
private void LstProfiles_PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
|||||||
@@ -208,12 +208,12 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
|
|||||||
@@ -30,12 +30,12 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
|
|||||||
@@ -22,12 +22,12 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
@@ -136,7 +136,8 @@
|
|||||||
Margin="{StaticResource Margin4}"
|
Margin="{StaticResource Margin4}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Text="{x:Static resx:ResUI.LvAutoUpdateInterval}" />
|
Text="{x:Static resx:ResUI.LvAutoUpdateInterval}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
@@ -230,7 +231,8 @@
|
|||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="{StaticResource Margin4}"
|
Margin="{StaticResource Margin4}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
PlaceholderText="{x:Static resx:ResUI.LvPrevProfileTip}" />
|
PlaceholderText="{x:Static resx:ResUI.LvPrevProfileTip}"
|
||||||
|
ToolTip.Tip="{x:Static resx:ResUI.LvPrevProfileTip}" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSelectPrevProfile"
|
x:Name="btnSelectPrevProfile"
|
||||||
Grid.Row="10"
|
Grid.Row="10"
|
||||||
@@ -250,7 +252,8 @@
|
|||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="{StaticResource Margin4}"
|
Margin="{StaticResource Margin4}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
PlaceholderText="{x:Static resx:ResUI.LvPrevProfileTip}" />
|
PlaceholderText="{x:Static resx:ResUI.LvPrevProfileTip}"
|
||||||
|
ToolTip.Tip="{x:Static resx:ResUI.LvPrevProfileTip}" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSelectNextProfile"
|
x:Name="btnSelectNextProfile"
|
||||||
Grid.Row="11"
|
Grid.Row="11"
|
||||||
|
|||||||
@@ -20,12 +20,12 @@
|
|||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnSave"
|
x:Name="btnSave"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Content="{x:Static resx:ResUI.TbConfirm}"
|
Content="{x:Static resx:ResUI.TbConfirm}"
|
||||||
IsDefault="True" />
|
IsDefault="True" />
|
||||||
<Button
|
<Button
|
||||||
x:Name="btnCancel"
|
x:Name="btnCancel"
|
||||||
Width="100"
|
MinWidth="100"
|
||||||
Margin="{StaticResource MarginLr8}"
|
Margin="{StaticResource MarginLr8}"
|
||||||
Content="{x:Static resx:ResUI.TbCancel}"
|
Content="{x:Static resx:ResUI.TbCancel}"
|
||||||
IsCancel="True" />
|
IsCancel="True" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user